《游戏服务端架构·核心系统与契约》5.2 结算契约与防重复发奖

战斗结束后的奖励发放,最容易出的问题不是算错,而是发两次。本节从 battle_id、参战者、结果和奖励表版本四类输入出发,对比先落结算单与先发奖两种顺序,给出幂等键、唯一约束、状态机、改判权限和冲正流程,并用可运行代码演示重复请求、部分失败与二次冲正的处理。

一笔奖励为什么会被发两次

《裂隙战场》的一次对局结束后,结算服务收到三份几乎相同的请求:战斗进程正常上报一次;客户端因为没收到回执,重连后又触发一次补报;网关在超时重试时又转发了一次。如果结算逻辑是“收到请求就发奖”,这名玩家会拿到三份奖励。

更隐蔽的重复来自故障恢复。结算服务写完结算单、发完奖励、还没来得及把状态改成“已结算”时进程被杀;重启后任务重新投递,系统看到的是“结算单存在但状态未完成”,于是再发一次。重复不是异常路径,而是重试机制的必然结果。

解决重复不能靠“客户端不要重试”,也不能靠“网关保证只投一次”。分布式系统里能保证的只有一件事:同一个幂等键执行多次,结果与执行一次相同。结算契约要围绕这个事实设计。

结算输入:四件事必须先固定

输入作用不固定的后果
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 战斗日志、回放与审计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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