想象你是一家餐厅的主厨,接到一张宴席菜单:一份十斤的炖牛腩。你一个人守着大锅,慢慢翻炒、收汁。客人催菜时,你终于喊来三个帮厨:一个人洗土豆,一个人切萝卜,一个人烧开水,你再把半成品汇总进同一口锅里。这就是 PostgreSQL 的并行查询。 在 PostgreSQL 里,一条 SQL 就是一份菜单。以前执行引擎只派一名“数据库服务进程”从头跑到尾;当它判断这份菜单够大、够复杂时,会临时召集多个 worker 进程,把表的数据块拆成几份并行处理,最后通过一个 Gather 节点把结果交回给主进程。对分析型查询来说,这就像从“一个人做全席”变成“一群厨师流水线作战”。 本文不要求你写过数据库内核。我们会从主厨和帮厨的视角,依次讲清楚并行查询为什么能加速、worker 怎么分配、Gather 怎么汇总、顺序扫描和连接怎么拆分工、哪些参数在控制厨房规模,以及什么时候不要强行用并行。

岗位关联

对数据开发与 DBA 而言,掌握并行查询意味着不需要改写业务 SQL,就能让大表统计、特征聚合和报表查询在分钟级内完成,这是给分析型 workload 提效的必修课。

目录
  1. 可视化演示
  2. 并行查询原理
  3. Worker 进程分配
  4. Gather 节点
  5. 并行顺序扫描
  6. 并行连接
  7. 配置参数
  8. 适用与调优
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

动态演示PostgreSQL并行查询的核心机制,通过采集/分析/决策/反馈四种模式展示关键概念与交互流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

PostgreSQL并行查询:并行查询 并行查询核心概念与Worker 并行顺序扫描与并行聚合计算 核心机制可视化 PostgreSQL并行查询整体架构与关键组件

并行查询原理

晚上八点,餐厅坐满了客人。主厨面前立着一块写满菜单的白板:三十桌都要“什锦大锅菜”,每份要用十种食材。若主厨一个人洗、切、炒、装盘,这道菜可能要四十分钟才上桌。他决定把食材分成三份,叫来三位帮厨同时备料,自己最后把三份半成品倒回大锅一起收汁。这个“先分工、再汇总”的流程,就是并行查询最朴素的模型。

在 PostgreSQL 中,执行一条 SQL 的进程叫 backend(数据库服务进程),它平时就像那位独自干活的主厨。查询优化器首先会形成计划,如果表很大、机器又有空闲 CPU,优化器可能放弃串行计划,改选并行计划:在计划树中插入 Parallel Seq Scan、Parallel Join 等节点,并在最上方放一个 Gather 节点。执行时,多个 worker 进程负责底层扫描和计算,backend 进程通过 Gather 把结果收回来。对用户而言,SQL 没有变,返回的结果集也没有变,只是“后厨团队”变大了。

这并不意味着时间一定缩短为原来的 N 分之一。启动 worker 有固定开销,把结果从各 worker 传回 Gather 也要花时间。用公式看更清楚:

所以并行不是“核越多越快”。只有在 T_s 足够大、能摊薄启动和汇总成本时,请帮厨才划算。这个“划算不划算”的账,后面要讲的优化器代价模型会替我们算。

S(N)=TsTp(N)=TsTsetup+TsN+Tgather\displaystyle S(N) = \frac{T_s}{T_p(N)} = \frac{T_s}{T_{\mathrm{setup}} + \dfrac{T_s}{N} + T_{\mathrm{gather}}}
  1. T_s(串行总耗时)这是主厨一个人做完整道菜所需的时间。
    TsT_s
  2. T_p(N)(并行总耗时)使用 N 个 worker 之后,从备料、烹饪到汇总上菜的全部时间。
    Tp(N)=Tsetup+TsN+TgatherT_p(N) = T_{\mathrm{setup}} + \frac{T_s}{N} + T_{\mathrm{gather}}
  3. T_{\mathrm{setup}}(启动开销)叫帮厨进门、分派任务、分配共享内存都需要固定成本。
    TsetupT_{\mathrm{setup}}
  4. \dfrac{T_s}{N}(理想分工作业时间)如果任务能完全均匀地分给 N 个 worker,每个人只需原来的 N 分之一时间。
    TsN\frac{T_s}{N}
  5. T_{\mathrm{gather}}(汇总开销)帮厨把半成品端到传菜窗口,主厨再一锅收汁,这本身就是成本。
    TgatherT_{\mathrm{gather}}

Worker 进程分配

主厨不可能平时养一百个帮厨在厨房里发呆。接到大单后,他打电话给临时工,菜做完便结账走人。PostgreSQL 中的 parallel worker 就是这样的临时后台进程:查询开始执行时才按需生成,查询结束就退出释放资源。它不是客户端连接,而是多个独立的 postgres 进程,每个 worker 都有自己的执行状态和内存。

分配 worker 时有两层限制。第一层是单个 Gather 节点最多能请几位帮厨,也就是 max_parallel_workers_per_gather;第二层是全库同时在岗的临时工总数,也就是 max_parallel_workers。假如有三个查询同时并行,每个都想请 4 个 worker,但全局池只有 8 个,那么后来的查询可能只拿到更少的 worker,甚至拿不到 worker。这个上限关系可以写成:

后厨不是养了一群吃闲饭的人,而是高峰时临时叫人。DBA 调参数时,只调大单个 Gather 上限可能不够;如果全局池已经被占满,换多少块“招聘启事”都请不到人。

Wused≤min⁡(Wmax, G×Wper_gather)\displaystyle W_{\mathrm{used}} \le \min\left(W_{\mathrm{max}},\ G \times W_{\mathrm{per\_gather}}\right)
  1. W_{\mathrm{used}}(实际使用的 worker 数)最终这台查询真的请到几位帮厨,可能比计划少。
    WusedW_{\mathrm{used}}
  2. W_{\mathrm{max}}(全局 worker 上限)由 max_parallel_workers 控制,整个数据库同时只能有这么多帮厨。
    WmaxW_{\mathrm{max}}
  3. G(Gather 节点数量)一个执行计划里可能有多个 Gather,每个 Gather 都是一个传菜窗口。
    GG
  4. W_{\mathrm{per\_gather}}(单 Gather 上限)由 max_parallel_workers_per_gather 控制,一个窗口最多收几个帮厨的菜。
    Wper_gatherW_{\mathrm{per\_gather}}
  5. \min(取最小值)两层限制必须同时满足,哪边先到顶就听哪边。
    min⁡(Wmax, G×Wper_gather)\min\left(W_{\mathrm{max}},\ G \times W_{\mathrm{per\_gather}}\right)

Gather 节点

有了帮厨,还得有一个传菜窗口。帮厨们把做好的半成品端到窗口,主厨站在窗口外面接菜。Gather 节点就是计划树上那扇传菜窗口:它是并行部分与串行部分的分界线,也是唯一把元组(一行行数据)送回主进程的节点。

在 EXPLAIN 输出里,Gather 的下一层通常是 -> Parallel Seq Scan 或 -> Parallel Hash Join。下层节点运行在多个 worker 中,worker 把结果元组放进共享内存队列;Gather 节点从队列里一条条取出,再交给上层节点。对上层而言,仿佛只是从一个普通表扫描读取数据。Gather 只是一个传输管道,它不会偷偷改数据,也不会替你完成 GROUP BY、ORDER BY。若需要聚合,计划会先在 worker 内做 Partial Aggregate(局部聚合),再由 Gather 之上的 Finalize Aggregate(最终聚合)合并。

这个设计让“并行”对上层透明。上层节点不需要知道数据来自 1 个 worker 还是 8 个 worker,只要能不断读到元组就行。这也是为什么 EXPLAIN 中看到 Gather,就基本锁定了并行执行的位置。

并行顺序扫描

全表扫描就像面对一大袋土豆,主厨让帮厨自己从袋子里拿土豆削皮。为了防止两个人削同一颗,帮厨先写一个号码,领走下一段。PostgreSQL 的并行顺序扫描也是这样:表在磁盘上由固定大小的页面组成,worker 通过一个共享的 page range 计数器领取页面区间,领到哪段就扫描哪段。

这种“动态领任务”比把表硬切成 N 份更聪明。如果某个 worker 的磁盘 I/O 特别慢,它只会少领一些区间;快的 worker 会继续领后面的页面,让总进度不会困在某一台“慢厨子”身上。因为页面区间互不重叠,同一行不会被两个 worker 重复扫描。EXPLAIN 中会出现 Parallel Seq Scan on 表名,下面可能还有 Workers Planned: N。

如果串行扫描整张表需要时间 T_{\mathrm{scan}},那么 N 个 worker 的理想耗时接近:

土豆削得再快,也需要有人把袋子搬进来;T_{\mathrm{setup}} 就是搬袋子的时间。表不够大时,请帮厨反而浪费时间。

Tpscan(N)=Tsetup+TscanN\displaystyle T_{\mathrm{pscan}}(N) = T_{\mathrm{setup}} + \frac{T_{\mathrm{scan}}}{N}
  1. T_{\mathrm{pscan}}(N)(并行顺序扫描预计耗时)这是模拟帮厨同时削土豆,最后把所有土豆片汇集起来所需的时间。
    Tpscan(N)T_{\mathrm{pscan}}(N)
  2. T_{\mathrm{scan}}(串行扫描全表耗时)一个进程从头到尾读完所有表页面所需的时间。
    TscanT_{\mathrm{scan}}
  3. N(worker 数)实际削土豆的帮厨人数,也是执行计划里的并行度。
    NN
  4. T_{\mathrm{setup}}(启动开销)初始化共享扫描状态、为 worker 分配页面区间都需要时间。
    TsetupT_{\mathrm{setup}}
  5. \frac{T_{\mathrm{scan}}}{N}(每 worker 的理想工作量)在完全均匀的理想世界里,每个人只干串行时间的 N 分之一。
    TscanN\frac{T_{\mathrm{scan}}}{N}

并行连接

如果一道菜需要把鸡丁和花生米按编号配对,帮厨不能随便把自己手里的食材丢进同一口锅。A 桌的鸡肉丁要配 A 桌的花生米,B 桌配 B 桌。于是厨房把食材按编号重新分到不同操作台,每个操作台对应一位帮厨,这就是连接键重分布。

在 PostgreSQL 中,最常见的并行连接是 Parallel Hash Join(并行哈希连接)。所有 worker 一起参与构建一张共享哈希表,再并行地拿另一张表的每一行去表中探测。大连接因此不再是单进程慢慢做,而是多个 worker 在共享内存上协作。另一个选项是 Parallel Nested Loop(并行嵌套循环):外表被并行扫描,内表反复走索引;它的开销通常更高,适合外表大、内表小且有索引的场景。

连接不是免费的。为了让两个输入能被 worker 协同处理,优化器会估算连接后的行数和传输代价,决定使用哪张表作为驱动表,以及是否需要重分布。调优时最常做的动作是:确认连接键是否合适、表统计信息是否新鲜,然后看 EXPLAIN 中并行哈希的构建与探测是否均衡。

配置参数

后厨墙上挂着一张管理制度牌。每个参数都是一条制度:max_parallel_workers_per_gather 控制一个 Gather 最多同时接收几个帮厨的菜,默认是 2;max_parallel_workers 控制全库同时在岗的 worker 总数,默认是 8;min_parallel_table_scan_size 控制低于多大体量的“订单”不叫帮厨,默认是 8MB;parallel_setup_cost 是叫一次帮厨的固定成本,默认是 1000;parallel_tuple_cost 是从 Gather 里端出一行菜的传输成本,默认是 0.1。

优化器不会只看参数,而是把所有代价放上天平。parallel_setup_cost 和 parallel_tuple_cost 是代价模型的一部分,不代表真实毫秒数。想要并行加速,表必须足够大,大到启动成本可以被摊薄。当表大小 S 超过阈值 S_{\min} 时,优化器会按下面的方式粗估可用并行度:

DBA 常做的是先看 EXPLAIN 里的 Workers Planned,如果只有 1,多半是表不够大或代价参数太高;再决定调高 max_parallel_workers_per_gather,还是降低 parallel_setup_cost。修改后执行 SELECT pg_reload_conf(); 让参数生效,不需要重启数据库。

D≈min⁡(Wper_gather, ⌈SSmin⁡⌉)\displaystyle D \approx \min\left(W_{\mathrm{per\_gather}},\ \left\lceil \frac{S}{S_{\min}} \right\rceil \right)
  1. D(估计并行度)优化器觉得这桌菜大概需要几位帮厨。
    DD
  2. S(表大小)被扫描的表在磁盘上占用的空间。
    SS
  3. S_{\min}(最小并行扫描阈值)由 min_parallel_table_scan_size 控制,低于它不划算。
    Smin⁡S_{\min}
  4. \left\lceil \frac{S}{S_{\min}} \right\rceil(向上取整)表越大,初步估算的帮厨人数越多。
    ⌈SSmin⁡⌉\left\lceil \frac{S}{S_{\min}} \right\rceil
  5. W_{\mathrm{per\_gather}}(单 Gather 上限)无论估算结果多大,单个 Gather 的 worker 数不能超过这个值。
    Wper_gatherW_{\mathrm{per\_gather}}

适用与调优

并不是每一道菜都需要叫帮厨。一盘花生米、一碗白米饭,主厨自己顺手就做了;叫三个人反而会挤在灶台前。并行查询最适合分析型 workload:大表聚合、多张大表连接、报表统计、特征计算。短小查询如按主键取一行、小范围索引扫描,串行计划的启动成本更低,优化器自然会选择串行。

以下情况优化器可能放弃并行:表体量低于 min_parallel_table_scan_size;查询调用 PARALLEL UNSAFE 的函数;全局 worker 数不足;单查询被事务或锁资源限制。调优时不要盲目加大 worker 数。先用 EXPLAIN (ANALYZE, BUFFERS) 看执行计划里有没有 Gather、Workers Planned 和 Workers Launched 是否一致,再对比串行计划与并行计划的耗时。若并行后 CPU 没吃满,先看表统计信息和 worker 上限;若 CPU 吃满却更慢,就降低并行度或缩小表扫描范围。

归根结底,并行查询是成本优化器决定的,不是“强制开启”的开关。懂原理的人不会追着最大并行度跑,而是让数据库在正确的 workload 下自己叫来合适的帮厨。

AI 应用场景

离线特征聚合提速

阶段:数据准备 / 特征工程

解决的问题:AI 建模前常常要在大宽表上做 GROUP BY、COUNT、AVG 等聚合,十亿级行数的表用串行 SQL 可能跑几十分钟。并行查询把表分成多个页面区间,由多个 worker 同时计算,再由 Gather 合并,不需要改写 SQL 就能把分钟级缩短到秒级。

具体案例:某推荐团队每天有 8 亿条用户点击日志。数据开发执行 SELECT user_id, COUNT(*), AVG(price) FROM click_log GROUP BY user_id;,将 max_parallel_workers_per_gather 调到 8 并 pg_reload_conf() 后,耗时从 12 分钟降到约 2 分钟。

模型评估统计加速

阶段:模型评估 / 数据监控

解决的问题:模型上线前要统计预估值分布、真实 label 分布、准确率等辅助指标。传统的串行 join 和 group by 在大表上很慢,并行查询可以让多个 worker 同时处理预测表和标签表,再快速汇总结论。

具体案例:CTR 模型离线评估时,算法工程师执行 SELECT label, COUNT(*) FROM eval_pred GROUP BY label;,以及把预测表与样本表做 join。开启并行后,原本要等 5 分钟的风险统计,不到 40 秒就返回。

Python 演示

python38 行
import osimport timefrom concurrent.futures import ProcessPoolExecutor  def sim_range(start, end):    total = 0    for i in range(start, end):        total += (i * i) % 97    return total  def run_parallel_scan(n_workers, total_items):    chunk = total_items // n_workers    ranges = []    start = 0    for w in range(n_workers):        end = start + chunk + (1 if w < total_items % n_workers else 0)        ranges.append((start, end))        start = end     with ProcessPoolExecutor(max_workers=n_workers) as pool:        futures = [pool.submit(sim_range, s, e) for s, e in ranges]        return sum(f.result() for f in futures)  if __name__ == "__main__":    items = 2_000_000    t0 = time.perf_counter()    serial = sum((i * i) % 97 for i in range(items))    ts = time.perf_counter() - t0     n = min(os.cpu_count() or 4, 4)    t0 = time.perf_counter()    parallel = run_parallel_scan(n, items)    tp = time.perf_counter() - t0     print(f"serial={serial} parallel={parallel} speedup={ts / tp:.2f}")
目的:用 Python 模拟 PostgreSQL 并行顺序扫描的“分区 + 多进程 + Gather 汇总”流程,直接验证并行加速比。
  1. ProcessPoolExecutor(max_workers=n_workers) as pool相当于主进程临时招募 N 个 worker 进程。
  2. pool.submit(sim_range, s, e)把一段下标范围交给一个 worker,类似并行顺序扫描分配页面区间。
  3. ranges.append((start, end))预先切好互不重叠的数据区间,保证不会重复计算。
  4. sum(f.result() for f in futures)主进程等所有 worker 返回后汇总,这就是一个简易的 Gather。
  5. serial = sum((i * i) % 97 for i in range(items))串行基准版本,用来和并行版本对比加速比。
  6. speedup={ts / tp:.2f}输出本文公式中的 S(N) = T_s / T_p(N)。

常见误区

误区我把 `max_parallel_workers_per_gather` 调到 64,为什么大查询反而更慢了?不是应该越并行越快吗?

正解CPU 核数是硬约束。worker 超过核数后,操作系统要频繁切换进程,内存带宽和锁也会成为瓶颈。帮厨越多,厨房过道越挤,互相碰掉盘子的概率越大。最好先看机器有多少核,再看 EXPLAIN 实际启动了几个 worker。

为何常见:人的直觉是“人多力量大”,容易忽略协调成本和资源竞争。

误区并行查询不是数据库自带的能力吗?我打开并行之后,随便一条 SELECT 应该都比原来快吧?

正解优化器按代价模型选择计划。启动 worker 有 parallel_setup_cost,每传一行结果有 parallel_tuple_cost;表很小、低于 min_parallel_table_scan_size 时,并行反而比串行慢。一盘花生米不需要十个厨师。

为何常见:只看到并行的好处,忽略了启动 worker 和传输结果的固定成本。

误区查询变成并行后,多个进程同时读同一张表,会不会读到不一致的数据,甚至把数据改坏?

正解不会。PostgreSQL 使用 MVCC 快照隔离,同一个事务里所有 worker 看到的是同一份快照,数据内容一致。Gather 只是把结果汇总起来,它不修改数据、不绕过权限、不制造脏读。

为何常见:把多进程并发想象成毫无协调的并行,忽略了数据库本身已有的隔离机制。

误区我明明设置了并行参数,EXPLAIN 里却没有 Gather,数据库是不是出 bug 了?

正解参数只是允许并行,不是强制并行。优化器仍然会比较串行和并行的代价。表太小、函数不满足 PARALLEL UNSAFE 限制、全局 worker 数不足,都可能让计划保持串行。没有 Gather 只代表优化器认为串行更划算。

为何常见:把配置参数当成强制指令,忽略了 PostgreSQL 是成本驱动优化器。

面试题

Q1. PostgreSQL 并行执行时,负责汇总 worker 结果的是哪个节点?

单选题 · easy

  • A.Gather 节点← 正确答案
  • B.Hash 节点
  • C.Sort 节点
  • D.Limit 节点

答案:A。Gather 节点相当于传菜窗口,专门收集各 worker 的半成品。Hash、Sort、Limit 都是具体的数据处理节点,不是汇总通道。

Q2. PostgreSQL 的并行 worker 本质上是什么?

单选题 · easy

  • A.操作系统线程
  • B.独立后台进程← 正确答案
  • C.独立数据库实例
  • D.客户端连接进程

答案:B。并行 worker 是独立的 postgres 后台进程,不是线程,也不是新实例。它拥有自己的执行状态和内存,类似临时叫来的帮厨。

Q3. 关于 max_parallel_workers_per_gather 参数,正确的理解是?

单选题 · mid

  • A.它控制全局 worker 总数
  • B.它控制后台进程总数
  • C.它控制单个 Gather 的 worker 数← 正确答案
  • D.它控制并行扫描的表阈值

答案:C。这个参数限制的是单个 Gather 节点能使用的 worker 数。全局 worker 总数由 max_parallel_workers 控制,表阈值由 min_parallel_table_scan_size 控制。

Q4. 优化器决定是否采用并行顺序扫描时,最重要的判断依据是?

单选题 · mid

  • A.客户端是否使用 Python
  • B.表的物理大小与成本估算← 正确答案
  • C.SQL 里是否写了 SELECT *
  • D.数据库端口号

答案:B。PostgreSQL 是成本驱动优化器,它会比较串行扫描和并行扫描的估算代价。表的大小、worker 启动成本、元组传输成本都会影响最终选择。

Q5. 关于 Gather 节点的说法,正确的是?

单选题 · mid

  • A.负责把数据写入磁盘
  • B.汇总结果且不改变数据← 正确答案
  • C.必须在内存中排序
  • D.只能使用一个进程

答案:B。Gather 是并行与串行部分的分界点,只负责从 worker 接收元组并转交上层。它不写盘、不强制排序,也可以对应多个 worker。

Q6. 下列哪些情况可能让并行查询无法生效或退化为串行?

多选题 · mid

  • A.表太小低于最小阈值← 正确答案
  • B.函数标记为 PARALLEL UNSAFE← 正确答案
  • C.全局 worker 数已被用完← 正确答案
  • D.SQL 文本中包含 ORDER BY

答案:ABC。表太小、函数不安全、worker 不足都会让优化器放弃并行。ORDER BY 本身不阻止并行,PostgreSQL 可以并行排序或在 Gather 后排序。

Q7. 串行执行耗时 100 秒,启动 worker 开销 5 秒,4 个 worker 理想均分,Gather 汇总耗时 5 秒,预计并行总耗时是多少?

单选题 · hard

  • A.约 25 秒
  • B.约 30 秒
  • C.约 35 秒← 正确答案
  • D.约 110 秒

答案:C。按公式 T_p = T_setup + T_s / N + T_gather,代入得到 5 + 100/4 + 5 = 35 秒。这个题提醒我们,Gather 汇总和启动开销都不能忽略。

Q8. 在并行哈希连接中,两个输入表的数据通常需要如何处理?

多选题 · hard

  • A.按连接键重新分布数据← 正确答案
  • B.每个 worker 只处理部分数据← 正确答案
  • C.通过共享哈希表协作连接← 正确答案
  • D.必须先把两张表完整排序

答案:ABC。并行哈希连接会利用连接键把数据拆分到可并行处理的分区,多个 worker 共享一张哈希表进行探测。哈希连接不需要先排序,所以 D 是错的。

Q9. min_parallel_table_scan_size 参数的主要作用是?

单选题 · hard

  • A.限制扫描器最多读取的数据块数
  • B.小于该大小的表不值得并行扫描← 正确答案
  • C.强制所有大表都使用并行扫描
  • D.指定每个 worker 扫描的数据块数

答案:B。这个参数是并行顺序扫描的“订单阈值”。表小于阈值时,启动 worker 的成本无法被摊薄,优化器倾向于走串行计划。

Q10. 两个并发查询同时使用并行计划时,worker 分配会发生什么?

多选题 · extreme

  • A.都会受全局 worker 总数上限约束← 正确答案
  • B.后到的查询可能获得更少 worker← 正确答案
  • C.每个查询计划中的 worker 数永远不变
  • D.后到的查询必须等待前一个结束

答案:AB。max_parallel_workers 是全局上限,多个并行查询要共享这个池子。后到的查询可能只拿到较少 worker,但不代表必须等待;它可能降低并行度后继续执行。