
事务能保证一组操作一起成功或失败,却不等于并发执行时业务结果一定正确。两个请求同时读库存、计算新值再写回,即使都提交成功,也可能覆盖彼此的修改。解决这类问题,关键不是机械地把隔离级别调到最高,而是先写清业务不变量,再为具体冲突选择原子更新、锁或版本检查。
先从不变量出发
以扣减库存为例,真正要守住的是“库存不能为负,并且每次成功下单只扣一次”。先把约束写成可检查的条件,才能判断事务方案是否有效。
1 | stock >= 0 |
隔离级别描述数据库允许哪些并发现象,但不同数据库的具体实现并不完全相同。不要只看配置名,应在实际引擎和版本上验证关键路径。
| 并发现象 | 表现 | 业务风险 |
|---|---|---|
| 脏读 | 读到其他事务尚未提交的数据 | 基于可能回滚的数据继续处理 |
| 不可重复读 | 同一事务两次读取同一行,结果不同 | 校验依据在事务中途变化 |
| 幻读 | 相同条件查询返回的行集合变化 | 汇总、范围约束出现偏差 |
| 丢失更新 | 后写入者覆盖先写入者的结果 | 扣款、库存或计数少记一次 |
优先把检查和更新合成一条语句
最危险的写法是先查库存,再在应用内计算,最后无条件更新。读取与写入之间留出了竞争窗口:
1 | SELECT stock FROM products WHERE id = :id; |
如果规则能由一条 SQL 表达,优先使用带条件的原子更新:
1 | UPDATE products |
随后检查受影响行数:等于 1 表示扣减成功,等于 0 表示商品不存在或库存不足。这样数据库在执行更新时同时判断条件,避免应用持有旧快照再覆盖新值。
需要多步决策时再选并发控制

当一次决策必须读取多行或调用复杂规则时,可以在悲观锁与乐观并发控制之间选择。
悲观锁适合冲突频繁、临界区很短的操作。锁必须在同一事务中获取并使用,所有路径按一致顺序加锁,减少死锁概率。
1 | BEGIN; |
乐观方案适合冲突较少、读取后可能思考较久的场景。表中增加 version,更新时把旧版本放进条件:
1 | UPDATE products |
受影响行数为 0 就说明数据已变化,应重新读取并判断,而不是继续提交旧结果。
| 方案 | 更适合 | 主要代价 |
|---|---|---|
| 条件原子更新 | 规则可由单条 SQL 表达 | 复杂跨行规则难表达 |
| 悲观锁 | 热点明显、冲突频繁 | 锁等待、死锁、吞吐下降 |
| 乐观版本号 | 冲突较少、事务不宜持锁 | 冲突后要重读和重算 |
重试必须有边界
死锁、序列化失败和版本冲突通常可以重试,但必须开启一个全新的事务,从头重新读取。不要在已经失败的事务里只重放最后一条 SQL,因为此前读取的前提可能已经失效。
重试应设置较小的次数上限,并加入指数退避和随机抖动;库存不足、参数错误等确定性失败则不应重试。如果事务还会发送消息或调用外部接口,要用请求幂等键、Outbox 等方式避免数据库回滚后外部副作用已经发生。
用指标和并发测试验证
线上至少观察事务时长、锁等待、死锁次数、序列化失败率和重试成功率。事务越长,锁与历史版本保留得越久,因此网络调用和用户交互不应放进数据库事务。
测试时不要只写顺序用例。可以让多个线程在屏障处同时开始,反复扣减同一商品,最后断言库存不为负、成功次数与库存变化一致;再注入超时和死锁,确认重试不会重复扣减。
落地清单
- 先定义业务不变量,再选择隔离级别和并发控制手段。
- 能用条件原子更新解决时,不拆成“先读后写”。
- 悲观锁保持事务短小,并统一多行加锁顺序。
- 乐观更新必须检查受影响行数,冲突后重新读取。
- 只重试可恢复错误,每次使用新事务,并限制次数。
- 对事务外副作用增加幂等键或可靠事件投递机制。
- 用真实数据库做并发测试,同时监控锁等待与失败率。
事务正确性不是一个隔离级别开关,而是一条从业务约束到 SQL、冲突处理和可观测性的完整链路。先缩小竞争窗口,再明确失败如何重试,才能让数据库在高并发下不仅“提交成功”,还真正得到正确结果。