Chapter 06

事务与并发控制

深入理解 PostgreSQL MVCC 机制、四种隔离级别,掌握安全并发的锁策略

6.1 ACID 特性与事务命令

特性含义PostgreSQL 如何保证
Atomicity 原子性事务内的操作要么全部成功,要么全部回滚WAL + ROLLBACK 机制
Consistency 一致性事务执行前后,数据满足所有约束CHECK、FK、UNIQUE 约束在事务提交时检查
Isolation 隔离性并发事务互不干扰MVCC(多版本并发控制)
Durability 持久性提交后的数据即使崩溃也不丢失WAL 预写日志,同步刷盘
SQL-- 基本事务控制
BEGIN;                          -- 或 START TRANSACTION;

UPDATE accounts SET balance = balance - 500 WHERE id = 1;
UPDATE accounts SET balance = balance + 500 WHERE id = 2;

COMMIT;   -- 提交:两条 UPDATE 同时生效
-- 或 ROLLBACK;  -- 回滚:两条 UPDATE 都撤销

-- SAVEPOINT:事务内的检查点,允许部分回滚
BEGIN;
INSERT INTO orders (user_id, total) VALUES (1, 500);
SAVEPOINT after_order;

INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (LASTVAL(), 999, 1, 500);  -- 假设 product_id=999 不存在,违反 FK

ROLLBACK TO SAVEPOINT after_order;  -- 回滚到保存点,订单行保留

-- 继续正确操作
INSERT INTO order_items (order_id, product_id, quantity, unit_price)
VALUES (LASTVAL(), 1, 1, 500);    -- 正确的 product_id

RELEASE SAVEPOINT after_order;     -- 释放保存点(可选)
COMMIT;

6.2 MVCC 原理

MVCC(Multi-Version Concurrency Control,多版本并发控制)是 PostgreSQL 并发的核心机制。读不阻塞写,写不阻塞读,极大提升了并发性能。

🔍

MVCC 工作原理每行(Tuple)有两个隐藏系统列:xmin(创建该行的事务 ID)和 xmax(删除/更新该行的事务 ID)。当事务读取数据时,只能看到:xmin <= 自己的快照 ID 且 xmax 为空(或 xmax > 自己快照 ID)的行版本。

SQL-- 查看行的 MVCC 系统列(普通 SELECT * 不显示)
SELECT xmin, xmax, ctid, * FROM orders LIMIT 3;

-- 输出示例:
--  xmin  | xmax |  ctid  | id  | user_id | status  | total
-- -------+------+--------+-----+---------+---------+--------
--  12340 |    0 | (0,1)  | 1   |    42   | shipped | 1500.00
--  12341 |    0 | (0,2)  | 2   |    43   | pending |  300.00
--  12342 | 12350| (0,3)  | 3   |    44   | pending |  500.00
-- xmax=12350 表示该行被事务 12350 删除/更新(如果 12350 已提交,该行是死元组)

-- 查看当前活跃事务快照
SELECT txid_current();           -- 当前事务 ID
SELECT txid_current_snapshot();  -- 当前快照: xmin:xmax:xip_list

6.3 隔离级别

PostgreSQL 支持 SQL 标准定义的所有四个隔离级别:

隔离级别脏读不可重复读幻读序列化异常
Read Uncommitted可能可能可能
Read Committed(PG 默认)不可能可能可能可能
Repeatable Read不可能不可能PG 中不可能可能
Serializable不可能不可能不可能不可能
💡

PostgreSQL 的 Read Uncommitted 等价于 Read CommittedPG 的 MVCC 实现中,即使设置 Read Uncommitted,实际上也不会读到未提交数据。另外,PG 的 Repeatable Read 自然防止了幻读,因为使用的是事务开始时的快照。

SQL-- 设置事务隔离级别
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;

-- 演示 Read Committed 的不可重复读问题
-- 会话 1(Read Committed,默认):
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 结果:1000
-- (此时会话 2 执行 UPDATE accounts SET balance=800 WHERE id=1; COMMIT;)
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
-- (即使会话 2 已提交 UPDATE)
SELECT balance FROM accounts WHERE id = 1;  -- 结果:仍是 1000(快照隔离)
COMMIT;

6.4 锁机制

SQL-- SELECT FOR UPDATE:悲观锁,锁定查询到的行,阻止其他事务修改
BEGIN;
SELECT * FROM products
WHERE id = 1
FOR UPDATE;           -- 其他事务的 UPDATE/DELETE/SELECT FOR UPDATE 将等待
-- 安全地修改
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

-- FOR UPDATE SKIP LOCKED:跳过已锁定的行(任务队列场景)
BEGIN;
SELECT * FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;   -- 多个 Worker 并发取任务,互不冲突
COMMIT;

-- FOR SHARE:共享锁,允许多个事务读但阻止修改
SELECT * FROM categories FOR SHARE;

-- 查看当前锁等待情况
SELECT
  blocking.pid   AS blocking_pid,
  blocking.query AS blocking_query,
  blocked.pid    AS blocked_pid,
  blocked.query  AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

6.5 死锁检测与预防

SQL-- 经典死锁场景
-- 会话 1:  BEGIN; UPDATE accounts SET balance=balance-100 WHERE id=1;  -- 锁行1
-- 会话 2:  BEGIN; UPDATE accounts SET balance=balance-100 WHERE id=2;  -- 锁行2
-- 会话 1:  UPDATE accounts SET balance=balance+100 WHERE id=2;  -- 等待行2(被会话2持有)
-- 会话 2:  UPDATE accounts SET balance=balance+100 WHERE id=1;  -- 等待行1(被会话1持有)
-- → 死锁!PostgreSQL 自动检测并回滚其中一个事务(抛出 ERROR: deadlock detected)

-- 死锁预防策略 1:统一加锁顺序(按 ID 升序锁定)
BEGIN;
SELECT * FROM accounts
WHERE id IN (1, 2) ORDER BY id  -- 始终按 id 升序
FOR UPDATE;
-- 此时所有事务都先锁 id=1,再锁 id=2,不会形成循环等待
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

6.6 实战:并发安全的库存扣减

方案一:悲观锁(SELECT FOR UPDATE)

SQL-- 下单时扣减库存(悲观锁方案)
BEGIN;

-- Step 1:锁定商品行,防止其他并发事务同时修改
SELECT id, stock, price
FROM products
WHERE id = 1
FOR UPDATE;    -- 其他下单事务将在这里等待

-- Step 2:检查库存是否充足(在应用层或 PL/pgSQL 中做判断)
-- 假设查到 stock=10, price=9999

-- Step 3:扣减库存(CHECK 约束保证不会负数)
UPDATE products
SET stock = stock - 1, updated_at = NOW()
WHERE id = 1 AND stock >= 1  -- 双重检查
RETURNING stock;

-- 如果影响行数=0,说明库存不足(应用层判断 rowcount)
COMMIT;

方案二:乐观锁(版本号)

SQL-- 乐观锁:不阻塞读,提交时检查版本
ALTER TABLE products ADD COLUMN version INTEGER NOT NULL DEFAULT 0;

-- Step 1:读取数据(记录 version=5)
SELECT id, stock, version FROM products WHERE id = 1;

-- Step 2:提交时检查 version 是否还是 5
UPDATE products
SET
  stock   = stock - 1,
  version = version + 1,
  updated_at = NOW()
WHERE id = 1
  AND version = 5          -- 乐观锁检查:version 必须是我读到的值
  AND stock >= 1
RETURNING id;

-- 如果影响行数=0:说明有其他事务已修改(version 已变),需要重试
-- 如果影响行数=1:成功,继续

-- 乐观锁适合:并发冲突概率低的场景(读多写少)
-- 悲观锁适合:并发冲突概率高的场景(高并发秒杀库存)
📌

本章小结PostgreSQL 的 MVCC 使读写互不阻塞:读取看到的是事务开始时的快照(READ COMMITTED 是语句开始时)。事务的 4 个隔离级别中,PG 默认 Read Committed,Repeatable Read 自动防幻读。SELECT FOR UPDATE 是悲观锁的核心,SKIP LOCKED 实现并发任务队列。乐观锁通过版本号实现,冲突时重试,适合低冲突场景。死锁预防的最简单方法:始终按固定顺序加锁。