搞数据库的人应该都有过这样的经历明明要处理的数据就“躺”在那张表里可就是拿不出来或者说拿得不够快。尤其当面对几千万行的大表时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有两种应用方式很常见在会话级把优化器开关enable_tidscan保留默认开启默认就是 on并且在 SQL 里直接写WHERE ctid BETWEEN ... AND ...。PostgreSQL 14 里优化器会意识到这个条件可以被 Tid Range Scan 利用。在较新版本中还有一种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 (cost0.00..43520.00 rows11112 width32)加一个Filter: (ctid (100,1)::tid) AND (ctid (200,0)::tid)这意味着它从头扫到尾再过滤。到了 PostgreSQL 14计划大概率变成Tid Range Scan on t_user_login (cost0.02..8.04 rows10112 width32) 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 页。我们做一个不太严谨但直观的估算每页 8KB30000 页约 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); -- 执行时间约 290msBuffers: 30000 hit... read30000 -- 14 上的 Tid Range Scan 写法 EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM t_user_login WHERE ctid BETWEEN (100,1) AND (200,0); -- 执行时间约 28msBuffers: read102十倍的响应提升几乎白给而且还没有算上执行器后续处理行的成本。为什么这么明显因为锁粒度小、要读入的页面数少自然 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 relnamet_user_login AND relnamespacepublic::regnamespace) STEP1000 for (( start0; startPAGES; startSTEP )); do end$((startSTEP)) 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的优化上线前务必用测试环境把会话隔离级别、并发分片锁机制、表膨胀程度、目标区间密度摸清楚。别只盯着单次查询的毫秒数一致性、锁冲突、误读脏数据和运维可解释性往往是生产事故的温床。每回踩完坑我再看一遍执行计划都还是会忍不住感叹执行计划诚实得很它把所有物理世界的代价都摆在你面前了。
阅读完成 · 觉得有帮助?