一、服务端权威:客户端只发意图
外挂防控的第一道防线不是检测,而是让作弊没有收益。位置、伤害、掉落、货币这些结果如果由客户端计算并上报,外挂作者只需要改一个数字;如果由服务端计算,客户端能做的就只剩“发送什么输入”,而输入必须经过速度、冷却、碰撞和状态校验。
| 业务对象 | 客户端可以发什么 | 服务端必须自己算什么 | 常见作弊方式 |
|---|---|---|---|
| 位置 | 移动方向、按键、目标点 | 速度、碰撞、地图边界、传送合法性 | 加速、穿墙、瞬移 |
| 伤害 | 技能 ID、目标 ID | 攻击力、防御、暴击、抗性、距离 | 秒杀、改伤害、无冷却 |
| 掉落 | 拾取请求 | 掉落表、归属、背包空间 | 强制拾取、复制道具 |
| 货币 | 购买/出售意图 | 价格、余额、税率、流水 | 改余额、负数交易 |
| 任务 | 提交任务 ID | 完成条件、次数、奖励 | 跳过条件、重复领奖 |
| 匹配 | 进入队列 | 分段、延迟、队伍合法性 | 狙击、强制匹配 |
“服务端权威”不等于“客户端什么都不做”。为了手感,客户端可以预测移动和技能表现,但预测结果只是临时显示;服务端返回权威状态后,客户端必须回滚或校正。关键在于:任何能影响资产、排名和其他玩家体验的结果,都不能以客户端上报为准。
1.1 输入校验要贴着业务规则
同样是“移动到目标点”,不同游戏要校验的东西完全不同:MOBA 要检查寻路和技能位移,MMO 要检查地形和坐骑速度,FPS 要检查碰撞体和延迟补偿。校验逻辑应该和业务规则放在一起,而不是在网关做一个通用的“速度上限”。通用规则容易漏掉传送、击退、载具、Buff 等合法例外,最后要么误杀,要么被迫放开。
二、行为特征:把“像不像外挂”变成可计算的量
行为分析不是读心术,而是把正常玩家的统计分布和外挂的异常模式区分开。常用的特征有四类:移动速度、攻击间隔、资源获取速率、在线与操作时段。每个特征都要由服务端观测,不能采信客户端上报的“我这一秒走了多远”。
| 特征 | 观测方式 | 异常模式 | 注意事项 |
|---|---|---|---|
| 移动速度 | 服务端位置差 / 服务端时间差 | 持续超过地图上限 | 传送、击退、载具是合法例外 |
| 攻击间隔 | 两次伤害结算的服务端时间差 | 低于技能冷却或 GCD | 延迟补偿不能突破冷却下限 |
| 资源获取 | 单位时间掉落/采集/交易增量 | 远超地图产出期望 | 活动、双倍掉落要单独建模 |
| 在线时段 | 登录时长、操作间隔分布 | 24 小时机械操作 | 工作室与正常重度玩家要区分 |
| 操作熵 | 按键/转向/目标切换的分布 | 过于规律或恒定 | 单一特征不足以定性 |
2.1 滑窗统计比累计值更可靠
累计值会被时间稀释:一个玩家昨天加速一小时,今天只加速一分钟,累计总量可能仍低于阈值。滑窗只看最近一段时间,异常过去后指标会自然回落,适合“实时发现 + 自动恢复”。窗口长度要覆盖一个完整的异常周期,例如技能冷却的几倍、地图跑图的一圈;太短会漏掉低频作弊,太长会让正常波动被放大。
| 窗口类型 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 固定窗口 | 实现简单、内存固定 | 窗口边界会出现双倍突发 | 粗粒度限流 |
| 滑动窗口(样本) | 边界准确、可解释 | 样本多时查询成本上升 | 中低频行为事件 |
| 滑动窗口(分桶) | 内存与查询都可控 | 桶边界有少量误差 | 高频事件、长时间窗口 |
| 指数衰减 | 实现紧凑、强调近期 | 参数不直观、难以审计 | 实时风险评分 |
本文的实现使用样本滑窗:事件频率在每秒几十到几百条时,直接保存带时间戳的样本最简单,也最容易解释。频率再高一个数量级,就应该改成按秒预聚合的桶,否则查询时遍历全部样本会成为瓶颈。
三、阈值必须从数据来,不能拍脑袋
阈值是最容易出事的地方。定得太松,外挂随便跑;定得太紧,正常玩家在高峰期、跨服战、网络抖动时被误封。比较稳妥的流程是:先只记录不处置,收集至少一周的分场景分布;按地图、等级、职业、设备分组看 p99 和 p99.9;把观察线放在 p99.9 附近,把限流线放在明显偏离正常分布的位置,把封禁线放在“正常玩法几乎不可能达到”的位置。
| 阈值 | 建议来源 | 处置 | 复核方式 |
|---|---|---|---|
| 观察线 | 正常分布 p99.9 | 记录证据,不限制玩家 | 抽样回放 |
| 限流线 | 观察线的 1.2–1.5 倍 | 降频、排队、提示 | 自动解除 + 人工抽查 |
| 封禁线 | 正常上限的数倍 | 临时封禁、进入复核 | 人工确认后转永久 |
| 解封线 | 与封禁线留出滞回 | 自动恢复 | 记录恢复原因 |
阈值还要有“滞回”:触发封禁后,不能因为下一秒指标回落就立刻解封,否则外挂可以按节奏间歇运行。可以要求连续多个窗口超限,或者在一个更长的时间范围内累计命中次数。反过来,误报后的解封也要留痕,方便发现规则本身的偏差。
四、误报、分级处置与申诉
行为分析的输出不应该只有“封”和“不封”。分级处置能同时降低误伤和运营成本:观察只写证据,限流降低收益,封禁才真正切断访问。每一级都要有明确的进入条件、持续时间和退出条件,不能靠人工临时决定。
4.1 失败场景:一次拍脑袋的封禁潮
某次版本更新后,运营为了“打击加速”,把移动速度阈值设成地图上限的 1.05 倍,并且只看单次采样。更新当天网络抖动,客户端预测回滚导致服务端连续收到几个位置修正包,时间差很小、位移看起来很大。系统在十分钟内封禁了上千名正常玩家,申诉工单挤爆客服,最后只能全量解封并回滚规则。
| 时间 | 动作 | 后果 |
|---|---|---|
| T+0 | 新阈值上线,只记录单次速度 | 正常回滚被当成加速 |
| T+10min | 封禁数快速上升 | 客服工单积压 |
| T+30min | 社区开始发酵 | 口碑受损 |
| T+1h | 人工介入,全量解封 | 规则可信度下降 |
| T+1d | 回滚阈值,改为多窗口累计 | 需要重新收集基线 |
这个案例的教训不是“不要做行为检测”,而是“单次采样不能作为封禁证据”。至少要多窗口累计、排除合法位移来源、保留原始事件,并且先经过观察期。封禁是不可逆的运营动作,必须比限流保守得多。
4.2 证据链要能回放
一条可用的证据至少包含:玩家 ID、连接 ID、服务端时间戳、事件类型、原始数值、当时的策略版本、命中的规则和处置结果。能回放意味着复核人员可以根据这些字段重建“为什么判定异常”,而不是只看一句“速度过快”。证据保留时间要覆盖申诉周期,关键资产相关的证据还要满足合规要求。
五、可运行实现:滑窗统计与分级判定
下面的实现把滑窗、策略和判定结果分开:SlidingWindow 只负责在 [now-window, now] 内求和,Monitor 负责把累计值与 Limit 比较并给出 observe、throttle、ban 三级结果。负值按 0 处理,避免用负样本抵消异常;乱序样本按时间插入,未来样本暂不参与计算。
// file: ch18/behavior/behavior.go
// tier: L1
// verify: go test ./ch18/behavior/
// Package behavior 演示服务端行为统计的滑窗实现与分级判定。
//
// 统计值由服务端自己观测,例如单位时间位移、攻击次数、资源增量,
// 不接受客户端上报的“我移动了多远”。滑窗只负责算数:在
// [now-window, now] 内对样本求和并按阈值分级;证据留存与人工
// 复核属于上层流程。
package behavior
import (
"sort"
"sync"
"time"
)
// Sample 是一条带时间戳的观测值。
type Sample struct {
At time.Time
Value float64
}
// Snapshot 是一次滑窗查询的结果。
type Snapshot struct {
Sum float64
Count int
}
// SlidingWindow 是按时间排序的样本集合。
//
// 样本量应与窗口长度和事件频率同阶。它适合每秒几十到几百条
// 的行为事件;如果频率高到十万级,应改成按秒预聚合的桶,
// 而不是在查询时遍历全部原始样本。
type SlidingWindow struct {
mu sync.Mutex
span time.Duration
samples []Sample
}
// NewSlidingWindow 创建滑窗,span 小于等于 0 时按 1 秒处理。
func NewSlidingWindow(span time.Duration) *SlidingWindow {
if span <= 0 {
span = time.Second
}
return &SlidingWindow{span: span}
}
// Add 插入一条样本。时间戳不要求单调,内部按时间有序插入,
// 因此乱序到达的事件也能被正确统计。
func (w *SlidingWindow) Add(at time.Time, value float64) {
w.mu.Lock()
defer w.mu.Unlock()
i := sort.Search(len(w.samples), func(i int) bool {
return !w.samples[i].At.Before(at)
})
w.samples = append(w.samples, Sample{})
copy(w.samples[i+1:], w.samples[i:])
w.samples[i] = Sample{At: at, Value: value}
}
// Snapshot 返回 [now-span, now] 内的样本和与样本数。
//
// 早于窗口的样本会被永久移除;晚于 now 的样本保留但暂不参与
// 计算,避免客户端时钟或乱序事件把未来样本算进当前窗口。
func (w *SlidingWindow) Snapshot(now time.Time) Snapshot {
w.mu.Lock()
defer w.mu.Unlock()
w.pruneLocked(now)
start := 0
end := len(w.samples)
for end > start && w.samples[end-1].At.After(now) {
end--
}
snap := Snapshot{Count: end - start}
for _, s := range w.samples[start:end] {
snap.Sum += s.Value
}
return snap
}
func (w *SlidingWindow) pruneLocked(now time.Time) {
cutoff := now.Add(-w.span)
i := sort.Search(len(w.samples), func(i int) bool {
return w.samples[i].At.After(cutoff)
})
if i > 0 {
w.samples = append(w.samples[:0], w.samples[i:]...)
}
}
// Len 返回当前窗口内的样本数。
func (w *SlidingWindow) Len(now time.Time) int {
return w.Snapshot(now).Count
}
// Reset 清空全部样本,用于处置结束后重新观察。
func (w *SlidingWindow) Reset() {
w.mu.Lock()
defer w.mu.Unlock()
w.samples = w.samples[:0]
}
// Action 是分级处置结果。
type Action int
const (
// ActionNone 表示未达到任何阈值。
ActionNone Action = iota
// ActionObserve 表示接近阈值,记录证据但不限制玩家。
ActionObserve
// ActionThrottle 表示超过限额,降低操作频率或进入审核队列。
ActionThrottle
// ActionBan 表示严重超过限额,进入封禁与人工复核流程。
ActionBan
)
// String 返回稳定的日志名称,避免日志里出现裸整数。
func (a Action) String() string {
switch a {
case ActionObserve:
return "observe"
case ActionThrottle:
return "throttle"
case ActionBan:
return "ban"
default:
return "none"
}
}
// Policy 是行为判定阈值。
//
// Limit 是窗口内允许的累计值,例如 10 秒内最多 6000 单位位移。
// ObserveRatio 与 BanRatio 都是相对 Limit 的比例:达到 0.7 开始
// 观察,达到 1 限流,达到 2 封禁。阈值必须来自正常行为分布,
// 不能凭感觉拍一个整数。
type Policy struct {
Window time.Duration
Limit float64
ObserveRatio float64
BanRatio float64
}
func (p Policy) normalized() Policy {
if p.Window <= 0 {
p.Window = 10 * time.Second
}
if p.Limit <= 0 {
p.Limit = 1
}
if p.ObserveRatio <= 0 || p.ObserveRatio >= 1 {
p.ObserveRatio = 0.7
}
if p.BanRatio <= 1 {
p.BanRatio = 2
}
return p
}
// Verdict 是一次判定的完整结果,可直接写入审计日志。
type Verdict struct {
Action Action
Sum float64
Rate float64
Ratio float64
Samples int
}
// Monitor 把滑窗与阈值策略组合起来。
type Monitor struct {
mu sync.Mutex
policy Policy
window *SlidingWindow
}
// NewMonitor 创建监控器;非法或缺失的字段会使用保守默认值。
func NewMonitor(p Policy) *Monitor {
p = p.normalized()
return &Monitor{policy: p, window: NewSlidingWindow(p.Window)}
}
// Observe 记录一次行为并返回分级结果。
//
// 负值按 0 处理:位移、伤害、资源增量都不应为负,若允许负值,
// 作弊者可以用负样本抵消前面的异常,把累计值压回阈值以下。
func (m *Monitor) Observe(at time.Time, value float64) Verdict {
if value < 0 {
value = 0
}
m.mu.Lock()
defer m.mu.Unlock()
m.window.Add(at, value)
snap := m.window.Snapshot(at)
ratio := snap.Sum / m.policy.Limit
v := Verdict{
Sum: snap.Sum,
Rate: snap.Sum / m.policy.Window.Seconds(),
Ratio: ratio,
Samples: snap.Count,
}
switch {
case ratio >= m.policy.BanRatio:
v.Action = ActionBan
case ratio >= 1:
v.Action = ActionThrottle
case ratio >= m.policy.ObserveRatio:
v.Action = ActionObserve
default:
v.Action = ActionNone
}
return v
}
// Policy 返回生效的策略副本,便于日志记录与排查。
func (m *Monitor) Policy() Policy {
m.mu.Lock()
defer m.mu.Unlock()
return m.policy
}
// Reset 清空滑窗,通常在人工复核确认误报后调用。
func (m *Monitor) Reset() {
m.mu.Lock()
defer m.mu.Unlock()
m.window.Reset()
}
测试覆盖正常行为不误报、接近阈值进入观察、超速触发限流、严重超速触发封禁、窗口滑动后自动恢复、乱序样本仍被统计,以及负值不会拉低累计值。运行方式:
cd .local/checks/gs-practice-lab
gofmt -l ch18/behavior
go vet ./ch18/behavior/
go test ./ch18/behavior/ -count=1
实际运行结果是 gofmt -l 无输出、go vet 通过、测试全部通过。测试使用固定时间基准,窗口滑动通过显式推进时间来模拟,不依赖 time.Sleep,因此结果稳定。
5.1 阈值进入代码之前先做离线回放
上线前应该用历史样本做离线回放:把同一套 Policy 跑在正常玩家和外挂样本上,统计误报率、漏报率和处置分布。回放时固定随机种子、固定策略版本,输出每个被判定样本的 Sum、Rate、Ratio 和窗口内样本数。这样调整阈值时,能说清楚“多封了多少正常玩家、多抓了多少外挂”,而不是凭感觉改一个数字。
六、把行为分析接进运营流程
行为分析的结果要进入运营闭环:观察期数据用于校准阈值,限流用于即时止损,封禁用于确认后的处置,申诉用于发现规则缺陷。每个环节都要有负责人和时限,否则“观察”会变成永久不处理,“封禁”会变成无人复核。对外挂样本还要做聚类:同一批账号的移动模式、上线时间、设备特征往往相似,聚类结果可以反过来指导规则和客户端加固。
小结
外挂防控的基础是服务端权威:位置、伤害、掉落、货币都由服务端计算,客户端只发意图。行为分析把“像不像外挂”变成滑窗内的可计算指标,阈值必须来自正常分布并留出滞回。处置要分级,证据要能回放,封禁必须经过复核。18.1 的签名、18.2 的封包校验和本篇的行为统计分别覆盖消息、连接和行为三个层次,三者叠加才构成可运营的防线。
常见错误与边界
- 信任客户端上报的位置、伤害或资源,服务端只做转发。
- 只看单次采样就封禁,把网络抖动和预测回滚当成加速。
- 阈值拍脑袋,不区分地图、等级、职业和活动时段。
- 用累计值代替滑窗,异常被时间稀释,永远达不到阈值。
- 允许负值进入统计,作弊者用负样本抵消异常。
- 封禁没有证据链,申诉时无法回放和自证。
- 只封号不补偿,误封造成的资产损失没有恢复流程。
- 把行为分析当成唯一防线,忽略协议签名、封包校验和客户端加固。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。