《MMO 服务器开发》15.3 异常行为信号与限速

15.2 的校验只会说「过 / 不过」,但反作弊需要的是「可疑到什么程度」。本节给 aegis 写三个真跑的零件:令牌桶限速、指数衰减的嫌疑分、以及把分数映射成处置的阈值表。核心是一个处置梯度——限速、标记、断开、封禁,宁可分级也不一刀切,把误伤的代价压到最低。

15.3 异常行为信号与限速

15.1 画了边界,15.2 把边界变成了逐包的校验。但校验只会说两句话:「过」或「不过」。而反作弊真正要回答的是第三个问题:「可疑到什么程度」。一个偶尔超速 10% 的玩家(可能只是卡顿)和一个持续超速 300% 的脚本,在「过 / 不过」的世界里没有区别——它们都只是「这一次不过」。

所以这一节给校验结果接上一条信号管线:把每一次偏离折算成一个分数,让分数随时间衰减,再按分数走一个处置梯度。梯度是关键:从最轻的限速到最重的封禁,每一步都有代价,也都有误伤的代价。一刀切封禁会误伤真实玩家,一刀切放过会纵容脚本,梯度让服务端可以在证据不足时先做「代价小、可回退」的动作。

三个零件:令牌桶(管请求频率)、衰减嫌疑分(管证据累积)、阈值表(把分数映射成处置)。

还有一个容易被忽略的前提:这一节做的所有事情都是服务端侧的。它不依赖客户端上报任何「我检测到我被作弊了」的数据——那种数据本身就可以被伪造。信号全部来自服务端自己观测到的行为:移动比值、请求节奏、分布偏离。这也意味着它可以对任何客户端生效,包括那些把检测模块删掉的自制客户端。

信号采集:偏离分布、超速比例、请求频率

反作弊的信号不必复杂,三条就够用,而且它们全都来自前两节已经产生的数据:

  1. 超速比例(overspeed ratio)。15.2 的 MoveVerdict.SpeedRatio 就是它——观测速度 ÷ 允许速度。1.0 附近是正常,1.1~1.5 是抖动或轻微作弊,3.0 以上基本可以确定是脚本。关键不是单次的值,而是分布:正常玩家的比值长期贴着 1.0 以下,脚本的比值长期显著大于 1。
  2. 请求频率(request rate)。一个玩家一秒钟发多少移动包、多少攻击包。正常操作有物理上限(手指点击速度),脚本没有。频率是最好量、也最容易被伪造的信号——所以它只降权,不单独定罪。
  3. 偏离分布的离群度。把每个玩家的比值/频率和历史基线比,偏离多少个标准差。这一条最贵(要维护基线),但最能抓「伪装成正常值、只是更频繁」的高级脚本。

采集的原则是只记录,不判定。信号层只负责把「谁、什么时候、偏离了多少」写下来,判定交给下一个阶段的分数。

三条信号怎么折算成一个分数,是这一节唯一需要「拍脑袋」的地方,但拍得要有依据:

信号权重策略理由
超速比例与超出容差的部分成正比,权重最高最难被正常行为解释,最接近确定作弊
请求频率只在对令牌桶造成压力时给固定小权重手速快、网络抖动都会抬高它,假阳性高
离群度按偏离标准差的倍数给权重需要基线,但能抓「伪装成正常值」的高级脚本

权重不必一开始就精确。先给一组保守的初值,上线后用人工复核的结果去校准——被标 Flag 的人里有多少最后确认是作弊,就是这组权重的回归指标。反作弊和风控一样,是长期调参,不是一次写完。

代码:令牌桶、衰减嫌疑分与处置梯度

下面这段是 L1,go test 可以直接跑:

// file: internal/authority/anomaly.go
// tier: L1
// verify: go test ./internal/authority/ -run 'TestTokenBucket|TestAccumulator|TestScorer'

package authority

import (
	"math"
	"time"
)

// Disposition is the graduated server response to accumulated evidence. It is
// ordered from most permissive to most severe, so comparisons are meaningful.
type Disposition uint8

const (
	Allow Disposition = iota
	Throttle
	Flag
	Disconnect
	Ban
)

var dispositionNames = [...]string{"allow", "throttle", "flag", "disconnect", "ban"}

// String renders a Disposition for logs and metrics labels.
func (d Disposition) String() string {
	if int(d) < len(dispositionNames) {
		return dispositionNames[d]
	}
	return "unknown"
}

// TokenBucket is the request-rate limiter. Capacity is the burst a client may
// spend at once; Refill is tokens per second. It is not a cheat detector by
// itself -- a fast but honest client can exhaust it -- so it only ever lowers a
// client to Throttle, never to Ban.
type TokenBucket struct {
	capacity float64
	refill   float64
	tokens   float64
	last     time.Time
}

// NewTokenBucket returns a bucket that starts full.
func NewTokenBucket(capacity, refill float64, now time.Time) *TokenBucket {
	return &TokenBucket{capacity: capacity, refill: refill, tokens: capacity, last: now}
}

// Allow consumes one token if available and reports the outcome.
func (b *TokenBucket) Allow(now time.Time) bool {
	if elapsed := now.Sub(b.last).Seconds(); elapsed > 0 {
		b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.refill)
		b.last = now
	}
	if b.tokens < 1 {
		return false
	}
	b.tokens--
	return true
}

// Accumulator is an exponentially decaying suspicion score. Each signal adds
// weight; the score halves every halfLife so that one bad packet from an hour
// ago does not outweigh a clean hour since.
type Accumulator struct {
	halfLife time.Duration
	score    float64
	last     time.Time
}

// NewAccumulator returns an accumulator whose score decays with the given
// half-life.
func NewAccumulator(halfLife time.Duration, now time.Time) *Accumulator {
	return &Accumulator{halfLife: halfLife, last: now}
}

func (a *Accumulator) decay(now time.Time) {
	if a.score == 0 || a.halfLife <= 0 {
		a.last = now
		return
	}
	dt := now.Sub(a.last)
	if dt <= 0 {
		return
	}
	a.score *= math.Pow(0.5, float64(dt)/float64(a.halfLife))
	a.last = now
}

// Add folds one weighted signal into the score and returns the new value.
func (a *Accumulator) Add(now time.Time, weight float64) float64 {
	a.decay(now)
	a.score += weight
	return a.score
}

// Score returns the current decayed score without changing it.
func (a *Accumulator) Score(now time.Time) float64 {
	a.decay(now)
	return a.score
}

// Scorer maps a suspicion score to a disposition. Thresholds are cumulative:
// every higher step implies every lower one.
type Scorer struct {
	ThrottleAbove   float64
	FlagAbove       float64
	DisconnectAbove float64
	BanAbove        float64
}

// Decide returns the disposition for a score. Ties go to the more severe step.
func (s Scorer) Decide(score float64) Disposition {
	switch {
	case score >= s.BanAbove:
		return Ban
	case score >= s.DisconnectAbove:
		return Disconnect
	case score >= s.FlagAbove:
		return Flag
	case score >= s.ThrottleAbove:
		return Throttle
	default:
		return Allow
	}
}

三个零件各自的定位很明确。令牌桶不是作弊检测器——一个手速快但诚实的玩家也会把突发额度花光——所以它最多把人降到 Throttle,永远不单独触发 Ban。衰减嫌疑分解决的是「旧账」问题:一次坏包不该在玩家老实玩了一小时之后还算数,所以分数每过一个 halfLife 就减半。阈值表把连续的分数映射成离散的处置,阈值是累积的——越高的档蕴含越低的档,所以 Decide 必须从高到低判。

它们在一个 tick 里是这样串起来的:移动包进来 → 令牌桶 Allow 判定是否超频(超了就 Throttle 并贡献小权重)→ 15.2 的 ValidateMove 得到 SpeedRatio → 用 ratio - 1 折算权重喂给 Accumulator.Add → 用 Scorer.Decide(score) 得到本 tick 的处置。整个过程是纯计算加一张每实体一张的小表,没有 I/O,可以直接挂在主循环上。

测试把三个零件的行为钉死:

// snippet: internal/authority/anomaly_test.go(节选)

// 突发 3 个请求放行,第 4 个立刻拒绝;1 秒后恰好回 1 个。
b := NewTokenBucket(3, 1, base)
for i := 0; i < 3; i++ { if !b.Allow(base) { t.Fatalf("burst %d denied", i) } }
if b.Allow(base) { t.Fatal("fourth immediate request should be denied") }

// 嫌疑分每过一个半衰期减半:4 -> 2 -> 1。
if got := a.Score(base.Add(10 * time.Second)); math.Abs(got-2) > 1e-6 {
	t.Fatalf("score after one half-life = %v, want 2", got)
}

// 阈值累积:分数落在哪一段就返回哪一档处置。
{score: 0, want: Allow}, {score: 5, want: Flag},
{score: 20, want: Disconnect}, {score: 50, want: Ban},

处置梯度:限速 → 标记 → 断开 → 封禁

阈值表本身只是数字,真正重要的是每一档对应什么动作、代价是什么、能不能回退。这就是处置梯度:

处置触发量级动作代价与回退
Throttle分数刚过线 / 令牌桶空降请求配额,超出部分丢弃或排队几乎无感,可随时恢复,最该多用
Flag分数明显偏高打标记,进人工/离线复核队列,加采样率无玩家可见影响,只花存储
Disconnect分数很高断开当前连接,要求重连玩家可见,但重连即可,可回退
Ban分数极高且持续封禁账号/设备不可逆、误伤代价最高,必须最谨慎

梯度的设计哲学是「在证据不足时做代价最小的动作」。一个刚超速两次的玩家,正确的反应是 Throttle(降一点配额),而不是 Ban。如果继续正常,分数会衰减回去;如果真是脚本,分数会迅速堆到 Disconnect 甚至 Ban。这样误伤的代价被摊薄成「一次几乎无感的限速」,而真作弊者仍然会被逐级升级挡住。

这里要特别说清 Throttle 和令牌桶的关系。令牌桶是限速的执行器,嫌疑分是限速的决策器:分数高到 Throttle 时,服务端把该玩家的令牌桶容量和回填速率一起调低(比如从 20/秒降到 5/秒)。这是一个很自然的耦合——限速的动作本来就是「少发令牌」,不需要另写一套丢弃逻辑。反过来,令牌桶空本身也会贡献一点分数,但它单独永远推不到 Throttle 以上,因为一次网络突发就能把诚实玩家的桶清空。

几个落地时的纪律:

  • Ban 永远不自动执行(或只在极高置信度下自动)。断开和限速可以自动,封禁应带人工复核,因为它的误伤不可逆。
  • 处置要带有效期。Throttle 和 Disconnect 应该是「一段时间」而不是「永久」,否则一次误判会变成永久的体验损伤。
  • 降级也要对称。分数衰减到阈值以下时,处置必须跟着往回收;只升不降的梯度最终会把每个玩家都推到 Ban。
  • 采集可以采样,处置不能采样。全量记录每条信号太贵时,按比例抽样统计分布即可;但真正触发处置的那条路径必须逐条判定,不能因为采样漏掉一次升级。
  • 信号和处置要能对账。每一次升级都记下「是哪些信号把分数推过了阈值」,否则客服和研发面对封禁申诉时无从解释。

最后,把每一档处置都做成指标(每秒多少 Throttle、多少 Flag、多少 Disconnect)。这些曲线是调阈值的唯一依据:如果 Flag 的量突然涨了十倍,要么是阈值调松了,要么是游戏里出现了新的正常玩法被误判——两种都值得立刻查。反作弊系统如果只有「处置」没有「观测」,它自己就会变成一个黑箱,而黑箱的反作弊最终一定误伤。

本节小结

  • 校验只说「过 / 不过」,反作弊要的是「可疑到什么程度」——中间隔着一层信号管线。
  • 三条够用的信号:超速比例(来自 15.2 的 SpeedRatio)、请求频率、偏离分布的离群度。只记录,不判定。
  • 令牌桶管频率、衰减嫌疑分管证据累积、阈值表把分数映射成处置。
  • 处置梯度:限速 → 标记 → 断开 → 封禁,证据不足时先做代价最小、可回退的动作。
  • Ban 是最贵的一档,默认人工复核、带有效期、可对账。

到这里,第 15 章把「服务端不被作弊客户端操纵」这条主线走完了:先划边界,再逐包校验,最后用梯度处置。下一章换一个角度——不再假设客户端是恶意的,而是假设它可能发来畸形数据:协议 fuzz、洪泛防护与封包加密签名。

阅读导航:上一节:15.2 移动与战斗合法性校验 · 下一节:16.1 协议 fuzz 与畸形包 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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