PostgreSQL 导出一致性:READ COMMITTED 与 REPEATABLE READ 的差别

举报
岚枫 发表于 2026/11/07 08:53:04 2026/11/07
【摘要】 后台导出当天订单时,数据仍在写入。单条 SELECT 看到一致快照,多条语句在 READ COMMITTED 下却各看各的;把整次导出放进一个 REPEATABLE READ READ ONLY 事务,才能固定在同一快照。这篇讲清快照边界、导出 SQL,以及长事务拖住 VACUUM 的代价。

一个常见的需求:后台要导出「当天订单」,运营点一下按钮,生成 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 变量才能执行。模板之外,真正要确认的是三件事:时间范围的时区对不对、写入侧的主单与明细是不是同一个事务、这次导出会不会在主库上开太久。

(完)

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。