在探讨高并发架构时,我们经常将大部分精力放在 Redis 缓存削峰和 MQ 消息异步解耦上。然而,无论前置防线多么坚固,核心业务数据最终都要落盘到关系型数据库中。
当成百上千个并发请求穿透缓存,同时尝试修改同一行记录(例如扣减库存或更新账户余额)时,MySQL 是如何保证数据不乱、不超卖的?这就不得不深入探究 InnoDB 引擎的核心利器:锁机制(Locking)。
在继续展开之前,先回答一个经常被问到的问题:事务到底能不能并发执行?
答案是:不仅能,而且在现代高性能系统里通常是必须并发。否则所有请求串行排队,系统吞吐量会被迅速拖垮。
一、 事务并发的核心矛盾:性能与安全的博弈
如果把数据库看成银行柜台:
- 串行执行: 只有一个窗口,一次只办一个人的业务,最安全但最慢。
- 并发执行: 多个窗口同时办理,吞吐量显著提升,但如果缺乏约束,多个事务同时改同一份数据就会出现一致性问题。
这也是数据库设计的核心矛盾:追求并发性能,必然要付出并发控制的复杂度。
1. 并发失控时的三类经典问题
- 脏读 (Dirty Read): 读到了另一个事务尚未提交、最终可能回滚的数据。
- 不可重复读 (Non-repeatable Read): 同一事务内两次读取同一行,结果不同。
- 幻读 (Phantom Read): 同一事务内两次范围查询,第二次凭空多出或少了记录。
2. 隔离级别是数据库提供的“安全阀”
为了平衡吞吐量与一致性,SQL 标准定义了四个隔离级别:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 并发性能 |
|---|---|---|---|---|
| 读未提交 (Read Uncommitted) | ❌ 允许 | ❌ 允许 | ❌ 允许 | 🚀 极高 |
| 读已提交 (Read Committed) | ✅ 禁止 | ❌ 允许 | ❌ 允许 | 🚄 较高 |
| 可重复读 (Repeatable Read) | ✅ 禁止 | ✅ 禁止 | ⚠️ 需额外机制约束 | 🚂 中等(MySQL 默认) |
| 串行化 (Serializable) | ✅ 禁止 | ✅ 禁止 | ✅ 禁止 | 🐌 最低 |
在 MySQL InnoDB 中,默认的可重复读并不是“纯锁硬抗”,而是借助 MVCC 与锁机制协同实现。
3. MVCC 与锁的分工
- MVCC(多版本并发控制): 让读事务尽量读取一致性快照,避免与写事务互相阻塞。
- 锁机制: 在写写冲突或需要强一致读时兜底,确保最终数据正确。
你可以把它理解为:MVCC 负责“尽量让路”,锁负责“关键路口红绿灯”。
下面进入锁机制本体,看看 InnoDB 是如何在高并发下把这套平衡真正落地的。
二、 锁的宏观视角:粒度的博弈
在 MySQL 中,按照锁的粒度划分,主要分为全局锁、表级锁和行级锁。粒度越大,并发度越低;粒度越小,并发度越高,但系统开销也越大。
- 全局锁 (Global Lock):
- 命令:
Flush tables with read lock (FTWRL) - 作用: 让整个数据库处于只读状态。通常只在全库逻辑备份(如 mysqldump)时使用。
- 命令:
- 表级锁 (Table Lock):
- 类型: 表锁、元数据锁 (MDL)、意向锁 (Intention Lock)。
- 场景: 当执行
ALTER TABLE修改表结构,或者执行更新语句但没有命中任何索引时,InnoDB 会退化使用表锁,把整张表锁死。这是高并发系统中的绝对灾难。
- 行级锁 (Row Lock):
- 特点: InnoDB 引擎的默认锁级别,只锁定当前操作的行,对其他行的读写没有影响。它是支撑高并发事务的核心。
三、 决战高并发:InnoDB 行锁的本质
在微服务业务线中(如订单结算),我们打交道最多的就是行锁。行锁分为共享锁(S锁/读锁)和排他锁(X锁/写锁)。
⚠️ 核心避坑指南: InnoDB 的行锁并不是加在物理数据行上的,而是加在索引树(B+Tree)的节点上的。 如果你的
UPDATE语句的WHERE条件没有走索引,或者索引失效,数据库无法精确定位到具体的行,行锁就会直接升级为表锁。
为了应对复杂的并发事务和解决“幻读”问题,InnoDB 的行锁在底层具体演化为三种形态:
1. 记录锁 (Record Lock)
- 概念: 仅仅锁住索引树上的一条确定的记录。
- 触发: 当你使用唯一索引或主键进行等值查询时(例如
SELECT * FROM users WHERE id = 10 FOR UPDATE),精准命中一条记录,就会加上记录锁。
2. 间隙锁 (Gap Lock)
- 概念: 锁住索引记录之间的“间隙”,但不包含记录本身。
- 触发: 主要在可重复读 (Repeatable Read, RR) 隔离级别下生效。它的唯一目的是防止其他事务在这个间隙里插入新数据,从而彻底解决“幻读(Phantom Read)”问题。
- 示例: 假设表中有 id 为 5 和 10 的记录。执行
SELECT * FROM users WHERE id > 5 AND id < 10 FOR UPDATE;,MySQL 会锁住(5, 10)这个区间,此时其他事务无法插入 id 为 7 的新记录。
3. 临键锁 (Next-Key Lock)
- 概念:
Record Lock + Gap Lock的结合体。它不仅锁住记录本身,还锁住该记录前面的间隙(左开右闭区间)。 - 触发: InnoDB 在 RR 隔离级别下的默认加锁单位。它能最大程度保证在一个事务中多次执行相同查询时,结果集绝对一致。
四、 实战演练:更新场景下的锁策略选型
在处理如量价回测的资金扣减,或电商秒杀的库存扣减时,我们通常面临两种锁的落地策略:
策略 A:悲观锁 (Pessimistic Locking)
- 理念: “总有刁民想害朕”。假设并发冲突一定会发生,在读数据时就直接上写锁。
- 实现:
SELECT stock FROM products WHERE id = 1001 FOR UPDATE; - 评价: 极其安全,绝对不会出现超卖。但由于阻塞了其他所有试图读取或修改该行的线程,并发吞吐量极差,极易引发锁等待超时或死锁。
策略 B:乐观锁 (Optimistic Locking)
- 理念: “世界是和平的”。假设大概率不会发生冲突,只在最终执行
UPDATE时校验数据是否被别人动过。 - 实现(CAS + 版本号):
在表中增加一个
version字段。- 查出当前版本:
SELECT stock, version FROM products WHERE id = 1001;(假设查出 version=1) - 业务代码判断内存库存充足后,带上版本号执行更新:
UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 1001 AND version = 1;
- 查出当前版本:
- 评价: 业界主流的高并发写方案。它没有使用沉重的
FOR UPDATE,而是利用了单条UPDATE语句自带的原子行锁。如果因为并发导致version不匹配,更新将影响 0 行,应用层可以选择重试或放弃。
五、 警惕深渊:死锁 (Deadlock)
行锁虽好,但滥用容易导致死锁。
- 场景: 事务 A 锁住了账户 X,想去锁账户 Y;同时事务 B 锁住了账户 Y,想去锁账户 X。双方都在等待对方释放资源,形成死循环。
- 破局原则:
- 固定加锁顺序: 业务代码中如果需要同时更新多行数据,必须保证所有线程按照统一的顺序(例如按主键 ID 从小到大)去加锁。
- 缩短事务逻辑: 事务尽量小,避免在事务中执行耗时的外部 RPC/HTTP 调用,做到快进快出释放锁。
结语
数据库的锁机制是一个严密的逻辑闭环。从大粒度的表锁到精细的临键锁,再到应用层的乐观锁设计,本质上都是在 “数据绝对安全” 与 “系统极致性能” 之间寻找最适合业务场景的平衡点。深入理解 InnoDB 的锁原理,是迈向资深后端架构设计的必经之路。
Leave a comment