PostgreSQL 事务与隔离级别
并发改数据不加控制就会脏读/幻读/丢更新。本文讲清 ACID 与四种隔离级别(读未提交/读已提交/可重复读/串行化)、PG 的 MVCC 实现、每种隔离下的并发现象、以及该选哪个级别——默认 Read Committed 够,强一致才升 Serializable。
本文要点
- 主题聚焦:DevOps
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
PostgreSQL 事务与隔离级别
并发是数据库的常态,也是 Bug 的温床。两个事务同时改一行、一个读一个写、一个统计一个插入——只要不加控制,就会冒出脏读、幻读、丢失更新这些让人半夜被叫醒的问题。
PostgreSQL 在这件事上做得很克制也很硬核:默认 Read Committed 够用,需要强一致就升 Serializable,中间还有个被低估的 Repeatable Read。要选对级别,得先搞清楚每种隔离到底防住了什么、防不住什么,以及 PG 的 MVCC 是怎么实现的。
一、并发事务不加控制,到底会出什么问题
先看四种经典的并发异常。理解了它们,隔离级别就是一份"防什么、不防什么"的清单。
1. 脏读(Dirty Read):事务 A 读到了事务 B 还没提交的数据。如果 B 回滚,A 就读到了一条根本不存在的数据。这是最严重的错误,几乎所有数据库都默认防住。
2. 不可重复读(Non-repeatable Read):事务 A 两次读同一行,中间事务 B 修改并提交了这行,两次结果不一样。强调的是"同一行被改"。
3. 幻读(Phantom Read):事务 A 两次执行同一个范围查询,中间事务 B 插入/删除了符合条件的行,第二次多出或少了"幻影"行。强调的是"行的集合变了"。
4. 丢失更新(Lost Update):事务 A 和 B 都基于同一个旧值算出新值再写回,后写的覆盖先写的,A 的更新被悄悄丢了。经典的 balance = balance - 100 并发扣款、库存扣减、计数器自增都踩过这个坑。
这四个问题里,前三个是"读"的可见性问题,第四个是"写"的冲突问题。隔离级别主要解决前三者,丢失更新往往要额外手段。
二、ACID 的 I,就是解决并发可见性
ACID 里:A(原子性)靠 undo/redo 日志,D(持久性)靠 WAL 落盘,C(一致性)是数据库要守的约束。而 I(Isolation 隔离性),解决的是"多个并发事务互相之间能看到什么"。
隔离性是个光谱:完全串行执行(一个事务跑完再跑下一个)隔离性最强,但性能不可接受;完全不加隔离,性能最好但数据会乱。SQL 标准定义了四级隔离,本质是在"一致性"和"并发度"之间做权衡——级别越高,防住的异常越多,但并发性能代价越大。
关键认知:隔离级别是事务"看世界的快照"的规则,而不是锁的开关。理解这点,MVCC 就顺理成章了。
三、四种隔离级别:各自允许的并发现象
SQL 标准定义的四级隔离,允许的异常如下(标准视角):
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| Read Uncommitted(读未提交) | 可能 | 可能 | 可能 |
| Read Committed(读已提交) | 不可能 | 可能 | 可能 |
| Repeatable Read(可重复读) | 不可能 | 不可能 | 可能 |
| Serializable(串行化) | 不可能 | 不可能 | 不可能 |
读起来像绕口令,但有规律:每升一级,就多防住一种异常。最低级什么都可能发生,最高级什么都不允许。但注意——这是 SQL 标准,不是 PostgreSQL 的实际行为。PG 有自己的实现取舍,下面会讲。
四、PG 的 MVCC:多版本并发控制
PostgreSQL 用 MVCC(Multi-Version Concurrency Control)实现隔离,核心思想是:每行数据保留多个版本,读操作读自己快照里的旧版本,写操作新建版本,读不阻塞写、写不阻塞读。
每行在磁盘上(其实是 tuple header 里)有两个关键的事务 ID:
xmin:插入/创建这行的事务 IDxmax:删除/更新这行的事务 ID(没被删就是 0)
一个事务在读某行时,会根据自己的快照,判断 xmin/xmax 对自己是否"可见":这行的版本是不是在我开始之前就提交了?是不是在我之后才删除的?可见就读,不可见就跳过,顺着版本链找下一个可见版本。
更新不是 in-place 改,而是把旧行标记为 xmax,再插入一个新行(新的 xmin)。所以 PG 的 UPDATE 本质是 DELETE + INSERT,这是为什么频繁更新大表会膨胀,需要 VACUUM 来清理"死元组"。
MVCC 的好处巨大:读写互不阻塞。长查询不会卡住写入,写入也不会因为有人 SELECT 就等待。代价是版本堆积和需要 VACUUM,但这是值得的交换。
五、PG 的"任性":RU 等于 RC,RR 已经防幻读
PG 的隔离实现和 SQL 标准有两处重要差异,这是面试常考、实战常踩的点:
差异一:Read Uncommitted 在 PG 中等同 Read Committed。 PG 根本不允许脏读——你设 READ UNCOMMITTED,PG 内部当成 READ COMMITTED 跑。文档原话:PostgreSQL 不允许脏读。所以最低也是读已提交,这一点对开发者是好事。
差异二:Repeatable Read 在 PG 中已经防住了幻读。 PG 的 RR 用的是 SI(Snapshot Isolation)——事务开始时拍一张快照,整个事务期间都看这张快照,不看到别的会话提交的新行。所以 PG 的 RR 下,同一事务两次范围查询结果完全一致,标准里"可能"的幻读在 PG 里"不可能"。
那 PG 的 RR 防不住什么?写偏序(Write Skew) 这种序列化异常。两个事务各自基于快照读到一致的状态,各自做合规的修改,但合并起来违反了业务约束。
经典例子:值班规则要求"至少一人在岗"。A 和 B 两人都在岗,A 事务看到 B 在岗,把自己设置为离岗;B 事务看到 A 在岗,把自己设置为离岗。两个都提交成功,结果没人值班。RR 下防不住,因为两个事务写的不是同一行(没有写冲突),MVCC 检测不到。
所以 PG 的实际并发现象表是这样:
| PG 隔离级别 | 脏读 | 不可重复读 | 幻读 | 序列化异常 |
|---|---|---|---|---|
| Read Uncommitted | 不可能 | 可能 | 可能 | 可能 |
| Read Committed | 不可能 | 可能 | 可能 | 可能 |
| Repeatable Read | 不可能 | 不可能 | 不可能 | 可能 |
| Serializable | 不可能 | 不可能 | 不可能 | 不可能 |
这张表才是你在 PG 里真正要记的。
六、Serializable:PG 用 SSI 实现真串行化
SERIALIZABLE 是最强隔离,PG 9.1+ 用 SSI(Serializable Snapshot Isolation)实现。它不仅看快照,还追踪事务之间的读写依赖,运行时检测是否存在"危险的依赖结构"——如果发现可能导致序列化异常的执行序列,就直接中止其中一个事务,抛出 could not serialize access due to read/write dependencies 错误。
强是一定强,但代价明确:
- 性能开销:SSI 要维护谓词锁(SIREAD)和依赖图,高并发写场景吞吐显著下降。
- 必须重试:你的代码要准备好捕获
SQLSTATE 40001,回滚整个事务从头再来。用 ORM 或应用层不显式重试,上线就是 500 报错。
设置方式:
SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- 或单事务
BEGIN ISOLATION LEVEL SERIALIZABLE;
-- 业务 SQL...
COMMIT; -- 失败则捕获异常重试整个 BEGIN...COMMIT
实战铁律:Serializable 用对了天下无敌,用错了就是慢 + 报错。除非你的业务真的要求强一致(金融对账、库存强约束),否则别无脑上最高级。
七、选哪个级别:默认 Read Committed 够多数场景
我的建议很直白:
- 绝大多数业务用默认的 Read Committed。 PG 默认就是它,Web 应用、CRUD、列表查询、统计报表都够。单条语句看到的是语句开始时的一致快照,不会脏读,够用。
- 需要事务内多次读一致(导出、报表、对账),用 Repeatable Read。 它给你快照一致性,且不会像 Serializable 那样报错重试。注意它防不住写偏序。
- 金融、库存、关键一致约束,用 Serializable 或显式锁。 要么上 Serializable + 应用层重试,要么用
SELECT ... FOR UPDATE显式加锁,二选一。 - 别无脑升最高级。 Serializable 在高并发写场景的吞吐代价真实存在,而且需要重试逻辑配套。如果团队没准备好重试,升级就是引入故障。
切换隔离级别:
-- 全局默认
ALTER DATABASE mydb SET default_transaction_isolation = 'read committed';
-- 当前会话
SET session characteristics as transaction isolation level repeatable read;
八、丢失更新的防护:SELECT FOR UPDATE 与乐观锁
隔离级别主要管"读的可见性",但丢失更新在任何级别(除 Serializable)都可能发生,需要单独防护。两种主流方案:
方案一:悲观锁 —— SELECT ... FOR UPDATE。 读的时候就把行锁住,别的事务只能等。
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- 锁住这行
-- 应用层基于读到的 balance 计算新值
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
并发的事务会在 SELECT FOR UPDATE 处排队,串行化对同一行的更新。简单可靠,缺点是并发度高时会等。
方案二:乐观锁 —— version 列 + CAS。 给表加一列 version,更新时带上条件,失败就重试。
-- 表带 version 列
ALTER TABLE accounts ADD COLUMN version INT DEFAULT 0;
-- 更新时做条件判断
UPDATE accounts
SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = :read_version;
-- affected_rows = 0 说明被别人改过,重试整个读-算-写
乐观锁适合冲突少的场景(读多写少),无锁等待吞吐高;冲突多了重试成本也高。悲观锁适合冲突多的场景。两者没有银弹,看你的冲突率。
PG 9.1+ 还有个增强版 SELECT ... FOR NO KEY UPDATE,对非主键列加锁,不影响别的会话查主键,锁粒度更细,值得了解。
小结
- 并发异常有四种:脏读、不可重复读、幻读是读可见性问题;丢失更新是写冲突问题。隔离级别主要管前三者,丢失更新要单独防护。
- PG 的实际隔离表和 SQL 标准不一样:Read Uncommitted = Read Committed(PG 不脏读);Repeatable Read 用 SI,已经防住幻读,但防不住写偏序。
- MVCC 是 PG 隔离的根基:每行有
xmin/xmax,读自己的快照,读写互不阻塞。代价是版本膨胀需要 VACUUM。 - Serializable 用 SSI,强但贵:要准备好捕获
40001错误并重试整个事务,否则别碰。 - 默认 Read Committed 够用,对账用 Repeatable Read,强一致约束才上 Serializable 或显式锁。丢失更新用
SELECT FOR UPDATE(悲观)或 version 列(乐观)兜底。
金句:隔离级别不是越高越好,是越合适越好。默认 Read Committed 是 PG 程序员的安全感来源,Serializable 是带着重试逻辑才配上的奢侈品。
Related
相关推荐
文章
Docker 部署 Next.js 社区项目的注意事项
围绕 DevOps、Next.js 的技术趋势、工程实践和独立开发视角。
文章
OpenTelemetry 如何帮助你观察 AI 应用?
围绕 DevOps、AI 的技术趋势、工程实践和独立开发视角。
文章
长运行工具调用:Operation、Poll 与 Cancel 的终态协议
初始请求超时后,工具可能仍在服务端运行;发出取消也不等于副作用已停止。本文为单次长运行工具调用建立 operation 身份、轮询水位、取消竞态和唯一终态接受点。
文章
Agent 记忆删除传播:从用户遗忘请求到可验证清除
删除主表一行不会清掉向量、摘要、缓存和恢复后的备份。本文用删除状态机、代际墓碑、派生血缘与端到端 canary 验证真正遗忘。