一次乐观锁更新丢失引发的数据一致性思考
一次看似安全的更新
事情发生在一个再普通不过的下午。运营同学在后台改了一条订单的备注,前端提示“保存成功”,但刷新之后,备注还是旧的。更奇怪的是,数据库里的 `version` 字段确实增加了,说明更新语句执行过。可业务数据就是没变。
这条订单没有高并发,也没有复杂事务,只有一个简单的乐观锁更新。也正因为如此,这次“更新丢失”才显得格外刺眼:我们以为加了版本号就进了保险箱,结果保险箱的门根本没锁上。
乐观锁到底锁住了什么
乐观锁的典型写法是:
update orders
set remark = ?, version = version + 1
where id = ? and version = ?;
它的前提是:更新时必须带上查询时拿到的旧版本号。如果期间有别人改过,`version` 已经变了,`where` 条件不匹配,影响行数为 0,调用方就知道“我基于旧快照的修改已经过期了”。
问题在于,很多代码只做到了“带上版本号”,却没有处理“影响行数为 0”之后该怎么办。有的悄悄吞掉结果,有的重试时仍然拿着旧对象,有的干脆在重试前把新值又写回旧快照。于是,一次本应被拦下的过期更新,最后以“成功”的姿态覆盖了别人的修改。
更隐蔽的一种情况是:更新被拆成了多次。先更新备注,再更新状态,再更新扩展字段,每次都用同一个版本号。第一次更新后 `version` 已经变了,后面几次全部失败,但业务代码只检查了第一次的返回值。最终数据看起来“部分成功”,一致性却已经碎了。
更新丢失不是并发量的问题
很多人把更新丢失归因于“并发太高”。其实并发只是放大器。真正的根因通常有三个:
第一,把乐观锁当成了事务隔离。乐观锁只能保证“这次更新基于的版本没变”,不能保证“读出来的多个字段之间逻辑一致”。如果业务规则依赖多个字段的联合判断,单行版本号远远不够。
第二,重试逻辑没有重新读取。正确的重试应该是:重新查询最新数据,基于最新数据重新计算业务值,再发起更新。如果只是把旧对象里的字段换个版本号再提交,等于把过期快照强行写入新版本,更新丢失照样发生。
第三,没有区分“我可以覆盖”和“我不该覆盖”。比如两个运营同时改备注,后提交的人覆盖前一个人,产品上也许可以接受;但如果是库存扣减、积分变更、状态流转,覆盖就意味着资损或状态错乱。乐观锁不解决业务语义,它只提供冲突检测。
从版本号到一致性闭环
那次问题之后,我们做了几件事,算不上银弹,但至少把坑填上了。
一是所有乐观锁更新必须检查影响行数,并且把“0 行”当作一等公民处理。要么向上抛出冲突异常,让业务决定重试还是提示用户;要么进入有限次数的重试循环,每次重试都重新加载聚合根。
二是把“读—算—写”收敛到一个领域方法里。版本号校验放在最外层,业务规则放在里面,不允许外部先查再拼一个对象直接更新。这样重试时重新执行的是完整规则,而不是只重放一个 SQL。
三是对于关键状态流转,引入状态机或条件更新。比如“待支付”只能变“已支付”,更新语句里直接带上 `where status = '待支付'`。这比单纯依赖版本号更贴近业务,也能防住一些版本号没变但语义已经非法的场景。
四是记录冲突日志。不是所有更新丢失都会报错,有些是静默覆盖。我们在关键表上增加了变更流水,至少能在事后回答:谁在什么版本上写了什么值,为什么后一个值覆盖了前一个值。
写在最后
乐观锁是一个很好的并发控制工具,但它不是数据一致性的终点。它更像一个警报器:告诉你有人在你之前改了数据。至于警报响了之后是重试、合并、放弃还是人工介入,取决于业务对一致性的定义。
一次更新丢失,暴露的往往不是 SQL 写错了,而是我们对“谁能改、基于什么改、改了之后算不算数”这些问题想得太少。版本号可以加,但一致性意识得先加上。
转载请注明出处,版权归原作者所有。
管理员
黑卡会员



