《游戏服务端编程实践》12.3 状态一致性与幂等性

本篇按强一致、读己之写、最终一致给玩家可见状态分级,讲清版本号 CAS、幂等键与请求指纹、重试乱序和跨服务补偿对账,并给出可运行的 Go 幂等表实现,以及并发冲突、重复请求、过期重执行、失败不缓存和指纹冲突的测试边界与事故复盘,覆盖接口设计和部署边界。

一、先给状态分级,再谈一致性

“状态要强一致”是一句没有边界的话。登录、扣款、发奖、排行榜、聊天记录对一致性的要求完全不同,把所有状态都塞进一个分布式事务,系统和团队都会先撑不住。工程上更可落地的做法是先按玩家可见性分级,再为每一级选择实现成本匹配的方案。

玩家可见状态推荐口径典型实现降级后的后果
余额、背包、货币扣减强一致单行事务 + 版本 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;使用指数退避并加抖动;重试请求必须携带原始幂等键和指纹。

乱序还可能发生在同一连接的多条消息之间。网关应按玩家或会话串行化写请求,或者给每条写请求带单调序列号,服务端只接受比当前序列更大的请求。注意序列号是“顺序”语义,不能替代幂等键:序列号相同但内容不同的重放仍然要拒绝。

五、跨服务状态变更:本地事务 + 事件 + 补偿

一笔充值要同时改订单状态、加钻石、写流水,这三件事可能落在不同服务。直接做分布式事务成本高,游戏服务端更常用本地事务加事件:

  1. 在订单库的同一个本地事务里,更新订单状态并写入一条 outbox 事件。
  2. 独立 relay 扫描 outbox,把事件投递到消息队列,允许重复投递。
  3. 资产服务按 event_id 去重,成功后写自己的流水和余额。
  4. 失败时进入重试队列;超过阈值告警,不无限阻塞主链路。
  5. 定时对账任务比较订单、流水和余额,发现差异后走幂等修复。

补偿不是“把刚才的操作反过来再执行一次”这么简单。补偿动作本身也会重试,所以必须有独立的幂等键,例如 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,活动开服时自旋打满。
  • 把展示型状态也纳入强一致事务,故障面被无谓放大。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist