数据库死锁的核心成因是 并发事务相互持有对方需要的锁,且等待顺序循环(比如事务 A 锁了资源 1 等资源 2,事务 B 锁了资源 2 等资源 1)。解决死锁的核心思路是:避免循环等待、缩小锁粒度、减少锁持有时间

唯一索引、悲观锁、乐观锁】是三种核心解决方案,分别对应不同业务场景(写多读少 / 读多写少、强一致性 / 最终一致性)。下面结合 PostgreSQL 具体说明「原理 + 实现 + 适用场景 + 注意事项」

一、唯一索引:从根源避免 “锁竞争” 和 “幻读”

核心原理

唯一索引(含主键)的核心作用是 强制数据唯一性,同时能:

  1. 缩小锁粒度:没有索引时,事务会加「表锁」(大范围竞争);有唯一索引时,只会加「行锁」(精准锁定目标行,减少冲突);
  2. 避免幻读:防止并发事务重复插入相同数据(比如重复创建订单号),从而减少因 “重复插入” 引发的锁竞争;
  3. 强制锁顺序:基于唯一键(如 ID、订单号)操作时,事务会按唯一键的自然顺序获取锁,避免循环等待(死锁的核心诱因)。

什么是幻读

幻读就是同一事务内,相同查询条件下「结果集行数忽多忽少」的现象,本质是并发事务的 “插入 / 删除” 操作导致的。解决幻读的核心是:要么用唯一索引阻止重复插入,要么用更高的事务隔离级别(如 SERIALIZABLE)强制事务串行执行。

拓展:不可重复读:结果集「内容变化」(行还在,但值变了)

强制锁顺序” 的真正含义(3 句话讲清)

  1. 「强制的主体」:是 数据库对 “单个事务内的锁顺序” 强制,不是对 “多个事务之间的操作顺 序” 强制;
  2. 「强制的规则」:单个事务如果要锁多个唯一键(比如同时锁 user_id=1 和 2)(关键字in),数据库会按唯一键的自然顺序(1<2)加锁,不会按事务写 SQL 的顺序乱加;
  3. 「不强制的部分」:数据库管不了 “多个事务之间,谁先操作哪个唯一键”—— 这是业务代码决定的,比如事务 A 先操作 1,事务 B 可以先操作 2,这就是 “事务间操作顺序混乱”,进而导致死锁。

适用场景

  • 存在 “重复数据插入” 风险的场景(如订单号、用户手机号唯一);
  • 并发事务需操作相同资源(如修改同一用户余额),需精准锁定单行。

具体实现(PostgreSQL)

  1. 创建唯一索引 / 主键(优先主键,主键自带唯一索引):

    -- 1. 给表加主键(最常用,比如id列,唯一标识每行)
    ALTER TABLE your_table ADD PRIMARY KEY (id);
    
    -- 2. 给非主键列加唯一索引(如订单号唯一)
    CREATE UNIQUE INDEX idx_unique_order_no ON your_table (order_no);
    
    -- 3. 组合唯一索引(多列组合唯一,如“用户ID+商品ID”避免重复下单)
    CREATE UNIQUE INDEX idx_unique_user_goods ON your_table (user_id, goods_id);
  2. 基于唯一索引操作数据(避免表锁,精准行锁):

    -- 事务1:修改order_no='OD123'的订单(通过唯一索引锁定单行)
    BEGIN;
    UPDATE your_table SET status = 'paid' WHERE order_no = 'OD123'; -- 仅锁这一行
    COMMIT; -- 尽快提交,释放锁
    
    -- 事务2:同时修改order_no='OD456'的订单(无冲突)
    BEGIN;
    UPDATE your_table SET status = 'paid' WHERE order_no = 'OD456'; -- 仅锁这一行
    COMMIT;

注意事项

  • 唯一索引的列需选择「查询频繁、基数高」的字段(如 ID、订单号),避免索引失效导致表锁;
  • 避免 “批量操作无索引的列”(如 UPDATE your_table SET status=1 WHERE create_time < '2025-01-01' 无索引时,会锁全表)。

上述的例子是「无冲突场景」—— 因为两个事务操作的是不同的唯一键值(比如不同订单号、不同商品 ID),自然能并行执行,这其实是唯一索引「缩小锁粒度」的优势(行锁 vs 表锁),但没体现出它「解决死锁」的核心作用。

要理解唯一索引如何解决死锁,关键是先看「没有唯一索引时,为什么会产生死锁」,再对比「有唯一索引时如何打破死锁」—— 死锁的核心是「循环等待」,唯一索引的核心作用是强制锁顺序、避免循环等待同时缩小锁冲突范围

死锁案例

场景:无唯一索引 → 死锁触发

假设我们有一张 order 表,没有主键 / 唯一索引,只有 user_id(用户 ID)和 order_no(订单号)两列,现在两个并发事务要「交叉修改同一用户的不同订单」:

时间线事务 A(操作用户 100 的订单)事务 B(操作用户 100 的订单)
1BEGIN;BEGIN;
2-- 无索引,修改订单 OD123 → 触发「表锁」(因为没索引,PG 只能锁全表)
3UPDATE order SET status='paid' WHERE order_no='OD123';
4-- 无索引,修改订单 OD456 → 申请表锁,但事务 A 已持有表锁 → 阻塞等待
5-- 事务 A 想继续修改 OD456 → 自己持有表锁,却要等自己释放?不,实际更常见的是「行锁混乱导致循环等待」(无索引时 PG 可能按物理行号锁,顺序随机)
6UPDATE order SET status='paid' WHERE order_no='OD456';
7-- 此时事务 A 持有 OD123 的 “伪行锁”,等待 OD456 的锁;事务 B 持有 OD456 的 “伪行锁”,等待表锁 → 循环等待 → 死锁触发!

为什么会这样?

没有唯一索引时,PostgreSQL 无法精准定位单行,会出现两种问题:

  1. 要么触发「表锁」(所有事务抢同一把锁,阻塞但可能不死锁,但并发为 0);
  2. 要么按「物理行号」加锁(行锁),但物理行号是无序的,事务可能「交叉锁定」资源(A 锁行 1 等行 2,B 锁行 2 等行 1),直接触发死锁。
场景:有唯一索引 → 无死锁,有序执行

给 order 表加「唯一索引」(比如 idx_unique_order_no (order_no)),再重复上面的交叉操作:

时间线事务 A(操作用户 100 的订单)事务 B(操作用户 100 的订单)
1BEGIN;BEGIN;
2-- 有唯一索引,修改 OD123 → 精准加「行锁」(只锁 OD123 这一行)
3UPDATE order SET status='paid' WHERE order_no='OD123';
4-- 有唯一索引,修改 OD456 → 精准加「行锁」(只锁 OD456 这一行)
5UPDATE order SET status='paid' WHERE order_no='OD456'; -- 无阻塞,直接执行
6-- 事务 A 继续修改 OD456 → 申请 OD456 的行锁 → 事务 B 已持有,阻塞等待
7COMMIT; -- 事务 B 提交,释放 OD456 的锁
8

-- 事务 A 获取 OD456 的锁,执行修改 → COMMIT;

为什么没死锁?唯一索引解决了两个核心问题(死锁的两个关键诱因):

  1. 锁粒度从「表锁」→「行锁」:两个事务操作不同订单号时,锁定的是「各自的行」,不会抢同一把锁,冲突范围大幅缩小;
  2. 强制「锁顺序」:唯一索引的列(如 order_no)是有序的(OD123 < OD456),事务操作时会按「唯一键升序」获取锁(哪怕是交叉操作,PG 也会按索引顺序加锁),不会出现「A 等 B、B 等 A」的循环等待。
反例:有唯一索引,但锁顺序混乱 → 死锁触发
时间点事务 A(用户 1→用户 2 转账)事务 A 的锁状态事务 B(用户 2→用户 1 转账)事务 B 的锁状态
0 秒BEGIN;(开启事务)无任何锁BEGIN;(开启事务)无任何锁
1 秒-- 第一步:扣减用户 1 的余额(操作 user_id=1)UPDATE user_account SET balance=balance-100 WHERE user_id=1;持有「user_id=1 的行锁」(因为 user_id 是唯一主键,精准锁这一行)-无任何锁
2 秒-仍持有「user_id=1 的行锁」-- 第一步:扣减用户 2 的余额(操作 user_id=2)UPDATE user_account SET balance=balance-100 WHERE user_id=2;持有「user_id=2 的行锁」(精准锁这一行)
3 秒-- 第二步:给用户 2 加余额(操作 user_id=2)UPDATE user_account SET balance=balance+100 WHERE user_id=2;想要获取「user_id=2 的行锁」,但这把锁已被事务 B 持有 → 阻塞等待(事务 A 停在这,不往下走)-仍持有「user_id=2 的行锁」
4 秒-阻塞等待(等事务 B 释放 user_id=2 的锁)-- 第二步:给用户 1 加余额(操作 user_id=1)UPDATE user_account SET balance=balance+100 WHERE user_id=1;想要获取「user_id=1 的行锁」,但这把锁已被事务 A 持有 → 阻塞等待(事务 B 也停在这)
5 秒数据库检测到:事务 A 持有「user_id=1 的锁」,等「user_id=2 的锁」;事务 B 持有「user_id=2 的锁」,等「user_id=1 的锁」;循环等待,无法解开!死锁触发!数据库自动回滚其中一个事务(比如事务 A),避免无限阻塞同上死锁触发!另一个事务(事务 B)可能继续执行,或也被回滚(看数据库策略)
  1. 为什么有唯一索引还会死锁?

    唯一索引的作用是「把锁粒度从表锁→行锁」,但它管不了「你先锁哪一行、后锁哪一行」。这里两个事务都只锁自己需要的行(没有锁全表),但因为「锁的顺序相反」,导致「互相持有对方需要的锁,互相等待」—— 这正是死锁的核心条件(循环等待),唯一索引解决不了 “顺序混乱” 的问题。

  2. 如果没有唯一索引会怎么样?

    若 user_id 没有唯一索引,执行 UPDATE WHERE user_id=xxx 时,PostgreSQL 会加「表锁」→ 两个事务会争抢同一把表锁,一个事务阻塞等另一个释放,不会死锁,但并发效率为 0(所有操作串行);而有唯一索引时,虽然并发效率高(能同时操作不同行),但如果顺序乱了,反而会触发死锁(比表锁更隐蔽的问题)。

  3. 死锁发生后会怎么样?

    PostgreSQL 会自动检测死锁(默认开启死锁检测),发现后会「回滚其中一个事务」(通常是执行时间更短、修改行数更少的那个),并抛出错误(比如 ERROR: deadlock detected),让另一个事务能继续执行,避免系统卡死。

对比:锁顺序一致 → 彻底避免死锁(正确做法)

只要让所有事务「按唯一键的固定顺序加锁」(比如:先锁 user_id 小的行,再锁 user_id 大的行),哪怕有唯一索引,也不会死锁:

时间点事务 A(用户 1→用户 2 转账,按 user_id 升序锁)事务 A 的锁状态事务 B(用户 2→用户 1 转账,按 user_id 升序锁)事务 B 的锁状态
0 秒BEGIN;无任何锁BEGIN;无任何锁
1 秒-- 第一步:先锁 user_id=1(小的),扣减余额UPDATE user_account SET balance=balance-100 WHERE user_id=1;持有「user_id=1 的行锁」-无任何锁
2 秒-持有「user_id=1 的行锁」-- 第一步:也先锁 user_id=1(小的)UPDATE user_account SET balance=balance-100 WHERE user_id=1;想要获取「user_id=1 的行锁」,但被事务 A 持有 → 阻塞等待
3 秒-- 第二步:再锁 user_id=2(大的),加余额UPDATE user_account SET balance=balance+100 WHERE user_id=2;持有「user_id=1+2 的行锁」-继续阻塞等待
4 秒COMMIT;(事务结束,释放所有锁)无任何锁-获得「user_id=1 的行锁」,执行扣减余额
5 秒---- 第二步:再锁 user_id=2(大的),加余额UPDATE user_account SET balance=balance+100 WHERE user_id=2;持有「user_id=1+2 的行锁」
6 秒--COMMIT;(事务结束,释放所有锁)无任何锁

结果:没有循环等待,两个事务有序执行,完全不会死锁!

核心总结(一句话打通逻辑)

1、唯一索引解决的是「锁粒度太大(表锁)导致的无意义冲突」,但解决不了「锁顺序混乱导致的循环等待」;

2、死锁的核心是「循环等待」,哪怕有唯一索引,只要多个事务交叉锁定同一组资源(比如两行数据)且顺序相反,就一定会死锁;

3、解决办法只有一个:所有事务都按「唯一键升序(或固定顺序)」加锁,打破循环等待。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐