《游戏服务端架构·平台化与架构治理》3.2 GM 命令的幂等与补偿

运营点了一次补发,页面转圈又点一次,玩家收到两份奖励;跨服转发再超时,第三份也可能落地。本文拆解 GM 命令的字段、幂等键的作用域与冲突判定,讨论重复点击、网关重试和跨服转发三条路径,并给出 internal/gmcommand 的执行账本、补偿计划与短款处理边界。

三次点击,两份奖励

运营在 GM 后台点了一次“补发 500 钻石”,页面一直转圈。他以为没提交成功,又点了一次。第一次请求其实已经写入,第二次请求被网关当成超时重试,玩家的余额增加了 1000。更麻烦的是跨服:请求从 A 服转到 B 服,B 服处理完但回包丢失,A 服又转了一次。

这不是“运营手滑”的问题。只要网络会超时、页面会重复提交、服务会转发,同一条命令就可能到达多次。服务端不能要求调用方“保证只发一次”,只能要求自己“收到多次也只生效一次”。这就是幂等键存在的理由。

补偿是幂等的另一面。发多了要扣,扣的时候可能余额不够;余额不够时,是把余额扣成负数、直接封号,还是记一笔短款等人处理?这个边界如果不在设计时定清楚,事故现场就会由值班同学临场发挥。

命令模型:把意图写全

一条 GM 命令至少要回答六个问题:谁发的、为哪张工单、对谁、做什么、做多少、这次请求的唯一标识是什么。少一个字段,事后就无法判断两次请求是“同一个意图的重试”还是“两个不同的意图”。

字段作用常见错误
command_id命令实例 ID每次重试重新生成,导致无法关联
idempotency_key幂等键,调用方生成并保持不变用时间戳或随机数,每次重试都变
target目标玩家或角色只写昵称,重名时打错人
action动作名用界面按钮名,改名后历史记录对不上
params数量、物品 ID、原因等把参数拼进字符串,顺序一变就误判冲突
issuer发起人只记服务账号,查不到具体的人
ticket工单号允许为空,事后无法解释为什么发
forwarded_from转发来源跨服时覆盖原值,丢失链路

幂等键由调用方生成,通常是一个 UUID 或 ULID,在第一次提交时确定,之后所有重试、重发、跨服转发都复用它。不要把“目标 + 动作 + 数量”直接当键:同一个玩家今天和明天各领一次 500 钻石,是两个合法意图,不应该被当成重复。

键的作用域要明确。代码里的账本以键为全局索引:同一个键再次出现,必须对应完全相同的命令;目标、动作、数量、命令 ID 任一不同,都返回冲突而不是重复。冲突是配置或调用方错误,不能悄悄当成幂等命中。

幂等的三条路径

重复请求通常从三个地方进来,处理方式不同,但判定点相同。

路径场景正确做法
重复点击页面卡顿,运营连点前端禁用按钮只是辅助,服务端按同一键去重
网关重试回包超时,网关自动重发重试必须带原键,不能生成新键
跨服转发A 服转到 B 服,B 服回包丢失命令路由到目标所在服,转发不创建新键

跨服是最容易出问题的一条。如果 A 服和 B 服各有一本账,同一条命令在两边各生效一次,账本都觉得自己是对的。解决办法不是“转发时再生成一个键”,而是让同一个目标始终由同一个归属服处理:A 服只负责转发,B 服用自己的账本判定;或者所有服共享一个集中账本。两种方案都要保证“判定键”和“执行点”在同一处。

“至少一次投递 + 幂等执行”才能得到事实上的“恰好一次”。消息队列、RPC 重试、网关超时都只能保证至少一次;服务端如果只做“先查再写”,两个并发请求可能同时查到“没做过”,然后各写一次。账本要用唯一键做原子插入,或者用单线程分区串行化同一目标的命令。

补偿:扣得动与扣不动

补偿不是“再发一条反向命令”这么简单。发多了 500 钻石,玩家可能已经花掉 200;此时从当前余额扣 500,会把余额扣成负数,或者扣到一半失败。补偿要回答三个问题:扣多少、扣不动怎么办、谁来决定后续处置。

情况处理记录
余额足够扣回请求金额补偿键、来源命令、扣回金额
余额不足扣到零,剩余记短款短款金额、需要人工复核
已消费不追成负数债务记录、限制交易或提现
重复补偿返回第一次结果补偿键幂等

代码里的 Compensate 以来源命令的键生成补偿键 cmp:<sourceKey>,重复调用返回第一次的补偿结果。它最多把余额扣到零,剩余部分放进 Shortfall 并置 NeedsHold。这样做的取舍是:宁可留下短款记录,也不制造负资产。

负资产会污染整个经济系统。排行榜、交易行、邮件附件、退款校验都假设余额非负;一个负数会引出更多特判。短款则是一个明确的状态:玩家当前没有可扣的资产,但账面上欠着一笔,需要运营、客服和财务共同决定是等待、限制还是核销。

封禁是另一个问题。短款本身不是封禁理由;封禁针对的是外挂、盗号、黑产或恶意利用,属于第 4 章的风控处置。把“扣不动”自动升级成“封号”,会让正常玩家因为一次误发被误伤,也会把经济问题和安全问题混在一起。

补偿还需要一条时间边界。发错了要尽快扣,因为玩家可能继续消费;拖得越久,可扣回的越少。示例值:发现后 2 小时内完成第一次补偿尝试,24 小时内完成人工复核。具体时限由工作室按经济节奏设定,但必须有,否则短款会无限累积。

代码:账本与补偿计划

internal/gmcommand 把“执行”和“补偿”分开:Apply 只处理发放与扣减,按幂等键做原子判定;Compensate 单独记账,绝不把余额扣成负数。先看完整实现。

// file: internal/gmcommand/command.go
// tier: L1
// verify: go test ./internal/gmcommand/
package gmcommand

import (
	"errors"
	"fmt"
)

// Action is the effect, not the button label. The ledger stores the effect so
// a replay can be compared without trusting the caller's serialization.
type Action string

const (
	ActionGrant      Action = "inventory.grant"
	ActionDeduct     Action = "inventory.deduct"
	ActionCompensate Action = "compensation.issue"
)

var (
	ErrBadCommand   = errors.New("gmcommand: invalid command")
	ErrKeyConflict  = errors.New("gmcommand: idempotency key reused")
	ErrInsufficient = errors.New("gmcommand: balance cannot cover deduction")
	ErrBadAction    = errors.New("gmcommand: unknown action")
	ErrNoSource     = errors.New("gmcommand: compensation needs a grant receipt")
)

// Command is the unit a GM tool, a ticket bot, or a cross-server forwarder
// sends. Key is chosen by the caller and must survive retries unchanged.
type Command struct {
	ID            string
	Key           string
	Target        string
	Action        Action
	Amount        int64
	Issuer        string
	Ticket        string
	ForwardedFrom string
}

// Validate rejects shape errors before they can consume an idempotency key.
// Keeping the ticket mandatory is deliberate: an untraceable grant is worse
// than a failed request.
func (c Command) Validate() error {
	if c.ID == "" || c.Key == "" || c.Target == "" || c.Issuer == "" || c.Ticket == "" {
		return ErrBadCommand
	}
	if c.Amount <= 0 {
		return ErrBadCommand
	}
	switch c.Action {
	case ActionGrant, ActionDeduct:
		return nil
	default:
		return ErrBadAction
	}
}

// Receipt is the durable answer to one key. A replay returns the stored
// receipt instead of applying the effect again.
type Receipt struct {
	Key          string
	CommandID    string
	Target       string
	Action       Action
	Amount       int64
	Applied      bool
	Duplicate    bool
	BalanceAfter int64
	Ticket       string
}

// Compensation explains how much of an over-grant was actually recovered.
// Shortfall is not silently turned into a negative balance; it becomes a debt
// record for a human decision.
type Compensation struct {
	Key       string
	SourceKey string
	Target    string
	Requested int64
	Deducted  int64
	Shortfall int64
	NeedsHold bool
	Replay    bool
}

// Ledger is an append-only receipt map plus the derived balance. It is small
// enough to embed in a service and strict enough to expose the failure paths.
type Ledger struct {
	receipts      map[string]Receipt
	compensations map[string]Compensation
	balances      map[string]int64
	order         []string
}

func NewLedger() *Ledger {
	return &Ledger{
		receipts:      map[string]Receipt{},
		compensations: map[string]Compensation{},
		balances:      map[string]int64{},
	}
}

// Apply executes a command at most once per key. A conflicting reuse of the
// key is an error, not a duplicate: two different intentions must not share
// one receipt.
func (l *Ledger) Apply(c Command) (Receipt, error) {
	if err := c.Validate(); err != nil {
		return Receipt{}, err
	}
	if prev, ok := l.receipts[c.Key]; ok {
		if prev.CommandID != c.ID || prev.Target != c.Target || prev.Action != c.Action || prev.Amount != c.Amount {
			return Receipt{}, ErrKeyConflict
		}
		prev.Duplicate = true
		prev.Applied = false
		return prev, nil
	}

	balance := l.balances[c.Target]
	switch c.Action {
	case ActionGrant:
		balance += c.Amount
	case ActionDeduct:
		if balance < c.Amount {
			return Receipt{}, fmt.Errorf("%w: need %d have %d", ErrInsufficient, c.Amount, balance)
		}
		balance -= c.Amount
	default:
		return Receipt{}, ErrBadAction
	}

	r := Receipt{
		Key:          c.Key,
		CommandID:    c.ID,
		Target:       c.Target,
		Action:       c.Action,
		Amount:       c.Amount,
		Applied:      true,
		BalanceAfter: balance,
		Ticket:       c.Ticket,
	}
	l.balances[c.Target] = balance
	l.receipts[c.Key] = r
	l.order = append(l.order, c.Key)
	return r, nil
}

// Forward is the cross-server entry point. The forwarding hop is recorded but
// does not create a second idempotency namespace: the original key still
// decides whether the effect happened.
func (l *Ledger) Forward(c Command, from string) (Receipt, error) {
	c.ForwardedFrom = from
	return l.Apply(c)
}

// Compensate reverses part or all of a grant. It never drives the balance
// below zero; the uncovered part is reported as Shortfall with NeedsHold set.
// Repeating the same source key returns the first compensation unchanged.
func (l *Ledger) Compensate(sourceKey string, requested int64) (Compensation, error) {
	if requested <= 0 {
		return Compensation{}, ErrBadCommand
	}
	source, ok := l.receipts[sourceKey]
	if !ok || source.Action != ActionGrant {
		return Compensation{}, ErrNoSource
	}
	key := "cmp:" + sourceKey
	if prev, ok := l.compensations[key]; ok {
		prev.Replay = true
		return prev, nil
	}

	balance := l.balances[source.Target]
	deducted := requested
	if deducted > balance {
		deducted = balance
	}
	shortfall := requested - deducted
	l.balances[source.Target] = balance - deducted

	comp := Compensation{
		Key:       key,
		SourceKey: sourceKey,
		Target:    source.Target,
		Requested: requested,
		Deducted:  deducted,
		Shortfall: shortfall,
		NeedsHold: shortfall > 0,
	}
	l.compensations[key] = comp
	l.order = append(l.order, key)
	return comp, nil
}

// Balance returns the current balance for a target.
func (l *Ledger) Balance(target string) int64 { return l.balances[target] }

// Receipt returns the stored receipt for a key and whether it exists.
func (l *Ledger) Receipt(key string) (Receipt, bool) {
	r, ok := l.receipts[key]
	return r, ok
}

// Order returns the keys in apply order. It is the evidence trail a reviewer
// reads when two operators claim they acted first.
func (l *Ledger) Order() []string {
	out := make([]string, len(l.order))
	copy(out, l.order)
	return out
}

账本里的 Order 保留命令生效顺序,是两个人争论“谁先操作”时的证据。Forward 只是把来源写进命令,仍然复用原键,因此跨服转发不会制造第二个幂等命名空间。

失败路径与检查表

失败路径典型表现处理
页面重复提交同一命令到达两次调用方复用幂等键
网关超时重试服务端收到重复请求账本返回首次回执
跨服双写两个服各生效一次目标归属服判定,或共享账本
键冲突同键不同目标或数量返回冲突,不当作重复
补偿重复扣了两次补偿键幂等,返回首次结果
余额不足扣成负数或扣到一半扣到零,记短款并挂起
账本丢失无法判断是否执行过从事件日志重建,禁止直接清空

上线前至少确认六件事:

  1. 每条命令都有调用方生成、重试不变的幂等键。
  2. 同一目标的所有写命令在同一个判定点串行化。
  3. 跨服转发复用原键,并记录转发来源。
  4. 同键不同载荷返回冲突,而不是静默命中。
  5. 补偿有独立键,重复补偿返回首次结果。
  6. 余额不会被扣成负数,短款有记录、有复核、有时限。

小结

幂等不是“加一个去重表”就结束,它要求命令模型把意图写全、判定点唯一、跨服路径不产生第二套命名空间。补偿也不是“反向发一次”,它要接受“扣不动”这个现实,把短款变成可复核的状态,而不是负数或封号。把这两件事分开设计,GM 命令才能在重复、超时和转发面前保持一次生效。

下一节换一个视角:客服拿到的不是命令,而是工单。工单如何从描述变成脚本、脚本如何评审和灰度执行、执行后拿什么证明修好了,是数据修复流程要解决的问题。

阅读导航:上一节:3.1 权限、审批与双人复核 · 下一节:3.3 客服工具与数据修复流程 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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