事务把若干读写组织成一次提交,但“包进事务”不意味着所有并发问题消失。必须先说清不变量:库存不为负、同一次预约只扣一次、预约与对应事件一起落库。隔离级别决定事务能观察到怎样的并发状态,约束和锁则帮助你维护具体规则。
先修正一次库存扣减
危险写法是先读库存,再由程序计算 newStock = oldStock - quantity,最后无条件写回。两个请求都读到 5,分别预约 4,可能各自写回 1;数据库并没有自动理解“总共预约了 8 本”。
PostgreSQL 中可把检查和修改放进同一条语句:
BEGIN;
UPDATE product
SET stock = stock - 4
WHERE sku = 'book-1' AND stock >= 4
RETURNING stock;
-- 必须检查是否返回一行;零行表示商品不存在或库存不足。
-- 只有扣减成功,才插入预约及 outbox 事件。
COMMIT;
这条 UPDATE 在竞争同一行时会取得行锁。默认 Read Committed 下,等待另一事务完成后,会对更新后的行重新检查条件。因此库存为 5 时,两个数量为 4 的预约最多一个成功。它没有证明跨多行汇总的规则也安全;例如“至少一位医生值班”需要另外分析。
用两个会话观察隔离
先把商品库存重置为 5。在会话 A 执行 BEGIN; SELECT stock FROM product WHERE sku='book-1';;会话 B 修改库存为 4 并提交。A 再执行相同 SELECT,默认 Read Committed 可以看到 4,因为每条命令取得新的可见快照。
把 A 的开头改成 BEGIN ISOLATION LEVEL REPEATABLE READ; 后重做。A 的普通查询保持事务快照,但这不等于所有业务规则已串行化。PostgreSQL 的 Repeatable Read 禁止幻读,仍可能有序列化异常;不能把别的数据库相同名称的行为直接搬过来。
| 手段 | 解决的主要问题 | 付出的成本 |
|---|---|---|
| 单条条件 UPDATE | 同一行上的检查与修改竞争 | 热点行会形成锁竞争 |
| SELECT FOR UPDATE | 先锁住将修改的现有行 | 持锁期间阻塞其他修改;要控制事务时长 |
| 版本号乐观更新 | 检测读取后发生的变更 | 冲突后重新读取并决策 |
| Serializable | 事务结果应等价于某种串行执行 | 可能回滚,需要正确重试 |
MVCC 让普通读取在许多场景下不阻塞写入,但不等于数据库没有锁。长事务保留旧版本、占用连接,也可能妨碍清理;在事务里等待远程 HTTP 调用,会把网络故障变成数据库资源问题。
重试要覆盖整个决策
死锁或序列化失败后,重试整笔事务,包括读取与业务判断;仅重发最后一条 UPDATE 可能继续使用过时结论。限制次数,加入退避,且只对明确的暂时冲突重试。扣款或发邮件这类不可逆外部调用不应夹在可重复执行的数据库事务里,使用 Outbox 与消息幂等管理边界。
多个事务按相反顺序锁两张商品行容易死锁。让所有事务按 SKU 排序后获取锁,可以消除这类循环等待;数据库仍可能因其他资源产生死锁,所以还要保留错误处理。
练习
用两个 SQL 会话执行条件扣减:A 更新后暂不提交,B 执行同样语句。观察 B 等待;A 提交后 B 返回零行。接着让 A 回滚,重新验证 B 的结果。记录最终库存,而不是只写“事务成功”。
阅读 Java 预约工程的并发测试,指出事务代理、唯一键、条件更新分别保护哪一个规则。练习数据库需自行准备;不要把 H2 测试通过当成 PostgreSQL 锁行为的实测。
面试自查
- 隔离级别、MVCC 与锁是同一个概念吗?可重复读是否自动解决写偏差?
- 为什么重试整笔事务比重试最后一条 SQL 更合理?如何避免外部副作用重复?
- 索引、事务时长和锁顺序怎样影响并发性能?