《游戏服务端架构·平台化与架构治理》4.3 黑产对抗的架构边界

黑产不追求赢,只追求单位时间的收益。本节按金币、账号、道具、排名、活动奖励五类目标梳理攻击面,给出服务端权威、接口限频、奖励延迟三条硬边界,讨论权威分级、令牌桶与最终校验,并用可运行的 Go 夹具演示边界判定、限频与延迟发放。最后附边界评审检查表。

黑产算的是收益,不是胜负

《泡泡工坊》上线三个月后,交易行里出现了一批价格稳定低于市场三成的材料。运营封了十几个账号,第二天又冒出新的。技术团队追踪后发现,黑产没有修改客户端,也没有攻击服务器,而是利用了一个合法接口:批量创建小号,完成新手任务领取材料,再通过交易行转移给主号。

这个案例说明,黑产的目标不是“赢过系统”,而是让单位时间的收益高于成本。封号只是成本的一项,账号注册、设备、时间、被追缴的风险都要算进去。只要收益仍然为正,封禁就只是生产损耗。因此,架构对抗的目标不是“彻底消灭黑产”,而是把三类边界划清楚:哪些数据必须由服务端权威计算,哪些接口必须限频,哪些奖励必须延迟发放。边界越清楚,黑产的可利用面越小,误伤也越少。

黑产目标与攻击面

不同资产对应不同的攻击手法,不能用一套规则包打天下:

目标典型手法当前边界残余风险
金币小号刷任务、脚本搬砖、低价转金产出服务端结算,交易行抽税多账号协同、跨服转移
账号批量注册、盗号、租号实名与设备风控接码平台、模拟器农场
道具复制、异常掉落、活动套利掉落服务端权威,道具带唯一 ID交易链洗白、延迟发放被绕过
排名代打、演员、外挂对局结果服务端校验高段位协同、跨区匹配
活动奖励多小号领奖、脚本刷榜领取资格与发放分离资格快照时点、奖励延迟期

这张表的作用是给每条防线找位置。金币问题的核心是产出与转移,账号问题的核心是注册与身份,道具问题的核心是唯一性和交易链,排名问题的核心是对局校验,活动奖励的核心是资格判定时点。把资源投在错误的位置,会出现“封了很多号,经济还是通胀”的结果。还要区分单账号作弊和团伙套利。单账号作弊靠行为检测和客户端完整性;团伙套利靠社交图谱、设备关联和交易链分析。前者的处置单位是账号,后者往往要处置整个关联簇,否则封一个补一个。

三条硬边界

服务端权威:结果不能由客户端说了算

任何会影响资产、排名、进度结算的数据,必须由服务端计算或校验。客户端可以上报输入,不能上报结果。常见的错误包括:客户端计算伤害后上报、客户端决定掉落、客户端提交任务完成状态。服务端权威不是“所有逻辑放服务端”。表现层、输入预测、部分非关键动画可以放在客户端,以降低延迟;但结算入口必须在服务端。判断标准很简单:这个值能不能直接换成金币、道具、排名或活动奖励?能,就必须权威。

接口限频:让批量操作付出时间成本

限频的目的不是防住所有请求,而是让脚本的吞吐低于人工。频率阈值要按“玩家 + 接口 + 场景”维度设置,而不是全局限流。全球限流会让正常玩家在活动高峰被误伤,黑产却能换接口继续刷。限频还要有成本梯度:普通查询便宜,写操作贵,涉及资产的接口最贵。令牌桶适合这种场景,容量决定突发容忍,补充速率决定持续吞吐。被限流时返回明确的等待时间,客户端才能做退避,而不是盲目重试放大压力。

奖励延迟:把发放和资格判定分开

活动奖励、排名奖励、交易所得不应该在事件发生时立即到账。延迟发放给风控留出观察窗口,也给最终校验留出时间。延迟期內发现异常,可以冻结或作废;已经发放的奖励再追回,成本和舆情都高得多。延迟不是越长越好。延迟越长,玩家体验越差,客服解释成本越高。按资产价值分级:小额即时或短延迟,大额和可交易资产延迟更久。延迟期要对外可见,例如“奖励将在 24 小时内发放”,而不是让玩家猜测。

权威等级与资产分级

把“服务端权威”做成一个开关不够用。实际系统里存在三档:

权威等级含义适用资产
客户端声明客户端上报,服务端只做格式校验外观预览、非结算性展示
服务端校验服务端检查合法性,但值来自客户端输入移动、技能释放、部分进度
服务端权威服务端计算并落库,客户端只收结果货币、道具、排名、活动奖励

资产分级决定权威门槛。外观类可以接受客户端声明;进度类至少服务端校验;货币、可交易道具、排名和活动奖励必须服务端权威。这个矩阵要写进接口规范,而不是靠开发自觉。另一个容易漏的点是幂等。涉及资产的接口必须带幂等键,重试不能重复发放。限频只能降低请求频率,不能防止重放;幂等键和唯一约束才是最后一道闸。命令层的幂等模型见3.2 GM 命令的幂等与补偿 。

限频不是越低越好

限频过低会把正常玩家挡在门外。活动开放瞬间,玩家会集中领取、集中匹配、集中交易;如果阈值按平峰设计,峰值就会大面积误伤。阈值应该按场景分开:

场景示例容量示例补充速率说明
登录5 次1 次/10 秒防撞库,允许重试
匹配10 次1 次/5 秒防脚本排队
交易行查询30 次5 次/秒读多写少
交易行成交5 次1 次/10 秒写操作,成本高
奖励领取3 次1 次/30 秒防批量小号

这些是示例值,必须用真实峰值压测校准。校准方法是看正常玩家的 P99 请求量,把容量设在 P99 之上,再用补充速率限制持续吞吐。不要用平均值,平均值会盖住活动峰值。限频的另一个误区是只按 IP。NAT 和校园网会让大量正常玩家共享 IP,按 IP 限频会集体误伤;黑产也能用代理池绕过。按账号、设备、接口组合限频,IP 只作为辅助信号。被限频的请求要记录原因和重试时间,否则客服无法解释“为什么我点不了”。

奖励延迟与最终校验

奖励延迟的实现要点是资格快照和发放时复核分开:

  1. 事件发生时记录资格快照:谁、因为什么、应得多少、依据哪条规则。
  2. 奖励进入待发放队列,状态为 pending。
  3. 延迟期结束后,发放前再查一次账号状态:是否被封禁、是否被标记、资格是否仍然成立。
  4. 通过则发放并记录幂等键;不通过则作废并记录原因。

这里有个重要边界:已经发放的奖励不能静默作废。如果发放后才发现异常,要走追缴流程,通知玩家并保留申诉入口;静默扣回会引发更大的信任问题。延迟期的价值就是把大部分异常拦在发放之前。延迟期还要防止“卡点”攻击:黑产知道 24 小时后发放,就在第 23 小时转移资产。对策是发放时校验资产流向和账号关联,而不是只看时间。对高价值奖励,可以在延迟期内分段检查,而不是等到最后一刻。

让收益低于成本

对抗的终点是经济账。下面是一张示例估算表,用来说明决策逻辑,不是真实账单:

成本项黑产单账号成本(示例值)提高成本的手段
账号获取2 元实名、设备绑定、注册冷却
设备与网络3 元/月设备指纹、代理检测
脚本维护5 元/月客户端完整性、行为检测
被封损失8 元/次提高检测率、追缴非法所得
时间成本2 小时/批限频、延迟发放、任务链加长

如果单账号预期收益是 30 元,总成本是 20 元,黑产仍然会做;把检测率提高、追缴比例提高、账号成本提高之后,总成本超过 30 元,供给才会下降。这里的数字必须用自己的数据重新测算,不能照抄。经济账还有一层:不要让防守成本高于黑产成本。如果为了防住一个小众玩法投入了大量人工复核,而黑产的实际损失很小,这笔投入就不划算。边界设计要优先保护高价值、可交易、可规模化的资产。

数据模型与可运行片段

internal/anticheatboundary 实现三件事:Check 按资产规则校验权威等级、幂等键与限频,Bucket 用令牌桶控制持续吞吐,Deliver 在延迟期结束后做最终校验并保证发放幂等。

// file: internal/anticheatboundary/anticheatboundary.go
// tier: L1
// verify: go test ./internal/anticheatboundary/
package anticheatboundary

import (
	"errors"
	"fmt"
	"math"
	"time"
)

// Authority says who produced the value the server is about to act on: a
// client claim, a server-side validation, or a fully authoritative server
// computation. Cheaper assets may accept validation; transferable assets
// require authority.
type Authority string

const (
	AuthorityClient        Authority = "client_claimed"
	AuthorityValidated     Authority = "server_validated"
	AuthorityAuthoritative Authority = "server_authoritative"
)

func authorityRank(a Authority) (int, bool) {
	switch a {
	case AuthorityClient:
		return 0, true
	case AuthorityValidated:
		return 1, true
	case AuthorityAuthoritative:
		return 2, true
	}
	return 0, false
}

// Asset classifies what an action can change. The more valuable and
// transferable the asset, the higher the authority bar.
type Asset string

const (
	AssetCosmetic Asset = "cosmetic"
	AssetProgress Asset = "progress"
	AssetItem     Asset = "item"
	AssetCurrency Asset = "currency"
	AssetRanking  Asset = "ranking"
)

func assetKnown(a Asset) bool {
	switch a {
	case AssetCosmetic, AssetProgress, AssetItem, AssetCurrency, AssetRanking:
		return true
	}
	return false
}

// Rule is the boundary for one asset: the minimum authority, how long a
// reward stays pending, and how many rate-limit tokens one action costs.
type Rule struct {
	MinAuthority Authority
	RewardDelay  time.Duration
	RateCost     int
}

// Policy maps assets to rules. An asset with no rule is denied, not defaulted.
type Policy struct {
	Rules map[Asset]Rule
}

// DefaultPolicy is the starting boundary for a live game: cosmetic and
// progress can be validated, while items, currency and ranking must be
// server-authoritative. Currency rewards wait before delivery.
func DefaultPolicy() Policy {
	return Policy{Rules: map[Asset]Rule{
		AssetCosmetic: {MinAuthority: AuthorityValidated, RateCost: 1},
		AssetProgress: {MinAuthority: AuthorityValidated, RateCost: 2},
		AssetItem:     {MinAuthority: AuthorityAuthoritative, RateCost: 3},
		AssetCurrency: {MinAuthority: AuthorityAuthoritative, RewardDelay: 10 * time.Minute, RateCost: 5},
		AssetRanking:  {MinAuthority: AuthorityAuthoritative, RateCost: 5},
	}}
}

// RuleFor fails closed: an unknown asset, a non-positive rate cost, or an
// unknown authority makes the whole rule unusable.
func (p Policy) RuleFor(a Asset) (Rule, error) {
	r, ok := p.Rules[a]
	if !ok {
		return Rule{}, fmt.Errorf("anticheatboundary: no rule for asset %q", a)
	}
	if _, ok := authorityRank(r.MinAuthority); !ok {
		return Rule{}, fmt.Errorf("anticheatboundary: unknown authority %q", r.MinAuthority)
	}
	if r.RateCost <= 0 {
		return Rule{}, errors.New("anticheatboundary: rate cost must be positive")
	}
	if r.RewardDelay < 0 {
		return Rule{}, errors.New("anticheatboundary: reward delay must not be negative")
	}
	return r, nil
}

// Action is a request that would change an asset.
type Action struct {
	PlayerID       string
	Asset          Asset
	Authority      Authority
	IdempotencyKey string
	At             time.Time
}

func needsIdempotency(a Asset) bool {
	return a == AssetItem || a == AssetCurrency || a == AssetRanking
}

// Decision is the boundary result. RetryAfter is set only for rate limiting;
// RewardDelay is advisory and tells the caller how long to hold the payout.
type Decision struct {
	Allowed     bool
	Reason      string
	RetryAfter  time.Duration
	RewardDelay time.Duration
}

// Check applies the boundary in a fixed order: rule, identity, authority,
// idempotency, then rate limit. The order matters because a denied request
// must not consume a token.
func Check(p Policy, a Action, bucket *Bucket, now time.Time) Decision {
	r, err := p.RuleFor(a.Asset)
	if err != nil {
		return Decision{Reason: err.Error()}
	}
	if a.PlayerID == "" {
		return Decision{Reason: "anticheatboundary: player id is required"}
	}
	got, ok := authorityRank(a.Authority)
	if !ok {
		return Decision{Reason: "anticheatboundary: unknown authority"}
	}
	want, _ := authorityRank(r.MinAuthority)
	if got < want {
		return Decision{Reason: fmt.Sprintf("anticheatboundary: %s requires %s", a.Asset, r.MinAuthority)}
	}
	if needsIdempotency(a.Asset) && a.IdempotencyKey == "" {
		return Decision{Reason: "anticheatboundary: idempotency key is required for a transferable asset"}
	}
	if bucket != nil {
		ok, retry := bucket.Allow(now, float64(r.RateCost))
		if !ok {
			return Decision{Reason: "anticheatboundary: rate limited", RetryAfter: retry}
		}
	}
	return Decision{Allowed: true, Reason: "within boundary", RewardDelay: r.RewardDelay}
}

// Bucket is a token bucket for one key, such as a player and endpoint pair.
// Tokens refill continuously; a cost above capacity can never succeed.
type Bucket struct {
	capacity        float64
	refillPerSecond float64
	tokens          float64
	last            time.Time
}

// NewBucket creates a full bucket. Zero refill is allowed and means the
// bucket only recovers when the policy or process restarts.
func NewBucket(capacity, refillPerSecond float64, now time.Time) (*Bucket, error) {
	if capacity <= 0 {
		return nil, errors.New("anticheatboundary: bucket capacity must be positive")
	}
	if refillPerSecond < 0 {
		return nil, errors.New("anticheatboundary: refill rate must not be negative")
	}
	return &Bucket{capacity: capacity, refillPerSecond: refillPerSecond, tokens: capacity, last: now}, nil
}

// Allow consumes cost tokens. When it fails, RetryAfter is the whole-second
// wait until enough tokens accumulate; zero means the request can never pass
// with the current bucket.
func (b *Bucket) Allow(now time.Time, cost float64) (bool, time.Duration) {
	if b == nil || cost <= 0 || cost > b.capacity {
		return false, 0
	}
	if now.Before(b.last) {
		now = b.last
	}
	if b.refillPerSecond > 0 {
		b.tokens += now.Sub(b.last).Seconds() * b.refillPerSecond
		if b.tokens > b.capacity {
			b.tokens = b.capacity
		}
	}
	b.last = now
	if b.tokens >= cost {
		b.tokens -= cost
		return true, 0
	}
	if b.refillPerSecond <= 0 {
		return false, 0
	}
	missing := cost - b.tokens
	seconds := math.Ceil(missing / b.refillPerSecond)
	return false, time.Duration(seconds) * time.Second
}

// Reward is a payout that is earned immediately but delivered later, after a
// delay and a final account check. Voided rewards are never paid.
type Reward struct {
	ID          string
	PlayerID    string
	Asset       Asset
	Amount      int64
	EarnedAt    time.Time
	Delay       time.Duration
	Voided      bool
	DeliveredAt time.Time
}

// NewReward validates the payout before it enters the delayed queue.
func NewReward(id, playerID string, asset Asset, amount int64, now time.Time, delay time.Duration) (Reward, error) {
	switch {
	case id == "":
		return Reward{}, errors.New("anticheatboundary: reward id is required")
	case playerID == "":
		return Reward{}, errors.New("anticheatboundary: reward player is required")
	case !assetKnown(asset):
		return Reward{}, fmt.Errorf("anticheatboundary: unknown reward asset %q", asset)
	case amount <= 0:
		return Reward{}, errors.New("anticheatboundary: reward amount must be positive")
	case delay < 0:
		return Reward{}, errors.New("anticheatboundary: reward delay must not be negative")
	}
	return Reward{ID: id, PlayerID: playerID, Asset: asset, Amount: amount, EarnedAt: now, Delay: delay}, nil
}

// DeliverOutcome is the result of one delivery attempt.
type DeliverOutcome string

const (
	DeliverPending DeliverOutcome = "pending"
	DeliverPaid    DeliverOutcome = "paid"
	DeliverVoided  DeliverOutcome = "voided"
)

// Deliver pays a reward only after its delay and only if the account is not
// flagged. Delivery is idempotent: a second successful call returns Paid
// without paying again.
func Deliver(r Reward, now time.Time, flagged bool) (Reward, DeliverOutcome) {
	if !r.DeliveredAt.IsZero() {
		return r, DeliverPaid
	}
	if r.Voided {
		return r, DeliverVoided
	}
	if flagged {
		r.Voided = true
		return r, DeliverVoided
	}
	if now.Before(r.EarnedAt.Add(r.Delay)) {
		return r, DeliverPending
	}
	r.DeliveredAt = now
	return r, DeliverPaid
}

本机验证命令与结果:

cd .local/checks/gsarch-lab
go test -count=1 ./internal/anticheatboundary/
ok  gsarchlab/internal/anticheatboundary

该包共有 6 个表驱动测试,覆盖规则查找、权威分级、幂等要求、令牌桶补充与拒绝、奖励参数校验、延迟发放与作废;gofmt -l 无输出。

边界评审检查表

  • 影响货币、道具、排名、活动奖励的结算入口,是否全部服务端权威?
  • 客户端上报的字段里,有没有被直接当成结果使用的?
  • 涉及资产的接口是否要求幂等键?重放会不会重复发放?
  • 限频是否按账号、设备、接口、场景分维度?阈值是否用峰值校准?
  • 被限流时是否返回明确的重试时间?客服能否解释?
  • 高价值奖励是否延迟发放?延迟期是否做了资格与账号状态复核?
  • 已发放奖励的追缴是否有通知和申诉入口?
  • 每条边界的成本是否低于它保护的资产价值?

小结

黑产看的是收益,不是胜负。对抗的架构边界有三条:结算必须服务端权威,批量接口必须限频,高价值奖励必须延迟发放并做最终校验。权威要分级,限频要分场景,延迟要配合资格快照和发放复核。

边界不是越严越好。过严会误伤正常玩家,也会让防守成本超过损失。正确的目标是让黑产的单位时间收益低于成本,同时把误伤控制在可申诉、可回滚的范围内。信号怎么采、误伤怎么控见4.1 信号采集与误伤控制 ,处置与申诉见4.2 处置、申诉与复核 。

阅读导航:上一节:4.2 处置、申诉与复核 · 下一节:5.1 日志、指标与链路追踪 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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