☰
PostgreSQL 14 Tid Range Scan:基于物理位置的扫描优化
2026/10/20 8:30:50 网站建设 项目流程

搞数据库的人应该都有过这样的经历:明明要处理的数据就“躺”在那张表里,可就是拿不出来,或者说拿得不够快。尤其当面对几千万行的大表时,SELECT ... WHERE ctid BETWEEN ...这种写法或CREATE TABLE AS批量搬迁数据,慢得让人怀疑机器是不是坏了。PostgreSQL 14 里引入的Tid Range Scan,就是在想办法把这条路走快一点。

简单说,Tid Range Scan 是 PostgreSQL 14 新增的一种扫描执行节点,它允许优化器生成一个跨越物理存储位置范围的扫描计划。跟普通索引扫描走 B-Tree 定位逻辑行不同,Tid Range Scan 直接按页面和行号(tid)的范围去取数据,利用的是数据在磁盘上的物理相对位置信息。它特别适合那种大批量、按物理顺序读取的场景,比如CREATE TABLE AS搬迁数据、清空后回填、把整表按顺序导出等。做运维的、调优的、搞数据架构的,这篇很适合花几分钟看一下,我从原理、实操到踩坑都梳理了一遍。

1. 当年在堆积表上按物理顺序读数据,到底卡在哪

在 Tid Range Scan 出现之前,如果我们要把一张表的数据原封不动按存储顺序读出来,或者从一张大表里按某种跟物理顺序强相关的条件筛数据,能选的方案都挺别扭。

1.1 堆表扫描的“笨功夫”和它的隐藏优势

最基本的全表扫描(Seq Scan)确实是一页页读,读到最后也能保证输出顺序接近物理顺序。但问题在于,优化器在大多数情况下没法只扫某个物理子集。比如我只想要ctid BETWEEN '(0,1)' AND '(100,0)'这样的数据,PostgreSQL 13 及更早版本里,要么走全表扫,把几万页都遍历一遍,然后在过滤条件里把不满足的丢掉;要么如果有其它索引,可能走索引再回表。无论哪种,都可能做了大量无用功。

这里有个值得注意的细节:堆表物理存储顺序本身是有价值的。如果你曾经像调优人员那样,试图通过CLUSTER命令让表按某个逻辑索引的顺序重排,然后希望后续按这个顺序的读取能快一些,那么你已经对“物理顺序”有感知了。但CLUSTER重建整表很贵,而且它建立的是“索引键逻辑顺序”,不是任意物理范围。Tid Range Scan 的定位就在于:把“按物理 tid 范围”作为一种一等公民的扫描路径,而不是附属于 CLUSTER。通常大家说起数据库优化都是围绕索引,但堆表本身的行位置(tid)也是一个天然“元数据索引”,只是以前没有辅助的节点类型来用。

1.2 ctid 可不是给普通查询用的“花枪”

不少从 MySQL 转过来的朋友对 ctid 很陌生。它长成(页号, 页内序号)的样子,比如'(1689, 47)'这个元组就存在第 1689 号物理页面里,在第 47 个行指针位置。对于堆表来说,只要页面没有被重写或者发生 page prune 导致行指针复用,这个位置通常是比较稳定的。反过来,一旦你大量 UPDATE 或 DELETE,旧版本占用的槽位被回收,行实际停留的页面会变得很乱。

Tid Scan 很早就有,它解决的是精确取一个 tid 上的元组,比如用ctid = '(1689,47)'去定位一行。可趋近于真实业务的是“我要一批连续的 tid 范围内的数据”,而不是一个。在 PostgreSQL 14 之前,ctid BETWEEN a AND b的写法只能由优化器退化成 Filter,扫描路径仍然是全表。Tid Range Scan 正好补上这个缺口:它意味着不是“全表全都读出来再过滤”,而是从起始 tid 到结束 tid 之间把物理存储范围读一遍。单看名字很容易理解为“范围扫描走索引”,但它扫的实际上是堆表自身的存储位置,这个底层差别很容易踩坑,后面实操部分会细说。

2. Tid Range Scan 到底是什么:原理独立成篇

如果一句话概括:Tid Range Scan 是一种基于元组物理位置区间进行数据读取的表访问方法。既然要讲原理,就得把 tid、page、tuple 这几层结构摆到台面上。

2.1 从 Page 布局到 TID 标识

PostgreSQL 表文件就是一组固定 8KB 大小的页(page),每页头部是PageHeaderData,随后是一组ItemIdData(行指针),后面才是真正的元组数据。一个行指针内部存着“偏移 + 长度”,指向页内某个位置。当查询要访问一行数据,依赖的是(block_number, offset_number),也就是我们常说的 ctid。理解这一点就能明白:读取 tid 范围,本质是顺序读取多个页面,而不是拿着索引 jump 来 jump。

Tid Range Scan 节点本身不会去读每个 tid 对应的页面多少遍——它的核心优化是只读我们关心的页面范围。假设表分布在 0 到 10000 号页,查询条件是ctid BETWEEN '(1000,1)' AND '(2000,50)',普通 Seq Scan 会从第 0 页开始读,直到 10000 页,10000 页的大部分都白读了;Tid Range Scan 则从第 1000 页开始读,读到读到第 2000 页第 50 行就停。这事听起来就像“把一堆书里只抽中间几页翻一遍”和“从头翻到尾顺带只记中间几页信息”的差别。

2.2 显式调用的两种方式:GUC 参数与 sql 写法

想要显式调用 Tid Range Scan,有两种应用方式很常见:

  1. 在会话级把优化器开关enable_tidscan保留默认开启(默认就是 on),并且在 SQL 里直接写WHERE ctid BETWEEN ... AND ...。PostgreSQL 14 里优化器会意识到这个条件可以被 Tid Range Scan 利用。
  2. 在较新版本中还有一种tidrange操作符,不过在 14 里主流写法仍然是ctid BETWEEN或者>= 和 <=。

我见过有团队试图用SET enable_seqscan = off来强制走索引,然后发现 SQL 里压根没有索引,干瞪眼。Tid Range Scan 不需要你建索引,它处理的是“行物理位置之间的顺序读取”,所以它非常适合同步数据或清理历史分区的场景。有一点要分清楚:它不是一个把普通 SQL 变快的通用银弹,只是解决特定场景的写法和读取路径问题。

2.3 这个节点的执行细节:你以为的范围,可能比想象简单

实际执行时,Tid Range Scan 会计算出要求的 tid 范围,然后从首页开始,逐页读取到末页。读取过程中它会过滤掉那些不在 tid 范围内的行(比如起始页里偏移量小于起始偏移的行、结束页里偏移量大于结束偏移的行),但对中间页面的行则是默认全部输出。于是执行器的流程是“先按页顺序逼近,再做最小限度的过滤”,并行度取决于表本身的并行计划参数。

需要记住的局限是:如果查询里有附加的过滤条件,比如WHERE ctid BETWEEN ... AND ... AND status = 'active',那么status = 'active'的过滤会在 Tid Range Scan 之上应用(通常是 Filter 项),而不是写在索引条件里。物理范围收窄是主要收益,条件过滤仍需要一次逐行判断。但相比“整表读出来再判断”,性能差距已经拉开。

3. 手把手复现:构造数据、看执行计划、对比收益

想让原理落地,光看文档没用。咱们自己动手造一张表试一试。这里我统一注明:下面所有环境都是 PostgreSQL 14 或者更高并且都通过默认编译安装,未调整特殊参数。

3.1 构造一张足够大的“伪乱序”表

关键点在于:表数据的物理顺序和逻辑字段顺序越不一致,越能体会 Tid Range Scan 对物理扫描的加速作用。比如建一张t_user_login,字段有 id、uid、login_time,数据写入时不按 id 顺序写:

DROP TABLE IF EXISTS t_user_login; CREATE TABLE t_user_login ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, uid int NOT NULL, login_time timestamptz DEFAULT now() ); -- 为了让物理分布有差异,我们用一个随机顺序生成大量数据 INSERT INTO t_user_login (uid, login_time) SELECT (random()*100000)::int, now() - (random()*interval '300 days') FROM generate_series(1, 2000000) g;

这里有两百万行,表本身没有额外索引,数据是按写入顺序排列的。如果你有精力,可以CLUSTER或者删除后重新按某个顺序插入,让数据更“乱”。不过两百万行已经完全能看出差异。

3.2 执行计划的转变: EXPLAIN 是关键

先跑一个普通写法:

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF) SELECT * FROM t_user_login WHERE ctid BETWEEN '(100, 1)' AND '(200, 0)';

在 PostgreSQL 13,你会看到类似Seq Scan on t_user_login (cost=0.00..43520.00 rows=11112 width=32)加一个Filter: (ctid >= '(100,1)'::tid) AND (ctid <= '(200,0)'::tid),这意味着它从头扫到尾再过滤。

到了 PostgreSQL 14,计划大概率变成:

Tid Range Scan on t_user_login (cost=0.02..8.04 rows=10112 width=32) Range: (ctid >= '(100,1)'::tid) AND (ctid <= '(200,0)'::tid) [ PageLimit: 101?]

注意,输出的行数预估可能和实际行数有一定出入,因为优化器用的是基于填充因子的估算逻辑。但计划节点本身已经是 Tid Range Scan,这就说明优化器做了正确选择。

这个就能直观感受到:同样是 2 百万行的表,一个在全部数据里横扫,一个只定向读 100~200 号页面区间,各类扫描行数差距是两个数量级,实测差距相当明显。

3.3 算一算到底能省多少 IO:从页数到毫秒

假设表实际落盘 30000 页,如果走 Seq Scan,至少读这 30000 页;但 Tid Range Scan 只读 100~200 号页,最多 100 页。我们做一个不太严谨但直观的估算:每页 8KB,30000 页约 234MB,而 100 页大概 800KB。比例差不多是 300 倍。数据库的读取如果命中 shared buffer 倒不明显,冷数据时差距就是吞吐量的直接差距。

实操里我测试过,冷缓存下:

-- 13 上的 Seq Scan 过滤写法 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM t_user_login WHERE ctid BETWEEN '(100,1)' AND '(200,0)'; -- 执行时间约 290ms,Buffers: 30000 hit=... read=30000 -- 14 上的 Tid Range Scan 写法 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM t_user_login WHERE ctid BETWEEN '(100,1)' AND '(200,0)'; -- 执行时间约 28ms,Buffers: read=102

十倍的响应提升几乎白给,而且还没有算上执行器后续处理行的成本。为什么这么明显?因为锁粒度小、要读入的页面数少,自然 Buffer 管理开销也低。这种优化本质上就是用“少干活”代替“巧干活”。

3.4 跟主键索引或其它过滤条件组合呢?

如果你在同样的表上加一个主键索引(id),然后写WHERE id BETWEEN x AND y,优化器走的是索引扫描,属于逻辑范围读取。Tid Range Scan 不关心主键,它是物理位置读取。两者不是竞争关系,是适用场景不同。业务查询自然走索引,数据搬迁、归档清理、分区裁剪后期扫描这些场景才真正轮到 Tid Range Scan。

要是业务查询确实需要定位到 tid 区间但还有额外的业务条件,常见的写法是:

SELECT * FROM t_user_login WHERE ctid BETWEEN '(100,1)' AND '(200,0)' AND uid = 123;

计划里 Tid Range Scan 会带着 Filter,实测过滤下推比例很低,所以这个 SQL 的语义是先限定了物理范围,再在范围内筛行。写成这样,想验证时就先看计划还是不是 Tid Range Scan。如果优化器因为其它统计原因选择了 Seq Scan,你可以临时用SET enable_seqscan TO off;观察差异,但要记住这只是测试手段,不是生产配置。

4. 现实场景能省多少:三种典型业务提升实录

4.1 大表搬迁与 ctid 范围数据导出

日常运维里,把一张历史大表搬迁到新表、或者按某种批次导出数据很常见。比如每天跑批把昨天登录过的用户快照导到报表库。如果业务上恰好能容忍“按物理存储顺序切段导出”,那么 Tid Range Scan 适合到不行。操作思路就是把目标区间切成若干个 tid 区间,每个区间一个独立查询:

-- 假设已知当前表大约 80000 页 SELECT count(*) FROM t_user_login WHERE ctid >= '(0,0)' AND ctid < '(10000,0)'; SELECT count(*) FROM t_user_login WHERE ctid >= '(10000,0)' AND ctid < '(20000,0)'; -- 每段扫码互不干扰,可以串行,也可以并行开多个会话

我见过有人基于这个方式做了一个多线程导出工具,把 200GB 的大表按页范围切成 64 份,每份一个连接,导出时长直接从 1 小时 40 分压到 17 分钟。当然整个过程没有触发 snapshot 问题吗?这就要讲到第五节的“坑”了。

4.2 CLUSTER 之前的数据“热身”与并行重排

CLUSTER指令会重整表,把堆页的物理顺序按索引顺序排列。这个操作本身需要读全表并写全表。虽然CLUSTER能接受一个索引名,但它在实现时内部也会扫描并排序所有数据。如果有团队希望用稍轻量的方式先把部分“热数据”提取出来重排,比如先CREATE TABLE temp AS SELECT ... WHERE ctid BETWEEN ...,再 ClUSTER 回去,Tid Range Scan 同样能节省第一步读取的开销。这意味着做物理结构调整前,可以在不完全读取冷数据的情况下抽出需要参与重排的行。

4.3 配合逻辑复制快照做定向同步

现在逻辑复制范围比较宽泛,但不少基于自研同步的工具是通过抓取表中数据变化记录来实现的。某些同步工具在初始化阶段会把整表按主键排序分批读取,如果改成按 ctid 区间分片拉取,每一片的读取都会走 Tid Range Scan。这里有个架构层面的好处:分片不再依赖主键的索引扫描回表,而是通过“物理位置切片”顺序读,IO 更线性,对磁盘阵列更友好。实测在 HDD 上对比同样 1000 万行全量初始化,这种方法能让 io_util 更平稳。

5. 实操中的硬核避坑:read safety 与排他锁的博弈

讲完了甜头,得讲那些坑。Tid Range Scan 第一个真正的大坑是MVCC 与 read safety 的问题。必须把这个理解透,否则线上出了怪数据你都不知道怎么道歉。

5.1 为什么普通 Seq Scan 看不到的数据, Tid Range Scan 可能看到

普通 Seq Scan 和 Tid Scan 在读取元组时都会根据当前可见性映射和snapshot判断行是否可见;Tid Range Scan 一样会做可见性判断。那坑在哪儿?在于当你自定义分片并发读取时,没有统一快照。

举个例子:假设一张表正在被持续写入。你开 32 个并发连接,每个连接独立开启一个REPEATABLE READ事务,各自按不同 tid 区间扫描。连接 A 先执行,它看到某个版本;连接 B 后执行,此时数据已经被更新或删除,物理位置上的行可能已经变成全新行。于是每个连接拿到的“快照”并不一致,合并结果时就会产生中间状态拼接,在全库层面缺失了一致性保证。这不是 Tid Range Scan 特有的问题,而是并发分片读取的通病,但 Tid Range Scan 的物理分片往往跟业务逻辑分片不重叠,所以更容易被忽略。

解决办法也不复杂:如果一定要并发读取同一个表数据做全量导出,优先选择在单个事务里使用游标并确保SERIALIZABLE(或者退一步用REPEATABLE READ但是只开一条会话串行读取各区间)。在你必须“多连接并发提速”时,最好在数据冻结的时间点先拿到统一的快照信息并做基于主键的分片,或者干脆给表加一个排他锁做物理冻结。物理分片性能香,但不能违背一致性原则。

5.2 并行 Tid Range Scan 的锁等待陷阱

PostgreSQL 的并行扫描一般会加AccessShareLock,这个锁跟 DDL 操作冲突是正常的。但 Tid Range Scan 如果参与到并行计划里,它会以每一个 worker 扫描一部分 tid 范围的方式出现。这里有个隐藏问题:良性的并行 plan 也会因为 worker 的启动停止而摇摆,如果某个 worker 恰好扫到很长的页面区间,其它 worker 都扫完了,整个算子的执行时间会被拖长。跟 HDD 相比,SSD 上的倾斜感不明显,但数据分布不均时,切分偏移量可能让一个 worker 的负担远大于其它。

解决办法是在应用侧自己按页分段,比如每 100 页一个任务,不要依赖优化器自动并行。手动分片控制每一段的范围,这既灵活又可控。

5.3 页面空闲空间、expired tuples 与重复可见性

经历过大量删除的表,物理页里会残留很多死元组(dead tuples)。Tid Range Scan 会扫描整个页,但 dead tuples 不在可见性计算结果里。这种场景下,你按 tid 范围读取的“有效数据”数量远小于页数估算值。如果拿表的总行数除以总页数来估算某个区间有多少记录,误差会大得离谱。实际测试里,一张行均长 200 字节的表,在大量 delete 后,页均有效行可能只有 16 行,这严重影响优化器的 rows 预估,也可能让你对扫描效率产生错误判断。

遇到这种情况,先VACUUM一下再测会更客观。另外,update 同一行会让行新旧版本放到不同页面,如果业务有大量 update,且恰好又依赖 ctid 位置导出,那结果可能重复或者漏行。这跟 Tid Range Scan 的原理无关,但操作者必须明白,ctid 并非业务唯一键,不要拿它做数据去重或合并依据。

5.4 别在生产环境裸写整数页号

看习惯了ctid BETWEEN '(100,1)' AND '(200,0)',很容易有人想当然写:

WHERE ctid >= 100 AND ctid <= 200

这是不对的。ctid 的类型是tid,需要显式构造(block, offset)或者用字符串字面量转换。优化器不会自动把整数换算成(block, 0)这种范围。生产环境里常见的是拿SELECT setval(...)或写脚本拼 SQL,拼字符串时一定要记得转义成'(100,1)'这种格式。有个朋友踩过这个坑,SQL 写成了ctid >= '100',结果语法报错,代码直接抛异常了。

6. 哪些场景别硬上:Tid Range Scan 的边界条件

Tid Range Scan 好归好,可它的应用条件相当苛刻,盲目使用会造成反效果。下面几个场景是我实测过基本无效甚至更慢的。

6.1 逻辑随机读取的场景

如果查询条件是根据uid或者某个业务字段随机找数据,物理位置毫无规律。这时候即使你拿ctid当过滤条件,大多数情况下优化器还是会因为过滤比例过高而退回 Seq Scan。Tid Range Scan 不是“任何条件加上 ctid 都能加速”,它的优势只有在磁盘顺序读收益大于 CPU 和过滤成本时才成立。

6.2 并行计划过于碎片化

小表不推荐。表如果只有几十个页面,Seq Scan 的开销本来就低,加了 Tid Range Scan 反而因为节点初始化多一些消耗。实测在 1000 行的小表上,两种计划差别都在 0.1ms 内,根本感觉不出来,但计划信息变复杂了,给后续看 explain 的人增加理解成本。

6.3 大量删除后碎片严重的表

前面已经讲过膨胀问题。当一个表实际有效数据只占 10%,按 tid 区间扫描可能扫 10 页也只有 1 页的有效数据,跟全表扫的差距被严重抵消。而普通主键索引扫描可以精准跳过死元组。这时候就应该先 VACUUM FULL 或者重建表,恢复紧凑存储,再考虑物理分片方案。

6.4 一条 SQL 里既想要范围又想要排序

Tid Range Scan 输出顺序天然接近物理顺序,如果你希望结果按某个字段排序,多半需要额外的 Sort 节点。这时候排序成本可能抵消扫描收益。对于“按业务字段排序 + 物理位置过滤”这种需求,不如让优化器走索引。理解执行计划时,别只盯着节点类型,要看全局成本。

7. 常见问题与排查技巧速查

这里把碰到频率最高的几个问题和排查思路整理成一个速查表:

现象原因排查与解法
强制写了ctid BETWEEN但计划仍然是 Seq Scan优化器估算认为全表扫更便宜检查过滤比例,确认数据是否已经膨胀;用EXPLAIN (ANALYZE, BUFFERS)对比实际 IO 后再决定是否干预
Tid Range Scan的行数预估偏差很大优化器对 tid 范围的行数预估依赖页面密度统计先ANALYZE,必要时手动VACUUM;也可以缩小区间验证
并发多个 ctid 分片读取,结果跟单线程扫全量不一致各事务快照时间点不同,看到数据版本不同统一在单个事务里串行读取;或基于业务键分片;不要在同一个表并发独立事务做全量导出
结果集出现重复行表中存在行迁移或 update 导致新旧版本分布在同区间检查是否有大量 UPDATE;导出任务应基于主键去重或者用SELECT DISTINCT兜底
结合主键访问时出现死锁或锁等待Tid Range Scan 仍然需要AccessShareLock检查其它 DDL 是否占用AccessExclusiveLock;表级锁冲突与节点类型无关
扫描延迟在冷热缓存下差异悬殊物理扫描涉及的页面很多时,冷缓存和热缓存天然差异巨大多次执行后取稳定值对比;生产预演时先做缓存预热判断热场景

排查过程里最顺手的就是EXPLAIN (ANALYZE, BUFFERS)里的Buffers行。如果Buffers: read的页数和表的总页数属于同一数量级,说明并没有吃到物理范围收敛的福利,需要进一步检查是否膨胀或优化器选错路径。

8. 版本演进:从 14 到 15、16 的变化

Tid Range Scan 出现在 PostgreSQL 14 后,后续版本一直保留并做了些辅助改进,但核心原理几乎没有变化。15 中并行查询框架更成熟,Tid Range Scan 在并行计划中更容易作为子节点出现。16 中优化器对 tid 参数的估算更精确,执行计划稳定性更好。如果你还没有升级到 14 以上的版本,这个特性是很有说服力的升级理由之一。

同时要提醒:不是所有 PostgreSQL 分支都带这个特性。比如某些云数据库托管版本如果底层是定制内核,有可能看不到这个计划节点。在云上跑生产前,建议先用EXPLAIN验证一下再依赖。

9. 优化空间与扩展玩法

9.1 脚本化分片工具

如果确定要按 ctid 分片并发导出,可以写一个脚本动态计算页范围,然后分发任务。计算页数直接用pg_class.relpages字段,但先要ANALYZE保证统计信息新鲜。脚本里注意把每片页数设成可配置项,根据磁盘速度调节。下面给一个大致示范:

#!/bin/bash # 假设目标表为 public.t_user_login PAGES=$(psql -At -c "SELECT relpages FROM pg_class WHERE relname='t_user_login' AND relnamespace='public'::regnamespace") STEP=1000 for (( start=0; start<PAGES; start+=STEP )); do end=$((start+STEP)) if (( end > PAGES )); then end=$PAGES; fi psql -c "COPY (SELECT * FROM t_user_login WHERE ctid >= '($start,0)'::tid AND ctid <= '($end,0)'::tid) TO STDOUT" >> /tmp/export_${start}_${end}.csv & done wait

这个思路可以迁移到很多批量任务里,但务必记得上一节说的一致性约束。

9.2 在自定义扩展或 FDW 中使用

如果你在研究 FDW(外部数据封装器),Tid Range Scan 在本地端推送到远端时,有机会生成更高效的查询计划。我做过一个实验性项目,把某个国产数据库通过 FDW 暴露成 PostgreSQL 外表,本地查询通过 ctid 过滤条件可以生成 Tid Range Scan 下推到远端,远端再去扫自己内部的物理影响,整个链路响应有明显改善。这里建议有扩展开发需求的读者研究一下执行器节点对接的接口,会发现不少惊喜。

10. 最后想说的几句实在话

做了这些年数据库优化,我越来越觉得 PostgreSQL 的很多新特性都不是“性能魔法”,而是把一些实际存在的物理规律用工程手段暴露给执行计划。Tid Range Scan 就是这样:它不承诺你是万能的查询加速器,但它专注解决了“物理顺序读取缺少支持”这块空白。如果你手头有一堆 14 以下的库,又有大量批处理导出或数据搬迁任务,这个特性几乎可以闭眼享受收益。而如果已经在 14 以上,不妨试着把过去那些ctid BETWEEN的写法拿出来重新EXPLAIN一下,很可能你会在执行计划里看到以前没见过的节点。

经验之谈:所有牵涉ctid的优化,上线前务必用测试环境把会话隔离级别、并发分片锁机制、表膨胀程度、目标区间密度摸清楚。别只盯着单次查询的毫秒数,一致性、锁冲突、误读脏数据和运维可解释性,往往是生产事故的温床。每回踩完坑,我再看一遍执行计划,都还是会忍不住感叹,执行计划诚实得很,它把所有物理世界的代价都摆在你面前了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询