PostgreSQL 14 Tid Range Scan:原理、适用场景与性能优化实战
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 之前有必要把tidTuple 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.68.9Shared blocks hit215,300148,700Shared blocks read42,80031,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.0seq_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 Scan12.82.0Bitmap Heap Scan12.51.5Tid Range Scan9.11.1Seq Scan11.30.5Seq Scan11.0这个表格说明了两件事第一random_page_cost太低也不好优化器可能过于乐观地认为全表扫也很便宜反而选了更耗时的路径第二Tid Range Scan 的优势区间确实存在但它不是无条件的需要成本参数落在合理范围。所以调参前一定要重新执行计划验证不要拿一套配置走天下。结合这些参数调整你现在可以从一个更高的维度来看待 Tid Range Scan 了它不只是一个新的执行节点而是 PostgreSQL 成本模型里一个重要的新选项。理解它就是在理解整个执行计划选择机制的运作方式。我在实际项目里反复验证过Tid Range Scan 最打动我的地方不是某个单独指标有多好看而是它让已知数据位置这个信息第一次真正做到物尽其用。以前我们查到一批 ctid往往还是让数据库按老路去索引回表现在可以把物理位置信息直接喂给执行器让它按空间顺序高效扫完。这种顺着数据存放的自然规律干活的思路比任何花哨的缓存优化都来得踏实。如果你的业务正在处理大量批量操作建议升级到 PostgreSQL 14 之后多花点时间在 ctid 条件和执行计划上做点实验很可能会打开一扇新的大门。