在高并发的数据库系统中,死锁(Deadlock) 是一种典型的并发控制问题。PostgreSQL 作为一款支持多版本并发控制(MVCC)和细粒度行级锁的数据库,虽然在读写场景中具备良好的并发性能,但在涉及显式更新、外键约束、唯一索引或复杂事务逻辑时,仍可能发生死锁。

PostgreSQL 内置了高效的死锁检测机制,能够在死锁发生后自动回滚其中一个事务以打破循环等待。然而,仅仅依赖自动回滚并不足以保障系统稳定性——开发人员和 DBA 必须能够从日志中快速定位死锁根源,并采取措施预防其再次发生

本文将从死锁的基本原理出发,深入剖析 PostgreSQL 的死锁检测机制、日志格式解析、常见死锁模式,并提供一套完整的排查与优化方法论。


一、死锁的本质:循环等待

PostgreSQL 的死锁检测机制高效可靠,但“自动回滚”只是应急手段。真正的工程能力体现在:

  1. 读懂死锁日志:从进程、表、元组、SQL 还原现场
  2. 识别根本模式:交叉更新、外键、无序批量等
  3. 实施预防策略:统一顺序、缩短事务、重试机制
  4. 建立监控体系:及时发现死锁趋势,防患于未然

死锁不是 PostgreSQL 的缺陷,而是并发系统的自然现象。理解其原理,掌握排查方法,才能构建真正健壮的数据库应用。

提醒:没有死锁的日志,不等于没有死锁风险。定期审查高并发事务逻辑,比事后排查更为重要。

1.1 什么是死锁?

死锁是指两个或多个事务相互持有对方所需的资源(如行锁、表锁),且都在等待对方释放,从而导致所有事务都无法继续执行的状态。

经典四条件(Coffman 条件):

  1. 互斥条件:资源一次只能被一个事务占用
  2. 占有并等待:事务持有资源的同时请求新资源
  3. 不可抢占:已分配的资源不能被强制收回
  4. 循环等待:存在一个事务等待环

PostgreSQL 中最常见的死锁发生在行级锁(Row-Level Locks)上,尤其是在 UPDATEDELETESELECT FOR UPDATE 场景中。

1.2 示例:典型双事务死锁

-- 会话 A
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 持有 id=1 的行锁
-- 此时切换到会话 B

-- 会话 B
BEGIN;
UPDATE accounts SET balance = balance + 50 WHERE id = 2;  -- 持有 id=2 的行锁
UPDATE accounts SET balance = balance - 50 WHERE id = 1;  -- 等待 A 释放 id=1

-- 切回会话 A
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 等待 B 释放 id=2

此时形成循环等待:A → B → A,构成死锁。

PostgreSQL 检测到后,会回滚其中一个事务(通常是后发起等待的),并在日志中记录详细信息。


二、PostgreSQL 的死锁检测机制

2.1 检测触发时机

PostgreSQL 并非实时检测死锁,而是在以下情况下触发:

  • 当一个事务尝试获取锁但被阻塞时
  • 阻塞时间超过 deadlock_timeout(默认 1 秒)
  • 后台死锁检测器(CheckDeadLock())被唤醒

注意:deadlock_timeout 不是死锁发生的阈值,而是开始检测的延迟。设置过小会增加 CPU 开销,过大则延长死锁发现时间。

2.2 检测算法:等待图(Wait-for Graph)

PostgreSQL 维护一个进程等待图,节点为后端进程(backend),边表示“P1 等待 P2 持有的锁”。

当新等待关系加入时,系统通过深度优先搜索(DFS)检查是否存在环。若存在,则判定为死锁。

  • 检测范围包括:行锁、表锁、轻量级锁(LWLock)、Advisory Lock 等
  • 支持跨事务、跨会话的死锁检测

2.3 死锁解决策略

一旦检测到死锁,PostgreSQL 会选择一个“代价最小”的事务进行回滚:

  • 通常选择最后请求锁的事务
  • 若涉及多个锁类型,优先回滚持有较少锁的事务
  • 回滚后释放其所有锁,打破循环

被回滚的事务会收到错误:

ERROR:  deadlock detected
DETAIL:  Process 12345 waits for ShareLock on transaction 67890; 
         blocked by process 54321.

三、死锁日志详解:如何读懂关键信息

要定位死锁根源,必须学会解读 PostgreSQL 的死锁日志。前提是开启相关日志配置:

log_min_messages = warning
log_min_error_statement = error
log_lock_waits = on          # 记录长时间锁等待
log_statement = 'none'       # 可选,避免日志过大

当死锁发生时,日志会输出类似以下内容(PostgreSQL 14+ 格式):

2026-02-02 08:15:23.456 UTC [12345] ERROR:  deadlock detected
2026-02-02 08:15:23.456 UTC [12345] DETAIL:  
  Process 12345 waits for ExclusiveLock on tuple (12,3) of relation 16384 of database 16385 after 1000.234 ms;
  blocked by process 54321.
  Process 54321 waits for ExclusiveLock on tuple (45,6) of relation 16384 of database 16385 after 980.123 ms;
  blocked by process 12345.

  See server log for query details.
2026-02-02 08:15:23.456 UTC [12345] HINT:  Run the ANALYZE command to update table and index statistics.
2026-02-02 08:15:23.456 UTC [12345] CONTEXT:  while updating tuple (12,3) in relation "accounts"
2026-02-02 08:15:23.456 UTC [12345] STATEMENT:  UPDATE accounts SET balance = balance + 100 WHERE id = 2;

3.1 日志字段解析

字段 含义
Process XXXXX 后端进程 ID(对应 pg_stat_activity.pid
waits for ExclusiveLock 等待的锁类型(常见:ExclusiveLock, ShareLock, RowExclusiveLock
tuple (12,3) 数据页号 12,偏移量 3(可通过 ctid 对应)
relation 16384 表的 OID(可通过 SELECT oid, relname FROM pg_class WHERE oid = 16384; 查询)
database 16385 数据库 OID(pg_database
after 1000.234 ms 等待时间(接近 deadlock_timeout
STATEMENT 触发死锁的 SQL 语句(需 log_min_error_statement = error

⚠️ 注意:STATEMENT 只显示当前语句,不显示事务中之前执行的语句。因此,完整事务上下文需结合应用日志


四、从日志定位死锁根源的五步法

步骤 1:提取死锁涉及的进程与表

从日志中找出:

  • 所有参与死锁的 Process ID
  • 涉及的 relation OID
  • 锁类型与元组位置

示例:

  • 进程 12345 和 54321
  • 表 OID 16384 → accounts
  • 互相等待对方持有的行锁

步骤 2:还原事务完整操作序列

由于日志只显示“最后一句”,需结合:

  • 应用程序日志(记录完整事务 SQL)
  • pg_stat_statements(若启用)
  • 代码逻辑(事务边界、SQL 顺序)

关键问题:

  • 两个事务是否以不同顺序访问相同行
  • 是否存在隐式锁(如外键检查、唯一索引插入)?

步骤 3:分析锁获取顺序

死锁的根本原因通常是锁获取顺序不一致

例如:

  • 事务 A:先锁 id=1,再锁 id=2
  • 事务 B:先锁 id=2,再锁 id=1

解决方案:强制所有事务按相同顺序访问资源(如按主键升序)。

步骤 4:检查隐式锁来源

并非所有锁都来自显式 UPDATE。以下操作也会加锁:

操作 加锁类型 说明
INSERT ROW EXCLUSIVE 若有外键,还会对父表加 SHARE ROW EXCLUSIVE
UPDATE 唯一索引列 SHARE on index 可能引发索引页锁竞争
ON DELETE CASCADE 行锁 on 子表 外键级联删除会锁子表行
SELECT FOR UPDATE EXCLUSIVE 显式行锁

案例:两个事务同时插入具有相同外键值的记录,可能因父表行锁冲突导致死锁。

步骤 5:复现与验证

使用脚本模拟死锁场景:

# 伪代码:模拟双事务交叉更新
def session_a():
    conn = connect()
    cur = conn.cursor()
    cur.execute("BEGIN")
    cur.execute("UPDATE accounts SET balance = balance - 100 WHERE id = 1")
    time.sleep(1)
    cur.execute("UPDATE accounts SET balance = balance + 100 WHERE id = 2")  # 死锁点
    conn.commit()

def session_b():
    conn = connect()
    cur = conn.cursor()
    cur.execute("BEGIN")
    cur.execute("UPDATE accounts SET balance = balance - 50 WHERE id = 2")
    time.sleep(1)
    cur.execute("UPDATE accounts SET balance = balance + 50 WHERE id = 1")  # 死锁点
    conn.commit()

通过复现确认根因,并验证修复方案。


五、常见死锁模式与解决方案

模式 1:交叉更新(Cross Update)

现象:两个事务以相反顺序更新多行。

解决方案

  • 应用层强制按主键/ID 升序更新
  • 使用 ORDER BYUPDATE 前确定顺序(但需注意:UPDATE 本身不保证顺序)

模式 2:外键死锁

现象

  • 事务 A 插入子表记录(外键指向父表 id=100)
  • 事务 B 删除父表 id=100
  • A 需要父表共享锁,B 需要排他锁 → 死锁

解决方案

  • 避免在高并发下频繁删除父记录
  • 使用 DEFERRABLE 外键(延迟检查到事务提交)
  • 先删除子表,再删除父表

模式 3:唯一索引插入冲突

现象

  • 两个事务同时插入相同唯一键值
  • 各自持有部分索引页锁,互相等待

解决方案

  • 使用 ON CONFLICT DO NOTHING/UPDATE(UPSERT)
  • 应用层先查后插(但需注意 race condition)

模式 4:批量操作无序

现象:批量 UPDATE 多行,但输入列表顺序随机。

解决方案

  • 在应用层对 ID 列表排序后再执行
  • 分批次处理,每批按固定顺序

六、死锁预防与监控体系

6.1 配置优化

deadlock_timeout = 1s        # 默认值通常合适,不建议过小
log_lock_waits = on         # 记录长时间锁等待(早于死锁)
log_min_duration_statement = 1000  # 辅助分析慢查询

6.2 监控指标

  • 死锁次数pg_stat_database.deadlocks
    SELECT datname, deadlocks FROM pg_stat_database;
    
  • 锁等待事件pg_stat_activity.wait_event_type = 'Lock'
  • 长事务:可能加剧死锁概率

6.3 应用层最佳实践

  1. 保持事务短小:减少锁持有时间
  2. 统一访问顺序:按主键、时间戳等固定顺序操作数据
  3. 避免交互式事务:不要在事务中等待用户输入
  4. 重试机制:捕获 deadlock detected 错误后自动重试(通常 1~3 次)

6.4 工具辅助

  • pgBadger:分析日志,统计死锁频率
  • Prometheus + postgres_exporter:监控 pg_stat_database.deadlocks
  • auto_explain:结合 log_analyze = on 查看执行计划是否引发意外锁

七、高级话题

7.1 死锁与隔离级别的关系

虽然 MVCC 减少了读写冲突,但写-写冲突仍需加锁,因此死锁与隔离级别关系如下:

  • READ COMMITTED:最常见死锁场景(每次语句获取新快照,但写操作仍加锁)
  • REPEATABLE READ / SERIALIZABLE:死锁可能性略低(因快照一致,减少部分冲突),但仍会发生

注意:SSI(Serializable Snapshot Isolation)用于检测写偏斜,而非传统死锁。死锁检测独立于 SSI。

7.2 常用诊断 SQL

-- 1. 查看当前锁等待
SELECT 
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_stat_activity blocked ON blocked.pid = blocked_locks.pid
JOIN pg_catalog.pg_locks blocking_locks
  ON blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.DATABASE IS NOT DISTINCT FROM blocked_locks.DATABASE
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.page IS NOT DISTINCT FROM blocked_locks.page
  AND blocking_locks.tuple IS NOT DISTINCT FROM blocked_locks.tuple
  AND blocking_locks.virtualxid IS NOT DISTINCT FROM blocked_locks.virtualxid
  AND blocking_locks.transactionid IS NOT DISTINCT FROM blocked_locks.transactionid
  AND blocking_locks.classid IS NOT DISTINCT FROM blocked_locks.classid
  AND blocking_locks.objid IS NOT DISTINCT FROM blocked_locks.objid
  AND blocking_locks.objsubid IS NOT DISTINCT FROM blocked_locks.objsubid
  AND blocking_locks.pid != blocked_locks.pid
JOIN pg_stat_activity blocking ON blocking.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

-- 2. 查看死锁历史(需日志分析,数据库内无直接视图)
-- 建议使用外部工具如 pgBadger 或 ELK

-- 3. 查看表 OID 与名称映射
SELECT oid, relname FROM pg_class WHERE relkind = 'r';
Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐