《游戏服务端编程实践》6.2.3 数据同步与脏写防护

从两个请求同时修改同一玩家、跨服写回与异步保存覆盖新值三类脏写场景出发,对比乐观锁、CAS、行锁与单写者模型的适用边界,说明整行覆盖和字段级更新的取舍,讨论可合并与不可合并字段的边界,并给出带版本校验、冲突后的重试拒绝合并策略与并发测试的 Go 存储实现。

一、脏写的三条路径

脏写不是“数据库写坏了”,而是“后写的值覆盖了先写但更重要的值”。它的可怕之处在于不报错:两条语句都成功,事务都提交,只是其中一次变更凭空消失。游戏服务端最常见的脏写有三条路径。

第一条是同玩家并发请求。 玩家在两台设备上同时在线,或者客户端因为超时重发了同一个请求,两个逻辑线程同时修改同一份角色数据。一个在扣金币买药,一个在领任务奖励;如果两边都读取旧快照、各自计算、再整行写回,后提交的那次会把前一次的结果覆盖掉。

第二条是跨服写。 玩家在本服有一份角色数据,跨服战场又复制了一份只读快照。战场结束时要把战果写回本服,而本服在此期间可能已经完成了升级、消费、邮件领取。如果战果写回采用“整行覆盖”,本服的新进度会被战场快照直接抹掉。

第三条是异步保存覆盖新值。 逻辑线程把存档快照丢进保存队列就返回,队列里的旧快照可能晚于后续操作落库。玩家先买了一件装备,又立刻卖掉;如果保存队列先写“有装备”的旧快照,再写“卖掉后”的新快照,顺序正确时没问题;一旦重试、乱序或合并策略写错,旧值就会覆盖新值。

这三条路径有共同结构:读旧值、算新值、写回之间没有隔离。防护手段本质上都是在这三步之间加一个“你读到的还是当前值吗”的检查,或者干脆让同一份数据只有一个写者。

二、四种防护手段

工程上常用的防护有四种,它们不是互斥的,很多系统会组合使用。

手段机制冲突时行为吞吐适用场景
乐观锁 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 和连接池被打满。重试要有次数上限、退避和指标。
  • 把版本号当业务字段:版本只由存储维护,业务不能拿它表示等级或赛季。混用会让冲突判断失去意义。

边界方面,乐观锁假设冲突不频繁;如果同一玩家每秒有几十次写,重试成本可能高于行锁,此时应考虑单写者或合并写请求。跨服场景还要处理网络分区:战场服写回失败时,不能假设本服数据没变,必须重新读取并合并增量。版本号能检测冲突,但不能自动决定业务上谁对;最终采用哪一方的值,仍要由业务规则回答。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

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