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 下实得 | 是否被截断 |
|---|---|---|---|
| 40ms | 120ms | 120ms | 否 |
| 100ms | 150ms | 150ms | 否 |
| 200ms | 200ms | 150ms | 是(截 50ms) |
| 400ms | 300ms | 150ms | 是(截 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 权威边界与信任模型 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。