《MMO 服务器开发》5.1 为什么游戏不只用 TCP

TCP 在实时对战里有三个硬伤:队头阻塞让一个丢包卡住后面所有消息,重传策略不区分消息重要性,拥塞控制在弱网下退让过猛。本节写一个确定性模拟器,在同一丢包率与 RTT 下对比单条有序流与多条独立通道的延迟分布,把队头阻塞的代价变成可以复现的数字。

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 是每条消息各走各的纯数据报:

丢包率streamsP50P90P99maxmean
5%1(TCP 式)36.7120.1159.3277.251.2
5%435.439.5158.8277.242.2
5%1635.439.5158.8277.242.0
5%60035.439.5158.8277.242.0
20%1(TCP 式)89.4213.1359.0396.8107.0
20%1636.3156.6277.4396.863.0
20%60036.3156.6277.4396.863.0

读法有三条:

  1. 5% 丢包时,单流的 P90 从 39.5ms 抬到 120.1ms,均值从 42.0ms 涨到 51.2ms。中位数几乎没变,说明大部分消息还是正常的;被拖累的是「恰好排在一个丢包后面」的那一批,这正是队头阻塞的形态。
  2. 丢包率升到 20%,单流连中位数都保不住了:P50 从 36.3ms 涨到 89.4ms。原因是丢包太密,有序流几乎没有一段「干净」的时间窗,交付一直在等重传。
  3. 从 1 条流加到 4 条流,收益最大;再加到 16、600 几乎不再变化。这说明要缓解队头阻塞,不需要真的「每条消息一条通道」,把消息按语义分到少数几条互不阻塞的通道就够了——这也是 KCP 用 conv 区分连接、用不同配置区分通道的动机。

需要强调:这是同机相对量级,绝对数值不可移植。表格里的毫秒数依赖这里的 RTT、抖动、发送节拍和 RTO 模型;换一组参数,数字会整体平移,但「单流被丢包拖累更多」这个方向不变。

另外,第 3 点只是「少分几条通道」的启发,它并不等价于「上 UDP 就万事大吉」。多条独立通道丢掉了可靠性和有序性,谁重传、谁去重、谁保证顺序,全要自己写。5.2 就从零实现一个把这两件事都接管过来的最小 KCP。

阅读导航:上一节:目录 · 下一节:5.2 KCP 从零实现 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标