三次点击,两份奖励
运营在 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 只是把来源写进命令,仍然复用原键,因此跨服转发不会制造第二个幂等命名空间。
失败路径与检查表
| 失败路径 | 典型表现 | 处理 |
|---|---|---|
| 页面重复提交 | 同一命令到达两次 | 调用方复用幂等键 |
| 网关超时重试 | 服务端收到重复请求 | 账本返回首次回执 |
| 跨服双写 | 两个服各生效一次 | 目标归属服判定,或共享账本 |
| 键冲突 | 同键不同目标或数量 | 返回冲突,不当作重复 |
| 补偿重复 | 扣了两次 | 补偿键幂等,返回首次结果 |
| 余额不足 | 扣成负数或扣到一半 | 扣到零,记短款并挂起 |
| 账本丢失 | 无法判断是否执行过 | 从事件日志重建,禁止直接清空 |
上线前至少确认六件事:
- 每条命令都有调用方生成、重试不变的幂等键。
- 同一目标的所有写命令在同一个判定点串行化。
- 跨服转发复用原键,并记录转发来源。
- 同键不同载荷返回冲突,而不是静默命中。
- 补偿有独立键,重复补偿返回首次结果。
- 余额不会被扣成负数,短款有记录、有复核、有时限。
小结
幂等不是“加一个去重表”就结束,它要求命令模型把意图写全、判定点唯一、跨服路径不产生第二套命名空间。补偿也不是“反向发一次”,它要接受“扣不动”这个现实,把短款变成可复核的状态,而不是负数或封号。把这两件事分开设计,GM 命令才能在重复、超时和转发面前保持一次生效。
下一节换一个视角:客服拿到的不是命令,而是工单。工单如何从描述变成脚本、脚本如何评审和灰度执行、执行后拿什么证明修好了,是数据修复流程要解决的问题。
阅读导航:上一节:3.1 权限、审批与双人复核 · 下一节:3.3 客服工具与数据修复流程 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。