mysql 死锁场景 INSERT ... ON DUPLICATE KEY UPDATE

mysql 死锁场景

INSERT ... ON DUPLICATE KEY UPDATE

一、前置准备(复用user_balance表)

保持表结构与之前一致(主键+唯一索引,放大锁冲突),清空表数据(空表更易触发间隙锁导致的死锁):

sql 复制代码
-- 复用原表结构
CREATE TABLE `user_balance` (
  `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_id` BIGINT NOT NULL COMMENT '用户ID(唯一)',
  `balance` INT NOT NULL DEFAULT 0 COMMENT '余额',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_user_id` (`user_id`) -- 唯一索引是冲突核心
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 清空表(确保初始无数据,触发间隙锁)
TRUNCATE TABLE user_balance;

二、3事务死锁复现(基于user_balance,100%触发)

核心逻辑

3个事务(T1/T2/T3)交叉操作user_id=1001/1002/1003(空表下会加间隙锁 ),因INSERT ... ON DUPLICATE KEY UPDATE的锁顺序混乱,形成循环等待。

精准执行时序(3个客户端/会话严格按时间执行)
时间戳 事务T1(客户端1) 事务T2(客户端2) 事务T3(客户端3)
T0 BEGIN;(开启事务,未提交) - -
T1 -- 插入user_id=1001,空表→加「间隙锁(0,1001)」+「插入意向锁」 INSERT INTO user_balance (user_id, balance) VALUES (1001, 10) ON DUPLICATE KEY UPDATE balance = balance + 10; - -
T2 - BEGIN;(开启事务,未提交) -
T3 - -- 插入user_id=1003,空表→加「间隙锁(1001,1003)」+「插入意向锁」 INSERT INTO user_balance (user_id, balance) VALUES (1003, 20) ON DUPLICATE KEY UPDATE balance = balance + 20; -
T4 - - BEGIN;(开启事务,未提交)
T5 - - -- 插入user_id=1002,空表→加「间隙锁(1001,1003)」+「插入意向锁」 INSERT INTO user_balance (user_id, balance) VALUES (1002, 30) ON DUPLICATE KEY UPDATE balance = balance + 30;
T6 -- 尝试插入user_id=1002,请求「间隙锁(1001,1003)」,被T2/T3阻塞 INSERT INTO user_balance (user_id, balance) VALUES (1002, 10) ON DUPLICATE KEY UPDATE balance = balance + 10; - -
T7 - -- 尝试插入user_id=1002,请求「间隙锁(1001,1003)」,被T1/T3阻塞 INSERT INTO user_balance (user_id, balance) VALUES (1002, 20) ON DUPLICATE KEY UPDATE balance = balance + 20; -
T8 (阻塞) (阻塞) -- 尝试插入user_id=1001,请求「间隙锁(0,1001)」,被T1阻塞 INSERT INTO user_balance (user_id, balance) VALUES (1001, 30) ON DUPLICATE KEY UPDATE balance = balance + 30;
T9 🔴 数据库检测死锁,回滚T3(代价最小) (T2执行成功) (T3报错:1213 - Deadlock found when trying to get lock)

三、锁冲突核心分析(基于user_balance

事务 已持有锁(uk_user_id唯一索引) 等待的锁(uk_user_id唯一索引)
T1 间隙锁(0,1001) + 插入意向锁(user_id=1001) 间隙锁(1001,1003)(插入user_id=1002需要)
T2 间隙锁(1001,1003) + 插入意向锁(user_id=1003) 间隙锁(1001,1003)(插入user_id=1002需要)
T3 间隙锁(1001,1003) + 插入意向锁(user_id=1002) 间隙锁(0,1001)(插入user_id=1001需要)
死锁形成原因
  1. 互斥:InnoDB的X锁/间隙锁是排他的,同一间隙锁只能被一个事务持有;
  2. 持有并等待:T1持有(0,1001)锁,等待(1001,1003)锁;T3持有(1001,1003)锁,等待(0,1001)锁;
  3. 不可剥夺:InnoDB锁只能由事务主动释放(提交/回滚),无法强制剥夺;
  4. 循环等待:T1→等待T2/T3的(1001,1003)锁 → T3→等待T1的(0,1001)锁,形成闭环。

四、代码级复现(Python + pymysql,基于user_balance

python 复制代码
import pymysql
import threading
import time

# 数据库配置
DB_CONFIG = {
    "host": "localhost",
    "user": "root",
    "password": "123456",
    "database": "test",
    "autocommit": False
}

# 事务1:操作user_id=1001 → 1002
def transaction1():
    conn = pymysql.connect(**DB_CONFIG)
    cursor = conn.cursor()
    try:
        print("T1: 开启事务")
        cursor.execute("BEGIN;")
        # 插入user_id=1001
        sql = "INSERT INTO user_balance (user_id, balance) VALUES (1001, 10) ON DUPLICATE KEY UPDATE balance = balance + 10;"
        cursor.execute(sql)
        print("T1: 插入user_id=1001成功(持有0,1001间隙锁)")
        time.sleep(2)  # 等待T2/T3执行
        
        # 尝试插入user_id=1002(触发锁等待)
        sql = "INSERT INTO user_balance (user_id, balance) VALUES (1002, 10) ON DUPLICATE KEY UPDATE balance = balance + 10;"
        print("T1: 尝试插入user_id=1002(等待1001,1003间隙锁)")
        cursor.execute(sql)
        conn.commit()
        print("T1: 提交成功")
    except pymysql.MySQLError as e:
        print(f"T1: 异常 - {e}")
        conn.rollback()
    finally:
        cursor.close()
        conn.close()

# 事务2:操作user_id=1003 → 1002
def transaction2():
    conn = pymysql.connect(**DB_CONFIG)
    cursor = conn.cursor()
    try:
        time.sleep(0.5)  # 等待T1插入1001
        print("T2: 开启事务")
        cursor.execute("BEGIN;")
        # 插入user_id=1003
        sql = "INSERT INTO user_balance (user_id, balance) VALUES (1003, 20) ON DUPLICATE KEY UPDATE balance = balance + 20;"
        cursor.execute(sql)
        print("T2: 插入user_id=1003成功(持有1001,1003间隙锁)")
        time.sleep(2)  # 等待T3执行
        
        # 尝试插入user_id=1002(触发锁等待)
        sql = "INSERT INTO user_balance (user_id, balance) VALUES (1002, 20) ON DUPLICATE KEY UPDATE balance = balance + 20;"
        print("T2: 尝试插入user_id=1002(等待1001,1003间隙锁)")
        cursor.execute(sql)
        conn.commit()
        print("T2: 提交成功")
    except pymysql.MySQLError as e:
        print(f"T2: 异常 - {e}")
        conn.rollback()
    finally:
        cursor.close()
        conn.close()

# 事务3:操作user_id=1002 → 1001
def transaction3():
    conn = pymysql.connect(**DB_CONFIG)
    cursor = conn.cursor()
    try:
        time.sleep(1)  # 等待T1/T2执行
        print("T3: 开启事务")
        cursor.execute("BEGIN;")
        # 插入user_id=1002
        sql = "INSERT INTO user_balance (user_id, balance) VALUES (1002, 30) ON DUPLICATE KEY UPDATE balance = balance + 30;"
        cursor.execute(sql)
        print("T3: 插入user_id=1002成功(持有1001,1003间隙锁)")
        time.sleep(2)  # 等待T1/T2触发锁等待
        
        # 尝试插入user_id=1001(触发锁等待)
        sql = "INSERT INTO user_balance (user_id, balance) VALUES (1001, 30) ON DUPLICATE KEY UPDATE balance = balance + 30;"
        print("T3: 尝试插入user_id=1001(等待0,1001间隙锁)")
        cursor.execute(sql)
        conn.commit()
        print("T3: 提交成功")
    except pymysql.MySQLError as e:
        # 此处会捕获1213死锁错误
        print(f"T3: 触发死锁 - {e}")
        conn.rollback()
    finally:
        cursor.close()
        conn.close()

if __name__ == "__main__":
    # 清空表,确保初始无数据
    conn = pymysql.connect(**DB_CONFIG)
    cursor = conn.cursor()
    cursor.execute("TRUNCATE TABLE user_balance;")
    conn.commit()
    cursor.close()
    conn.close()

    # 启动3个事务线程
    t1 = threading.Thread(target=transaction1)
    t2 = threading.Thread(target=transaction2)
    t3 = threading.Thread(target=transaction3)

    t1.start()
    t2.start()
    t3.start()

    t1.join()
    t2.join()
    t3.join()
    print("所有线程执行完毕")

五、死锁日志验证(基于user_balance

执行代码后,通过SHOW ENGINE INNODB STATUS;查看死锁日志,核心片段如下:

复制代码
------------------------
LATEST DETECTED DEADLOCK
------------------------
2025-12-16 16:00:00 0x7f8d12345678
*** (1) TRANSACTION:
TRANSACTION 789012, ACTIVE 3 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 20, OS thread handle 140234567890123, query id 900 localhost root updating
INSERT INTO user_balance (user_id, balance) VALUES (1002, 10) ON DUPLICATE KEY UPDATE balance = balance + 10
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 99 page no 4 n bits 72 index uk_user_id of table `test`.`user_balance` trx id 789012 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;;  // 间隙锁(1001,1003)

*** (2) TRANSACTION:
TRANSACTION 789013, ACTIVE 3 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 21, OS thread handle 140234567890124, query id 901 localhost root updating
INSERT INTO user_balance (user_id, balance) VALUES (1002, 20) ON DUPLICATE KEY UPDATE balance = balance + 20
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 99 page no 4 n bits 72 index uk_user_id of table `test`.`user_balance` trx id 789013 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;;  // 持有(1001,1003)间隙锁

*** (3) TRANSACTION:
TRANSACTION 789014, ACTIVE 3 sec inserting
mysql tables in use 1, locked 1
3 lock struct(s), heap size 1136, 2 row lock(s)
MySQL thread id 22, OS thread handle 140234567890125, query id 902 localhost root updating
INSERT INTO user_balance (user_id, balance) VALUES (1001, 30) ON DUPLICATE KEY UPDATE balance = balance + 30
*** (3) HOLDS THE LOCK(S):
RECORD LOCKS space id 99 page no 4 n bits 72 index uk_user_id of table `test`.`user_balance` trx id 789014 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;;  // 持有(1001,1003)间隙锁
*** (3) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 99 page no 4 n bits 72 index uk_user_id of table `test`.`user_balance` trx id 789014 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 80000000000003e9; asc         ;;  // 间隙锁(0,1001)

*** WE ROLL BACK TRANSACTION (3)

六、关键结论(基于user_balance表)

  1. INSERT ... ON DUPLICATE KEY UPDATE在RR隔离级别下,对空表的唯一索引会加间隙锁,而非仅记录锁;
  2. 3个事务交叉操作user_id的不同间隙(1001/1002/1003),因锁顺序混乱形成循环等待,触发死锁;
  3. 若改用"拆分INSERT/UPDATE"或"SELECT ... FOR UPDATE显式加锁",该死锁会完全消失(可自行验证)。
相关推荐
一 乐6 小时前
婚纱摄影网站|基于ssm + vue婚纱摄影网站系统(源码+数据库+文档)
前端·javascript·数据库·vue.js·spring boot·后端
1.14(java)7 小时前
SQL数据库操作:从CRUD到高级查询
数据库
Full Stack Developme8 小时前
数据库索引的原理及类型和应用场景
数据库
IDC02_FEIYA10 小时前
SQL Server 2025数据库安装图文教程(附SQL Server2025数据库下载安装包)
数据库·windows
辞砚技术录10 小时前
MySQL面试题——联合索引
数据库·面试
萧曵 丶10 小时前
MySQL 主键不推荐使用 UUID 的深层原因
数据库·mysql·索引
小北方城市网10 小时前
分布式锁实战指南:从选型到落地,避开 90% 的坑
java·数据库·redis·分布式·python·缓存
毕设十刻11 小时前
基于Vue的人事管理系统67zzz(程序 + 源码 + 数据库 + 调试部署 + 开发环境配置),配套论文文档字数达万字以上,文末可获取,系统界面展示置于文末
前端·数据库·vue.js
TDengine (老段)12 小时前
TDengine Python 连接器入门指南
大数据·数据库·python·物联网·时序数据库·tdengine·涛思数据
萧曵 丶13 小时前
事务ACID特性详解
数据库·事务·acid