第26章 · 持久化与一致性:数据库、缓存与回档

📑 目录

内测第 41 天凌晨 2 点,「远港 Online」的交易所里,同一件传说武器的物品 UID 被查出了 47 个副本。源头是跨区交接窗口里的一个复制漏洞(见第 25 章),被人用 5 个小时刷出了流通量三倍的顶级装备,再经拍卖行洗进无辜玩家手里。止损只剩一个办法:回档到前一天 20 点的快照,全服 8 小时的掉落、任务与交易陪葬。宕机十次,玩家骂完还会回来;写丢一次装备,骂名跟着游戏进坟墓。这一章讲的,就是怎么不按这个按钮——以及不得不按时,怎么收场。

本章地图

mindmap
  root((持久化与一致性))
    事故复盘
      复制漏洞 × 8 小时快照
      RPO = 敢承诺的最大损失
    数据三层体温
      热:Zone 内存 μs 级
      温:Redis 类缓存 ms 内
      冷:关系库 唯一真相
    选型与补课
      关系库 文档库 KV 分工
      覆盖索引与回表
      隔离级别与行锁
    数据模型
      进 WHERE 的字段必须列化
      整存整取的才配进 JSON
    回存管道
      下线 定时 事件 三保险
      脏标记 合并 异步写 确认
    一致性
      钱货两讫一个事务
      ledger 流水兜底审计
    回档与补偿
      决策树 个人 局部 全服
      补时间不补物品

复盘那场事故:8 小时是怎么丢的

先把时间线摆出来,再谈教训。

gantt
    dateFormat YYYY-MM-DD HH:mm
    axisFormat %H:%M
    title 回档 8 小时:事故时间线
    section 背景
    每日快照完成 :2026-04-21 20:00, 5m
    复制漏洞被悄悄利用 :2026-04-21 21:00, 300m
    section 发现与决策
    交易所 UID 重复报警,定位扩散范围 :2026-04-22 02:00, 120m
    停服维护,拍板全服回档 :2026-04-22 04:00, 10m
    section 恢复
    回滚至前一日 20:00 快照 :2026-04-22 04:10, 150m
    数据校验,分批开服 :2026-04-22 06:40, 80m

漏洞本身不复杂:角色跨区时,旧 Zone 先把数据回存,新 Zone 再接管(见第 25 章的交接序列)。交接窗口里一次超时重试,让同一件装备在两个 Zone 各存了一份。真正致命的是后面三件事。

第一,快照间隔决定了损失上限。每日一快照,意味着最坏情况下要拿 24 小时的数据陪葬——这次实际丢了 8 小时,已经算运气好。你的快照间隔,就是你敢对玩家承诺的最大损失。事后团队把全量快照缩到 4 小时一次,增量日志实时落盘,RPO(恢复点目标)从"一天"压到"分钟级"。

第二,污染不可枚举,才被迫全服回档。复制装备经拍卖行转手后,买家是无辜的。想定向回收,就得回答"每一件传说武器的血缘"——当时的系统回答不了,因为没有独立的物品流转流水。事故后补上的 ledger 表,让下一次同类事故的止损手段从"全服回档"降级成了"定向回收"。

第三,决策用了两个小时,比漏洞本身更伤。凌晨的值班群里没有预案文档,"回不回、回到哪、怎么补偿"全靠现场拍脑袋。回档这种事,预案必须在太平日子里写好,本章最后一节给模板。

恢复侧也有一笔账要记。回滚本身两个半小时,真正磨人的是之后 80 分钟的校验:金币总量对账、物品 UID 唯一性扫描、任务状态抽样比对,全部通过才分批开服。这是 RTO(恢复时间目标)的常态——回滚是拷贝,校验才是手艺。校验清单同样要在太平日子里写好;临场想"该查点什么",每一条遗漏都可能把污染带进新时间线。

记住这场事故的三个数字:快照间隔 8 小时(损失窗口)、决策耗时 2 小时(无预案的代价)、止损手段 1 种(没有流水就只能动核弹)。本章剩下的内容,就是围着这三个数字补课。

数据有体温:热、温、冷三层各管什么

玩家按下"拾取"的那一瞬间,数据落在哪一层,决定了它会不会丢、会不会卡。

热层是 Zone Server 的内存。角色、背包、技能的运行时对象就活在这里——UE 侧对应的是角色的 USTRUCT 属性与 GAS 属性集(见第 22 章)。读写延迟不到 1 微秒,玩家手感路径上的每一次访问都只碰这一层。代价同样直白:进程崩溃,热层蒸发。它是真相的工作副本,从来不是真相本身。

温层是 Redis 类的缓存。延迟 0.1 到 1 毫秒,放登录态、排行榜、未读邮件数、跨服共享的外观快照。温层只有一条铁律:只放可重建的数据。缓存丢了,从冷层重建就是;把唯一真相塞进缓存,等于把房产证存在快捷酒店——快捷酒店会失火。

冷层是 PostgreSQL / MySQL 加 SSD。点查 1 到 10 毫秒,跨机房再乘 10。慢,但它是法律意义上的唯一真相:回档、审计、客服查单,全部以冷层为准。

三层数据温度:读写落在哪一层,决定数据会不会丢热层 · Zone Server 内存角色 / 背包 / 技能的运行时对象(USTRUCT、GAS 属性)读写 < 1 μs · 进程崩溃即蒸发 · 只是真相的工作副本温层 · Redis 类缓存登录态 / 排行榜 / 未读邮件数 / 跨服共享快照0.1–1 ms · 只放可重建数据,不放唯一真相冷层 · PostgreSQL / MySQL(SSD)角色 / 背包 / 任务 / 邮件 / 公会 核心表点查 1–10 ms · 跨机房再 ×10 · 唯一真相读 · 登录预热冷 → 温 → 热一次性,秒级写 · 脏标记异步批量落冷秒–分钟级回存确认后删缓存每往下一层,延迟 ×10 到 ×1000;唯一真相只在最冷一层玩家手感路径上的读写,永远不直接打冷层

延迟阶梯是硬约束:热层微秒、温层毫秒、冷层十毫秒,每下一层差 10 到 1000 倍。推论只有一句——登录时一次性预热(冷到温到热,秒级,玩家能接受),游戏过程中只碰热层,落库全部走异步。任何"战斗中同步查一次数据库"的写法,都是埋在高峰期的事故。

温层的缓存还有两条使用纪律:读穿填充,写后失效。读穿是说缓存未命中时由读路径负责回冷层加载并回填;写后失效是说回存事务提交成功后,把缓存删掉而不是更新它。

# 伪代码:温层缓存两条纪律——读穿填充、写后失效
def get_profile(char_id):
    data = redis.get(f"profile:{char_id}")
    if data is None:                        # 未命中:读穿到冷层
        data = db.query("SELECT ... WHERE char_id = ?", char_id)
        redis.setex(f"profile:{char_id}", ttl=300, value=data)
    return data

def on_save_committed(char_id):
    # 回存事务提交成功后:删缓存,不要更新缓存
    # 更新会与并发回存形成双写竞态,把旧值盖回去
    # 删除则让下一次读重新填充,天然收敛到最新值
    redis.delete(f"profile:{char_id}")

为什么删?让更新缓存和回存事务这两个写者直接对抗,并发时序根本无法保证——回存提交的是新值,缓存更新到的可能是旧值,玩家就会看到装备"凭空回档"。删除只有一个动作,没有时序问题,代价是下一次读多一次回源,这笔账稳赚。

最后泼一盆冷水:UE 引擎本身只提供单机级的序列化能力(SaveGame 系统,见第 2 章),MMO 级的三层持久化是引擎之外完全自建的一层。别去引擎里找"MMO 数据库模块",没有这个东西;服务器端的数据访问代码写在 Dedicated Server 进程里,用的是数据库厂商的客户端库,不是 UE 的 API。

选型没有银弹:关系库、文档库、KV 的真实分工

三类数据库的争论在团队里永远吵不完,因为大家吵的常常不是同一件事。把评判维度摆到一张表上,答案会自己浮出来。

维度关系库(PostgreSQL/MySQL)文档库(MongoDB 类)KV(Redis 类)
事务能力多行、多表、强一致单文档强,跨文档弱且贵基本没有
查询能力JOIN、聚合、二级索引齐全按文档结构查,JOIN 是痛按键查,另加有序集合
Schema 演进改列要走迁移,重免迁移,随写随变无概念
典型延迟1–10 ms(SSD 点查)1–10 ms0.1–1 ms
在「远港 Online」管什么角色/背包/任务/邮件/公会行为流水、日志、运营事件登录态、排行榜、未读数

分工判断三句话。角色资产数据必须进关系库——你需要事务、JOIN 和审计三件套,文档库给不齐。行为流水(谁几点捡了什么、谁几点打了谁)走文档库或列存——写多查少、字段天天变,关系库的迁移成本会拖死你。会话态与排行榜走 KV——TTL 和有序集合跟这两类需求是天生一对。

"别用文档库存背包",这句话的出处是事故学,不是鄙视链。背包的需求里天然有"一个事务内改两个玩家的背包"(交易)、"按物品模板全服统计"(运营查掉落)、"按 UID 点查血缘"(复制检测)。文档库做这三件事,要么绕到应用层自己拼事务,要么干脆做不了。等需求追上来再迁移,数据已经长成搬不动的形状。

容量上也给个数量级定心:一万在线的 MMO,核心库的写入峰值不过几千 TPS,单机 PostgreSQL 绰绰有余,犯不上分库分表。真正的体量压力在行为流水——每天几亿行,那部分走消息队列加列存,永远不进核心库。核心库越小,备份越快,回档越轻。

补课:索引与事务隔离级别的工程直觉

补课:本节补数据库基础,DBA 出身可跳过。

索引就是字典的拼音检字表。B+ 树索引让"按 char_id 找背包"从翻整本字典(全表扫描)变成查检字表(几次磁盘页跳转)。背包查询每次登录、每次开背包都发生,一张千万行的表没有 char_id 索引,等于每次开背包都把图书馆翻一遍——数据库会在高峰期被活活打死。

索引有代价,这笔账要认。每多一个索引,INSERT 和 UPDATE 就要多维护一棵树,这叫写放大。背包表是高频写表,索引控制在三个以内,每一个都要能说出它服务的具体查询。

更进一步是覆盖索引:把查询要用的列直接塞进索引里,查到即取走,不再回主表翻行。背包列表只需要格子、模板、数量三列,那就让索引恰好带上这三列:

# 伪代码:背包查询的两条路径(SQL 语义)
# 慢路径:只有 char_id 单列索引
#   命中索引 -> 拿到 120 个行指针 -> 回主表取整行 120 次
plan_slow = "INDEX(char_id) -> 回表 × 120"

# 快路径:覆盖索引 (char_id, slot, tpl_id, stack_count)
#   查询列全在索引里,EXPLAIN 出现 index-only scan,回表次数归零
plan_fast = "INDEX_ONLY(char_id, slot, tpl_id, stack_count)"

# 纪律:每个新索引都要回答"它服务哪条查询"
#       背包表高频写,索引 > 3 个时写放大开始反噬

EXPLAIN 是数据库的心电图。上线前对每条高频查询跑一次,看见全表扫描就当报警处理;上线后把慢查询日志接到监控里,阈值定在 50 毫秒。

隔离级别管的是"并发事务互相能看见什么"。四级里工程上常用的三档,直觉如下:

隔离级别直觉会出的事故用在哪
读已提交只能看到别人提交后的值同一事务里两次读结果不同报表、运营后台
可重复读事务内读到的快照不变写偏斜:两事务各查各改,合计违规一般业务默认档
串行化事务像排队挨个执行吞吐下降,死锁变多钱货交易

交易为什么必须串行化(或用行锁等价实现)?看读已提交下的经典事故:两个并发事务同时给同一玩家扣金币,各自读到 gold=100,各自扣 80,都提交,余额 -60。金币就这么凭空印出来了。修复手段有两个层次:数据库层面用 SELECT ... FOR UPDATE 把这一行锁住,迫使第二个事务排队;应用层面更稳的一招是把余额条件写进 UPDATE 语句本身(WHERE gold >= ?),再核对影响行数——即使隔离级别配错了,这一层也能兜底。下一节的交易代码两个都用。

还有一类问题隔离级别管不了:热点行。公会金库是一行数据,全公会几百人同时存取,任何隔离级别都救不了这一行的锁排队。工程解法是绕过它:金库变更不进事务抢锁,改成记账请求进队列、单消费者串行落账。隔离级别保证正确性,队列消化热点,两者分工不同,别指望一个工具干两份活。

「远港 Online」数据模型:列化与 JSON 大字段的楚河汉界

核心表之间的关系,一张图说清楚。字段后面的注释只回答一个问题:它凭什么待在这一列。

erDiagram
    CHARACTER ||--o{ BAG_ITEM : owns
    CHARACTER ||--o{ QUEST_STATE : tracks
    CHARACTER ||--o{ MAIL : receives
    CHARACTER ||--o{ GUILD_MEMBER : joins
    GUILD ||--o{ GUILD_MEMBER : has

    CHARACTER {
        bigint char_id PK
        string name "列化:登录与重名检查"
        int level "列化:排行榜与匹配"
        bigint gold "列化:审计热点,独立行锁"
        int zone_id "列化:跨区路由"
        json appearance "JSON:外观快照,整存整取"
        json settings "JSON:键位与 UI 设置"
    }
    BAG_ITEM {
        bigint uid PK "物品UID,全服唯一,复制检测按它点查"
        bigint char_id FK "列化:覆盖索引首列"
        int tpl_id "列化:模板,全服掉落统计"
        smallint slot "列化:格子号"
        int stack_count "列化:堆叠数"
        tinyint bind_flag "列化:绑定标记,交易校验"
        json affixes "JSON:随机词条明细,只展示不查询"
    }
    QUEST_STATE {
        bigint char_id PK, FK
        int quest_id PK
        tinyint state "列化:进行中/可交付/已交付"
        json progress "JSON:击杀计数等进度明细"
        datetime updated_at "列化:回存对账"
    }
    MAIL {
        bigint mail_id PK
        bigint to_char FK "列化:收件箱查询"
        tinyint kind "列化:系统/玩家/补偿"
        json attachment "JSON:附件物品快照"
        datetime expire_at "列化:过期回收扫描"
    }
    GUILD {
        bigint guild_id PK
        string name
        bigint vault_gold "列化:全服最热的行之一"
        json notice "JSON:公告与配置"
    }
    GUILD_MEMBER {
        bigint guild_id PK, FK
        bigint char_id PK, FK
        tinyint rank "列化:权限校验"
    }

划界的规则一句话:会进 WHERE、JOIN、ORDER BY 的字段,必须列化;只进不出、整存整取的数据,才配进 JSON。逐条看几个关键决策。

gold 必须列化,而且在很多项目里值得单独拆一张账户表。它是审计对象(每一笔变动要有来路)、是热点行(交易、拍卖行、修理全在打它)、是回滚优先级最高的字段。塞进 JSON 大字段,等于把保险柜钥匙挂在门垫上。

affixes(随机词条明细)相反,纯展示数据:玩家点开装备才读,服务器逻辑只关心词条结算后的结果属性。进 JSON,字段随便加,不用为新词条类型改表结构。

bind_flag 看着小,却必须列化:交易校验要按它过滤,"全服非绑定传说武器存量"这种风控查询也靠它。判断标准一句话:会不会出现在查询条件里。字段大小是次要的。

MAIL.expire_at 列化是为了回收——补偿邮件 30 天不领要过期(后文会讲为什么),过期扫描是一条按时间范围的查询,JSON 里的字段没法走索引,只能全表扫。

再给一个反例收尾,这是真实项目里踩过的坑:把整个背包塞进 CHARACTER 表的一个 JSON blob。四个恶果接踵而至——拾取一件物品要读改写整个几十 KB 的 blob,写放大几十倍;两个并发写者互相覆盖,丢装备;运营半夜要查"全服橙武存量",只能全表扫加 JSON 解析,跑三个小时;复制漏洞排查时无法按 UID 点查,审计无从下手。等你需要连夜把它拆回行表时,玩家已经在论坛给游戏烧纸了。

规模账也要算在模型设计里:BAG_ITEM 的行数是角色数乘以平均格子数(约 120),十万角色就是千万行级。单表千万行以内别折腾分表,把覆盖索引和分区做好;奔着亿行去的时候,按 char_id 哈希分表,跨表查询的需求(全服统计)交给 binlog 变更流喂出来的离队列存,核心库只服务在线事务。

回存管道:三保险、脏标记与批量合并

先回答一个新手必问的问题:为什么不"改了立刻写库"?一场 25 人副本战,单个玩家的任务进度改 40 次、背包改 60 次、属性改几百次。逐条写库,写放大会把数据库打死;攒起来合并写,40 次任务进度落库时只剩 1 条 UPDATE。回存管道存在的意义,就是把"高频小修改"翻译成"低频批量写"。

触发机制用三保险,各自兜一种流失场景:

触发源兜什么不兜什么
下线立即存99% 的正常退出进程崩溃、断线
定时兜底(5 分钟全量脏扫描)崩溃与断线,损失 ≤ 5 分钟高价值瞬间
事件触发(贵重掉落/交易完成/任务交付)高价值时刻,损失 ≈ 0日常琐碎变更

第三条值得多说一句。丢 5 分钟经验,玩家无所谓;丢一把刚出的橙武,玩家删游戏。两类数据的"丢失代价"差着数量级,回存策略必须分贵贱——掉落稀有度超过阈值,当场触发一次该角色的回存,不等下线,也不等定时。

管道本体的四个环节:脏标记、批量合并、异步写、确认

flowchart TD
    A[玩家数据变更
拾取 / 交易 / 任务 / 强化] --> B[热层修改 + 置脏标记] B --> C{三个触发源} C -->|下线立即存| D[收集该角色全部脏数据] C -->|定时兜底 5 min| D C -->|事件触发:贵重品| D D --> E[写合并
同 key 多次修改只留最新] E --> F[组装事务包
每玩家一个事务] F --> G[异步写队列
限速削峰] G --> H[(DB 提交)] H -->|成功| I[清脏标记 + 删温层缓存] H -->|失败| J[重试,最多 3 次] J -->|仍失败| K[死信队列 + P1 报警
数据转储本地磁盘] style K fill:#f5e6e0,stroke:#b5543a style C fill:#f0e9db,stroke:#4a4a45

确认语义是管道的良心:只有 DB 提交成功,才清脏标记、才删缓存。失败进重试,三次失败进死信队列、把数据转储到本地磁盘、同时拉 P1 报警。回存失败必须按最高优先级处理——它不报警的时候,玩家数据正在悄悄蒸发。

# 伪代码:Zone 内的脏标记回存循环
DIRTY = {}                     # key=(char_id, table) -> 最新行快照

def mark_dirty(char_id, table, row):
    DIRTY[(char_id, table)] = row        # 同 key 覆盖,天然写合并

def flush_batch(reason):                 # reason: logout / timer / event
    if not DIRTY:
        return
    batch, DIRTY = list(DIRTY.items()), {}
    for char_id, rows in group_by_char(batch):
        write_queue.enqueue(char_id, rows, reason)   # 每玩家一个事务包

def write_worker():
    for job in write_queue.consume():
        try:
            db.transaction(lambda tx:
                [tx.upsert(t, r) for (t, r) in job.rows])
            on_save_committed(job.char_id)   # 清缓存(前文两条纪律)
            metrics.incr("save.ok", tags=[job.reason])
        except DbError as e:
            retry_or_deadletter(job, e)        # 三次后进死信 + 转储 + 报警

# 定时兜底:每 300 秒 flush_batch("timer")
# 事件触发:稀有掉落时 flush_single(char_id, "event")

这套管道要写多大?用数字说话。回存带宽取决于三个量:在线人数、每个玩家的回存触发频率、单次合并后的事务包大小。

W=Nλs,Wpeak=kW\overline{W} = N \cdot \lambda \cdot s, \qquad W_{\text{peak}} = k \cdot \overline{W}

其中 NN 是在线人数,λ\lambda 是平均每玩家每秒的回存触发率(事件加定时摊薄),ss 是单次事务包平均大小,kk 是峰值毛刺系数。代入一万在线的数:λ\lambda 取每 30 秒一次(即 1/30s11/30\,\text{s}^{-1}),ss 取 8 KB(角色主表行加若干背包、任务行合并后),得到

W=104×130×8KB2.7MB/s,TPS330\overline{W} = 10^{4} \times \tfrac{1}{30} \times 8\,\text{KB} \approx 2.7\,\text{MB/s}, \quad \text{TPS} \approx 330

峰值毛刺系数 kk 取 10——对应 raid 散场十分钟内三千人集体下线的场景——瞬时约 27 MB/s、3300 TPS。

对一块企业级 NVMe(写带宽 GB/s 级、随机写 IOPS 数万),这点量是零头。回存管道的指标从第一天起就只有一个:写得平。写得快从未进入过设计目标清单:削峰(下线请求进队列限速,宁可玩家下线动画多转两秒)、合并(40 次修改变 1 次写)、隔离(金币行与背包行拆事务,热点行单独走记账队列)。真正的瓶颈不在带宽,在锁等待和 WAL(预写日志)的 fsync 延迟——3300 TPS 的冲击下,它们会让提交延迟从 2 毫秒涨到 200 毫秒,而玩家感知到的就是"下线卡住"。

定时兜底的周期就是崩溃时的损失窗口:5 分钟扫描一次,进程猝死最多丢 5 分钟普通数据,高价值数据另有事件触发兜底。这个周期与开篇事故的"8 小时快照"是同一枚硬币的两面——快照管全服级灾难,回存管进程级灾难,两个 RPO 各管一段,谁都替代不了谁。

冷层自己也要上保险,这就是开篇那个"快照"的真身:全量备份加 WAL 归档。关系库的预写日志本身就是一份有序的全量变更记录,把它实时归档到异地存储,配合定期全量快照,就得到 PITR(时间点恢复)能力——回档不再只有"回到昨天 20 点"一个选项,而是能恢复到任意一分钟。开篇那场事故若有 PITR,损失窗口可以从 8 小时压到漏洞刚开始扩散的那几分钟。代价是存储与纪律:WAL 归档每天几十 GB 起,恢复演练每季度要真刀真枪做一次——没演练过的备份,等于没有备份。

一致性:事务边界画在"钱货两讫"上

持久化的正确性最终归结为一个判断:哪些修改必须同生共死。答案也固定——一次不可再分的最小经济动作,就是一个事务。玩家间交易、拍卖行成交、邮件附件领取、公会金库存取,各自是一个事务;而"打怪掉三件装备"可以是三个事务,因为掉两件也不会有人觉得世界错了。

同一个 Zone 内的交易好办:内存态先改,回存时一个事务包原子落库。难的是跨 Zone——买家在新手村 Zone-1,卖家在港口 Zone-2,谁都没法单独保证"货钱同时换手"。这时需要一个仲裁者:交易服务(挂在 World 层,见第 25 章架构图)。它主导一套"锁定、校验、双写、确认"的序列:

sequenceDiagram
    autonumber
    participant A as 玩家A
Zone-1 participant T as 交易仲裁
Trade Service participant B as 玩家B
Zone-2 participant D as DB A->>T: 发起交易(物品 UID 清单) T->>A: 锁定物品(内存态 locked,禁止再操作) T->>B: 锁定金币(冻结对应额度) B->>T: 确认交易清单 T->>T: 校验:UID 归属 / 绑定标记 / 数量 / 余额 alt 校验失败 T->>A: 解锁归还 T->>B: 解冻归还 else 校验通过 T->>D: BEGIN:双写事务 D->>D: A 背包删行 + B 背包插行
金币两行变动 + 写 trade_ledger alt 任一步影响行数 ≠ 预期 D-->>T: ROLLBACK T->>A: 从锁定快照恢复内存 T->>B: 从锁定快照恢复内存 else 全部精确命中 D-->>T: COMMIT T->>A: 解锁并应用结果 T->>B: 解锁并应用结果 end end Note over T: 任一方掉线:超时取消
仲裁进程崩溃:锁带 TTL,自动释放

每一步失败路径都要给出"钱货两讫"的保证,逐条过一遍。锁定失败:物品正在别的交易里,直接拒绝,各归原主。校验失败:归属变了或余额不够,解锁归还。DB 任一步失败:整个事务回滚,双方内存从锁定前的快照恢复——物品和金币回到交易前,一分不多一分不少。任一方中途掉线:仲裁超时取消,同样回滚。仲裁进程自己崩溃:锁必须带 TTL,超时自动释放,否则一批玩家的装备会被锁成永久幽灵。任何一条路径下,结局只有两种:原样归还,或精确交换,不存在第三种。

仲裁侧的事务代码长这样:

# 伪代码:交易仲裁的双写事务——要么全成,要么全不成
def execute_trade(a, b):
    if not (lock_items(a.offer) and lock_gold(b, b.gold_offer)):
        return cancel("锁定失败:物品或金币正在其他事务中")
    try:
        validate(a, b)                    # 归属 / 绑定 / 数量 / 余额快照
        db.begin()
        for item in a.offer:
            n = db.exec("DELETE FROM bag_item WHERE uid=? AND owner=?",
                        item.uid, a.id)
            assert n == 1                 # 行数不符 = 并发动了数据
            db.exec("INSERT INTO bag_item(uid, owner, tpl_id, affixes)"
                    " VALUES(?,?,?,?)", item.uid, b.id, item.tpl, item.affixes)
        # 余额条件写进 UPDATE:隔离级别配错也扣不成负数
        n = db.exec("UPDATE account SET gold=gold-? WHERE char_id=? AND gold>=?",
                    b.gold_offer, b.id, b.gold_offer)
        assert n == 1
        db.exec("UPDATE account SET gold=gold+? WHERE char_id=?",
                b.gold_offer, a.id)
        db.exec("INSERT INTO trade_ledger(a_id,b_id,items,gold,ts)"
                " VALUES(?,?,?,?,now())", a.id, b.id, a.offer, b.gold_offer)
        db.commit()
    except Exception as e:
        db.rollback()
        unlock_and_restore(a, b)          # 内存回到锁定前快照
        alarm("trade_tx_failed", e)       # 交易失败是 P1 告警
    else:
        unlock_and_apply(a, b)            # 提交成功才把结果写回内存

三个细节是血泪换来的。余额条件写进 UPDATE 的 WHERE 里,配合影响行数核对,即使有人把隔离级别改回读已提交,超扣也会被这一层挡下。每一步都断言影响行数精确等于预期——行数是数据库给你的并发探测器,不符就说明有别人动了数据,立刻回滚。ledger 流水与业务写同事务提交,它本身不改钱货,但它是审计的根:开篇那场事故里,没有它就回答不了"每件装备的血缘"。

物品实体在这套机制下的状态流转,一图收束:

stateDiagram-v2
    [*] --> 内存热态: 登录加载
    内存热态 --> 脏标记: 任何修改
    脏标记 --> 写队列: 三保险触发
    写队列 --> 已落库: 事务提交确认
    已落库 --> 内存热态: 清脏,继续服役
    写队列 --> 脏标记: 写失败,等待重试
    内存热态 --> 交易中锁定: 进入交易/上架
    交易中锁定 --> 内存热态: 取消/超时/回滚归还
    交易中锁定 --> 写队列: 成交,随双写事务落库
    内存热态 --> [*]: 下线保存后卸载

最后把一致性收成三个不变量,运维和新人各抄一份贴墙上:物品 UID 全服唯一且守恒,任何时刻同一 UID 只能有一个主人(复制检测就查这个);每一笔物品或货币的转移,ledger 里必有成对记录;DB 提交是唯一真相,内存永远是副本——两边不一致时,信 DB。

这套"锁定、校验、双写、确认"也不只服务玩家面对面交易。拍卖行成交是它与挂单系统的组合:锁的是挂单,校验的是买家余额;邮件附件领取更简单,锁的是邮件,双写的是收件人背包与邮件状态。把仲裁写成通用服务——别让它变成交易专用逻辑。后面每一个新玩法——公会仓库、摆摊、跨服邮寄——都是配置,不新起任何工程。

回档与补偿:按按钮前的决策树

回档是 MMO 的核弹,威力大、辐射久。按不按、按多大,不能靠凌晨四点的值班群现场发挥,必须提前写成决策树。

flowchart TD
    X[数据事故被发现] --> Y{影响面有多大}
    Y -->|单个玩家:客服报单| Z1[GM 工具处理
优先补发,再考虑个人快照恢复
不动时间线] Y -->|一批玩家:单 Zone 崩溃| Z2[局部补偿
按 ledger 审计补回损失
不回档] Y -->|全服级| Q{污染可枚举吗
ledger 能追回每一笔吗} Q -->|能| Z3[定向回收 + 封禁利用者
不动时间线] Q -->|不能:已流入市场| R{快照损失 vs 污染速度} R -->|污染仍在扩散| Z4[全服回档到污染前最近快照
+ 统一补偿方案] R -->|已平息且体量小| Z5[观察 + 定向回收
避免回档造成二次伤害] Z4 --> S[补偿四原则
补时间不补物品 / 补绑定不补流通
全服统一不走申请 / 贵重品走审计补发] style Z4 fill:#f5e6e0,stroke:#b5543a

个人与全服两条路线,成本完全不同。个人问题永远优先补发——恢复快照是备选手段,不是首选。补发只加不动减,不会误伤玩家后来获得的任何东西;个人快照恢复要覆盖当前数据,等于对这个玩家单独回档,只在他明确同意时用。全服回档只在"经济污染不可枚举"时按:复制装备已经过拍卖行洗了几手,每一笔追不回来,或者追回来的成本高过回档的损失。决策的算术很直白:快照损失(全服 N 小时进度)对比污染体量(刷出来的装备值多少钱、还在不在扩散),但按下按钮的人要清楚,他删掉的是几万人真实的几个小时。

回档之后,补偿邮件是比回档本身更考手艺的活。四条原则,条条对应一次真实翻车。

补时间不补物品。玩家失去的是 8 小时游戏时间,那就还时间:双倍经验 buff 三天、副本重置券。直接补"大概相当于 8 小时收益"的装备和金币,等于官方定价了所有人的时间,肝帝觉得亏了,休闲玩家觉得赚了不该赚的,两头挨骂。

补绑定不补流通。补偿进流通市场就是印钞。某项目回档后全服发一万金"压惊",一周内拍卖行物价翻倍,新人连药水都买不起——回档治好的通胀,被补偿邮件原样奉还。产出与回收的平衡是第 27 章的主题,这里只记住一条:补偿的每一分钱都是货币政策。绑定补给箱、限时时装、双倍 buff,这些东西没有流通价值,发多少都不冲击经济。

全服统一,不走申请。"受影响的玩家请提交工单"听起来公平,实际是灾难:客服被工单淹没,真正损失大的玩家和跟风薅羊毛的分不出来,最后按闹分配。按快照统一发放,标准公开,没得谈。

贵重品走审计补发。常规损失统一补,但"我那把正常掉落的橙武"必须单独处理:查 ledger、人工核对、定向补发。数量不会大(贵重品本来就少),但每一件都是社区舆情的发射架,值得花人工。

两个运营细节收尾。补偿邮件一律带 30 天过期,防止工作室囤小号领补偿养农场。发放走异步队列限速,别把邮件服务和数据库在打不开的档口再打一次——回档当晚同时给全服写信,也是一种流量攻击。

小结

快照间隔是你敢对玩家承诺的最大损失。全量快照管全服级灾难,回存管进程级灾难,两个 RPO 各管一段,缺了哪个,事故都会替你补上这一课。热层服务手感,冷层服务真相,温层只做可重建的加速器——把唯一真相放进缓存,等于把房产证存在快捷酒店。字段列化还是塞 JSON,只看一件事:它会不会进 WHERE。会,就列化;不会,才配整存整取。背包整体塞 JSON blob,是拿写放大、并发覆盖和审计能力换一时的省事。事务边界画在"钱货两讫"上:余额条件写进 UPDATE 的 WHERE,影响行数逐条核对,ledger 与业务写同事务提交——隔离级别会被人改错,这三层不会。补偿补时间不补物品、补绑定不补流通。回档治好的通胀,别用补偿邮件原样奉还。

上手任务

  1. 给背包表建索引。写出 BAG_ITEM 的建表语句与覆盖索引,用 EXPLAIN 验证"按 char_id 查背包列表"走 index-only scan。验收:执行计划无回表,rows 估算接近实际行数(约 120);再删掉覆盖索引跑一次,观察回表次数从 0 涨到 120。
  2. 实现脏标记回存循环。用内存 mock 一个 DB,实现 mark_dirty、flush_batch、写合并与失败重试。验收:同一角色 1 分钟内 40 次任务进度修改,落库 UPDATE 数为 1;kill -9 进程后,数据损失不超过定时兜底周期(5 分钟);写库连续失败 3 次时死信队列有记录且报警触发。
  3. 复现并修复并发超扣。两个线程对同一账户并发扣金币,隔离级别设为读已提交。验收:不加防护时能复现负余额;加上 WHERE gold >= ? 与影响行数断言后,一万次并发扣款零超扣、零复制,且失败方收到明确错误而非静默成功。
  4. 写一份回档预案。为你的项目写一页纸预案:决策树、RPO/RTO 目标数字、补偿邮件模板(绑定补给与贵重品审计各一份)。验收:把它交给一个不了解系统的同事,他能在 10 分钟内说清"单玩家丢装备"和"全服复制漏洞"分别走哪条路径。

下一章

数据存住了、写不丢了,接下来该回答数据里的数字从哪来、到哪去:第 27 章把「远港 Online」的经验曲线、掉落保底与经济系统摆到电子表格上,让通胀死在监控指标里,而不是玩家的嘴里。

延伸阅读

  • 官方文档:Unreal Engine → Programming → Unreal Architecture → Serialization,配合 SaveGame 系统词条——单机存档与对象序列化的引擎侧基础,本章的自建持久化层与它的边界。
  • PostgreSQL 官方文档:Chapter 13 "Concurrency Control"——隔离级别的精确定义与显式锁定(SELECT ... FOR UPDATE)语义,MySQL 读者对应看 InnoDB 的事务与锁章节。
  • Redis 官方文档:"Persistence" 与 "Key eviction" 词条——RDB/AOF 的差异与过期策略,理解温层"可重建"三个字的分量。
  • Martin Kleppmann《Designing Data-Intensive Applications》第 7 章(事务)与第 8 章(分布式系统的麻烦)——把本章的工程直觉放回理论框架。
  • GDC Vault 主题方向:以 "MMO postmortem""database""economy rollback" 检索,找 EVE Online 与 Star Wars Galaxies 的架构与经济复盘——老牌 MMO 在回档与补偿上交过的学费,比任何教科书都具体。