《MMO 服务器开发》14.3 高延迟玩家的公平性

延迟补偿不能无限给:给得越多,被击中的玩家越觉得「明明躲开了」。本节给补偿套上上限——回退窗口取「绝对上限」与「最大补偿 RTT 的一半」的较小值,并用一个 RTT 追踪器识别「平时很快、开枪瞬间掉包」的延迟作弊,最后讨论被高延迟玩家击杀的体感如何用反馈层缓解。

14.3 高延迟玩家的公平性

14.2 把延迟补偿的判定写对了:服务端回退到射手开枪那一刻做命中判定,兑现了「我打中了」。但那一节留了一个没回答的问题——回退多少才算够? 如果无条件满足每个射手,一个 400ms RTT 的玩家每开一枪都要把世界拖回 200ms 前,被击中的玩家会觉得「我明明躲进掩体了」。补偿给得越多,这种反向不公就越严重。

这一节处理补偿的闸门。要解决三件事:给回退窗口设一个上限(不是由历史环容量顺带决定的,而是显式策略);识别一种专门钻空子的行为——延迟作弊(lag switch),即平时网络很快、一到对枪就故意让连接掉包;以及,对那些确实被高延迟玩家击杀的受害者,怎么用反馈层让他们觉得这一枪来得合理。

先给结论:公平性不是「让所有人体验一样」,而是让每个玩家遭遇的不确定都在可接受范围内。上限负责压制高延迟玩家的优势,滥用防护负责堵住「故意制造延迟」的作弊,反馈层负责安抚「感觉被冤枉」的一方。三者缺一不可。

还有一点要说清楚:本节讨论的「公平」不是绝对意义上的公平。网络延迟客观存在,任何方案都只能在不公平之间做选择——要么偏袒低延迟玩家,要么偏袒高延迟玩家。本节的目标是让这个选择显式、可控、可观测,而不是让它藏在「服务端按当前状态判定」这种看似中立的实现里。

补偿上限:回退窗口的闸门

14.2 里 NewHistory(64) 的容量看似已经给了上限,但那是个副产品——它取决于帧率和内存预算,而不是公平性判断。真正的上限应该是显式的、可调的,并且和玩家的 RTT 挂钩:RTT 越大,允许的回退越多,但增长要在某处停住。

aegis 的策略取两个约束里更紧的那个:

   允许回退 = min( 绝对上限 , 最大补偿RTT / 2 )

   ↑ 绝对上限:无论玩家多卡,回退都不会超过它(例如 150ms)
   ↑ 最大补偿RTT/2:RTT 超过「最大补偿 RTT」后,补偿不再增长

为什么是「最大补偿 RTT 的一半」?因为回退量本身就等于 RTT/2 + 插值。如果上限取 RTT/2,那么当一个玩家的 RTT 超过 2 × 绝对上限 时,他实际能拿到的补偿就被绝对上限卡住了——补偿曲线在 RTT 越过某个点后变平。这正是我们要的:对绝大多数正常玩家(RTT < 200ms)补偿是完整满足的,只有极端高延迟才被截断。

   允许回退
    150ms ┤───────────────┐   ← 绝对上限,曲线在此拉平
          │               └────────────────
          │          ╱
     75ms ┤       ╱
          │    ╱
     25ms ┤ ╱
          ┼─────────────────────────────→ RTT
             50    100    200    400
          ↑ 曲线斜率 1/2:回退 ≈ RTT/2 + 插值
玩家 RTT需要回退(含 100ms 插值)上限 150ms 下实得是否被截断
40ms120ms120ms否
100ms150ms150ms否
200ms200ms150ms是(截 50ms)
400ms300ms150ms是(截 150ms)

被截断意味着高延迟玩家会偶尔遇到「我打中了却没伤害」——这是故意的。让高延迟玩家偶尔吃亏,好过让低延迟玩家频繁被「穿越掩体击杀」。这条取舍没有中间路线,只能选边,选完再用反馈层把体感补回来。

参数怎么定

两个参数(绝对上限、最大补偿 RTT)不是孤立的,它们共同决定补偿曲线的形状。定参数时按三步走:

  • 先定「必须完整补偿」的 RTT 区间:通常是同区域玩家的 P95 RTT。同区 P95 约 100ms,那么 最大补偿RTT/2 至少要覆盖 100/2 + 插值 = 150ms。
  • 再定绝对上限:它是「即便玩家再卡也不越过的线」。取上一步算出的值(150ms)即可——再大,受害者体感明显变差;再小,正常玩家被误伤。
  • 最后用监控校准:统计「因截断而落空的枪」占全部射击的比例。它应当低于 1%;明显偏高说明上限设小了,低到几乎为零说明还能再收紧。

这套参数按游戏类型可调:战术射击(CS 类)对「穿越掩体」零容忍,上限压得很低、甚至不做回退补偿;大世界 MMO 的近战技能命中,玩家宽容度高得多,上限可以放宽。同一个引擎,参数不同,手感就不同。

调参的验证闭环是:上线 → 采集「截断率」和「因补偿而判中的枪占比」两个指标 → 定期回调。补偿参数不该是「上线前拍一次、之后永不动」的常量。

滥用防护:识别延迟作弊

上限本身只压制了「天然的」高延迟,挡不住故意制造的高延迟。延迟作弊的套路是:玩家正常游玩时网络很好(低 RTT),但在对枪的关键帧上主动断开上行几百毫秒——等到自己先开枪、对方先死,再恢复连接。从他的视角,他的延迟是真的高,服务端也「合法地」给了他完整补偿。

识别它的关键不在「当前 RTT 高不高」,而在RTT 的形态:正常高延迟是持续的,延迟作弊是突发的。所以追踪器不只看绝对延迟,还看一个玩家相对自己基线的突变频率。

下面这段是 L1,go test 可以直接跑。它实现了补偿上限的钳制,和一个基于 EWMA 基线的延迟作弊追踪器。

// file: internal/clock/fairness/guard.go
// tier: L1
// verify: go test ./internal/clock/fairness/

package fairness

import "time"

// Policy caps how much lag compensation a single shooter may claim.
type Policy struct {
	MaxRewind time.Duration // absolute ceiling on any rewind window
	MaxRTT    time.Duration // compensation stops growing past this RTT
}

// ClampRewind limits a claimed rewind window. It returns the window actually
// granted and whether the claim was trimmed. The effective ceiling is the
// smaller of the absolute cap and half the largest compensated RTT.
func (p Policy) ClampRewind(claimed time.Duration) (time.Duration, bool) {
	if claimed < 0 {
		claimed = 0
	}
	limit := p.MaxRewind
	if half := p.MaxRTT / 2; half < limit {
		limit = half
	}
	if claimed > limit {
		return limit, true
	}
	return claimed, false
}

// LagTracker watches a client's RTT samples to flag a lag switch: a link that is
// normally fast but spikes exactly when the player needs to win an exchange.
type LagTracker struct {
	baseline time.Duration
	spike    time.Duration // a sample above baseline+spike counts as a spike
	window   int
	recent   []bool
}

// NewLagTracker builds a tracker seeded with a baseline RTT and a spike margin.
func NewLagTracker(baseline, spike time.Duration, window int) *LagTracker {
	if window < 1 {
		window = 1
	}
	return &LagTracker{baseline: baseline, spike: spike, window: window}
}

// Observe records one RTT sample. A sample well above the baseline is a spike;
// the baseline is a slow EWMA over every sample, so a genuine sustained latency
// shift becomes the new baseline while a one-off spike barely moves it.
func (t *LagTracker) Observe(rtt time.Duration) {
	isSpike := rtt > t.baseline+t.spike
	t.recent = append(t.recent, isSpike)
	if len(t.recent) > t.window {
		t.recent = t.recent[len(t.recent)-t.window:]
	}
	t.baseline = (t.baseline*7 + rtt) / 8
}

// SpikeRate returns the fraction of the last window samples that were spikes.
func (t *LagTracker) SpikeRate() float64 {
	if len(t.recent) == 0 {
		return 0
	}
	n := 0
	for _, s := range t.recent {
		if s {
			n++
		}
	}
	return float64(n) / float64(len(t.recent))
}

// Suspicious reports whether recent spikes are frequent enough to warrant
// dropping compensation for this client.
func (t *LagTracker) Suspicious() bool {
	if len(t.recent) < t.window {
		return false
	}
	return t.SpikeRate() >= 0.5
}

LagTracker 里最重要的一个设计是:基线是 EWMA,而且对每个样本都更新。这解决了一个棘手的问题——如果一个玩家真的长期活在 120ms 的链路上,最初几帧 120ms 会被判成「尖峰」,但如果基线不吸收这些样本,他就永远被误判为作弊。让 EWMA 慢慢跟上,持续的高延迟就会变成基线,尖峰判定随之失效;而一次性的突发则几乎不动基线。这就是「持续高延迟」和「突发掉包」的区别所在。

下面这段 L1 测试同时验证两种相反的情形:

// snippet: internal/clock/fairness/guard_test.go(节选)

func TestLagSwitchDetection(t *testing.T) {
	lt := NewLagTracker(30*time.Millisecond, 50*time.Millisecond, 8)
	for i := 0; i < 8; i++ {
		lt.Observe(28 * time.Millisecond)
	}
	if lt.Suspicious() {
		t.Fatalf("steady low latency flagged: rate=%v", lt.SpikeRate())
	}
	for i := 0; i < 8; i++ {
		lt.Observe(200 * time.Millisecond)
	}
	if !lt.Suspicious() {
		t.Fatalf("sustained spikes not flagged: rate=%v", lt.SpikeRate())
	}
}

func TestSustainedLatencyBecomesBaseline(t *testing.T) {
	lt := NewLagTracker(30*time.Millisecond, 50*time.Millisecond, 4)
	// A player who genuinely lives on a 120ms link: once the slow EWMA baseline
	// has drifted up, samples stop exceeding baseline+50ms.
	for i := 0; i < 40; i++ {
		lt.Observe(120 * time.Millisecond)
	}
	if lt.Suspicious() {
		t.Fatalf("genuine high latency flagged as abuse: baseline=%v rate=%v",
			lt.baseline, lt.SpikeRate())
	}
}

两个断言合起来说明了一件事:判据必须是「相对自己基线的突变频率」,而不是「绝对延迟」。 前者能把持续高延迟的正常玩家放过去、把忽高忽低的作弊者挑出来;后者只会把所有高延迟玩家一网打尽。检测到 Suspicious() 之后,服务端的处置不是封号(误判成本太高),而是收回补偿:这个玩家的射击不再回退,按当前时刻判定——作弊的收益立刻消失,而正常玩家几乎不受影响。

处置可以分档,避免一刀切误伤:第一次触发只降级(补偿减半)、持续触发才完全收回、并结合 15.3 的异常信号做人工复核。回收补偿是可逆的、温和的,比封号更适合「可能只是网络抖动」的场景——毕竟追踪器看的是统计形态,不是铁证。

被高延迟玩家击杀的体感

上限和滥用防护都站在「让判定公平」这一侧,但受害者感受到的不公平是另一回事。一个玩家被一枪「穿越掩体」打死,如果屏幕上什么都不解释,他会认定是外挂或服务器有问题。公平性工程里有一半是沟通,不是算法。

三个立竿见影的手段:

  • 击杀回放(killcam):回放要按服务端的回退时刻渲染,而不是客户端本地时刻。这样受害者看到的画面,正是「射手开枪那一刻世界的样子」——他会看到自己在那一刻确实还在掩体外面,冤枉感立刻下降。
  • 补偿可见化:在战斗日志或结算里标注「本次命中含 X ms 延迟补偿」。玩家知道系统在补偿、且补偿有上限,比蒙在鼓里更容易接受。
  • 受害者侧预告:对高延迟射手的目标,服务端可以提前一点下发位置快照,让受害者的插值缓冲略短、看到的世界更「新鲜」。代价是流量增加,只值得对少数高威胁目标启用。
手段改变的层主要代价
击杀回放呈现需保留最近数秒的裁决数据
补偿可见化信息几乎为零
受害者侧预告数据流量增加,仅对少数目标启用

这三条都改变不了判定结果——判定已经在上限那一层定死了——但它们改变了玩家如何理解这个结果。在 MMO 里,「感觉被冤枉」和「真的被冤枉」对留存的影响几乎一样大,所以反馈层不是锦上添花,而是公平性方案的必要组成部分。

但要清醒:反馈层只是止血,不是根治。如果补偿上限设得太宽,受害者会一次次看到「自己确实在掩体外」的回放,看多了只会更确信服务器在偏袒高延迟玩家。反馈层的作用是让偶发的截断被理解,而不是掩盖系统性的不公。上限合理,反馈才有意义;上限失控,再好的回放也救不回体验。

本节小结

  • 补偿的上限是显式策略,不是历史环容量的副产品:允许回退 = min(绝对上限, 最大补偿RTT/2)。
  • 上限对 RTT < 200ms 的正常玩家几乎无感,只在极端高延迟处把补偿曲线拉平——故意让高延迟玩家偶尔吃亏。
  • 延迟作弊的判据是相对自身基线的突变频率,不是绝对延迟;EWMA 基线让持续高延迟「变成」正常,只挑出突发掉包。
  • 处置延迟作弊用收回补偿而非封号:作弊收益归零,正常玩家不受影响;处置可分档(降级 → 收回 → 人工复核)。
  • 公平性有一半是沟通:击杀回放按服务端回退时刻渲染、补偿可见化、受害者侧快照预告,都在降低「感觉被冤枉」。
  • 但反馈层只止血不根治:上限失控时,再好的回放也只会加深「服务器偏袒高延迟」的印象。

到这里,从时间线(14.1)到补偿判定(14.2)再到公平性闸门(14.3),一条完整的延迟补偿链就闭环了。下一章把视角从「怎么判得对」转到「怎么防得住」——服务器权威边界与反外挂。

阅读导航:上一节:14.2 延迟补偿裁决 · 下一节:15.1 权威边界与信任模型 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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