前言
最近花了点时间看了下 PG 中的并行顺序扫描的实现,有了一些新的认知和思考。
页面如何分配?
首先是页面分配的问题,假如现在有个表 t,总共 100 个页面,那这些页面是如何分配给多个 worker 的?是人人平等,还是动态分配,亦或是纯随机乱扫?其实对于并行顺序扫描,其核心机制是:多个 worker 共享一个扫描进度指针,谁先拿到下一段 chunk,谁就去扫那一段 chunk。官方文档也是这么描述的
In a parallel sequential scan, the table’s blocks will be divided into ranges and shared among the cooperating processes. Each worker process will complete the scanning of its given range of blocks before requesting an additional range of blocks.
举个形象的例子:假如有 100 个货架格子和 3 个员工,门口有一个共享白板,写着下一个待处理的格子号,每个人每次去白板那儿领一小段任务,谁干得快谁就多领几段,直到所有格子都处理完。
以前我以为 PG 的算法是静态平均分配 — worker1 扫 033、worker2 扫 3466、worker3 扫 67~99,后面仔细一想,这种算法有着一些明显的问题:
- 负载不均,虽然都是差不多的页面,但是每个页面的处理成本可能不一样,比如有的页面里 tuple 多,有的页面命中更多过滤条件,后续计算更重,有的页面 HOT 链长、可见性检查更复杂等等,如果一开始就固定分死,可能某个 worker 很快扫完,另外两个还在忙,CPU 利用率不好。
- 其次,并行 worker 可能不是同时启动,也有可能因为各种原因卡死,动态领取能让先准备好的人先干活。
所以 PG 采用了 共享一个全局分配计数器 + 每个 worker 本地维护自己当前 chunk 的剩余页数,核心分配机制大致如下:
- Chunk 大小计算,在扫描开始时,系统计算 chunk 大小 tableam.c
1 | // 将总页面数分成 PARALLEL_SEQSCAN_NCHUNKS 个块,然后取最接近的2的幂 |
当前源码里的常量是:
- PARALLEL_SEQSCAN_NCHUNKS = 2048
- PARALLEL_SEQSCAN_RAMPDOWN_CHUNKS = 64
- PARALLEL_SEQSCAN_MAX_CHUNK_SIZE = 8192 blocks
源码注释说明,这样做的目标是把并行顺序扫描拆成大约 2048 到 1024 个 chunk,同时避免大表上的 chunk 过大,因为过大的 chunk 已被证明会损害顺序扫描性能。
- 页面分配,当有 worker 请求下一个页面时:
- 有剩余 chunk 页面:直接返回 chunk 中的下一个页面
- 需要新 chunk:通过原子操作分配新的 chunk 范围
1 | // 原子分配新的 chunk |
- 计算实际页面,最终页面号通过模运算计算
1 | if (nallocated >= scan_nblocks) |
- 动态调整,在扫描后期,系统会动态减小 chunk 大小,这样设计的目的很明显,前期 chunk 大一点,减少争抢共享计数器的频率;后期 chunk 小一点,避免最后只剩一些零头页面时,某个 worker 一把拿走太多,其他 worker 闲着,确保最后的工作能更均匀地分配给所有 worker。
1 | // 当剩余 chunk 少于 PARALLEL_SEQSCAN_RAMPDOWN_CHUNKS 时,减半 chunk 大小 |
因此以前面表 t 为例,假设有 100 个页面和 3 个worker:
- 初始 chunk 大小:100/8 ≈ 12.5 → 16 (最接近的 2 的幂)
- 分配过程:
- Worker 1:获得页面 0-15
- Worker 2:获得页面 16-31
- Worker 3:获得页面 32-47
- Worker 1:获得页面 48-63
- Worker 2:获得页面 64-79
- Worker 3:获得页面 80-95
- Worker 1:获得页面 96-99(最后 chunk 可能小于标准大小)
简而言之
- chunk 机制解决了早期版本中页面分散导致的 I/O 性能问题
- 实际分配可能因同步扫描起点而不同,但分配逻辑保持一致
书中怎么说
让我看看 14 Internals 书中是怎么描述的
为并行处理设计的节点之一是 Parallel Seq Scan 节点,负责执行并行顺序扫描。
这个名字听起来有点矛盾 (扫描究竟是顺序的还是并行的?),但无论如何,它反映了操作的本质。如果我们观察文件访问,会发现表页面是顺序读取的,遵循它们在简单顺序扫描中读取的顺序。然而,这个操作是由多个并发进程执行的。为了避免重复扫描同一个页面,执行器通过共享内存来同步这些进程。
此处有一个微妙的问题,操作系统无法获得典型的顺序扫描的全貌;相反,它看到的是多个执行随机读取的进程。因此,通常用于加速顺序扫描的数据预取几乎变得毫无用处。为了尽量减少这种不愉快的影响 ,PostgreSQL 为每个进程分配的不是一个页面,而是多个连续的页面进行读取。
因此,并行扫描看起来并没有多大意义,因为通常的读取成本因进程间的数据传输开销而增加。然而,如果工作进程对获取的行进行了后续处理 (如聚合),那么总执行时间可能会大大缩短。
这一段话听起来有点拗口,重点是从整个表的角度看,访问 block 的总体顺序仍然是按物理页号向前推进的,并且从单个 worker 的角度看,它拿到的是其中一段连续页面,多个进程协同完成一个全表顺序推进的扫描任务。
那么为什么不是每人一页?不难理解,如果每扫 1 页就去共享内存里抢下一页,原子操作和同步太频繁,其次很重要的一点是,这种行为会破坏操作系统眼中的顺序读特征,OS 最喜欢看到的是某个进程在连续读一段文件,这样才容易做预读提升性能,如果 3 个 worker 都是一页一页地跳着拿:
- worker1 拿第 0 页
- worker2 拿第 1 页
- worker3 拿第 2 页
- worker1 再拿第 3 页
那在 OS 看来,不是一个进程在稳定顺序读,而是多个进程在交错读同一个文件,这样顺序扫描的顺序 I/O 优势会被削弱。另外还有一段有点略微难懂的解释 — OS 看不到典型顺序扫描的全貌,单进程顺序扫描时,OS 看到的是一个进程从 block 0、1、2、3、4……这样往后读,对预读也非常友好。但并行顺序扫描时,OS 可能看到的是:
- 进程 A 在读 0~7
- 进程 B 在读 8~15
- 进程 C 在读 16~23
虽然从数据库整体视角看,这仍然是顺序推进的,但从 OS 视角看,是多个进程分别在读文件的不同区间,所以书里才会提到
操作系统无法获得典型顺序扫描的全貌;相反,它看到的是多个执行随机读取的进程。
这里的随机读取不是说 PG 在逻辑上真的随机扫页,而是说:从操作系统的 I/O 观察角度,这种访问模式不再像单进程顺序扫描那样纯粹。
最后一段则是重点,作者提及”并行扫描看起来并没有多大意义”,其实其意思不是说并行顺序扫描没价值,而是说:
- 如果只是单纯读磁盘页面这件事,并行化不一定总是更优的
- 因为单进程顺序扫描本来就很高效,并行后反而可能损失一部分顺序 I/O 的好处,还增加进程协作开销
所以真正让并行顺序扫描变得有意义的,通常不是”扫描”本身,而是扫描之后还有大量的 CPU 计算,比如 Join / Aggregation / Function Call 等等,也就是书里说的:
然而,如果工作进程对获取的行进行了后续处理(如聚合),那么总执行时间可能会大大缩短。
因此,简单小结一下就是:
- 并行顺序扫描在逻辑上仍然是按表页面顺序推进的,不是随机扫描。
- 多个 worker 通过共享内存协调,避免重复扫描同一页面。
- PG 不会按单页分工,而是按一小段连续页面分工,每个人拿到 chunk 后,本地独立顺序扫描,不需要互相发消息说”我扫到哪了”,只需要在申请新 chunk 时通过原子加操作协调一次,以减少同步开销并尽量保留顺序读特征。
- 从 OS 角度看,多进程交错读取会削弱单进程顺序扫描那种理想的预读模式。
- 因此,并行顺序扫描的收益往往更多来自后续并行计算,而不只是扫描动作本身
GP 7 支持并行吗?
我们都知道,GP7 是 PG 12 的内核,按理说也应该是支持并行的,但是翻看其 release note,并没有看到 Parallel 相关字眼,经验证也确实生成不了并行计划。

在测试文件中,明确注明了 GP 不支持并行查询
– GPDB_12_MERGE_FEATURE_NOT_SUPPORTED: We don’t support parallel query.
– These tests won’t actually generate any parallel plans.
Greenplum 有一些与并行相关的配置参数,但它们主要用于 MPP 架构的优化,而非 PG 风格的并行查询,其次 GP 使用 Gather Motion 节点收集 segment 的执行结果,这与 PG 的 Gather 节点功能类似但实现不同。
小结
当前 PG 这套并行顺序扫描方案实现简单、同步成本低、负载天然动态均衡,都是其优势,也符合 PG 的风格设计
— 优先用最小复杂度解决 80% 的问题。当然你说还有可以优化的地方吗?比如
- 没有感知页面处理成本差异
- NUMA / CPU / 存储拓扑感知的分配 (前阵子才写过 PG18 新特性 — 对于 NUMA 的感知)
- 14 Internals 书中提到的”区间亲和性”,更有利于预读
- …
拭目以待。