《游戏服务端架构·品类、状态与拓扑》6.2 反作弊应该放在架构的哪一层

从《裂隙战场》一次只依据客户端完整性导致的误封讲起,把反作弊拆成客户端、网关、战斗服、经济审计与离线复核五层,逐层给出能发现什么、误伤什么、成本多少;实现一个带分层归因、阈值分级、哈希证据链与双层确认的 Go 评分引擎,并用 5 组测试验证单层不能直接封禁。

一次误封,把“反作弊”从功能变成了架构问题

《裂隙战场》上线第二个月做过一次封禁,依据是客户端完整性校验:检测到调试器或内存修改就直接封号。名单里出现了一名主播,他的直播推流软件恰好会注入图形层,触发了同一条规则。申诉花了三天,舆论已经发酵完。

同一个季度,《群岛纪元》出现了一次资源复制。玩家利用断线重连的时间窗,让同一笔建造扣款执行了两次。客户端完整性校验完全看不见这个问题,因为客户端没有改任何代码,它只是重复发了请求。

《泡泡工坊》的情况更直接:团队只有两名后端,没有安全专职,也不可能维护一套客户端加固。它的反作弊目标不是“抓住高手”,而是“让批量脚本的成本高于收益”。

三个产品、三种失败,说明反作弊不是一个可以插在某一层的模块。它是一组分布在链路各处的检查点,每个检查点只能回答一部分问题。

五层检查点

客户端完整性 ──┐
网关频率/协议 ──┼──> 评分与归因 ──> 阈值分级 ──> 人工复核 / 封禁
战斗服规则 ────┤         │
经济审计 ──────┤         └──> 证据链(只追加、可校验)
离线复核 ──────┘
层能发现什么主要误伤成本与延迟绕过成本
客户端完整性改包、注入、调试器、模拟器特征直播/辅助/无障碍工具客户端体积与兼容性,服务端低中,改特征即可
网关频率与协议洪水、畸形包、不可能的时间间隔NAT 重绑定、重连后的突发很低,线速可做低,限速即可绕过
战斗服规则超速、超射程、冷却不符、伤害异常延迟补偿、边界碰撞、合法连招中,跟随 tick高,要贴合规则
经济审计复制、异常产出、交易闭环客服补发、活动配置错误低,可离线高,要改账
离线复核与人工跨天模式、团伙关联、申诉证据人工判断偏差高,按人天最高,但不可规模化

这张表最重要的不是“哪层更强”,而是“每层都能被绕过”。客户端可以被改特征,网关可以被限速到正常范围,战斗服规则可以被慢慢逼近阈值,经济账可以被拆成多笔小额。没有任何单层能“杜绝作弊”;架构的目标是让攻击成本持续上升,同时让误伤可解释、可申诉、可撤销。

为什么单层封禁一定会出事

单层封禁有两个结构性问题。

第一,单层证据的误伤率不可控。客户端完整性会命中辅助工具,网关频率会命中重连风暴,战斗服规则会命中延迟补偿的边界。把这些信号直接映射成封禁,等于把该层所有的假阳性都变成用户损失。

第二,单层证据无法区分“异常”和“恶意”。一个玩家在高铁上玩游戏,RTT 抖动、重连、输入突发会同时触发网关和战斗服规则;他的行为统计与脚本几乎一样。只有跨层、跨时间的证据才能把两者分开。

所以策略要分成两级:评分回答“有多可疑”,归因回答“证据来自几层”,封禁回答“是否满足复核条件”。 单层可以触发观察和人工复核,但不能直接封禁。

证据链:封禁决定必须能被复查

申诉处理时,运营需要的不是“模型给出 0.93 分”,而是“在什么时间、因为哪条规则、命中了什么数据、当时的追踪号是什么”。因此每条命中都要落成一条不可静默修改的证据记录。

字段作用
Seq全局递增,防止插入和删除
PlayerID / Rule / Layer归因,支撑分层统计
Score / Detail本次命中的权重与可读说明
At / TraceID关联日志与回放
PrevHash / Hash哈希链,检测事后篡改

哈希链不提供“不可删除”的保证,那需要外部存储或 WORM 介质;它提供的是“删除或修改会被发现”。对内部审计和申诉来说,这已经足够,而且实现成本远低于引入一套专门的审计基础设施。

评分、分级与双层确认

评分引擎只做三件事:累加权重、按层归因、根据策略给出分级。它不包含任何检测模型,检测规则由各层自己产生 Finding,引擎不关心规则细节。

策略里有四个阈值:观察、复核、封禁,以及封禁所需的最小层数。前三个回答“分数到哪”,第四个回答“证据够不够硬”。

一个常见的误区是把所有层的分数直接相加。网关一次洪水可能产生几百分,战斗服一次异常只有十分;直接相加会让网关单层轻松越过封禁线,正好绕过了双层确认。正确做法是同时看总分和层分布:总分决定是否进入视野,层数决定能否升级处置。

另一条边界是衰减。三周前的一次网关异常不应该和今天的战斗服异常等权。生产上应给证据设置时间窗,或在评分时按时间衰减;本节为了保持可测试性,把衰减留给调用方,只在接口上区分“记录”和“评估”。

代码:anticheat 包

下面的实现包含五个部分:层枚举、命中记录、只追加的证据链、分层评分与评估、链校验。它不实现任何具体检测规则,因为那属于各层的领域逻辑。

// file: internal/anticheat/engine.go
// tier: L1
// verify: go test ./internal/anticheat/
package anticheat

import (
	"crypto/sha256"
	"encoding/hex"
	"fmt"
	"sort"
	"time"
)

// Layer names where a signal was produced. The order is the escalation order,
// not a priority: a cheap gateway rule runs long before an expensive replay
// review, and a ban should never rest on one layer alone.
type Layer string

const (
	LayerClient  Layer = "client"
	LayerGateway Layer = "gateway"
	LayerBattle  Layer = "battle"
	LayerEconomy Layer = "economy"
	LayerReview  Layer = "review"
)

// Finding is one rule hit. Score is a policy weight, not a probability.
type Finding struct {
	Rule   string
	Layer  Layer
	Score  int
	Detail string
}

// Evidence is an append-only record. PrevHash and Hash make the trail
// tamper-evident without requiring a database trigger.
type Evidence struct {
	Seq      int
	PlayerID string
	Rule     string
	Layer    Layer
	Score    int
	Detail   string
	At       time.Time
	TraceID  string
	PrevHash string
	Hash     string
}

type Verdict string

const (
	VerdictPass   Verdict = "pass"
	VerdictWatch  Verdict = "watch"
	VerdictReview Verdict = "review"
	VerdictBan    Verdict = "ban"
)

// Policy separates "how much evidence" from "how much corroboration". A ban
// requires both a score and findings from at least MinBanLayers layers.
type Policy struct {
	Watch        int
	Review       int
	Ban          int
	MinBanLayers int
}

func DefaultPolicy() Policy {
	return Policy{Watch: 20, Review: 60, Ban: 120, MinBanLayers: 2}
}

type Engine struct {
	policy   Policy
	scores   map[string]int
	layers   map[string]map[Layer]int
	evidence []Evidence
}

func NewEngine(p Policy) *Engine {
	if p.MinBanLayers < 1 {
		p.MinBanLayers = 1
	}
	return &Engine{
		policy: p,
		scores: make(map[string]int),
		layers: make(map[string]map[Layer]int),
	}
}

// Record appends a finding and returns the sealed evidence record. Callers must
// keep the returned record; the in-memory chain is a convenience, not storage.
func (e *Engine) Record(playerID string, at time.Time, traceID string, f Finding) Evidence {
	ev := Evidence{
		Seq:      len(e.evidence) + 1,
		PlayerID: playerID,
		Rule:     f.Rule,
		Layer:    f.Layer,
		Score:    f.Score,
		Detail:   f.Detail,
		At:       at.UTC(),
		TraceID:  traceID,
	}
	if n := len(e.evidence); n > 0 {
		ev.PrevHash = e.evidence[n-1].Hash
	}
	ev.Hash = hashEvidence(ev.PrevHash, ev)

	e.evidence = append(e.evidence, ev)
	e.scores[playerID] += f.Score
	if e.layers[playerID] == nil {
		e.layers[playerID] = make(map[Layer]int)
	}
	e.layers[playerID][f.Layer] += f.Score
	return ev
}

type Assessment struct {
	PlayerID string
	Score    int
	Layers   []Layer
	Verdict  Verdict
	Note     string
}

// Assess caps a single-layer score at review. This is the architecture rule
// that says "no single sensor may ban an account".
func (e *Engine) Assess(playerID string) Assessment {
	layers := make([]Layer, 0, len(e.layers[playerID]))
	for layer := range e.layers[playerID] {
		layers = append(layers, layer)
	}
	sort.Slice(layers, func(i, j int) bool { return layers[i] < layers[j] })

	verdict := e.verdict(e.scores[playerID])
	note := ""
	if verdict == VerdictBan && len(layers) < e.policy.MinBanLayers {
		verdict = VerdictReview
		note = "ban requires corroboration from at least two layers"
	}
	return Assessment{
		PlayerID: playerID,
		Score:    e.scores[playerID],
		Layers:   layers,
		Verdict:  verdict,
		Note:     note,
	}
}

func (e *Engine) verdict(score int) Verdict {
	switch {
	case score >= e.policy.Ban:
		return VerdictBan
	case score >= e.policy.Review:
		return VerdictReview
	case score >= e.policy.Watch:
		return VerdictWatch
	default:
		return VerdictPass
	}
}

func (e *Engine) Score(playerID string) int { return e.scores[playerID] }

func (e *Engine) LayerScore(playerID string, layer Layer) int {
	return e.layers[playerID][layer]
}

func (e *Engine) Evidence() []Evidence {
	return append([]Evidence(nil), e.evidence...)
}

// VerifyChain recomputes every hash and link. A modified score, detail or
// timestamp anywhere in the trail fails the whole chain.
func VerifyChain(evidence []Evidence) error {
	prev := ""
	for i, ev := range evidence {
		if ev.Seq != i+1 {
			return fmt.Errorf("anticheat: evidence %d has seq %d", i, ev.Seq)
		}
		if ev.PrevHash != prev {
			return fmt.Errorf("anticheat: evidence %d prev hash mismatch", i)
		}
		if ev.Hash != hashEvidence(prev, ev) {
			return fmt.Errorf("anticheat: evidence %d hash mismatch", i)
		}
		prev = ev.Hash
	}
	return nil
}

func hashEvidence(prev string, ev Evidence) string {
	raw := fmt.Sprintf("%s|%s|%s|%s|%d|%d|%s|%s|%s",
		prev, ev.PlayerID, ev.Rule, ev.Layer, ev.Score, ev.Seq,
		ev.At.UTC().Format(time.RFC3339Nano), ev.TraceID, ev.Detail)
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

Record 接收时间与追踪号,而不是在内部取 time.Now()。这样回放、测试和离线补录都能得到确定的时间线;如果引擎自己取时间,同一批证据在不同机器上会算出不同的哈希。

Assess 先按分数得到分级,再检查层数。单层高分不会返回 ban,而是降级为 review 并附带说明。这个降级不是“宽大处理”,而是把决定权交回给能看到完整证据的人。

VerifyChain 从第一条开始重算。任何一条的分数、详情、时间或序号被改动,后续所有链接都会失配;只改最后一条也无法通过,因为它的 PrevHash 仍指向旧值。

测试覆盖了什么

lab 里的 internal/anticheat 有 5 个测试函数,其中 4 个是表驱动:

测试覆盖的失败模式
TestVerdictThresholds四个分级在阈值边界上的取值
TestAssessmentTable干净、观察、单层高分、双层封禁四种组合
TestLayerAttribution同层累加、跨层独立、未命中层为 0
TestEvidenceChainDetectsTampering修改分数、详情、前哈希、序号均被发现
TestBanRequiresTwoLayers单层 200 分仍只能复核,补一层后才封禁

本机运行结果:

$ go test -count=1 -v ./internal/anticheat/
--- PASS: TestVerdictThresholds (0.00s)
--- PASS: TestBanRequiresTwoLayers (0.00s)
--- PASS: TestLayerAttribution (0.00s)
--- PASS: TestAssessmentTable (0.00s)
--- PASS: TestEvidenceChainDetectsTampering (0.00s)
PASS
ok  	gsarchlab/internal/anticheat	0.268s

失败模式与兜底

规则误伤集中爆发。 一次客户端更新可能让某类正常工具全部命中。上线前必须支持按规则灰度、按层关闭和快速回滚;只支持“全开或全关”的引擎会在事故时被迫整体下线。

分数通胀。 规则加得越多,总分越容易越线。需要定期回看各层的分数分布,给单层设置贡献上限,否则双层确认会被“每层都很低但加起来很高”绕过。

证据链断裂。 并发写入、重试、补录都可能打乱序号。写入端必须单线程追加,或在入库时统一分配序号;发现断链时应告警并停止自动处置,而不是跳过坏记录继续。

申诉无据。 只存最终分数,不存命中详情,运营无法解释封禁原因。证据记录必须在处置前落盘,而不是事后从日志里拼。

常见错误与边界

第一,把客户端完整性当作封禁依据。它适合做“进入对局的准入检查”和“观察信号”,不适合单独定罪,因为误伤面覆盖了大量正常软件。

第二,只做服务端规则,不做经济审计。规则能抓住“这一帧不对”,抓不住“每天多出 200 金币、连续 30 天”;后者只有账本视角能看见。

第三,把反作弊放进战斗服主循环。检测和评分应与战斗 tick 解耦,否则一次规则升级就会影响帧预算;战斗服只负责产生命中,评分异步消费。

第四,忽略《泡泡工坊》这类小团队的成本约束。五层全建不是默认答案;休闲产品可以先做网关限速加经济对账,把客户端加固延后到出现规模化脚本之后。

小结

反作弊的架构问题不是“用哪个检测算法”,而是“把信任放在哪一层、每层能证明什么、证据如何组合成决定”。客户端完整性、网关、战斗服、经济审计和人工复核各自只能回答一部分问题,任何单层都不足以封禁。

internal/anticheat 把评分、归因、分级、证据链做成一个与规则无关的引擎。它强制双层确认,强制证据只追加,强制每次评估都能追溯到具体命中;这些约束比任何单条规则都更难被绕过。

下一节进入经济系统:当“谁说了算”落到掉落、随机数和账本上,服务端权威如何做到可审计,而不是仅仅“不可预测”。

阅读导航:上一节:6.1 客户端预测与服务端裁决 · 下一节:6.3 经济系统与随机数的可信边界 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查