2.1 进程内嵌与多核并行


2.1 进程内嵌与多核并行

本节摘要:进程内架构省掉的不是"一点网络延迟",而是序列化、拷贝、连接管理三整层开销;morsel 驱动的并行让多核按数据块协作而不是按算子排队。本节讲清这两件事的机理,并给出并行度参数的调法。

承上启下的位置

第1章说 DuckDB"长在你的进程里",本节负责把这句话兑现成两条硬道理:嵌入省了什么、多核怎么用满。这两点决定了同一查询在 DuckDB 和外部数据库上的耗时差距从哪来,也是第4章讨论向量化执行的地基——并行解决"几个人干活",向量化解决"每个人怎么干活",缺一不可。

嵌入式到底省了什么

对比一条 SQL 在两种架构下的旅程。独立服务架构下,查询从你的代码出发,要经过驱动层的连接校验、SQL 文本的网络传输、服务端的解析、结果的逐行序列化、网络回传、客户端反序列化——六个环节里,真正干活的只有"服务端解析"之后那段,其余全是搬运与协议开销。嵌入式架构下,SQL 字符串变成函数调用的入参,解析完直接执行,结果集以进程内对象直接交回——第5章会看到,这个结果甚至可以和 Pandas、Arrow 对象零拷贝互通。

省钱的不止是延迟。序列化是 CPU 密集操作,连接管理是内存与文件描述符开销,重查询下这些开销会被放大:一次聚合本身只要两百毫秒,加上取回一亿行结果的序列化与网络,总耗时可能翻十倍。嵌入式把"搬运"压到零之后,性能优化的注意力才能集中在真正计算的地方。换个角度算这笔账也成立:服务端架构里"结果集多大、搬运多贵"的权衡会反过来扭曲查询设计——工程师会下意识少查几列、分页取数,把本该一次算清的问题切成多次往返;嵌入式直接消灭了这层顾虑,查询设计回归"怎么算得清楚"本身。省下的不只是运行时的开销,还有设计时的扭曲。

另一种架构对比是那些常驻的分析服务。它们面对多客户端共享的场景是合理的,但当使用者只有你一个人、数据就在本机时,服务化的一切抽象都在为不存在的需求付费。这就是第1.4节反例清单的镜像:反例里 DuckDB 不该去扛在线服务,正面里在线服务的开销也不该摊在单人分析头上。

图2-1 两种架构的查询路径对照

图2-1 两种架构的查询路径对照

morsel 驱动:多核怎么分活

多核并行的老思路是"按算子并行":一个核做扫描、一个核做聚合、一个核做连接。问题在于各算子耗时悬殊——扫描十秒、聚合半秒,聚合那个核九成时间在等。DuckDB 用的是数据切分思路:把表横向切成大量小块(内部叫 morsel),线程池里的每个线程领一块数据,从头跟到尾做完自己这一段的扫描、过滤、聚合,各线程的中间结果最后合并。负载因此天然均衡:块是均匀的,谁也不闲着。这个思路对数据倾斜也格外宽容:哪怕某些块因为过滤后存活行数少而"轻",领到轻块的线程提前回来领下一块就行——均衡发生在任务分派层而不是数据层,这是它与手动分区方案的本质区别。

这带来一个使用层的推论:并行的上限取决于你碰的数据块数,而不是核数。小表再小也至少吃满一个块,线程开多了互相争抢反而添乱;大表则是线程越多越开心,直到内存带宽饱和。理解这一点,线程参数的调法就有了抓手——参数永远服务数据形态,而不是反过来。

-- 查看与调整并行线程数(默认跟随机器核数) SELECT current_setting('threads') AS 当前线程数; SET threads = 8; -- 排查或压测时把并行度压到 1,观察单线程基线耗时 SET threads = 1; SELECT count(*), sum(amount) FROM trades;

调参的三条经验

第一,别动默认值,除非有理由。默认线程数等于核数,对绝大多数查询就是最优;把它调小只该发生在两种情况——排查性能问题时想看单线程基线,或者机器上还跑着不能被抢资源的应用。第二,虚拟机容器里要留心:某些环境报给进程的核数不真实,线程默认值会失真,表现为查询并发起不来,这时手工 SET 到真实可用核数即可。第三,并行不解决一切:如果单线程已经跑满内存带宽,加线程只会让溢出更早发生——这正是下一节的话题。第四条经验给容器场景补个实操:配额受限时除了设线程数,也要顺手核一遍内存上限与配额一致——并行与内存两章的参数在这里是成对出现的,只调一个等于只拧了双头螺栓的一头。

跟一条聚合查询走完并行全程

机理讲完,用主线案例的订单表(四千万行)跑一条聚合,看并行是怎么发生的。查询发出后,执行层先把表按物理块切成几百个 morsel,八个工作线程像领任务一样各自认领一块:读块、过滤、算局部和,全程不与他人协商。每个线程算完自己的块,局部结果进入一个合并阶段——聚合的合并是可结合的,谁先谁后都不影响答案。全程唯一的"协调点"是任务认领:线程做完手上的块,回来领下一块,直到块被领光。这种设计的妙处在于自适应:快的线程自然多干,慢的线程不拖累全局,你不需要为数据倾斜写任何补救代码。

-- 同一条聚合,两种并行度的对照(四千万行量级) SET threads = 1; SELECT status, count(*), sum(amount) FROM trades GROUP BY status; -- 单线程基线 SET threads = 8; SELECT status, count(*), sum(amount) FROM trades GROUP BY status; -- 八线程并行

实测参考:单线程约四秒的任务,八线程压到一秒以内——接近线性,因为扫描聚合的并行度远未到内存带宽瓶颈。这组数字建议你自己跑一遍,同样的 SQL 在你的机器上会得到属于你的对照值,而这个"单线程基线"还有个隐藏用途:它就是第7.1节排查慢查询时的对照组,任何优化的收益都拿它来衡量。

与宿主进程共处的分寸

进程内嵌入还有一层工程含义讲在后面:引擎和你的应用共吃一份资源。同一进程意味着同一份内存配额、同一组核——DuckDB 默认取全部核、大块内存,如果你的进程还跑着 Web 服务或其他重活,就需要第2.2节的内存上限与这里的线程数一起出面划界。两个参数配合的口诀:先给引擎划定内存上限,再按剩余资源定线程数——反过来配的常见后果是,线程开满导致内存峰值翻倍,把同进程的宿主应用挤到崩溃。嵌入是特权也是责任:省掉服务端隔离的同时,资源隔离的责任落到了你自己头上。

一个常见疑问:为什么我的查询只有一个线程在忙

把并行讲透之前,先回答一个高频困惑:明明设了多线程,监控里却只有一两个核在动。多数时候不是并行失灵,是查询里可并行的段太短:小表的扫描块数不足以喂饱线程池,或者查询大头在阻塞段(比如最终的大排序),阻塞段天然单线程。判断方法很简单——用上面压到单线程的方式做个对照:如果多线程版与单线程版耗时接近,说明这条查询的并行空间本来就小,加线程无解;差别明显才说明并行在干活。此外还有一类"假单线程"是会话参数被上游框架覆盖——连接池里的连接各自有各自会话,你在 A 连接里 SET 的参数不会传到 B 连接。排查并行问题时,先确认"这条查询值得并行",再确认"参数真的生效",两个检查只要两分钟,能省掉一下午的怀疑人生。

本节要点回顾

  • 嵌入省的是三层:序列化、网络、连接管理;单人单机的场景里,这些是为不存在的需求付的税。
  • 并行按数据切分:morsel 驱动让各线程领块全程负责,负载均衡来自块均匀而非算子排队。
  • 线程参数心法:默认最优、压一测基线、容器里核对核数;内存带宽饱和后加线程适得其反。

作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U