10 - 事务与锁
第 10 章 · 事务与锁
事务是关系型数据库的基石。本章深入讲解 ACID 原理、隔离级别、MVCC 实现、锁机制和死锁处理。
10.1 ACID 原则
| 原则 | 全称 | 含义 | PG 实现机制 |
|---|
| A | Atomicity 原子性 | 事务全部成功或全部回滚 | CLOG 事务状态 |
| C | Consistency 一致性 | 数据满足所有约束 | 约束检查(CHECK/UNIQUE/FK) |
| I | Isolation 隔离性 | 并发事务互不干扰 | MVCC + 锁 |
| D | Durability 持久性 | 提交后数据不丢失 | WAL + fsync |
10.2 事务基本操作
-- 显式事务
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 回滚
BEGIN;
DELETE FROM important_data WHERE id = 42;
ROLLBACK; -- 数据恢复
-- SAVEPOINT(保存点)
BEGIN;
INSERT INTO orders (customer_id, total) VALUES (1, 100);
SAVEPOINT sp1;
INSERT INTO order_items (order_id, product_id, qty) VALUES (1, 10, 5);
ROLLBACK TO sp1; -- 只回滚到保存点,订单仍在
INSERT INTO order_items (order_id, product_id, qty) VALUES (1, 20, 3);
COMMIT;
-- 自动提交
-- psql 默认每条语句自动提交
-- 关闭自动提交:\set AUTOCOMMIT off
10.3 隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 序列化异常 |
|---|
| READ UNCOMMITTED | ✅ 可能 | ✅ 可能 | ✅ 可能 | ✅ 可能 |
| READ COMMITTED(PG 默认) | ❌ 不会 | ✅ 可能 | ✅ 可能 | ✅ 可能 |
| REPEATABLE READ | ❌ 不会 | ❌ 不会 | ❌ 不会 | ✅ 可能 |
| SERIALIZABLE | ❌ 不会 | ❌ 不会 | ❌ 不会 | ❌ 不会 |
⚠️ 注意:PostgreSQL 中,即使设置 READ UNCOMMITTED,行为也等同于 READ COMMITTED(不会读到脏数据)。这是因为 MVCC 的天然优势。
实际演示
-- READ COMMITTED(默认)
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM accounts WHERE id = 1; -- 假设读到 1000
-- 此时另一个事务把 balance 改为 800 并提交
SELECT balance FROM accounts WHERE id = 1; -- 读到 800(不可重复读)
COMMIT;
-- REPEATABLE READ
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1; -- 读到 1000
-- 此时另一个事务把 balance 改为 800 并提交
SELECT balance FROM accounts WHERE id = 1; -- 仍然读到 1000(快照)
-- 但如果尝试修改这行,会报错:could not serialize access due to concurrent update
COMMIT;
-- SERIALIZABLE
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 最强隔离,任何序列化异常都会回滚
-- 适合银行转账等场景
COMMIT;
隔离级别选择指南
| 场景 | 推荐隔离级别 |
|---|
| 一般业务 | READ COMMITTED(默认,够用) |
| 需要一致性快照 | REPEATABLE READ |
| 金融/转账 | SERIALIZABLE |
| 批量报表 | REPEATABLE READ |
| 高并发 OLTP | READ COMMITTED |
10.4 MVCC 实现细节
可见性规则
每行有 xmin(插入事务 ID)和 xmax(删除事务 ID)。可见性判断:
行对当前事务可见,当且仅当:
1. xmin 已提交
2. xmax 未设置(为 0)或 xmax 未提交或 xmax 在当前快照之后
-- 查看行的事务信息
SELECT xmin, xmax, ctid, id, name FROM employees;
-- xmin | xmax | ctid | id | name
-- 1000 | 0 | (0,1) | 1 | Alice ← 活跃(xmax=0)
-- 1000 | 1001 | (0,2) | 2 | Bob ← 被事务 1001 删除
-- 1002 | 0 | (0,3) | 3 | Charlie ← 活跃
-- 查看事务 ID 信息
SELECT txid_current(); -- 当前事务 ID
SELECT txid_current_snapshot(); -- 当前快照
HOT Update(Heap-Only Tuple)
-- 如果更新的列不涉及索引列,且新行在同一页面,PG 使用 HOT Update
-- HOT Update 不需要更新索引,性能更好
-- 设计建议:频繁更新的列和索引列分开
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id INT NOT NULL, -- 索引列(不频繁更新)
status SMALLINT DEFAULT 0, -- 频繁更新的列
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_orders_customer ON orders (customer_id);
-- 更新 status 和 updated_at 不影响 customer_id 索引 → HOT Update
10.5 锁机制
行级锁
| 锁类型 | SQL 语法 | 冲突 | 用途 |
|---|
| FOR UPDATE | SELECT ... FOR UPDATE | 其他 FOR UPDATE | 更新行 |
| FOR NO KEY UPDATE | SELECT ... FOR NO KEY UPDATE | FOR UPDATE | 更新非键列 |
| FOR SHARE | SELECT ... FOR SHARE | FOR UPDATE | 读取并阻止修改 |
| FOR KEY SHARE | SELECT ... FOR KEY SHARE | FOR NO KEY UPDATE | 外键检查 |
-- 锁定行,防止并发修改
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- 其他会话尝试修改这行会阻塞
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- NOWAIT:如果无法获取锁,立即报错
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- SKIP LOCKED:跳过已锁定的行(队列场景)
SELECT * FROM tasks WHERE status = 'pending' ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED;
表级锁
| 锁模式 | 说明 | 冲突 |
|---|
| ACCESS SHARE | SELECT | ACCESS EXCLUSIVE |
| ROW SHARE | SELECT FOR UPDATE/SHARE | EXCLUSIVE, ACCESS EXCLUSIVE |
| ROW EXCLUSIVE | INSERT/UPDATE/DELETE | SHARE, SHARE ROW EXCLUSIVE, EXCLUSIVE, ACCESS EXCLUSIVE |
| SHARE UPDATE EXCLUSIVE | VACUUM, 某些 ALTER | SHARE UPDATE EXCLUSIVE 以上 |
| SHARE | CREATE INDEX | ROW EXCLUSIVE 以上 |
| SHARE ROW EXCLUSIVE | — | ROW EXCLUSIVE 以上 |
| EXCLUSIVE | — | 大部分 |
| ACCESS EXCLUSIVE | ALTER TABLE, DROP TABLE, TRUNCATE | 所有锁 |
-- 查看当前锁
SELECT
l.locktype, l.mode, l.granted,
a.pid, a.usename, a.query, a.state
FROM pg_locks l
JOIN pg_stat_activity a ON l.pid = a.pid
WHERE l.relation IS NOT NULL
ORDER BY l.relation, l.mode;
-- 查看锁等待关系
SELECT
blocked_locks.pid AS blocked_pid,
blocked_activity.usename AS blocked_user,
blocking_locks.pid AS blocking_pid,
blocking_activity.usename AS blocking_user,
blocked_activity.query AS blocked_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.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_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;
10.6 死锁
死锁场景
事务 A: UPDATE t SET x=1 WHERE id=1; -- 锁住行 1
事务 B: UPDATE t SET x=2 WHERE id=2; -- 锁住行 2
事务 A: UPDATE t SET x=1 WHERE id=2; -- 等待行 2 → 阻塞
事务 B: UPDATE t SET x=2 WHERE id=1; -- 等待行 1 → 死锁!
PostgreSQL 自动检测死锁(默认 1 秒),回滚其中一个事务:
ERROR: deadlock detected
DETAIL: Process 1234 waits for ShareLock on transaction 5678; blocked by process 5678.
Process 5678 waits for ShareLock on transaction 1234; blocked by process 1234.
HINT: See server log for query details.
预防死锁
-- 策略 1:按固定顺序访问表/行
-- 总是按 id 升序处理
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- 策略 2:使用 NOWAIT 或超时
SET lock_timeout = '5s';
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- 策略 3:减少事务持有时间
-- 不要在事务中做外部 API 调用
10.7 Advisory Lock(咨询锁)
-- 会话级咨询锁
SELECT pg_advisory_lock(12345); -- 获取锁(阻塞式)
SELECT pg_try_advisory_lock(12345); -- 尝试获取(非阻塞)
SELECT pg_advisory_unlock(12345); -- 释放锁
-- 事务级咨询锁(事务结束自动释放)
SELECT pg_advisory_xact_lock(12345);
SELECT pg_try_advisory_xact_lock(12345);
-- 适用场景:
-- 1. 分布式锁
-- 2. 防止并发任务重复执行
-- 3. 速率限制
10.8 VACUUM 与事务 ID 回卷
-- 事务 ID 是 32 位无符号整数,约 20 亿个
-- 超过后会回卷(wraparound),导致旧数据丢失
-- 查看事务 ID 消耗
SELECT
datname,
age(datfrozenxid) AS xid_age,
2147483647 - age(datfrozenxid) AS remaining
FROM pg_database;
-- VACUUM FREEZE 防止回卷
VACUUM FREEZE mytable;
-- autovacuum 会自动执行防回卷 VACUUM
-- 确保 autovacuum 正常运行!
⚠️ 注意事项:如果某个表的 age(relfrozenxid) 接近 20 亿,数据库会强制关闭以防止数据损坏。必须确保 autovacuum 正常运行。
业务场景
| 场景 | 推荐方案 |
|---|
| 转账 | SERIALIZABLE + SELECT FOR UPDATE |
| 库存扣减 | SELECT FOR UPDATE NOWAIT + 乐观锁 |
| 任务队列 | SELECT FOR UPDATE SKIP LOCKED |
| 防重复执行 | pg_advisory_lock |
| 长事务 | 限制 idle_in_transaction 会话超时 |
扩展阅读