PostgreSQL 事务、隔离级别与锁:MVCC 快照、行锁/表锁、死锁检测与长事务治理

深入解析 PostgreSQL 事务体系:MVCC 快照与可见性规则、四种隔离级别(读已提交/可重复读/可串行化/SSI)的差异与异常、行锁与表锁的兼容矩阵、pg_locks 锁等待分析、死锁检测机制与避免策略、快照陈旧与长事务(idle in transaction)、事务重试与幂等设计、锁监控常用 SQL。

事务是数据库正确性的最后一道防线。PostgreSQL 的并发控制基于 MVCC(Multi-Version Concurrency Control):读不阻塞写、写不阻塞读,通过快照实现隔离。但"快照"的语义因隔离级别而异,锁的冲突与死锁又让并发问题变得隐蔽。

核心认知:理解了快照的可见性规则,你才能真正解释"为什么我在别的事务里读不到刚提交的数据";理解了锁的兼容矩阵,你才能回答"为什么我的 UPDATE 卡住了"。


一、事务与 MVCC 快照

1.1 事务的基本特性

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 或 ROLLBACK 取消全部变更

ACID 中,I(隔离性) 正是本文重点。PostgreSQL 用 MVCC 保证:事务看到的是一致性快照,而不是其他事务的中间状态。

1.2 快照(Snapshot)是什么

每个事务开始时(或每条语句开始时,取决于隔离级别),PostgreSQL 会记录一个快照:一个活跃事务 ID 列表。可见性规则据此判断某元组是否可见:

-- 查看当前快照信息
SELECT txid_current(), txid_current_snapshot();
-- 输出示例:(100001, "100001:100003:100001,100002")

快照结构 xmin:xmax:xip_list 表示:最早活跃事务 xmin、下一个事务 xmax、正在运行的 xip_list。

1.3 可见性规则

元组状态对当前事务是否可见
xmin < 快照.xmin 且已提交✅ 可见
xmin 在快照的 xip_list 中❌ 事务未结束,不可见
xmin > 快照.xmax❌ 未来事务,不可见
xmax < 快照.xmin 且已提交❌ 已被删除/更新
xmax 在 xip_list 中✅ 删除未提交,仍可见

记忆口诀:“提交在快照之前、未在快照活跃列表中的版本"可见。这个规则决定了不同隔离级别的行为差异。


二、四种隔离级别与异常

2.1 设置隔离级别

BEGIN ISOLATION LEVEL REPEATABLE READ;
-- 或
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 查看当前会话
SHOW transaction_isolation;

2.2 隔离级别行为对比

隔离级别快照时机脏读不可重复读幻读写偏斜
Read Committed(默认)每条语句无有有可能
Repeatable Read整个事务无无无(PG 特性)可能
Serializable(SSI)整个事务无无无拦截(报错)

PostgreSQL 的 Repeatable Read 同时防止了幻读(通过快照 + 谓词锁机制),这一点与标准定义略有差异。但 Repeatable Read 仍可能发生写偏斜(write skew),只有 Serializable 才能完全保证。

2.3 不可重复读演示(Read Committed)

-- 会话 A
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 返回 100
-- 会话 B(并发)
BEGIN;
UPDATE accounts SET balance = 200 WHERE id = 1;
COMMIT;
-- 会话 A 再次查询
SELECT balance FROM accounts WHERE id = 1;  -- 返回 200(不一致!)

2.4 可串行化与 SSI

BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance * 1.1 WHERE type = 'savings';
COMMIT;
-- 若发生可串行化冲突,事务会以 40001 serialization_failure 终止

应用层必须捕获 40001 并重试,这是可串行化隔离的代价(完整重试框架见第八章)。

2.5 如何选择隔离级别

场景推荐
默认 OLTP 应用Read Committed(默认,性能最好)
报表/对账/多语句一致性读Repeatable Read
涉及并发写偏斜的业务规则Serializable + 重试
只读分析查询任意 + SET TRANSACTION READ ONLY

三、行锁(Row Locks)

3.1 四种行锁模式

PostgreSQL 通过 SELECT ... FOR UPDATE 等子句为行加锁,阻止并发修改:

-- 等值锁定行(最常用)
SELECT * FROM orders WHERE id = 1001 FOR UPDATE;

-- 只阻止 UPDATE/DELETE,允许改非键列
SELECT * FROM orders WHERE id = 1001 FOR NO KEY UPDATE;

-- 共享行锁:阻止 UPDATE/DELETE/NO KEY UPDATE
SELECT * FROM orders WHERE id = 1001 FOR SHARE;

-- 最弱共享:只阻止 UPDATE/DELETE
SELECT * FROM orders WHERE id = 1001 FOR KEY SHARE;

3.2 行锁兼容矩阵

请求 \ 已持有FOR KEY SHAREFOR SHAREFOR NO KEY UPDATEFOR UPDATE
FOR KEY SHARE✅✅✅❌
FOR SHARE✅✅❌❌
FOR NO KEY UPDATE✅❌❌❌
FOR UPDATE❌❌❌❌

3.3 行锁使用场景

-- 防止并发下超卖:先锁库存行再扣减
BEGIN;
SELECT stock FROM products WHERE id = 10 FOR UPDATE;
-- 检查 stock > 0
UPDATE products SET stock = stock - 1 WHERE id = 10;
COMMIT;

-- 乐观锁替代:用 version 列比较,影响行数为 0 表示版本冲突
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 10 AND version = 5;

锁是数据库的"反资源”:持有越久,并发度越低。行锁应尽量在事务末尾释放,即事务尽快 COMMIT。


四、表锁(Table Locks)

4.1 八种表锁模式

PostgreSQL 定义了从 ACCESS SHARE 到 ACCESS EXCLUSIVE 的八级锁:

-- 常见显式加锁
LOCK TABLE orders IN SHARE MODE;
LOCK TABLE orders IN EXCLUSIVE MODE;
LOCK TABLE orders IN ACCESS EXCLUSIVE MODE;
锁模式冲突级别典型持锁操作
ACCESS SHARE最弱SELECT
ROW SHARE—SELECT … FOR UPDATE
ROW EXCLUSIVE—INSERT/UPDATE/DELETE
SHARE UPDATE EXCLUSIVE—VACUUM、ANALYZE
SHARE—CREATE INDEX(非 CONCURRENTLY)
SHARE ROW EXCLUSIVE—某些维护操作
EXCLUSIVE—少量维护
ACCESS EXCLUSIVE最强DROP/ALTER/VACUUM FULL/TRUNCATE

4.2 锁冲突示例

-- 会话 A:长事务持有行级锁
BEGIN;
SELECT * FROM orders WHERE id = 1 FOR UPDATE;
-- 会话 B:执行 DDL(需要 ACCESS EXCLUSIVE)
ALTER TABLE orders ADD COLUMN note TEXT;
-- 会阻塞等待,直到会话 A 提交

4.3 查看表级锁等待

SELECT
  l.locktype, l.mode,
  c.relname,
  a.pid, a.state, a.query
FROM pg_locks l
JOIN pg_class c ON c.oid = l.relation
JOIN pg_stat_activity a ON a.pid = l.pid
WHERE l.locktype = 'relation'
  AND l.granted = false;

五、锁等待与 pg_locks 分析

5.1 定位锁等待

-- 找出阻塞者与被阻塞者
SELECT
  blocked.pid AS blocked_pid,
  blocked.query AS blocked_query,
  blocking.pid AS blocking_pid,
  blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.state = 'active'
ORDER BY blocked.query_start;

pg_blocking_pids() 是 PostgreSQL 9.6+ 提供的最实用锁诊断函数,直接返回阻塞指定进程的 PID 列表。

5.2 完整锁等待链查询

-- 经典锁等待视图(含等待时间)
SELECT
  a.pid,
  now() - a.xact_start AS xact_age,
  a.state,
  a.wait_event_type,
  a.wait_event,
  a.query,
  array_length(pg_blocking_pids(a.pid), 1) AS blocked_by_count,
  pg_blocking_pids(a.pid) AS blocking_pids
FROM pg_stat_activity a
WHERE a.state = 'active'
  AND pg_blocking_pids(a.pid) IS NOT NULL
ORDER BY xact_age DESC;

5.3 pg_locks 关键字段

字段含义用途
locktyperelation/tuple/transactionid 等区分锁对象类型
mode锁模式名判断兼容性
granted是否已获得false 表示在等待
relation锁定的表 OIDjoin pg_class 定位表名
pid持锁/等锁进程join pg_stat_activity
virtualxid虚拟事务 ID事务锁归属

5.4 锁监控常用 SQL

-- 统计各类锁的数量
SELECT locktype, mode, count(*)
FROM pg_locks
GROUP BY locktype, mode
ORDER BY count(*) DESC;

-- 找出被阻塞超过 N 秒的会话
SELECT pid, state, wait_event, query
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
  AND now() - state_change > INTERVAL '5 seconds';

六、死锁检测与避免

6.1 死锁是如何发生的

两个事务各自持有一把锁,同时等待对方释放另一把锁:

-- 会话 A
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- 会话 B
BEGIN;
UPDATE accounts SET balance = balance - 10 WHERE id = 2;
-- 会话 A
UPDATE accounts SET balance = balance + 10 WHERE id = 2;  -- 等待 B
-- 会话 B
UPDATE accounts SET balance = balance + 10 WHERE id = 1;  -- 等待 A → 死锁!

6.2 死锁检测机制

PostgreSQL 后台进程周期性运行死锁检测,默认间隔 deadlock_timeout = 1s:

SHOW deadlock_timeout;  -- 默认 1s

-- 检测到死锁后,其中一个事务会收到:
-- ERROR: deadlock detected
-- DETAIL: Process 12345 waits for ShareLock on transaction 1001; blocked by process 12346.
-- Process 12346 waits for ShareLock on transaction 1002; blocked by process 12345.

6.3 避免死锁的策略

策略做法
固定加锁顺序所有事务按同一顺序(如 id 升序)更新
批量操作排序更新多条时先 ORDER BY id
缩短事务锁持有时间越短,死锁窗口越小
设置锁超时lock_timeout 防止无限等待
索引一致性通过索引定位行,避免全表锁
-- 加锁超时保护,避免无限等待
SET lock_timeout = '3s';
UPDATE accounts SET balance = balance - 1 WHERE id = 1;

6.4 死锁后的重试

死锁是业务可重试的:检测到 deadlock detected 后,回滚整个事务,稍后重试整个事务逻辑,而不是只重试最后一条语句。


七、快照陈旧与长事务(idle in transaction)

7.1 长事务的危害

一个长期未提交的事务会让它的快照保持陈旧,并阻塞死元组回收:

危害说明
表膨胀VACUUM 无法清理其快照之后的死元组
事务 ID 老化backend_xmin 停滞,回卷风险上升
锁持有行锁/表锁被长时间占用,阻塞他人
数据可见性其他事务读到不一致的"旧世界"

7.2 发现 idle in transaction

SELECT pid, state,
       now() - xact_start AS xact_age,
       now() - state_change AS idle_age,
       backend_xmin,
       wait_event_type, wait_event,
       left(query, 80) AS query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
ORDER BY xact_start ASC;

7.3 治理手段

-- 会话级超时:事务空闲超时自动回滚(PG 9.6+)
SET idle_in_transaction_session_timeout = '5min';

-- 全局生效后 reload
ALTER SYSTEM SET idle_in_transaction_session_timeout = '5min';
SELECT pg_reload_conf();

-- 紧急终止长时间空闲事务(慎用)
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND now() - state_change > INTERVAL '10 minutes';

建议:应用层务必使用连接池 + 事务超时 + finally/context 释放,数据库端再兜底 idle_in_transaction_session_timeout。

7.4 快照陈旧导致的读异常

在 REPEATABLE READ 下,长事务中的多次查询看到的是同一个快照:即使其他会话已提交 status 变更,本事务仍读到旧值。这在"事务内做对账/报表"场景极易引发业务 bug——数据看似新鲜,实则早已过期。所以报表类查询要么放在独立短事务中,要么用 READ COMMITTED。


八、重试、幂等与锁监控

8.1 事务重试框架

# Python 伪代码:处理可重试错误
RETRYABLE = {
    '40001': 'serialization_failure',   # SSI 冲突
    '40P01': 'deadlock_detected',       # 死锁
}

def with_retry(sql, max_retries=3):
    for i in range(max_retries):
        try:
            execute_in_transaction(sql)
            return
        except SqlStateError as e:
            if e.sqlstate in RETRYABLE and i < max_retries - 1:
                time.sleep(0.1 * (2 ** i))   # 指数退避
                continue
            raise

8.2 幂等设计

手段示例
唯一约束兜底订单号唯一,重复插入报 23505 后幂等返回
幂等键(Idempotency Key)请求携带 key,DB 记录已处理 key
状态机约束只有 status=‘pending’ 才能置为 ‘paid’
版本号乐观锁WHERE version = ? 影响行数为 0 即冲突

幂等键的落地做法:为幂等字段建唯一约束,插入时 ON CONFLICT (idempotency_key) DO NOTHING,影响行数为 0 即说明是重复请求,直接返回已成功。

8.3 锁监控告警规则

Prometheus 告警要点:IdleInTransactionTooLong(pg_stat_activity_idle_in_transaction_seconds > 300)、LockWaiting(pg_locks_waiting > 10)、Deadlocks(pg_stat_database_deadlocks > 0),并结合 pg_stat_statements 的 rows/total_exec_time 观察语句级变化。

8.4 事务健康检查 SQL

-- 事务与死锁统计
SELECT
  sum(xact_commit) AS commits,
  sum(xact_rollback) AS rollbacks,
  sum(deadlocks) AS deadlocks,
  round(100.0 * sum(xact_rollback) /
        GREATEST(sum(xact_commit) + sum(xact_rollback), 1), 2) AS rollback_pct
FROM pg_stat_database;

-- 当前活跃事务列表(含年龄)
SELECT pid, state, now() - xact_start AS xact_age
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start ASC;

常见问题(FAQ)

PostgreSQL 的 Repeatable Read 和 MySQL 有什么不同?

PostgreSQL 的 Repeatable Read 通过单快照 + 谓词锁机制同时防止了幻读,比 MySQL InnoDB 的 Repeatable Read 语义更严格。但两者都无法阻止写偏斜,只有 Serializable(PG 的 SSI)才拦截写偏斜。

为什么我的 UPDATE 一直卡住?

大概率是锁等待:用 pg_blocking_pids(pid) 找到阻塞者,通常是另一会话持有了同一行的 FOR UPDATE 锁,或 DDL 需要 ACCESS EXCLUSIVE。也可设置 lock_timeout 让等待快速失败。

死锁检测到之后应该怎么办?

死锁事务会被回滚并报 40P01。正确处理是:整个事务回滚 → 退避重试整个事务,而不是重试最后一条语句。同时检查应用层是否违反"固定加锁顺序"原则。

idle in transaction 的会话必须杀吗?

优先在应用层设置事务超时和连接释放,数据库端兜底 idle_in_transaction_session_timeout;紧急情况下 pg_terminate_backend(pid) 杀掉超长空闲事务。杀前确认该事务没有业务价值。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「database」更多文章

  1. 数据库安全加固与审计实战:权限最小化、加密、脱敏与合规
  2. 数据库容量规划与资源治理:从评估、监控到扩展路径
  3. 数据库字符集、排序规则与乱码实战:utf8mb4、Collation 选择与排查