15.3 异常行为信号与限速
15.1 画了边界,15.2 把边界变成了逐包的校验。但校验只会说两句话:「过」或「不过」。而反作弊真正要回答的是第三个问题:「可疑到什么程度」。一个偶尔超速 10% 的玩家(可能只是卡顿)和一个持续超速 300% 的脚本,在「过 / 不过」的世界里没有区别——它们都只是「这一次不过」。
所以这一节给校验结果接上一条信号管线:把每一次偏离折算成一个分数,让分数随时间衰减,再按分数走一个处置梯度。梯度是关键:从最轻的限速到最重的封禁,每一步都有代价,也都有误伤的代价。一刀切封禁会误伤真实玩家,一刀切放过会纵容脚本,梯度让服务端可以在证据不足时先做「代价小、可回退」的动作。
三个零件:令牌桶(管请求频率)、衰减嫌疑分(管证据累积)、阈值表(把分数映射成处置)。
还有一个容易被忽略的前提:这一节做的所有事情都是服务端侧的。它不依赖客户端上报任何「我检测到我被作弊了」的数据——那种数据本身就可以被伪造。信号全部来自服务端自己观测到的行为:移动比值、请求节奏、分布偏离。这也意味着它可以对任何客户端生效,包括那些把检测模块删掉的自制客户端。
信号采集:偏离分布、超速比例、请求频率
反作弊的信号不必复杂,三条就够用,而且它们全都来自前两节已经产生的数据:
- 超速比例(overspeed ratio)。15.2 的
MoveVerdict.SpeedRatio就是它——观测速度 ÷ 允许速度。1.0 附近是正常,1.1~1.5 是抖动或轻微作弊,3.0 以上基本可以确定是脚本。关键不是单次的值,而是分布:正常玩家的比值长期贴着 1.0 以下,脚本的比值长期显著大于 1。 - 请求频率(request rate)。一个玩家一秒钟发多少移动包、多少攻击包。正常操作有物理上限(手指点击速度),脚本没有。频率是最好量、也最容易被伪造的信号——所以它只降权,不单独定罪。
- 偏离分布的离群度。把每个玩家的比值/频率和历史基线比,偏离多少个标准差。这一条最贵(要维护基线),但最能抓「伪装成正常值、只是更频繁」的高级脚本。
采集的原则是只记录,不判定。信号层只负责把「谁、什么时候、偏离了多少」写下来,判定交给下一个阶段的分数。
三条信号怎么折算成一个分数,是这一节唯一需要「拍脑袋」的地方,但拍得要有依据:
| 信号 | 权重策略 | 理由 |
|---|---|---|
| 超速比例 | 与超出容差的部分成正比,权重最高 | 最难被正常行为解释,最接近确定作弊 |
| 请求频率 | 只在对令牌桶造成压力时给固定小权重 | 手速快、网络抖动都会抬高它,假阳性高 |
| 离群度 | 按偏离标准差的倍数给权重 | 需要基线,但能抓「伪装成正常值」的高级脚本 |
权重不必一开始就精确。先给一组保守的初值,上线后用人工复核的结果去校准——被标 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 与畸形包 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。