想象你是一家餐厅的主厨,接到一张宴席菜单:一份十斤的炖牛腩。你一个人守着大锅,慢慢翻炒、收汁。客人催菜时,你终于喊来三个帮厨:一个人洗土豆,一个人切萝卜,一个人烧开水,你再把半成品汇总进同一口锅里。这就是 PostgreSQL 的并行查询。 在 PostgreSQL 里,一条 SQL 就是一份菜单。以前执行引擎只派一名“数据库服务进程”从头跑到尾;当它判断这份菜单够大、够复杂时,会临时召集多个 worker 进程,把表的数据块拆成几份并行处理,最后通过一个 Gather 节点把结果交回给主进程。对分析型查询来说,这就像从“一个人做全席”变成“一群厨师流水线作战”。 本文不要求你写过数据库内核。我们会从主厨和帮厨的视角,依次讲清楚并行查询为什么能加速、worker 怎么分配、Gather 怎么汇总、顺序扫描和连接怎么拆分工、哪些参数在控制厨房规模,以及什么时候不要强行用并行。
对数据开发与 DBA 而言,掌握并行查询意味着不需要改写业务 SQL,就能让大表统计、特征聚合和报表查询在分钟级内完成,这是给分析型 workload 提效的必修课。
可视化演示
动态演示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 足够大、能摊薄启动和汇总成本时,请帮厨才划算。这个“划算不划算”的账,后面要讲的优化器代价模型会替我们算。
T_s(串行总耗时)这是主厨一个人做完整道菜所需的时间。T_p(N)(并行总耗时)使用 N 个 worker 之后,从备料、烹饪到汇总上菜的全部时间。T_{\mathrm{setup}}(启动开销)叫帮厨进门、分派任务、分配共享内存都需要固定成本。\dfrac{T_s}{N}(理想分工作业时间)如果任务能完全均匀地分给 N 个 worker,每个人只需原来的 N 分之一时间。T_{\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 上限可能不够;如果全局池已经被占满,换多少块“招聘启事”都请不到人。
W_{\mathrm{used}}(实际使用的 worker 数)最终这台查询真的请到几位帮厨,可能比计划少。W_{\mathrm{max}}(全局 worker 上限)由max_parallel_workers控制,整个数据库同时只能有这么多帮厨。G(Gather 节点数量)一个执行计划里可能有多个 Gather,每个 Gather 都是一个传菜窗口。W_{\mathrm{per\_gather}}(单 Gather 上限)由max_parallel_workers_per_gather控制,一个窗口最多收几个帮厨的菜。\min(取最小值)两层限制必须同时满足,哪边先到顶就听哪边。
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}} 就是搬袋子的时间。表不够大时,请帮厨反而浪费时间。
T_{\mathrm{pscan}}(N)(并行顺序扫描预计耗时)这是模拟帮厨同时削土豆,最后把所有土豆片汇集起来所需的时间。T_{\mathrm{scan}}(串行扫描全表耗时)一个进程从头到尾读完所有表页面所需的时间。N(worker 数)实际削土豆的帮厨人数,也是执行计划里的并行度。T_{\mathrm{setup}}(启动开销)初始化共享扫描状态、为 worker 分配页面区间都需要时间。\frac{T_{\mathrm{scan}}}{N}(每 worker 的理想工作量)在完全均匀的理想世界里,每个人只干串行时间的 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(估计并行度)优化器觉得这桌菜大概需要几位帮厨。S(表大小)被扫描的表在磁盘上占用的空间。S_{\min}(最小并行扫描阈值)由min_parallel_table_scan_size控制,低于它不划算。\left\lceil \frac{S}{S_{\min}} \right\rceil(向上取整)表越大,初步估算的帮厨人数越多。W_{\mathrm{per\_gather}}(单 Gather 上限)无论估算结果多大,单个 Gather 的 worker 数不能超过这个值。
适用与调优
并不是每一道菜都需要叫帮厨。一盘花生米、一碗白米饭,主厨自己顺手就做了;叫三个人反而会挤在灶台前。并行查询最适合分析型 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 演示
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}")
ProcessPoolExecutor(max_workers=n_workers) as pool相当于主进程临时招募 N 个 worker 进程。pool.submit(sim_range, s, e)把一段下标范围交给一个 worker,类似并行顺序扫描分配页面区间。ranges.append((start, end))预先切好互不重叠的数据区间,保证不会重复计算。sum(f.result() for f in futures)主进程等所有 worker 返回后汇总,这就是一个简易的 Gather。serial = sum((i * i) % 97 for i in range(items))串行基准版本,用来和并行版本对比加速比。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 结果的是哪个节点?
- A.Gather 节点← 正确答案
- B.Hash 节点
- C.Sort 节点
- D.Limit 节点
答案:A。Gather 节点相当于传菜窗口,专门收集各 worker 的半成品。Hash、Sort、Limit 都是具体的数据处理节点,不是汇总通道。
Q2. PostgreSQL 的并行 worker 本质上是什么?
- A.操作系统线程
- B.独立后台进程← 正确答案
- C.独立数据库实例
- D.客户端连接进程
答案:B。并行 worker 是独立的 postgres 后台进程,不是线程,也不是新实例。它拥有自己的执行状态和内存,类似临时叫来的帮厨。
Q3. 关于 max_parallel_workers_per_gather 参数,正确的理解是?
- A.它控制全局 worker 总数
- B.它控制后台进程总数
- C.它控制单个 Gather 的 worker 数← 正确答案
- D.它控制并行扫描的表阈值
答案:C。这个参数限制的是单个 Gather 节点能使用的 worker 数。全局 worker 总数由 max_parallel_workers 控制,表阈值由 min_parallel_table_scan_size 控制。
Q4. 优化器决定是否采用并行顺序扫描时,最重要的判断依据是?
- A.客户端是否使用 Python
- B.表的物理大小与成本估算← 正确答案
- C.SQL 里是否写了 SELECT *
- D.数据库端口号
答案:B。PostgreSQL 是成本驱动优化器,它会比较串行扫描和并行扫描的估算代价。表的大小、worker 启动成本、元组传输成本都会影响最终选择。
Q5. 关于 Gather 节点的说法,正确的是?
- A.负责把数据写入磁盘
- B.汇总结果且不改变数据← 正确答案
- C.必须在内存中排序
- D.只能使用一个进程
答案:B。Gather 是并行与串行部分的分界点,只负责从 worker 接收元组并转交上层。它不写盘、不强制排序,也可以对应多个 worker。
Q6. 下列哪些情况可能让并行查询无法生效或退化为串行?
- A.表太小低于最小阈值← 正确答案
- B.函数标记为 PARALLEL UNSAFE← 正确答案
- C.全局 worker 数已被用完← 正确答案
- D.SQL 文本中包含 ORDER BY
答案:ABC。表太小、函数不安全、worker 不足都会让优化器放弃并行。ORDER BY 本身不阻止并行,PostgreSQL 可以并行排序或在 Gather 后排序。
Q7. 串行执行耗时 100 秒,启动 worker 开销 5 秒,4 个 worker 理想均分,Gather 汇总耗时 5 秒,预计并行总耗时是多少?
- 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. 在并行哈希连接中,两个输入表的数据通常需要如何处理?
- A.按连接键重新分布数据← 正确答案
- B.每个 worker 只处理部分数据← 正确答案
- C.通过共享哈希表协作连接← 正确答案
- D.必须先把两张表完整排序
答案:ABC。并行哈希连接会利用连接键把数据拆分到可并行处理的分区,多个 worker 共享一张哈希表进行探测。哈希连接不需要先排序,所以 D 是错的。
Q9. min_parallel_table_scan_size 参数的主要作用是?
- A.限制扫描器最多读取的数据块数
- B.小于该大小的表不值得并行扫描← 正确答案
- C.强制所有大表都使用并行扫描
- D.指定每个 worker 扫描的数据块数
答案:B。这个参数是并行顺序扫描的“订单阈值”。表小于阈值时,启动 worker 的成本无法被摊薄,优化器倾向于走串行计划。
Q10. 两个并发查询同时使用并行计划时,worker 分配会发生什么?
- A.都会受全局 worker 总数上限约束← 正确答案
- B.后到的查询可能获得更少 worker← 正确答案
- C.每个查询计划中的 worker 数永远不变
- D.后到的查询必须等待前一个结束
答案:AB。max_parallel_workers 是全局上限,多个并行查询要共享这个池子。后到的查询可能只拿到较少 worker,但不代表必须等待;它可能降低并行度后继续执行。