一、先给状态分级,再谈一致性
“状态要强一致”是一句没有边界的话。登录、扣款、发奖、排行榜、聊天记录对一致性的要求完全不同,把所有状态都塞进一个分布式事务,系统和团队都会先撑不住。工程上更可落地的做法是先按玩家可见性分级,再为每一级选择实现成本匹配的方案。
| 玩家可见状态 | 推荐口径 | 典型实现 | 降级后的后果 |
|---|---|---|---|
| 余额、背包、货币扣减 | 强一致 | 单行事务 + 版本 CAS | 重复扣款、负数、刷道具 |
| 订单、发货、邮件领取 | 强一致 + 幂等 | 唯一业务键 + 结果表 | 重复发货、重复领奖 |
| 好友申请、公会成员 | 读己之写 | 主库读或会话粘滞 | 刚加好友刷新后消失 |
| 排行榜、战力展示 | 最终一致 | 异步汇总、定时刷新 | 短时间名次偏差 |
| 聊天、动态、日志 | 最终一致 | 消息队列、批量落库 | 延迟可见,可接受 |
强一致并不等于“所有副本同时可见”,而是对同一份可写状态,任何成功的读都能看到最后一次成功的写。读己之写放宽了一步:玩家自己刚提交的修改必须立刻可见,其他玩家可以稍后看到。最终一致则允许任意延迟,只要求没有更新丢失。
游戏里最常见的错误,是把展示型状态也做成强一致。排行榜每秒重算一次全服数据,战斗服却要等排行榜事务提交;这类耦合带来的延迟和故障面,远比“名次晚三秒更新”严重。反过来,把货币扣减做成最终一致,则会出现玩家在两个服同时消费同一笔余额的漏洞。
二、版本号、CAS 与乐观锁
版本号是描述“这份状态被改过几次”的单调计数器,通常跟随聚合根存放,例如玩家资产、角色、订单各有一个 version。读取时把 version 一起带回,写入时要求数据库中的当前版本仍等于读到的版本,条件不成立就拒绝这次写。
SQL 里最直接的写法是:
UPDATE player_asset
SET gold = gold - 100, version = version + 1
WHERE player_id = ? AND version = ?;
受影响行数为 0,就说明期间有人改过,调用方需要重新读取、重新计算、再重试。这个“读取—计算—比较—写入”的循环就是乐观锁。
不要用时间戳当版本号。不同机器存在时钟漂移,同一毫秒内可能有多次写入,时间戳既不能保证严格递增,也无法区分“同一个值的两次写”。更隐蔽的是,业务把时间戳精度截断到秒后,两次连续更新会得到相同的版本,CAS 形同虚设。
| 方案 | 适用场景 | 主要代价 | 典型失败 |
|---|---|---|---|
| 版本号 CAS | 读多写少、跨请求 | 高冲突时重试放大 | 重试次数无上限、饿死 |
| 悲观锁 | 单进程短临界区 | 阻塞、死锁、连接占用 | 持锁跨网络调用 |
| 单行原子更新 | 单库单行计数 | 依赖数据库能力 | 跨库后条件失效 |
乐观锁的关键不是“重试”,而是“重试前重新读取”。如果拿着旧版本反复提交,只会稳定失败。重试还必须有业务上限或退避策略:活动开服瞬间,同一个热门角色可能被几十个请求同时修改,无限自旋会把 CPU 打满。
跨库场景要特别小心。两张表在两个库,单库事务只能保证各自成功,无法保证一起成功。这时要么把强一致范围收敛到同一个分片,要么改用“本地事务 + 事件 + 补偿”,不能假装 CAS 能跨库。
三、幂等键、请求指纹与去重窗口
CAS 解决并发覆盖,幂等解决重复执行。玩家点击“购买”后网络超时,客户端重试,网关也可能重试,消息队列至少投递一次,同一笔业务请求到达服务端的次数是不确定的。服务端必须让“执行一次”和“执行多次”的结果相同。
幂等键要来自业务身份,而不是每次请求新生成的随机数。例如 order:{order_id}、claim:{player_id}:{mail_id}、buy:{player_id}:{sku_id}:{client_seq}。客户端在第一次请求时生成键,所有重试复用同一个键;如果每次重试都换键,服务端根本无法识别它们是同一件事。
只有键还不够。同一个键可能被不同参数复用,例如客户端 bug 把两个礼包都塞进 buy:1001。因此还要记录请求指纹:把业务参数按固定顺序规范化后做摘要,命中已有键时先比较指纹,不一致直接报冲突,而不是返回旧结果。
| 请求类型 | 幂等键 | 指纹内容 | 建议窗口 |
|---|---|---|---|
| 下单购买 | 订单号 | 商品、数量、价格、玩家 | 24 小时以上 |
| 邮件领取 | 玩家 + 邮件 | 邮件 ID、附件版本 | 7 天 |
| 战斗结算 | 战斗 ID | 双方阵容、随机种子 | 1 小时 |
| 聊天发送 | 客户端消息 ID | 频道、内容摘要 | 5 分钟 |
窗口长度要覆盖“最坏重试链”,而不是拍一个 30 秒。客户端超时 10 秒、网关重试 3 次、队列延迟几分钟,窗口太短就会出现“第一次结果已过期,第二次重新执行”的重复扣款。窗口也不是越长越好:结果表会持续增长,需要按时间分区或异步归档。
高并发下还要处理“同键请求同时到达”。如果两个请求都查不到缓存,就会同时执行。正确做法是给键加在途标记:第一个请求执行,其余请求等待同一个结果,而不是各自跑一遍业务。
四、重试与乱序
重试会制造重复,乱序会制造覆盖。两者要分开处理:幂等键解决重复,版本号或序列号解决乱序。
假设玩家连续两次修改公会公告,第一次请求因为网络抖动晚到,第二次先落库。如果没有版本校验,晚到的旧公告会覆盖新公告。加上版本号后,旧请求的 expected_version 已经过期,服务端返回冲突,而不是静默写入。
客户端重试、网关重试、服务间重试、消息队列重投,每一层都可能放大请求量。重试策略要满足三点:只对可重试错误重试,例如超时和 5xx;使用指数退避并加抖动;重试请求必须携带原始幂等键和指纹。
乱序还可能发生在同一连接的多条消息之间。网关应按玩家或会话串行化写请求,或者给每条写请求带单调序列号,服务端只接受比当前序列更大的请求。注意序列号是“顺序”语义,不能替代幂等键:序列号相同但内容不同的重放仍然要拒绝。
五、跨服务状态变更:本地事务 + 事件 + 补偿
一笔充值要同时改订单状态、加钻石、写流水,这三件事可能落在不同服务。直接做分布式事务成本高,游戏服务端更常用本地事务加事件:
- 在订单库的同一个本地事务里,更新订单状态并写入一条 outbox 事件。
- 独立 relay 扫描 outbox,把事件投递到消息队列,允许重复投递。
- 资产服务按
event_id去重,成功后写自己的流水和余额。 - 失败时进入重试队列;超过阈值告警,不无限阻塞主链路。
- 定时对账任务比较订单、流水和余额,发现差异后走幂等修复。
补偿不是“把刚才的操作反过来再执行一次”这么简单。补偿动作本身也会重试,所以必须有独立的幂等键,例如 refund:{order_id}。如果补偿只是 gold = gold + 100,重试两次就会多加一次。
对账是最后一道兜底。它不依赖事件是否送达,而是按业务日切比较三份数据:订单应收、资产流水、账户余额。差异记录要能定位到具体订单和事件,修复动作必须可重复执行。对账发现问题不可怕,可怕的是修复脚本自己不具备幂等性,越修越乱。
六、失败场景:超时重试导致双倍发奖
某次版本更新后,运营给在线玩家发补偿邮件。客户端领取时先调邮件服务标记已领取,再调资产服务加钻石。活动高峰期资产服务响应变慢,客户端 5 秒超时后自动重试。
第一轮请求其实已经成功,只是响应丢了。第二轮请求再次进入:邮件服务发现状态已是“已领取”,直接返回成功;资产服务没有幂等键,又加了一次钻石。玩家领取一次,到账两次。更糟的是,部分玩家用脚本反复重试,把同一封邮件刷成了多次奖励。
复盘时能看到三个问题叠在一起:领取接口没有业务幂等键;邮件状态和资产变更不在同一个事务边界内;客户端重试没有携带原始请求标识。修复方案不是简单加一个“已领取”判断,而是以 claim:{player_id}:{mail_id} 作为幂等键,把“标记领取 + 发放奖励”的结果整体缓存,重复请求返回同一份结果。
第二个变种是窗口太短。幂等表只保留 30 秒,而网关的重试链最长两分钟。第一次结果过期后,第二次请求重新执行,幂等表看起来“工作正常”,实际没有覆盖真实重试窗口。窗口必须按最坏链路估算,并用监控观察重复命中率。
第三个变种是补偿不幂等。退款失败后定时任务重试,每次都执行 gold = gold + amount,玩家反而因为故障赚了钱。补偿动作也要走幂等表,键取原订单号,而不是补偿任务 ID。
七、Go 实现:版本 CAS 与幂等表
下面的 Store 用版本号实现 CAS,Apply 在冲突时重新读取并重试;Idempotency 用键、指纹和过期时间缓存执行结果,并用在途表保证同一键并发到达时只执行一次。时钟通过函数注入,测试可以精确推进过期时间。
// file: ch12/idempotent/idempotent.go
// tier: L1
// verify: go test ./ch12/idempotent/
// Package idempotent 演示版本号 CAS、乐观锁重试与带过期时间的幂等表。
package idempotent
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"runtime"
"sync"
"time"
)
var (
ErrEmptyKey = errors.New("idempotent: empty key")
ErrEmptyFingerprint = errors.New("idempotent: empty fingerprint")
ErrVersionConflict = errors.New("idempotent: version conflict")
ErrFingerprintMismatch = errors.New("idempotent: fingerprint mismatch")
)
// Record 是带版本号的玩家状态片段。Version 从 0 开始,每次成功写入加一。
type Record struct {
Version uint64
Value string
}
// Store 保存版本化记录,CAS 是唯一的写入口。
type Store struct {
mu sync.Mutex
records map[string]Record
}
func NewStore() *Store {
return &Store{records: make(map[string]Record)}
}
// Get 返回记录副本;第二个返回值表示键是否存在。
func (s *Store) Get(key string) (Record, bool) {
s.mu.Lock()
defer s.mu.Unlock()
r, ok := s.records[key]
return r, ok
}
// CAS 只有当前版本等于 expected 时才写入 value,并把版本加一。
// 新键的当前版本视为 0,因此首次写入需要传 expected=0。
func (s *Store) CAS(key string, expected uint64, value string) (Record, error) {
if key == "" {
return Record{}, ErrEmptyKey
}
s.mu.Lock()
defer s.mu.Unlock()
cur := s.records[key]
if cur.Version != expected {
return cur, fmt.Errorf("%w: key=%s current=%d expected=%d",
ErrVersionConflict, key, cur.Version, expected)
}
next := Record{Version: expected + 1, Value: value}
s.records[key] = next
return next, nil
}
// Apply 在版本冲突时重新读取并重试,mutate 收到当前记录并返回新值。
// 重试次数不设上限,但每次冲突都让出 CPU,避免在锁竞争下忙等。
func (s *Store) Apply(key string, mutate func(Record) (string, error)) (Record, error) {
for {
cur, _ := s.Get(key)
value, err := mutate(cur)
if err != nil {
return Record{}, err
}
next, err := s.CAS(key, cur.Version, value)
if err == nil {
return next, nil
}
if !errors.Is(err, ErrVersionConflict) {
return Record{}, err
}
runtime.Gosched()
}
}
// Result 是幂等执行缓存下来的结果。
type Result struct {
Version uint64
Value string
}
type entry struct {
fingerprint string
result Result
expiresAt time.Time
}
type inflight struct {
fingerprint string
done chan struct{}
result Result
err error
}
// Idempotency 用 key 去重,用 fingerprint 防止同一个 key 被不同请求复用。
// 结果只在 ttl 内复用,过期后允许重新执行。
type Idempotency struct {
mu sync.Mutex
ttl time.Duration
now func() time.Time
entries map[string]entry
inflight map[string]*inflight
}
// New 创建幂等表。now 为 nil 时使用 time.Now,测试可以注入可控时钟。
func New(ttl time.Duration, now func() time.Time) *Idempotency {
if now == nil {
now = time.Now
}
return &Idempotency{
ttl: ttl,
now: now,
entries: make(map[string]entry),
inflight: make(map[string]*inflight),
}
}
// Do 在 key 未命中时执行 fn,命中未过期结果时直接返回缓存。
// replayed 表示本次调用没有真正执行 fn。
func (i *Idempotency) Do(key, fingerprint string, fn func() (Result, error)) (Result, bool, error) {
if key == "" {
return Result{}, false, ErrEmptyKey
}
if fingerprint == "" {
return Result{}, false, ErrEmptyFingerprint
}
i.mu.Lock()
if e, ok := i.entries[key]; ok {
if i.now().Before(e.expiresAt) {
if e.fingerprint != fingerprint {
i.mu.Unlock()
return Result{}, false, ErrFingerprintMismatch
}
i.mu.Unlock()
return e.result, true, nil
}
delete(i.entries, key)
}
if call, ok := i.inflight[key]; ok {
if call.fingerprint != fingerprint {
i.mu.Unlock()
return Result{}, false, ErrFingerprintMismatch
}
i.mu.Unlock()
<-call.done
return call.result, true, call.err
}
call := &inflight{fingerprint: fingerprint, done: make(chan struct{})}
i.inflight[key] = call
i.mu.Unlock()
result, err := fn()
i.mu.Lock()
delete(i.inflight, key)
if err == nil {
i.entries[key] = entry{
fingerprint: fingerprint,
result: result,
expiresAt: i.now().Add(i.ttl),
}
}
call.result, call.err = result, err
close(call.done)
i.mu.Unlock()
return result, false, err
}
// Fingerprint 把业务参数规范化后拼成请求指纹。
// 分隔符用 0x00,避免 ("ab","c") 与 ("a","bc") 得到同一个摘要。
func Fingerprint(parts ...string) string {
h := sha256.New()
for _, p := range parts {
h.Write([]byte(p))
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil))
}
CAS 把“不存在”视为版本 0,因此首次写入传 expected=0 即可,不需要额外的插入分支。返回值同时给出当前记录,冲突时调用方可以直接看到实际版本,便于日志和调试。
Apply 只对 ErrVersionConflict 重试,其他错误立即返回。每次冲突调用 runtime.Gosched() 让出时间片,避免在极端竞争下忙等;生产实现还应加入随机退避和最大耗时。
Do 的检查顺序是:先看未过期结果,再看在途请求,最后才执行。命中结果时比较指纹,指纹不一致返回 ErrFingerprintMismatch,不会把旧结果错误地交给新请求。执行失败不写缓存,调用方可以安全重试。
过期采用懒删除:查询时发现 expiresAt 已过就删掉并重新执行。这样不需要后台清理协程,但结果表仍会保留无人访问的旧键,生产环境应配合定期归档。
八、测试覆盖哪些边界
实验包的 idempotent_test.go 覆盖了七类行为:
- 过期版本 CAS 返回
ErrVersionConflict,且不改变原值。 - 32 个 goroutine 通过
Apply并发自增,最终版本和值都等于 32。 - 同一键同一指纹的第二次调用返回缓存结果,函数只执行一次。
- 16 个并发重复请求只执行一次,所有调用拿到相同结果。
- 同一键不同指纹被拒绝,函数不会再次执行。
- 推进时钟超过 TTL 后,同一键允许重新执行。
- 执行失败不缓存,下一次调用会真正重跑。
这些用例对应线上最常见的四类事故:并发覆盖、客户端重试、键复用和窗口过期。
九、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch12/idempotent && go vet ./ch12/idempotent/ && go test ./ch12/idempotent/ -count=1
ok gspractice/ch12/idempotent 0.334s
gofmt -l 没有输出表示格式一致,go vet 通过,测试全部通过。测试使用注入时钟,不依赖真实等待,因此可以在毫秒级完成。
小结
状态一致性的第一步是分级,而不是选型。余额、订单、发奖需要强一致和幂等;好友、公会成员至少要做到读己之写;排行榜、聊天可以最终一致。并发覆盖用版本号 CAS 解决,重复执行用幂等键、请求指纹和去重窗口解决,乱序用序列号或版本比较解决。跨服务变更优先本地事务加事件,补偿和对账都必须幂等。把这些边界写进接口和测试,比在故障后补丁更便宜。
常见错误与边界
- 用时间戳当版本号,时钟回拨或同毫秒写入导致 CAS 失效。
- 幂等键由每次重试重新生成,服务端无法识别重复请求。
- 只记录“这个键来过”,不缓存结果,重复请求拿不到一致响应。
- 幂等表没有过期时间,或窗口短于真实重试链。
- 同一键不同参数直接覆盖,客户端 bug 被静默吞掉。
- 补偿逻辑不幂等,重试两次反而多扣或多发。
- 高冲突场景无限重试且不让出 CPU,活动开服时自旋打满。
- 把展示型状态也纳入强一致事务,故障面被无谓放大。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。