事务是数据库正确性的最后一道防线。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 SHARE | FOR SHARE | FOR NO KEY UPDATE | FOR 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 关键字段
| 字段 | 含义 | 用途 |
|---|---|---|
locktype | relation/tuple/transactionid 等 | 区分锁对象类型 |
mode | 锁模式名 | 判断兼容性 |
granted | 是否已获得 | false 表示在等待 |
relation | 锁定的表 OID | join 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) 杀掉超长空闲事务。杀前确认该事务没有业务价值。
相关阅读
- PostgreSQL 详解 — MVCC、WAL、索引类型
- PostgreSQL VACUUM 与表膨胀治理 — 长事务对死元组回收的影响
- PostgreSQL 查询优化实战 — EXPLAIN、慢查询治理
- PostgreSQL 监控与诊断体系 — 锁等待与长事务告警
- PostgreSQL 高可用与备份 — 流复制与故障转移
- PostgreSQL 专题导航
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。