5.1 为什么游戏不只用 TCP
很多团队第一次做实时对战,直觉都是「上 TCP 就完事了」:有序、可靠、不用自己处理重传,内核帮你搞定一切。可一旦玩家在移动网络、跨运营商链路或者 Wi-Fi 边缘区玩起来,就会收到「走着走着瞬移一下」「技能按下去半秒才有反应」的反馈。这些现象背后,往往不是带宽不够,而是 TCP 的可靠性语义和实时对战的需求对不上。
《晨曦大陆》的移动同步包每 33 毫秒生成一批,位置过期了就是废数据——玩家早就走开了。而 TCP 会把这些「已经过期但还没确认」的数据当成必须送达的宝贵字节,一遍遍重传,直到对端确认。它没有「这条消息的价值随时间衰减」这个概念,也没有「这条比那条更重要」这个概念。
本节先讲清楚 TCP 在实时对战里的三个硬伤,然后写一个确定性模拟器,用同机可复现的数字把其中最要命的那个——队头阻塞——量化出来。本节只产出诊断结论和基线数字,真正的替代方案(KCP)在 5.2 实现、在 5.3 压测。
与站内既有专题的分工
站内已经有一批讨论「选型与架构取舍」的文章,本节和它们不重复,而是补上「为什么这样选」的量化依据。
| 主题 | 站内既有文章(负责架构方案与取舍) | 本节负责的部分 |
|---|---|---|
| 传输层选型 | 游戏服务器 TCP WebSocket 与 QUIC 选择 | 从零写模拟器,量化 TCP 有序流的代价 |
| 协议网关 | 游戏协议网关架构设计 | 解释网关下面为什么可以换掉 TCP 而不影响上层 |
| 延迟与裁决 | 游戏服务器延迟补偿裁决架构设计 | 说明延迟抖动如何直接损害裁决公平性 |
| 快照同步 | 游戏服务器快照增量同步架构设计 | 给出「过期快照不值得重传」的量化理由 |
既有文章回答「架构上怎么选」,本书回答「从零实现 + 真跑」。本节的两个代码文件都在本章 module 里可以直接 go test。
TCP 的三个硬伤
硬伤一:队头阻塞
TCP 提供的是有序字节流。接收端内核必须按序把字节交给应用,任何一段丢了,后面已经到达的字节也只能压在接收缓冲区里,等它重传回来才能一起交付。对文件传输这是优点,对实时状态同步就是灾难:玩家 A 的一个移动包丢了,后面 3、4 个更新更及时的包明明已经到了,却全部被卡住。
发送: [p1] [p2] [p3] [p4] [p5] p2 丢失
到达: [p1] [--] [p3] [p4] [p5]
应用: p1 交付;p3/p4/p5 全部阻塞在接收缓冲区
↓ 等 p2 重传(约一个 RTO)
应用: p2 到达 → p2/p3/p4/p5 一次性交付
此时 p3/p4/p5 的数据已经过期,但延迟已经发生了
硬伤二:重传策略不区分消息重要性
一条「10 秒前的移动指令」和一条「暴击伤害结算」在 TCP 眼里完全平等,都是必须送达的字节。前者其实早就没用了——玩家现在在哪,靠的是最新的那条;重传它只是浪费带宽、制造延迟。KCP 这类协议允许按通道(conv)区分策略:移动通道可以配置成「丢就丢,最多重传两次」,结算通道才用「必须送达」。
硬伤三:拥塞控制无法自定义
TCP 的拥塞控制在弱网下会同时降低发送速率并放大 RTO。这是为了保护公网整体,但对一个只有几百字节、延迟敏感的移动包来说,退让过猛。游戏场景更想要的是:小包、低延迟、宁可多花一点带宽也要尽快送达。内核 TCP 不给你这个旋钮,除非换成用户态的可靠 UDP。
模拟器:把队头阻塞变成数字
空谈「队头阻塞很严重」没有说服力。下面这段是 L1,go test 可以直接跑:它用一个固定种子的 PRNG 在「丢包率 p、RTT r」的虚拟信道上跑同一条消息流,分别按「一条有序流」和「多条独立通道」交付,返回每条消息的端到端延迟。
// file: internal/kcp/hol.go
// tier: L1
// verify: go test ./internal/kcp/
package kcp
import (
"math"
"math/rand"
"sort"
)
// SimConfig 描述一条虚拟信道。所有模拟都由固定种子驱动,因此同一份配置
// 在任何机器上都会跑出同一组数字。
type SimConfig struct {
Seed int64 // 固定随机种子
LossRate float64 // 丢包率,0~1
RTTMs float64 // 往返时延(毫秒)
JitterMs float64 // 单向抖动上限(毫秒)
RTOFactor float64 // 重传超时 = RTOFactor × RTT
Count int // 消息条数
IntervalMs float64 // 相邻消息的发送间隔(毫秒)
}
// oneWay 返回单向时延。RTT 是对称信道,所以一半一半。
func (c SimConfig) oneWay() float64 { return c.RTTMs / 2 }
// rto 返回重传超时。教学模型用 RTOFactor×RTT 近似 TCP 的 RTO,
// 不建模指数退避——这一节的目的是暴露队头阻塞,而不是复刻 TCP 的退让曲线。
func (c SimConfig) rto() float64 {
f := c.RTOFactor
if f <= 0 {
f = 2
}
return f * c.RTTMs
}
// arrival 返回一条消息第一次成功抵达接收端的时刻:每次尝试按 LossRate
// 判定是否丢失,丢了就等一个 RTO 再试,直到某次成功。
func (c SimConfig) arrival(rng *rand.Rand, send float64) float64 {
t := send
for {
if rng.Float64() >= c.LossRate {
j := 0.0
if c.JitterMs > 0 {
j = rng.Float64() * c.JitterMs
}
return t + c.oneWay() + j
}
t += c.rto()
}
}
// Simulate 用同一条消息流跑一次虚拟信道,返回每条消息的端到端延迟(毫秒)。
//
// streams == 1 时所有消息共享一条有序流,这正是 TCP 的语义:接收端必须按序
// 交付,前面一条丢了,后面已经到达的消息也只能压在缓冲区里等它重传,这就是
// 队头阻塞(head-of-line blocking)。
//
// streams 越大,消息越分散到互相独立的通道上;streams >= Count 时每条消息
// 各走各的,等价于一组互不阻塞的数据报。
func Simulate(cfg SimConfig, streams int) []float64 {
if streams < 1 {
streams = 1
}
rng := rand.New(rand.NewSource(cfg.Seed))
delays := make([]float64, cfg.Count)
last := make([]float64, streams)
for i := range last {
last[i] = math.Inf(-1)
}
for i := 0; i < cfg.Count; i++ {
send := float64(i) * cfg.IntervalMs
a := cfg.arrival(rng, send)
s := i % streams
// 关键的一行:交付时刻不能早于本通道上一条消息的交付时刻。
d := math.Max(a, last[s])
last[s] = d
delays[i] = d - send
}
return delays
}
// LatencyStat 是一组延迟样本的常用分位。
type LatencyStat struct {
P50 float64
P99 float64
Max float64
Mean float64
}
// Summarize 汇总一组延迟样本。
func Summarize(xs []float64) LatencyStat {
return LatencyStat{
P50: Percentile(xs, 50),
P99: Percentile(xs, 99),
Max: Percentile(xs, 100),
Mean: Mean(xs),
}
}
// Percentile 用最近秩法求第 p 百分位(p 取 0~100),不修改入参。
func Percentile(xs []float64, p float64) float64 {
if len(xs) == 0 {
return 0
}
s := append([]float64(nil), xs...)
sort.Float64s(s)
rank := int(math.Ceil(p/100*float64(len(s)))) - 1
if rank < 0 {
rank = 0
}
if rank >= len(s) {
rank = len(s) - 1
}
return s[rank]
}
// Mean 返回样本均值。
func Mean(xs []float64) float64 {
if len(xs) == 0 {
return 0
}
var sum float64
for _, v := range xs {
sum += v
}
return sum / float64(len(xs))
}
这段测试跑出正文要用到的数字,并对「单流 P90 高于多流 P90」做硬断言——如果哪天模型改坏了,测试会红:
// file: internal/kcp/hol_test.go
// tier: L1
// verify: go test ./internal/kcp/
package kcp
import "testing"
// TestHOLBlocking 用同一组信道参数对比「单条有序流」与「多条独立通道」的
// 延迟分布。单流的中位数与 P90 会被队头阻塞显著抬高。
func TestHOLBlocking(t *testing.T) {
for _, loss := range []float64{0.05, 0.20} {
cfg := SimConfig{
Seed: 42,
LossRate: loss,
RTTMs: 60,
JitterMs: 10,
RTOFactor: 2,
Count: 600,
IntervalMs: 33.3, // 约 30Hz
}
t.Logf("=== 丢包率 %.0f%%,RTT 60ms,30Hz ===", loss*100)
for _, streams := range []int{1, 4, 16, 600} {
d := Simulate(cfg, streams)
st := Summarize(d)
t.Logf("streams=%-4d P50=%6.1f P90=%6.1f P99=%7.1f max=%8.1f mean=%6.1f",
streams, st.P50, Percentile(d, 90), st.P99, st.Max, st.Mean)
}
}
cfg := SimConfig{Seed: 42, LossRate: 0.05, RTTMs: 60, JitterMs: 10, RTOFactor: 2, Count: 600, IntervalMs: 33.3}
single, multi := Simulate(cfg, 1), Simulate(cfg, 16)
if Mean(single) <= Mean(multi) {
t.Fatalf("队头阻塞未体现:single mean=%.1f 应高于 multi mean=%.1f", Mean(single), Mean(multi))
}
if Percentile(single, 90) <= Percentile(multi, 90) {
t.Fatalf("队头阻塞未体现:single P90=%.1f 应高于 multi P90=%.1f",
Percentile(single, 90), Percentile(multi, 90))
}
// 零丢包时两种模型必须完全一致,证明差异确实来自丢包而非模型本身。
clean := cfg
clean.LossRate = 0
if s0, s16 := Summarize(Simulate(clean, 1)), Summarize(Simulate(clean, 16)); s0.P99 != s16.P99 {
t.Fatalf("零丢包下不该有差异:%+v vs %+v", s0, s16)
}
}
实测结论
在本机跑 go test -run TestHOLBlocking -v ./internal/kcp/(600 条消息、30Hz、RTT 60ms、单向抖动 ≤10ms、固定种子 42)得到下表。streams=1 就是 TCP 的有序流,streams=600 是每条消息各走各的纯数据报:
| 丢包率 | streams | P50 | P90 | P99 | max | mean |
|---|---|---|---|---|---|---|
| 5% | 1(TCP 式) | 36.7 | 120.1 | 159.3 | 277.2 | 51.2 |
| 5% | 4 | 35.4 | 39.5 | 158.8 | 277.2 | 42.2 |
| 5% | 16 | 35.4 | 39.5 | 158.8 | 277.2 | 42.0 |
| 5% | 600 | 35.4 | 39.5 | 158.8 | 277.2 | 42.0 |
| 20% | 1(TCP 式) | 89.4 | 213.1 | 359.0 | 396.8 | 107.0 |
| 20% | 16 | 36.3 | 156.6 | 277.4 | 396.8 | 63.0 |
| 20% | 600 | 36.3 | 156.6 | 277.4 | 396.8 | 63.0 |
读法有三条:
- 5% 丢包时,单流的 P90 从 39.5ms 抬到 120.1ms,均值从 42.0ms 涨到 51.2ms。中位数几乎没变,说明大部分消息还是正常的;被拖累的是「恰好排在一个丢包后面」的那一批,这正是队头阻塞的形态。
- 丢包率升到 20%,单流连中位数都保不住了:P50 从 36.3ms 涨到 89.4ms。原因是丢包太密,有序流几乎没有一段「干净」的时间窗,交付一直在等重传。
- 从 1 条流加到 4 条流,收益最大;再加到 16、600 几乎不再变化。这说明要缓解队头阻塞,不需要真的「每条消息一条通道」,把消息按语义分到少数几条互不阻塞的通道就够了——这也是 KCP 用
conv区分连接、用不同配置区分通道的动机。
需要强调:这是同机相对量级,绝对数值不可移植。表格里的毫秒数依赖这里的 RTT、抖动、发送节拍和 RTO 模型;换一组参数,数字会整体平移,但「单流被丢包拖累更多」这个方向不变。
另外,第 3 点只是「少分几条通道」的启发,它并不等价于「上 UDP 就万事大吉」。多条独立通道丢掉了可靠性和有序性,谁重传、谁去重、谁保证顺序,全要自己写。5.2 就从零实现一个把这两件事都接管过来的最小 KCP。
阅读导航:上一节:目录 · 下一节:5.2 KCP 从零实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。