《游戏服务端编程实践》18.3 行为分析与外挂防控

以服务端权威原则为起点,说明位置、伤害、掉落与货币为什么必须由服务端计算,介绍移动速度、攻击间隔、资源获取速率和在线时段的滑窗统计方法,并给出可运行的行为监控代码,演示正常不误报、超速触发与窗口滑动后恢复,并强调阈值必须来自正常分布、封禁必须保留可回放证据链。

一、服务端权威:客户端只发意图

外挂防控的第一道防线不是检测,而是让作弊没有收益。位置、伤害、掉落、货币这些结果如果由客户端计算并上报,外挂作者只需要改一个数字;如果由服务端计算,客户端能做的就只剩“发送什么输入”,而输入必须经过速度、冷却、碰撞和状态校验。

业务对象客户端可以发什么服务端必须自己算什么常见作弊方式
位置移动方向、按键、目标点速度、碰撞、地图边界、传送合法性加速、穿墙、瞬移
伤害技能 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 的封包校验和本篇的行为统计分别覆盖消息、连接和行为三个层次,三者叠加才构成可运营的防线。

常见错误与边界

  • 信任客户端上报的位置、伤害或资源,服务端只做转发。
  • 只看单次采样就封禁,把网络抖动和预测回滚当成加速。
  • 阈值拍脑袋,不区分地图、等级、职业和活动时段。
  • 用累计值代替滑窗,异常被时间稀释,永远达不到阈值。
  • 允许负值进入统计,作弊者用负样本抵消异常。
  • 封禁没有证据链,申诉时无法回放和自证。
  • 只封号不补偿,误封造成的资产损失没有恢复流程。
  • 把行为分析当成唯一防线,忽略协议签名、封包校验和客户端加固。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist