在探讨高并发架构时,我们经常将大部分精力放在 Redis 缓存削峰和 MQ 消息异步解耦上。然而,无论前置防线多么坚固,核心业务数据最终都要落盘到关系型数据库中。

当成百上千个并发请求穿透缓存,同时尝试修改同一行记录(例如扣减库存或更新账户余额)时,MySQL 是如何保证数据不乱、不超卖的?这就不得不深入探究 InnoDB 引擎的核心利器:锁机制(Locking)

在继续展开之前,先回答一个经常被问到的问题:事务到底能不能并发执行?

答案是:不仅能,而且在现代高性能系统里通常是必须并发。否则所有请求串行排队,系统吞吐量会被迅速拖垮。

一、 事务并发的核心矛盾:性能与安全的博弈

如果把数据库看成银行柜台:

  • 串行执行: 只有一个窗口,一次只办一个人的业务,最安全但最慢。
  • 并发执行: 多个窗口同时办理,吞吐量显著提升,但如果缺乏约束,多个事务同时改同一份数据就会出现一致性问题。

这也是数据库设计的核心矛盾:追求并发性能,必然要付出并发控制的复杂度。

1. 并发失控时的三类经典问题

  1. 脏读 (Dirty Read): 读到了另一个事务尚未提交、最终可能回滚的数据。
  2. 不可重复读 (Non-repeatable Read): 同一事务内两次读取同一行,结果不同。
  3. 幻读 (Phantom Read): 同一事务内两次范围查询,第二次凭空多出或少了记录。

2. 隔离级别是数据库提供的“安全阀”

为了平衡吞吐量与一致性,SQL 标准定义了四个隔离级别:

隔离级别 脏读 不可重复读 幻读 并发性能
读未提交 (Read Uncommitted) ❌ 允许 ❌ 允许 ❌ 允许 🚀 极高
读已提交 (Read Committed) ✅ 禁止 ❌ 允许 ❌ 允许 🚄 较高
可重复读 (Repeatable Read) ✅ 禁止 ✅ 禁止 ⚠️ 需额外机制约束 🚂 中等(MySQL 默认)
串行化 (Serializable) ✅ 禁止 ✅ 禁止 ✅ 禁止 🐌 最低

在 MySQL InnoDB 中,默认的可重复读并不是“纯锁硬抗”,而是借助 MVCC 与锁机制协同实现。

3. MVCC 与锁的分工

  • MVCC(多版本并发控制): 让读事务尽量读取一致性快照,避免与写事务互相阻塞。
  • 锁机制: 在写写冲突或需要强一致读时兜底,确保最终数据正确。

你可以把它理解为:MVCC 负责“尽量让路”,锁负责“关键路口红绿灯”。

下面进入锁机制本体,看看 InnoDB 是如何在高并发下把这套平衡真正落地的。

二、 锁的宏观视角:粒度的博弈

在 MySQL 中,按照锁的粒度划分,主要分为全局锁、表级锁和行级锁。粒度越大,并发度越低;粒度越小,并发度越高,但系统开销也越大。

  1. 全局锁 (Global Lock):
    • 命令: Flush tables with read lock (FTWRL)
    • 作用: 让整个数据库处于只读状态。通常只在全库逻辑备份(如 mysqldump)时使用。
  2. 表级锁 (Table Lock):
    • 类型: 表锁、元数据锁 (MDL)、意向锁 (Intention Lock)。
    • 场景: 当执行 ALTER TABLE 修改表结构,或者执行更新语句但没有命中任何索引时,InnoDB 会退化使用表锁,把整张表锁死。这是高并发系统中的绝对灾难。
  3. 行级锁 (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 字段。
    1. 查出当前版本:SELECT stock, version FROM products WHERE id = 1001; (假设查出 version=1)
    2. 业务代码判断内存库存充足后,带上版本号执行更新:
      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。双方都在等待对方释放资源,形成死循环。
  • 破局原则:
    1. 固定加锁顺序: 业务代码中如果需要同时更新多行数据,必须保证所有线程按照统一的顺序(例如按主键 ID 从小到大)去加锁。
    2. 缩短事务逻辑: 事务尽量小,避免在事务中执行耗时的外部 RPC/HTTP 调用,做到快进快出释放锁。

结语

数据库的锁机制是一个严密的逻辑闭环。从大粒度的表锁到精细的临键锁,再到应用层的乐观锁设计,本质上都是在 “数据绝对安全”“系统极致性能” 之间寻找最适合业务场景的平衡点。深入理解 InnoDB 的锁原理,是迈向资深后端架构设计的必经之路。

Leave a comment