1.2 状态归谁:防重复发奖与旧数据覆盖
上一节立了一条原则:一个状态可以有多个读者,但只能有一个写者。原则很干净,但现实里这条原则几乎总是被两种方式悄悄破坏。
第一种破坏是「写者其实不止一个」。你设计上只有一个 logic 进程负责玩家的背包,但客户端重发了一次请求、或者结算服务和邮件服务各触发了一次发奖、或者两个线程同时抢同一个玩家的锁——物理上就有两个写者在同一份状态上动手。第二种破坏是「写者虽然是同一个,但它写的是旧数据」。写入者只有一个,可它手里攥的是几分钟前读出来的快照,等它写回时,更新的数据已经被别人写进去了,于是旧值覆盖了新值。
这两种破坏,对应玩法系统里两个最经典、也最烧钱的事故:重复发奖和旧数据覆盖。前者让玩家凭空多拿一份奖励,后者让玩家凭空丢一件装备。本节把两个事故的根因拆开,各自给出一条守卫,并写成能跑的代码。
两个经典事故:重复发奖与旧数据覆盖
先看重复发奖。玩家打完一场副本,结算完成,客户端发来领取请求。这时网络抖了一下,客户端没收到应答,于是重发了同一条请求;或者更隐蔽——结算服务发了一次奖,邮件服务因为收到一条延迟的事件,又发了一次。结果玩家背包里多了一份奖励。根因不在网络,而在发奖这个动作没有幂等键:系统把「同一个结算」当成了「两次不同的结算」,于是老老实实发了两遍。
再看旧数据覆盖。玩家先花金币买了件装备,存档推进到版本 v2;紧接着做任务又改了金币,推进到 v3。假如某个慢写入者手里还攥着它几分钟前读到的 v1 快照,在 v3 落盘之后才把 v1 写回——v3 就被 v1 覆盖了,玩家刚买的装备凭空消失。根因同样不在存储,而在写入没有版本序:存储不知道 v1 是「旧的」,它只是老老实实把后到的那份写了进去。
这两个事故有一个共同的本质:唯一写者原则在物理层面被破坏了,而系统没有在逻辑层面把它补回来。解法也不是「想办法保证物理上只有一个写者」——那不可能,重试和并发是网络服务的基本事实。解法是在逻辑层面加一道守卫,让多写者和旧写入都无法造成伤害。
唯一写者原则的两个推论
从「一个状态一个写者」这条原则,可以直接推出两条可实现的守卫:
- 每次写入必须带一个单调递增的版本号,存储只接受严格更新的版本。 这样即使物理上有多个写入者,逻辑上旧版本永远赢不了——因为存储会把「版本不大于当前值」的写入直接拒掉。旧数据覆盖新数据,被版本序挡在门外。
- 每个有副作用的动作必须有一个纯函数的幂等键。 同一个结算,无论在哪个进程、重试多少次,算出来的键都必须完全一样;守卫见到重复的键就拒绝第二次。重复发奖,被幂等键挡在门外。
这两条里,幂等键的「纯函数」性质是最容易被写错的地方。键必须是结算身份的纯函数——比如 hash(玩家ID, 战斗ID)——绝不能掺进随机数、时间戳或者自增序号。一旦掺进去,重试就会算出一个新键,守卫以为这是另一次结算,重复发奖照旧发生。第 7 章的掉落服务、第 6 章的战斗结算,用的都是这条口径。
可运行产物:把两条推论写成守卫
下面这段是 L1,go test 可以直接跑。它把上面两条推论分别实现成 Store(版本单调)和 RewardGuard(幂等键),先看 internal/ownership/state.go:
// file: internal/ownership/state.go
// tier: L1
// verify: go test ./internal/ownership/
// Package ownership turns "who owns the state" into two concrete guards that
// the logic tier applies on every write:
//
// - a monotonic version, so a slow writer that read an old snapshot can never
// overwrite a newer one ("old data overwrites new");
// - an idempotency key per settlement, so a retried or duplicated reward can
// only ever be granted once ("duplicate reward").
//
// Both are single-process guards in this chapter. Turning them into a
// cross-process protocol is a later chapter's job; the invariant is the same
// either way.
package ownership
import (
"errors"
"fmt"
"sync"
)
// PlayerID is a globally unique character id.
type PlayerID uint64
// ErrStale is returned when a commit carries a version that is not strictly
// newer than the stored one.
var ErrStale = errors.New("ownership: stale snapshot rejected")
// Snapshot is one versioned slice of player state as the data tier would store
// it. Version increases monotonically for a given player; the store accepts a
// write only if its version is strictly greater than the current one.
type Snapshot struct {
PlayerID PlayerID
Version uint64
Gold int64
}
// Store is the authoritative in-memory copy owned by the logic tier.
type Store struct {
mu sync.Mutex
byID map[PlayerID]Snapshot
}
// NewStore builds an empty store.
func NewStore() *Store {
return &Store{byID: make(map[PlayerID]Snapshot)}
}
// Commit applies snap if it is newer than what is already stored. A snapshot
// whose version is less than or equal to the stored version is rejected with
// ErrStale and leaves the store untouched, so a writer that raced with a newer
// one cannot clobber it.
func (s *Store) Commit(snap Snapshot) error {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.byID[snap.PlayerID]
if ok && snap.Version <= cur.Version {
return fmt.Errorf("%w: have v%d, got v%d for player %d",
ErrStale, cur.Version, snap.Version, snap.PlayerID)
}
s.byID[snap.PlayerID] = snap
return nil
}
// Get returns the stored snapshot for a player.
func (s *Store) Get(id PlayerID) (Snapshot, bool) {
s.mu.Lock()
defer s.mu.Unlock()
snap, ok := s.byID[id]
return snap, ok
}
// RewardGuard makes reward grants idempotent. A settlement may be applied at
// most once, even if the client retries the claim or two workers race on the
// same settlement id.
type RewardGuard struct {
mu sync.Mutex
applied map[string]struct{}
}
// NewRewardGuard builds an empty guard.
func NewRewardGuard() *RewardGuard {
return &RewardGuard{applied: make(map[string]struct{})}
}
// SettleKey derives the idempotency key for a reward. It must be a pure
// function of the settlement identity: the same battle must always produce the
// same key, on any process, so a retry cannot mint a second key and slip past
// the guard.
func SettleKey(p PlayerID, battleID uint64) string {
return fmt.Sprintf("reward:%d:%d", p, battleID)
}
// Apply records that key has been granted and reports whether this call was the
// one that granted it. The first call for a key returns true; every later call
// returns false and must not hand out a second reward.
func (g *RewardGuard) Apply(key string) bool {
g.mu.Lock()
defer g.mu.Unlock()
if _, dup := g.applied[key]; dup {
return false
}
g.applied[key] = struct{}{}
return true
}
配套测试 internal/ownership/state_test.go 覆盖四件事:旧版本被拒、同版本也算重复、同一结算只发一次、以及 64 个 goroutine 抢同一个结算时只有一个人拿到奖:
// file: internal/ownership/state_test.go
// tier: L1
// verify: go test ./internal/ownership/
package ownership
import (
"errors"
"sync"
"sync/atomic"
"testing"
)
func TestStaleSnapshotIsRejected(t *testing.T) {
s := NewStore()
if err := s.Commit(Snapshot{PlayerID: 1001, Version: 2, Gold: 200}); err != nil {
t.Fatalf("commit v2: %v", err)
}
// A writer that read the old v1 snapshot arrives late and tries to write it
// back. It must lose, or the player's gold silently rolls back.
err := s.Commit(Snapshot{PlayerID: 1001, Version: 1, Gold: 100})
if !errors.Is(err, ErrStale) {
t.Fatalf("commit v1 = %v, want ErrStale", err)
}
got, _ := s.Get(1001)
if got.Version != 2 || got.Gold != 200 {
t.Fatalf("stored = %+v, want v2/gold200", got)
}
}
func TestEqualVersionIsAlsoStale(t *testing.T) {
s := NewStore()
_ = s.Commit(Snapshot{PlayerID: 7, Version: 5, Gold: 50})
// Replaying the same version is a duplicate write, not a new one.
if err := s.Commit(Snapshot{PlayerID: 7, Version: 5, Gold: 50}); !errors.Is(err, ErrStale) {
t.Fatalf("duplicate version = %v, want ErrStale", err)
}
}
func TestRewardAppliedAtMostOnce(t *testing.T) {
g := NewRewardGuard()
key := SettleKey(1001, 42)
if !g.Apply(key) {
t.Fatal("first apply must grant")
}
if g.Apply(key) {
t.Fatal("second apply must be rejected: duplicate reward")
}
if !g.Apply(SettleKey(1001, 43)) {
t.Fatal("a different battle must grant")
}
}
func TestSettleKeyIsDeterministic(t *testing.T) {
if SettleKey(1001, 42) != SettleKey(1001, 42) {
t.Fatal("SettleKey must be pure")
}
if SettleKey(1001, 42) == SettleKey(1002, 42) {
t.Fatal("different players must not collide")
}
}
func TestConcurrentClaimGrantsOnce(t *testing.T) {
g := NewRewardGuard()
key := SettleKey(1001, 99)
var granted int32
var wg sync.WaitGroup
for i := 0; i < 64; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if g.Apply(key) {
atomic.AddInt32(&granted, 1)
}
}()
}
wg.Wait()
if n := atomic.LoadInt32(&granted); n != 1 {
t.Fatalf("granted %d times, want exactly 1", n)
}
}
本机实跑结果(go test ./internal/ownership/ -v):
=== RUN TestStaleSnapshotIsRejected
--- PASS: TestStaleSnapshotIsRejected (0.00s)
=== RUN TestEqualVersionIsAlsoStale
--- PASS: TestEqualVersionIsAlsoStale (0.00s)
=== RUN TestRewardAppliedAtMostOnce
--- PASS: TestRewardAppliedAtMostOnce (0.00s)
=== RUN TestSettleKeyIsDeterministic
--- PASS: TestSettleKeyIsDeterministic (0.00s)
=== RUN TestConcurrentClaimGrantsOnce
--- PASS: TestConcurrentClaimGrantsOnce (0.00s)
PASS
ok aegis/internal/ownership 0.349s
TestConcurrentClaimGrantsOnce 是关键的一条:64 个 goroutine 同时用同一个结算键调用 Apply,只有一个人能拿到 true。这条断言在 go test -race 下也通过——它证明 RewardGuard 的互斥不是靠运气,而是靠那把 sync.Mutex。注意 Commit 里的判断是 snap.Version <= cur.Version 而不是 <:同版本也算重复写入,因为同一个版本被写两次,说明有人把同一份状态算了两遍,这本身就是个 bug,应该报错而不是静默接受。
本节小结
这一节把「唯一写者」原则落到了两个最常出事的场景上:
- 重复发奖的根因是缺幂等键。解法是给每个有副作用的动作配一个纯函数的结算键,守卫见到重复键就拒绝;键必须是结算身份的纯函数,不能掺随机数或时间戳。
- 旧数据覆盖的根因是写入没有版本序。解法是让每次写入带单调递增的版本号,存储只接受严格更新的版本;同版本也算重复,一律拒绝。
- 两条守卫都是单进程版本,本章先在本机把它们跑绿;把它们扩展成跨进程协议是后面章节的事,但不变量(版本单调、键幂等)不会变。
下一节回答一个读者一定会问的问题:站内已经有一大批游戏服务器架构文章,这本书和它们到底谁写什么、边界在哪。
阅读导航:上一节:1.1 gateway / logic / scene / data 分层 · 下一节:1.3 本书范围与站内架构文章的分工 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。