一、脏写的三条路径
脏写不是“数据库写坏了”,而是“后写的值覆盖了先写但更重要的值”。它的可怕之处在于不报错:两条语句都成功,事务都提交,只是其中一次变更凭空消失。游戏服务端最常见的脏写有三条路径。
第一条是同玩家并发请求。 玩家在两台设备上同时在线,或者客户端因为超时重发了同一个请求,两个逻辑线程同时修改同一份角色数据。一个在扣金币买药,一个在领任务奖励;如果两边都读取旧快照、各自计算、再整行写回,后提交的那次会把前一次的结果覆盖掉。
第二条是跨服写。 玩家在本服有一份角色数据,跨服战场又复制了一份只读快照。战场结束时要把战果写回本服,而本服在此期间可能已经完成了升级、消费、邮件领取。如果战果写回采用“整行覆盖”,本服的新进度会被战场快照直接抹掉。
第三条是异步保存覆盖新值。 逻辑线程把存档快照丢进保存队列就返回,队列里的旧快照可能晚于后续操作落库。玩家先买了一件装备,又立刻卖掉;如果保存队列先写“有装备”的旧快照,再写“卖掉后”的新快照,顺序正确时没问题;一旦重试、乱序或合并策略写错,旧值就会覆盖新值。
这三条路径有共同结构:读旧值、算新值、写回之间没有隔离。防护手段本质上都是在这三步之间加一个“你读到的还是当前值吗”的检查,或者干脆让同一份数据只有一个写者。
二、四种防护手段
工程上常用的防护有四种,它们不是互斥的,很多系统会组合使用。
| 手段 | 机制 | 冲突时行为 | 吞吐 | 适用场景 |
|---|---|---|---|---|
| 乐观锁 version | 写入时带 WHERE version = ?,成功后版本加一 | 影响行数为零,调用方重读重试 | 高,无锁等待 | 玩家存档、配置、跨服快照写回 |
| CAS | 比较并交换,常见于 Redis WATCH/MULTI 或 Lua | 返回失败,调用方重试 | 高 | 缓存计数、排行榜、分布式锁外的小状态 |
| 行锁 | SELECT ... FOR UPDATE 锁住目标行 | 后到者等待,超时或死锁 | 中低,锁竞争明显 | 短事务内的资产扣减、订单状态流转 |
| 单写者 | 按 playerID 路由到唯一逻辑线程或分片 | 天然串行,无冲突 | 取决于分片 | 在线角色状态、战斗结算 |
乐观锁最适合游戏存档。它假设冲突不频繁,读的时候不加锁,写的时候用版本号做条件。冲突发生时数据库影响行数为零,调用方知道“有人先改了”,可以重读、重算、再写。它的成本是一次额外的版本比较,收益是没有长事务和锁等待。
CAS 与乐观锁思想相同,只是载体不同。Redis 的 WATCH 适合小状态,Lua 脚本可以把“读版本、比较、写回”做成原子操作。要注意 CAS 只能保护单键;跨多个键的操作仍然需要分布式锁或单写者。
行锁适合“读出来马上就要改,且改的过程很短”的场景,比如扣金币、扣库存。它的优点是简单,缺点是锁持有时间一长就会排队。绝对不要在持有行锁时做远程调用、发邮件、写日志到慢设备,否则一次下游抖动会拖住整张表。
单写者是架构级方案:同一个玩家的所有写请求都路由到同一个逻辑线程或同一个分片,串行处理。它从根上消除并发写,但要求路由稳定,否则玩家迁移分片时会出现两个写者。单写者不排斥版本号;版本号仍然用于检测“旧快照写回”和“跨服复制”。
四种手段的取舍可以压缩成一句话:在线状态用单写者,落库用乐观锁,短事务用行锁,缓存小状态用 CAS。 不要指望一种手段包打天下。
这四种手段可以叠加,但叠加的顺序有讲究。在线角色先由单写者串行,落库时再带版本号;版本号负责发现“这个快照已经落后”,单写者负责减少“同一时刻两个写者”的概率。两者不是重复保护,而是分别覆盖内存态与持久化态。
选型时还要算三笔账:
- 冲突概率:低冲突用乐观锁,高冲突用单写者或行锁。
- 锁持有时间:临界区里有网络调用,就不适合行锁。
- 失败代价:资产类操作宁可拒绝也不能静默覆盖,展示类数据可以接受后写覆盖。
把这三笔账算清楚,比记住哪个模式更“高级”有用。
三、整行覆盖还是字段级更新
确定防护手段后,下一个问题是写多少列。整行覆盖和字段级更新各有风险。
| 方式 | 写法 | 优点 | 风险 |
|---|---|---|---|
| 整行覆盖 | UPDATE player_save SET ...所有列... WHERE player_id=? | 简单,写入形状固定,容易批量 | 旧快照会覆盖别人刚写的新值 |
| 字段级更新 | UPDATE ... SET gold_cents=? WHERE player_id=? | 只改关心的列,减少冲突面 | 漏列、并发改同一列仍会丢更新,动态列名难校验 |
| 差量更新 | SET gold_cents = gold_cents - ? | 计数类天然可合并 | 需要业务保证幂等,失败重试可能重复扣减 |
| JSON 局部更新 | JSON_SET(data, '$.gold', ?) | 避免整段 JSON 重写 | 业务语义仍可能读改写,索引和校验弱 |
整行覆盖并不天然错误,前提是带版本条件。如果每次写入都校验 version,旧快照的写入会失败,不会覆盖新值。真正危险的是“整行覆盖 + 无版本条件”,也就是盲写。
字段级更新看起来更精细,但没有版本条件时仍会丢更新:两个请求同时改金币,都读 100,一个扣 10、一个加 20,最后写入的顺序决定结果,另一个人的变更消失。字段级更新只降低了“不同字段互相覆盖”的概率,没有解决“同一字段”的竞争。
差量更新适合金币、经验、积分这类可加减的数值。它把“读旧值再算”变成数据库内的原子运算,天然避免丢更新。但它要求幂等:请求超时后客户端重试,如果没有幂等键,可能扣两次。差量更新还要防止扣成负数,通常用 WHERE gold_cents >= ? 或数据库约束兜底。
JSON 列适合字段稀疏、结构经常扩展的数据,比如任务进度、设置项。它的风险是“读整段、改一个字段、写整段”仍然是读改写,必须配版本号;如果业务能接受,用 JSON_SET 做局部更新可以减少写放大,但跨字段的业务约束仍然要在应用层校验。
字段级更新还需要一份列白名单。列名如果来自客户端或配置,绝不能直接拼进 SQL;仓储层应只接受预定义的字段枚举,再由枚举映射到真实列名。下面两条语句的差别不在写法,而在失败语义:
-- 盲写:后提交者覆盖前一个请求
UPDATE player_save SET gold_cents = 120 WHERE player_id = 1001;
-- 版本保护:版本不匹配时影响行数为零
UPDATE player_save SET gold_cents = 120, version = version + 1
WHERE player_id = 1001 AND version = 5;
第一条语句永远“成功”,即使它覆盖了别人刚写入的 200;第二条语句在版本变化时返回零行,调用方必须处理冲突。生产代码里,凡是玩家可感知的资产变更,都应该走第二条的形状。
一个实用原则是:关键资产用差量更新加幂等键,角色全量存档用整行覆盖加版本号,展示类字段可以用字段级更新加较短 TTL。 不要为了“看起来精细”把一次业务操作拆成十几条字段更新,那会把事务边界和失败重试搞得更复杂。
四、冲突之后:重试、拒绝与合并
版本冲突不是异常,而是正常并发的结果。关键是冲突之后做什么。
| 策略 | 做法 | 适用 | 风险 |
|---|---|---|---|
| 重试 | 重读最新值,重新应用变更,再写 | 冲突少、变更可重放 | 重试风暴、重复副作用 |
| 拒绝 | 返回冲突,让客户端刷新后重试 | 客户端可感知、操作需用户确认 | 体验差,频繁冲突时不可用 |
| 合并 | 按字段或业务语义合并两边变更 | 计数、集合、无冲突字段 | 合并规则错误会静默丢数据 |
| 放弃 | 记录日志,丢弃本次写入 | 非关键遥测、展示数据 | 关键数据绝对不能放弃 |
重试要有上限和退避。无上限重试在高冲突时会形成风暴:所有请求都在读最新值、计算、再冲突,CPU 被打满,数据库连接被占住。常见做法是最多重试两到三次,每次加随机退避;超过上限就返回失败,让上层决定是提示玩家还是转异步补偿。
重试还必须保证副作用只发生一次。如果变更里有“发邮件”“扣道具”“写订单”,重试前要确认这些动作是否已经执行。做法是把副作用放在事务外,用幂等键去重;或者只重试纯计算部分,副作用由独立步骤补偿。
不是所有字段都能合并。可以合并的通常是:
- 计数类:金币加减、经验增加,可以按差量合并。
- 集合类:解锁的关卡、已读邮件,可以取并集。
- 无冲突字段:一个请求改昵称,一个请求改头像,字段级合并安全。
- 装饰性状态:最后登录时间、最近使用的表情,允许后写覆盖。
不能合并的通常是:
- 绝对值资产:背包里某格子的物品数量,必须以某一方的完整快照为准。
- 互斥状态:订单已支付与已取消,不能各取一半。
- 有先后依赖的操作:先穿装备再强化,顺序颠倒结果不同。
- 消耗型道具:两个请求都认为“还有一件”,合并后变成负库存。
跨服写回尤其要小心。战果里包含“战场内获得的荣誉”,这是可以按差量合并的;但“战场结束时的角色等级”不能直接覆盖本服等级,因为本服可能已经升级。正确做法是只写回战场产出的增量,角色本体仍由本服单写者维护。
冲突处理还要配指标。至少要记录三类计数:版本冲突次数、重试次数、重试后仍失败次数。冲突突然上升,通常意味着某个热点玩家被多端同时操作,或者保存队列出现乱序;重试后仍失败上升,则说明冲突不是偶发,而是写入模型需要调整。
幂等键是重试的安全带。客户端每次操作带一个 request_id,服务端在事务里记录“这个请求已经处理过”,重复请求直接返回上次结果,而不是再扣一次金币。幂等键要按业务范围唯一,不能只用玩家 ID,否则同一玩家的两次正常购买会被误判为重复。
重试预算也要按业务分级。强化装备、购买道具可以重试两三次;抽卡、开箱这类有随机结果的操作,重试必须复用第一次的随机种子,否则会变成“重试一次多抽一次”。无法安全重放的操作用拒绝,而不是硬重试。
五、Go 实现:VersionedStore
下面的实现把乐观锁收敛到一个很小的方法里:Update 接收期望版本和变更函数,只有版本匹配才执行变更并递增版本。
// file: ch06/persist/version.go
// tier: L1
// verify: go test ./ch06/persist/
package persist
import (
"errors"
"sync"
)
var (
ErrRecordNotFound = errors.New("persist: record not found")
ErrRecordExists = errors.New("persist: record already exists")
ErrVersionConflict = errors.New("persist: version conflict")
)
// VersionedRecord 是带乐观锁版本号的存档行。
// Version 只由存储递增,业务不能用它表达游戏内数值。
type VersionedRecord struct {
PlayerID uint64
Data string
Version uint64
}
// VersionedStore 用一条 version 条件实现 CAS:
// 只有调用方看到的版本仍是当前版本,写入才被接受。
type VersionedStore struct {
mu sync.RWMutex
rows map[uint64]VersionedRecord
}
func NewVersionedStore() *VersionedStore {
return &VersionedStore{rows: make(map[uint64]VersionedRecord)}
}
func (s *VersionedStore) Create(playerID uint64, data string) (VersionedRecord, error) {
if playerID == 0 {
return VersionedRecord{}, errors.New("persist: empty player id")
}
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.rows[playerID]; ok {
return VersionedRecord{}, ErrRecordExists
}
record := VersionedRecord{PlayerID: playerID, Data: data, Version: 1}
s.rows[playerID] = record
return record, nil
}
func (s *VersionedStore) Get(playerID uint64) (VersionedRecord, error) {
s.mu.RLock()
defer s.mu.RUnlock()
record, ok := s.rows[playerID]
if !ok {
return VersionedRecord{}, ErrRecordNotFound
}
return record, nil
}
// Update 是“读取-修改-写回”的原子版本。
// mutate 在锁内执行,只能做本地计算,不能发起 RPC 或写别的存储。
// 版本不匹配时返回 ErrVersionConflict,调用方必须重新读取后再决定重试或放弃。
func (s *VersionedStore) Update(
playerID uint64,
expectedVersion uint64,
mutate func(old VersionedRecord) (string, error),
) (VersionedRecord, error) {
s.mu.Lock()
defer s.mu.Unlock()
record, ok := s.rows[playerID]
if !ok {
return VersionedRecord{}, ErrRecordNotFound
}
if record.Version != expectedVersion {
return VersionedRecord{}, ErrVersionConflict
}
data, err := mutate(record)
if err != nil {
return VersionedRecord{}, err
}
record.Data = data
record.Version++
s.rows[playerID] = record
return record, nil
}
Update 把“检查版本、执行变更、递增版本、写回”放在同一把锁里,因此同一时刻只有一个调用能成功。mutate 在锁内执行,必须只做本地计算;如果里面发起 RPC、写日志到远端或等待网络,锁会被长时间持有,吞吐迅速下降。真实实现里,mutate 通常是纯粹的内存变换,所有外部调用都放在 Update 之外。
版本只在写入成功后递增。mutate 返回错误时,记录保持原样,版本不变,调用方可以修正输入后重试。这个区分很重要:业务校验失败不应该消耗版本号,否则无意义的失败会让其他写者频繁冲突。
对应到 SQL,核心语句只有一条:
UPDATE player_save
SET data = ?, version = version + 1, updated_at = ?
WHERE player_id = ? AND version = ?;
执行后必须检查影响行数。行数为 1 表示成功,为 0 表示记录不存在或版本已变,两者要区分:先查记录是否存在,再决定返回 ErrRecordNotFound 还是 ErrVersionConflict。不要用“先 SELECT 再 UPDATE”替代这条带条件的语句,否则检查和写入之间仍有窗口。
内存实现用一把全局锁,适合演示和小规模测试;生产环境按 playerID 分片加锁,或者直接依赖数据库的行级锁与版本条件。分片锁的关键是路由稳定:同一个玩家必须始终落到同一把锁,否则等于没锁。
六、失败场景:异步保存覆盖新值
某款 MMO 的存档采用异步保存:逻辑线程每五秒把在线玩家的完整快照丢进队列,后台工作协程批量写库。为了“提高吞吐”,写库语句是整行覆盖,没有版本条件。
事故发生在一次版本更新后。玩家在强化装备,请求 A 读取版本 5 的存档,计算后把装备强化到 +7,版本变成 6,同时把快照放入保存队列。紧接着玩家又领取了邮件奖励,请求 B 读取版本 6,写入金币和道具,版本变成 7,也放入队列。
保存队列因为数据库抖动发生了重试。工作协程先处理了 A 的快照,把整行写成版本 6 的内容;随后处理 B 的快照时,B 的快照本身是版本 7,内容也包含强化结果,看起来没问题。但另一条路径上,一个更早入队、因超时被重新排到队尾的旧快照版本 5 最后落库,整行覆盖,强化结果和邮件奖励一起消失。玩家重新登录后发现装备退回 +6,客服查日志只能看到三次成功的 UPDATE。
复盘时有四个根因:
- 写库语句没有
WHERE version = ?,旧快照可以覆盖新快照。 - 保存队列没有按玩家串行,重试和合并打乱了顺序。
- 快照没有携带版本,工作协程无法判断新旧。
- 监控只看写入成功率,没有看版本冲突和影响行数。
修复分四步。第一,所有存档写入必须带版本条件,影响行数为零时进入冲突处理。第二,保存队列按 playerID 分片串行,同一玩家的快照不并发。第三,快照携带版本,合并时只保留版本更高的那份;旧快照直接丢弃并记录指标。第四,冲突时不要静默覆盖,而是重读最新存档,把本次变更重新应用后再写;如果变更无法重放,就拒绝并告警。
这个案例的教训是:异步保存不是“把写操作挪到后台”这么简单,它把顺序问题从线程内搬到了队列里。 只要写入可能乱序,就必须有版本或序列号来判断新旧;没有版本条件的整行覆盖,在异步路径上迟早会覆盖掉新值。
另一个常见变体是跨服写回。战场服在结束时把整份角色快照发回本服,本服直接覆盖数据库。战场持续二十分钟,本服玩家可能已经完成日常、领取邮件、更换装备;写回一落库,这些变更全部回退。修复方式不是“让战场服也加锁”,而是把写回内容缩小为战场产出的增量,并携带战场开始时的角色版本。本服收到后先校验版本,版本已变则只合并荣誉、战功等增量字段,角色本体保持不变。
这类问题的共同点是写者离数据太远。写者越远,越不应该整行覆盖;跨服、跨进程、异步队列都适用这条规则。
七、测试覆盖
配套测试覆盖六类行为:创建后版本为一、重复创建被拒绝、正常更新后版本递增、旧版本写入返回 ErrVersionConflict 且不改变数据、变更函数报错时版本不变、记录不存在时返回 ErrRecordNotFound。
并发测试让六十四个 goroutine 同时用版本 1 更新同一条记录,断言只有一个成功、其余全部冲突,最终版本为 2。这个测试验证的是“检查与写入不可分割”;如果实现里把版本检查和赋值拆成两步,或者锁的粒度不对,就会有两个以上成功。
另有一个重试测试:第一次用旧版本失败,重新读取最新版本后再次更新成功。它模拟的是调用方的正确恢复路径,而不是存储层自动重试。存储层不应该替调用方决定重试几次,因为不同业务的副作用和幂等性完全不同。
并发测试的价值还在于暴露锁的实现错误。全局锁换成按玩家分片锁之后,同一个测试应该继续通过;如果分片键算错,两个 goroutine 落到不同锁上,成功数就会大于一。测试里可以让所有 goroutine 使用同一个 playerID,再补一个不同 playerID 应互不阻塞的用例,确认分片没有退化成全局串行。
版本号本身的溢出也要考虑。uint64 在实际业务里几乎不可能用完,但如果用 int32 或数据库 INT,高频写入几年后仍可能触顶。触顶后版本回绕会让旧快照重新匹配,所以版本列应使用足够宽的无符号类型,并在接近上限时告警而不是静默回绕。
建议在 CI 里加 -race。版本存储的并发错误往往不是逻辑判断写错,而是锁外读了共享字段;race detector 能在测试阶段暴露这类问题,比线上丢数据后再排查便宜得多。
小结
脏写的本质是“读旧值、算新值、写回”缺少隔离。防护手段有乐观锁、CAS、行锁和单写者,分别适合落库、缓存小状态、短事务和在线角色状态。游戏存档最常用的是“单写者维护在线状态 + 乐观锁保护落库”。
整行覆盖不是问题,盲写才是问题。只要写入带 version 条件,旧快照就会失败而不是覆盖新值。字段级更新降低了冲突面,但不能替代版本检查;差量更新适合计数类资产,但必须配幂等键和下限约束。
冲突之后要有明确策略:能重放就有限重试,不能重放就拒绝,能按业务语义合并才合并。异步保存必须按玩家串行、携带版本、在冲突时重读重算,不能把“写成功”当成“写对了”。
常见错误与边界
UPDATE ... SET不带 version:这是最直接的脏写来源。旧快照、重试请求、跨服写回都能覆盖新值。- 异步保存不携带版本:队列只能看到一份数据,无法判断新旧。每个快照都必须带版本或单调序列号。
- 冲突后静默覆盖:捕获到影响行数为零却忽略,等于把乐观锁退化成盲写。冲突必须进入重试、拒绝或告警。
- 在锁内做远程调用:行锁或互斥锁持有时间被网络放大,吞吐骤降,严重时拖垮整个分片。远程调用放在锁外。
- 用字段级更新代替版本检查:不同字段的并发安全,不代表同一字段安全。同一字段的读改写仍然会丢更新。
- 合并规则拍脑袋:金币、背包数量、订单状态不能简单取一方。合并只适用于计数、集合和无冲突字段。
- 重试没有上限:高冲突时形成重试风暴,CPU 和连接池被打满。重试要有次数上限、退避和指标。
- 把版本号当业务字段:版本只由存储维护,业务不能拿它表示等级或赛季。混用会让冲突判断失去意义。
边界方面,乐观锁假设冲突不频繁;如果同一玩家每秒有几十次写,重试成本可能高于行锁,此时应考虑单写者或合并写请求。跨服场景还要处理网络分区:战场服写回失败时,不能假设本服数据没变,必须重新读取并合并增量。版本号能检测冲突,但不能自动决定业务上谁对;最终采用哪一方的值,仍要由业务规则回答。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。