MySQL 死锁问题的排查与解决:别只会重启服务17认证网

正规官方授权
更专业・更权威

MySQL 死锁问题的排查与解决:别只会重启服务

问题背景

MySQL 死锁是生产环境中让人头疼的问题之一。很多运维工程师看到死锁报错,第一反应是重启数据库——重启确实能解决当下的阻塞,但根因还在,下次还会发生,而且可能在更糟糕的时间点爆发。

死锁的本质是并发事务对资源加锁顺序不一致,导致循环等待。在 MySQL 的 InnoDB 引擎里,死锁并不一定是配置问题或 SQL 写得不好——高并发场景下,即使业务逻辑合理,也可能出现死锁。但死锁频繁发生,一定说明业务逻辑或索引设计存在优化空间。

本文面向 MySQL 5.7 和 MySQL 8.0,以一线运维工程师的角度,系统讲解死锁的形成机制、排查方法、解决思路和预防措施。涉及命令、SQL、配置均基于生产环境常用版本,MySQL 5.7 和 8.0 的差异会单独说明。

适用场景

  • 线上频繁出现死锁报错,业务受阻
  • 监控发现 InnoDB Lock wait timeout exceeded 错误增多
  • 需要分析死锁日志定位根因
  • 优化业务 SQL 减少死锁发生概率

死锁形成机制

什么是死锁

死锁是两个或多个事务在执行过程中,因互相持有对方需要的锁而形成循环等待,导致都无法继续执行下去。

MySQL InnoDB 的死锁通常发生在以下场景:

  1. 行锁冲突:事务 A 锁住了行 X,事务 B 锁住了行 Y,然后 A 要锁 Y、B 要锁 X,形成循环
  2. 间隙锁冲突:范围查询导致间隙锁,多个事务对相交的索引范围加锁
  3. 唯一索引冲突:多个事务同时插入相同的唯一键
  4. 锁升级:行锁升级为页锁或表锁,扩大了冲突范围

InnoDB 锁的类型

理解死锁要先理解 InnoDB 的锁机制:

-- 查看当前 InnoDB 锁信息(MySQL 5.7+)
SELECT * FROM information_schema.INNODB_LOCKS;

-- 查看锁等待关系
SELECT * FROM information_schema.INNODB_LOCK_WAITS;

-- 查看当前事务及持有的锁
SELECT * FROM information_schema.INNODB_TRX;

InnoDB 的锁分为:

  • 共享锁(S锁):允许事务读取一行
  • 排他锁(X锁):允许事务更新或删除一行
  • 记录锁(Record Lock:锁住索引记录
  • 间隙锁(Gap Lock:锁住索引记录之间的间隙,防止幻读
  • Next-Key Lock:记录锁 + 间隙锁的组合
  • 意向锁(Intention Lock):表级锁,表示事务要在某行加锁
-- 查看事务隔离级别
SELECT @@transaction_isolation;
-- REPEATABLE READ 是最容易产生间隙锁的隔离级别

-- 修改隔离级别(仅当前会话)
SETSESSION transaction_isolation = 'READ-COMMITTED';

死锁与锁等待的区别

死锁和普通的锁等待不是一回事:

  • 锁等待(Lock wait timeout):事务 A 等事务 B 释放锁,超过 innodb_lock_wait_timeout(默认 50 秒)后报错。这是单向等待,不是死锁。
  • 死锁(Deadlock):事务 A 等事务 B,B 又等事务 A,MySQL 的死锁检测机制主动回滚其中一个事务,打破循环等待。

MySQL 8.0 中,死锁检测默认开启,innodb_deadlock_detect 默认 ON。死锁发生时,MySQL 会自动回滚代价最小的事务(通常是持有最少行级锁的事务),让其他事务继续执行。


排查工具:如何拿到死锁信息

第一步:开启死锁日志

MySQL 的死锁信息会记录在 error log 中。确认以下配置:

# my.cnf / my.ini
[mysqld]
# 开启死锁详细信息记录到 error log
innodb_print_all_deadlocks = ON

# 锁等待超时时间(秒),超时后报错并回滚
innodb_lock_wait_timeout = 50

# 死锁检测开关(MySQL 8.0+ 默认 ON,MySQL 5.7 也默认 ON)
innodb_deadlock_detect = ON
# 修改后重载配置,不重启数据库的方式:
# MySQL 8.0+ 支持动态修改部分参数
SET GLOBAL innodb_print_all_deadlocks = ON;
SET GLOBAL innodb_lock_wait_timeout = 50;

# 确认配置生效
SHOW VARIABLES LIKE 'innodb_print_all_deadlocks';
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
SHOW VARIABLES LIKE 'innodb_deadlock_detect';

第二步:查看死锁日志

# 查看 error log 中的死锁信息
grep -A 50 "TRANSACTION" /var/log/mysql/error.log | grep -A 30 "DEADLOCK"

# 如果不知道 error log 位置
SHOW VARIABLES LIKE 'log_error';

# 查看最近的死锁记录(MySQL 5.7+)
-- 需要先执行:SET GLOBAL innodb_print_all_deadlocks = ON;
-- 然后触发死锁,查看 error log

第三步:分析 performance_schema 中的锁信息

MySQL 5.7 开始,可以通过 performance_schema 获取更详细的锁信息:

-- 开启锁监控(需要提前在 my.cnf 中配置)
-- performance_schema 需要在启动时开启

-- 查看当前所有锁信息
SELECT
    l.lock_id,
    l.lock_mode,
    l.lock_type,
    l.lock_table,
    l.lock_index,
    l.lock_space,
    l.lock_page,
    l.lock_rec,
    l.lock_data,
    t.trx_id,
    t.trx_state,
    t.trx_started,
    t.trx_rows_locked,
    t.trx_rows_modified,
    t.trx_is_read_only,
    p.ps_table_name
FROM information_schema.INNODB_LOCKS l
JOIN information_schema.INNODB_TRX t ON l.lock_trx_id = t.trx_id
LEFTJOIN performance_schema.metadata_locks p ON p.lock_object = l.lock_table;
-- 查看锁等待图(谁在等谁的锁)
SELECT
    r.trx_id AS waiting_trx_id,
    r.trx_mysql_thread_id AS waiting_thread,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx_id,
    b.trx_mysql_thread_id AS blocking_thread,
    b.trx_query AS blocking_query,
    b.trx_started AS blocking_trx_started,
    b.trx_rows_locked AS blocking_trx_rows_locked
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id = b.trx_id
JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id = r.trx_id;

MySQL 8.0 中,information_schema.INNODB_LOCKS 和 INNODB_LOCK_WAITS 被移除了,锁信息需要从 performance_schema.data_locks 和 performance_schema.data_lock_waits 中查询:

-- MySQL 8.0 锁信息查询
SELECT * FROM performance_schema.data_locks;

-- MySQL 8.0 锁等待关系
SELECT * FROM performance_schema.data_lock_waits;

-- MySQL 8.0 综合查看
SELECT
    dl.lock_id,
    dl.lock_mode,
    dl.lock_type,
    dl.lock_status,
    dl.object_name,
    dl.index_name,
    dl.lock_data,
    dt.trx_id,
    dt.trx_state,
    dt.trx_started,
    dt.trx_query
FROM performance_schema.data_locks dl
JOIN performance_schema.data_locks dt ON dl.lock_trx_id = dt.trx_id
WHERE dl.lock_type = 'RECORD'AND dt.trx_state = 'RUNNING';

实战案例一:行锁冲突导致的死锁

现象

某订单表 orders,高并发下单时出现死锁:

ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

业务日志中看到部分订单创建失败,频率约为千分之一。

排查过程

  1. 查看 error log 中的死锁记录
tail -n 200 /var/log/mysql/error.log | grep -A 50 "DEADLOCK"

死锁日志格式如下:

*** (1) TRANSACTION:
TRANSACTION 98765, ACTIVE 2 sec inserting
mysql thread id 12345, OS thread handle 0x7f8a2c3d4e0, query id 67890 localhost root update
INSERT INTO orders (user_id, product_id, amount, status) VALUES (1001, 2001, 99.00, 'pending')

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 123 page no 4 n bits 200 index PRIMARY of table `shop`.`orders` trx id 98765 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000001; asc   ;; (primary key)
 1: len 6; hex 00000001871a; asc      ;; (transaction id)
 2: len 6; hex 000000001b5a; asc      ;; (roll ptr)
 3: len 4; hex 80000001; asc   ;; (user_id)
 4: len 4; hex 800003e8; asc    ;; (amount)

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 123 page no 4 n bits 200 index PRIMARY of table `shop`.`orders` trx id 98766 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 5; compact format; info bits 0
 0: len 4; hex 80000001; asc   ;; (primary key)

*** (2) TRANSACTION:
TRANSACTION 98766, ACTIVE 2 sec inserting
mysql thread id 12346, OS thread handle 0x7f8a2c3d5f1, query id 67891 localhost root update
INSERT INTO orders (user_id, product_id, amount, status) VALUES (1001, 2002, 149.00, 'pending')
  1. 分析死锁日志

从日志可以看出:

  • 事务 98765 和 98766 都在插入 orders 表
  • 两者都持有某条记录的排他锁(X locks rec but not gap),并且在等待对方的锁grant
  • 这说明两条 INSERT 语句插入的主键值相同或产生了相同的自增 ID 冲突
  1. 查看当前订单表状态
-- 查看订单表结构
SHOWCREATETABLE orders\G

-- 查看最近新增的订单
SELECT * FROM orders ORDERBYidDESCLIMIT10;

-- 查看是否有订单缺失(连续 ID 不连续说明有回滚)
SELECTid, user_id, created_at FROM orders ORDERBYidDESCLIMIT20;
  1. 查看当时并发插入的记录
-- 查看特定时间范围内创建的订单(根据 error log 中的时间推算)
SELECTid, user_id, product_id, status, created_at
FROM orders
WHERE created_at BETWEEN'2024-01-01 10:00:00'AND'2024-01-01 10:05:00'
ORDERBYidDESC;

根因定位

通过日志分析,发现根因是:两个并发事务同时对同一用户插入订单时,由于表上有 user_id 索引,InnoDB 在 REPEATABLE READ 隔离级别下对相同 user_id 的记录加了 Next-Key Lock,导致相互等待

但更直接的根因是:应用层的重试逻辑导致相同的 INSERT 被执行了两次,而两次执行在极短时间内同时到达。

解决修复

方案一:应用层加分布式锁(推荐)

import redis
import uuid

defcreate_order(user_id, product_id, amount):
    # 使用 Redis 分布式锁,防止同一用户的并发下单
    lock_key = f"order:lock:{user_id}"
    lock_value = str(uuid.uuid4())
    lock_timeout = 5

    ifnot redis.set(lock_key, lock_value, nx=True, ex=lock_timeout):
        raise Exception("下单过于频繁,请稍后重试")

    try:
        with db_connection.cursor() as cursor:
            sql = "INSERT INTO orders (user_id, product_id, amount, status) VALUES (%s, %s, %s, 'pending')"
            cursor.execute(sql, (user_id, product_id, amount))
            db_connection.commit()
            return cursor.lastrowid
    finally:
        # 释放锁(只释放自己持有的锁)
        if redis.get(lock_key) == lock_value:
            redis.delete(lock_key)

方案二:数据库层加唯一约束(兜底方案)

-- 给 orders 表加业务唯一键约束(假设 user_id + product_id 组合唯一)
ALTERTABLE orders ADDCONSTRAINT uk_user_product
    UNIQUE (user_id, product_id);

-- 插入时使用 INSERT ... ON DUPLICATE KEY UPDATE
INSERTINTO orders (user_id, product_id, amount, status)
VALUES (1001, 2001, 99.00, 'pending')
ONDUPLICATEKEYUPDATE amount = VALUES(amount), status = 'pending';

方案三:降低隔离级别

如果业务允许,可以将隔离级别从 REPEATABLE READ 降到 READ COMMITTED,减少 Next-Key Lock 的范围:

-- 当前会话修改
SETSESSION transaction_isolation = 'READ-COMMITTED';

-- 全局修改(需要权限)
SETGLOBAL transaction_isolation = 'READ-COMMITTED';
# 写入 my.cnf 永久生效
[mysqld]
transaction-isolation = READ-COMMITTED

方案四:调整 SQL 写入顺序

如果是多个事务并发更新不同行的同一批数据产生死锁,可以让所有事务按相同顺序操作资源:

-- 原来:事务 A 先锁用户再锁订单,事务 B 先锁订单再锁用户 -> 死锁
-- 改为:所有事务都按 user_id 顺序加锁

-- 错误写法(不同事务顺序不同)
-- 事务 A: UPDATE orders SET status='paid' WHERE id=1; UPDATE users SET balance=balance-100 WHERE id=100;
-- 事务 B: UPDATE users SET balance=balance-100 WHERE id=100; UPDATE orders SET status='paid' WHERE id=1;

-- 正确写法(所有事务统一先锁用户再锁订单)
-- 事务 A: UPDATE users SET balance=balance-100 WHERE id=100; UPDATE orders SET status='paid' WHERE id=1;
-- 事务 B: UPDATE users SET balance=balance-100 WHERE id=100; UPDATE orders SET status='paid' WHERE id=1;

验证方法

-- 1. 检查死锁是否解决:连续观察 error log 中是否还有新死锁
tail -f /var/log/mysql/error.log | grep DEADLOCK

-- 2. 检查是否有 INSERT 重复数据(唯一约束生效)
SELECT user_id, product_id, COUNT(*) as cnt
FROM orders
GROUPBY user_id, product_id
HAVING cnt > 1;

-- 3. 检查应用层重试次数是否降低
-- 查看应用日志中 "Deadlock found" 错误的出现频率

-- 4. 压力测试验证
-- 用 mysqlslap 或 sysbench 模拟并发插入
mysqlslap --concurrency=20 --iterations=100 \
    --query="INSERT INTO orders (user_id, product_id, amount, status) VALUES (FLOOR(1+RAND()*1000), FLOOR(1+RAND()*1000), 99.00, 'pending')" \
    --create-schema=shop

风险提醒

  • 加唯一约束前,确认业务逻辑上 user_id + product_id 确实应该唯一,否则会导致正常业务报 duplicate key 错误
  • 降低隔离级别到 READ COMMITTED 会降低隔离性,可能出现不可重复读,需要评估业务是否接受
  • 应用层加分布式锁引入了对 Redis 的依赖,如果 Redis 不可用可能导致下单失败,需要考虑降级策略
  • DELETE + INSERT 混合场景的死锁比纯 INSERT 更复杂,可能涉及记录锁和间隙锁的组合

实战案例二:间隙锁导致的死锁

现象

一张商品库存表 products,多个事务同时执行以下语句扣减库存:

UPDATE products SET stock = stock - 1WHEREid = 1001AND stock > 0;

在商品库存只有 1 个、同时有 3 个请求并发扣减时,出现了死锁。只有一个请求成功,另外两个报 deadlock error。

排查过程

-- 查看 products 表结构和索引
SHOWCREATETABLE products\G

-- 查看当时 products 表的读操作
SELECT * FROM performance_schema.events_statements_history
WHERE SQL_TEXT LIKE'%products%'
ORDERBY END_EVENT_ID DESCLIMIT10;

死锁日志:

*** (1) TRANSACTION:
TRANSACTION 123456, ACTIVE 0 sec updating
mysql thread id 111, OS thread handle 0x7f8a1b2c3d4e, query id 999 localhost root
UPDATE products SET stock = stock - 1 WHERE id = 1001 AND stock > 0
RECORD LOCKS space id 99 page no 3 n bits 100 index PRIMARY of table `shop`.`products` trx id 123456 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICALL RECORD: n_fields 5; info bits 0
 0: len 4; hex 800003e9; asc   ;;

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 99 page no 3 n bits 100 index PRIMARY of table `shop`.`products` trx id 123456 lock_mode X locks rec but not gap

*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 99 page no 3 n bits 100 index PRIMARY of table `shop`.`products` trx id 123457 lock_mode X locks rec but not gap waiting

*** (2) TRANSACTION:
TRANSACTION 123457, ACTIVE 0 sec updating
mysql thread id 112, OS thread handle 0x7f8a1b2c3d5f, query id 1000 localhost root
UPDATE products SET stock = stock - 1 WHERE id = 1001 AND stock > 0
RECORD LOCKS space id 99 page no 3 n bits 100 index PRIMARY of table `shop`.`products` trx id 123457 lock_mode X locks rec but not gap

根因定位

这个场景的死锁原因是:三个事务同时执行相同的 UPDATE 语句,在 stock=1 时,三个事务都读到了 stock>0 的记录。由于 InnoDB 在当前读(current read)时会对读取的行加锁,三个事务实际上在竞争同一行记录的 X 锁。

更具体的根因在于 WHERE stock > 0 这个条件——在 REPEATABLE READ 隔离级别下,范围查询不仅锁住存在的记录,还可能锁住不存在的”间隙”。如果库存为 1 时有两个并发请求,它们可能分别锁住了不同的间隙区间。

解决修复

方案一:使用乐观锁代替悲观锁

-- 悲观锁:直接 UPDATE,行锁,可能死锁
UPDATE products SET stock = stock - 1WHEREid = 1001AND stock > 0;

-- 乐观锁:通过版本号或 stock 值作为条件UPDATE,不锁定行
UPDATE products
SET stock = stock - 1, version = version + 1
WHEREid = 1001AND stock > 0ANDversion = 1;
defdeduct_stock(product_id, quantity=1):
    """使用乐观锁扣减库存"""
    with db_connection.cursor() as cursor:
        # 先检查库存
        cursor.execute(
            "SELECT stock FROM products WHERE id = %s FOR UPDATE",
            (product_id,)
        )
        result = cursor.fetchone()
        ifnot result or result['stock'] < quantity:
            raise Exception("库存不足")

        # 乐观锁更新:检查 stock 是否变化
        affected = cursor.execute(
            "UPDATE products SET stock = stock - %s WHERE id = %s AND stock >= %s",
            (quantity, product_id, quantity)
        )

        if affected == 0:
            raise Exception("库存已变化,请重试")

        db_connection.commit()

方案二:使用 SELECT … FOR UPDATE 预锁定(避免重试)

STARTTRANSACTION;

-- 先锁定行,再检查和更新
SELECT stock INTO @current_stock
FROM products
WHEREid = 1001
FORUPDATE;

IF @current_stock >= 1 THEN
    UPDATE products SET stock = stock - 1WHEREid = 1001;
    COMMIT;
ELSE
    ROLLBACK;
    -- 抛出库存不足异常
ENDIF;

方案三:使用 INSERT … ON DUPLICATE KEY 代替 UPDATE

如果业务允许,可以将扣减库存改为”预占库存”的思路:

-- 创建库存预占表
CREATETABLE stock_reserve (
    idBIGINT AUTO_INCREMENT PRIMARY KEY,
    product_id INTNOTNULL,
    quantity INTNOTNULLDEFAULT1,
    status ENUM('pending', 'confirmed', 'cancelled') DEFAULT'pending',
    created_at TIMESTAMPDEFAULTCURRENT_TIMESTAMP,
    INDEX idx_product_status (product_id, status),
    UNIQUEKEY uk_reserve (product_id, id)
);

-- 预占库存(插入一条记录)
INSERTINTO stock_reserve (product_id, quantity)
VALUES (1001, 1);

-- 确认库存(更新预占记录状态)
UPDATE stock_reserve, products
SET stock_reserve.status = 'confirmed',
    products.stock = products.stock - stock_reserve.quantity
WHERE stock_reserve.id = ??
AND stock_reserve.product_id = products.id
AND stock_reserve.status = 'pending';

验证方法

-- 1. 检查死锁频率是否降低
SELECT
    TIME,
    COUNT(*) as deadlock_count
FROM mysql.general_log
WHERE command_type = 'Query'
AND argument LIKE'%Deadlock%'
GROUPBYTIME
ORDERBYTIMEDESCLIMIT20;

-- 2. 检查库存扣减是否准确
SELECTid, stock, versionFROM products WHEREid = 1001;

-- 3. 对比修改前后的库存流水
SELECTCOUNT(*) as total_reserve, SUM(quantity) as total_quantity
FROM stock_reserve
WHERE product_id = 1001ANDstatus = 'confirmed';

-- 4. 压测验证
-- 使用 sysbench 模拟并发扣库存,观察死锁频率

风险提醒

  • 乐观锁的 retry 逻辑需要应用层配合,如果 retry 次数过多会造成用户等待,建议设置最大重试次数
  • SELECT … FOR UPDATE 在高并发下会显著降低吞吐量,因为它会锁定行直到事务结束
  • 预占库存方案增加了数据量和复杂度,需要额外的定时任务清理 cancelled 状态的预占记录

实战案例三:唯一索引冲突导致的死锁

现象

用户注册场景,多个线程同时为新用户创建账户,执行相同的 INSERT 语句:

INSERTINTOusers (username, email, password_hash, created_at)
VALUES ('john_doe', 'john@example.com', 'hashed_pw', NOW());

数据库表有 username 和 email 两个唯一索引。高并发注册时,出现死锁。

排查过程

死锁日志分析:

*** (1) TRANSACTION:
TRANSACTION 5555, ACTIVE 0 sec inserting
mysql thread id 88, query id 333 localhost root
INSERT INTO users (username, email, ...) VALUES ('john_doe', 'john@example.com', ...)
RECORD LOCKS space id 10 page no 3 n bits 100 index username of table `app`.`users` trx id 5555 lock_mode X locks rec but not gap waiting
Record lock, heap no 3 PHYSICAL RECORD: (name='john_doe')

*** (2) TRANSACTION:
TRANSACTION 5556, ACTIVE 0 sec inserting
mysql thread id 89, query id 334 localhost root
INSERT INTO users (username, email, ...) VALUES ('john_doe', 'john@example.com', ...)
RECORD LOCKS space id 10 page no 3 n bits 100 index username of table `app`.`users` trx id 5556 lock_mode X
Record lock, heap no 3 PHYSICAL RECORD: (name='john_doe')

根因定位

两个并发 INSERT 插入相同的唯一键值(username='john_doe'),在唯一索引上形成冲突:

  1. 事务 A 插入 username=’john_doe’,在唯一索引上请求排他锁
  2. 事务 B 也插入 username=’john_doe’,也在唯一索引上请求排他锁
  3. 唯一索引检查发现键值重复,事务 A 或 B 被通知等待对方释放锁
  4. 形成循环等待——死锁

MySQL 检测到死锁后会回滚其中一个事务,通常是回滚插入较晚的那个。

解决修复

方案一:应用层做插入前检查

defregister_user(username, email, password):
    # 先检查是否已存在
    existing = db.query(
        "SELECT id FROM users WHERE username = %s OR email = %s",
        (username, email)
    ).fetchone()

    if existing:
        raise ValueError("用户名或邮箱已被注册")

    # 插入(理论上可能有并发重复,但概率极低)
    db.execute(
        "INSERT INTO users (username, email, password_hash) VALUES (%s, %s, %s)",
        (username, email, hash_password(password))
    )
    db.commit()

方案二:使用 INSERT IGNORE 或 ON DUPLICATE KEY(适合幂等场景)

-- INSERT IGNORE:插入冲突时忽略,不报错
INSERTIGNOREINTOusers (username, email, password_hash)
VALUES ('john_doe', 'john@example.com', 'hashed_pw');

-- 检查是否插入成功
SELECTROW_COUNT(); -- 1 = 插入成功,0 = 被忽略(重复)

-- ON DUPLICATE KEY UPDATE:插入冲突时更新
INSERTINTOusers (username, email, password_hash)
VALUES ('john_doe', 'john@example.com', 'hashed_pw')
ONDUPLICATEKEYUPDATE email = VALUES(email), password_hash = VALUES(password_hash);

方案三:使用 REPLACE(删除旧记录,插入新记录)

-- REPLACE:如果唯一键冲突,先删除旧记录,再插入新记录
-- 注意:这是 DELETE + INSERT,不是 UPDATE
-- 会触发 DELETE 和 INSERT 两条语句,可能导致自增 ID 不连续
REPLACEINTOusers (username, email, password_hash)
VALUES ('john_doe', 'john@example.com', 'hashed_pw');

方案四:分布式锁(最可靠)

defregister_user_with_lock(username, email, password):
    lock_key = f"user:register:{username}"
    lock_val = str(uuid.uuid4())

    ifnot redis.set(lock_key, lock_val, nx=True, ex=5):
        raise Exception("注册进行中,请稍后")

    try:
        # 检查是否存在
        if db.query("SELECT 1 FROM users WHERE username = %s", (username,)).fetchone():
            raise ValueError("用户名已存在")

        db.execute(
            "INSERT INTO users (username, email, password_hash) VALUES (%s, %s, %s)",
            (username, email, hash_password(password))
        )
        db.commit()
    finally:
        if redis.get(lock_key) == lock_val:
            redis.delete(lock_key)

验证方法

-- 1. 检查是否有重复用户名(说明唯一约束没有兜底拦住)
SELECT username, COUNT(*) FROMusersGROUPBY username HAVINGCOUNT(*) > 1;

-- 2. 检查表上唯一约束是否生效
SHOWCREATETABLEusers\G

-- 3. 模拟并发注册测试
-- 启动多个 mysql client 同时执行相同 INSERT
-- 查看只有一个成功,其余报 deadlock 或 duplicate key error

-- 4. 监控死锁频率
SHOWGLOBALSTATUSLIKE'Innodb_deadlock%';
-- Innodb_deadlock_graph_writes: 死锁图更新次数
-- Innodb_deadlock_recursive_locks: 递归锁检测次数

风险提醒

  • REPLACE INTO 是 DELETE + INSERT,会触发 DELETE 的行锁和 INSERT 的排他锁,锁范围比普通 INSERT 大,不适合高并发场景
  • ON DUPLICATE KEY UPDATE 在 MySQL 5.7+ 支持,但注意:如果唯一键上有多个列的组合唯一索引,任意一个冲突都会触发 UPDATE
  • 应用层检查 + INSERT 的组合不是原子操作,并发时仍可能两个事务都通过检查然后同时插入。需要配合唯一约束作为最后防线

死锁排查的核心命令速查

-- 1. 查看当前所有事务及持有的锁(最常用)
SELECT
    trx_id,
    trx_state,
    trx_started,
    trx_requested_lock_id,
    trx_weight,
    trx_mysql_thread_id,
    trx_query,
    trx_rows_locked,
    trx_rows_modified
FROM information_schema.INNODB_TRX;

-- MySQL 8.0
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;

-- 2. 查看当前锁信息
SELECT * FROM information_schema.INNODB_LOCKS;  -- MySQL 5.7
SELECT * FROM information_schema.INNODB_LOCK_WAITS;  -- MySQL 5.7

-- 3. 查看当前阻塞的 SQL
SELECT
    r.trx_id AS waiting_trx_id,
    r.trx_mysql_thread_id AS waiting_thread,
    r.trx_query AS waiting_query,
    b.trx_id AS blocking_trx_id,
    b.trx_mysql_thread_id AS blocking_thread,
    b.trx_query AS blocking_query
FROM information_schema.INNODB_LOCK_WAITS w
JOIN information_schema.INNODB_TRX b ON w.blocking_trx_id = b.trx_id
JOIN information_schema.INNODB_TRX r ON w.requesting_trx_id = r.trx_id;

-- 4. 查看死锁次数统计
SHOWGLOBALSTATUSLIKE'Innodb_deadlock%';

-- 5. 查看事务等待时间超过阈值的事件(需要开启 performance_schema)
SELECT * FROM performance_schema.events_transactions_history
WHERE STATE = 'ROLLING BACK'AND EVENT_NAME = 'transaction'
ORDERBY END_EVENT_ID DESCLIMIT10;

-- 6. 查看当前正在执行的 SQL(不包括空闲连接)
SELECT
    p.ID AS thread_id,
    p.USER,
    p.HOST,
    d.COMMAND,
    t.trx_state,
    t.trx_started,
    t.trx_rows_locked,
    SUBSTRING(SQL.text, 1, 100) AS current_sql
FROM information_schema.PROCESSLIST p
LEFTJOIN information_schema.INNODB_TRX t ON p.ID = t.trx_mysql_thread_id
LEFTJOIN performance_schema.events_statements_current SQLON p.ID = SQL.THREAD_ID
WHERE p.COMMAND != 'Sleep'
ORDERBY t.trx_started;

死锁预防措施

从 SQL 层面预防

  1. 按固定顺序访问数据多个事务对多行数据进行操作时,按主键或唯一键的顺序访问,避免反向顺序导致死锁。
  2. 减少事务持有时间事务时间越长,持有锁的时间越长,死锁概率越高。尽量减少在事务内的网络 IO 和复杂计算。
-- 好的做法:批量操作在事务内一次完成
STARTTRANSACTION;
INSERTINTO order_items (order_id, product_id, quantity)
VALUES (1, 101, 2), (1, 102, 1), (1, 103, 3);
UPDATE orders SETstatus='confirmed'WHEREid=1;
COMMIT;

-- 不好的做法:事务时间过长
STARTTRANSACTION;
-- 读取数据到应用层处理(中间有网络延迟)
items = db.query("SELECT * FROM order_items WHERE order_id=1");
for item in items:
    # 调用外部服务(很慢)
    result = call_external_service(item)
    if result:
        db.execute("UPDATE order_items SETstatus='done'WHEREid=%s", item.id)
COMMIT;
  1. 使用合理的索引缺失索引的查询会导致全表扫描和更大的锁范围。
-- 检查查询是否走了索引
EXPLAINSELECT * FROM orders WHERE user_id = 1001ANDstatus = 'pending';
-- 看 type 列,如果是 ALL 说明全表扫描,需要加索引

-- 给常用查询条件加索引
CREATEINDEX idx_orders_user_status ON orders(user_id, status);
  1. 尽量使用主键或唯一索引查询基于主键或唯一索引的等值查询只会锁定一行,普通索引可能锁定多行( Next-Key Lock)。

从数据库配置层面预防

[mysqld]
# 锁等待超时时间(秒),不要设置过长
innodb_lock_wait_timeout = 10

# 死锁检测开关(默认 ON,关闭后用锁等待超时兜底)
innodb_deadlock_detect = ON

# 开启死锁日志
innodb_print_all_deadlocks = ON

# 监控锁信息(performance_schema)
performance-schema-instrument = 'lock%=ON'

# 事务隔离级别(高并发场景可考虑 READ-COMMITTED)
transaction-isolation = REPEATABLE-READ

# InnoDB 表锁(避免锁升级为表锁)
innodb_table_locks = ON

从应用架构层面预防

  1. 使用消息队列削峰库存扣减、余额变动等高并发操作,通过消息队列串行化处理,完全避免数据库层面的并发冲突。
  2. 应用层加分布式锁在数据库操作前通过 Redis/ZooKeeper 分布式锁限制并发度。
  3. 读写分离读操作走从库,写操作走主库,减少主库的锁冲突。
  4. 分库分表将热点数据按用户 ID 或订单 ID 拆分到不同库表,从物理上减少锁冲突概率。

常见误区与避坑指南

误区一:死锁了就是数据库的问题

死锁是业务逻辑和数据库交互模式共同造成的。单纯重启数据库或增加数据库资源不能解决根本问题。需要分析死锁日志,找出是哪些 SQL 之间的锁冲突导致的。

误区二:加大 innodb_lock_wait_timeout 就不会超时

这个参数只是让锁等待的错误晚一点出现,不会减少死锁的发生次数。相反,设置过长会让事务长时间占用连接资源,反而加剧资源紧张。

误区三:把 REPEATABLE READ 改成 READ COMMITTED 就不会死锁

降低隔离级别确实会减少 Next-Key Lock 的范围,但不能完全消除死锁。行锁冲突在任意隔离级别下都可能发生。调整隔离级别前要评估对业务一致性的影响。

误区四:大事务不会有问题

大事务持有锁的时间长,锁定范围大,是死锁的高发场景。应该拆分为多个小事务,避免在单个事务中处理过多数据。

误区五:加了唯一约束就不会重复插入

唯一约束只保证数据库层面的约束,应用层仍可能出现并发检查通过后同时插入的情况。唯一约束是兜底方案,不是预防方案。


总结

MySQL 死锁的排查与解决,核心在于三点:

第一,读懂死锁日志。innodb_print_all_deadlocks = ON 开启后,error log 中的死锁记录包含了两个事务持有的锁和等待的锁,这是定位根因的第一手材料。重点看 HOLDS THE LOCK(S) 和 WAITING FOR THIS LOCK TO BE GRANTED 部分,判断是行锁冲突、间隙锁冲突还是唯一索引冲突。

第二,理解 InnoDB 锁机制。 Next-Key Lock 在 REPEATABLE READ 隔离级别下会锁定索引范围,而不仅仅是命中的行。理解这一点,就能明白为什么范围查询比等值查询更容易引发死锁。

第三,从业务逻辑和 SQL 两层共同优化。 单纯改 SQL 不能解决所有问题——如果应用层并发模式不合理,即使 SQL 再优化也可能产生死锁。分布式锁、消息队列、乐观锁等架构层面的手段往往比单纯调整 SQL 更有效。

死锁是 MySQL 高并发下的正常现象,完全消除死锁几乎不可能。目标是将死锁频率控制在可接受范围内,并确保死锁发生时业务能够正确处理(重试、幂等)。不要一遇到死锁就重启数据库,那只是在掩盖问题,不是在解决问题。

版权申明:内容来源网络,版权归原创者所有,如有侵权请联系删除

想了解更多干货,可通过下方扫码关注

可扫码添加上智启元官方客服微信👇

未经允许不得转载:17认证网 » MySQL 死锁问题的排查与解决:别只会重启服务
分享到:0

评论已关闭。

400-663-6632
咨询老师
咨询老师
咨询老师