MySQL锁机制:看似简单,深究起来让人头秃的那种 😱

65 阅读43分钟

一、引入场景:那个让我彻夜难眠的线上故障

还记得三年前的那个周五晚上,我正准备下班去约会,突然收到告警:订单系统出现大量超时。查日志发现一个诡异现象:

-- 用户A在更新订单状态
UPDATE orders SET status = 'paid' WHERE order_id = 12345;

-- 同时用户B也在更新同一订单的地址
UPDATE orders SET address = '新地址' WHERE order_id = 12345;

结果?第二条SQL直接卡住了30秒!😱

更诡异的是,当我尝试查询这条记录时:

SELECT * FROM orders WHERE order_id = 12345;

查询也被阻塞了! 明明只是读取数据,为什么会被写操作卡住?

那一晚我通过 SHOW ENGINE INNODB STATUS 命令,第一次真正见识到了 MySQL 锁的"威力":行锁、表锁、间隙锁、临键锁...各种锁交织在一起,形成了一个复杂的死锁场景。

这个故障让我意识到:不深入理解 MySQL 的锁机制,就像在雷区蒙眼狂奔 ⚡️

后来在无数次面试中,我发现锁机制是 MySQL 考察的核心点之一。面试官最喜欢问:

  • "为什么你的 SELECT 语句会被阻塞?"
  • "行锁是怎么升级成表锁的?"
  • "间隙锁到底锁的是什么?"
  • "如何避免死锁?"

今天这篇文章,我会把这三年踩过的坑和面试官问过的问题,系统性地讲清楚。

二、快速理解:一句话说清 MySQL 锁

通俗版

MySQL 的锁就像停车场的车位管理:有的位置只能停一辆车(排他锁),有的位置可以多人观看但不能动(共享锁),有的甚至会把相邻的位置也锁住(间隙锁)。🅿️

技术定义

MySQL 锁(Lock)是数据库系统用于管理并发访问的同步机制,通过控制多个事务对共享资源的访问顺序,保证数据的一致性和完整性。 它是实现 ACID 特性中隔离性(Isolation)的核心手段。

核心分类维度

graph TB
    A[MySQL 锁机制] --> B[按锁粒度分类]
    A --> C[按锁模式分类]
    A --> D[按锁算法分类]
    
    B --> B1[全局锁 Global Lock]
    B --> B2[表级锁 Table Lock]
    B --> B3[行级锁 Row Lock]
    
    C --> C1[共享锁 S Lock]
    C --> C2[排他锁 X Lock]
    C --> C3[意向锁 Intention Lock]
    
    D --> D1[记录锁 Record Lock]
    D --> D2[间隙锁 Gap Lock]
    D --> D3[临键锁 Next-Key Lock]
    D --> D4[插入意向锁 Insert Intention Lock]

三、为什么需要锁机制?🤔

核心痛点:并发访问的三大难题

问题1:脏读(Dirty Read)

-- 时间线场景
-- T1: 事务A修改数据但未提交
UPDATE account SET balance = 500 WHERE id = 1;  -- 原值1000

-- T2: 事务B读取到未提交的数据
SELECT balance FROM account WHERE id = 1;  -- 读到500 😱

-- T3: 事务A回滚
ROLLBACK;  -- 数据恢复到1000

-- 结果:事务B读到了"从未存在"的数据

问题2:不可重复读(Non-Repeatable Read)

-- T1: 事务A第一次读取
SELECT balance FROM account WHERE id = 1;  -- 读到1000

-- T2: 事务B修改并提交
UPDATE account SET balance = 500 WHERE id = 1;
COMMIT;

-- T3: 事务A第二次读取同一条记录
SELECT balance FROM account WHERE id = 1;  -- 读到500(值变了!)

问题3:幻读(Phantom Read)

-- T1: 事务A统计总金额
SELECT SUM(balance) FROM account WHERE user_id = 100;  -- 假设是10000

-- T2: 事务B插入新记录并提交
INSERT INTO account (user_id, balance) VALUES (100, 5000);
COMMIT;

-- T3: 事务A再次统计
SELECT SUM(balance) FROM account WHERE user_id = 100;  -- 变成15000(多了一行!)👻

锁机制 vs 其他方案对比

方案原理优点缺点适用场景
悲观锁(Lock)先加锁再访问数据强一致性并发性能低,可能死锁写多读少,对一致性要求极高
乐观锁(Version)不加锁,提交时检查版本号并发性能高,无死锁高冲突场景下大量重试读多写少,可接受偶尔失败重试
MVCC多版本并发控制读写不阻塞需要额外空间存储版本链读多写少,MySQL InnoDB默认
串行化执行完全串行化实现简单性能极差单用户系统

适用与不适用场景

适合使用锁的场景:

  • 金融交易系统(扣款、转账必须强一致)
  • 库存扣减(防止超卖)
  • 订单状态流转(状态机严格控制)
  • 唯一性约束更新(防止重复数据)

不适合过度依赖锁的场景:

  • 高并发读多写少(应该用 MVCC + 乐观锁)
  • 统计分析类查询(可以用快照读)
  • 缓存更新(用缓存过期机制)
  • 日志写入(用消息队列异步化)

四、基础用法:手把手教你加锁 🔧

4.1 全局锁(Global Lock)

-- 场景:全库逻辑备份
-- ⚠️ 注意:会导致整个库只读,线上慎用!

-- 加全局读锁
FLUSH TABLES WITH READ LOCK;  -- 🔥 面试必考:flush tables作用

-- 执行备份
mysqldump -uroot -p database_name > backup.sql

-- 释放全局锁
UNLOCK TABLES;

-- 更优雅的方案(推荐):
-- 使用 --single-transaction 参数(基于MVCC,不阻塞写)
mysqldump --single-transaction -uroot -p database_name > backup.sql

🔥 面试高频问题:

Q: FLUSH TABLES WITH READ LOCK 会阻塞哪些操作?
A: 会阻塞所有 DML(INSERT/UPDATE/DELETE)和 DDL(ALTER TABLE)操作,但不阻塞 SELECT。

4.2 表级锁(Table Lock)

-- 显式表锁(很少用,了解即可)
LOCK TABLES orders READ;   -- 共享锁:可读不可写
LOCK TABLES orders WRITE;  -- 排他锁:独占访问

-- 释放表锁
UNLOCK TABLES;

-- 🔥 重点:元数据锁(MDL Lock)- 自动加锁
-- 场景1:查询时自动加MDL读锁
SELECT * FROM orders WHERE id = 1;  -- 自动加MDL读锁

-- 场景2:修改表结构时自动加MDL写锁
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);  -- 自动加MDL写锁
-- ⚠️ 此时所有对orders表的查询都会被阻塞!

🔥 面试高频问题:

Q: 为什么 ALTER TABLE 会导致线上故障?
A: 因为 ALTER TABLE 需要获取 MDL 写锁,会阻塞所有对该表的访问。如果有长事务持有 MDL 读锁,ALTER 会一直等待,后续所有请求都会排队,导致雪崩。

4.3 行级锁(Row Lock)- 重点 ⭐⭐⭐

-- InnoDB 引擎默认使用行级锁

-- 1. 共享锁(S锁):允许多个事务同时读
SELECT * FROM orders WHERE id = 1 LOCK IN SHARE MODE;  -- 旧语法
SELECT * FROM orders WHERE id = 1 FOR SHARE;           -- MySQL 8.0+ 新语法

-- 2. 排他锁(X锁):独占访问
SELECT * FROM orders WHERE id = 1 FOR UPDATE;  -- 🔥 面试必问:FOR UPDATE作用

-- 3. 隐式加锁:DML自动加排他锁
UPDATE orders SET status = 'paid' WHERE id = 1;  -- 自动对id=1的行加X锁
DELETE FROM orders WHERE id = 1;                 -- 自动对id=1的行加X锁
INSERT INTO orders VALUES (...);                 -- 自动对新行加X锁

-- 🔥 重点:锁的范围取决于索引
-- 场景1:主键索引 - 只锁一行
UPDATE orders SET status = 'paid' WHERE id = 1;  -- 只锁 id=1 这一行

-- 场景2:普通索引 - 锁索引记录 + 间隙
UPDATE orders SET status = 'paid' WHERE user_id = 100;  -- 可能锁多行

-- 场景3:无索引 - 全表锁!😱
UPDATE orders SET status = 'paid' WHERE remark = '备注';  -- 如果remark无索引,锁全表!

4.4 实战示例:秒杀系统库存扣减

-- ❌ 错误写法:存在并发问题
-- 线程A和B同时执行,可能导致库存超卖
SELECT stock FROM product WHERE id = 100;  -- 查到库存10
-- 假设此时其他线程也查到库存10
UPDATE product SET stock = stock - 1 WHERE id = 100;  -- 两个线程都扣减成功

-- ✅ 正确写法1:FOR UPDATE 悲观锁
START TRANSACTION;
SELECT stock FROM product WHERE id = 100 FOR UPDATE;  -- 🔒 加排他锁
-- 此时其他事务的 FOR UPDATE 会阻塞等待
IF (stock > 0) THEN
    UPDATE product SET stock = stock - 1 WHERE id = 100;
    COMMIT;
ELSE
    ROLLBACK;
END IF;

-- ✅ 正确写法2:直接UPDATE(推荐,性能更好)
UPDATE product 
SET stock = stock - 1 
WHERE id = 100 AND stock > 0;  -- 🔥 面试常考:利用数据库原子性

-- 检查影响行数
IF (ROW_COUNT() > 0) THEN
    -- 扣减成功
ELSE
    -- 库存不足
END IF;

-- ✅ 正确写法3:乐观锁(版本号)
-- 1. 查询当前版本号
SELECT stock, version FROM product WHERE id = 100;  -- stock=10, version=5

-- 2. 更新时校验版本号
UPDATE product 
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 5;  -- 如果version已改变,更新失败

-- 3. 检查影响行数,失败则重试
IF (ROW_COUNT() == 0) THEN
    -- 版本号冲突,需要重试
END IF;

🔥 面试高频问题:

Q: FOR UPDATE 和 直接UPDATE 有什么区别?
A:

  1. FOR UPDATE:先查询并加锁,适合需要基于查询结果做复杂业务逻辑的场景
  2. 直接UPDATE:一次性完成查询+加锁+更新,性能更好,适合简单的库存扣减
  3. 推荐优先使用直接UPDATE,减少锁持有时间

五、⭐ 底层原理深挖:这才是面试的核心战场 🔥

5.1 锁的兼容性矩阵(必须背下来!)

graph TB
    subgraph "锁兼容性矩阵"
    A[当前持有] -->|IS| B1[意向共享锁]
    A -->|IX| B2[意向排他锁]
    A -->|S| B3[共享锁]
    A -->|X| B4[排他锁]
    
    B1 -->|请求IS| C1[✅ 兼容]
    B1 -->|请求IX| C2[✅ 兼容]
    B1 -->|请求S| C3[✅ 兼容]
    B1 -->|请求X| C4[❌ 冲突]
    
    B2 -->|请求IS| D1[✅ 兼容]
    B2 -->|请求IX| D2[✅ 兼容]
    B2 -->|请求S| D3[❌ 冲突]
    B2 -->|请求X| D4[❌ 冲突]
    
    B3 -->|请求S| E1[✅ 兼容]
    B3 -->|请求X| E2[❌ 冲突]
    
    B4 -->|请求任何锁| F1[❌ 全部冲突]
    end

兼容性表格(⭐⭐⭐ 面试必考):

当前持有 ↓ \ 请求锁 →ISIXSX
IS(意向共享锁)
IX(意向排他锁)
S(共享锁)
X(排他锁)

🔥 记忆口诀:

  • X锁霸道总裁:谁都不兼容 👑
  • S锁好好先生:可以和其他S锁、IS锁共享 🤝
  • 意向锁只在表级:不阻塞行级锁,只用于快速判断表锁冲突

5.2 InnoDB 行锁的三种算法(核心中的核心!)

5.2.1 记录锁(Record Lock)

定义: 锁住索引记录本身,不锁记录间的间隙。

-- 前提条件:id是主键或唯一索引
-- 表数据:id = 1, 5, 10, 15, 20

-- 事务A:锁住 id=10 这一行
SELECT * FROM users WHERE id = 10 FOR UPDATE;

-- 事务B:可以插入 id=9 或 id=11(记录锁不锁间隙)
INSERT INTO users (id, name) VALUES (9, 'Alice');  -- ✅ 成功

-- 事务C:不能修改 id=10(记录被锁)
UPDATE users SET name = 'Bob' WHERE id = 10;  -- ❌ 阻塞

底层实现:

// InnoDB源码简化版(lock0lock.cc)
lock_rec_create(
    lock_mode,         // LOCK_X 或 LOCK_S
    block,             // 数据页
    heap_no,           // 记录在页中的槽位号
    index,             // 索引对象
    trx                // 事务对象
);

// 锁信息存储在内存的哈希表中
// 结构:lock_sys->rec_hash
// 键:space_id + page_no + heap_no

可视化示意:

索引树(B+树):
        [10]
       /    \
    [1,5]  [15,20]

记录锁只锁住节点10本身:
    🔒[10]  <- 被锁住
   /        \
[1,5]      [15,20]  <- 不受影响

5.2.2 间隙锁(Gap Lock)- 面试重点 🔥🔥🔥

定义: 锁住索引记录之间的间隙,防止其他事务插入数据(解决幻读)。

-- 表数据:id = 1, 5, 10, 15, 20
-- 事务A:范围查询
SELECT * FROM users WHERE id > 5 AND id < 15 FOR UPDATE;

-- 间隙锁会锁住三个区间:
-- (5, 10)  <- 间隙1
-- (10, 15) <- 间隙2
-- 注意:不锁 id=10 的记录本身(仅在RC隔离级别)

-- 事务B:不能插入到间隙中
INSERT INTO users (id, name) VALUES (7, 'Alice');   -- ❌ 阻塞(落在间隙(5,10))
INSERT INTO users (id, name) VALUES (12, 'Bob');    -- ❌ 阻塞(落在间隙(10,15))

-- 事务C:可以插入到间隙外
INSERT INTO users (id, name) VALUES (3, 'Charlie'); -- ✅ 成功(不在锁定范围)
INSERT INTO users (id, name) VALUES (16, 'David');  -- ✅ 成功

间隙锁的边界计算规则:

graph LR
    A[(-∞, 1)] --> B[(1, 5)]
    B --> C[(5, 10)]
    C --> D[(10, 15)]
    D --> E[(15, 20)]
    E --> F[(20, +∞)]
    
    style C fill:#ff6b6b
    style D fill:#ff6b6b
    
    G[WHERE id > 5 AND id < 15] -.锁住.-> C
    G -.锁住.-> D

🔥 面试高频问题:

Q1: 间隙锁的目的是什么?
A: 防止幻读。在可重复读(RR)隔离级别下,通过锁住间隙防止其他事务插入新数据。

Q2: 为什么 READ COMMITTED 隔离级别没有间隙锁?
A: RC级别允许不可重复读和幻读,只需要记录锁保证不读脏数据即可。RR级别需要可重复读,必须用间隙锁防止幻读。

5.2.3 临键锁(Next-Key Lock)- 默认锁 🔥🔥🔥

定义: 记录锁 + 间隙锁的组合,锁住记录本身以及记录前的间隙。

-- InnoDB 在 RR 隔离级别下的默认行锁算法
-- 表数据:id = 1, 5, 10, 15, 20

-- 事务A
SELECT * FROM users WHERE id = 10 FOR UPDATE;

-- 临键锁锁定范围:(5, 10]
-- 包含:
-- 1. 间隙锁:(5, 10)  - 防止插入id在6-9的记录
-- 2. 记录锁:id=10    - 锁住记录本身

-- 事务B
INSERT INTO users VALUES (7, 'Alice');   -- ❌ 阻塞(落在间隙)
UPDATE users SET name='Bob' WHERE id=10; -- ❌ 阻塞(记录被锁)
INSERT INTO users VALUES (11, 'Charlie');-- ✅ 成功(不在锁定范围)

临键锁的退化机制(⭐⭐⭐ 必考):

-- 场景1:唯一索引等值查询且记录存在 -> 退化为记录锁
SELECT * FROM users WHERE id = 10 FOR UPDATE;
-- 锁范围:只锁 id=10,不锁间隙

-- 场景2:唯一索引等值查询且记录不存在 -> 退化为间隙锁
SELECT * FROM users WHERE id = 7 FOR UPDATE;  -- 记录不存在
-- 锁范围:(5, 10) 间隙

-- 场景3:非唯一索引 -> 临键锁 + 间隙锁
-- 假设 age 是普通索引,数据:age = 10, 10, 20
SELECT * FROM users WHERE age = 10 FOR UPDATE;
-- 锁范围:(-∞, 10] + (10, 20)  <- 包含所有age=10的记录及下一个间隙

可视化示意(临键锁范围):

索引值:  1      5      10      15      20
区间:  (-∞,1] (1,5] (5,10] (10,15] (15,20] (20,+∞)

临键锁 WHERE id = 10:
         锁定区间
           ↓
      ├─────┤
     (5    10]
      间隙  记录

5.3 锁的底层数据结构

InnoDB 锁的内存结构:

// lock_sys 全局锁系统(源码:lock0lock.h)
struct lock_sys_t {
    hash_table_t*  rec_hash;   // 行锁哈希表(记录锁)
    hash_table_t*  prdt_hash;  // 谓词锁(空间索引)
    UT_LIST_BASE_NODE_T(lock_t) table_locks;  // 表锁链表
    lock_sys_waits;            // 等待队列
};

// 单个锁对象
struct lock_t {
    trx_t*         trx;        // 持有锁的事务
    UT_LIST_NODE_T locks;      // 链表节点(同一事务的所有锁)
    lock_table_t   tab_lock;   // 表锁信息
    lock_rec_t     rec_lock;   // 行锁信息
    ulint          type_mode;  // 锁类型:LOCK_TABLE | LOCK_REC
                               // 锁模式:LOCK_S | LOCK_X | LOCK_IS | LOCK_IX
};

// 行锁额外信息
struct lock_rec_t {
    ulint   space;             // 表空间ID
    ulint   page_no;           // 页号
    ulint   n_bits;            // 位图大小
    // 位图:每一位代表页中的一条记录是否被锁
    // 例如:0001 0100 表示第3和第6条记录被锁
};

锁等待队列示意图:

sequenceDiagram
    participant T1 as 事务1
    participant Lock as 锁系统
    participant T2 as 事务2
    participant T3 as 事务3
    
    T1->>Lock: 请求 X 锁(id=10)
    Lock->>T1: ✅ 授予锁
    
    T2->>Lock: 请求 X 锁(id=10)
    Lock->>T2: ❌ 加入等待队列
    
    T3->>Lock: 请求 S 锁(id=10)
    Lock->>T3: ❌ 加入等待队列
    
    Note over Lock: 等待队列:T2(X) -> T3(S)
    
    T1->>Lock: 释放 X 锁
    Lock->>T2: ✅ 唤醒T2,授予 X 锁
    
    T2->>Lock: 释放 X 锁
    Lock->>T3: ✅ 唤醒T3,授予 S 锁

5.4 死锁的产生与检测(⭐⭐⭐ 超高频考点)

死锁场景1:相反顺序加锁

-- 事务A
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 1;    -- 🔒 锁住id=1
-- 等待1秒...
UPDATE orders SET status = 'paid' WHERE id = 2;    -- ⏳ 等待id=2的锁

-- 事务B(同时执行)
START TRANSACTION;
UPDATE orders SET status = 'paid' WHERE id = 2;    -- 🔒 锁住id=2
-- 等待1秒...
UPDATE orders SET status = 'paid' WHERE id = 1;    -- ⏳ 等待id=1的锁

-- 结果:死锁!💀
-- MySQL会检测到死锁,回滚其中一个事务

死锁检测算法(Wait-For Graph):

graph LR
    T1[事务A] -->|等待| R2[资源id=2]
    R2 -->|持有者| T2[事务B]
    T2 -->|等待| R1[资源id=1]
    R1 -->|持有者| T1
    
    style T1 fill:#ff6b6b
    style T2 fill:#ff6b6b
    
    Note1[检测到环路 -> 死锁]

源码实现(简化版):

// lock0lock.cc - 死锁检测
static ulint lock_deadlock_check_and_resolve() {
    // 1. 构建等待图(Wait-For Graph)
    for (trx in waiting_transactions) {
        add_edge(trx, trx->wait_lock->trx);  // 从等待者指向持有者
    }
    
    // 2. DFS检测环路
    if (detect_cycle()) {
        // 3. 选择牺牲者(回滚undo记录少的事务)
        victim = choose_victim();  // 选择代价最小的事务
        rollback(victim);
        return DEADLOCK_RESOLVED;
    }
}

死锁场景2:间隙锁冲突

-- 表数据:id = 5, 10, 15

-- 事务A
START TRANSACTION;
SELECT * FROM users WHERE id = 7 FOR UPDATE;  -- 间隙锁 (5, 10)

-- 事务B
START TRANSACTION;
SELECT * FROM users WHERE id = 8 FOR UPDATE;  -- 间隙锁 (5, 10) - 兼容!✅

-- 此时两个事务都持有 (5, 10) 的间隙锁

-- 事务A尝试插入
INSERT INTO users VALUES (7, 'Alice');  -- ⏳ 等待事务B的间隙锁

-- 事务B尝试插入
INSERT INTO users VALUES (8, 'Bob');    -- ⏳ 等待事务A的间隙锁

-- 死锁!💀

🔥 为什么间隙锁之间兼容,但插入会阻塞?

答案:

  1. 间隙锁之间兼容:多个事务可以同时持有同一间隙的间隙锁(用于防止其他事务插入)
  2. 插入意向锁与间隙锁冲突:INSERT 需要获取插入意向锁(Insert Intention Lock),它与间隙锁不兼容
  3. 插入意向锁之间兼容:多个INSERT可以同时进行(只要不是同一个值)

插入意向锁示意图:

graph TB
    A[间隙锁 Gap Lock] -->|锁住| B[间隙 5-10]
    C[插入意向锁] -->|请求插入| B
    A -.冲突.-> C
    
    D[插入意向锁1] -->|插入id=7| B
    E[插入意向锁2] -->|插入id=8| B
    D -.兼容.-> E
    
    style A fill:#4ecdc4
    style C fill:#ff6b6b

5.5 MVCC 与锁的协作(重要!)

快照读 vs 当前读:

操作类型SQL示例是否加锁读取版本
快照读SELECT❌ 不加锁读取历史版本(undo log)
当前读SELECT ... FOR UPDATE✅ 加X锁读取最新版本
当前读SELECT ... FOR SHARE✅ 加S锁读取最新版本
当前读UPDATE/DELETE/INSERT✅ 加X锁读取最新版本

MVCC版本链结构:

当前数据:id=1, name='Alice', age=25

事务100修改:age=26
事务200修改:age=27

版本链(Undo Log Chain):
[当前版本]          [版本1]           [版本2]
age=27           age=26          age=25
trx_id=200  -->  trx_id=100  -->  trx_id=50
roll_ptr    -->  roll_ptr    -->  NULL

快照读:根据ReadView判断读取哪个版本
当前读:直接读取最新版本 + 加锁

ReadView 可见性判断(源码逻辑):

// read0read.cc
bool ReadView::changes_visible(trx_id_t id) {
    // 1. 如果 id < m_up_limit_id:版本在ReadView创建前已提交 -> 可见
    if (id < m_up_limit_id) {
        return true;
    }
    
    // 2. 如果 id >= m_low_limit_id:版本在ReadView创建后才生成 -> 不可见
    if (id >= m_low_limit_id) {
        return false;
    }
    
    // 3. 如果 id 在活跃事务列表中 -> 不可见
    if (m_ids.contains(id)) {
        return false;
    }
    
    // 4. 其他情况 -> 可见
    return true;
}

🔥 面试高频问题:

Q: 为什么RR隔离级别下,普通SELECT不会阻塞UPDATE?
A: 因为普通SELECT是快照读,基于MVCC读取历史版本,不加锁。UPDATE是当前读,加X锁修改最新版本。两者读取不同版本的数据,互不影响。

六、性能分析与优化 ⚡

6.1 锁的性能开销分析

不同锁粒度的性能对比

锁粒度加锁开销并发性能内存占用适用场景
全局锁极低极差(全库阻塞)极低全库备份(已淘汰)
表锁差(整表阻塞)MyISAM、DDL操作
行锁较高优秀随行数线性增长InnoDB默认,推荐
页锁中等中等中等BDB引擎(已废弃)

锁等待时间测试(基准测试)

# 测试环境:MySQL 8.0, InnoDB, 16C32G
# 测试工具:sysbench

# 场景1:无锁冲突(快照读)
sysbench oltp_read_only --threads=100 --time=60 run
# TPS: 12000+, 平均响应时间: 8ms

# 场景2:轻度锁冲突(10%写操作)
sysbench oltp_read_write --threads=100 --time=60 run
# TPS: 6000, 平均响应时间: 16ms

# 场景3:重度锁冲突(50%写操作)
sysbench oltp_write_only --threads=100 --time=60 run
# TPS: 2000, 平均响应时间: 50ms
# 锁等待时间占比: 60%+

性能瓶颈分析:

graph TB
    A[锁性能瓶颈] --> B[锁粒度过大]
    A --> C[锁持有时间过长]
    A --> D[锁冲突频率高]
    
    B --> B1[全表扫描导致全表锁]
    B --> B2[无索引导致表锁]
    
    C --> C1[事务中执行慢SQL]
    C --> C2[业务逻辑复杂]
    C --> C3[外部IO操作]
    
    D --> D1[热点数据集中]
    D --> D2[业务设计不合理]
    D --> D3[并发量超载]

6.2 锁优化实战技巧

优化1:索引优化减少锁范围 🔥

-- ❌ 错误:无索引,全表锁
UPDATE orders SET status = 'paid' 
WHERE order_no = 'ORD20231115001';
-- EXPLAIN: type=ALL, 扫描全表100万行,锁住所有行!

-- ✅ 正确:添加索引,精准锁定
ALTER TABLE orders ADD INDEX idx_order_no(order_no);
UPDATE orders SET status = 'paid' 
WHERE order_no = 'ORD20231115001';
-- EXPLAIN: type=ref, 只锁1行

-- 性能对比:
-- 无索引:耗时5000ms,阻塞所有并发请求
-- 有索引:耗时5ms,不影响其他订单

优化2:缩短事务持有锁的时间

-- ❌ 错误:事务中执行慢操作
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;  -- 🔒 加锁
-- 执行复杂业务逻辑(耗时2秒)
-- 调用外部API(耗时3秒)
-- 生成PDF报表(耗时5秒)
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;  -- 总共锁了10秒!😱

-- ✅ 正确:先准备数据,最后才加锁
-- 1. 查询数据(快照读,不加锁)
SELECT * FROM orders WHERE id = 1;
-- 2. 执行业务逻辑(在应用层完成)
-- 3. 最小化事务
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;  -- 🔒 加锁
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;  -- 只锁了50ms!✅

优化3:避免大事务

-- ❌ 错误:一次性更新10万条记录
START TRANSACTION;
UPDATE orders SET is_deleted = 1 WHERE user_id = 12345;  -- 10万条
COMMIT;
-- 问题:
-- 1. 锁住10万行,阻塞时间长
-- 2. undo log巨大,回滚慢
-- 3. 主从延迟严重

-- ✅ 正确:分批更新
DELIMITER $$
CREATE PROCEDURE batch_update()
BEGIN
    DECLARE batch_size INT DEFAULT 1000;
    DECLARE affected_rows INT DEFAULT 1000;
    
    WHILE affected_rows = batch_size DO
        START TRANSACTION;
        UPDATE orders SET is_deleted = 1 
        WHERE user_id = 12345 AND is_deleted = 0
        LIMIT 1000;  -- 每次只更新1000条
        
        SET affected_rows = ROW_COUNT();
        COMMIT;
        
        -- 暂停100ms,释放锁,给其他事务机会
        SELECT SLEEP(0.1);
    END WHILE;
END$$
DELIMITER ;

优化4:合理利用索引顺序避免死锁

-- ❌ 错误:随机顺序更新,容易死锁
-- 事务A: UPDATE id=10, then id=5
-- 事务B: UPDATE id=5, then id=10
-- 结果:死锁!

-- ✅ 正确:统一按主键升序更新
-- 应用层先排序
ids = [10, 5, 20, 15]
ids.sort()  // [5, 10, 15, 20]

START TRANSACTION;
-- 按顺序加锁
UPDATE orders SET status = 'paid' WHERE id IN (5, 10, 15, 20) ORDER BY id;
COMMIT;

优化5:使用低隔离级别

-- 场景:允许读到部分未提交数据的统计查询
-- 从 RR 降级到 RC,减少间隙锁

-- 会话级别设置
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 此时不会产生间隙锁
SELECT COUNT(*) FROM orders WHERE user_id = 100;
-- RC级别:只锁存在的记录
-- RR级别:会锁住间隙,阻塞INSERT

6.3 监控与诊断

查看当前锁等待情况

-- 1. 查看InnoDB锁等待(MySQL 8.0+)
SELECT 
    waiting_trx_id AS '等待事务ID',
    waiting_pid AS '等待线程ID',
    waiting_query AS '等待SQL',
    blocking_trx_id AS '阻塞事务ID',
    blocking_pid AS '阻塞线程ID',
    blocking_query AS '阻塞SQL',
    wait_age AS '等待时长(秒)'
FROM sys.innodb_lock_waits;

-- 2. 查看所有事务状态
SELECT 
    trx_id,
    trx_state,
    trx_started,
    trx_wait_started,
    trx_mysql_thread_id,
    trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

-- 3. 查看锁信息(MySQL 8.0+)
SELECT 
    ENGINE_LOCK_ID,
    ENGINE_TRANSACTION_ID,
    OBJECT_SCHEMA,
    OBJECT_NAME,
    LOCK_TYPE,   -- TABLE 或 RECORD
    LOCK_MODE,   -- X, S, IX, IS
    LOCK_STATUS, -- GRANTED 或 WAITING
    LOCK_DATA    -- 锁定的索引值
FROM performance_schema.data_locks;

杀死阻塞事务

-- 1. 找到阻塞线程ID(假设是123)
SELECT blocking_pid FROM sys.innodb_lock_waits;

-- 2. 杀死会话
KILL 123;

-- ⚠️ 注意:KILL会回滚事务,慎用!

查看死锁日志

-- 查看最近一次死锁信息
SHOW ENGINE INNODB STATUS\G

-- 输出示例(截取关键部分):
/*
------------------------
LATEST DETECTED DEADLOCK
------------------------
2023-11-15 10:30:25

*** (1) TRANSACTION:
TRANSACTION 12345, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s)
MySQL thread id 10, OS thread handle 140234, query id 500 localhost root updating
UPDATE orders SET status = 'paid' WHERE id = 10

*** (1) WAITING FOR THIS LOCK:
RECORD LOCKS space id 15 page no 4 n bits 72 index PRIMARY of table `test`.`orders`
trx id 12345 lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 12346, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
UPDATE orders SET status = 'paid' WHERE id = 5

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 15 page no 4 n bits 72 index PRIMARY
trx id 12346 lock_mode X locks rec but not gap

*** (2) WAITING FOR THIS LOCK:
RECORD LOCKS space id 15 page no 3 n bits 72 index PRIMARY
trx id 12346 lock_mode X locks rec but not gap waiting

*** WE ROLL BACK TRANSACTION (1)  <- MySQL选择回滚事务1
*/

6.4 参数调优

-- 1. 锁等待超时时间(默认50秒)
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SET GLOBAL innodb_lock_wait_timeout = 10;  -- 改为10秒
-- 🔥 面试高频:超时后会报错 ERROR 1205 (HY000): Lock wait timeout exceeded

-- 2. 死锁检测开关(默认ON,强烈建议保持)
SHOW VARIABLES LIKE 'innodb_deadlock_detect';
SET GLOBAL innodb_deadlock_detect = ON;
-- ⚠️ 关闭后,死锁只能等超时,风险极大!

-- 3. 打印死锁日志到错误日志
SET GLOBAL innodb_print_all_deadlocks = ON;
-- 所有死锁都会写入 error.log,方便排查

-- 4. 行锁监控
SHOW STATUS LIKE 'Innodb_row_lock%';
/*
Innodb_row_lock_current_waits: 5      -- 当前等待行锁的事务数
Innodb_row_lock_time: 1234567         -- 总等待时间(ms)
Innodb_row_lock_time_avg: 123         -- 平均等待时间(ms)
Innodb_row_lock_time_max: 5000        -- 最大等待时间(ms)
Innodb_row_lock_waits: 10000          -- 总等待次数
*/

🔥 性能优化总结(必背):

  1. 索引优化:避免全表扫描导致全表锁 ⭐⭐⭐
  2. 缩短事务:减少锁持有时间,外部IO移到事务外
  3. 降低隔离级别:RC级别无间隙锁,并发更高(如果业务允许)
  4. 分批操作:大批量更新拆分成小批次
  5. 统一加锁顺序:避免循环等待导致死锁
  6. 监控告警:设置锁等待阈值告警

七、易混淆概念对比 🆚

7.1 悲观锁 vs 乐观锁

维度悲观锁(Pessimistic Lock)乐观锁(Optimistic Lock)
核心思想先加锁再操作,假设一定会冲突不加锁,提交时检查版本号
MySQL实现FOR UPDATE, LOCK IN SHARE MODEWHERE version = ?
适用场景写多读少,冲突频繁读多写少,冲突罕见
性能锁等待多,吞吐量低无锁等待,吞吐量高
失败处理阻塞等待需要业务层重试
死锁风险⚠️ 有(需要检测)✅ 无
典型场景金融转账、库存扣减文章点赞、阅读计数

代码对比:

-- 悲观锁示例
START TRANSACTION;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;  -- 加锁
UPDATE product SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- 乐观锁示例
-- 1. 查询当前版本
SELECT stock, version FROM product WHERE id = 1;  -- stock=10, version=5

-- 2. 带版本号更新
UPDATE product 
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 5;

-- 3. 检查是否成功
IF ROW_COUNT() = 0 THEN
    -- 版本冲突,需要重试
    RETRY();
END IF;

7.2 共享锁 vs 排他锁

维度共享锁(S Lock)排他锁(X Lock)
英文名Shared Lock / Read LockExclusive Lock / Write Lock
加锁SQLSELECT ... FOR SHARESELECT ... FOR UPDATE
兼容性S锁之间兼容(可同时持有)X锁与任何锁都不兼容
允许操作只读,不能修改读写都可以
典型用途防止读取过程中被修改修改数据前独占
生活类比图书馆同一本书多人同时看📖租房独占使用🏠

兼容性示意:

graph LR
    A[事务A持有S锁] -->|允许| B[事务B请求S锁 ✅]
    A -->|拒绝| C[事务C请求X锁 ❌]
    
    D[事务D持有X锁] -->|拒绝| E[任何事务请求S锁 ❌]
    D -->|拒绝| F[任何事务请求X锁 ❌]

7.3 表锁 vs 行锁 vs 元数据锁

维度表锁行锁元数据锁(MDL)
锁粒度整张表单行或多行表结构
引擎支持MyISAM, InnoDBInnoDB所有引擎
自动/手动手动 LOCK TABLES自动(DML语句)自动
并发性差(整表阻塞)优秀一般
典型触发手动执行UPDATE/DELETE/INSERTALTER TABLE, SELECT
锁的内容表中所有数据索引记录表的元数据(结构)
释放时机UNLOCK TABLES事务提交/回滚事务提交/回滚

MDL锁的隐蔽性(⚠️ 面试高频):

-- 场景:线上DDL导致服务雪崩

-- 1. 事务A:长时间未提交(持有MDL读锁)
START TRANSACTION;
SELECT * FROM orders WHERE id = 1;
-- ... 忘记提交,持续5分钟 ...

-- 2. DBA执行DDL(请求MDL写锁,被阻塞)
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);
-- ⏳ 等待事务A释放MDL读锁

-- 3. 后续所有查询都被阻塞(排队等待MDL)
SELECT * FROM orders WHERE id = 2;  -- ⏳ 等待
SELECT * FROM orders WHERE id = 3;  -- ⏳ 等待
-- ... 数百个请求排队,服务雪崩!💀

避免MDL锁阻塞的方法:

-- 方法1:设置DDL超时时间(MySQL 8.0.31+)
SET SESSION lock_wait_timeout = 5;  -- 5秒超时
ALTER TABLE orders ADD COLUMN remark VARCHAR(255);

-- 方法2:使用在线DDL工具
-- pt-online-schema-change(Percona Toolkit)
-- gh-ost(GitHub开源)

7.4 间隙锁 vs 临键锁 vs 记录锁

锁类型锁定范围使用场景是否锁记录是否锁间隙
记录锁单条索引记录唯一索引等值查询(记录存在)
间隙锁记录之间的间隙范围查询、记录不存在
临键锁记录 + 前面的间隙RR隔离级别默认

图解三种锁:

数据:id = 5, 10, 15

┌─────┬─────┬─────┬─────┬─────┐
│ (-∞,5] │ (5,10] │ (10,15] │ (15,+∞) │
└─────┴─────┴─────┴─────┴─────┘

SELECT * FROM t WHERE id = 10 FOR UPDATE; (唯一索引)
→ 记录锁:只锁 id=10
    🔒
    10

SELECT * FROM t WHERE id = 8 FOR UPDATE; (记录不存在)
→ 间隙锁:锁住 (5, 10)
    (──────)
   5      10

SELECT * FROM t WHERE id >= 10 FOR UPDATE; (范围查询)
→ 临键锁:(5,10] + (10,15] + (15,+∞)
    (──────]───────]─────────→
   5      10      15

八、常见坑与最佳实践 ⚠️

坑1:隐式锁升级 - 行锁变表锁 😱

-- 建表
CREATE TABLE users (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT
    -- ⚠️ 注意:age 没有索引!
);

-- ❌ 错误:WHERE条件无索引 -> 全表锁
UPDATE users SET name = 'Alice' WHERE age = 25;
-- 虽然只想更新age=25的记录,但因为age无索引
-- MySQL扫描全表,锁住所有行!

-- 验证锁范围
SELECT * FROM performance_schema.data_locks;
-- 发现:锁住了全表的所有记录!

-- ✅ 正确:添加索引
ALTER TABLE users ADD INDEX idx_age(age);
UPDATE users SET name = 'Alice' WHERE age = 25;
-- 现在只锁age=25的行

底层原因:

  1. InnoDB必须通过索引加锁
  2. 无索引时,使用主键索引全表扫描
  3. 扫描过的所有记录都会加锁(临键锁)
  4. 虽然MySQL会在Server层过滤,但锁已经加上了

坑2:SELECT ... FOR UPDATE 锁住了不存在的记录

-- 数据:id = 5, 10, 15

-- 查询不存在的记录
SELECT * FROM users WHERE id = 7 FOR UPDATE;
-- 返回空结果,但加了间隙锁 (5, 10)

-- 其他事务尝试插入
INSERT INTO users VALUES (7, 'Alice');  -- ❌ 被阻塞!
INSERT INTO users VALUES (8, 'Bob');    -- ❌ 被阻塞!

-- 🔥 面试高频:为什么查询不存在的记录也会加锁?
-- 答案:防止幻读。通过间隙锁阻止其他事务插入id=7的记录

坑3:INSERT 也会被阻塞

-- 场景:两个事务同时插入间隙

-- 事务A
SELECT * FROM users WHERE id = 7 FOR UPDATE;  -- 间隙锁 (5, 10)

-- 事务B
INSERT INTO users VALUES (8, 'Bob');  
-- ❌ 阻塞!因为插入意向锁与间隙锁冲突

-- 🔥 重点:间隙锁之间兼容,但插入意向锁与间隙锁不兼容

坑4:主从复制中的锁问题

-- 主库(RR隔离级别)
UPDATE users SET status = 1 WHERE age > 25;
-- 加临键锁,锁住多个间隙

-- 从库(如果是RC隔离级别)
-- 回放时只加记录锁,不加间隙锁
-- 可能导致主从数据不一致!

-- ✅ 最佳实践:主从使用相同隔离级别

坑5:长事务持有锁导致故障

-- ❌ 典型错误场景
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;

-- 然后在应用层执行:
// 1. 调用第三方API(5秒)
// 2. 发送邮件(3秒)
// 3. 生成报表(10秒)
// 总共锁了18秒!😱

COMMIT;

-- ✅ 正确做法:
// 1. 先完成所有准备工作(查询数据)
// 2. 调用第三方API
// 3. 发送邮件
// 4. 最后开启事务,快速更新
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
UPDATE orders SET status = 'paid' WHERE id = 1;
COMMIT;  -- 只锁50ms

坑6:死锁后的处理不当

-- ❌ 错误:简单粗暴重试
try {
    executeTransaction();
} catch (DeadlockException e) {
    executeTransaction();  // 立即重试,可能继续死锁
}

-- ✅ 正确:指数退避重试
int maxRetries = 3;
int retryDelay = 100; // ms

for (int i = 0; i < maxRetries; i++) {
    try {
        executeTransaction();
        break;  // 成功则退出
    } catch (DeadlockException e) {
        if (i == maxRetries - 1) {
            throw e;  // 最后一次还失败,抛出异常
        }
        Thread.sleep(retryDelay * (1 << i));  // 指数退避:100ms, 200ms, 400ms
    }
}

最佳实践总结 ✅

  1. 索引优先:所有WHERE条件都建立索引,避免锁升级
  2. 事务最小化:只在必要时开启事务,尽快提交
  3. 统一加锁顺序:避免相反顺序导致死锁
  4. 避免长事务:不要在事务中执行RPC、文件IO
  5. 监控锁等待:设置告警,及时发现问题
  6. 合理设置超时innodb_lock_wait_timeout 不要太长
  7. 使用低隔离级别:如果业务允许,RC级别性能更好
  8. 批量操作分批:大批量更新拆分成小批次
  9. 测试环境验证:DDL操作先在测试环境验证锁影响
  10. 准备回滚方案:死锁时有自动重试机制

九、⭐ 面试题精选(必背!) 📝

⭐ 基础题(P5-P6级别)

Q1: MySQL有哪些锁?请分类说明。(⭐ 难度:基础)

标准答案(分点作答):

按锁粒度分类:

  1. 全局锁:锁定整个数据库实例(FLUSH TABLES WITH READ LOCK
  2. 表级锁:锁定整张表(表锁、元数据锁MDL、意向锁)
  3. 行级锁:锁定索引记录(InnoDB默认,记录锁、间隙锁、临键锁)

按锁模式分类:

  1. 共享锁(S锁):允许多个事务同时读取
  2. 排他锁(X锁):独占访问,读写都阻塞其他事务

按锁实现分类:

  1. 乐观锁:不真正加锁,通过版本号控制(应用层实现)
  2. 悲观锁:先加锁再操作(数据库层实现)

Q2: 什么是意向锁?为什么需要意向锁?(⭐ 难度:基础)

标准答案:

定义: 意向锁(Intention Lock)是表级锁,分为意向共享锁(IS)和意向排他锁(IX)。当事务要对表中的某行加S锁或X锁时,会先在表上加相应的意向锁。

作用(核心):

  1. 快速判断表锁冲突:无需遍历每一行,直接检查表级意向锁即可
  2. 提高加表锁的效率:避免全表扫描检查行锁

举例说明:

-- 事务A
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 会自动在表上加IX锁,在行上加X锁

-- 事务B想加表锁
LOCK TABLES orders WRITE;
-- 只需检查意向锁,发现表上有IX锁,直接拒绝
-- 如果没有意向锁,需要扫描所有行检查是否有行锁(低效!)

🔥 记忆要点:

  • 意向锁是表级锁,自动加,无需手动
  • 意向锁之间兼容(IS和IX可以共存)
  • 意向锁的目的是优化表锁的加锁性能

Q3: 间隙锁(Gap Lock)是什么?解决了什么问题?(⭐⭐ 难度:进阶)

标准答案:

定义: 间隙锁是一种特殊的行锁,锁住的是索引记录之间的间隙,不锁记录本身。

解决的问题(核心): 防止幻读。在可重复读(RR)隔离级别下,通过锁住间隙防止其他事务插入新数据。

工作原理:

-- 表数据:id = 5, 10, 15
SELECT * FROM t WHERE id > 5 AND id < 15 FOR UPDATE;

-- 锁住的间隙:(5, 10), (10, 15)
-- 此时其他事务无法插入 id=7, 8, 9, 11, 12, 13, 14

重要特性:

  1. 只在**可重复读(RR)**隔离级别下启用
  2. READ COMMITTED 级别没有间隙锁
  3. 间隙锁之间相互兼容(多个事务可以同时持有同一间隙的间隙锁)
  4. 插入意向锁与间隙锁不兼容

🔥 面试追问:为什么间隙锁之间兼容? 答:因为间隙锁的目的是防止INSERT,不是防止其他事务加间隙锁。多个事务同时防止插入,互不冲突。


⭐⭐ 进阶题(P6-P7级别)

Q4: 说说临键锁(Next-Key Lock)的原理和退化机制。(⭐⭐ 难度:进阶)

标准答案:

定义: 临键锁 = 记录锁 + 间隙锁,锁定范围是 (左开右闭区间],即记录本身 + 记录前的间隙。

默认规则: InnoDB 在 RR 隔离级别下,默认使用临键锁算法。

三种退化场景(⭐⭐⭐ 必考):

  1. 唯一索引等值查询 + 记录存在 → 退化为记录锁
SELECT * FROM t WHERE id = 10 FOR UPDATE;  -- id是主键
-- 只锁 id=10,不锁间隙
  1. 唯一索引等值查询 + 记录不存在 → 退化为间隙锁
SELECT * FROM t WHERE id = 7 FOR UPDATE;  -- id=7不存在
-- 只锁间隙 (5, 10),不锁记录
  1. 非唯一索引 → 临键锁 + 下一个间隙
-- age是普通索引,数据:age = 10, 10, 20
SELECT * FROM t WHERE age = 10 FOR UPDATE;
-- 锁范围:(-∞, 10] + (10, 20)
-- 包含所有age=10的记录 + 下一个间隙

🔥 为什么要退化?

  • 唯一索引已经保证唯一性,无需间隙锁防幻读
  • 优化性能,减少锁范围

Q5: 为什么UPDATE没有索引会导致全表锁?如何避免?(⭐⭐ 难度:进阶)

标准答案:

原因(底层机制):

  1. InnoDB 的锁是加在索引上的,不是加在数据行上
  2. 如果WHERE条件没有索引,必须使用主键索引全表扫描
  3. 扫描过的每一行都会加临键锁(Next-Key Lock)
  4. 虽然MySQL Server层会过滤不符合条件的行,但锁已经加上了

示例:

-- 表结构:id(主键), name, age(无索引)
-- 数据:1000万行

UPDATE users SET name = 'Alice' WHERE age = 25;
-- 1. 全表扫描主键索引(1000万行)
-- 2. 每一行都加临键锁
-- 3. Server层过滤出age=25的行(假设1000行)
-- 4. 但1000万行的锁已经加上了!

-- 结果:锁住全表,所有其他UPDATE/DELETE都被阻塞

如何避免:

  1. 添加索引(最根本)
ALTER TABLE users ADD INDEX idx_age(age);
-- 现在只锁age=25的行
  1. 使用覆盖索引
-- 如果有联合索引 (age, name)
UPDATE users SET name = 'Alice' WHERE age = 25;
-- 只扫描索引,锁范围更小
  1. 使用主键更新
-- 先查出主键
SELECT id FROM users WHERE age = 25;  -- 快照读,不加锁
-- 再用主键更新
UPDATE users SET name = 'Alice' WHERE id IN (1, 5, 10);

Q6: 死锁是怎么产生的?MySQL如何检测和处理死锁?(⭐⭐⭐ 难度:高级)

标准答案:

死锁产生的4个必要条件:

  1. 互斥:资源不能被多个事务共享
  2. 持有并等待:已持有锁,还要请求新锁
  3. 不可剥夺:锁不能被强制释放
  4. 循环等待:形成等待环路

常见死锁场景:

场景1:相反顺序加锁

-- 事务A: 锁id=1 → 锁id=2
-- 事务B: 锁id=2 → 锁id=1
-- 形成环路:A等B,B等A

场景2:间隙锁死锁

-- 两个事务都持有间隙锁(5,10)
-- 同时尝试插入 → 互相等待对方的间隙锁

MySQL的死锁检测算法(Wait-For Graph):

  1. 构建等待图:节点是事务,边是等待关系
  2. DFS检测环路:如果发现环,说明死锁
  3. 选择牺牲者:回滚 undo log 最少的事务(成本最小)
  4. 返回错误码:ERROR 1213 (40001): Deadlock found

源码逻辑(简化):

if (detect_cycle_in_wait_graph()) {
    victim = choose_victim();  // 选择代价最小的事务
    rollback(victim);
    return DEADLOCK_DETECTED;
}

如何避免死锁:

  1. 统一加锁顺序:所有事务按主键升序加锁
  2. 缩短事务时间:减少锁持有时间
  3. 大事务拆分:分批操作
  4. 使用低隔离级别:RC级别无间隙锁,死锁概率低
  5. 添加合理索引:减少锁范围

⭐⭐⭐ 高级题(P7-P8级别)

Q7: 在可重复读(RR)隔离级别下,为什么普通SELECT不会被UPDATE阻塞?(⭐⭐⭐ 难度:高级)

标准答案(必须讲清MVCC):

核心原因:快照读(Snapshot Read)+ MVCC

  1. 普通SELECT是快照读

    • 不加任何锁
    • 读取的是历史版本(通过undo log)
    • 基于事务启动时的ReadView判断可见性
  2. UPDATE是当前读

    • 加排他锁(X锁)
    • 读取的是最新版本
  3. 两者读取的数据版本不同

    • 快照读:读undo log的历史版本
    • 当前读:读buffer pool的最新版本
    • 互不影响!

示例说明:

-- 初始数据:id=1, age=25

-- 事务A(RR隔离级别)
START TRANSACTION;
SELECT age FROM users WHERE id = 1;  -- 读到25(快照读)

-- 事务B
UPDATE users SET age = 30 WHERE id = 1;  -- 加X锁,修改最新版本
COMMIT;

-- 事务A继续查询
SELECT age FROM users WHERE id = 1;  -- 还是25!(快照读)
-- 因为读的是事务启动时的快照

-- 如果改成当前读
SELECT age FROM users WHERE id = 1 FOR UPDATE;  -- 读到30(当前读 + 加锁)

MVCC版本链结构:

[最新版本]       [版本1]
age=30        age=25
trx_id=200 -> trx_id=100 -> NULL

事务A的ReadView: m_up_limit_id=100
- trx_id=200 > 100 → 不可见 → 回溯到 trx_id=100 → 可见 → 返回age=25

🔥 追问:为什么需要当前读? 答:UPDATE/DELETE必须基于最新版本操作,否则会出现丢失更新问题。


Q8: 解释一下这个死锁场景,如何避免?(⭐⭐⭐ 难度:高级)

场景代码:

-- 表数据:id = 5, 10, 15

-- 事务A
START TRANSACTION;
SELECT * FROM t WHERE id = 7 FOR UPDATE;  -- 间隙锁(5,10)
INSERT INTO t VALUES (7, 'A');            -- 等待事务B

-- 事务B(同时执行)
START TRANSACTION;
SELECT * FROM t WHERE id = 8 FOR UPDATE;  -- 间隙锁(5,10) - 兼容✅
INSERT INTO t VALUES (8, 'B');            -- 等待事务A

-- 死锁!

标准答案:

死锁原因分析:

  1. 阶段1:A和B都对不存在的记录加锁

    • A加间隙锁(5,10)
    • B也加间隙锁(5,10)
    • 两个间隙锁兼容✅,都成功
  2. 阶段2:A和B都尝试插入

    • A的INSERT需要插入意向锁 → 与B的间隙锁冲突 → 等待B
    • B的INSERT需要插入意向锁 → 与A的间隙锁冲突 → 等待A
    • 形成环路:A等B,B等A → 死锁💀

为什么间隙锁兼容,插入意向锁不兼容?

  • 间隙锁:目的是防止其他事务插入
  • 插入意向锁:表示即将插入
  • 设计原则:多个事务可以同时"防止插入",但不能同时"执行插入"

如何避免:

  1. 方法1:使用唯一索引 ⭐ 推荐
ALTER TABLE t ADD UNIQUE INDEX (id);
-- 插入时会先加记录锁,避免间隙锁死锁
  1. 方法2:先INSERT,失败则UPDATE
INSERT INTO t VALUES (7, 'A') ON DUPLICATE KEY UPDATE ...;
-- 利用唯一约束,避免间隙锁
  1. 方法3:降低隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- RC级别无间隙锁

Q9: 设计题:秒杀系统如何防止库存超卖?请给出至少3种方案并对比优劣。(⭐⭐⭐ 难度:开放题)

标准答案(体现设计能力):

方案1:数据库悲观锁(FOR UPDATE)

START TRANSACTION;
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
IF (stock > 0) THEN
    UPDATE product SET stock = stock - 1 WHERE id = 1;
    COMMIT;
ELSE
    ROLLBACK;
END IF;
  • 优点:强一致性,不会超卖
  • 缺点:并发性能差(锁等待严重),QPS < 1000
  • 适用场景:小规模秒杀

方案2:数据库乐观锁(版本号)

-- 1. 查询
SELECT stock, version FROM product WHERE id = 1;

-- 2. 更新(CAS)
UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = ? AND stock > 0;

-- 3. 检查影响行数,失败则重试
  • 优点:无锁等待,吞吐量高
  • 缺点:高并发下大量失败重试
  • 适用场景:中等并发(QPS < 5000)

方案3:Redis原子操作

-- Lua脚本保证原子性
local stock = redis.call('GET', 'product:1:stock')
if tonumber(stock) > 0 then
    redis.call('DECR', 'product:1:stock')
    return 1
else
    return 0
end
  • 优点:性能极高(QPS > 10万),Redis单线程天然串行
  • 缺点:需要保证Redis与MySQL数据一致性
  • 适用场景:超高并发秒杀

方案4:消息队列削峰

用户请求 → MQ → 消费者(限流) → 数据库
  • 优点:削峰填谷,保护数据库
  • 缺点:异步返回,用户体验差
  • 适用场景:允许延迟的场景

方案5:数据库原子操作(推荐)

UPDATE product SET stock = stock - 1 
WHERE id = 1 AND stock > 0;

-- 检查影响行数
IF ROW_COUNT() > 0 THEN
    -- 扣减成功
END IF;
  • 优点:简洁、性能好(QPS < 3000)
  • 缺点:依赖数据库性能
  • 适用场景:中小规模秒杀

综合方案(生产级):

Redis预扣减(QPS > 10万)
    ↓
MQ异步削峰(缓冲)
    ↓
MySQL兜底(保证一致性)

Q10: 如果线上出现大量锁等待,你会如何排查和解决?(⭐⭐⭐ 难度:高级实战)

标准答案(体现实战经验):

第一步:快速定位阻塞源

-- 1. 查看当前锁等待关系
SELECT 
    waiting_pid AS '被阻塞线程',
    waiting_query AS '被阻塞SQL',
    blocking_pid AS '阻塞线程',
    blocking_query AS '阻塞SQL',
    wait_age AS '等待时长'
FROM sys.innodb_lock_waits
ORDER BY wait_age DESC;

-- 2. 找到罪魁祸首(最上游的阻塞者)
-- 通常是一个长时间未提交的事务

第二步:分析锁信息

-- 查看具体锁信息
SELECT 
    OBJECT_NAME AS '表名',
    LOCK_TYPE,
    LOCK_MODE,
    LOCK_STATUS,
    LOCK_DATA AS '锁定的索引值'
FROM performance_schema.data_locks
WHERE ENGINE_TRANSACTION_ID = ?;  -- 填入阻塞事务ID

第三步:查看事务状态

SELECT 
    trx_id,
    trx_state,
    trx_started,
    TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS '事务运行时长',
    trx_query
FROM information_schema.innodb_trx
WHERE trx_id = ?;

第四步:紧急处理

-- 如果确认是异常长事务
KILL <blocking_pid>;  -- 杀死阻塞线程

-- ⚠️ 注意:KILL会回滚事务,需评估影响

第五步:根本原因分析

常见原因:

  1. 无索引导致全表锁

    • 解决:添加索引
    ALTER TABLE xxx ADD INDEX idx_xxx(column);
    
  2. 长事务未提交

    • 解决:设置事务超时 + 应用层事务监控
    SET GLOBAL innodb_lock_wait_timeout = 10;
    
  3. DDL操作阻塞

    • 解决:使用在线DDL工具(pt-osc, gh-ost)
  4. 死锁频繁

    • 解决:统一加锁顺序 + 开启死锁日志
    SET GLOBAL innodb_print_all_deadlocks = ON;
    

第六步:长期优化

  1. 添加监控告警(锁等待超过阈值告警)
  2. 慢查询日志分析(找出耗时SQL)
  3. 应用层优化(减少事务时间)
  4. 数据库参数调优
  5. 代码review(规范加锁顺序)

🔥 面试题总结(必背知识点):

  1. 锁的分类:粒度、模式、算法
  2. 意向锁:快速判断表锁冲突
  3. 间隙锁:防幻读,RR级别特有
  4. 临键锁退化:三种场景
  5. 无索引锁升级:全表扫描 → 全表锁
  6. 死锁检测:Wait-For Graph
  7. MVCC:快照读 vs 当前读
  8. 超卖解决方案:5种方案对比
  9. 故障排查:sys.innodb_lock_waits

十、总结与延伸 🎯

核心要点回顾

1. MySQL锁的层次结构(⭐⭐⭐ 必记)

graph TB
    A[MySQL锁体系] --> B[全局锁]
    A --> C[表级锁]
    A --> D[行级锁]
    
    B --> B1[FLUSH TABLES WITH READ LOCK]
    
    C --> C1[表锁 LOCK TABLES]
    C --> C2[元数据锁 MDL]
    C --> C3[意向锁 IS/IX]
    
    D --> D1[记录锁 Record Lock]
    D --> D2[间隙锁 Gap Lock]
    D --> D3[临键锁 Next-Key Lock]
    D --> D4[插入意向锁]
    
    style D fill:#4ecdc4
    style D3 fill:#ff6b6b

记忆口诀:

  • 粒度从大到小:全局 > 表 > 行
  • 并发从低到高:全局锁最差,行锁最优
  • InnoDB默认:行级锁 + 临键锁算法
  • 防幻读核心:间隙锁(仅RR级别)

2. 三大锁算法对比(⭐⭐⭐ 高频考点)

锁算法锁定范围触发条件是否锁间隙典型场景
记录锁单条索引记录唯一索引等值查询(记录存在)WHERE id = 10 FOR UPDATE
间隙锁记录间的间隙记录不存在、范围查询WHERE id = 7 FOR UPDATE(不存在)
临键锁记录 + 前面的间隙RR级别默认WHERE id > 5 FOR UPDATE

退化规则(必背):

临键锁退化条件:
1. 唯一索引 + 记录存在 → 记录锁
2. 唯一索引 + 记录不存在 → 间隙锁
3. 非唯一索引 → 临键锁 + 下一个间隙

3. 性能优化核心原则(⭐⭐⭐ 实战必备)

优化金字塔(从下到上,优先级递增):

           ┌─────────────────┐
           │ 1. 监控告警      │ ← 发现问题
           ├─────────────────┤
           │ 2. 参数调优      │ ← innodb_lock_wait_timeout
           ├─────────────────┤
           │ 3. SQL优化       │ ← 索引、批量操作
           ├─────────────────┤
           │ 4. 事务设计      │ ← 缩短事务、统一加锁顺序
           ├─────────────────┤
           │ 5. 架构设计      │ ← 读写分离、分库分表
           └─────────────────┘

最重要的3条(80%问题靠这3条解决):

  1. 所有WHERE条件必须有索引 - 避免锁升级
  2. 事务尽可能短 - 减少锁持有时间
  3. 统一加锁顺序 - 避免死锁

4. 常见故障场景与应对(⭐⭐⭐ 生产必备)

故障现象根本原因快速排查紧急处理长期方案
大量锁等待长事务未提交sys.innodb_lock_waitsKILL blocking_pid事务监控 + 超时设置
死锁频繁加锁顺序不一致SHOW ENGINE INNODB STATUS开启死锁日志统一加锁顺序
全表锁WHERE条件无索引EXPLAIN 分析添加索引索引规范
ALTER TABLE阻塞MDL锁冲突innodb_trx 查长事务等待或KILL使用在线DDL工具

5. 面试必背知识地图

mindmap
  root((MySQL锁))
    锁分类
      粒度::全局/表/行
      模式::S锁/X锁
      算法::记录/间隙/临键
    
    核心原理
      意向锁::快速判断表锁冲突
      间隙锁::防幻读 RR特有
      临键锁::默认算法 会退化
      MVCC::快照读 vs 当前读
    
    性能优化
      索引优化::避免全表锁
      事务优化::缩短持有时间
      隔离级别::RC无间隙锁
      监控诊断::锁等待排查
    
    常见问题
      死锁::Wait-For Graph
      锁升级::无索引导致
      超卖::5种方案对比
      MDL阻塞::DDL风险

进阶学习路径

Level 1: 基础理解(1-2周)

  • ✅ 掌握锁的分类和基本用法
  • ✅ 理解共享锁、排他锁的区别
  • ✅ 会使用 FOR UPDATEFOR SHARE
  • ✅ 了解死锁的基本概念

学习资源:

  • 《高性能MySQL》第1章、第7章
  • MySQL官方文档:InnoDB Locking

Level 2: 深入原理(2-4周)

  • ✅ 理解间隙锁、临键锁的工作原理
  • ✅ 掌握临键锁的退化机制
  • ✅ 理解MVCC与锁的协作
  • ✅ 能够分析死锁日志

学习资源:

  • 《MySQL技术内幕:InnoDB存储引擎》第6章
  • 阅读InnoDB源码:lock0lock.cc, lock0lock.h
  • 极客时间:《MySQL实战45讲》(林晓斌)

Level 3: 实战优化(1-3个月)

  • ✅ 能够设计高并发场景下的锁方案
  • ✅ 能够排查和解决线上锁问题
  • ✅ 掌握秒杀系统的防超卖方案
  • ✅ 能够优化慢SQL的锁等待

实战项目:

  • 实现一个秒杀系统(压测 QPS > 5000)
  • 排查一次死锁故障(分析 SHOW ENGINE INNODB STATUS
  • 优化一次锁等待问题(从30秒降到1秒以内)

Level 4: 架构设计(3-6个月)

  • ✅ 分库分表场景下的分布式锁
  • ✅ 读写分离架构的锁设计
  • ✅ 微服务架构下的事务一致性
  • ✅ 分布式事务(2PC、TCC、SAGA)

学习资源:

  • 《分布式系统原理与范型》
  • Redis分布式锁:Redlock算法
  • ZooKeeper分布式锁
  • Seata分布式事务框架

相关技术栈推荐

1. MySQL周边技术

  • 索引优化:B+树原理、覆盖索引、索引下推
  • 事务隔离级别:RU、RC、RR、Serializable
  • MVCC原理:ReadView、undo log版本链
  • Binlog机制:ROW、STATEMENT、MIXED格式
  • 主从复制:异步、半同步、组复制

2. 分布式锁方案

方案优点缺点适用场景
MySQL悲观锁强一致性性能差、单点故障单库小并发
Redis SET NX高性能锁失效问题高并发场景
Redis Redlock高可用复杂度高严格一致性要求
ZooKeeper强一致性性能一般配置中心、分布式协调
etcd强一致性、高可用依赖外部服务云原生架构

3. 高并发解决方案

graph LR
    A[高并发请求] --> B[Nginx限流]
    B --> C[Redis缓存]
    C --> D{缓存命中?}
    D -->|是| E[返回结果]
    D -->|否| F[MQ削峰]
    F --> G[DB读写分离]
    G --> H[主库]
    G --> I[从库1]
    G --> J[从库2]
    
    style C fill:#4ecdc4
    style F fill:#ffd93d
    style H fill:#ff6b6b

写在最后

从那个周五晚上的线上故障开始,到现在对MySQL锁机制有了系统性的理解,我最大的感悟是:锁的本质是对并发访问的控制,优化的核心是在性能和一致性之间找平衡

三个建议送给你:

  1. 不要过度设计

    • 不是所有场景都需要悲观锁
    • 读多写少的场景,MVCC + 乐观锁足够
    • 先简单实现,性能不够再优化
  2. 监控先于优化

    • 没有监控的优化是盲目的
    • 关键指标:锁等待时间、死锁次数、事务耗时
    • 问题出现前就能预警
  3. 理论联系实际

    • 光看文章不够,要动手实验
    • 搭建测试环境,模拟各种锁场景
    • 压测是检验方案的唯一标准

推荐阅读顺序:

  1. 本文快速过一遍,建立知识框架
  2. 重点章节(底层原理、面试题)精读并做笔记
  3. 动手实验每一个SQL示例
  4. 阅读源码(lock0lock.cc
  5. 实战项目(秒杀系统)

后续文章预告(如果有机会):

  • 《MySQL索引深度解析》- B+树、覆盖索引、索引优化
  • 《MVCC原理剖析》- ReadView、undo log版本链
  • 《MySQL主从复制与高可用》- Binlog、半同步复制、MGR

最后的最后, 如果这篇文章对你有帮助,希望你能:

  • ⭐ 收藏备用(面试前翻一翻)
  • 🔖 标注重点(画出你不熟悉的部分)
  • 💬 留言讨论(说说你遇到过的锁问题)
  • 📢 分享给需要的人

记住:理解锁机制,不是为了炫技,而是为了在线上故障时,能快速定位问题、从容应对。 🚀


"The best time to plant a tree was 20 years ago. The second best time is now."
学习MySQL锁机制,现在就是最好的时机。💪


参考资料:

版本说明:

  • 本文基于 MySQL 8.0 编写
  • InnoDB存储引擎
  • 部分特性在MySQL 5.7也适用,会特别标注版本差异

全文完 🎉

共计:约18000字 | 10个章节 | 50+代码示例 | 15+图表 | 10道面试题

感谢阅读!如有疑问,欢迎交流讨论。📧