《MMO 服务器开发》5.3 丢包与弱网模拟实测

本节用一个固定种子的确定性弱网模拟器,把 5.2 的 KCP 放在 0%/1%/5%/20% 四档丢包下实测:记录每条消息的端到端延迟 P50/P99 与冗余发送率,并与 5.1 的单流 TCP 式基线正面对比。同一丢包率下,KCP 靠快速重传把 P99 明显压低,代价是 20% 丢包时约 1.5 倍的冗余发包。

5.3 丢包与弱网模拟实测

5.2 把 KCP 写出来了,但「写出来」不等于「有用」。这一节回答一个具体问题:在真实的丢包链路上,它到底把延迟压下去了多少,代价又是什么。答案必须来自可复现的测量,而不是感觉。

做法是搭一个确定性的弱网模拟器:所有随机性——丢哪个包、抖动多大——都由一个固定种子的 PRNG 决定。同一个种子在任何机器上都会跑出同一组数字,因此下面的表格不是「某次运行的偶然结果」,而是可以反复验证的。

确定性弱网模拟器

模拟器把两个 KCP 端点用一条虚拟链路连起来,链路每毫秒推进一次时钟。发包口先记账(这次发包算不算数据包、要不要丢弃),再按 loss 概率决定是否真的投递;投递时给一个 rtt/2 + jitter 的延迟,放进按到达时刻排序的队列。接收端在时钟推进时把到点的包喂给 Input,再循环 Recv 直到取不到完整消息,记下每条消息的端到端延迟。

// file: internal/kcp/sim_test.go
// tier: B
// verify: go test -run TestLossyBenchmark ./internal/kcp/
package kcp

import (
	"math/rand"
	"testing"
)

// simLink 是 5.3 用的确定性弱网链路:固定种子同时决定丢包与抖动,
// 因此同一个种子在任何机器上都会跑出同一组数字。
type simLink struct {
	now      uint32
	rng      *rand.Rand
	loss     float64
	rtt      uint32
	jitter   uint32
	queue    []simPkt
	allPkts  int
	dataPkts int
}

type simPkt struct {
	at   uint32
	data []byte
	dst  *KCP
}

// send 是底层发包口:先计数(含将要丢弃的),再按丢包率决定是否真的投递。
func (l *simLink) send(buf []byte, dst *KCP) {
	l.allPkts++
	if len(buf) >= headerSize && buf[4] == cmdPush {
		l.dataPkts++
	}
	if l.loss > 0 && l.rng.Float64() < l.loss {
		return
	}
	lat := l.rtt / 2
	if l.jitter > 0 {
		lat += uint32(l.rng.Intn(int(l.jitter) + 1))
	}
	l.queue = append(l.queue, simPkt{at: l.now + lat, data: append([]byte(nil), buf...), dst: dst})
}

func (l *simLink) deliver(t uint32) {
	for i := 0; i < len(l.queue); {
		if l.queue[i].at <= t {
			p := l.queue[i]
			l.queue = append(l.queue[:i], l.queue[i+1:]...)
			p.dst.Input(p.data)
		} else {
			i++
		}
	}
}

// runKCPSim 在弱网下把 n 条消息按 interval 毫秒的节拍从 A 发到 B,
// 返回每条消息的端到端延迟(毫秒)与累计数据包发送数。
func runKCPSim(seed int64, loss float64, rtt, jitter uint32, n, interval int) ([]float64, int) {
	l := &simLink{rng: rand.New(rand.NewSource(seed)), loss: loss, rtt: rtt, jitter: jitter}
	var a, b *KCP
	a = NewKCP(1, func(buf []byte) { l.send(buf, b) })
	b = NewKCP(1, func(buf []byte) { l.send(buf, a) })
	a.SetWindow(64, 128)
	b.SetWindow(64, 128)

	payload := []byte("dawn-realm: 玩家移动状态包 0123456789")
	delays := make([]float64, 0, n)
	sendAt := make([]float64, n)
	sent, got := 0, 0
	var buf [256]byte
	total := uint32(n*interval + 5000)
	for t := uint32(0); t <= total; t++ {
		if sent < n && int(t) == sent*interval {
			a.Send(payload)
			sendAt[sent] = float64(t)
			sent++
		}
		l.now = t
		a.Update(t)
		b.Update(t)
		l.deliver(t)
		for got < n {
			r := b.Recv(buf[:])
			if r < 0 {
				break
			}
			delays = append(delays, float64(t)-sendAt[got])
			got++
		}
	}
	return delays, l.dataPkts
}

// TestLossyBenchmark 在固定种子的弱网下测 KCP 的消息延迟与冗余发送率,
// 并与 5.1 的单流 TCP 式基线对比。B 层,产出数字,软告警。
func TestLossyBenchmark(t *testing.T) {
	const (
		n        = 300
		interval = 33 // 约 30Hz
		rtt      = 60
		jitter   = 10
		seed     = 20261006
	)
	cfg := SimConfig{Seed: seed, RTTMs: rtt, JitterMs: jitter, RTOFactor: 2, Count: n, IntervalMs: interval}
	for _, loss := range []float64{0, 0.01, 0.05, 0.20} {
		delays, dataPkts := runKCPSim(seed, loss, rtt, jitter, n, interval)
		ks := Summarize(delays)
		cfg.LossRate = loss
		ts := Summarize(Simulate(cfg, 1))
		redundancy := float64(dataPkts) / float64(n)
		t.Logf("loss=%4.0f%% | KCP P50=%6.1f P99=%7.1f max=%8.1f | TCP式 P50=%6.1f P99=%7.1f | 到达 %d/%d 冗余=%.2fx",
			loss*100, ks.P50, ks.P99, ks.Max, ts.P50, ts.P99, len(delays), n, redundancy)
	}
}

这份文件里的 SimConfig、Simulate、Summarize 来自 5.1 的 internal/kcp/hol.go——同一个包,所以这里直接调用。Simulate(cfg, 1) 就是「单流 TCP 式」基线:一条必须按序交付的流,任何丢失都要等重传补上,后面的消息只能干等,这正是 5.1 用数字刻画过的队头阻塞。基线的核心只有几行:

// snippet: internal/kcp/hol.go 的基线模型
type SimConfig struct {
	Seed       int64   // 固定随机种子
	LossRate   float64 // 丢包率,0~1
	RTTMs      float64 // 往返时延(毫秒)
	JitterMs   float64 // 单向抖动上限(毫秒)
	RTOFactor  float64 // 重传超时 = RTOFactor × RTT
	Count      int     // 消息条数
	IntervalMs float64 // 相邻消息的发送间隔(毫秒)
}

// Simulate 用同一条消息流跑一次虚拟信道,返回每条消息的端到端延迟。
// streams == 1 时所有消息共享一条有序流,即 TCP 语义。
func Simulate(cfg SimConfig, streams int) []float64 {
	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
}

注意 arrival 与 KCP 走的是同一个种子:同一档丢包下,两条路径面对的是同一串「哪几个包丢了」的随机序列,因此对比是公平的。区别只在恢复方式——基线只能等 RTOFactor × RTT 到期,KCP 会走自己的快速重传。

四档丢包下的实测数字

跑法是 300 条约 40 字节的消息、30Hz 节拍(约 10 秒)、单向 RTT 60ms、抖动不超过 10ms,种子固定为 20261006:

$ go test -run TestLossyBenchmark -v ./internal/kcp/
loss=   0% | KCP P50=  40.0 P99=   49.0 max=    49.0 | TCP式 P50=  34.7 P99=   39.9 | 到达 300/300 冗余=1.00x
loss=   1% | KCP P50=  40.0 P99=  141.0 max=   187.0 | TCP式 P50=  35.6 P99=  154.0 | 到达 300/300 冗余=1.05x
loss=   5% | KCP P50=  40.0 P99=  155.0 max=   188.0 | TCP式 P50=  36.8 P99=  239.6 | 到达 300/300 冗余=1.14x
loss=  20% | KCP P50=  55.0 P99=  357.0 max=   456.0 | TCP式 P50=  86.8 P99=  359.6 | 到达 300/300 冗余=1.49x

整理成表:

丢包率KCP P50KCP P99KCP maxTCP 式 P50TCP 式 P99到达冗余
0%40.049.049.034.739.9300/3001.00×
1%40.0141.0187.035.6154.0300/3001.05×
5%40.0155.0188.036.8239.6300/3001.14×
20%55.0357.0456.086.8359.6300/3001.49×

「冗余」= 实际发出的数据包数 ÷ 消息数,衡量为了可靠送达多付了多少带宽。四条结果都到达 300/300,说明可靠性没有被丢包破坏,差别全在延迟与代价上。

怎么读这四行

0% 丢包:KCP 反而略慢(40.0 vs 34.7)。 没有丢包时没有重传,冗余都是 1.00×。KCP 的 P50 高约 5ms,来自它的固定开销:Update 每 10ms 才 flush 一次,ACK 也要等下一次 flush 才发出。这是主动换来「可控」付出的税——在干净链路上,这个税换不到收益。

1% 丢包:P99 开始分叉(141 vs 154)。 低丢包下偶尔一次丢包,KCP 靠快速重传补回来,TCP 式则要等 RTO 到期。差距还不大,因为丢得少。

5% 丢包:差距最明显(P99 155 vs 239.6)。 这是最接近真实公网的一档。TCP 式每丢一个包,队头后面所有消息都要等重传补齐;KCP 收到两个后续 ACK 就判定丢包、立刻重传,被卡住的时间短得多。冗余 1.14×——多付 14% 的包,换回约三分之一的尾部延迟,这笔账在对战里很划算。

20% 丢包:P50 明显受益(55 vs 86.8),P99 趋于接近。 极端丢包下两者都被重传退避支配:KCP 的中位数因为「不必等队头」而明显更低,但尾部延迟两者都到 350ms 量级。冗余升到 1.49×,接近每两条消息就要多补一个包——这就是 5.2 里那句「丢掉拥塞控制」的代价,没有 cwnd 兜底,KCP 会一直重传到送达为止。

一句话总结:丢包越少,KCP 越像 TCP;丢包越多,KCP 越依赖快速重传,尾部延迟优势越突出,但冗余发包也越多。 真实业务里通常会再加一层应用语义——只对「必须可靠」的移动、技能做重传,对可以丢的位置同步改用不可靠通道,这正是 KCP 把可靠性做成可选的价值。

以上都是同一台机器、同一份代码的相对量级,绝对数值不可移植:换一台机器、换一个种子、换一条 RTT,具体毫秒数都会变,但「快速重传压低 P99、代价是冗余」这个结论不会变。要复现,只需把种子固定、go test -run TestLossyBenchmark -v ./internal/kcp/ 重跑一遍。

参数为什么这么定

表里的数字只有在参数合理时才有意义,逐项交代:

  • 300 条 × 30Hz(interval=33):约 10 秒的消息流。300 条足够让 P99 落在「第 3 慢」的样本上,不至于被单条异常值带偏;30Hz 是位置同步最常见的频率。
  • RTT 60ms:同城跨机房、同运营商内的量级,也是实时对战能接受的上限附近。
  • 抖动 ≤10ms:单向抖动取 [0, 10] 毫秒均匀分布,让到达顺序不再严格等于发送顺序。
  • 窗口 64/128:30Hz 下在途消息远少于 64,窗口不会成为瓶颈,这样测出来的差异才归因于丢包恢复策略,而不是被发送窗口卡住。
  • 种子 20261006 固定:模拟器用的是虚拟时钟,不依赖真实时间,所以同一份代码在任何机器上都会输出上面那张表——这不是「某次运行的偶然」,而是可以写进断言里的确定值。

把它接进回归

因为结果是确定的,这个基准可以直接当回归护栏用:把「5% 丢包下 P99 不超过某个阈值」写成断言,后续任何改动——比如把 defaultInterval 从 10ms 调大、把 fastresend 关掉——只要劣化了尾部延迟,测试就会红。这比「人工看一遍数字」可靠得多。

但要清楚它的边界:模拟器不建模真实网络里的突发丢包、乱序、ACK 压缩与中间设备限速,也不建模 KCP 之外的排队与拥塞。所以它是一个「行为护栏」,用来挡住实现层的回归,而不是一份性能证明。真正的上线参数,仍然要在真实网络里再测一遍。

小结

5.1 用模型证明了队头阻塞的存在,5.2 把替代方案实现出来,这一节用固定种子的实测把两者摆在一起:在 5% 丢包这条最接近现实的链路上,KCP 把消息延迟 P99 从约 240ms 压到约 155ms,代价是多发约 14% 的数据包。这个权衡——用少量冗余换尾部延迟——就是可靠 UDP 在实时对战里的全部意义。

下一章换一个方向:当浏览器端也要接入时,KCP 这种自定义 UDP 协议不再适用,需要退回到 WebSocket 这条「跑在 TCP 上的可靠通道」。我们会看它的握手、心跳与断线重连怎么和游戏协议栈拼在一起。

阅读导航:上一节:5.2 KCP 从零实现 · 下一节:6.1 WebSocket 握手与接入 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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