MySQL 加锁机制分析与死锁排查
前置知识
锁的类型
- 排他锁(Exclusive Lock,X 锁):又称写锁。当事务持有 X 锁时,可以读取和修改数据,但会阻塞其他事务获取相同数据的 S 锁和 X 锁。
INSERT,UPDATE,DELETE等数据修改操作会自动获取 X 锁。可以通过SELECT ... FOR UPDATE显式加锁; - 共享锁(Shared Lock,S 锁):又称读锁。当事务持有 S 锁时,可以读取数据,但不能修改数据。它允许其他事务同时获取相同数据的 S 锁,但会阻止其他事务获取 X 锁。可以通过
SELECT ... LOCK IN SHARE MODE显式加锁。
即 X 锁与 S 锁之间的关系如下:
| X 锁 | S 锁 | |
|---|---|---|
| X 锁 | 冲突 | 冲突 |
| S 锁 | 冲突 | 兼容 |
常见的锁
Intention Locks
Intention Locks 即意向锁,这个锁是表级别的锁,用于标记事务后续对表中某行所需的锁类型(共享锁或独占锁),不会阻塞任何操作,仅对全表请求例外。一共有两类意向锁:
- IS (Intention Shared):事务计划在该表上获取一个或多个行级共享锁
SELECT ... LOCK IN SHARE MODE、某些读锁场景
- IX (Intention Exclusive):事务计划在该表上获取一个或多个行级排他锁
UPDATE/DELETE/INSERT、SELECT ... FOR UPDATE等
意向锁的主要是为了表明有人正在锁定某行,或即将锁定表中的某行。这样数据库可以在表级别快速判断是否可以授予某些表级锁,而不用遍历所有行锁。
| IS | IX | S 表锁 | X 表锁 | |
|---|---|---|---|---|
| IS | 兼容 | 兼容 | 兼容 | 冲突 |
| IX | 兼容 | 兼容 | 冲突 | 冲突 |
| S 表锁 | 兼容 | 冲突 | 兼容 | 冲突 |
| X 表锁 | 冲突 | 冲突 | 冲突 | 冲突 |
Record Lock
记录锁(Record Lock)是一种行级锁,我喜欢把它称为行锁。用于锁定索引中的一条具体记录,即使表没有定义索引,InnoDB 也会创建一个隐式的聚簇索引并使用它加锁。
Gap Lock
间隙锁(Gap Lock):锁定索引记录之间的间隙,而不是记录本身。
主要作用是防止其他事务在间隙中插入新的数据,从而在 REPEATABLE READ 隔离级别下有效避免 “幻读” 现象。
Next-Key Lock
Next-Key Lock 即临键锁,是记录锁(Record Lock)和间隙锁(Gap Lock)的合称。它锁定一个索引记录以及该记录之前的间隙,锁定区间是 “左开右闭”。
Insert Intention Locks
插入意向锁(Insert Intention Lock):一种特殊的间隙锁(Gap Lock)。它是由 INSERT 操作执行前设置的一种特殊间隙锁,用于表明事务打算在某个间隙中插入新记录。
多个事务只要插入的位置不冲突(即不在间隙中的完全相同的位置),就可以同时持有对相同间隙的插入意向锁,不会相互阻塞。插入意向锁与普通的间隙锁是互斥的,如果一个事务已经持有了某个间隙的间隙锁,另一个事务尝试在该间隙获取插入意向锁时会被阻塞。
当前读和快照读
| 语句 | 类型 | 读到什么版本 | 是否加锁 | 主要用途 |
|---|---|---|---|---|
SELECT ... |
快照读 | 事务 ReadView 中可见的版本 | 不加行锁 | 普通查询 |
SELECT ... LOCK IN SHARE MODE |
当前读 | 最新已提交版本 | 加 S 锁 | 读后希望别人不能改 |
SELECT ... FOR SHARE |
当前读 | 最新已提交版本 | 加 S 锁 | MySQL 8 推荐写法 |
SELECT ... FOR UPDATE |
当前读 | 最新已提交版本 | 加 X 锁 | 读后准备更新 |
假设:InnoDB、REPEATABLE READ,初始 val = 5。
| 时序 | 会话 A | 会话 B |
|---|---|---|
| 1 | BEGIN; |
|
| 2 | SELECT val ...;→5,建立快照 |
|
| 3 | UPDATE ... SET val=val+1; COMMIT;→ 最新值6 |
|
| 4 | SELECT val ...;→5,普通读继续读旧快照 |
|
| 5 | SELECT val ... LOCK IN SHARE MODE;→6,当前读并加 S 锁 |
|
| 6 | SELECT val ...;→5,快照并没有刷新 |
|
| 7 | SELECT val ... FOR UPDATE;→6,当前读并加 X 锁 |
|
| 8 | UPDATE ... SET val=val+1;→ 修改为7 |
|
| 9 | SELECT val ...;→7,事务能看到自己的修改 |
|
| 10 | COMMIT; |
加锁期间,会话 B 的表现:
| 会话 A 持有 | 会话 B 普通 SELECT |
会话 B LOCK IN SHARE MODE |
会话 B FOR UPDATE / UPDATE |
|---|---|---|---|
| S 锁 | 不阻塞,读快照 | 不阻塞 | 阻塞 |
| X 锁 | 不阻塞,读快照 | 阻塞 | 阻塞 |
- 普通 SELECT:快照读,读旧版本,不加锁
- LOCK IN SHARE MODE:当前读,读最新版本,加 S 锁
- FOR UPDATE:当前读,读最新版本,加 X 锁
- 当前读不会刷新普通 SELECT 的快照
- 事务修改后,可以读到自己的新数据
总结
| 分类 | 名称 | 作用范围 / 含义 | 常见场景 |
|---|---|---|---|
| 锁模式 | S 锁 | 共享锁;允许多个事务同时读,但阻止修改 | SELECT ... FOR SHARE |
| 锁模式 | X 锁 | 排他锁;阻止其他事务加 S 锁或 X 锁 | UPDATE、DELETE、SELECT ... FOR UPDATE |
| 锁模式 | IS 锁 | 表级意向共享锁;表示准备对表中部分记录加 S 锁 | 执行 FOR SHARE前自动获取 |
| 锁模式 | IX 锁 | 表级意向排他锁;表示准备对表中部分记录加 X 锁 | 执行 UPDATE、FOR UPDATE前自动获取 |
| 锁定范围 | Record Lock | 锁住一条索引记录 | 主键或唯一索引等值查询 |
| 锁定范围 | Gap Lock | 锁住两条索引记录之间的间隙,防止插入 | RR 隔离级别下的范围查询 |
| 锁定范围 | Next-Key Lock | Record Lock + 前方 Gap Lock |
RR 下非唯一索引范围查询 |
| 特殊锁 | Insert Intention Lock | 表示准备向某个索引间隙插入数据 | INSERT |
| 特殊锁 | AUTO-INC Lock | 保护自增值的分配 | 使用自增主键批量插入 |
| Server 层 | MDL 元数据锁 | 保护表结构,避免查询和 DDL 同时破坏结构 | SELECT与 ALTER TABLE冲突 |
死锁
死锁是指两个或多个事务在执行过程中,因操作需要加锁而相互等待的现象。这种情况下,MySQL 的 InnoDB 存储引擎会自动检测到死锁并回滚其中一个事务,以打破僵局。
举个例子,比如事务 A 持有事务 B 需要的锁,且事务 B 持有事务 A 需要的锁时,事务 A、B 双方都需要等待对方释放所需的锁,导致事务无法继续执行,形成一种 “僵局”(即循环依赖)。
如何判断是否发生了死锁
一种是直接通过服务端报错日志:由于我这边已经解决了死锁问题,找不到日志了,这里就不做展示了。
虽然但是,我刚搜索了一下,日志了又出现了新的死锁,正好展示:
{"level":"warn","ts":1783587512.371989,"caller":"db/gorm_logger_writer.go:36","msg":"\u001b[31;1m/Users/henry/Proj/im-demo/internal/message/delivery/repo.go:70 \u001b[35;1mError 1213 (40001): Deadlock found when trying to get lock; try restarting transaction\n\u001b[0m\u001b[33m[8.594ms] \u001b[34;1m[rows:0]\u001b[0m UPDATE `message_deliveries` SET `received_at`=COALESCE(received_at, '2026-07-09 16:58:32.363'),`status`=CASE WHEN status < 3 THEN 3 ELSE status END,`updated_at`='2026-07-09 16:58:32.363' WHERE conversation_id = 'single_b74cd02da8b9267f2c45fff5083829236e56da8d0f5f8ec4a801e3ca298d90bd' AND user_id = 'load_receiver_521' AND seq <= 1628 AND status < 3"}
{"level":"warn","ts":1783587540.527381,"caller":"db/gorm_logger_writer.go:36","msg":"\u001b[31;1m/Users/henry/Proj/im-demo/internal/message/delivery/repo.go:86 \u001b[35;1mError 1213 (40001): Deadlock found when trying to get lock; try restarting transaction\n\u001b[0m\u001b[33m[4.207ms] \u001b[34;1m[rows:0]\u001b[0m UPDATE `message_deliveries` SET `read_at`=COALESCE(read_at, '2026-07-09 16:59:00.523'),`received_at`=COALESCE(received_at, '2026-07-09 16:59:00.523'),`status`=CASE WHEN status < 4 THEN 4 ELSE status END,`updated_at`='2026-07-09 16:59:00.523' WHERE conversation_id = 'single_2faf4cb35d1c7bb15e2c5839cb22e3b40d4612d59c0df40f0d6ee7ec48720ea3' AND user_id = 'load_receiver_1147' AND seq <= 184 AND status < 4"}
{"level":"warn","ts":1783587551.845919,"caller":"db/gorm_logger_writer.go:36","msg":"\u001b[31;1m/Users/henry/Proj/im-demo/internal/message/delivery/repo.go:46 \u001b[35;1mError 1213 (40001): Deadlock found when trying to get lock; try restarting transaction\n\u001b[0m\u001b[33m[12.359ms] \u001b[34;1m[rows:0]\u001b[0m UPDATE `message_deliveries` SET `consumed_at`='2026-07-09 16:59:11.833',`last_error`='',`realtime_delivered_at`='2026-07-09 16:59:11.833',`realtime_push_status`=1,`status`=CASE WHEN status < 2 THEN 2 ELSE status END,`updated_at`='2026-07-09 16:59:11.833' WHERE server_msg_id = 'msg_0538b439-e62d-4f95-a0bc-446ec8da83f9' AND user_id = 'load_sender_960'"}
还有一种常见的就是通过 InnoDB 状态信息(SHOW ENGINE INNODB STATUS)输出最近的一次死锁:
=====================================
2026-07-07 17:24:57 281472767020800 INNODB MONITOR OUTPUT
=====================================
Per second averages calculated from the last 31 seconds
-----------------
BACKGROUND THREAD
-----------------
srv_master_thread loops: 542 srv_active, 0 srv_shutdown, 19702 srv_idle
srv_master_thread log flush and writes: 0
----------
SEMAPHORES
----------
OS WAIT ARRAY INFO: reservation count 343043
OS WAIT ARRAY INFO: signal count 310938
RW-shared spins 0, rounds 0, OS waits 0
RW-excl spins 0, rounds 0, OS waits 0
RW-sx spins 0, rounds 0, OS waits 0
Spin rounds per wait: 0.00 RW-shared, 0.00 RW-excl, 0.00 RW-sx
------------------------
LATEST DETECTED DEADLOCK
------------------------
2026-07-07 17:14:14 281472678223616
*** (1) TRANSACTION:
TRANSACTION 411123, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 12 lock struct(s), heap size 1128, 8 row lock(s)
MySQL thread id 559, OS thread handle 281469453651712, query id 2959629 192.168.215.1 root update
INSERT INTO `conversation_members` (`conversation_id`,`user_id`,`peer_user_id`,`read_seq`,`recv_seq`,`unread_count`,`created_at`,`updated_at`) VALUES ('single_26ac5b5c3a91f64083c980ebe5a1fac5ccbb8cb8fcadd66fb8bbaa07b560fda0','load_receiver_496','load_sender_496',0,0,0,'2026-07-07 17:14:14.480975','2026-07-07 17:14:14.480975'),('single_26ac5b5c3a91f64083c980ebe5a1fac5ccbb8cb8fcadd66fb8bbaa07b560fda0','load_sender_496','load_receiver_496',0,0,0,'2026-07-07 17:14:14.480975','2026-07-07 17:14:14.480975') ON DUPLICATE KEY UPDATE `id`=`id`
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411123 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411123 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (2) TRANSACTION:
TRANSACTION 411125, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 11 lock struct(s), heap size 1128, 8 row lock(s)
MySQL thread id 553, OS thread handle 281469431287552, query id 2959630 192.168.215.1 root update
INSERT INTO `conversation_members` (`conversation_id`,`user_id`,`peer_user_id`,`read_seq`,`recv_seq`,`unread_count`,`created_at`,`updated_at`) VALUES ('single_f789bef7899a607cd55d518fbd6cab89fdeffc90d5324199ac37ed0af545eaa5','load_receiver_497','load_sender_497',0,0,0,'2026-07-07 17:14:14.485384','2026-07-07 17:14:14.485384'),('single_f789bef7899a607cd55d518fbd6cab89fdeffc90d5324199ac37ed0af545eaa5','load_sender_497','load_receiver_497',0,0,0,'2026-07-07 17:14:14.485384','2026-07-07 17:14:14.485384') ON DUPLICATE KEY UPDATE `id`=`id`
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411125 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 98 page no 50 n bits 160 index PRIMARY of table `tinyim`.`conversation_members` trx id 411125 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;
*** WE ROLL BACK TRANSACTION (2)
------------
TRANSACTIONS
------------
Trx id counter 413117
Purge done for trx's n:o < 412935 undo n:o < 0 state: running but idle
History list length 0
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 562948231742720, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231720904, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231720096, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231719288, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231718480, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231715248, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
---TRANSACTION 562948231714440, not started
0 lock struct(s), heap size 1128, 0 row lock(s)
--------
FILE I/O
--------
I/O thread 0 state: waiting for completed aio requests (insert buffer thread)
I/O thread 1 state: waiting for completed aio requests (read thread)
I/O thread 2 state: waiting for completed aio requests (read thread)
I/O thread 3 state: waiting for completed aio requests (read thread)
I/O thread 4 state: waiting for completed aio requests (read thread)
I/O thread 5 state: waiting for completed aio requests (read thread)
I/O thread 6 state: waiting for completed aio requests (read thread)
I/O thread 7 state: waiting for completed aio requests (read thread)
I/O thread 8 state: waiting for completed aio requests (read thread)
I/O thread 9 state: waiting for completed aio requests (read thread)
I/O thread 10 state: waiting for completed aio requests (write thread)
I/O thread 11 state: waiting for completed aio requests (write thread)
I/O thread 12 state: waiting for completed aio requests (write thread)
I/O thread 13 state: waiting for completed aio requests (write thread)
Pending normal aio reads: [0, 0, 0, 0, 0, 0, 0, 0, 0] , aio writes: [0, 0, 0, 0] ,
ibuf aio reads:
Pending flushes (fsync) log: 0; buffer pool: 0
7835 OS file reads, 880740 OS file writes, 337207 OS fsyncs
1.38 reads/s, 16384 avg bytes/read, 4.34 writes/s, 3.58 fsyncs/s
-------------------------------------
INSERT BUFFER AND ADAPTIVE HASH INDEX
-------------------------------------
Ibuf: size 1, free list len 0, seg size 2, 0 merges
merged operations:
insert 0, delete mark 0, delete 0
discarded operations:
insert 0, delete mark 0, delete 0
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
Hash table size 34679, node heap has 0 buffer(s)
0.00 hash searches/s, 15.74 non-hash searches/s
---
LOG
---
Log capacity 104857600
Log capacity used 104857600
Log sequence number 1001130960
Log buffer assigned up to 1001130960
Log buffer completed up to 1001130960
Log written up to 1001130960
Log flushed up to 1001130960
Added dirty pages up to 1001130960
Pages flushed up to 1001130890
Last checkpoint at 1001130890
Log minimum file id is 277
Log maximum file id is 305
569598 log i/o's done, 2.03 log i/o's/second
----------------------
BUFFER POOL AND MEMORY
----------------------
Total large memory allocated 0
Dictionary memory allocated 1079211
Buffer pool size 8192
Free buffers 980
Database pages 7212
Old database pages 2670
Modified db pages 1
Pending reads 0
Pending writes: LRU 0, flush list 0, single page 0
Pages made young 35943, not young 1235373
0.00 youngs/s, 7.92 non-youngs/s
Pages read 7115, created 9250, written 285174
1.38 reads/s, 0.00 creates/s, 1.01 writes/s
Buffer pool hit rate 965 / 1000, young-making rate 0 / 1000 not 202 / 1000
Pages read ahead 0.00/s, evicted without access 0.00/s, Random read ahead 0.00/s
LRU len: 7212, unzip_LRU len: 0
I/O sum[98]:cur[1], unzip sum[0]:cur[0]
--------------
ROW OPERATIONS
--------------
0 queries inside InnoDB, 0 queries in queue
0 read views open inside InnoDB
Process ID=1, Main thread ID=281472593682176 , state=sleeping
Number of rows inserted 150253, updated 3480946, deleted 0, read 11822072
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 0.00 reads/s
Number of system rows inserted 815, updated 352, deleted 800, read 28838
0.00 inserts/s, 0.00 updates/s, 0.00 deletes/s, 8.77 reads/s
----------------------------
END OF INNODB MONITOR OUTPUT
============================
直接搜索关键词 LATEST DETECTED DEADLOCK 即可找到。
生产环境常见死锁
交叉死锁
假设发送事务顺序是:
SendMessage:
1. 锁 conversations
2. 更新 conversation_members
3. 更新 message_deliveries
而某个已读事务却是:
ConversationRead:
1. 更新 message_deliveries
2. 更新 conversation_members
3. 更新 conversations
就可能形成:
事务 A:
持有 conversations
持有 conversation_members
等待 message_deliveries
事务 B:
持有 message_deliveries
等待 conversation_members
由于两个事务中更新顺序不同带来的加锁顺序不同,造成相互持有对方需要的锁,从而死锁,这就是最经典的交叉死锁。
MySQL 官方也明确建议:修改多张表或同一张表多行时,所有事务都使用一致的访问顺序。
再来看第二个:
message.received
conversation.read
批量更新 message_deliveries
批量推进 read_seq / recv_seq
假设存在两个异步 Worker,并且它们分别在事务中更新相同的两条记录:
Worker A:先更新 delivery 100,再更新 delivery 101
Worker B:先更新 delivery 101,再更新 delivery 100
由于行锁会一直持有到事务提交或回滚,因此可能出现:
Worker A 持有 delivery 100 的行锁,等待 delivery 101
Worker B 持有 delivery 101 的行锁,等待 delivery 100
这就形成了循环等待,最终触发数据库死锁检测,其中一个事务会被回滚。
最直接的解决方式,是让所有事务按照统一顺序获取行锁。例如在更新前,统一按照 DeliveryID 排序:
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].DeliveryID < tasks[j].DeliveryID
})
当然上面的方法太简单粗暴了,任务队列一旦变大,那排序这里就会花不少时间(虽然比起网络延时与 I/O 也算不上什么),我们最好能让这个任务一开始就是有序入队的。
我目前的方案是分片串行队列:
给每一个 Worker 都单独开一个任务队列,然后将每个队列分配给固定的 Worker,这样可以保证同一个队列是由一个 Worker 串行消费,避免并行导致的顺序问题。此外,通过用业务聚合键等特征进行哈希,将同一个会话的 delivery 映射到相同的队列中,刚才说到,一个队列是由一个 Worker 串行消费,所以这样也就确定了执行顺序。
在我的 IM 微服务实现链路下,一次 message.received 或 conversation.read 任务,可能同时更新多条 message_deliveries,并推进同一用户在同一会话中的 read_seq 或 recv_seq。只要这些相关任务都进入同一个分片队列,就会由同一个 Worker 串行执行,从而避免它们并发争抢相同的行锁。
不会再出现一个持有 delivery 100 锁的 Worker 在等 delivery 101 的行锁,而另一个持有 delivery 101 锁的 Worker 又在等 delivery 100 的锁。
此外,哈希碰撞会导致两个原本无关的会话被映射到同一个队列,虽然降低了部分并发度,但不会造成多个 Worker 交叉持有这些记录的行锁。
其他场景
TODO…
累了,我先休息了。
评论区