Record Lock 锁定索引中的单条记录。当你使用唯一索引的等值查询精确匹配到一条记录时,InnoDB 只加 Record Lock 而不加 Gap Lock。
-- SetupCREATE TABLE t (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
INDEX idx_age (age)
) ENGINE=InnoDB;
INSERT INTO t VALUES (1,'Alice',25),(5,'Bob',30),(10,'Carol',35),(15,'Dave',40);
-- Transaction A: exact match on PK → Record Lock onlyBEGIN;
SELECT * FROM t WHERE id = 5 FOR UPDATE;
-- Locks: X,REC_NOT_GAP on index `PRIMARY`, record (id=5)-- Transaction B: can still insert id=3 or id=7 (no gap lock)BEGIN;
INSERT INTO t VALUES (3,'Eve',28); -- ✅ succeedsINSERT INTO t VALUES (7,'Frank',32); -- ✅ succeeds
-- Index structure diagram: Record Lock on id=5 Primary Index B+ Tree Leaf Page ┌────────┬────────┬────────┬────────┬──────────┐ │ id=1 │ id=5 │ id=10 │ id=15 │ supremum │ │ │ 🔒 X │ │ │ │ │ │REC_NOT │ │ │ │ │ │ _GAP │ │ │ │ └────────┴────────┴────────┴────────┴──────────┘
Gap Lock 锁定索引记录之间的间隙(gap),防止其他事务向间隙中插入新记录。Gap Lock 只在 REPEATABLE READ(RR)隔离级别下使用,在 READ COMMITTED(RC)下不使用。
核心特性:
Gap Lock 之间不冲突!两个事务可以同时持有同一个间隙上的 Gap Lock(无论 S 还是 X 模式)。Gap Lock 唯一的作用是阻止 INSERT。这也是为什么 Gap Lock 容易导致死锁的原因之一。
-- Transaction A: query on non-existent key → Gap LockBEGIN;
SELECT * FROM t WHERE id = 7 FOR UPDATE;
-- id=7 doesn't exist. InnoDB locks the gap (5, 10)-- Locks: X,GAP on index `PRIMARY`, record (id=10) — means gap before 10-- Transaction B: trying to insert into the locked gapBEGIN;
INSERT INTO t VALUES (6,'Eve',28); -- ❌ BLOCKED (waiting for gap lock)INSERT INTO t VALUES (8,'Frank',32); -- ❌ BLOCKEDINSERT INTO t VALUES (9,'Grace',29); -- ❌ BLOCKEDINSERT INTO t VALUES (3,'Hank',27); -- ✅ succeeds (outside gap)INSERT INTO t VALUES (11,'Ivy',31); -- ✅ succeeds (outside gap)
-- Gap Lock diagram Primary Index B+ Tree Leaf Page ┌────────┬───────────────────┬────────┬────────┬──────────┐ │ id=1 │ GAP (5,10) │ id=10 │ id=15 │ supremum │ │ │ 🔒 X,GAP │ │ │ │ │ │ blocks INSERT │ │ │ │ │ │ of id=6,7,8,9 │ │ │ │ └────────┴───────────────────┴────────┴────────┴──────────┘ ↑ ↑ gap starts gap lock is attached after id=5 to record id=10
源码参考: storage/innobase/lock/lock0lock.cc — lock_rec_lock(), LOCK_GAP flag
2.3 Next-Key Lock(临键锁)
Next-Key Lock = Record Lock + Gap Lock,即锁定一条记录以及该记录之前的间隙。Next-Key Lock 是 InnoDB 在 RR 隔离级别下的默认行锁类型,用于防止幻读(Phantom Reads)。
-- Transaction A: range query on secondary index → Next-Key LocksBEGIN;
SELECT * FROM t WHERE age >= 30 AND age < 40 FOR UPDATE;
-- InnoDB scans idx_age and locks:-- 1. Next-Key Lock on idx_age record (age=30, id=5): locks (25,30]-- 2. Next-Key Lock on idx_age record (age=35, id=10): locks (30,35]-- 3. Gap Lock on idx_age record (age=40, id=15): locks (35,40) — gap only-- 4. Record Lock (X,REC_NOT_GAP) on PRIMARY id=5 and id=10-- Net effect: age range [30, 40) is fully locked, no phantom possible
-- Existing records: id = 1, 5, 10, 15-- Gap between id=5 and id=10: (5, 10)-- Transaction A:BEGIN;
INSERT INTO t VALUES (6,'Eve',28);
-- Acquires: Insert Intention Lock on gap (5,10), then Record Lock on id=6-- Transaction B (concurrent):BEGIN;
INSERT INTO t VALUES (8,'Frank',32);
-- Acquires: Insert Intention Lock on gap (5,10), then Record Lock on id=8-- ✅ Both succeed concurrently — Insert Intention Locks don't conflict!
但是:
如果另一个事务已经在该间隙上持有 Gap Lock(非 Insert Intention 类型),则 Insert Intention Lock 会被阻塞。这是 Gap Lock + INSERT 导致死锁的根本原因。
-- Deadlock scenario: Gap Lock vs Insert Intention Lock-- TX A:BEGIN;
SELECT * FROM t WHERE id = 7 FOR UPDATE; -- Gap Lock on (5,10)-- TX B:BEGIN;
SELECT * FROM t WHERE id = 8 FOR UPDATE; -- Gap Lock on (5,10) — compatible!-- TX A:INSERT INTO t VALUES (7,'Eve',28);
-- Needs Insert Intention Lock on (5,10), blocked by TX B's Gap Lock → WAIT-- TX B:INSERT INTO t VALUES (8,'Frank',32);
-- Needs Insert Intention Lock on (5,10), blocked by TX A's Gap Lock → DEADLOCK!
源码参考: storage/innobase/lock/lock0lock.cc — LOCK_INSERT_INTENTION flag
// Simplified from storage/innobase/lock/lock0lock.ccstatic boollock_rec_has_to_wait(
const lock_t *lock1, // requesting lockconst lock_t *lock2, // existing lock
ulint heap_no)
{
// Rule 1: Same transaction never waits for itselfif (lock1->trx == lock2->trx) return false;
// Rule 2: If modes are compatible (S+S), no waitif (lock_mode_compatible(lock1->mode, lock2->mode)) return false;
// Rule 3: Gap lock never blocks anything except Insert Intentionif (lock_rec_get_gap(lock2)) {
// Existing lock is a gap-only lockif (!lock_rec_get_insert_intention(lock1)) return false;
// Insert Intention vs Gap → must wait
}
// Rule 4: Requesting a gap lock? Never waitif (lock_rec_get_gap(lock1)) return false;
// Rule 5: Insert Intention never has to wait for Insert Intentionif (lock_rec_get_insert_intention(lock1)
&& lock_rec_get_insert_intention(lock2)) return false;
// Rule 6: Record part of requesting lock vs record part of existing lockif (lock_rec_get_rec_not_gap(lock1)) {
// Only the record component mattersif (lock_rec_get_gap(lock2)) return false;
}
return true; // must wait
}
CREATE TABLE orders (
id INT PRIMARY KEY,
user_id INT,
amount DECIMAL(10,2),
status VARCHAR(20),
INDEX idx_user (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB;
INSERT INTO orders VALUES
(1, 100, 50.00, 'paid'),
(5, 100, 80.00, 'paid'),
(10, 200, 120.00, 'pending'),
(15, 200, 200.00, 'paid'),
(20, 300, 90.00, 'shipped'),
(25, 300, 150.00, 'paid');
场景 1: SELECT ... FOR UPDATE — 主键等值
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
-- Index used: PRIMARY-- Locks acquired:-- TABLE: IX-- PRIMARY id=10: X,REC_NOT_GAP (record lock only, no gap)-- Why no gap lock? PK is unique, exact match → record lock suffices
场景 2: SELECT ... FOR UPDATE — 主键不存在
SELECT * FROM orders WHERE id = 12 FOR UPDATE;
-- Index used: PRIMARY-- Locks acquired:-- TABLE: IX-- PRIMARY id=15: X,GAP (gap lock on (10, 15))-- Why? id=12 doesn't exist, InnoDB locks the gap to prevent phantom
场景 3: SELECT ... FOR UPDATE — 主键范围
SELECT * FROM orders WHERE id BETWEEN 5 AND 15 FOR UPDATE;
-- Index used: PRIMARY (range scan)-- Locks acquired:-- TABLE: IX-- PRIMARY id=5: X (next-key lock, locks (1,5])-- PRIMARY id=10: X (next-key lock, locks (5,10])-- PRIMARY id=15: X (next-key lock, locks (10,15])-- PRIMARY id=20: X,GAP (gap lock, locks (15,20) — scan stop point)-- Total locked range: (1, 20) — wider than you might expect!
场景 4: SELECT ... FOR SHARE(LOCK IN SHARE MODE)
SELECT * FROM orders WHERE id = 10 FOR SHARE;
-- MySQL 8.0+ syntax; equivalent to: LOCK IN SHARE MODE-- Locks acquired:-- TABLE: IS-- PRIMARY id=10: S,REC_NOT_GAP (shared record lock)-- Another tx can also FOR SHARE on id=10 → both succeed-- But FOR UPDATE on id=10 → blocked until S lock released
场景 5: SELECT ... FOR UPDATE — 非唯一索引等值
SELECT * FROM orders WHERE user_id = 200 FOR UPDATE;
-- Index used: idx_user-- Locks acquired:-- TABLE: IX-- idx_user (user_id=200, id=10): X (next-key, locks gap before + record)-- idx_user (user_id=200, id=15): X (next-key)-- idx_user (user_id=300, id=20): X,GAP (gap lock — scan stop)-- PRIMARY id=10: X,REC_NOT_GAP-- PRIMARY id=15: X,REC_NOT_GAP-- Why next-key on non-unique? To prevent phantom: INSERT user_id=200
场景 6: UPDATE — 主键等值
UPDATE orders SET amount = 100.00 WHERE id = 10;
-- Index used: PRIMARY-- Locks acquired:-- TABLE: IX-- PRIMARY id=10: X,REC_NOT_GAP-- Same as FOR UPDATE with PK equality — record lock only
场景 7: UPDATE — 非唯一索引范围
UPDATE orders SET amount = amount * 1.1 WHERE user_id >= 200;
-- Index used: idx_user (range scan from user_id=200 to supremum)-- Locks acquired:-- TABLE: IX-- idx_user (user_id=200, id=10): X (next-key)-- idx_user (user_id=200, id=15): X (next-key)-- idx_user (user_id=300, id=20): X (next-key)-- idx_user (user_id=300, id=25): X (next-key)-- idx_user supremum: X (next-key — locks to +infinity)-- PRIMARY id=10,15,20,25: X,REC_NOT_GAP (each)-- WARNING: supremum lock blocks ALL inserts with user_id ≥ 200
场景 8: UPDATE — 修改索引列
UPDATE orders SET user_id = 400 WHERE id = 10;
-- Locks acquired:-- TABLE: IX-- PRIMARY id=10: X,REC_NOT_GAP-- Note: When updating a secondary index column, InnoDB also needs to-- delete the old secondary index entry and insert a new one.-- This involves additional implicit locking on idx_user.
场景 9: DELETE — 主键等值
DELETE FROM orders WHERE id = 10;
-- Locks acquired:-- TABLE: IX-- PRIMARY id=10: X,REC_NOT_GAP-- Same as UPDATE on PK equality-- The record is delete-marked, purge thread removes it later
场景 10: DELETE — 非唯一索引
DELETE FROM orders WHERE user_id = 200;
-- Index used: idx_user-- Locks (same pattern as SELECT ... FOR UPDATE on non-unique index):-- idx_user (user_id=200, id=10): X (next-key)-- idx_user (user_id=200, id=15): X (next-key)-- idx_user (user_id=300, id=20): X,GAP-- PRIMARY id=10: X,REC_NOT_GAP-- PRIMARY id=15: X,REC_NOT_GAP
场景 11: DELETE — 无索引(全表扫描)
DELETE FROM orders WHERE amount > 100.00;
-- No index on amount → full table scan on PRIMARY-- Locks acquired (TERRIFYING):-- TABLE: IX-- PRIMARY: next-key lock on EVERY record + supremum-- Effectively locks the entire table!-- -- Why? Without an index, InnoDB must scan all rows and lock each one.-- It cannot release locks on non-matching rows mid-transaction.
严重警告:
没有合适索引的 UPDATE/DELETE 会锁住整张表!这是生产环境中最常见的"锁全表"事故原因。始终确保 WHERE 条件能命中索引。
场景 12: INSERT — 基本加锁
INSERT INTO orders VALUES (12, 200, 75.00, 'pending');
-- Locks acquired (briefly):-- TABLE: IX-- Insert Intention Lock on gap (10, 15) of PRIMARY-- PRIMARY id=12: X,REC_NOT_GAP (held until commit)-- -- The insert intention lock is released as soon as the record lock-- is placed on the newly inserted row.
场景 13: INSERT — 唯一键冲突
-- TX A:BEGIN;
INSERT INTO orders VALUES (10, 500, 99.00, 'new');
-- ERROR 1062: Duplicate entry '10' for key 'PRIMARY'-- BUT: TX A now holds S,REC_NOT_GAP on PRIMARY id=10 (shared lock!)-- Why S lock on failure? To prevent the conflicting row from being-- deleted before the failing transaction decides what to do.
场景 14: INSERT ... ON DUPLICATE KEY UPDATE
INSERT INTO orders VALUES (10, 200, 130.00, 'paid')
ON DUPLICATE KEY UPDATE amount = VALUES(amount);
-- Locks acquired:-- TABLE: IX-- PRIMARY id=10: X,REC_NOT_GAP (exclusive — because it's an UPDATE)-- -- Difference from plain INSERT:-- Plain INSERT failure → S lock (shared)-- ON DUPLICATE KEY UPDATE → X lock (exclusive)-- This distinction matters for deadlock analysis!
场景 15: REPLACE INTO
REPLACE INTO orders VALUES (10, 200, 130.00, 'paid');
-- If id=10 exists:-- Equivalent to DELETE + INSERT-- PRIMARY id=10: X (next-key lock — includes gap!)-- The next-key lock (vs record lock) is because REPLACE may need-- to prevent phantom on secondary unique indexes.-- If id=10 doesn't exist:-- Same as plain INSERT (insert intention + record lock)
场景 16: SELECT ... FOR UPDATE with LIMIT
SELECT * FROM orders WHERE user_id = 100 FOR UPDATELIMIT 1;
-- Index used: idx_user-- Locks acquired:-- idx_user (user_id=100, id=1): X (next-key)-- PRIMARY id=1: X,REC_NOT_GAP-- -- Stops after first match! Fewer locks than without LIMIT.-- LIMIT can significantly reduce lock contention.
场景 17: 同一事务内多条语句的锁累积
BEGIN;
SELECT * FROM orders WHERE id = 5 FOR UPDATE; -- Lock 1: X on PK=5SELECT * FROM orders WHERE id = 15 FOR UPDATE; -- Lock 2: X on PK=15UPDATE orders SET amount = 0 WHERE user_id = 300; -- Lock 3: X on user_id=300 range-- ALL locks held simultaneously until COMMIT/ROLLBACK!-- InnoDB does not release locks within a transaction (2PL: two-phase locking)-- This is the fundamental reason why long transactions cause lock contention.
场景 18: 子查询中的加锁
-- Subquery with FOR UPDATE — be careful!SELECT * FROM orders
WHERE user_id IN (
SELECT user_id FROM vip_users WHERE level = 'gold'
)
FOR UPDATE;
-- The FOR UPDATE applies to the outer query (orders), not the subquery-- vip_users: only snapshot read (no locks)-- orders: X locks on all matching rows-- -- WARNING: If MySQL transforms this into a JOIN internally,-- locking behavior may differ from what you expect.-- Check EXPLAIN to verify the actual execution plan.
场景 19: 全表 COUNT 的加锁行为
-- Plain COUNT — no locks (snapshot read)SELECTCOUNT(*) FROM orders;
-- Uses MVCC snapshot, no row locks at all-- COUNT with FOR UPDATE — locks every row!SELECTCOUNT(*) FROM orders FOR UPDATE;
-- Scans entire PRIMARY index-- Next-Key Lock on every record + supremum-- Effectively table-wide lock. Almost never what you want!
-- Demonstrating snapshot read vs locking read difference-- TX A: TX B:BEGIN; BEGIN;
SELECT * FROM orders WHERE id=10;
-- sees: amount=120.00 (snapshot)UPDATE orders SET amount=999
WHERE id=10;
COMMIT;
SELECT * FROM orders WHERE id=10;
-- still sees: amount=120.00 (same snapshot)SELECT * FROM orders WHERE id=10 FOR UPDATE;
-- sees: amount=999.00 (current read, latest committed)
5.2 为什么 RR 使用 Gap Lock 而 RC 不使用
RR 要保证可重复读和防止幻读,因此需要 Gap Lock 阻止其他事务在已扫描的范围内插入新行。而 RC 只关心"读已提交"的数据,每次 SELECT 都获取新快照,天然允许幻读,所以不需要 Gap Lock。
-- Phantom row problem (without gap locks, i.e. under RC)-- TX A: TX B:BEGIN;
SELECT * FROM orders
WHERE user_id = 200 FOR UPDATE;
-- Returns: id=10, id=15BEGIN;
INSERT INTO orders VALUES
(12, 200, 55.00, 'new');
COMMIT;
SELECT * FROM orders
WHERE user_id = 200 FOR UPDATE;
-- Under RC: Returns id=10, id=12, id=15 — PHANTOM ROW (id=12)!-- Under RR: TX B's INSERT would be BLOCKED by gap lock → no phantom
5.3 Semi-Consistent Read(RC 下的特殊优化)
在 RC 隔离级别下,UPDATE 语句会进行"半一致性读":如果扫描到的行已经被其他事务锁定且不匹配 WHERE 条件,InnoDB 会读取该行的最新已提交版本来评估条件。如果仍不匹配,立即释放该行上的锁。这大大减少了 RC 下的锁等待。
-- Semi-consistent read example (RC isolation level)-- Table has: id=1 (status='paid'), id=5 (status='paid'), id=10 (status='pending')-- TX A:BEGIN;
SELECT * FROM orders WHERE id = 5 FOR UPDATE; -- X lock on id=5-- TX B (RC):BEGIN;
UPDATE orders SET amount = 0 WHERE status = 'pending';
-- Full scan: encounters id=5 locked by TX A-- Semi-consistent: reads latest committed version of id=5-- status='paid' ≠ 'pending' → skips, does NOT wait for TX A's lock-- Continues to id=10 (status='pending') → locks and updates-- Without semi-consistent read, TX B would block on id=5 waiting for TX A
-- Classic AB-BA deadlock TX A TX B ──────────────────── ──────────────────── BEGIN; BEGIN; UPDATE orders SET amount=0 UPDATE orders SET amount=0 WHERE id = 5; WHERE id = 10; -- holds X lock on PK=5 -- holds X lock on PK=10 UPDATE orders SET amount=0 WHERE id = 10; -- WAIT: needs X lock on PK=10 -- (held by TX B) UPDATE orders SET amount=0 WHERE id = 5; -- WAIT: needs X lock on PK=5 -- (held by TX A) ┌────────┐ ┌────────┐ │ TX A │ waits → │ TX B │ │ │ ← waits │ │ └────────┘ └────────┘ DEADLOCK! Circular wait detected. InnoDB rolls back TX B (fewer undo log records) TX A proceeds, TX B gets: ERROR 1213 (40001): Deadlock found when trying to get lock
-- Wait-for Graph visualization-- -- Each node = a transaction-- Each edge = "waits for lock held by"-- -- No deadlock (linear chain): Deadlock (cycle):-- -- TX A → TX B → TX C TX A → TX B-- (A waits B waits C) ↑ ↓-- No cycle → just waiting TX D ← TX C-- Cycle detected → deadlock!-- -- Algorithm: depth-first search (DFS) on the wait-for graph-- Complexity: O(V + E) where V = transactions, E = wait edges-- -- Victim selection: InnoDB picks the transaction with the fewest-- undo log records (least work to roll back) as the victim.
SHOW ENGINE INNODB STATUS\G
------------------------
LATEST DETECTED DEADLOCK
------------------------
2024-01-15 14:23:07 140234567890432
*** (1) TRANSACTION: ← TX A info
TRANSACTION 421937285, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 12, OS thread handle 140234567890432, query id 5678
UPDATE orders SET amount=0 WHERE id = 10 ← TX A's blocked query
*** (1) HOLDS THE LOCK(S): ← What TX A already holds
RECORD LOCKS space id 58 page no 4 n bits 72
index PRIMARY of table `test`.`orders` trx id 421937285 lock_mode X locks rec but not gap
Record lock, heap no 3 PHYSICAL RECORD: n_fields 6; ...
0: len 4; hex 80000005; asc ;; ← id=5 (X record lock)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED: ← What TX A is waiting for
RECORD LOCKS space id 58 page no 4 n bits 72
index PRIMARY of table `test`.`orders` trx id 421937285 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 6; ...
0: len 4; hex 8000000a; asc ;; ← id=10 (waiting for X)
*** (2) TRANSACTION: ← TX B info
TRANSACTION 421937286, ACTIVE 2 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 15, OS thread handle 140234567890433, query id 5679
UPDATE orders SET amount=0 WHERE id = 5 ← TX B's blocked query
*** (2) HOLDS THE LOCK(S): ← What TX B already holds
RECORD LOCKS space id 58 page no 4 n bits 72
index PRIMARY of table `test`.`orders` trx id 421937286 lock_mode X locks rec but not gap
Record lock, heap no 4 PHYSICAL RECORD: n_fields 6; ...
0: len 4; hex 8000000a; asc ;; ← id=10 (X record lock)
*** (2) WAITING FOR THIS LOCK TO BE GRANTED: ← What TX B is waiting for
RECORD LOCKS space id 58 page no 4 n bits 72
index PRIMARY of table `test`.`orders` trx id 421937286 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 6; ...
0: len 4; hex 80000005; asc ;; ← id=5 (waiting for X)
*** WE ROLL BACK TRANSACTION (2) ← TX B is the victim
lock_mode X locks rec but not gap = Record Lock(X, REC_NOT_GAP)
lock_mode X(不带 but not gap)= Next-Key Lock
lock_mode X locks gap before rec = Gap Lock
lock_mode X insert intention = Insert Intention Lock
6.4 常见死锁模式
模式 A: AB-BA 顺序颠倒
两个事务以相反顺序访问同一组资源。这是最经典的死锁模式,解决方案是确保所有事务以相同顺序访问资源。
-- TX A: UPDATE WHERE id=5, then UPDATE WHERE id=10-- TX B: UPDATE WHERE id=10, then UPDATE WHERE id=5-- Fix: both should access id=5 first, then id=10 (ascending order)
模式 B: Gap Lock + INSERT
这是最隐蔽的死锁模式。两个事务都获取了同一个间隙上的 Gap Lock(Gap Lock 之间兼容),然后都尝试 INSERT 到该间隙中,互相被对方的 Gap Lock 阻塞。
-- Records: id = 1, 5, 10, 15-- TX A:BEGIN;
SELECT * FROM t WHERE id = 7 FOR UPDATE; -- Gap Lock on (5,10)-- TX B:BEGIN;
SELECT * FROM t WHERE id = 8 FOR UPDATE; -- Gap Lock on (5,10) — COMPATIBLE!-- TX A:INSERT INTO t VALUES (7, 'x', 0); -- Insert Intention on (5,10) → BLOCKED by TX B's Gap-- TX B:INSERT INTO t VALUES (8, 'y', 0); -- Insert Intention on (5,10) → BLOCKED by TX A's Gap-- 💀 DEADLOCK
-- TX A: uses secondary index idx_userBEGIN;
UPDATE orders SET amount = 0 WHERE user_id = 200;
-- Lock order: idx_user(200,id=10) → PRIMARY(id=10) → idx_user(200,id=15) → PRIMARY(id=15)-- TX B: uses primary index directlyBEGIN;
UPDATE orders SET user_id = 999 WHERE id = 15;
-- Lock order: PRIMARY(id=15) → idx_user(200,id=15) (old entry) → idx_user(999,id=15) (new)-- If TX A has locked idx_user(200,id=10)+PRIMARY(10) and is waiting for PRIMARY(15),-- while TX B has locked PRIMARY(15) and is waiting for idx_user(200,id=15):-- 💀 DEADLOCK
模式 D: 批量 INSERT 死锁
多个事务同时批量插入有唯一约束的记录,唯一键冲突产生的 S 锁与后续 INSERT 的 X 锁形成交叉等待。
-- BAD: long transaction with external callBEGIN;
SELECT * FROM orders WHERE id = 10 FOR UPDATE;
-- ... call payment gateway (3 seconds) ...UPDATE orders SET status = 'paid'WHERE id = 10;
COMMIT;
-- GOOD: minimize lock hold time-- 1. Read data (no lock)SELECT * FROM orders WHERE id = 10;
-- 2. Call payment gateway (no transaction)-- ... call payment gateway (3 seconds) ...-- 3. Quick write (minimal lock time)BEGIN;
UPDATE orders SET status = 'paid'WHERE id = 10 AND status = 'pending';
COMMIT;
8.3 使用 RC 代替 RR
READ COMMITTED 不使用 Gap Lock,大幅减少死锁概率。如果你的业务不需要可重复读和防幻读(大部分 OLTP 场景),切换到 RC 是最简单的降锁方案。
-- Global settingSET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- Or per-sessionSET SESSION transaction_isolation = 'READ-COMMITTED';
-- Or in my.cnf
[mysqld]
transaction_isolation = READ-COMMITTED
// Go versionfuncexecuteWithRetry(fn func() error, maxRetries int) error {
for i := 0; i < maxRetries; i++ {
err := fn()
if err == nil {
return nil
}
if !isDeadlockError(err) {
return err
}
if i == maxRetries-1 {
return fmt.Errorf("deadlock after %d retries: %w", maxRetries, err)
}
backoff := time.Duration(1<<uint(i)) * 100 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(backoff / 2)))
time.Sleep(backoff + jitter)
}
return nil
}
funcisDeadlockError(err error) bool {
var mysqlErr *mysql.MySQLError
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1213
}
8.5 Advisory Lock(应用级协调锁)
当业务逻辑需要更粗粒度的互斥时,可以使用 MySQL 的 Advisory Lock(咨询锁):
-- Acquire named lock (timeout 10 seconds)SELECTGET_LOCK('order_process_123', 10); -- returns 1 if acquired-- Do complex multi-table operations safelyBEGIN;
UPDATE orders SET status = 'processing'WHERE id = 123;
INSERT INTO order_log VALUES (123, 'status_change', NOW());
COMMIT;
-- Release named lockSELECTRELEASE_LOCK('order_process_123');
-- Check if lock is freeSELECTIS_FREE_LOCK('order_process_123'); -- 1=free, 0=in use
8.6 队列 + 合并写入模式
对于高并发更新同一行的场景,将更新请求放入队列,由单个消费者合并后一次性写入,彻底消除行级锁争用:
-- Instead of N concurrent transactions doing:UPDATE product SET stock = stock - 1 WHERE id = 100; -- N times, each in own TX-- Use a queue table:INSERT INTO stock_changes (product_id, delta, created_at)
VALUES (100, -1, NOW()); -- Fast, no contention-- Background worker merges periodically:BEGIN;
SELECT product_id, SUM(delta) AS total_delta
FROM stock_changes
WHERE processed = 0
GROUP BY product_id
FOR UPDATE;
UPDATE product p
JOIN (SELECT product_id, SUM(delta) d FROM stock_changes WHERE processed=0 GROUP BY product_id) c
ON p.id = c.product_id
SET p.stock = p.stock + c.d;
UPDATE stock_changes SET processed = 1 WHERE processed = 0;
COMMIT;
9. 锁监控
9.1 performance_schema.data_locks(MySQL 8.0+)
这是 MySQL 8.0 引入的最重要的锁监控视图,替代了 5.7 的 information_schema.INNODB_LOCKS(只显示被等待的锁)。data_locks 显示所有已持有的锁。
-- View all current locksSELECT
ENGINE_TRANSACTION_ID AS trx_id,
OBJECT_SCHEMA AS db,
OBJECT_NAME AS tbl,
INDEX_NAME AS idx,
LOCK_TYPE,
LOCK_MODE,
LOCK_STATUS,
LOCK_DATA
FROM performance_schema.data_locks
ORDER BY ENGINE_TRANSACTION_ID, LOCK_TYPE DESC;
+----------+------+--------+---------+-----------+------------------+-------------+-----------+| trx_id | db | tbl | idx | LOCK_TYPE | LOCK_MODE | LOCK_STATUS | LOCK_DATA |+----------+------+--------+---------+-----------+------------------+-------------+-----------+| 28147.. | test | orders | NULL | TABLE | IX | GRANTED | NULL || 28147.. | test | orders | PRIMARY | RECORD | X,REC_NOT_GAP | GRANTED | 10 || 28147.. | test | orders | PRIMARY | RECORD | X | WAITING | 15 |+----------+------+--------+---------+-----------+------------------+-------------+-----------+
9.2 performance_schema.data_lock_waits
显示当前正在等待的锁和阻塞它的锁:
SELECT
r.ENGINE_TRANSACTION_ID AS waiting_trx,
r.LOCK_MODE AS waiting_mode,
r.LOCK_DATA AS waiting_data,
b.ENGINE_TRANSACTION_ID AS blocking_trx,
b.LOCK_MODE AS blocking_mode,
b.LOCK_DATA AS blocking_data
FROM performance_schema.data_lock_waits w
JOIN performance_schema.data_locks r
ON w.REQUESTING_ENGINE_LOCK_ID = r.ENGINE_LOCK_ID
JOIN performance_schema.data_locks b
ON w.BLOCKING_ENGINE_LOCK_ID = b.ENGINE_LOCK_ID;
9.3 sys.innodb_lock_waits
sys schema 提供了一个更友好的等待锁视图,直接显示阻塞和等待的 SQL 语句:
SELECT
waiting_trx_id,
waiting_pid,
waiting_query,
blocking_trx_id,
blocking_pid,
blocking_query,
wait_age,
locked_table,
locked_index,
locked_type
FROM sys.innodb_lock_waits;
-- Example output:+-----------------+-------------+----------------------------------+------------------+--------------+----------------------------------+----------+------------------+--------------+-------------+| waiting_trx_id | waiting_pid | waiting_query | blocking_trx_id | blocking_pid | blocking_query | wait_age | locked_table | locked_index | locked_type |+-----------------+-------------+----------------------------------+------------------+--------------+----------------------------------+----------+------------------+--------------+-------------+| 421937286 | 15 | UPDATE orders SET ... WHERE id=5 | 421937285 | 12 | NULL | 00:00:03 | `test`.`orders` | PRIMARY | RECORD |+-----------------+-------------+----------------------------------+------------------+--------------+----------------------------------+----------+------------------+--------------+-------------+-- blocking_query=NULL means that transaction is idle (already executed its statement)
9.4 SHOW ENGINE INNODB STATUS — TRANSACTIONS 段
SHOW ENGINE INNODB STATUS\G
------------
TRANSACTIONS
------------
Trx id counter 421937290
Purge done for trx's n:o < 421937288 undo n:o < 0 state: running
History list length 15
LIST OF TRANSACTIONS FOR EACH SESSION:
---TRANSACTION 421937289, ACTIVE 8 sec
2 lock struct(s), heap size 1128, 1 row lock(s), undo log entries 1
MySQL thread id 12, OS thread handle 140234567890432, query id 5688 localhost root
↑ thread info
---TRANSACTION 421937290, ACTIVE 3 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 1128, 1 row lock(s)
MySQL thread id 15, OS thread handle 140234567890433, query id 5690 localhost root updating
UPDATE orders SET amount=0 WHERE id = 10 ← currently executing query
------- TRX HAS BEEN WAITING 3 SEC FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 58 page no 4 n bits 72
index PRIMARY of table `test`.`orders`
trx id 421937290 lock_mode X locks rec but not gap waiting
Record lock, heap no 4 PHYSICAL RECORD: ...
关键指标解读:
lock struct(s): 锁结构数量(每个表/索引的锁组一个结构)
row lock(s): 行锁数量(锁住的行记录数)
undo log entries: Undo 日志条目数(修改了多少行)
LOCK WAIT: 表示该事务正在等待锁
History list length: 未被 Purge 的 Undo 版本数(过大表示有长事务)
9.5 设置死锁日志
-- Print all deadlocks to the error log (not just the latest)SET GLOBAL innodb_print_all_deadlocks = ON;
-- In my.cnf:
[mysqld]
innodb_print_all_deadlocks = 1
-- This logs every deadlock to the MySQL error log with the same-- detail as SHOW ENGINE INNODB STATUS, which only keeps the LATEST one.-- Essential for production: you need history, not just the last deadlock.
-- Comprehensive lock wait reportSELECT
r.trx_id AS waiting_trx_id,
r.trx_mysql_thread_id AS waiting_thread,
r.trx_query AS waiting_query,
TIMESTAMPDIFF(SECOND, r.trx_wait_started, NOW()) AS wait_seconds,
b.trx_id AS blocking_trx_id,
b.trx_mysql_thread_id AS blocking_thread,
b.trx_query AS blocking_query,
TIMESTAMPDIFF(SECOND, b.trx_started, NOW()) AS blocking_age_seconds,
b.trx_rows_locked AS blocking_rows_locked,
b.trx_rows_modified AS blocking_rows_modified,
CONCAT(dl.OBJECT_SCHEMA, '.', dl.OBJECT_NAME) AS locked_table,
dl.INDEX_NAME AS locked_index,
dl.LOCK_MODE AS waiting_lock_mode,
dl.LOCK_DATA AS lock_data
FROM information_schema.INNODB_TRX r
JOIN performance_schema.data_lock_waits w
ON r.trx_id = w.REQUESTING_ENGINE_TRANSACTION_ID
JOIN information_schema.INNODB_TRX b
ON b.trx_id = w.BLOCKING_ENGINE_TRANSACTION_ID
JOIN performance_schema.data_locks dl
ON dl.ENGINE_LOCK_ID = w.REQUESTING_ENGINE_LOCK_ID
WHERE r.trx_state = 'LOCK WAIT'ORDER BY wait_seconds DESC;
9.7 关键状态变量
-- Lock-related status variablesSHOW GLOBAL STATUSLIKE'Innodb_row_lock%';
+-------------------------------+-------+| Variable_name | Value |+-------------------------------+-------+| Innodb_row_lock_current_waits | 0 | ← currently waiting transactions| Innodb_row_lock_time | 15234 | ← total wait time (ms) since startup| Innodb_row_lock_time_avg | 127 | ← average wait time (ms)| Innodb_row_lock_time_max | 5023 | ← max single wait time (ms)| Innodb_row_lock_waits | 120 | ← total wait count since startup+-------------------------------+-------+-- Deadlock countSHOW GLOBAL STATUSLIKE'Innodb_deadlocks';
-- Monitor the rate of change, not absolute value-- Long-running transactions (potential lock holders)SELECT
trx_id, trx_state, trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
trx_rows_locked, trx_rows_modified,
trx_query, trx_mysql_thread_id
FROM information_schema.INNODB_TRX
WHERETIMESTAMPDIFF(SECOND, trx_started, NOW()) > 30
ORDER BY trx_started;
9.8 Grafana 监控面板建议
建议在 Grafana 中设置以下锁相关面板:
面板名称
指标源
告警阈值
行锁等待率
rate(Innodb_row_lock_waits)
> 50/s
平均锁等待时间
Innodb_row_lock_time_avg
> 500ms
死锁率
rate(Innodb_deadlocks)
> 1/min
当前锁等待数
Innodb_row_lock_current_waits
> 10
长事务数
COUNT(INNODB_TRX WHERE age > 60s)
> 0
History List 长度
Innodb_history_list_length
> 10000
10. 元数据锁(MDL)
10.1 什么是 MDL
Metadata Lock(MDL,元数据锁)是 MySQL 在 5.5.3 引入的锁机制,用于保护表结构不在查询执行期间被修改。当你执行任何 SQL 语句时,MySQL 会自动获取 MDL:
SELECT / DML → MDL Shared Read / Shared Write
DDL (ALTER TABLE, DROP TABLE 等) → MDL Exclusive
10.2 MDL 阻塞 ALTER TABLE 的典型场景
-- Timeline showing how MDL can cascade into a total block Time Session 1 Session 2 Session 3 ───── ────────────────── ────────────────── ────────────────── T1 BEGIN; SELECT * FROM t; -- holds MDL_SHARED_READ T2 ALTER TABLE t ADD COLUMN c INT; -- needs MDL_EXCLUSIVE -- BLOCKED by Session 1's MDL_SHARED_READ T3 SELECT * FROM t; -- needs MDL_SHARED_READ -- BLOCKED! Why? -- Session 2's pending -- MDL_EXCLUSIVE is ahead -- in the wait queue Result: Session 1's long transaction causes Session 2 (DDL) to wait, which in turn blocks ALL subsequent queries (Session 3, 4, 5, ...)! This can take down an entire service.
生产事故高频原因:
一个未提交的事务 + 一个 ALTER TABLE = 所有查询阻塞。这是 MySQL 线上故障最常见的原因之一。执行 DDL 前务必检查是否有长事务:SELECT * FROM information_schema.INNODB_TRX WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 10;
10.3 监控 MDL
-- Enable MDL instrumentation (MySQL 8.0+)UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'WHERE NAME = 'wait/lock/metadata/sql/mdl';
-- View current MDL locksSELECT
OBJECT_TYPE, OBJECT_SCHEMA, OBJECT_NAME,
LOCK_TYPE, LOCK_DURATION, LOCK_STATUS,
OWNER_THREAD_ID, OWNER_EVENT_ID
FROM performance_schema.metadata_locks
WHERE OBJECT_TYPE = 'TABLE';
-- Find the blocking threadSELECT
ml.OBJECT_SCHEMA, ml.OBJECT_NAME, ml.LOCK_TYPE, ml.LOCK_STATUS,
t.PROCESSLIST_ID, t.PROCESSLIST_INFO AS query
FROM performance_schema.metadata_locks ml
JOIN performance_schema.threads t
ON ml.OWNER_THREAD_ID = t.THREAD_ID
WHERE ml.OBJECT_SCHEMA = 'your_db'AND ml.OBJECT_NAME = 'your_table';
-- Online DDL lifecycle:-- -- Phase 1: Preparation (brief MDL Exclusive)-- - Acquire MDL_EXCLUSIVE-- - Determine DDL algorithm (INPLACE, INSTANT, COPY)-- - Downgrade to MDL_SHARED_UPGRADABLE-- - Very short, usually milliseconds-- -- Phase 2: Execution (MDL_SHARED_UPGRADABLE — allows DML)-- - Rebuild table / add index in background-- - DML continues, changes logged to row log-- - Can be minutes/hours for large tables-- -- Phase 3: Commit (brief MDL Exclusive again)-- - Upgrade to MDL_EXCLUSIVE-- - Apply row log (accumulated DML changes)-- - Swap old/new table-- - Release MDL_EXCLUSIVE-- - This is where the blocking happens if there are long transactions!-- Recommended: set a lock wait timeout for DDLSET lock_wait_timeout = 5; -- give up after 5 secondsALTER TABLE orders ADD COLUMN note TEXT, ALGORITHM=INPLACE, LOCK=NONE;
-- If MDL Exclusive can't be acquired in 5s, the ALTER fails gracefully-- instead of blocking all traffic
-- Original: single hot rowCREATE TABLE product_stock (
product_id INT PRIMARY KEY,
stock INT
);
-- All threads fight for the same row → bottleneck-- Sharded: 16 sub-rows per productCREATE TABLE product_stock_shard (
product_id INT,
shard_id TINYINT, -- 0..15
stock INT,
PRIMARY KEY (product_id, shard_id)
);
-- Initialize: distribute stock across shards-- product_id=100, total stock=1000, 16 shards → 62 or 63 each-- Deduct stock: randomly pick a shardUPDATE product_stock_shard
SET stock = stock - 1
WHERE product_id = 100
AND shard_id = FLOOR(RAND() * 16)
AND stock > 0;
-- Read total stock:SELECTSUM(stock) FROM product_stock_shard WHERE product_id = 100;
-- Throughput improvement: ~16x (linear with shard count)-- Tradeoff: reads become slightly more expensive (SUM over N shards)
方案 2: 队列 + 批量合并
将变更请求写入队列表(高吞吐、无争用),后台 Worker 定期合并到主表。详见 8.6 节。
方案 3: 乐观锁(Version Column)
不使用 FOR UPDATE 显式加锁,而是用版本号实现 CAS(Compare-And-Swap)语义:
CREATE TABLE product (
id INT PRIMARY KEY,
stock INT,
version INTDEFAULT 0
);
-- Step 1: Read (no lock, snapshot read)SELECT id, stock, version FROM product WHERE id = 100;
-- Returns: stock=50, version=7-- Step 2: Update with version check (CAS)UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 7;
-- If affected_rows = 1 → success-- If affected_rows = 0 → another transaction already modified it → retry-- Advantage: no lock wait, losers retry immediately-- Disadvantage: high retry rate under extreme contention (bad for 100+ concurrent)
方案 4: SELECT ... SKIP LOCKED(MySQL 8.0+)
跳过已被锁定的行,适用于任务队列、工单分配等场景:
-- Job queue tableCREATE TABLE jobs (
id INT PRIMARY KEY,
status ENUM('pending','running','done'),
payload JSON
);
-- Worker picks next available job, skipping locked onesBEGIN;
SELECT * FROM jobs
WHERE status = 'pending'ORDER BY id
LIMIT 1
FOR UPDATESKIP LOCKED;
-- If another worker already locked the first pending job, skip to the next one-- No blocking, no contention!UPDATE jobs SET status = 'running'WHERE id = ?;
COMMIT;
方案 5: NOWAIT(MySQL 8.0+)
如果无法立即获取锁,立即报错而不是等待。适用于"获取不到就快速失败"的场景:
BEGIN;
SELECT * FROM orders WHERE id = 10 FOR UPDATENOWAIT;
-- If id=10 is already locked:-- ERROR 3572 (HY000): Statement aborted because lock(s) could not be acquired-- immediately and NOWAIT is set.-- Application catches error and handles gracefully (show "item busy" to user)
-- Problem 1: shard exhaustion-- If one shard reaches 0 while others still have stock,-- the random selection may fail repeatedly.-- Fix: retry with different shard, or use a smarter selection:UPDATE product_stock_shard
SET stock = stock - 1
WHERE product_id = 100
AND stock > 0
ORDER BYRAND()
LIMIT 1;
-- Problem 2: rebalancing-- When restocking, distribute evenly across shards:UPDATE product_stock_shard
SET stock = stock + FLOOR(1000 / 16)
WHERE product_id = 100;
-- Remainder goes to shard 0:UPDATE product_stock_shard
SET stock = stock + (1000 MOD 16)
WHERE product_id = 100 AND shard_id = 0;
-- Problem 3: negative stock race-- The AND stock > 0 check + UPDATE is atomic within a single row-- but total stock across all shards might briefly show wrong totals-- during concurrent operations. This is acceptable for most use cases.
-- Problem code (simplified)BEGIN;
-- Step 1: lock the SKU rowSELECT stock FROM sku WHERE sku_id = 1001 FOR UPDATE;
-- Step 2: check stock-- if stock < quantity: rollback-- Step 3: deductUPDATE sku SET stock = stock - 1 WHERE sku_id = 1001;
-- Step 4: create order (on different table)INSERT INTO orders (sku_id, user_id, qty) VALUES (1001, ?, 1);
COMMIT;
-- Deadlock scenario:-- An order might contain multiple SKUs. If TX A locks sku_id=1001 then 1002,-- while TX B locks sku_id=1002 then 1001 → classic AB-BA deadlock
解决方案
-- Fix 1: Sort SKU IDs before locking (consistent ordering)
sku_ids = sorted(order_items.keys()) # [1001, 1002]for sku_id in sku_ids:
SELECT stock FROM sku WHERE sku_id = ? FOR UPDATE-- Fix 2: Single UPDATE with stock check (atomic, no FOR UPDATE needed)UPDATE sku SET stock = stock - 1
WHERE sku_id = 1001 AND stock >= 1;
-- Check affected_rows: 1=success, 0=insufficient stock-- No separate SELECT FOR UPDATE → simpler, less lock contention-- Fix 3: For extreme concurrency, use sharded counters (see Section 11)
案例 2: 转账(复式记账)死锁
问题描述
用户 A 给用户 B 转账,同时用户 B 也给用户 A 转账。两个事务以相反的顺序锁定两个账户。
-- Transfer: A → B (TX 1)BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A'; -- lock AUPDATE accounts SET balance = balance + 100 WHERE user_id = 'B'; -- lock B → waitCOMMIT;
-- Transfer: B → A (TX 2)BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE user_id = 'B'; -- lock BUPDATE accounts SET balance = balance + 50 WHERE user_id = 'A'; -- lock A → DEADLOCKCOMMIT;
解决方案
-- Fix: Always lock accounts in ascending user_id orderdeftransfer(from_user, to_user, amount):
# Ensure consistent lock ordering
first, second = sorted([from_user, to_user])
BEGIN;
SELECT * FROM accounts WHERE user_id = first FOR UPDATE;
SELECT * FROM accounts WHERE user_id = second FOR UPDATE;
UPDATE accounts SET balance = balance - amount WHERE user_id = from_user;
UPDATE accounts SET balance = balance + amount WHERE user_id = to_user;
COMMIT;
-- Now both TX 1 and TX 2 will lock 'A' first, then 'B' (if A < B)-- No deadlock possible
案例 3: 批量导入引发的 Gap Lock 风暴
问题描述
批量导入用户数据到有唯一索引(email)的表中。多个线程并发 INSERT ... ON DUPLICATE KEY UPDATE,频繁死锁。
-- Multiple threads doing:INSERT INTO users (email, name) VALUES ('[email protected]', 'Alice')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- Problem: INSERT on unique index acquires next-key lock, not just record lock-- When the key doesn't exist yet, InnoDB acquires gap locks on idx_email-- Multiple threads with gap locks on adjacent gaps → INSERT intention deadlock
解决方案
-- Fix 1: Switch to READ COMMITTED (no gap locks)SET SESSION transaction_isolation = 'READ-COMMITTED';
-- Fix 2: Batch INSERTs in a single statement (fewer transactions)INSERT INTO users (email, name) VALUES
('[email protected]', 'Alice'),
('[email protected]', 'Bob'),
...
('[email protected]', 'Zach')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- Fix 3: Sort import data by the unique key to minimize gap lock range-- Process emails alphabetically: a@..., b@..., c@... → sequential gaps-- Fix 4: Use LOAD DATA INFILE for very large imports (single transaction)
原则:(1) 如果你读取数据后要修改它,用 FOR UPDATE。(2) 如果你只需要确保数据在事务期间不被修改,用 FOR SHARE。(3) 避免对不需要锁的查询使用锁定读——大部分只读查询用普通 SELECT(快照读)即可。(4) FOR SHARE 容易导致死锁:两个事务同时 FOR SHARE 同一行(兼容),然后都 UPDATE 该行(需要 X 锁)→ 死锁。如果读后要写,直接用 FOR UPDATE。
Q7: LOCK TABLES 和 InnoDB 行锁是什么关系?
LOCK TABLES 是 MySQL Server 层的表级锁,与 InnoDB 引擎层的行锁是独立的两套机制。在 InnoDB 中,不推荐使用 LOCK TABLES,因为它会禁用 InnoDB 的行级锁优化。正确的做法是使用 SELECT ... FOR UPDATE 和事务来实现并发控制。唯一需要 LOCK TABLES 的场景是与 MyISAM 表交互(MyISAM 不支持行锁和事务)。
Q8: 如何判断我的应用是否受到锁争用影响?
监控以下指标:(1) SHOW GLOBAL STATUS LIKE 'Innodb_row_lock%': Innodb_row_lock_waits(累计等待次数)和 Innodb_row_lock_time_avg(平均等待时间 ms)。(2) performance_schema.data_lock_waits 中的行数(当前等待数)。(3) information_schema.INNODB_TRX 中 trx_state='LOCK WAIT' 的事务。(4) 慢查询日志中 Lock_time 较高的查询。如果 Innodb_row_lock_time_avg > 500ms 或 Innodb_row_lock_waits 增速超过 100/s,就需要关注了。
Q9: Next-Key Lock 到底锁的是哪个范围?
Next-Key Lock 锁定的是一个左开右闭区间 (prev_record, current_record]。例如索引中有记录 5, 10, 15,对 10 加 Next-Key Lock 锁定的范围是 (5, 10],即阻止 INSERT id=6,7,8,9,10 到间隙中,并锁定记录 10 本身。需要注意 InnoDB 有一些优化规则会自动将 Next-Key Lock 退化为 Record Lock 或 Gap Lock:(1) 唯一索引等值命中 → 退化为 Record Lock;(2) 非唯一索引等值扫描到第一条不满足的记录 → 该记录上退化为 Gap Lock。
Q10: 如何找出持有锁但不释放的"幽灵事务"?
常见原因:应用代码中 BEGIN 后没有 COMMIT/ROLLBACK(连接泄露),或在事务中等待外部资源超时。排查方法:
SELECT trx_id, trx_started, trx_state, trx_query, trx_mysql_thread_id FROM information_schema.INNODB_TRX ORDER BY trx_started; — 找到 trx_started 很久以前且 trx_query=NULL 的事务
SELECT * FROM performance_schema.threads WHERE THREAD_ID = (SELECT THREAD_ID FROM performance_schema.events_statements_current WHERE SQL_TEXT IS NOT NULL LIMIT 1); — 追踪线程来源