把事务并发做对:隔离级别、锁与冲突重试

数据库事务并发控制

事务能保证一组操作一起成功或失败,却不等于并发执行时业务结果一定正确。两个请求同时读库存、计算新值再写回,即使都提交成功,也可能覆盖彼此的修改。解决这类问题,关键不是机械地把隔离级别调到最高,而是先写清业务不变量,再为具体冲突选择原子更新、锁或版本检查。

先从不变量出发

以扣减库存为例,真正要守住的是“库存不能为负,并且每次成功下单只扣一次”。先把约束写成可检查的条件,才能判断事务方案是否有效。

1
2
3
stock >= 0
成功扣减次数 = 已确认且未取消的订单数
同一个 request_id 最多产生一次扣减

隔离级别描述数据库允许哪些并发现象,但不同数据库的具体实现并不完全相同。不要只看配置名,应在实际引擎和版本上验证关键路径。

并发现象 表现 业务风险
脏读 读到其他事务尚未提交的数据 基于可能回滚的数据继续处理
不可重复读 同一事务两次读取同一行,结果不同 校验依据在事务中途变化
幻读 相同条件查询返回的行集合变化 汇总、范围约束出现偏差
丢失更新 后写入者覆盖先写入者的结果 扣款、库存或计数少记一次

优先把检查和更新合成一条语句

最危险的写法是先查库存,再在应用内计算,最后无条件更新。读取与写入之间留出了竞争窗口:

1
2
3
SELECT stock FROM products WHERE id = :id;
-- 应用计算 stock - qty
UPDATE products SET stock = :new_stock WHERE id = :id;

如果规则能由一条 SQL 表达,优先使用带条件的原子更新:

1
2
3
UPDATE products
SET stock = stock - :qty
WHERE id = :id AND stock >= :qty;

随后检查受影响行数:等于 1 表示扣减成功,等于 0 表示商品不存在或库存不足。这样数据库在执行更新时同时判断条件,避免应用持有旧快照再覆盖新值。

需要多步决策时再选并发控制

事务并发控制的选择路径

当一次决策必须读取多行或调用复杂规则时,可以在悲观锁与乐观并发控制之间选择。

悲观锁适合冲突频繁、临界区很短的操作。锁必须在同一事务中获取并使用,所有路径按一致顺序加锁,减少死锁概率。

1
2
3
4
5
BEGIN;
SELECT stock FROM products WHERE id = :id FOR UPDATE;
-- 校验并更新
UPDATE products SET stock = stock - :qty WHERE id = :id;
COMMIT;

乐观方案适合冲突较少、读取后可能思考较久的场景。表中增加 version,更新时把旧版本放进条件:

1
2
3
UPDATE products
SET stock = :new_stock, version = version + 1
WHERE id = :id AND version = :old_version;

受影响行数为 0 就说明数据已变化,应重新读取并判断,而不是继续提交旧结果。

方案 更适合 主要代价
条件原子更新 规则可由单条 SQL 表达 复杂跨行规则难表达
悲观锁 热点明显、冲突频繁 锁等待、死锁、吞吐下降
乐观版本号 冲突较少、事务不宜持锁 冲突后要重读和重算

重试必须有边界

死锁、序列化失败和版本冲突通常可以重试,但必须开启一个全新的事务,从头重新读取。不要在已经失败的事务里只重放最后一条 SQL,因为此前读取的前提可能已经失效。

重试应设置较小的次数上限,并加入指数退避和随机抖动;库存不足、参数错误等确定性失败则不应重试。如果事务还会发送消息或调用外部接口,要用请求幂等键、Outbox 等方式避免数据库回滚后外部副作用已经发生。

用指标和并发测试验证

线上至少观察事务时长、锁等待、死锁次数、序列化失败率和重试成功率。事务越长,锁与历史版本保留得越久,因此网络调用和用户交互不应放进数据库事务。

测试时不要只写顺序用例。可以让多个线程在屏障处同时开始,反复扣减同一商品,最后断言库存不为负、成功次数与库存变化一致;再注入超时和死锁,确认重试不会重复扣减。

落地清单

  • 先定义业务不变量,再选择隔离级别和并发控制手段。
  • 能用条件原子更新解决时,不拆成“先读后写”。
  • 悲观锁保持事务短小,并统一多行加锁顺序。
  • 乐观更新必须检查受影响行数,冲突后重新读取。
  • 只重试可恢复错误,每次使用新事务,并限制次数。
  • 对事务外副作用增加幂等键或可靠事件投递机制。
  • 用真实数据库做并发测试,同时监控锁等待与失败率。

事务正确性不是一个隔离级别开关,而是一条从业务约束到 SQL、冲突处理和可观测性的完整链路。先缩小竞争窗口,再明确失败如何重试,才能让数据库在高并发下不仅“提交成功”,还真正得到正确结果。