锁机制与并发控制
并发、事务与一致性 从库存、重复提交等业务问题出发。这篇进一步解释:锁保护什么、锁的范围在哪里、数据库为什么会锁住不止一行,以及锁失效和死锁该怎么处理。数据库示例默认 MySQL 8.0 / InnoDB。
先理解:锁保护的是一条业务规则
假设账户余额是 100,两次请求都要扣 80。下面的流程会出问题:
A 读到 100 → 判断够用 ─────────→ 写回 20
B 读到 100 → 判断够用 ───→ 写回 20两个请求都报告扣款成功,但余额只扣了一次。这是“读取 → 判断 → 修改”交错执行造成的竞态。业务希望维持的规则是:余额不能透支,每次成功扣款都必须反映到余额里。
锁让访问同一资源的冲突操作按规则排队。需要保护的操作区间叫“临界区”。只锁住最后一行赋值,前面的判断仍然可能基于旧值。
不过,这个场景可以先用一条条件更新解决:
UPDATE accounts SET balance = balance - 80
WHERE id = 1 AND balance >= 80;检查影响行数:1 表示成功,0 表示账户不存在或余额不足。没有显式写加锁语句,不等于数据库没有加锁:InnoDB 执行 UPDATE 时仍会取得必要的锁。
如果还要写扣款流水,两步放进同一事务;扣款失败时不能继续写成功流水。锁解决并发冲突,事务组织一组操作的提交与回滚。
锁的作用范围:先问资源在哪里
| 共享资源 | 可用机制 | 覆盖范围 |
|---|---|---|
| 同一进程中的对象 | 线程互斥锁、读写锁 | 使用同一把锁的线程 |
| 同一事件循环中的协程状态 | asyncio.Lock 等异步锁 | 使用同一把锁的协程 |
| MySQL 中的余额、库存 | 条件更新、行锁、版本校验、唯一约束 | 访问同一数据库的服务实例 |
| 多实例共同执行的任务 | 数据库任务认领、分布式锁 | 遵守同一协调协议的参与者 |
在两个容器里分别创建 new Mutex(),得到的是两把互不相干的锁。Python 的 asyncio.Lock 也不用于跨线程或跨进程同步。
Node.js 单线程为什么还会有竞态
单个事件循环不会同时执行两段普通 JavaScript,但请求可以在 await 处交错:
// 错误示例:两个请求可能读取到相同余额
const account = await db.getAccount(id)
if (account.balance >= amount) {
await db.setBalance(id, account.balance - amount)
}读数据库期间,另一个请求可以继续执行。再加上集群、多个 Pod、后台 worker,单线程更不能代表整个系统串行。这里应把余额规则放到数据库条件更新或事务锁中。
常见术语不是同一维度的分类
| 术语 | 关注的问题 | 注意事项 |
|---|---|---|
| 互斥锁 | 同时只允许一个持有者进入临界区 | 所有冲突访问都要遵守协议 |
| 读写锁 | 允许并发读,写入时独占 | 写多时未必更快;公平性依实现而定 |
| 可重入锁 | 同一执行主体能否重复获取同一把锁 | 重复获取通常要对应释放;不代表不会死锁 |
| 公平锁 | 是否按某种排队规则分配锁 | 公平性与吞吐可能存在取舍 |
| 自旋与阻塞 | 等锁时忙等,还是挂起等待唤醒 | 长时间自旋会浪费 CPU |
| 悲观并发控制 | 先取得锁,再读取并修改 | 冲突时主要付出等待代价 |
| 乐观并发控制 | 更新时校验数据是否已被修改 | 冲突时要拒绝或重新读取后有限重试 |
“乐观锁”通常指版本校验协议,不代表数据库底层完全无锁。可重入、公平和读写等属性也可以组合,不能把它们背成互斥的几个选项。
语言层继续阅读:Go 并发模式。主攻 Java 时,可以沿 synchronized、ReentrantLock、CAS、AQS 深入;其中 CAS 是原子比较交换操作,AQS 是构建同步器的框架,不是两种数据库锁。
MySQL 锁:索引决定你会碰到哪些记录
共享锁、排他锁和意向锁
- 共享锁(S):同一记录可以被多个事务持有 S 锁,与其他事务的 X 锁冲突。锁定读可使用
SELECT ... FOR SHARE。 - 排他锁(X):同一记录上与其他事务的 S、X 锁冲突。
SELECT ... FOR UPDATE以及修改记录通常需要 X 锁。 - 意向锁(IS / IX):表级锁,表明事务准备在行级加共享锁或排他锁,让表级锁请求可以快速检查冲突。IX 与 IX 可以兼容,不能把 IX 理解成“整张表不许别人写”。
普通一致性 SELECT 通常使用 MVCC,不会仅仅因为某行存在 X 锁就等待它释放。所以“加了行锁,其他事务连查询都不能做”是错误的概括。
Record、Gap、Next-Key
InnoDB 的行级锁实际作用于索引记录,还可能保护记录之间的间隙:
| 类型 | 保护对象 | 主要用途 |
|---|---|---|
| Record Lock | 索引记录 | 阻止冲突修改 |
| Gap Lock | 索引记录之间、首尾的间隙 | 阻止往该间隙插入新记录 |
| Next-Key Lock | 索引记录 + 它之前的间隙 | 保护记录及相邻范围 |
例如索引里有 10、20、30,记录 20 对应的 next-key 区间可以表示为 (10, 20]。这是理解区间的示意,具体 SQL 最终锁哪些区间,仍要结合执行计划、访问路径和隔离级别判断。
间隙锁主要阻止插入;不同事务的间隙锁可以共存,不能照搬记录 S/X 锁的冲突表。
为什么 WHERE 只想更新一行,却挡住了很多请求
SELECT * FROM accounts WHERE email = 'a@example.com' FOR UPDATE;要追问:email 有索引吗?是否唯一索引?查询是否真正走了这个索引?
- 使用完整唯一键等值查找且记录存在时,通常只需要对应记录锁,无需保护前面的间隙。
- 范围查询、非唯一索引查询,在 RR 下可能涉及 next-key 锁。
- 唯一键查找不存在的记录,在 RR 下也可能锁住它应当插入的间隙。
- 没有合适索引时,扫描和锁定范围可能很大,阻塞大量无关操作。
不能简单说“没索引,行锁升级成表锁”。InnoDB 可能锁住大量扫描到的索引记录与相关间隙;这是加锁范围扩大,不是通常意义的锁升级。
事务、隔离级别与 MVCC
事务不是“自动把所有请求串行执行”。它能提供怎样的并发可见性和保护,要看隔离级别及 SQL 类型。
| 隔离级别 | 普通一致性读 | 锁与范围保护 |
|---|---|---|
| READ COMMITTED(RC) | 每次一致性读使用新的快照 | 通常减少间隙锁;外键检查和重复键检查等仍是例外 |
| REPEATABLE READ(RR,InnoDB 默认) | 通常复用事务中第一次一致性读建立的快照 | 范围锁定读、更新等可使用 next-key 锁保护扫描范围 |
| SERIALIZABLE | 提供更强隔离;普通 SELECT 的锁定行为还与 autocommit 等条件有关 | 等待和冲突可能更多,需要权衡吞吐 |
MVCC 利用数据版本让一致性读看到合适的快照,减少读写阻塞。它不等于写写冲突消失,也不自动保护应用里“先查余额,再写一个计算结果”的流程。
面试常把 FOR UPDATE 等锁定读称为“当前读”:它要读取并锁定可供当前操作使用的记录,可能等待并发事务,不能按普通快照读理解。因此同一 RR 事务中混用普通 SELECT 与锁定读,可能看到不同的数据状态。
“RR 如何避免幻读”也要分开回答:普通一致性读通过快照保持读视图,范围锁定操作通过 next-key 等锁阻止相关插入。不要概括成任何混合读写流程都绝不会出现意外结果。
悲观锁和乐观锁怎么选
悲观锁:锁住之后再做判断
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- 应用检查:账户存在且余额足够;否则 ROLLBACK 并结束
UPDATE accounts SET balance = balance - 80 WHERE id = 1;
-- 如需流水,在同一事务里插入
COMMIT;这是一段带应用分支的流程示意,不能省掉余额检查直接用于扣款。锁定读、判断和修改必须使用同一连接、同一事务;提交或回滚后锁才失去这段保护作用。
适合必须基于当前值做复杂判断的短事务。不要在持锁期间请求第三方接口、调用模型或等待用户输入。
乐观锁:写入时带上读到的版本
UPDATE articles
SET title = '新标题', version = version + 1
WHERE id = 7 AND version = 3;影响行数为 1 表示更新成功;为 0 表示记录不存在或版本条件已不满足。用户编辑表单通常应提示冲突并重新加载,而不是无限自动重试覆盖别人的修改。
所有会冲突的写入路径都必须参与版本协议。仅仅在实体上声明一个 version 字段,不能证明 ORM 最终发出的 UPDATE 带了版本条件;要检查实际 SQL 和影响行数。
| 场景 | 优先考虑 |
|---|---|
| 库存大于 0 才扣减、状态从 pending 改为 running | 单条条件 UPDATE |
| 用户编辑文章,冲突不频繁 | version 校验,冲突后提示或合并 |
| 读取当前多项数据后做复杂判断 | 短事务 + 必要的锁定读 |
| 同一业务请求只能创建一条记录 | 唯一约束 + 幂等处理 |
| 热点资源竞争严重 | 缩短临界区、减小争用,必要时排队;避免无限重试 |
死锁:不是等得久,而是互相等
事务 A:已锁账户 1 → 等待账户 2
事务 B:已锁账户 2 → 等待账户 1这形成等待环。一个事务长时间持锁、其他事务排队,只能说明锁等待,不一定是死锁。
常见死锁条件是互斥、持有并等待、不能强行剥夺、循环等待。工程上通常通过统一加锁顺序、缩短事务、减少扫描范围来降低发生概率。
InnoDB 默认开启死锁检测,检测到死锁会选择一个事务作为牺牲者回滚。事务被回滚是正常的并发失败路径,应用需要处理。
如何重试
捕获可重试的并发错误
→ 确认旧事务已回滚、释放连接上的事务状态
→ 带抖动的短暂退避
→ 开启新事务,重新读取并重做整个业务事务
→ 达到有限次数后返回失败并记录告警线索不要只重试最后一条 SQL,因为前面的读和判断可能已经失效。死锁与锁等待超时也不同:默认情况下,InnoDB 锁等待超时通常只回滚当前语句,不能据此假设整个事务都已回滚;应用应明确清理事务后再决定重试。
事务中的邮件、支付调用等外部副作用不会随数据库回滚。需要幂等键或提交后投递等设计,不能随着事务重试反复执行。
如何定位
MySQL 8.0 可从以下入口排查(需要相应查看权限):
-- 最近一次被检测到的 InnoDB 死锁等状态信息
SHOW ENGINE INNODB STATUS;
-- 当前锁等待关系与锁信息
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM performance_schema.data_locks;
-- 结合事务持续时间和正在执行的语句定位阻塞者
SELECT trx_id, trx_started, trx_state, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx;这些是当前或最近状态,不是完整历史。结合应用 request ID、事务耗时、SQL 和执行计划判断:谁持锁、谁在等、事务为什么还没结束。不要看到等待就直接杀连接。
分布式锁:过期是故障恢复,也是边界
多实例协调常用 Redis 锁。基础写法把“只在不存在时写入”和“设置过期”放进同一条命令:
SET lock:job:42 <随机唯一 token> NX PX 10000只有返回成功才能进入业务逻辑。不要先 SETNX 再单独设置过期,中间崩溃可能留下无期限的锁。
释放时要原子地比较 token 再删除,例如使用 Lua。先 GET 判断、再单独 DEL 仍有竞态;详细代码与续期实现见 Redis 实战:分布式锁。
随机 token 为什么还不够
A 获取锁 → 执行业务时长时间停顿
锁到期 → B 获取同名锁并开始执行
A 恢复 → 仍以为自己有权写入随机 token 可以防止 A 误删 B 的锁,却不能自动阻止 A 继续写数据库或文件。续期能降低正常长任务丢锁的概率,也不能保证进程停顿、网络故障时永远不丢锁。
异步复制下,主节点确认加锁后、锁尚未复制就发生故障切换,也可能出现第二个持有者。因此要明确锁实现的故障假设,不能把“抢锁成功”当成永久排他权。
Fencing token:让资源拒绝过期持有者
一种方案是给每次成功授予的锁分配单调递增的令牌:A 拿到 41,后来的 B 拿到 42。受保护资源原子地记录已接受的令牌,并拒绝更旧令牌的写入。B 的 42 被接受后,A 带 41 的迟到写入就应被拒绝。
这里有两个前提:
- 令牌生成与授予协议在故障后仍保持要求的顺序,不能随意回退或重复。
- 实际执行写入的资源支持并强制校验令牌,比较与写入必须原子完成。
随机 UUID 不是 fencing token;单独增加一次 Redis INCR 也不能自动证明故障切换后的协议正确。同一持有者重复执行业务的问题还要靠幂等控制。
对余额、库存、订单等数据,优先让数据库条件更新、唯一约束和事务承担最终校验。对无法校验令牌的外部服务,则需要它支持的幂等能力或其他协调设计。
动手验证
只在本地测试数据库操作,打开两个独立连接,关闭它们遗留的事务后开始:
- 记录锁等待:准备两个账户;A 开事务并按主键对账户 1 执行
FOR UPDATE,B 开事务后更新账户 1,观察等待;A 提交后观察 B 继续,最后提交或回滚 B。 - 普通读与锁定读:A 锁住并修改账户 1、暂不提交;B 在 RC 或 RR 下普通 SELECT,再尝试
FOR UPDATE,比较快照读与锁定读的行为。实验结束后回滚两边。 - 复现死锁:A 先锁账户 1,B 先锁账户 2;A 再请求账户 2,B 再请求账户 1。观察一个事务收到死锁错误,另一个继续;回滚剩余事务并查看死锁记录。
- 版本冲突:两边读取同一 version,依次提交带相同旧版本条件的 UPDATE;确认只能有一次成功。务必检查影响行数。
做完要能解释每一步为什么等待、为什么成功或失败。阅读实验代码不等于已验证并发行为。
官方参考
- InnoDB 锁类型:记录锁、间隙锁、next-key 锁和意向锁。
- 事务隔离级别与锁定读:区分快照和锁定操作的行为。
- InnoDB 死锁处理与错误处理:回滚范围和应用重试责任。
- Redis 分布式锁:原子获取、释放、故障假设与 fencing 建议。
面试问答
1. 悲观锁和乐观锁怎么选?
- 悲观锁(
FOR UPDATE)适合必须基于当前值做复杂判断的短事务:锁定读、判断、修改必须在同一连接、同一事务里,提交或回滚后保护才结束。 - 别踩的坑:持锁期间请求第三方接口、调用模型、等用户输入——所有排队的事务一起陪等。
- 乐观锁(version 条件更新)适合冲突不频繁的场景:影响行数为 0 就是有人先改了,提示用户重新加载,而不是无限自动重试覆盖别人的修改。
- 加分:仅仅声明一个 version 字段不等于协议生效,所有会冲突的写入路径都要带版本条件,要检查 ORM 实际发出的 SQL 和影响行数。
- 事务本身不防超卖:取决于 SQL 与并发控制写法,优先条件扣减并检查影响行数;乐观锁也不是"完全不加锁",它是冲突检测协议,UPDATE 底层仍可能加锁。
2. 接口日志里偶发死锁,怎么处理和排查?
- 先分清死锁和锁等待:互相等形成等待环才是死锁;一个事务长时间持锁、其他事务排队,只是锁等待。
- InnoDB 默认开启死锁检测,会选一个事务回滚——被回滚是正常的并发失败路径,应用要捕获并重试,达到有限次数后返回失败并告警。
- 重试必须开启新事务、重新读取并重做整个业务事务,带抖动地短暂退避;不能只重试最后一条 SQL,前面的读和判断可能已经失效。
- 别踩的坑:锁等待超时默认只回滚当前语句,不能假设整个事务已回滚;事务里的邮件、支付调用也不随回滚消失,要靠幂等键或提交后投递。
3. MVCC 和锁是什么关系?RR 下幻读到底防没防住?
- 普通 SELECT 走 MVCC 一致性读,靠数据版本拿快照,不会因为行上有 X 锁就干等;RR 下通常复用事务中第一次一致性读建立的快照。
- 范围锁定读和更新靠 next-key 这类锁保护扫描范围、阻止相关插入——"快照保持读视图、锁阻止插入"是两条机制。
- 别踩的坑:不要概括成"任何混合读写流程都绝不会出现意外结果"——同一 RR 事务里混用普通 SELECT 和
FOR UPDATE这类当前读,可能看到不同的数据状态。
4. 共享锁、排他锁、意向锁之间是什么关系?
- S 锁之间兼容,S 和 X、X 和 X 冲突:
FOR SHARE拿 S 锁,FOR UPDATE和修改记录拿 X 锁。 - 意向锁(IS/IX)是表级锁,只表明"事务准备在行级加 S/X 锁",让表级锁请求能快速检查冲突;IX 与 IX 兼容,不是"整张表不许别人写"。
- 加分:理解"意向锁只是标记"就不会误解兼容矩阵——它不阻止行级操作,服务于表级锁请求的快速冲突检查。
5. 拿到 Redis 分布式锁,哪些地方还可能出问题?
- 锁会过期:A 执行业务时长时间停顿,锁到期被 B 拿走,A 恢复后仍以为自己有权写入;续期能降低正常长任务丢锁的概率,但不能保证停顿、网络故障时永远不丢锁。
- 异步复制下,主节点确认加锁后、锁尚未复制就发生故障切换,可能出现第二个持有者。
- Fencing token 是资源侧的防御:每次授锁发单调递增令牌,受保护资源原子记录已接受的令牌、拒绝更旧的写入——前提是令牌协议故障后仍有序,且资源支持并强制校验。
- 加分:余额、库存、订单这类数据,最终校验优先交给数据库条件更新、唯一约束和事务,锁只负责协调。
6. Node 单线程还需要考虑锁吗?
- 需要:
await让交错执行成为常态,多个请求、多个实例访问同一共享状态(数据库行、Redis 键)时,单线程帮不了你;先判断共享资源在哪一层,再选进程内同步原语、数据库约束还是分布式协调。 - 加分:单线程只消除进程内单个变量的竞态,跨请求和跨实例的共享资源一个都没省。
7. 行锁到底锁什么?条件列没索引会怎样?
- InnoDB 锁的是索引记录,不是抽象的"表里那一行";范围访问还可能带上间隙锁(next-key 锁)。
- 条件列没索引时,扫描与锁定范围会扩大到大量记录——更准确的表述是"扫描与锁定范围扩大",不能直接称为"锁升级"。
- 别踩的坑:这就是给并发写的条件建索引的原因——索引不对,隔离级别再正确也会大面积互堵。