9.2 落库与脏写防护
9.1 把角色的数据拆成了主表加扩展表,但只解决了「存在哪」,没解决「什么时候写、谁来写、写冲突怎么办」。在单服内存里(卷二),一个角色只有一个写者,状态归属清晰;可一旦要落库,写者就变成了好几个——定时快照在写、玩家退出登录在写、交易发奖这类关键操作也在写。多个写者 + 同一行数据,脏写的土壤就齐了。
脏写(lost update)的典型剧本很短:定时快照在 T0 时刻读到金币 500,正准备落库;T1 时刻玩家完成一笔购买,金币被扣到 400 并立即落库;T2 时刻那份基于 T0 旧值的快照才写下去,把 400 盖回 500。玩家凭空多出 100 金币,账目从此对不上。跨服场景更隐蔽:玩家从 A 服迁到 B 服(卷二 2.3),A 服那份滞留的 flush 落到 B 服已经写过的行上,同样是一份旧值覆盖新值。
本节给出三类防护,并全部落到能真跑的代码上:用版本号乐观锁让并发写只有一个能成功;用定时快照 + 关键操作即时落库划清「什么能等、什么不能等」;用脏标记 + 批量 flush把高频小改动合并成一次写。最后给出「两个并发写只有一个成功、版本号递增」的可运行断言。
脏写为什么防不住「后写覆盖先写」
先看清根因:数据库默认的 UPDATE ... SET gold = 400 WHERE player_id = 1 是无条件的,它只认「主键等于 1」,不关心你手里的 400 是基于哪个版本算出来的。所以只要有两个写者先后执行它,后执行的那个一定覆盖先执行的那个,无论谁的值更新。
很多团队的第一反应是加锁,但行锁解决不了这个问题:快照在 T0 读、T2 写,中间隔着两秒,锁根本没法持有两秒。真正要表达的是「我这次写,是基于我读到的那个版本改的;如果这期间有人改过,就别写」。这个语义用版本号表达最自然:给每行加一个 version 列,读的时候连版本一起读出来,写的时候要求 version 没变过,写成功后把版本加一。
顺带一提,这也是为什么 9.1 的 player 表里提前埋了 version 列——它不是给查询用的,是给写路径做并发控制用的。扩展表可以各自带版本号,也可以共用主表版本号,本节先用主表这一把锁把最关键的资产写保护住。
版本号乐观锁
下面这段是 L2,是本节的并发控制核心,连真 Postgres 跑:
// file: internal/store/persist.go
// tier: L2
// verify: go test ./internal/store/ -run TestOptimisticLockDirtyWrite -v
package store
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrStaleWrite is returned when an UPDATE matches zero rows because the row's
// version moved on since this writer read it. The caller must reload and
// reapply its change; blindly writing would clobber the other writer.
var ErrStaleWrite = errors.New("store: stale write (version mismatch)")
// DirtyMask records which tables a flush must touch. Extension tables are only
// rewritten when their bit is set, so a position change never rewrites the bag
// and a quest turn-in never rewrites the attribute bag.
type DirtyMask uint8
const (
DirtyMain DirtyMask = 1 << iota
DirtyItems
DirtyQuests
DirtyAttrs
)
// Flush persists a player in one transaction. The main UPDATE carries the
// concurrency gate: `WHERE player_id = $1 AND version = $8`. If two writers
// race, the loser's WHERE matches nothing, it gets ErrStaleWrite, and it never
// touches any extension table. The version is bumped in the same statement, so
// the next writer must have read the row after this commit to win.
func Flush(ctx context.Context, pool *pgxpool.Pool, p *Player,
items []Item, quests []Quest, attrs []Attr, dirty DirtyMask) error {
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if dirty&DirtyMain != 0 {
ct, err := tx.Exec(ctx, fmt.Sprintf(`UPDATE %s.player SET
account=$2, name=$3, level=$4, pos_x=$5, pos_y=$6, gold=$7,
version = version + 1, updated_at = now()
WHERE player_id=$1 AND version=$8`, Schema),
p.ID, p.Account, p.Name, p.Level, p.PosX, p.PosY, p.Gold, p.Version)
if err != nil {
return err
}
if ct.RowsAffected() == 0 {
return ErrStaleWrite
}
}
if dirty&DirtyItems != 0 {
if _, err := tx.Exec(ctx, fmt.Sprintf(
"DELETE FROM %s.player_item WHERE player_id=$1", Schema), p.ID); err != nil {
return err
}
for _, it := range items {
if _, err := tx.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.player_item
(inst_id, player_id, item_id, count) VALUES ($1,$2,$3,$4)`, Schema),
it.InstID, it.PlayerID, it.ItemID, it.Count); err != nil {
return err
}
}
}
if dirty&DirtyQuests != 0 {
for _, q := range quests {
if _, err := tx.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.player_quest
(player_id, quest_id, state) VALUES ($1,$2,$3)
ON CONFLICT (player_id, quest_id) DO UPDATE SET state=EXCLUDED.state`, Schema),
q.PlayerID, q.QuestID, q.State); err != nil {
return err
}
}
}
if dirty&DirtyAttrs != 0 {
for _, a := range attrs {
if _, err := tx.Exec(ctx, fmt.Sprintf(`INSERT INTO %s.player_attr
(player_id, key, value) VALUES ($1,$2,$3)
ON CONFLICT (player_id, key) DO UPDATE SET value=EXCLUDED.value`, Schema),
a.PlayerID, a.Key, a.Value); err != nil {
return err
}
}
}
if err := tx.Commit(ctx); err != nil {
return err
}
// The DB bumped the version; keep the in-memory copy in step so a second
// flush from the same object is not immediately stale.
p.Version++
return nil
}
// DirtyTracker accumulates which tables changed since the last flush. It is
// the write-combining half of the story: a hundred ticks that only move the
// player collapse into one DirtyMain flush.
type DirtyTracker struct {
mask DirtyMask
}
func (d *DirtyTracker) Mark(m DirtyMask) { d.mask |= m }
func (d *DirtyTracker) Mask() DirtyMask { return d.mask }
func (d *DirtyTracker) Reset() { d.mask = 0 }
func (d *DirtyTracker) Dirty() bool { return d.mask != 0 }
关键在 WHERE player_id=$1 AND version=$8 这一句。它把「我基于哪个版本改」编码进了写条件,而 version = version + 1 和写在同一句里原子完成。并发时会发生什么?在 Postgres 默认的 READ COMMITTED 隔离级别下,后到的写者会先阻塞在先行者的行锁上,等先行者提交后再重新求值 WHERE——此时 version 已经变成 2,与它手里的 1 不匹配,于是匹配到 0 行,RowsAffected() == 0,函数返回 ErrStaleWrite。调用方拿到这个错误就知道「有人抢先了」,重新读一次、重放自己的改动即可。整个过程不需要显式加锁,也不会产生死锁。
落库时机与写合并
有了乐观锁,还要决定什么时候发起写。全量高频落库扛不住,全量低频落库又会丢数据,所以按「能不能等」分两档:
| 改动类型 | 落库时机 | 理由 |
|---|---|---|
| 坐标、朝向等高频小改动 | 定时快照(如每 30s) | 丢几秒位置可接受,重新登录会以服务端权威值纠正 |
| 等级、经验等进度 | 定时快照 + 关键节点即时 | 升级那一刻要即时,普通涨经验可等 |
| 金币、物品、交易、发奖 | 即时落库 | 资产一旦发放就不能因为崩溃而消失 |
| 退出登录 | 立即全量 flush | 下线是天然的持久化点 |
「即时落库」不等于「每次改动都写库」。玩家一次操作可能连着改好几处(扣钱、加物品、改属性),这些应该合并成一次 Flush,用 DirtyMask 标出哪些表变了,只写变了的表。这就是 DirtyTracker 的作用:一次移动 tick 只 Mark(DirtyMain),一百次移动累积下来,flush 时也只写 player 一张表,绝不触碰背包和任务。反过来,一笔交易同时改了主表和背包,就 Mark(DirtyMain|DirtyItems),一次事务里两张表一起写,要么都成要么都不成。
这里有个必须坚持的顺序:先在内存里改状态,再标脏,最后 flush。不要在改内存之前先写库——那样一旦写成功而内存更新失败,内存与库就不一致了,而内存才是运行时读的权威。
断线与崩溃时的数据边界
最后说清「最坏情况下会丢什么」,这是给线上同学的定心丸,也是设计即时落库的依据:
- 正常断线(玩家下线):退出时的全量 flush 是最后一次写,数据定格在那一刻,不丢。
- 进程崩溃:内存里自上次 flush 之后未落库的改动会丢,回退到上一次快照。
- 跨服迁移:旧服滞留的 flush 靠版本号被新服拒绝,不会覆盖新服的更新。
因此判断一条数据「能不能等」的唯一标准是:丢它会不会伤害玩家。位置、朝向丢了无所谓;金币、物品、已发放的奖励丢了就是事故。所以资产的写路径必须即时落库,且落库用乐观锁防覆盖;而位置这类可以放心交给 30 秒的快照。
实测结论
下面这段是 L2 的可运行断言:两个写者都读到 version=1,各自改了金币后同时 flush,断言只有一个成功、另一个拿到 ErrStaleWrite、最终版本号是 2:
// file: internal/store/persist_test.go
// tier: L2
// verify: go test ./internal/store/ -run TestOptimisticLockDirtyWrite -v
package store
import (
"context"
"errors"
"sync"
"testing"
)
// TestOptimisticLockDirtyWrite proves the two properties of the version gate:
// when two writers race on the same row, exactly one wins, and the version is
// bumped by exactly one so the loser can detect the collision and retry.
func TestOptimisticLockDirtyWrite(t *testing.T) {
pool := testPool(t)
defer pool.Close()
resetSchema(t, pool)
ctx := context.Background()
if err := SavePlayer(ctx, pool, &Player{
ID: 1001, Account: "acc-1001", Name: "晨光", Level: 10, Gold: 500, Version: 1,
}); err != nil {
t.Fatal(err)
}
// Both writers read the same row, so both hold version 1.
a, err := LoadPlayer(ctx, pool, 1001)
if err != nil {
t.Fatal(err)
}
b, err := LoadPlayer(ctx, pool, 1001)
if err != nil {
t.Fatal(err)
}
if a.Version != 1 || b.Version != 1 {
t.Fatalf("both should read version 1, got %d/%d", a.Version, b.Version)
}
a.Gold = 600
b.Gold = 700
var wg sync.WaitGroup
errs := make([]error, 2)
wg.Add(2)
go func() { defer wg.Done(); errs[0] = Flush(ctx, pool, a, nil, nil, nil, DirtyMain) }()
go func() { defer wg.Done(); errs[1] = Flush(ctx, pool, b, nil, nil, nil, DirtyMain) }()
wg.Wait()
success, stale := 0, 0
for _, e := range errs {
switch {
case e == nil:
success++
case errors.Is(e, ErrStaleWrite):
stale++
default:
t.Fatalf("unexpected error: %v", e)
}
}
if success != 1 || stale != 1 {
t.Fatalf("success=%d stale=%d, want exactly 1 and 1", success, stale)
}
final, err := LoadPlayer(ctx, pool, 1001)
if err != nil {
t.Fatal(err)
}
if final.Version != 2 {
t.Fatalf("version=%d want 2 (bumped exactly once)", final.Version)
}
// The surviving value is one writer's whole value, never a merge.
if final.Gold != 600 && final.Gold != 700 {
t.Fatalf("gold=%d, want 600 or 700", final.Gold)
}
t.Logf("version gate ok: 1 commit, 1 stale, version=2, gold=%d", final.Gold)
}
本机实测输出:version gate ok: 1 commit, 1 stale, version=2, gold=700。两个并发写里恰好一个提交、一个被判陈旧,版本号从 1 精确加到 2,落库的金币是其中一个写者的完整值(700)而不是两者的混合——证明败者的事务没有部分生效。
本节小结
- 脏写的根因是无条件 UPDATE:它只认主键,不认「我基于哪个版本改」。
- 版本号乐观锁把并发语义编码进
WHERE ... AND version = ?,并在同句version = version + 1,让并发写恰好一个成功。 - 落库分两档:可等的(位置、普通进度)走定时快照,不可等的(资产、交易、发奖)即时落库。
DirtyMask+DirtyTracker做写合并,只重写变了的表,把高频小改动压成一次 flush。- 崩溃会吞掉上次快照之后的未落库改动,所以判断「能不能等」的唯一标准是「丢了会不会伤害玩家」。
阅读导航:上一节:9.1 角色数据模型 · 下一节:9.3 分片与再均衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。