版本号条件更新
乐观锁不提前锁住数据,而是在更新时校验版本:读出记录时记住版本号,写回时把旧版本号写进条件,只有影响行数为一时才算成功。影响行数为零说明这期间有别的请求改过它,业务层据此决定重试还是提示冲突。它的价值在于把并发冲突的判断从数据库的锁等待挪到业务代码里,读多写少的场景下几乎没有额外开销,代价是冲突时必须由应用决定怎么收敛。
基本信息
| 适用平台 | MySQL 8.0 / PostgreSQL 12 及以上,应用层通用模式 |
|---|---|
| 读取阶段 | 查询时带上版本号字段 |
| 写入阶段 | 更新条件里带旧版本号,同时把版本号加一 |
| 冲突判定 | 影响行数为零即表示发生并发修改 |
| PostgreSQL 变体 | 可用系统列作为版本标记,省去业务字段 |
典型使用场景
- 内容编辑:两个人同时编辑同一篇文档时避免后写覆盖前写
- 库存扣减:校验与扣减在一条语句里完成,避免超卖
- 工单状态流转:防止重复点击导致的重复流转
- 配置修改:多人维护配置项时避免互相覆盖
- 外部接口幂等:把版本号作为乐观锁的一部分实现幂等写入
条件更新写法
条件更新写法
-- 读取时记住版本号(这里是 7)
SELECT id, content, version FROM documents WHERE id = 42;
-- 写入时带旧版本号,并把版本号加一
UPDATE documents
SET content = '新内容', version = version + 1
WHERE id = 42 AND version = 7;
-- 影响行数为 1:更新成功
-- 影响行数为 0:版本冲突,需要重新读取后再试或提示用户
-- 库存扣减:把校验与扣减合并,避免超卖
UPDATE stock
SET qty = qty - 1, version = version + 1
WHERE sku_id = 100 AND version = 3 AND qty >= 1;
-- PostgreSQL:也可以用系统列作为版本标记,无需业务字段
-- UPDATE documents SET content = '新内容'
-- WHERE id = 42 AND xmin::text = '旧值';要点对照
| 环节 | 做法 | 容易出错的地方 |
|---|---|---|
| 读取 | 把版本号一并查出 | 只读业务字段,写回时无处校验 |
| 写入 | 条件里带旧版本号并自增 | 忘写条件,锁形同虚设 |
| 判定 | 按影响行数判断成功与否 | 口径不统一导致判断失真 |
| 重试 | 冲突后重新读取再试 | 沿用旧版本号重试,永远冲突 |
| 查询与更新 | 尽量合成一条语句 | 先查后改之间有窗口期 |
影响行数的口径要统一:MySQL 默认返回的是「实际发生变化」的行数,值没变时可能返回零,因此更新条件里带上版本号自增可以避免这种歧义。判断冲突与连接配置要放在一起确认。
把校验与修改合并到一条语句是避免竞态的关键:先查后改之间永远存在时间窗口,期间别的请求可能已经改过数据。