写在前面
欢迎来到MySQL系列教学第10天!今天我们将深入MySQL的锁机制,这是保证并发环境下数据一致性的核心机制。理解锁的工作原理对于排查死锁、优化并发性能至关重要。
无论你是刚接触数据库的新手,还是准备面试的求职者,这篇文章都将帮助你全面掌握MySQL锁机制的核心知识。

文章目录
-
- 写在前面
- 一、为什么需要锁
-
- [1.1 并发修改导致的数据不一致](#1.1 并发修改导致的数据不一致)
- [1.2 锁的作用](#1.2 锁的作用)
- 二、锁的分类
-
- [2.1 按粒度分类](#2.1 按粒度分类)
- [2.2 按功能分类](#2.2 按功能分类)
- 三、表级锁
-
- [3.1 表锁(Table Lock)](#3.1 表锁(Table Lock))
- [3.2 元数据锁(MDL, Metadata Lock)](#3.2 元数据锁(MDL, Metadata Lock))
- [3.3 意向锁(Intention Lock)](#3.3 意向锁(Intention Lock))
- 四、行级锁
-
- [4.1 记录锁(Record Lock)](#4.1 记录锁(Record Lock))
- [4.2 间隙锁(Gap Lock)](#4.2 间隙锁(Gap Lock))
- [4.3 临键锁(Next-Key Lock)](#4.3 临键锁(Next-Key Lock))
- [4.4 行锁类型总结](#4.4 行锁类型总结)
- 五、死锁
-
- [5.1 什么是死锁](#5.1 什么是死锁)
- [5.2 死锁检测与处理](#5.2 死锁检测与处理)
- [5.3 如何避免死锁](#5.3 如何避免死锁)
- [六、乐观锁 vs 悲观锁](#六、乐观锁 vs 悲观锁)
-
- [6.1 悲观锁](#6.1 悲观锁)
- [6.2 乐观锁](#6.2 乐观锁)
- [6.3 乐观锁 vs 悲观锁对比](#6.3 乐观锁 vs 悲观锁对比)
- 七、实战:库存扣减的并发控制
-
- [7.1 业务场景](#7.1 业务场景)
- [7.2 方案一:悲观锁](#7.2 方案一:悲观锁)
- [7.3 方案二:乐观锁](#7.3 方案二:乐观锁)
- [7.4 方案三:数据库原子操作](#7.4 方案三:数据库原子操作)
- [7.5 方案对比](#7.5 方案对比)
- 八、踩坑提醒
-
- [8.1 SELECT * FOR UPDATE的锁范围](#8.1 SELECT * FOR UPDATE的锁范围)
- [8.2 死锁排查](#8.2 死锁排查)
- [8.3 锁超时处理](#8.3 锁超时处理)
- [8.4 避免在事务中做耗时操作](#8.4 避免在事务中做耗时操作)
- 九、面试高频考点
- 十、总结
- 下一步预告
- 参考资料
- 互动话题
一、为什么需要锁
1.1 并发修改导致的数据不一致
当多个用户同时操作同一数据时,如果没有锁机制,就会出现数据不一致的问题。
场景:库存扣减
商品A库存:100件
用户1和用户2同时购买(并发执行):
时间点 用户1操作 用户2操作
T1 读取库存:100
T2 读取库存:100
T3 扣减库存:100-1=99
T4 扣减库存:100-1=99
T5 更新库存:99
T6 更新库存:99
结果:库存变成99,但实际上卖出了2件,应该变成98!
这就是典型的"超卖"问题。
1.2 锁的作用
锁是数据库管理系统用来控制多个事务对共享资源的并发访问的机制。
锁的核心作用:
- 互斥性:同一时间只有一个事务能修改数据
- 一致性:保证并发事务执行结果的正确性
- 隔离性:实现事务隔离级别
sql
-- 使用锁解决超卖问题
-- 用户1
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 加排他锁
-- 读取到100
UPDATE products SET stock = 99 WHERE id = 1;
COMMIT; -- 释放锁
-- 用户2(同时执行)
START TRANSACTION;
SELECT stock FROM products WHERE id = 1 FOR UPDATE; -- 等待用户1释放锁
-- 用户1提交后,读取到99
UPDATE products SET stock = 98 WHERE id = 1;
COMMIT;
-- 结果:库存正确变成98
二、锁的分类
2.1 按粒度分类
| 锁类型 | 粒度 | 并发度 | 开销 | 适用场景 |
|---|---|---|---|---|
| 全局锁 | 整个数据库 | 最低 | 大 | 全库备份 |
| 表级锁 | 整张表 | 较低 | 较小 | MyISAM、DDL操作 |
| 行级锁 | 单行记录 | 最高 | 较大 | InnoDB DML操作 |
2.2 按功能分类
| 锁类型 | 英文名 | 说明 |
|---|---|---|
| 共享锁 | Shared Lock (S锁) | 读锁,多个事务可同时持有 |
| 排他锁 | Exclusive Lock (X锁) | 写锁,同一时间只有一个事务可持有 |
锁的兼容性:
| S锁 | X锁 | |
|---|---|---|
| S锁 | 兼容 | 冲突 |
| X锁 | 冲突 | 冲突 |
三、表级锁
3.1 表锁(Table Lock)
MyISAM引擎使用表锁,InnoDB在特定情况下也会使用表锁。
sql
-- 加读锁(共享锁)
LOCK TABLES table_name READ;
-- 加写锁(排他锁)
LOCK TABLES table_name WRITE;
-- 释放锁
UNLOCK TABLES;
表锁特点:
- 开销小,加锁快
- 不会出现死锁
- 并发度低
3.2 元数据锁(MDL, Metadata Lock)
MDL用于保护表结构,在访问表时自动加锁。
sql
-- 读MDL锁(查询时自动加)
SELECT * FROM users; -- 加MDL读锁
-- 写MDL锁(DDL操作时加)
ALTER TABLE users ADD COLUMN age INT; -- 加MDL写锁
MDL锁的问题:
sql
-- 事务A
START TRANSACTION;
SELECT * FROM users; -- 持有MDL读锁,事务未提交
-- 事务B(同时执行)
ALTER TABLE users ADD COLUMN address VARCHAR(200); -- 需要MDL写锁,阻塞
-- 事务C(同时执行)
SELECT * FROM users; -- 需要MDL读锁,但事务B在排队,也阻塞
-- 结果:后续所有查询都阻塞!
解决方案:
- 避免长事务
- DDL操作使用
ALGORITHM=INPLACE, LOCK=NONE
3.3 意向锁(Intention Lock)
意向锁是表级锁,用于表示事务稍后要对表中的行加锁。
| 意向锁类型 | 说明 |
|---|---|
| 意向共享锁(IS) | 事务要对表中的某些行加S锁 |
| 意向排他锁(IX) | 事务要对表中的某些行加X锁 |
作用:
- 提高锁冲突检测效率
- 事务想对表加X锁时,只需检查是否有其他事务持有IX/IS锁
sql
-- 事务A
START TRANSACTION;
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 自动加:表级IX锁 + 行级X锁
-- 事务B
LOCK TABLES users WRITE; -- 需要检查是否有IX锁,发现有,阻塞
四、行级锁
4.1 记录锁(Record Lock)
记录锁锁定索引记录,是最基本的行锁。
sql
-- 对id=1的记录加排他锁
SELECT * FROM users WHERE id = 1 FOR UPDATE;
-- 其他事务无法修改id=1的记录
UPDATE users SET name = 'xxx' WHERE id = 1; -- 阻塞
注意:
- 记录锁锁定的是索引记录
- 如果表没有索引,InnoDB会创建隐藏的聚簇索引
4.2 间隙锁(Gap Lock)
间隙锁锁定索引记录之间的"间隙",防止幻读。
sql
-- 假设users表有id:1, 5, 10, 15
START TRANSACTION;
SELECT * FROM users WHERE id > 5 AND id < 15 FOR UPDATE;
-- 加锁范围:(5, 10], (10, 15)
-- 间隙:(5,10) 和 (10,15) 被锁定
-- 其他事务无法插入id=7或id=12的记录
INSERT INTO users (id, name) VALUES (7, 'test'); -- 阻塞!
间隙锁特点:
- 只在REPEATABLE READ和SERIALIZABLE下生效
- 间隙锁之间不冲突(多个事务可以同时持有同一间隙的间隙锁)
- 目的是防止幻读
4.3 临键锁(Next-Key Lock)
临键锁 = 记录锁 + 间隙锁,是InnoDB默认的行锁算法。
sql
-- 临键锁范围:(记录, 下一个记录)
-- 假设索引值:10, 20, 30
SELECT * FROM users WHERE age = 20 FOR UPDATE;
-- 临键锁锁定范围:(10, 20] 和 (20, 30]
-- 即:大于10且小于等于30的范围
临键锁的作用:
- 解决幻读问题
- 是InnoDB实现REPEATABLE READ隔离级别的关键
4.4 行锁类型总结
| 行锁类型 | 锁定对象 | 解决什么问题 |
|---|---|---|
| 记录锁 | 单个索引记录 | 行级并发控制 |
| 间隙锁 | 索引记录间的间隙 | 防止幻读 |
| 临键锁 | 记录+间隙 | 行级控制+防止幻读 |
五、死锁
5.1 什么是死锁
死锁是指两个或多个事务互相等待对方释放锁,导致所有事务都无法继续执行。
死锁示例:
事务A 事务B
1. 锁住记录X
2. 锁住记录Y
3. 尝试锁住记录Y
(等待B释放)
4. 尝试锁住记录X
(等待A释放)
结果:A等B,B等A,死锁!
sql
-- 事务A
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1; -- 锁住id=1
-- 稍等...
UPDATE accounts SET balance = 1900 WHERE id = 2; -- 尝试锁住id=2,阻塞
-- 事务B(同时执行)
START TRANSACTION;
UPDATE accounts SET balance = 800 WHERE id = 2; -- 锁住id=2
-- 稍等...
UPDATE accounts SET balance = 1800 WHERE id = 1; -- 尝试锁住id=1,死锁!
5.2 死锁检测与处理
MySQL死锁检测:
sql
-- 查看死锁日志
SHOW ENGINE INNODB STATUS;
-- 死锁相关配置
SHOW VARIABLES LIKE 'innodb_deadlock_detect'; -- 默认ON
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout'; -- 默认50秒
死锁处理策略:
- 超时等待 :等待一定时间后自动回滚(
innodb_lock_wait_timeout) - 死锁检测:主动检测死锁,选择代价最小的事务回滚(默认策略)
5.3 如何避免死锁
1. 按固定顺序访问资源
sql
-- 好的实践:所有事务都按id从小到大加锁
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1; -- 先操作id小的
UPDATE accounts SET balance = 1900 WHERE id = 2; -- 再操作id大的
COMMIT;
2. 尽量缩短事务长度
sql
-- 不好的做法:事务中包含不必要的操作
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
-- 执行其他耗时操作...
UPDATE accounts SET balance = 1900 WHERE id = 2;
COMMIT;
-- 好的做法:只包含必要的操作
START TRANSACTION;
UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE accounts SET balance = 1900 WHERE id = 2;
COMMIT;
3. 使用较低的隔离级别
sql
-- READ COMMITTED级别下,间隙锁减少,死锁概率降低
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
4. 一次性锁定所有资源
sql
-- 使用SELECT ... FOR UPDATE一次性锁定所有需要的记录
START TRANSACTION;
SELECT * FROM accounts WHERE id IN (1, 2) FOR UPDATE;
UPDATE accounts SET balance = 900 WHERE id = 1;
UPDATE accounts SET balance = 1900 WHERE id = 2;
COMMIT;
六、乐观锁 vs 悲观锁
6.1 悲观锁
思想:假设冲突会发生,先加锁再操作。
实现:数据库锁机制(SELECT ... FOR UPDATE)
sql
-- 悲观锁示例
START TRANSACTION;
-- 1. 查询并加锁
SELECT stock, version FROM products WHERE id = 1 FOR UPDATE;
-- 2. 判断库存
IF stock >= quantity THEN
-- 3. 扣减库存
UPDATE products SET stock = stock - quantity WHERE id = 1;
COMMIT;
ELSE
ROLLBACK;
END IF;
适用场景:
- 写操作多,冲突频繁
- 强一致性要求
6.2 乐观锁
思想:假设冲突不会发生,提交时检查是否冲突。
实现:版本号机制或CAS(Compare And Swap)
sql
-- 乐观锁示例(版本号机制)
-- 表结构增加version字段
ALTER TABLE products ADD COLUMN version INT DEFAULT 0;
-- 业务逻辑
-- 1. 查询(不加锁)
SELECT stock, version FROM products WHERE id = 1;
-- 结果:stock=100, version=1
-- 2. 执行业务逻辑
new_stock = 100 - 1; -- 扣减1个
-- 3. 更新(带版本号检查)
UPDATE products
SET stock = 99, version = version + 1
WHERE id = 1 AND version = 1;
-- 如果返回影响行数为0,说明版本已变,需要重试
适用场景:
- 读操作多,写操作少
- 冲突概率低
- 可以接受重试
6.3 乐观锁 vs 悲观锁对比
| 特性 | 悲观锁 | 乐观锁 |
|---|---|---|
| 实现方式 | 数据库锁 | 版本号/CAS |
| 加锁时机 | 操作前 | 提交时 |
| 性能 | 低(锁竞争) | 高(无锁) |
| 冲突处理 | 阻塞等待 | 重试 |
| 适用场景 | 写多读少 | 读多写少 |
| 复杂度 | 低 | 高(需处理重试) |
七、实战:库存扣减的并发控制
7.1 业务场景
电商系统中,多个用户同时购买同一商品,需要保证:
- 不超卖(库存不能为负)
- 高并发性能
7.2 方案一:悲观锁
sql
-- 存储过程:扣减库存(悲观锁)
DELIMITER $$
CREATE PROCEDURE deduct_stock_pessimistic(
IN p_product_id BIGINT,
IN p_quantity INT,
OUT p_result VARCHAR(100)
)
BEGIN
DECLARE v_stock INT;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
SET p_result = '系统错误';
RESIGNAL;
END;
START TRANSACTION;
-- 查询并加排他锁
SELECT stock INTO v_stock
FROM products
WHERE id = p_product_id FOR UPDATE;
-- 检查库存
IF v_stock IS NULL THEN
ROLLBACK;
SET p_result = '商品不存在';
ELSEIF v_stock < p_quantity THEN
ROLLBACK;
SET p_result = '库存不足';
ELSE
-- 扣减库存
UPDATE products
SET stock = stock - p_quantity
WHERE id = p_product_id;
COMMIT;
SET p_result = '扣减成功';
END IF;
END$$
DELIMITER ;
优点 :简单可靠,不会超卖
缺点:并发性能差,大量请求会阻塞
7.3 方案二:乐观锁
sql
-- 存储过程:扣减库存(乐观锁)
DELIMITER $$
CREATE PROCEDURE deduct_stock_optimistic(
IN p_product_id BIGINT,
IN p_quantity INT,
IN p_max_retry INT,
OUT p_result VARCHAR(100)
)
BEGIN
DECLARE v_stock INT;
DECLARE v_version INT;
DECLARE v_retry INT DEFAULT 0;
DECLARE v_affected_rows INT;
retry_loop: LOOP
-- 查询(不加锁)
SELECT stock, version INTO v_stock, v_version
FROM products
WHERE id = p_product_id;
-- 检查库存
IF v_stock IS NULL THEN
SET p_result = '商品不存在';
LEAVE retry_loop;
ELSEIF v_stock < p_quantity THEN
SET p_result = '库存不足';
LEAVE retry_loop;
END IF;
-- 尝试更新
UPDATE products
SET stock = stock - p_quantity,
version = version + 1
WHERE id = p_product_id
AND version = v_version;
SET v_affected_rows = ROW_COUNT();
-- 更新成功
IF v_affected_rows > 0 THEN
SET p_result = '扣减成功';
LEAVE retry_loop;
END IF;
-- 更新失败,重试
SET v_retry = v_retry + 1;
IF v_retry >= p_max_retry THEN
SET p_result = '重试次数超限';
LEAVE retry_loop;
END IF;
END LOOP retry_loop;
END$$
DELIMITER ;
优点 :并发性能好,无锁等待
缺点:高并发时重试频繁,CPU消耗大
7.4 方案三:数据库原子操作
sql
-- 利用数据库原子性,一条SQL完成判断和更新
UPDATE products
SET stock = stock - 1
WHERE id = 1 AND stock >= 1;
-- 检查影响行数
-- 如果为1,表示扣减成功
-- 如果为0,表示库存不足或商品不存在
优点 :最简单,性能最好
缺点:无法获取扣减前的库存值
7.5 方案对比
| 方案 | 并发性能 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 悲观锁 | 低 | 低 | 库存少,并发低 |
| 乐观锁 | 中 | 中 | 库存多,并发中等 |
| 原子操作 | 高 | 低 | 只关心是否成功 |
八、踩坑提醒
8.1 SELECT * FOR UPDATE的锁范围
sql
-- 危险:没有索引的列会导致全表锁
SELECT * FROM users WHERE name = '张三' FOR UPDATE;
-- 如果name没有索引,会锁住整张表!
-- 正确做法:确保WHERE条件使用索引
-- 1. 给name加索引
CREATE INDEX idx_name ON users(name);
-- 2. 或者使用主键
SELECT * FROM users WHERE id = 1 FOR UPDATE;
8.2 死锁排查
sql
-- 1. 查看死锁日志
SHOW ENGINE INNODB STATUS;
-- 2. 查看当前锁等待
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query
FROM information_schema.innodb_lock_waits w
INNER JOIN information_schema.innodb_trx b ON b.trx_id = w.blocking_trx_id
INNER JOIN information_schema.innodb_trx r ON r.trx_id = w.requesting_trx_id;
-- 3. 查看锁信息
SELECT * FROM information_schema.innodb_locks;
8.3 锁超时处理
sql
-- 设置锁等待超时时间(秒)
SET GLOBAL innodb_lock_wait_timeout = 10;
-- 应用层捕获超时异常并重试
try {
executeTransaction();
} catch (LockWaitTimeoutException e) {
// 记录日志
// 重试或返回友好提示
}
8.4 避免在事务中做耗时操作
sql
-- 错误示范
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 调用外部API(可能耗时几秒)
callExternalAPI();
COMMIT;
-- 锁持有时间过长!
-- 正确做法
-- 1. 先调用外部API
result = callExternalAPI();
-- 2. 再开启事务
START TRANSACTION;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
UPDATE orders SET status = result.status WHERE id = 1;
COMMIT;
九、面试高频考点
Q1:InnoDB什么时候用行锁,什么时候用表锁?
答:
- 默认使用行锁:对索引项加锁
- 使用表锁的情况 :
- 执行DDL操作(ALTER TABLE等)
- 全表更新(UPDATE不带WHERE条件)
- 行锁升级为表锁:更新时索引失效,导致全表扫描
Q2:死锁是怎么产生的?如何解决?
答:
- 产生原因:两个或多个事务互相等待对方释放锁
- 解决方案 :
- 按固定顺序访问资源
- 缩短事务长度
- 使用乐观锁
- 设置锁超时时间
- 一次性锁定所有需要的资源
Q3:间隙锁和临键锁有什么区别?
答:
- 间隙锁(Gap Lock):锁定索引记录之间的间隙,不包含记录本身
- 临键锁(Next-Key Lock):记录锁+间隙锁,锁定记录及其前面的间隙
- 关系:临键锁是InnoDB默认的行锁算法,包含间隙锁
Q4:乐观锁和悲观锁的适用场景?
答:
- 悲观锁:写多读少,冲突频繁,强一致性要求的场景
- 乐观锁:读多写少,冲突概率低,可以容忍重试的场景
Q5:如何排查锁等待问题?
答:
- 使用
SHOW PROCESSLIST查看正在执行的SQL - 使用
SHOW ENGINE INNODB STATUS查看锁信息 - 查询
information_schema.innodb_lock_waits和innodb_trx表 - 使用
performance_schema中的锁相关表
十、总结
今天我们深入学习了MySQL的锁机制:
- 锁的分类:全局锁、表级锁、行级锁
- 表级锁:表锁、MDL、意向锁
- 行级锁:记录锁、间隙锁、临键锁
- 死锁:产生原因、检测、避免策略
- 乐观锁 vs 悲观锁:两种并发控制思想的对比
- 实战应用:库存扣减的多种实现方案
核心要点:
- 理解不同锁的粒度和开销
- 临键锁是InnoDB实现可重复读的关键
- 死锁可以通过设计避免
- 根据业务场景选择合适的锁策略
下一步预告
Day11我们将学习MySQL的存储过程与函数,探讨如何在数据库层封装业务逻辑,以及存储过程的优缺点和最佳实践。敬请期待!
参考资料
互动话题
- 你在项目中遇到过死锁问题吗?是如何排查和解决的?
- 你们项目中库存扣减是怎么实现的?为什么选择这种方式?
- 对于乐观锁和悲观锁,你更倾向于使用哪种?为什么?
欢迎在评论区分享你的经验和见解!如果觉得本文有帮助,别忘了点赞收藏哦~