MySQL 锁机制深度解析——表锁、行锁、间隙锁、死锁排查完整手册
MySQL 锁机制深度解析——表锁、行锁、间隙锁、死锁排查完整手册
适读人群:经历过死锁、锁等待超时、并发数据不一致问题的后端工程师 | 阅读时长:约19分钟 | 核心价值:彻底搞懂 MySQL 锁的分类和原理,建立死锁排查和预防的完整能力
那次让我们熬了一整夜的死锁
2021 年某个大促前夕,我帮一个做新零售的团队排查生产问题。他们的日志里每隔几分钟就出现一次:
Deadlock found when trying to get lock; try restarting transaction错误只在高并发时出现,本地复现不了,测试环境也复现不了,只有生产才有。负责人老孙已经两天没睡了,手忙脚乱地在各个地方加了一堆重试逻辑,但死锁还是不断出现。
我进去之后,第一件事是查 InnoDB 的锁信息:
SHOW ENGINE INNODB STATUS\G在 LATEST DETECTED DEADLOCK 段落里,清清楚楚地记录了最近一次死锁的两个事务、各自持有的锁和等待的锁。五分钟内定位到了根因:两个事务以不同顺序操作同一批行,互相等待对方的锁释放。
这不是玄学,是可以分析、可以解决的工程问题。今天把 MySQL 锁机制从原理到排查,系统梳理一遍。
一、锁的分类
MySQL 的锁按粒度分:表锁、行锁、页锁(NDB Cluster,不常见)。按类型分:共享锁(S Lock)、排他锁(X Lock)。
1.1 表锁
MyISAM 用表锁,InnoDB 在特殊情况下也会用表锁(比如 ALTER TABLE、LOCK TABLES)。
表锁粒度粗,并发写性能差,但开销低。对于绝大多数 OLTP 场景,我们都在用 InnoDB 的行锁。
1.2 行锁的三种形态
InnoDB 的行锁不是锁"行",准确说是锁"索引记录"。这个理解很重要。
记录锁(Record Lock):锁住索引上的某个具体记录。
-- 锁住 id=5 这一条记录的索引
SELECT * FROM orders WHERE id = 5 FOR UPDATE;间隙锁(Gap Lock):锁住索引上两个记录之间的"间隙",防止其他事务在这个间隙里插入新记录。
假设表里 id 有 1, 5, 10 三条记录,间隙是 (1,5)、(5,10)、(10,+∞)。
-- 对 id=5 加间隙锁(锁住 (1,5) 和 (5,10) 两个间隙)
-- 阻止其他事务插入 id=2,3,4 或 id=6,7,8,9 的记录
SELECT * FROM orders WHERE id = 5 FOR UPDATE; -- 在 RR 隔离级别下临键锁(Next-Key Lock):记录锁 + 间隙锁的组合,锁住记录本身和它左侧的间隙。是 InnoDB 行锁的默认形态(在 Repeatable Read 隔离级别下)。
二、间隙锁:被误解最深的锁
2.1 间隙锁存在的原因
间隙锁是为了解决幻读(Phantom Read)问题。
-- 事务 T1(RR 隔离级别)
BEGIN;
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 返回 100
-- 此时事务 T2 插入了 5 条 status=1 的订单并提交
SELECT COUNT(*) FROM orders WHERE status = 1;
-- 如果没有间隙锁,这里返回 105,出现幻读间隙锁锁住了 status=1 的记录之间的间隙,T2 的插入会被阻塞,T1 两次查询结果一致。
2.2 间隙锁的副作用——插入堵塞
踩坑一:间隙锁导致并发插入严重阻塞
现象:订单状态更新操作 QPS 只有 200,但实际并发插入被大量阻塞,超时报错。
原因:更新操作(UPDATE orders SET status=2 WHERE status=1)在 RR 隔离级别下,会对 status=1 的索引范围加临键锁,同时在索引间隙上加间隙锁。其他事务插入新的 status=1 的订单时,被间隙锁阻塞。
解法之一:降低隔离级别到 Read Committed(RC)。RC 级别下,间隙锁不存在,只有记录锁,并发插入不再被阻塞。但需要在应用层处理幻读问题(通常业务上是可以接受的)。
-- 会话级别修改隔离级别
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 全局修改(需要在 my.cnf 配置)
transaction-isolation = READ-COMMITTED三、死锁:原理、案例与排查
3.1 死锁的必要条件
死锁的四个必要条件:互斥、占有且等待、不可抢占、循环等待。MySQL 死锁本质上是两个(或多个)事务形成了"锁等待环"。
3.2 经典死锁案例
案例一:不同顺序操作同一批行
-- 事务 T1:先操作 id=1,再操作 id=2
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- 持有 id=1 的锁
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- 等待 id=2 的锁
-- 事务 T2:先操作 id=2,再操作 id=1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 2; -- 持有 id=2 的锁
UPDATE accounts SET balance = balance + 100 WHERE id = 1; -- 等待 id=1 的锁 → 死锁!解法:保证所有事务以相同顺序操作资源。比如转账操作,统一按 MIN(id) 先操作 id 更小的账户。
案例二:先查后改与直接插入冲突
-- 事务 T1:
BEGIN;
SELECT * FROM orders WHERE order_no = 'ORD001' FOR UPDATE; -- 没找到记录,加间隙锁
INSERT INTO orders (order_no, ...) VALUES ('ORD001', ...); -- 等待(被 T2 的间隙锁阻塞)
-- 事务 T2(同时执行):
BEGIN;
SELECT * FROM orders WHERE order_no = 'ORD001' FOR UPDATE; -- 没找到记录,加间隙锁
INSERT INTO orders (order_no, ...) VALUES ('ORD001', ...); -- 等待(被 T1 的间隙锁阻塞)→ 死锁!这是"先检查后插入"(Check-Then-Insert)模式下的经典死锁,高并发环境下极易出现。
解法:使用 INSERT ... ON DUPLICATE KEY UPDATE 或 REPLACE INTO 代替先查后改的模式。
3.3 死锁排查步骤
/**
* 死锁发生时的处理建议
* 1. 捕获 DeadlockLoserDataAccessException
* 2. 自动重试(有限次数)
* 3. 同时记录日志,便于后续分析
*/
@Service
public class OrderService {
private static final Logger log = LoggerFactory.getLogger(OrderService.class);
@Transactional
@Retryable(
value = DeadlockLoserDataAccessException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
public void updateOrderStatus(Long orderId, Integer status) {
try {
orderMapper.updateStatus(orderId, status);
} catch (DeadlockLoserDataAccessException e) {
log.warn("死锁发生,将重试。orderId={}, status={}", orderId, status);
throw e; // 抛出让 @Retryable 捕获并重试
}
}
@Recover // 所有重试都失败后执行
public void recoverFromDeadlock(DeadlockLoserDataAccessException e,
Long orderId, Integer status) {
log.error("死锁重试耗尽,需要人工介入。orderId={}, status={}", orderId, status, e);
// 发告警、写失败日志等
throw new BusinessException("系统繁忙,请稍后重试");
}
}排查死锁的 SQL 工具箱:
-- 1. 查看最近一次死锁详情
SHOW ENGINE INNODB STATUS\G
-- 找 LATEST DETECTED DEADLOCK 段落,查看两个事务的 SQL 和锁信息
-- 2. 查看当前锁等待(MySQL 8.0)
SELECT * FROM performance_schema.data_lock_waits\G
-- 3. 查看当前所有锁(MySQL 8.0)
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;四、锁等待超时与解决
踩坑二:Lock wait timeout exceeded; try restarting transaction
现象:某些更新操作频繁报锁等待超时(innodb_lock_wait_timeout 默认 50 秒,出现说明锁被持有超过 50 秒)。
原因:通常是有长事务持有锁不释放。常见原因:
- 事务中有慢查询,导致事务执行时间过长
- 代码里有
@Transactional方法里调用了外部服务(HTTP 调用、MQ 发送),事务等待外部调用完成 - 数据库连接归还不及时,事务没有被正确提交/回滚
// 踩坑:@Transactional 方法里调用外部 HTTP 接口
@Transactional // 事务开启后,持有 orders 表的行锁
public void createOrder(CreateOrderRequest req) {
Order order = buildOrder(req);
orderMapper.insert(order); // 插入后,持有行锁
// 危险!调用外部支付接口可能需要 2-5 秒
// 这 2-5 秒内,行锁一直被持有
paymentClient.initPayment(order.getId(), req.getAmount());
// 如果 paymentClient 超时 30 秒,行锁被持有 30 秒
// 其他更新这行的事务都在等待,超时后报 Lock wait timeout
}
// 正确做法:把外部调用移到事务之外
public void createOrder(CreateOrderRequest req) {
// 先在事务内完成数据库操作(快速完成,释放锁)
Long orderId = createOrderInTransaction(req);
// 事务提交后,再调用外部接口(锁已释放)
paymentClient.initPayment(orderId, req.getAmount());
}
@Transactional
public Long createOrderInTransaction(CreateOrderRequest req) {
Order order = buildOrder(req);
orderMapper.insert(order);
return order.getId();
// 方法返回,事务提交,锁立即释放
}踩坑三:SELECT ... FOR UPDATE 范围锁导致大量阻塞
现象:订单状态流转时,大量超时。
原因:SELECT * FROM orders WHERE user_id = 123 FOR UPDATE 在 user_id 是非唯一索引的情况下,会锁住 user_id=123 的所有记录及其间隙。如果这个用户有 1000 个订单,就锁了 1000 行 + 大量间隙,阻塞了所有对这批记录的操作。
解法:如果只需要更新特定的记录,把锁范围缩到最小:
-- 差:锁住所有 user_id=123 的订单
SELECT * FROM orders WHERE user_id = 123 FOR UPDATE;
-- 好:只锁住要操作的特定订单
SELECT * FROM orders WHERE id = #{orderId} FOR UPDATE;五、锁的最佳实践
锁问题是数据库并发编程中最复杂的领域,一句话总结我的建议:事务越短越好,锁的范围越小越好,加锁的顺序要固定。
具体来说:
- 把耗时操作(网络调用、复杂计算)移到事务外面
- 用精确的主键查询代替范围查询做加锁
- 同一个业务流程里,始终按照固定顺序操作多张表/多条记录
- 遇到死锁不要只加重试,要分析根因并从设计上消除死锁条件
