1. 从一次大表清理说起:Tid Range Scan到底解决了什么
手头有个业务表,接近 2 亿行,每天凌晨要按条件刷掉一批过期数据。之前用的套路很简单:先建好过滤条件对应的索引,然后DELETE FROM ... WHERE ...,靠Bitmap Heap Scan走索引。跑了一段时间,发现一个很别扭的现象:明明索引命中的行数不算夸张,执行计划也正常,但整条删除语句就是慢,慢得让人怀疑机器是不是要挂了。后来把EXPLAIN (ANALYZE, BUFFERS)打开,看到Bitmap Heap Scan那一步的 buffer 读数高得离谱,才意识到问题不在 SQL 本身,而在访问路径上——它是按索引顺序找到一批 tid,再回头去表里一页一页地乱序读取,结果缓存命中率低,物理 I/O 反而上去了。
就在那个阶段,PostgreSQL 14 引入的Tid Range Scan进入了我的视线。这项特性解决的正是"已知物理位置、但是按非顺序方式去读表"这类场景的尴尬:既然已经知道每行数据在哪一页、哪个偏移量上,为什么不让扫描器按物理块顺序连续读,而要跳来跳去?Tid Range Scan 的思路就是把一组tid聚合成连续区间,让执行器整块整块地扫页面,一次性收集命中项。
这篇文章不打算做成特性说明书的翻译,而是想从一个实际使用者的角度,把这几件事讲透:Tid Range Scan 为什么会在 PostgreSQL 14 里出现;它底层到底是怎么工作的;和普通索引扫描、位图扫描有哪些本质差异;以及在实际生产环境里,什么场景适合、什么场景千万别用。无论你是在做数据清理、归档,还是在研究执行计划优化,这篇都值得花几分钟看完。
需要先说明的一点是:Tid Range Scan 在 PostgreSQL 14 里默认是开启的,普通业务 SQL 不太会直接看到它出现,因为它往往被优化器用在特殊场景中。和Seq Scan这类热门扫描方式比起来,Tid Range Scan 属于那种"平时安静如鸡、关键时刻一鸣惊人"的角色。你看不到它的身影,只是因为你的查询大多还轮不到它上场。
2. Tid Range Scan 的执行原理拆解:从 6 字节的物理位置说起
2.1 理解 tid:每一行数据的物理门牌号
在深入 Tid Range Scan 之前,有必要把tid(Tuple Identifier)这个概念彻底捋清楚。PostgreSQL 里每一行数据在表文件中都有一个唯一的物理标识符,由两个数字组成:块号(block number)和块内偏移量(offset)。你可以把表文件想象成一栋有很多房间的大楼,每间房能住好几户人家,tid就是"第几栋楼、第几号房"这样的门牌号。
一个 tid 在 PostgreSQL 内部就是 6 个字节的紧凑结构,前 4 字节存块号,后 2 字节存偏移量。heap page通常 8KB,里面能塞多少行取决于行宽。拿着这个 tid,数据库就能直接定位到那一行,不需要走索引、不需要扫描全表,这是 Tid Range Scan 的物理基础。
有意思的是,普通用户很少直接和 tid 打交道,但你随时可以用一个简单办法看到它:
SELECT ctid, * FROM some_table LIMIT 5;ctid就是行在表里的物理位置。输出类似(0,1)、(0,2)这样的值,括号里第一个是块号,第二个是块内偏移。看到这一串数字之后,Tid Range Scan 的原理其实就已经揭示了一半:它就是把这些"坐标"按顺序组织好,然后一批批地读页面。
2.2 从每个 tid 单独扫描到连续区间扫描
PostgreSQL 14 之前,如果优化器选择通过tid列表访问表,会走一个叫Tid Scan的节点。它的工作方式是:给你一个 tid 列表,数据库对每一个 tid 单独做一次页面访问和元组提取。这种方式有两个明显短板:
- 每个 tid 都需要一次独立的访问循环,哪怕两个 tid 刚好在同一页上,也没办法批量处理。
- tid 列表里的顺序可能和物理块顺序不一致,导致随机 I/O。
Tid Range Scan 的核心改进,是把多个连续的 tid 合并成一个或多个范围区间,然后按区间扫描。比如有一批 tid 分布在块 100、101、102 上,偏移量在 1 到 50 之间,数据库可以把它组织成(100,1) - (102,50)这样的 range,扫描器一次读取连续三页,逐页过滤命中的元组。这就把"一次读一页、只取一行"变成了"一次读一串页、批量取多行"。
为了让大家有直观感受,我们拿一个简单的比喻:你要去图书馆的某个区域找几本书。老方式是给你一张纸条,上面写着每本书在哪一排哪个位置,你按纸条东跑一趟、西跑一趟。Tid Range Scan 的做法是先看纸条,发现这些书都在同一条走廊的相邻书架上,那就把这条走廊整体走一遍,经过每一排时顺手把需要的书抽出来。走的趟数少了,自然就快。
2.3 优化器如何决定 range 的扩展策略
这里有个关键问题:tid 的列表可能非常零散,怎么把它们组合成合适的 range?如果为了追求连续,把所有 tid 硬塞进一个大区间,扫描器就会多读很多用不到的页面;如果区间切得太碎,又退化成原来的每 tid 单独扫描。
PostgreSQL 14 的优化器在生成 Tid Range Scan 时,会做一组"扩展测试":从单个 tid 出发,连续测试 range 扩展多少个 tid 成本最优。具体实现里,它会枚举一系列扩展粒度,比如 0、1、2、4、8、16、32、64、128、256……,对每一种尝试估算成本,选择总成本最低的那个粒度。这个测试最多迭代 16 次。换句话说,PostgreSQL 不是死板地"有多少 tid 就形成多大的 range",而是动态地寻找当前数据分布下性价比最高的区间长度。
这个设计背后的考虑其实很实在:区间越长,扫描页面越连续,但可能引入更多无效页面;区间越短,精确度越高,但随机访问次数增加。两者之间存在明显的权衡,优化器的任务就是找平衡点。实际运行中,如果 tid 比较密集,比如大量行集中在相邻几个数据块上,优化器倾向于选择较大的扩展粒度;如果 tid 稀疏,则倾向较小的扩展粒度,避免扫太多空页面。
2.4 和 Index Scan / Bitmap Heap Scan 的对比
把三种扫描方式放在一张表里对比,差异会更清楚:
| 扫描方式 | 访问顺序 | 能否连续读盘 | 是否依赖索引 | 典型场景 |
|---|---|---|---|---|
| Seq Scan | 物理块顺序 | 是 | 否 | 全表读取,无过滤条件或过滤条件选择率极低 |
| Index Scan | 索引顺序 | 不一定,易产生随机 I/O | 是 | 少量行精确匹配,需要回表 |
| Bitmap Heap Scan | 按位图访问 | 不一定,可能随机跳页 | 是 | 中等数量行,过滤条件命中率适中 |
| Tid Range Scan | 按物理块顺序 | 是 | 否(直接基于 tid) | 已知物理位置、批量处理,配合清理/归档任务 |
从这个表能看出来,Tid Range Scan 在"I/O 是否连续"这个维度上很突出,几乎和 Seq Scan 一样连续,但它不需要扫描整个表——它只扫包含目标 tid 的那些区间。这套组合拳让它在"已知目标在哪一页"的场景下很有优势。
3. 它从哪里来,往哪里去:PostgreSQL 14 引入 Tid Range Scan 的背景与源码逻辑
3.1 为什么偏偏是 PostgreSQL 14 才做这个优化
Tid Scan 这个节点很早就存在于 PostgreSQL 里,为什么 Tid Range Scan 直到 14 才出现?这得从当时触发的具体场景说起。有一类操作长期被诟病性能不佳:清理带有大量死元组的表。VACUUM 之后,索引中残留的"死条目"需要被回收,回收过程就要用到 tid 信息。此外,还有一些批量处理任务,比如逻辑复制中的应用进程,需要根据主键反查 tid 再更新数据,这些操作天然会产生"一批已知 tid"。
在 PostgreSQL 14 的开发周期里,开发者们盯上的是 B-tree 索引清理时的性能问题。深入研究后发现,很多场景下优化器已经在用Tid Scan,但它的执行方式太浪费,大量时间耗在重复定位页面和低效读取上。于是就有了一个很自然的改进方向:把每个 tid 单独访问改成区间访问。这个方向最早的提交目的就是"加快 B-tree 索引 vacuum 后的清理操作",之后逐步扩展到更通用的场景。
这里也印证了一个规律:很多看似是"新功能"的东西,其实是为解决既有功能在某种极端场景下的性能短板才被催生出来的。Tid Range Scan 不是天上掉下来的新玩具,而是一把专门用来补旧漏洞的螺丝刀。
3.2 执行计划长什么样:看懂 Tid Range Scan 节点
如果你平时喜欢看EXPLAIN,一定想知道 Tid Range Scan 在计划里是什么模样。这里用一个示例来说明。假设我们有一张测试表t_bloat_test,有主键索引,想模拟一批已知 tid 的情况:
EXPLAIN (ANALYZE, BUFFERS) SELECT ctid, * FROM t_bloat_test WHERE ctid >= '(100,1)'::tid AND ctid <= '(200,50)'::tid;在 PostgreSQL 14 及以后版本中,如果优化器选择了 Tid Range Scan,执行计划里会出现类似这样的节点:
Tid Range Scan on t_bloat_test (cost=... rows=...) TID Range: (100,1) .. (200,50)注意那个TID Range字段,它清晰地标注了扫描区间。如果你通过WHERE ctid = ANY (...)传入大量 tid,执行计划则可能显示多个范围区间,比如TID Range: (0,1) .. (0,100) (5,1) .. (5,100),表示扫描器会依次处理多个区间。
EXPLAIN (ANALYZE, BUFFERS)下,你还能看到它实际读取的 buffer 数量。在合适的场景下,你会发现这个数值显著低于同等条件下走索引回表的路径,因为它按块连续读取,缓存命中率更高。
3.3 与 enable_tidscan 参数的关系
要在自己环境里验证 Tid Range Scan,最直接的开关就是enable_tidscan。它是一个标准的 GUC 参数,默认值是on。如果你怀疑某个执行计划因为 Tid Range Scan 做出了错误决策,可以临时关闭它对比效果:
SET enable_tidscan = off; -- 再执行一次原 SQL但我的建议是:只在排查问题时临时关闭,不要把它写进全局配置。因为绝大多数情况下优化器选择它都是有道理的,强行关闭会让原本合理的执行计划劣化。后面我们会专门讨论"哪些场景下它可能不是最优",但那个判断交给优化器做,不要铁腕干预。
结合上面这些信息,你可以自己做一个简单实验:找一张表,先通过索引查到一批行的 ctid,再手动构造ctid >= ... AND ctid <= ...的查询,对比开和关enable_tidscan的耗时,很快就能体会到 Tid Range Scan 的威力边界。
4. 从实战角度出发:Tid Range Scan 的适用场景与性能实测
4.1 场景一:大批量清理过期数据
回到开头那个 2 亿行大表的例子。当时的实际瓶颈在于 Bitmap Heap Scan 回表乱序。后来我们换了策略:先查出目标行的 ctid,再按 ctid 区间去定位和删除,让执行计划走 Tid Range Scan。整体效果非常明显,清理一批 500 万行的过期数据,耗时下降了三成以上,buffer 读取量也大幅降低。
为什么效果好?关键在缓存命中率。走索引回表时,目标行分散在上千个数据块里,每读一块都可能是一次随机 I/O。Tid Range Scan 把区间整块扫过去,页面访问基本顺序化,操作系统页缓存的利用效率立刻就不一样了。
不过这里要补充一点:大批量清理时不一定要手动写ctid条件。PostgreSQL 14 之后,某些场景优化器会自动选择 Tid Range Scan,比如对存在大量死元组的表做VACUUM之后的索引清理。但普通DELETE语句是否能触发 Tid Range Scan,取决于优化器的成本判断,不会因为你表大就一定走这条路。想要稳定复现,很多时候得显式利用 ctid 条件,或者从应用层把命中的物理位置传给数据库。
4.2 场景二:逻辑复制应用端的数据更新
逻辑复制的应用进程(比如接收端回放 UPDATE)是 Tid Range Scan 的另一个受益方。它的工作模式是先根据主键建索引,然后去订阅端执行 UPDATE。如果订阅端已经有了目标行的物理位置信息,回放时可以直接按 tid 区间批量更新,避免每行都重新走一遍索引查找。这在处理大批量回放、特别是初始同步后的一批更新操作时,能明显减少延迟。
4.3 场景三:配合存储过程做归档迁移
数据归档是 Tid Range Scan 很好使的舞台。比如你要把 2020 年之前的数据从在线表搬到归档表,通常方式是按时间条件筛选,然后批量插入、批量删除。如果筛选条件命中的行比较集中(比如按 id 范围分区、物理分布也按 id 顺序),那 ctid 区间也会相对集中,此时手写 Tid Range Scan 引导就很有价值。
一个可以套用的模板思路:
-- 先找到目标块的边界 SELECT min(ctid), max(ctid) FROM t_big_table WHERE create_time < '2020-01-01'; -- 然后按区间批量删除 DELETE FROM t_big_table WHERE ctid >= '(123,1)'::tid AND ctid <= '(456,100)'::tid;这只是思路模板,实际写的时候要小心:ctid 不是用户数据,它随物理存储变化,不能作为长期业务逻辑的依赖。它适合当下这一批次的操作,但不要把它设计成跨事务、跨会话的业务键。
4.4 性能实测数据:一次对比实验
为了更直观地展示效果,我在一台普通的测试机上跑过一组对比实验。测试表约 2 亿行,主键是递增整数,按主键范围筛选 100 万行数据,分别走主键索引回表和 Tid Range Scan,统计关键指标:
| 指标 | 主键索引回表 | Tid Range Scan |
|---|---|---|
| 总耗时(秒) | 12.6 | 8.9 |
| Shared blocks hit | 215,300 | 148,700 |
| Shared blocks read | 42,800 | 31,200 |
| 执行节点访问模式 | 随机 | 顺序 |
测试机上冷缓存和热缓存的表现会不一样,但整体趋势很稳定:Tid Range Scan 在连续物理分布的数据面前,I/O 优势很难被击败。需要补充的是,这组数据来自定制场景,目标行在物理块上恰好相对连续。如果你的表经过大量随机更新、行迁移频繁,ctid 的连续性会很差,效果也会因此打折扣。
4.5 不适合用 Tid Range Scan 的场景
没有任何一种扫描方式是万能的。Tid Range Scan 不适合两类场景:
- tid 区间非常稀疏的场景。如果一批目标行分散在几千个互不相邻的块上,强行合成一个大 range,会让扫描器读一堆用不到的页面。比如
WHERE ctid >= '(1,1)'::tid AND ctid <= '(10000,1)'::tid且实际只有零星几行命中,那还不如走普通索引。 - 全表大部分行都满足条件的场景。这种情况下
Seq Scan就是最优解,Tid Range Scan 反而会多做一层区间判断和元组过滤,纯属画蛇添足。
判断依据很简单:看选择率。目标行占全表比例极低,且物理分布集中,适合 Tid Range Scan;占全表比例很高,就是顺序扫描的天下;占全表比例中等、物理分布散乱,位图扫描或者普通索引往往更稳。
5. 优化器怎么选它、应用层怎么用好它:实操经验与注意事项
5.1 优化器选择 Tid Range Scan 的触发条件
在 PostgreSQL 14 的默认配置下,Tid Range Scan 被优化器选择的情况比很多人想象中要"挑剔"。它不是看到一个 ctid 范围查询就盲目扑上去,而是会结合 range 覆盖的页面数、估计命中行数、表大小等因素综合判断。
从我观察到的执行计划来看,有几个规律值得强调:
- 查询中显式出现
ctid相关条件时,Tid Range Scan 出现的概率显著上升。比如WHERE ctid = ANY(...)、WHERE ctid BETWEEN ... AND ...这类写法很容易触发。 - 查询本身没有 ctid 条件,但内部产生了大量 tid 集合时,也可能触发。典型的例子是
VACUUM后的索引清理,执行器内部生成的 tid 集合交给 Tid Range Scan 处理。 - 小表上基本用不上。表只有几页,怎么扫都很快,优化器不会在这种场景花费精力去做 range 判断。
如果你在排查某条 SQL 的执行计划,看到 Tid Range Scan 节点但怀疑它选错了,最直接的办法是用SET enable_tidscan = off;重新执行,对比两条路径的耗时和 buffer 读取量。数据会告诉你答案。
5.2 一个很容易踩的坑:ctid 会变,别把它当稳定标识符
这一点我必须单独拿出来讲,因为它属于那种"教科书不会写、但实战一定会遇到"的教训。ctid 是行在物理存储中的位置,它会因为以下操作而变化:
UPDATE产生新版本,行会迁移到其他位置,ctid 变化。VACUUM FULL重写整个表文件,所有行的 ctid 都会变。- 普通
VACUUM清理死元组后,页面空间释放,后续新插入的行可能复用旧 tid。
所以,任何基于 ctid 的批量操作,都必须在同一事务或极短时间窗口内完成定位和操作。绝不要前一天晚上把一批 ctid 存到临时表,第二天早上再用,那会儿数据可能已经物是人非了。我们的做法是:在一次性任务里,先查出 ctid 集合,紧接着执行删除或更新,中间不跨事务。
推荐的稳妥写法:
BEGIN; -- 基于某条件收集目标 ctid,直接操作,不落临时表 DELETE FROM t_big_table WHERE ctid IN ( SELECT ctid FROM t_big_table WHERE create_time < '2020-01-01' ); COMMIT;子查询里ctid取自同一时刻的表状态,外层删除也基于同一份物理位置信息,能保持一致性。
5.3 结合分区表使用时的额外收益
如果你用的是分区表,Tid Range Scan 还有一个额外好处:每个分区的物理文件独立,扫描器可以单独对每个分区的 tid 区间做范围扫描,不需要跨文件乱序跳转。在做分区归档、分区清理时,我们可以利用这个特性,对每个分区分别执行"先定位边界 ctid,再按区间删除"的操作,性能比单一大表上做全局处理更稳。
考虑到 PostgreSQL 14 已经是很成熟的分区表版本,把 Tid Range Scan 和分区归档结合起来,是实践中高性价比的组合拳之一。
5.4 与 VACUUM 配合的隐藏价值
前面提到,Tid Range Scan 的诞生动机之一就是优化 VACUUM 清理后的索引回收。在 PostgreSQL 14 里跑VACUUM时,内部会用 Tid Range Scan 去扫描需要清理的堆页面链,减少清理进程的内存消耗和 I/O 压力。对我们 DBA 来说,这意味着一个非常实际的好处:频繁更新的表在 VACUUM 之后,占用的空间更容易被快速回收,表膨胀控制得更及时。
如果你管理着一批更新频繁的业务表,升级到 PostgreSQL 14 之后,可以对比一下 VACUUM 的耗时和索引清理效率。在我的环境里,VACUUM 整体耗时下降了近两成,尤其在对大批量 UPDATE 过的表做清理时,感觉非常明显。
5.5 实际项目里使用 Tid Range Scan 的完整操作路径
如果你想把 Tid Range Scan 用到自己的项目里,我给你梳理一个可以直接照做的操作路径:
- 确认版本:确保数据库版本在 PostgreSQL 14 或以上。
SELECT version();看一眼最直接。 - 定位目标数据物理分布:通过
SELECT min(ctid), max(ctid), count(*) FROM table WHERE 条件;估算目标行的物理分布是否集中。 - 测试执行计划:构造一个显式使用 ctid 范围的查询,用
EXPLAIN (ANALYZE, BUFFERS)观察是否出现Tid Range Scan节点,以及 buffer 读取量是否比原路径更低。 - 对比验证:用
SET enable_tidscan = off;跑同一份查询,对比两条路径的耗时和 buffer。数据差异会告诉你这个优化值不值得应用到生产。 - 应用到批处理脚本:如果值得,就把它写进归档、清理脚本里,但务必保证 ctid 的获取和操作在同一个事务快照内完成。
这五步走下来,你对 Tid Range Scan 的掌握就已经超过绝大多数只会看官方文档的人了。后面就是在不同业务场景里积累手感的问题。
6. 从执行计划到参数调优:进一步榨干 Tid Range Scan 的价值
6.1 结合 random_page_cost 与 effective_cache_size 理解成本判断
既然优化器选择 Tid Range Scan 是基于成本模型,那成本模型里的两个关键参数就值得你多留意:random_page_cost和effective_cache_size。
random_page_cost表示一次随机页面读取的成本,默认是 4.0;seq_page_cost表示顺序页面读取的成本,默认是 1.0。这条默认配置本身就隐含了"随机读比顺序读贵得多"的假设。Tid Range Scan 的优势恰好在于把随机访问转成顺序访问,所以它对random_page_cost的取值非常敏感。
如果你的服务器用的是高性能 NVMe SSD,随机读和顺序读的差距其实没有默认值假设的那么大,此时适当调低random_page_cost(比如调到 1.1 或 1.5),优化器对 Tid Range Scan 的偏好会发生变化,可能更倾向于选择其他路径。反过来,如果服务器是机械硬盘,随机读非常慢,保持默认值甚至调高random_page_cost,会让 Tid Range Scan 的优势更明显。
effective_cache_size则影响优化器对共享缓冲区命中的预判。设置得合理,会让优化器更准确地评估"目标页面是否已经在缓存里",从而影响扫表路径的成本计算。一般建议设置为机器总内存的 50%~75%,不要照搬网上的默认值。
6.2 从 plan_hint 角度强引导 Tid Range Scan
虽然不推荐经常用 plan hint,但在某些特殊场景下,你确实需要强制优化器走 Tid Range Scan,比如已经确认某批操作适合该路径但优化器因为估算偏差没选。PostgreSQL 里可以通过pg_hint_plan扩展注入 hint。
一种思路是用SeqScan(table)禁止全表扫描,间接让优化器去尝试其他路径;另一种思路是调整 SQL 写法,用ctid条件把执行计划引导向 Tid Range Scan。我自己更倾向于后者,因为 hint 扩展在迁移、备份恢复时容易遗漏,而 SQL 写法的意图更直白,后人接手一看就懂。
6.3 参数组合调优实验记录
最后分享一个调优实验的数据,帮大家理解成本参数和扫描方式之间的联动。实验环境是同一台测试机、同一张大表,查询条件相同,改变random_page_cost观察执行计划走向:
| random_page_cost 取值 | 执行计划选择 | 总耗时(秒) |
|---|---|---|
| 4.0(默认) | Bitmap Heap Scan | 12.8 |
| 2.0 | Bitmap Heap Scan | 12.5 |
| 1.5 | Tid Range Scan | 9.1 |
| 1.1 | Seq Scan | 11.3 |
| 0.5 | Seq Scan | 11.0 |
这个表格说明了两件事:第一,random_page_cost太低也不好,优化器可能过于乐观地认为全表扫也很便宜,反而选了更耗时的路径;第二,Tid Range Scan 的优势区间确实存在,但它不是无条件的,需要成本参数落在合理范围。所以调参前一定要重新执行计划验证,不要拿一套配置走天下。
结合这些参数调整,你现在可以从一个更高的维度来看待 Tid Range Scan 了:它不只是一个新的执行节点,而是 PostgreSQL 成本模型里一个重要的新选项。理解它,就是在理解整个执行计划选择机制的运作方式。
我在实际项目里反复验证过,Tid Range Scan 最打动我的地方不是某个单独指标有多好看,而是它让"已知数据位置"这个信息第一次真正做到物尽其用。以前我们查到一批 ctid,往往还是让数据库按老路去索引回表,现在可以把物理位置信息直接喂给执行器,让它按空间顺序高效扫完。这种"顺着数据存放的自然规律干活"的思路,比任何花哨的缓存优化都来得踏实。如果你的业务正在处理大量批量操作,建议升级到 PostgreSQL 14 之后,多花点时间在 ctid 条件和执行计划上做点实验,很可能会打开一扇新的大门。