PostgreSQL 导出一致性:READ COMMITTED 与 REPEATABLE READ 的差别
一个常见的需求:后台要导出「当天订单」,运营点一下按钮,生成 CSV;与此同时,业务侧还在正常下单。
导出代码通常长这样:先查订单主表,再查订单明细,最后算一个汇总。三步各自一条 SQL。开发环境数据少、并发低,跑起来没问题;上线后偶尔出现「列表里只有 98 单,汇总却是 100 单」,或者「明细里少了刚下的那几单」。
这不是导出代码的算术错,而是三条 SQL 看到了三个不同时刻的数据库。
先交代依据:本文依据 PostgreSQL 官方文档与 pg_dump 源码核对,未本机启动实例,也没有跑过真实数据库。下面关于隔离级别、锁模式、报错文本和 pg_dump 的说法,都逐条对着官方文档核过;transaction_timeout 按 17 的发布说明确认过,pg_dump 那一节还对了主干源码里它实际发出的语句。文中「98 单对 100 单」和几个具体时刻是示意场景,不是实测输出。
MVCC 让读不阻塞写
PostgreSQL 的每一行有多个版本。行的可见性由两个隐藏字段决定:
xmin:创建这个版本的事务;xmax:删除或更新这个版本的事务。
普通 SELECT 不申请行锁,只拿一个快照,按快照判断哪个版本可见。UPDATE 和 DELETE 生成新版本、标记旧版本,也不会阻塞读。读写因此可以并行,不需要互相等待。
但「读不阻塞写」不等于「读到的数据永远一致」。一致性取决于两件事:快照是什么时候取的,以及一次导出里用了一个还是多个快照。
另外,读写互相等待仍有几个来源,导出路径上要主动避开:
SELECT ... FOR UPDATE/FOR SHARE一类的加锁读,会锁住读到的行,后续对这些行的UPDATE、DELETE要等它;- 显式
LOCK TABLE; - DDL 和维护操作需要的表锁:多数
ALTER TABLE要ACCESS EXCLUSIVE,非并发的CREATE INDEX要SHARE,都会挡住写入。
导出是只读业务,不应该出现上面任何一种。
READ COMMITTED:每条语句一个快照
PostgreSQL 默认的隔离级别是 READ COMMITTED。它的规则是:每条语句开始时取一次快照。
一条 SELECT 在执行期间用同一个快照,所以导出期间新建并提交的订单不会出现在这条语句的结果里,也不会阻塞写入。这一点经常被误解成「MVCC 保证导出是一致的」——它只保证这一条语句一致。
如果导出由多条语句组成:
SELECT id, user_id, amount, created_at
FROM orders
WHERE created_at >= $1 AND created_at < $2;
SELECT order_id, sku, qty
FROM order_items
WHERE order_id = ANY($3);
SELECT count(*), sum(amount)
FROM orders
WHERE created_at >= $1 AND created_at < $2;
三条语句各取各的快照。第一条跑在 10:00:00,第三条跑在 10:00:05,中间提交的订单会进第三条,却不在第一条的结果里;明细按第一批拿到的订单 id 查,只能覆盖列表里的单,后两单的明细自然不会出现。列表、明细、汇总对不上,根源在这里。
(这里的 $1、$2 是时间范围参数,$3 是第一批查到的订单 id 数组;它们由驱动传入,不是 psql 里能直接执行的写法。)
单条 SQL 导出:已经是一致的
如果整次导出就是一条 SQL,事情简单得多:
SELECT id, user_id, amount, created_at
FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-10-27 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-10-28 00:00:00+08';
这条语句在开始时取一个快照,执行期间不会看到之后提交的新订单;同时它不持有行锁,业务侧创建订单不会被它挡住。
时间范围有两个细节。
第一,用半开区间,不要用 ::date。
-- 不推荐
WHERE created_at::date = current_date
把列包进表达式之后,普通的 btree 索引用不上(除非另建表达式索引);列是 timestamptz 时,「当天」还取决于会话的 TimeZone,容易和业务日界对不上。写成 >= 当天 00:00 AND < 次日 00:00,既是半开区间,也能让索引正常工作。
第二,边界显式带时区,范围用常量或参数。
WHERE created_at >= TIMESTAMPTZ '2026-10-27 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-10-28 00:00:00+08'
不要依赖服务器默认时区,也不要在多条自动提交的语句里各自算 now():范围一旦在导出中途变化,前面查过的数据和后面查的数据就落在了不同区间。即使放进同一个事务,显式常量也更容易和业务日界对齐。
这套写法还有一个前提:created_at 本身是 timestamptz。如果列是 timestamp(不带时区),拿它和带时区的字面量比较时,列值会先按会话的 TimeZone 解释,+08 就和业务日界对不上了。列类型不对,先改列类型,再谈字面量。
多条 SQL:用 REPEATABLE READ 固定快照
只要导出逻辑不止一条查询(主表 + 明细 + 汇总),就要显式开事务:
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
-- 省略了列清单,只保留筛选条件
SELECT ... FROM orders WHERE created_at >= $1 AND created_at < $2;
SELECT ... FROM order_items WHERE order_id = ANY($3);
COMMIT;
REPEATABLE READ 下,事务里所有语句共用第一条非事务控制语句建立的快照。注意这个「第一条」:BEGIN、SET TRANSACTION 不算,第一条 SELECT 才算。如果导出前顺手跑了一条 SELECT 1 做连接检查,快照就在那里建立了;后面真正导出的数据,看到的是那个更早的时刻。
加 READ ONLY 是防御性的:导出代码不应该写库,按文档,INSERT、UPDATE、DELETE、MERGE 以及写普通表的 COPY FROM 都会被拒绝,CREATE、ALTER、DROP 这类命令也在拒绝之列,所以连 CREATE TEMP TABLE 也做不了;已经存在的临时表仍然可写,因为文档的措辞是「写入的对象不是临时表时才拒绝」。这组规则是按文档核对的,本机没有实例可验。它也让事务的身份更清楚:这是一个只读事务,不是要改数据的事务。
两个可以确认当前状态的命令:
SHOW default_transaction_isolation; -- 默认隔离级别
SHOW transaction_isolation; -- 当前事务的实际级别(要在 BEGIN 之后执行)
还有一个常见担心:REPEATABLE READ 会不会因为序列化冲突被中止?分两种情况。纯只读的 REPEATABLE READ 事务不会:它不写任何行,不会和别的写事务产生更新冲突。如果导出事务里混进了写操作,REPEATABLE READ 在更新一行已被其他事务改过并提交的数据时,会报 could not serialize access due to concurrent update(SQLSTATE 40001)——这是「先更新者胜」,和 SSI 无关。这句报错文本和状态码是对着文档核的,本机没能复现。
SERIALIZABLE 特有的 SSI 冲突(读写依赖成环)是另一类 40001,只读的 SERIALIZABLE 事务也可能被它中止。文档在这里说得很细:只读事务往往能在开始时就确认自己引不出序列化异常,从而一个谓词锁都不加;没能在开始时确认成的,仍然可能被中止,所以不能把「只读」直接当成「不会失败」。DEFERRABLE 不是免死金牌,它的作用是让事务先等到一个确定安全的时间点再开始读,代价是可能阻塞,文档也写明这是唯一一种 SERIALIZABLE 会等而 REPEATABLE READ 不会等的情况。导出用的是 REPEATABLE READ,不涉及 SSI,这两类冲突都用不上。
快照只保证「同一时刻」,不保证业务完整
这一点容易被忽略:快照能保证「所有查询看到同一个快照时刻的数据」,但不能保证「业务上应该在一起的数据一定在一起」。
如果订单主单和明细是分两个事务提交的:
T1: 插入订单主单,提交
T2: 插入订单明细,提交
导出快照落在 T1 和 T2 之间,就会看到「主单在、明细还没来」。调隔离级别解决不了这个问题——它属于写入侧的事务边界问题:主单和明细应该放在同一个事务里提交。
同理,如果导出依赖某个「状态字段」的中间值,而状态是分步更新的,快照可能落在中间状态。导出的一致性,前提是写入侧先把业务上的原子性定义清楚。
pg_dump 也是同一个机制
pg_dump 的一致性不是特殊魔法:它开一个 REPEATABLE READ 只读事务,再对每张表拿 ACCESS SHARE 锁。这一条对着源码确认过:它先 BEGIN,紧接着发的是 SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ ONLY;只有带 --serializable-deferrable 时才换成 SERIALIZABLE, READ ONLY, DEFERRABLE。ACCESS SHARE 只和 ACCESS EXCLUSIVE 冲突,所以它挡 DDL(ALTER TABLE、DROP TABLE),不挡 INSERT、UPDATE、DELETE,备份期间业务可以继续写。锁一直持有到备份结束,所以备份跑得越久,这段时间里想改表结构的 DDL 就等得越久。
并行导出时,pg_dump 在主连接上调用 pg_export_snapshot() 拿到快照标识,再把它交给各个工作连接;工作连接先进入 REPEATABLE READ READ ONLY 事务,然后执行 SET TRANSACTION SNAPSHOT,于是看到的是主连接那个时刻的数据。这里要把默认路径和可选路径分开说:默认的主连接是 REPEATABLE READ, READ ONLY,不是 SERIALIZABLE;只有显式加上 --serializable-deferrable,主连接才换成 SERIALIZABLE, READ ONLY, DEFERRABLE,工作连接仍然以 REPEATABLE READ 接收这个快照。把「并行」和「可序列化」当成同一件事,是这一段最常见的误读。
这也说明了一件事:快照可以跨连接共享。前提是接收方也用 REPEATABLE READ 或 SERIALIZABLE,并且在第一条查询或写语句之前导入快照;接收方若是 READ COMMITTED,快照会被立刻丢弃,因为这个级别每条语句都重新取快照。方向要看清:文档的限制是「导入方声明 SERIALIZABLE 时,导出方也必须用 SERIALIZABLE」,而不是反过来;SERIALIZABLE 导出的快照可以被 REPEATABLE READ 的连接接收,--serializable-deferrable 与并行导出同时打开时走的正是这条组合。另外还有一条容易踩:非只读的 SERIALIZABLE 事务不能导入只读事务导出的快照(文档原句:a non-read-only serializable transaction cannot import a snapshot from a read-only transaction,核对自 PostgreSQL 18 文档 SET TRANSACTION 一节)。以上关于隔离级别、导出与导入快照的规则,都是对着官方文档和 pg_dump 源码核对的;本机没有实例,未做并行导出的实际运行。
代价:长事务会拖住 VACUUM
MVCC 的读不阻塞写是有代价的:只要还有一个快照可能看到旧版本,VACUUM 就不能回收这些旧版本。
导出期间业务仍在更新,长快照让 VACUUM 无法回收这些更新留下的死元组,orders 和 order_items 就越容易膨胀,后续查询也会变慢。导出本身可能只跑几分钟,但它开着一个长事务,影响的是整个数据库的清理节奏。
几个实用的约束:
- 导出尽量走只读副本或离线库;在主库上做,也要控制时长;
- 设置
statement_timeout限制单条查询,设置idle_in_transaction_session_timeout清掉挂起的空闲事务;要限制整个事务的总时长,PostgreSQL 17 起还有transaction_timeout(这一条在 17 的发布说明里能查到,本机没有实例试它的实际效果); - 监控长事务(下面这些列名对着
pg_stat_activity的文档核过,未在本机执行):
SELECT pid, state, xact_start,
now() - xact_start AS age,
query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
AND backend_type = 'client backend'
AND pid <> pg_backend_pid()
ORDER BY age DESC;
- 大批量导出按主键分段提交,每段一个短事务。这会放弃「全局同一快照」:分段之间新提交的数据可能被后一段看到。能不能接受,要按业务确认,不能默认。
如果既要全局一致、又不想让客户端长时间持有快照,另一条路是:在一个普通(READ WRITE)事务里把结果 CREATE TEMP TABLE ... AS SELECT,提交后客户端再对临时表分页读取。注意这只是把「客户端读取时间」从快照事务里挪出去:拷贝本身仍然要读完整个结果集,快照事务的时长约等于拷贝时长。临时表只在当前连接可见,连接断开就没了;数据量大时,这还是一次额外的写入和存储开销。READ ONLY 事务里连 CREATE TEMP TABLE 也会被拒绝,所以这一步必须单独开一个可写事务。还有一条要先想清楚:这条路和上面「导出尽量走只读副本」是冲突的,文档写明只读副本上的连接是严格只读的,临时表也写不了,显式声明读写模式的事务同样会报错,所以这个方案只在可写实例上成立。
回到「导出当天订单」这个场景
- 导出用一条 SQL,或一个
REPEATABLE READ READ ONLY事务:看到的是第一条查询时建立的快照,之后新建并提交的订单不会混进来,也不会阻塞下单; - 导出用多条语句、却留在默认
READ COMMITTED:不同语句看到各自快照时刻的数据,列表、明细、汇总可能互相矛盾; - 导出用了
SELECT ... FOR UPDATE:读变成了加锁读,会锁住读到的行,后续对这些行的UPDATE、DELETE要等它,这正是要避免的做法; - 导出事务里做了 DDL:
ACCESS EXCLUSIVE表锁会把写入挡住,导出越慢,业务停得越久。这一条只在可写事务里成立,事务声明了READ ONLY时CREATE、ALTER、DROP会被直接拒掉,根本走不到拿锁那一步。
一句话:读不阻塞写靠的是 MVCC;多条语句的导出一致性,要靠显式把整次导出放进一个 REPEATABLE READ READ ONLY 事务。单条 SQL 用默认隔离级别就够。 这句话只覆盖快照边界,业务上的完整还得靠写入侧把主单和明细放进同一个事务,否则同一个快照照样可能只看到一半。
一份可用的导出模板
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET LOCAL statement_timeout = '10min'; -- 限制单条语句,不是整个导出
-- 只查导出需要的数据,时间范围用半开区间 + 显式时区
SELECT id, user_id, amount, created_at
FROM orders
WHERE created_at >= TIMESTAMPTZ '2026-10-27 00:00:00+08'
AND created_at < TIMESTAMPTZ '2026-10-28 00:00:00+08';
-- $1 是上一步查到的订单 id 数组
SELECT order_id, sku, qty
FROM order_items
WHERE order_id = ANY($1);
COMMIT;
模板里的 $1 是驱动占位符(node-postgres、psycopg 之类),在 psql 里要换成具体值或 \set 变量才能执行。模板之外,真正要确认的是三件事:时间范围的时区对不对、写入侧的主单与明细是不是同一个事务、这次导出会不会在主库上开太久。
(完)
- 点赞
- 收藏
- 关注作者
评论(0)