第一次在 EXPLAIN 里看到 Tid Range Scan 这个节点的时候,我愣了几秒。当时在处理一张订单明细表的迁移,五千多万行,表上没有主键、没有像样的索引,逻辑上只是想“把一部分数据搬出去归档”。常规做法是写个WHERE create_time < ...的过滤条件慢慢扫,但那一次,执行计划里冒出来一个以前没见过的节点:TID Range Scan。顺着这个节点查下去,才发现 PostgreSQL 14 已经悄悄补上了一个很实用的能力——按行的物理位置范围直接扫描表。这篇文章想把这个特性完整拆开讲清楚:TID 到底是什么,为什么 14 之前只能做等值查找,14 之后的范围扫描在内部如何工作,以及在什么场景下它能省下大量 IO、什么场景下它会帮倒忙。不只是看概念,我会把实际跑过的 SQL、执行计划、分片复制思路和踩过的坑都放出来,方便你直接照着用。1. 先搞清楚基础:ctid 是行的物理门牌号1.1 一个由“页码 + 槽位号”组成的系统列PostgreSQL 的堆表是按 8KB 的页面(page/block)来存放数据的。每一行数据,在某个页面内部又有自己的行指针,指向真正的元组数据。这个位置信息在每一行上都以一个系统列的形式暴露出来,叫作ctid,格式是(页码, 槽位号)。比如(42, 15)表示第 42 号物理页面里的第 15 个行槽位。你可以在任意一张表上执行下面这条 SQL,直观看到它的样子:SELECT ctid, * FROM orders WHERE id = 10086;这个ctid不属于任何业务字段,也不受 SQL 标准约束,它纯粹是 PostgreSQL 堆表的物理寻址信息。这就像楼房里每户人家的门牌号:一个楼栋编号加一个房间号。只要这一行在文件里的物理位置不变,它的ctid就不会变。1.2 为什么说它是“版本地址”需要明确的是,ctid定位的是这一行的当前版本,不是逻辑主键。当UPDATE发生时,新版本的行通常会被写到新的页面位置,原来的版本如果不再被任何事务需要,最终会在 vacuum 时被清理。所以,你在同一张表上连续执行两次SELECT ctid FROM ...,得到的结果完全可能不一样——如果有并发 DML 在动同一行。这个特点决定了ctid不适合作为长期稳定的外部标识,但在短时间内的局部操作里,它反而是最精准的定位手段。PostgreSQL 14 之前,针对ctid的扫描方式只有一种等值查找,也就是你知道确切位置,直接按门牌号找到那一行。而“从第几页到第几页范围内取数”这种需求,优化器处理不了,只能退回全表顺序扫描,或者靠其他索引条件间接过滤。2. PostgreSQL 14 的 TID Range Scan:优化器和执行器是怎么配合的2.1 优化器如何识别范围谓词PostgreSQL 14 的提交信息里明确写着:允许为元组标识符创建范围扫描路径。这里的核心作用是让优化器识别出ctid上的范围条件,并生成一个名为TidRangePath的执行路径,最终对应到执行器里的TidRangeScan节点。优化器会从 WHERE 子句里提取关于ctid的如下条件形态:ctid > '(1000, 1)' ctid >= '(1000, 1)' ctid < '(2000, 20)' ctid <= '(2000, 20)'或者语法糖式的组合:ctid BETWEEN '(1000, 1)' AND '(2000, 20)'当上界和下界同时存在,并且边界值是常量、类型能正确推断成tid,优化器就会尝试构造一个 TID 范围扫描路径。在执行计划里,它会显示成类似下面这样的节点:Tid Range Scan on orders (cost=... rows=...) TID Range: (1000,1) .. (2000,20)不是所有范围查询都会选这个路径,优化器仍然要走成本估算。但这类条件天然把要访问的物理页面限制在一个明确区间里,相比顺序扫描需要从表的第一页扫到最后一页,它的优势在范围较小时非常明显。2.2 执行器实际上在做什么执行器拿到TidRangeScan节点后,会先解析出起始 TID 和结束 TID。扫描按物理页码顺序进行:从起始页面开始,逐页读取,并在每个页面内按行槽位顺序返回落在范围内的元组。这里有几个值得注意的内部行为:执行器仍然要做 MVCC 可见性判断,所以返回的行都是当前快照下对当前事务可见的版本。扫描方向是物理顺序,这跟顺序扫描很像,但访问的页面集合是受限的。页面内的行槽位可能有空洞(行被删除、更新过),执行器会跳过这些空洞。对已经通过可见性映射标记为全空或不需要访问的页面,实现上有机会跳过,从而进一步节省 IO。换句话说,它不像索引扫描那样需要回表随机读,也不像顺序扫描那样必须把整个表从头到尾摸一遍。它更像是一段“定向的顺序扫描”,只扫你指定的物理范围。2.3 与 Seq Scan、Index Scan 的执行模式对照维度TID Range ScanSeq ScanIndex Scan访问范围一次性访问指定 TID 区间从头扫到尾依赖索引定位再回表是否要求有索引否否是输出顺序按物理位置顺序按物理位置顺序按索引键顺序,后续决定随机 IO几乎没有,顺序为主无常见,尤其范围大时主要适用场景按行物理位置批量取数无过滤条件或整表大量读取按业务字段精准查找对表结构要求无主键也可以无主键也可以必须有匹配索引对照下来你会发现,TID Range Scan 在气质上更接近“受限的顺序扫描”,但它把“扫描哪几页”这个决策交到了优化器手里,而不是傻傻地全表扫。3. 实际操作:TID Range Scan 最值得用的几个场景3.1 场景一:无主键大表按物理范围批量复制日常做表迁移或者备份时,最头疼的就是遇上没有主键、也没有可用索引的大表。比如埋点日志表、中间结果表。这时候你想做“取出若干行,插入另一张表”,经常只能靠WHERE多条件过滤,一旦过滤条件收益不好,就是全表扫描。更尴尬的是,想用并行加速时,不知道如何把一张没有主键的表干脆地切成几段。有了 TID Range Scan,切分的依据就简单多了:直接把物理页码作为分片键。假设表log_detail有 10000 个物理页面,你想分 4 段并行复制:-- 先看表规模和页面数 SELECT relpages, reltuples FROM pg_class WHERE relname = 'log_detail'; -- 段 1 CREATE TABLE log_detail_copy_1 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(0, 1)' AND '(2500, 200)'; -- 段 2 CREATE TABLE log_detail_copy_2 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(2501, 1)' AND '(5000, 200)'; -- 段 3 CREATE TABLE log_detail_copy_3 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(5001, 1)' AND '(7500, 200)'; -- 段 4 CREATE TABLE log_detail_copy_4 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(7501, 1)' AND '(10000, 200)';这里把偏移量上限设成200,是一个保守做法:普通页面里的最大行数一般不会超过这个值,即使超出也不影响正确性,因为扫描会自动截断到实际存在的元组。在多个会话里并行执行这些CREATE TABLE AS,每一段都只需要扫描对应的物理页面。相比每个会话里都全表扫一遍,总 IO 直接下降一个量级。3.2 场景二:让并行采集/迁移任务天然均匀分片延续上面的思路,如果你在写数据同步脚本,有没有主键就不再是瓶颈了。可以先把表的关键信息查出来:SELECT relpages FROM pg_class WHERE relname = 'log_detail';然后根据页面总数算出每一段的起止 TID,再把任务分发给多个并行 worker。一种简单的分发逻辑如下:total_pages = 10000 worker_count = 8 step = total_pages // worker_count for worker_id in range(worker_count): start_page = worker_id * step end_page = (worker_id + 1) * step - 1 sql = ( f"SELECT * FROM log_detail " f"WHERE ctid BETWEEN '({start_page}, 1)' AND '({end_page}, 200)'" ) # 交给不同的连接或 worker 执行这样做的好处是,每个 worker 之间互不重叠,而且按物理页面划分足够均匀。比按某个业务字段划分更不容易出现数据倾斜——因为物理页面的填充情况总体上是均匀的,某一段里的数据量不会比另一段夸张太多。3.3 场景三:局部数据修复和定向清理有时候你已经精确定位到了有问题的那几行,甚至能拿到它们的ctid。比如一张流水表里,你发现凌晨某个时间窗口内写入的数据有逻辑问题,想快速取出并修正:SELECT ctid, * FROM orders WHERE order_time BETWEEN '2025-01-01 00:00:00' AND '2025-01-01 00:30:00';这时候你会发现这批记录恰好落在某几个物理页里。接下来可以用ctid精确修正:UPDATE orders SET status = 'abnormal' WHERE ctid IN ('(1234, 5)', '(1234, 6)', '(1235, 2)');如果需要更新的是一个范围,比如一个物理页面内的连续多行,也可以直接写:UPDATE orders SET status = 'archived' WHERE ctid BETWEEN '(1234, 1)' AND '(1235, 100)';注意,这里 UPDATE 本身仍然要走行锁和 MVCC,但定位成本非常低,因为它不需要扫索引,也不需要通过业务字段二次过滤。在清理历史分区、修正导入错误这类一次性操作里,这种方法特别省事。3.4 用 EXPLAIN 验证计划是否正确生成实践出真知,写任何查询之前先看一眼执行计划。我们造一个小表验证一下:CREATE TABLE demo AS SELECT id, md5(random()::text) AS padding FROM generate_series(1, 100000) AS id; EXPLAIN (VERBOSE, ANALYZE, BUFFERS) SELECT * FROM demo WHERE ctid BETWEEN '(0, 1)' AND '(20, 100)';实际输出大致如下:Tid Range Scan on public.demo (cost=0.02..3.04 rows=100 width=40) Output: id, padding TID Range: (0,1)..(20,100)TID Range: (0,1)..(20,100)是关键信息,说明优化器已经正确识别了范围。如果这个节点没有出现,就要检查查询条件写法是否满足识别要求,后面我会专门讲排查方法。4. 性能收益与代价:TID Range Scan 到底快在哪4.1 最小化页面访问,避开索引随机读TID Range Scan 的收益本质来自两个层面:一是少读页面,二是读得规整。少读页面很好理解。范围扫描只需要访问start_tid到end_tid之间涉及的物理页。假设一张表有 10000 页,你只关心第 1000 到 1500 页,那最多只需要读取约 500 个页面;顺序扫描则是从第 0 页一直读到最后一页,哪怕后面大部分数据都不需要,也必须把钱付了。读得规整体现在 IO 模式上。按物理页面顺序访问,在磁盘层面基本是顺序 IO,尤其在机械硬盘上,顺序读和随机读的差距可以到一到两个数量级。索引扫描虽然能精准定位到每一行,但如果目标行分散在很多页面里,每一次回表都可能触发一次随机读。TID Range Scan 恰好避免了这种问题。4.2 对比顺序扫描和索引扫描的成本模型在优化器的成本公式里,TID Range Scan 的启动成本相对低,因为它不需要像索引扫描那样先读索引页,也不需要像顺序扫描那样准备整表遍历。不过,它毕竟还是堆表访问,所以单页读取成本和顺序扫描类似,主要取决于扫描范围的大小。还需要说明的是,TID Range Scan 不是银弹。如果一个范围条件覆盖了表中超过大半的页面,优化器很可能认为它和顺序扫描没有本质差异,此时成本上占不到便宜,优化的价值更多体现在“把范围明确表达出来”这件事上。换句话说,TID Range Scan 适合的场景是“明确知道只要一小段物理区间”,而不是“把整表分成一大半扫出来”。4.3 有一张主键索引就一定能赢吗不一定。假设有一张表主键是自增 id,你想取 id 从 100 万到 200 万的行。用主键索引范围扫描也能高效完成,而且语义上更自然,毕竟id是稳定列。但如果你要取的数据没有业务键,或者业务过滤条件无法走索引,TID Range Scan 就有了独特的用武之地。在主键连续且与物理顺序高度相关的情况下,主键索引扫描和 TID 范围扫描的性能差距通常不大。真正让 TID Range Scan 胜出的场景是:主键索引的键顺序和物理存储顺序不一致,或者根本没有主键可用。这时候,其他方案要么无法切片,要么承受巨大随机 IO。4.4 也要注意它的代价边界有一个代价很容易被忽略:如果范围条件的上下界相差很远,TID Range Scan 会退化成“近乎全表扫描的物理范围扫描”,但与顺序扫描相比,它仍然不带任何业务过滤逻辑。如果在这样一个大范围上再叠加大量 CPU 过滤条件,效率未必比顺序扫描好。所以实际使用中,我的经验是:范围宽度占全表 10% 以内,TID Range Scan 明显划算。范围宽度占全表 30% 以上,要谨慎评估,配合EXPLAIN ANALYZE实测。完全没有范围限制时,继续用顺序扫描。5. 常见问题与踩坑记录5.1 写了ctid BETWEEN,却没有出现 Tid Range Scan这是最常遇到的困惑。通常有几种原因:边界值不是常量。如果边界来自子查询、函数调用或参数,优化器无法在计划阶段确定范围。类型没有被正确推断成tid。比如直接写:WHERE ctid BETWEEN '(0,1)' AND '(20,100)'在部分场景下不会出问题,但为了保险,建议显式转换:WHERE ctid BETWEEN '(0,1)'::tid AND '(20,100)'::tid范围条件之外还有其他条件,优化器成本估算后选择了别的路径。此时可以强制验证一下:SET enable_seqscan = off; SET enable_indexscan = off; EXPLAIN SELECT * FROM demo WHERE ctid BETWEEN '(0,1)' AND '(20,100)';如果强制关闭其他路径后能看到 Tid Range Scan,说明条件本身没问题,只是成本模型没有倾向它。5.2 ctid 会在 VACUUM FULL / CLUSTER 之后失效这一点必须时刻挂在嘴边。ctid是物理位置,但VACUUM FULL、CLUSTER、pg_repack这类重写表的操作,会把整张表的行重新排列。重写之后,旧的ctid值就完全失效了,甚至可能指向完全不相干的行。更隐蔽的是,普通的VACUUM虽然不会移动行,但会清理死亡元组,影响页内槽位的复用。如果其他事务随后插入新行,可能会占用你之前见过的槽位。因此在跨事务保存ctid值时,要意识到它不是可靠的长期句柄,最长使用窗口应该是同一个短事务或同一批任务内。5.3 并行分片时如何避免重复与遗漏按 TID 范围并行处理时,最容易出问题的是边界重叠或遗漏。比如上一段以(2500, 200)结尾,下一段从(2500, 201)开始,还是从(2501, 1)开始,需要严格设计。更稳妥的做法是:把所有分片范围放在同一个事务里执行,使用相同的事务快照。这样一来,即使有并发的 DML,所有会话看到的表内容也是一致的,不会出现某个范围内的行在两个分片里都被读到,或者某个范围内的行在两个分片里都没被读到的情况。5.4 别把结果顺序当成逻辑顺序TID Range Scan 按物理位置顺序返回结果,这种顺序与业务逻辑顺序无关。如果你需要对结果按业务键排序,仍然要显式写ORDER BY。也不要因为看到返回顺序刚好是某个字段升序,就误以为以后都能这么用——物理顺序随时可能因数据重写而改变。5.5 小心大范围扫描与 HOT 更新在高更新频率的表上,HOT(Heap-Only Tuple)更新会尽量把新版本留在同一个页面中,但页面空间不够时,新版本也会跑到其他页面。这意味着某一行在索引里的键没变,但其ctid可能已经变化。所以如果你先用普通查询拿到了若干行的ctid,又过了很久才用这些ctid去做 UPDATE/DELETE,必须意识到指向的可能是另一个行版本,甚至另一行。5.6 常见问题速查表问题排查方向建议计划里没有 Tid Range Scan边界不是常量 / 类型未推断显式'...'::tid,检查 SQL 形态范围太大,性能不明显覆盖页面占比过高缩小分片,或改用顺序扫描实测跨事务使用 ctid 失效表被重写或槽位复用尽量单事务内完成并行分片出现重复/漏数边界重叠或快照不一致统一快照,严格定义区间需要稳定返回顺序依赖物理顺序显式ORDER BY6. 落地心得:我把 TID Range Scan 放到流程里的方式在实际项目里,我一般不会因为它叫“14 新特性”就到处用。真正让我把它固定进工具箱的场景,还是那几类明确定位的批量操作。比如做冷热数据分离时,一张大日志表没有主键,之前用pg_dump或者SELECT ... WHERE搬运都非常痛苦。现在我会先跑一次SELECT relpages FROM pg_class WHERE relname = ...拿到物理页数,然后按 worker 数做切片,每个 worker 里执行CTAS ... WHERE ctid BETWEEN ...。整个过程简单直接,不需要额外造主键,也不需要先做冗长的全表扫描。还有一个经验是,别把 TID Range Scan 当成 SQL 优化的日常手段。日常 OLTP 查询继续用主键或普通索引就好,业务条件命中率高的场景,索引扫描仍是正确的选择。但如果某天你遇到一张没有可用索引的大表,又需要快速搬运、分块处理、定向修复时,记得翻出这个老朋友——它真的能在计划里帮你省下一大截 IO 时间。最后补充一个小技巧:执行计划的EXPLAIN (ANALYZE, BUFFERS)输出里,可以重点看Buffers: shared hit/read的数据。TID Range Scan 的优势最终都会体现在这里的页面访问量上,这个数字远比时间数字更可靠。毕竟机器负载不同,时间会骗人,读了多少页不会。
第一次在 EXPLAIN 里看到 Tid Range Scan 这个节点的时候,我愣了几秒。当时在处理一张订单明细表的迁移,五千多万行,表上没有主键、没有像样的索引,逻辑上只是想“把一部分数据搬出去归档”。常规做法是写个WHERE create_time < ...的过滤条件慢慢扫,但那一次,执行计划里冒出来一个以前没见过的节点:TID Range Scan。顺着这个节点查下去,才发现 PostgreSQL 14 已经悄悄补上了一个很实用的能力——按行的物理位置范围直接扫描表。这篇文章想把这个特性完整拆开讲清楚:TID 到底是什么,为什么 14 之前只能做等值查找,14 之后的范围扫描在内部如何工作,以及在什么场景下它能省下大量 IO、什么场景下它会帮倒忙。不只是看概念,我会把实际跑过的 SQL、执行计划、分片复制思路和踩过的坑都放出来,方便你直接照着用。1. 先搞清楚基础:ctid 是行的物理门牌号1.1 一个由“页码 + 槽位号”组成的系统列PostgreSQL 的堆表是按 8KB 的页面(page/block)来存放数据的。每一行数据,在某个页面内部又有自己的行指针,指向真正的元组数据。这个位置信息在每一行上都以一个系统列的形式暴露出来,叫作ctid,格式是(页码, 槽位号)。比如(42, 15)表示第 42 号物理页面里的第 15 个行槽位。你可以在任意一张表上执行下面这条 SQL,直观看到它的样子:SELECT ctid, * FROM orders WHERE id = 10086;这个ctid不属于任何业务字段,也不受 SQL 标准约束,它纯粹是 PostgreSQL 堆表的物理寻址信息。这就像楼房里每户人家的门牌号:一个楼栋编号加一个房间号。只要这一行在文件里的物理位置不变,它的ctid就不会变。1.2 为什么说它是“版本地址”需要明确的是,ctid定位的是这一行的当前版本,不是逻辑主键。当UPDATE发生时,新版本的行通常会被写到新的页面位置,原来的版本如果不再被任何事务需要,最终会在 vacuum 时被清理。所以,你在同一张表上连续执行两次SELECT ctid FROM ...,得到的结果完全可能不一样——如果有并发 DML 在动同一行。这个特点决定了ctid不适合作为长期稳定的外部标识,但在短时间内的局部操作里,它反而是最精准的定位手段。PostgreSQL 14 之前,针对ctid的扫描方式只有一种等值查找,也就是你知道确切位置,直接按门牌号找到那一行。而“从第几页到第几页范围内取数”这种需求,优化器处理不了,只能退回全表顺序扫描,或者靠其他索引条件间接过滤。2. PostgreSQL 14 的 TID Range Scan:优化器和执行器是怎么配合的2.1 优化器如何识别范围谓词PostgreSQL 14 的提交信息里明确写着:允许为元组标识符创建范围扫描路径。这里的核心作用是让优化器识别出ctid上的范围条件,并生成一个名为TidRangePath的执行路径,最终对应到执行器里的TidRangeScan节点。优化器会从 WHERE 子句里提取关于ctid的如下条件形态:ctid > '(1000, 1)' ctid >= '(1000, 1)' ctid < '(2000, 20)' ctid <= '(2000, 20)'或者语法糖式的组合:ctid BETWEEN '(1000, 1)' AND '(2000, 20)'当上界和下界同时存在,并且边界值是常量、类型能正确推断成tid,优化器就会尝试构造一个 TID 范围扫描路径。在执行计划里,它会显示成类似下面这样的节点:Tid Range Scan on orders (cost=... rows=...) TID Range: (1000,1) .. (2000,20)不是所有范围查询都会选这个路径,优化器仍然要走成本估算。但这类条件天然把要访问的物理页面限制在一个明确区间里,相比顺序扫描需要从表的第一页扫到最后一页,它的优势在范围较小时非常明显。2.2 执行器实际上在做什么执行器拿到TidRangeScan节点后,会先解析出起始 TID 和结束 TID。扫描按物理页码顺序进行:从起始页面开始,逐页读取,并在每个页面内按行槽位顺序返回落在范围内的元组。这里有几个值得注意的内部行为:执行器仍然要做 MVCC 可见性判断,所以返回的行都是当前快照下对当前事务可见的版本。扫描方向是物理顺序,这跟顺序扫描很像,但访问的页面集合是受限的。页面内的行槽位可能有空洞(行被删除、更新过),执行器会跳过这些空洞。对已经通过可见性映射标记为全空或不需要访问的页面,实现上有机会跳过,从而进一步节省 IO。换句话说,它不像索引扫描那样需要回表随机读,也不像顺序扫描那样必须把整个表从头到尾摸一遍。它更像是一段“定向的顺序扫描”,只扫你指定的物理范围。2.3 与 Seq Scan、Index Scan 的执行模式对照维度TID Range ScanSeq ScanIndex Scan访问范围一次性访问指定 TID 区间从头扫到尾依赖索引定位再回表是否要求有索引否否是输出顺序按物理位置顺序按物理位置顺序按索引键顺序,后续决定随机 IO几乎没有,顺序为主无常见,尤其范围大时主要适用场景按行物理位置批量取数无过滤条件或整表大量读取按业务字段精准查找对表结构要求无主键也可以无主键也可以必须有匹配索引对照下来你会发现,TID Range Scan 在气质上更接近“受限的顺序扫描”,但它把“扫描哪几页”这个决策交到了优化器手里,而不是傻傻地全表扫。3. 实际操作:TID Range Scan 最值得用的几个场景3.1 场景一:无主键大表按物理范围批量复制日常做表迁移或者备份时,最头疼的就是遇上没有主键、也没有可用索引的大表。比如埋点日志表、中间结果表。这时候你想做“取出若干行,插入另一张表”,经常只能靠WHERE多条件过滤,一旦过滤条件收益不好,就是全表扫描。更尴尬的是,想用并行加速时,不知道如何把一张没有主键的表干脆地切成几段。有了 TID Range Scan,切分的依据就简单多了:直接把物理页码作为分片键。假设表log_detail有 10000 个物理页面,你想分 4 段并行复制:-- 先看表规模和页面数 SELECT relpages, reltuples FROM pg_class WHERE relname = 'log_detail'; -- 段 1 CREATE TABLE log_detail_copy_1 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(0, 1)' AND '(2500, 200)'; -- 段 2 CREATE TABLE log_detail_copy_2 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(2501, 1)' AND '(5000, 200)'; -- 段 3 CREATE TABLE log_detail_copy_3 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(5001, 1)' AND '(7500, 200)'; -- 段 4 CREATE TABLE log_detail_copy_4 AS SELECT * FROM log_detail WHERE ctid BETWEEN '(7501, 1)' AND '(10000, 200)';这里把偏移量上限设成200,是一个保守做法:普通页面里的最大行数一般不会超过这个值,即使超出也不影响正确性,因为扫描会自动截断到实际存在的元组。在多个会话里并行执行这些CREATE TABLE AS,每一段都只需要扫描对应的物理页面。相比每个会话里都全表扫一遍,总 IO 直接下降一个量级。3.2 场景二:让并行采集/迁移任务天然均匀分片延续上面的思路,如果你在写数据同步脚本,有没有主键就不再是瓶颈了。可以先把表的关键信息查出来:SELECT relpages FROM pg_class WHERE relname = 'log_detail';然后根据页面总数算出每一段的起止 TID,再把任务分发给多个并行 worker。一种简单的分发逻辑如下:total_pages = 10000 worker_count = 8 step = total_pages // worker_count for worker_id in range(worker_count): start_page = worker_id * step end_page = (worker_id + 1) * step - 1 sql = ( f"SELECT * FROM log_detail " f"WHERE ctid BETWEEN '({start_page}, 1)' AND '({end_page}, 200)'" ) # 交给不同的连接或 worker 执行这样做的好处是,每个 worker 之间互不重叠,而且按物理页面划分足够均匀。比按某个业务字段划分更不容易出现数据倾斜——因为物理页面的填充情况总体上是均匀的,某一段里的数据量不会比另一段夸张太多。3.3 场景三:局部数据修复和定向清理有时候你已经精确定位到了有问题的那几行,甚至能拿到它们的ctid。比如一张流水表里,你发现凌晨某个时间窗口内写入的数据有逻辑问题,想快速取出并修正:SELECT ctid, * FROM orders WHERE order_time BETWEEN '2025-01-01 00:00:00' AND '2025-01-01 00:30:00';这时候你会发现这批记录恰好落在某几个物理页里。接下来可以用ctid精确修正:UPDATE orders SET status = 'abnormal' WHERE ctid IN ('(1234, 5)', '(1234, 6)', '(1235, 2)');如果需要更新的是一个范围,比如一个物理页面内的连续多行,也可以直接写:UPDATE orders SET status = 'archived' WHERE ctid BETWEEN '(1234, 1)' AND '(1235, 100)';注意,这里 UPDATE 本身仍然要走行锁和 MVCC,但定位成本非常低,因为它不需要扫索引,也不需要通过业务字段二次过滤。在清理历史分区、修正导入错误这类一次性操作里,这种方法特别省事。3.4 用 EXPLAIN 验证计划是否正确生成实践出真知,写任何查询之前先看一眼执行计划。我们造一个小表验证一下:CREATE TABLE demo AS SELECT id, md5(random()::text) AS padding FROM generate_series(1, 100000) AS id; EXPLAIN (VERBOSE, ANALYZE, BUFFERS) SELECT * FROM demo WHERE ctid BETWEEN '(0, 1)' AND '(20, 100)';实际输出大致如下:Tid Range Scan on public.demo (cost=0.02..3.04 rows=100 width=40) Output: id, padding TID Range: (0,1)..(20,100)TID Range: (0,1)..(20,100)是关键信息,说明优化器已经正确识别了范围。如果这个节点没有出现,就要检查查询条件写法是否满足识别要求,后面我会专门讲排查方法。4. 性能收益与代价:TID Range Scan 到底快在哪4.1 最小化页面访问,避开索引随机读TID Range Scan 的收益本质来自两个层面:一是少读页面,二是读得规整。少读页面很好理解。范围扫描只需要访问start_tid到end_tid之间涉及的物理页。假设一张表有 10000 页,你只关心第 1000 到 1500 页,那最多只需要读取约 500 个页面;顺序扫描则是从第 0 页一直读到最后一页,哪怕后面大部分数据都不需要,也必须把钱付了。读得规整体现在 IO 模式上。按物理页面顺序访问,在磁盘层面基本是顺序 IO,尤其在机械硬盘上,顺序读和随机读的差距可以到一到两个数量级。索引扫描虽然能精准定位到每一行,但如果目标行分散在很多页面里,每一次回表都可能触发一次随机读。TID Range Scan 恰好避免了这种问题。4.2 对比顺序扫描和索引扫描的成本模型在优化器的成本公式里,TID Range Scan 的启动成本相对低,因为它不需要像索引扫描那样先读索引页,也不需要像顺序扫描那样准备整表遍历。不过,它毕竟还是堆表访问,所以单页读取成本和顺序扫描类似,主要取决于扫描范围的大小。还需要说明的是,TID Range Scan 不是银弹。如果一个范围条件覆盖了表中超过大半的页面,优化器很可能认为它和顺序扫描没有本质差异,此时成本上占不到便宜,优化的价值更多体现在“把范围明确表达出来”这件事上。换句话说,TID Range Scan 适合的场景是“明确知道只要一小段物理区间”,而不是“把整表分成一大半扫出来”。4.3 有一张主键索引就一定能赢吗不一定。假设有一张表主键是自增 id,你想取 id 从 100 万到 200 万的行。用主键索引范围扫描也能高效完成,而且语义上更自然,毕竟id是稳定列。但如果你要取的数据没有业务键,或者业务过滤条件无法走索引,TID Range Scan 就有了独特的用武之地。在主键连续且与物理顺序高度相关的情况下,主键索引扫描和 TID 范围扫描的性能差距通常不大。真正让 TID Range Scan 胜出的场景是:主键索引的键顺序和物理存储顺序不一致,或者根本没有主键可用。这时候,其他方案要么无法切片,要么承受巨大随机 IO。4.4 也要注意它的代价边界有一个代价很容易被忽略:如果范围条件的上下界相差很远,TID Range Scan 会退化成“近乎全表扫描的物理范围扫描”,但与顺序扫描相比,它仍然不带任何业务过滤逻辑。如果在这样一个大范围上再叠加大量 CPU 过滤条件,效率未必比顺序扫描好。所以实际使用中,我的经验是:范围宽度占全表 10% 以内,TID Range Scan 明显划算。范围宽度占全表 30% 以上,要谨慎评估,配合EXPLAIN ANALYZE实测。完全没有范围限制时,继续用顺序扫描。5. 常见问题与踩坑记录5.1 写了ctid BETWEEN,却没有出现 Tid Range Scan这是最常遇到的困惑。通常有几种原因:边界值不是常量。如果边界来自子查询、函数调用或参数,优化器无法在计划阶段确定范围。类型没有被正确推断成tid。比如直接写:WHERE ctid BETWEEN '(0,1)' AND '(20,100)'在部分场景下不会出问题,但为了保险,建议显式转换:WHERE ctid BETWEEN '(0,1)'::tid AND '(20,100)'::tid范围条件之外还有其他条件,优化器成本估算后选择了别的路径。此时可以强制验证一下:SET enable_seqscan = off; SET enable_indexscan = off; EXPLAIN SELECT * FROM demo WHERE ctid BETWEEN '(0,1)' AND '(20,100)';如果强制关闭其他路径后能看到 Tid Range Scan,说明条件本身没问题,只是成本模型没有倾向它。5.2 ctid 会在 VACUUM FULL / CLUSTER 之后失效这一点必须时刻挂在嘴边。ctid是物理位置,但VACUUM FULL、CLUSTER、pg_repack这类重写表的操作,会把整张表的行重新排列。重写之后,旧的ctid值就完全失效了,甚至可能指向完全不相干的行。更隐蔽的是,普通的VACUUM虽然不会移动行,但会清理死亡元组,影响页内槽位的复用。如果其他事务随后插入新行,可能会占用你之前见过的槽位。因此在跨事务保存ctid值时,要意识到它不是可靠的长期句柄,最长使用窗口应该是同一个短事务或同一批任务内。5.3 并行分片时如何避免重复与遗漏按 TID 范围并行处理时,最容易出问题的是边界重叠或遗漏。比如上一段以(2500, 200)结尾,下一段从(2500, 201)开始,还是从(2501, 1)开始,需要严格设计。更稳妥的做法是:把所有分片范围放在同一个事务里执行,使用相同的事务快照。这样一来,即使有并发的 DML,所有会话看到的表内容也是一致的,不会出现某个范围内的行在两个分片里都被读到,或者某个范围内的行在两个分片里都没被读到的情况。5.4 别把结果顺序当成逻辑顺序TID Range Scan 按物理位置顺序返回结果,这种顺序与业务逻辑顺序无关。如果你需要对结果按业务键排序,仍然要显式写ORDER BY。也不要因为看到返回顺序刚好是某个字段升序,就误以为以后都能这么用——物理顺序随时可能因数据重写而改变。5.5 小心大范围扫描与 HOT 更新在高更新频率的表上,HOT(Heap-Only Tuple)更新会尽量把新版本留在同一个页面中,但页面空间不够时,新版本也会跑到其他页面。这意味着某一行在索引里的键没变,但其ctid可能已经变化。所以如果你先用普通查询拿到了若干行的ctid,又过了很久才用这些ctid去做 UPDATE/DELETE,必须意识到指向的可能是另一个行版本,甚至另一行。5.6 常见问题速查表问题排查方向建议计划里没有 Tid Range Scan边界不是常量 / 类型未推断显式'...'::tid,检查 SQL 形态范围太大,性能不明显覆盖页面占比过高缩小分片,或改用顺序扫描实测跨事务使用 ctid 失效表被重写或槽位复用尽量单事务内完成并行分片出现重复/漏数边界重叠或快照不一致统一快照,严格定义区间需要稳定返回顺序依赖物理顺序显式ORDER BY6. 落地心得:我把 TID Range Scan 放到流程里的方式在实际项目里,我一般不会因为它叫“14 新特性”就到处用。真正让我把它固定进工具箱的场景,还是那几类明确定位的批量操作。比如做冷热数据分离时,一张大日志表没有主键,之前用pg_dump或者SELECT ... WHERE搬运都非常痛苦。现在我会先跑一次SELECT relpages FROM pg_class WHERE relname = ...拿到物理页数,然后按 worker 数做切片,每个 worker 里执行CTAS ... WHERE ctid BETWEEN ...。整个过程简单直接,不需要额外造主键,也不需要先做冗长的全表扫描。还有一个经验是,别把 TID Range Scan 当成 SQL 优化的日常手段。日常 OLTP 查询继续用主键或普通索引就好,业务条件命中率高的场景,索引扫描仍是正确的选择。但如果某天你遇到一张没有可用索引的大表,又需要快速搬运、分块处理、定向修复时,记得翻出这个老朋友——它真的能在计划里帮你省下一大截 IO 时间。最后补充一个小技巧:执行计划的EXPLAIN (ANALYZE, BUFFERS)输出里,可以重点看Buffers: shared hit/read的数据。TID Range Scan 的优势最终都会体现在这里的页面访问量上,这个数字远比时间数字更可靠。毕竟机器负载不同,时间会骗人,读了多少页不会。