一笔奖励为什么会被发两次
《裂隙战场》的一次对局结束后,结算服务收到三份几乎相同的请求:战斗进程正常上报一次;客户端因为没收到回执,重连后又触发一次补报;网关在超时重试时又转发了一次。如果结算逻辑是“收到请求就发奖”,这名玩家会拿到三份奖励。
更隐蔽的重复来自故障恢复。结算服务写完结算单、发完奖励、还没来得及把状态改成“已结算”时进程被杀;重启后任务重新投递,系统看到的是“结算单存在但状态未完成”,于是再发一次。重复不是异常路径,而是重试机制的必然结果。
解决重复不能靠“客户端不要重试”,也不能靠“网关保证只投一次”。分布式系统里能保证的只有一件事:同一个幂等键执行多次,结果与执行一次相同。结算契约要围绕这个事实设计。
结算输入:四件事必须先固定
| 输入 | 作用 | 不固定的后果 |
|---|---|---|
battle_id | 结算单主键与幂等键的一部分 | 同一场战斗生成多张结算单 |
| 参战者列表 | 决定谁有资格拿奖励 | 掉线玩家被漏发,观战者被误发 |
| 结果与胜方 | 决定奖励档位 | 改判后无法判断哪一版是权威 |
| 奖励表版本 | 决定金额与掉落 | 回滚配置后重算,金额与已发奖励不一致 |
四类输入里,battle_id 必须由战斗进程生成并全局唯一;参战者列表必须来自战斗开始时的快照,而不是结束时的在线列表;结果要明确胜方或平局;奖励表版本必须随结算单一起落库。只存结果、不存规则版本,是结算系统最常见的技术债。
幂等键:让重试变成同一个请求
| 方案 | 键 | 优点 | 问题 |
|---|---|---|---|
| 请求 ID | 客户端或网关生成 | 能去重单次请求 | 重连后生成新 ID,仍会重复发奖 |
| 战斗 + 玩家 | battle_id:player_id | 简单,天然覆盖重试 | 改判后无法区分新旧奖励 |
| 战斗 + 玩家 + 规则版本 | battle_id:rule_version:player_id | 改判与重试都能区分 | 键略长,需要版本管理纪律 |
| 战斗 + 玩家 + 发放批次 | 每次结算生成批次号 | 支持多次补发 | 批次号本身又要防重 |
北辰工作室选第三种:battle_id 加奖励表版本加玩家 ID。重试时键不变,发放侧天然幂等;改判后如果规则版本变化,新键与旧键不同,必须先冲正旧奖励再发新奖励,不能直接覆盖。数据库侧再加一条唯一约束 (battle_id, rule_version, player_id),让“代码忘了判重”也发不出去。
先落单还是先发奖
| 顺序 | 过程 | 崩溃窗口 | 结果 |
|---|---|---|---|
| 先发奖后落单 | 发奖成功后写结算单 | 发奖后、落单前崩溃 | 奖励已发但无记录,重试再发一次 |
| 先落单后发奖 | 写结算单,再逐笔发奖 | 落单后、发奖前崩溃 | 结算单存在但未完成,重试补发 |
| 落单与发奖同事务 | 一个数据库事务包住两边 | 跨库时无法保证 | 只适合奖励与结算同库 |
正确顺序是先写结算单,再发奖励,最后把结算单标记为已结算。崩溃发生在发奖中途时,结算单仍是“已校验”,重试会重新执行发奖;由于每笔奖励的键稳定,发放侧会把已发的识别为重复,只补发缺失的部分。这个方案不要求分布式事务,只要求发放接口幂等。
状态机与改判权限
received --validate--> validated --settle--> settled --reverse--> reversed
| | |
+------- override ------+ +--- 二次冲正:拒绝
| 操作 | 允许状态 | 最低权限 | 副作用 |
|---|---|---|---|
| 校验 | received | 结算服务 | 结果形状检查,不改奖励 |
| 结算 | validated | 结算服务 | 逐笔发放,成功后进入 settled |
| 冲正 | settled | 客服主管 | 逐笔撤销,记录原因,进入 reversed |
| 改判 | received / validated | 运营值班经理 | 版本加一,回到 received,重新校验 |
| 二次冲正 | reversed | 不允许 | 返回明确错误,要求人工对账 |
改判的难点不是权限,而是旧奖励已经发出去。如果改判发生在结算前,直接覆盖结果即可;如果发生在结算后,必须先冲正再按新结果重新结算。代码里把 reversed 设为终态,二次冲正直接拒绝,避免同一笔奖励被反复加减。
可运行片段:状态机、幂等发奖与冲正
下面的 internal/settlecontract 把结算单状态、幂等发放、冲正和改判写成可测试的包。发放侧只依赖一个 Ledger 接口,数据库事务、消息队列或内存账本都可以实现它。
// file: internal/settlecontract/settlecontract.go
// tier: L1
// verify: go test ./internal/settlecontract/
package settlecontract
import (
"errors"
"fmt"
)
type State string
const (
Received State = "received"
Validated State = "validated"
Settled State = "settled"
Reversed State = "reversed"
)
type Outcome string
const (
OutcomeWin Outcome = "win"
OutcomeDraw Outcome = "draw"
OutcomeDisconnect Outcome = "disconnect"
OutcomeFlee Outcome = "flee"
OutcomeAbnormal Outcome = "abnormal"
)
var (
ErrInvalidSettlement = errors.New("settlecontract: invalid settlement")
ErrBadTransition = errors.New("settlecontract: illegal transition")
ErrRuleVersion = errors.New("settlecontract: rule version mismatch")
ErrUnauthorized = errors.New("settlecontract: authority too low")
ErrReasonRequired = errors.New("settlecontract: reason required")
ErrAlreadyReversed = errors.New("settlecontract: already reversed")
)
type Authority int
const (
Agent Authority = iota
SupportLead
OpsManager
)
type Participant struct {
PlayerID string
Team int
Score int
Connected bool
}
type Reward struct {
Key string
PlayerID string
Amount int
}
type Settlement struct {
BattleID string
RuleVersion int
Outcome Outcome
WinningTeam int
Participants []Participant
State State
Revision int
Rewards []Reward
ReverseWhy string
}
// NewSettlement creates a Received document. WinningTeam is ignored unless the
// outcome is a win, so a caller cannot smuggle a winner into a draw.
func NewSettlement(battleID string, ruleVersion int, outcome Outcome, winningTeam int, ps []Participant) *Settlement {
s := &Settlement{BattleID: battleID, RuleVersion: ruleVersion, Outcome: outcome,
WinningTeam: winningTeam, Participants: append([]Participant(nil), ps...), State: Received}
if outcome != OutcomeWin {
s.WinningTeam = -1
}
return s
}
// Validate checks the result shape and moves Received to Validated.
func (s *Settlement) Validate() error {
if s.State == Reversed {
return ErrBadTransition
}
if s.BattleID == "" || s.RuleVersion <= 0 || len(s.Participants) < 2 {
return ErrInvalidSettlement
}
seen, best := map[string]bool{}, map[int]int{}
for _, p := range s.Participants {
if p.PlayerID == "" || seen[p.PlayerID] {
return ErrInvalidSettlement
}
seen[p.PlayerID] = true
if p.Score > best[p.Team] {
best[p.Team] = p.Score
}
}
switch s.Outcome {
case OutcomeWin:
top, ok := best[s.WinningTeam]
if !ok {
return ErrInvalidSettlement
}
for team, score := range best {
if team != s.WinningTeam && score >= top {
return ErrInvalidSettlement
}
}
case OutcomeDraw:
top, tied := -1, 0
for _, score := range best {
if score > top {
top, tied = score, 1
} else if score == top {
tied++
}
}
if len(best) < 2 || tied < 2 {
return ErrInvalidSettlement
}
case OutcomeDisconnect, OutcomeFlee:
offline := false
for _, p := range s.Participants {
offline = offline || !p.Connected
}
if !offline {
return ErrInvalidSettlement
}
case OutcomeAbnormal:
default:
return ErrInvalidSettlement
}
s.State = Validated
return nil
}
type RewardTable struct {
Version int
Winner int
Loser int
Draw int
Abnormal int
}
// settle applies one reward operation to every grant, deduplicating through
// the ledger on retry.
func apply(rewards []Reward, op func(Reward) (bool, error)) (ApplyResult, error) {
var r ApplyResult
for _, w := range rewards {
fresh, err := op(w)
if err != nil {
return r, err
}
if fresh {
r.Granted++
} else {
r.Duplicates++
}
}
return r, nil
}
type Ledger interface {
Grant(key, playerID string, amount int) (bool, error)
Revoke(key, playerID string, amount int) (bool, error)
}
type ApplyResult struct {
Granted int
Duplicates int
}
// Settle plans rewards from the versioned table and grants them. The document
// stays Validated until every grant succeeds; a retry re-sends the same keys.
func (s *Settlement) Settle(l Ledger, table RewardTable) (ApplyResult, error) {
if s.State == Settled {
return ApplyResult{Duplicates: len(s.Rewards)}, nil
}
if s.State != Validated {
return ApplyResult{}, fmt.Errorf("%w: %s -> settled", ErrBadTransition, s.State)
}
if table.Version != s.RuleVersion {
return ApplyResult{}, ErrRuleVersion
}
if len(s.Rewards) == 0 {
for _, p := range s.Participants {
amount := table.Abnormal
switch {
case s.Outcome == OutcomeWin && p.Team == s.WinningTeam:
amount = table.Winner
case s.Outcome == OutcomeWin:
amount = table.Loser
case s.Outcome == OutcomeDraw:
amount = table.Draw
}
key := fmt.Sprintf("battle:%s:r%d:%s", s.BattleID, s.RuleVersion, p.PlayerID)
s.Rewards = append(s.Rewards, Reward{key, p.PlayerID, amount})
}
}
r, err := apply(s.Rewards, func(w Reward) (bool, error) {
return l.Grant(w.Key, w.PlayerID, w.Amount)
})
if err == nil {
s.State = Settled
}
return r, err
}
// Reverse revokes all grants after review. A second reversal is rejected.
func (s *Settlement) Reverse(l Ledger, by Authority, reason string) (ApplyResult, error) {
if s.State == Reversed {
return ApplyResult{}, ErrAlreadyReversed
}
if s.State != Settled {
return ApplyResult{}, fmt.Errorf("%w: %s -> reversed", ErrBadTransition, s.State)
}
if by < SupportLead {
return ApplyResult{}, ErrUnauthorized
}
if reason == "" {
return ApplyResult{}, ErrReasonRequired
}
r, err := apply(s.Rewards, func(w Reward) (bool, error) {
return l.Revoke(w.Key, w.PlayerID, w.Amount)
})
if err == nil {
s.State, s.ReverseWhy = Reversed, reason
}
return r, err
}
// Override changes the outcome before settlement and reopens validation.
func (s *Settlement) Override(by Authority, outcome Outcome, winningTeam int, reason string) error {
if by < OpsManager {
return ErrUnauthorized
}
if reason == "" {
return ErrReasonRequired
}
if s.State != Received && s.State != Validated {
return fmt.Errorf("%w: %s -> override", ErrBadTransition, s.State)
}
if outcome != OutcomeWin {
winningTeam = -1
}
s.Outcome, s.WinningTeam = outcome, winningTeam
s.Revision++
s.State = Received
s.Rewards = nil
return nil
}
几个实现取舍:
Settle在全部奖励发放成功后才把状态改为settled。中途失败时状态仍是validated,重试会重新走一遍;已发的奖励由账本按键去重,返回Duplicates。- 幂等键由
battle_id、规则版本和玩家 ID 组成,不含时间戳或随机数。重试、主备切换、消息重投都不会改变键。 Reverse只允许从settled进入,并且要求客服主管权限与非空原因。二次冲正返回ErrAlreadyReversed,而不是静默成功。Override只允许在结算前改判,版本号加一并回到received。这样审计日志能回答“哪一版结果被哪个角色改过”。
配套的五条表驱动测试覆盖结果形状、重复结算、部分发奖失败后的续传、冲正权限与二次冲正、改判窗口。验证命令与结果如下。
本机验证
$ cd .local/checks/gsarch-lab
$ gofmt -l internal/settlecontract
$ go vet ./internal/settlecontract/...
$ go test -count=1 ./internal/settlecontract/
ok gsarchlab/internal/settlecontract
gofmt -l 与 go vet 均无输出。测试里的假账本会在第三笔发奖时注入失败,用来验证状态停留在 validated、重试只补发剩余两笔且不重复记账。
失败模式与兜底
| 失败 | 表现 | 兜底 |
|---|---|---|
| 客户端重复上报 | 同一场战斗多次触发结算 | 结算单以 battle_id 为主键,重复请求直接返回已有结果 |
| 发奖中途崩溃 | 部分玩家到账、部分未到账 | 状态保持 validated,重试按幂等键补发 |
| 奖励表热更新 | 新旧版本金额混用 | 结算单记录规则版本,重算必须使用同一版本 |
| 改判后旧奖励未撤 | 玩家同时拿到胜方与负方奖励 | 先冲正旧结算,再按新结果重新结算 |
| 二次冲正 | 奖励被反复加减 | reversed 设为终态,返回错误并转人工对账 |
| 掉线玩家漏发 | 参战者列表取结束时的在线列表 | 用战斗开始快照作为参战者来源 |
最容易被忽略的是最后一项。参战者列表如果在结算时重新查在线状态,掉线玩家会被静默排除;正确做法是在战斗开始时冻结参战者快照,结算只读快照。
常见错误
第一,用请求 ID 做幂等键。请求 ID 只能去重一次投递,不能去重“同一场战斗的多次结算”。
第二,先发奖后落单。发奖成功、落单失败时,系统没有任何依据判断奖励是否已经发过。
第三,把改判当成更新结算单。改判会改变奖励档位,必须先冲正旧奖励,再按新版本重新结算。
第四,冲正不记录原因和操作者。客服申诉时,日志必须能回答“谁在何时因为什么撤销了这笔奖励”。
第五,在结算服务里直接操作玩家背包。发放应通过幂等的账本接口,否则换存储或加风控时要重写结算逻辑。
小结
结算契约的核心不是奖励公式,而是三件事:输入固定、幂等键稳定、状态机不允许跳跃。先落结算单再发奖,用 battle_id 加规则版本加玩家 ID 做幂等键,把冲正和改判做成显式状态迁移,重复请求和崩溃重试就都变成了可处理的分支。
下一节往证据层走:结算争议、作弊复核和经济异常都需要日志支撑。战斗日志要记什么、回放依赖什么、审计查询怎么回答“谁在何时改了什么”,是 5.3 的主题。
阅读导航:上一节:5.1 战斗服模型:房间内、旁路与托管 · 下一节:5.3 战斗日志、回放与审计 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。