如何设计数据库锁、隔离级别与死锁治理
不要先背脏读幻读;先解释“两个窗口同时改预算,为什么都成功却覆盖了”
这一页只解决一个问题: 并发写入下,业务不变量怎样不被写坏,同时不把系统锁死。锁和隔离级别不是面试概念,而是并发代价管理。
窗口 A 把餐饮预算从 3000 改到 3500;窗口 B 仍基于旧页面改成 3200。两个请求都返回成功,最后 A 的修改被静默覆盖。
| 错误反应 | 为什么不够 | 逼出的设计 |
| 给接口加事务 | 两个事务都能成功提交 | 需要冲突检测 |
| 提高隔离级别 | 不一定阻止最后写覆盖,还可能增加等待 | 先判断害怕哪种异常 |
| 最后写入为准 | 用户不知道自己被覆盖 | version 乐观锁和冲突提示 |
| 问题 | 常见机制 |
| 怕最后写覆盖吗 | version 字段、乐观锁、更新行数校验 |
| 怕重复扣减吗 | 条件更新、行锁、唯一约束 |
| 怕范围内重复创建吗 | UNIQUE(tenant_id, month),不要只先查再插 |
| 冲突高还是低 | 低冲突用乐观锁,高冲突锁明确业务对象 |
| 事务里有没有外部副作用 | 支付、MQ、HTTP 不能跟数据库锁绑在一起等 |
| 死锁后能不能重试 | 只有幂等且无外部副作用的动作才能有限重试 |
budgets
- id
- tenant_id
- month
- category_id
- amount
- version
optimistic update:
UPDATE budgets
SET amount = ?, version = version + 1
WHERE id = ?
AND tenant_id = ?
AND version = ?;
吸收点: 乐观锁不是“不加锁”,而是提交时证明自己修改的是看到的那一版。更新 0 行就是业务冲突,不是成功。
| 边界 | 事故 | 设计动作 |
| 锁对象 | 锁太大拖慢所有人,锁太小挡不住不变量 | 锁住预算行、账户行、结账状态行这类明确对象 |
| 加锁顺序 | A 先锁转出,B 先锁转入,互相等待 | 固定顺序,例如账户 id 从小到大 |
| 事务长度 | 事务里做报表、HTTP、MQ,别人一直等 | 事务内只做最短校验和写入 |
| 重试边界 | 死锁重试导致重复支付或重复消息 | 幂等键、Outbox、有限退避重试 |
关键边界: 数据库锁期间不要等待外部系统。HTTP、MQ、文件、人工审批都不该放进持锁事务。
| 样本 | 通过标准 |
| 两个窗口基于同一 version 改预算 | 一个成功,一个冲突提示 |
| 并发扣减余额 | 不会扣成负数,更新行数可解释 |
| 并发创建同月结账 | 唯一约束保证只成功一次 |
| 两个事务反向锁账户 | 能检测死锁,重试有上限 |
| 月报长事务扫热库 | 事务年龄告警,必要时拆批或走副本 |
| 死锁后重试带外部消息 | 消息不会重复发,或通过 Outbox 隔离 |
总结: 锁和隔离级别不是背概念。先说清业务怕哪种并发异常,再选择乐观锁、悲观锁、唯一约束、条件更新、固定加锁顺序和有限重试。能把这几件事说清,才算真的会设计并发数据库写入。