ITPub博客

首页 > 数据库 > 国内数据库 > 胖头鱼的技术专栏-489 一条 SQL 在跑,八个核在围观(20261113)

胖头鱼的技术专栏-489 一条 SQL 在跑,八个核在围观(20261113)

原创 国内数据库 作者:yhw1809 时间:2026-11-12 18:41:50 0 删除 编辑

胖头鱼的技术专栏-489 一条 SQL 在跑,八个核在围观(20261112)

作者:胖头鱼的鱼缸(尹海文)
Oracle ACE Pro: Database
PostgreSQL ACE
10年+数据库行业经验
拥有OCM 11g/12c/19c、MySQL 8.0 OCP、Exadata、CDP等认证
墨天轮MVP,ITPUB认证专家
圈内拥有“总监”称号,非著 名社恐(社交恐怖分子)
全网同名:胖头鱼的鱼缸
ITPUB:yhw1809
除授权转载并标明出处外,均为“非法”抄袭

914fcc7ad57defa7868c3be1ca7fb4f5.jpg

TDSQL Boundless 的 V21.6.4 是今年 8 月推的,重点不在容量和成本,在查询本身:Parallel Hash Join、Runtime Filter、并行 DML、长 IN 列表改写,都指向同一件事,把过去必须一个线程走完的那一段拆开。

并行这件事说白了就是开窗口办事:开八个窗口,队伍散开。但很多 SQL 的真实情况是,八个窗口都挂着牌,只有一个在办。

前面那篇 《胖头鱼的技术专栏-453 让冷数据"冷"得其所(20260730)》聊的是把数据搬到便宜的地方去,这次反过来,数据不搬了,让机器多干活。

不太顺耳的是,这些能力的开关默认基本是关的,而且不止一道。更麻烦的是它不报错:监控上 CPU 不高不低,结果一条不差,就这么串行跑完了。

跑下面这些数的是台 21.6.4.3 的实例,一百万行订单表加两万行维表,会话级 8 路并行。单机、单次测量,看倍数和现象,别拿去和别的环境比。
环境.png

实验环境:内核 21.6.4.3,节点 hybrid-2,会话级开 8 路并行

 

一、先看看门在哪

很多人对并行的理解是"把并行度调大"。调了没反应,就以为参数不生效。实际上是四道门,任何一道没过,出来的都是串行。

fig_gates.png

一条 SQL 要穿过四道门,才看得到 Gather

 

第一道是  max_parallel_degree,默认 0。 0 就是关,不设就是串行。

这个变量是会话级, SET SESSION 只影响当前连接,在客户端窗口里设一遍就以为全库生效,这是第一个坑。

第二个坑更隐蔽: 你连上看到的未必是默认值。 我这次刚重连、什么都没设,拍下来这一项是 4 不是 0,全局值被之前的人动过,新会话继承的就是它。

配套的还有  max_parallel_workers,GLOBAL 级,默认 10000,管的是整个节点的并行 worker 总数。 它管天花板,不管单条 SQL 的并行度。

第二道是阈值。 并行度给了 8,优化器还要算账:

变量 默认值 卡什么
parallel_plan_cost_threshold 50000 串行代价低于这个数不生成并行计划
parallel_scan_records_threshold 10000 表预估行数不到这个数不进候选

另外还有扫描代价和可切分范围数两个门槛,道理一样。几十万行的表在这套模型里可能连门都够不着,小表开并行没反应,多半卡在这儿。

第三道是并行安全检查。 优化器会把查询里所有表达式遍历一遍,只要有一个不支持并行执行,整条查询退回串行。 这一关不是调参数能过的,只能改写法。

第四道是 Collector。 前三道都过了,最终选中的执行路径里还得有一个 Collector,计划里显示成  Gather。没有它,说明并行在这儿捞不到好处,系统直接退回串行。

所以  parallel_query_switch 里的  force=on 能跳过前两道,跳不过第四道。它让路径免于阈值剪枝、选优时偏好并行路径,但不会硬选一个没有并行结构的计划。

阈值那道门,hint 一带数字就能顶穿。我拿两万行的维表试了一句  PARALLEL(dim_user 8):

EXPLAIN FORMAT=TREE
SELECT /*+ PARALLEL(dim_user 8) */ COUNT(*) FROM dim_user;

计划里照样蹦出  Gather (slice: 1, workers: 8),下面挂  Table scan on dim_user, with range parallel scan。两万行的表,八个 worker 真的分到了活。

带数字的 hint 确实能顶穿阈值那一道,比我原先估计的强。但别急着高兴:为两万行开八个 worker,收集代价划不划算,计划里看不出来。 hint 能让你并行,不保证让你更快。

截图1.png

两万行的维表加 PARALLEL(dim_user 8),计划里照样出现 Gather (workers: 8)

 

PARALLEL hint 有个地方最容易踩:带数字和不带数字,行为完全不同。

-- 带数字:该表用 4 个 worker,max_parallel_degree=0 时也能生效
SELECT /*+ PARALLEL(fact_order 4) */ COUNT(*) FROM fact_order;
-- 不带数字:向上继承,一路回落到 max_parallel_degree
SELECT /*+ PARALLEL(fact_order) */ COUNT(*) FROM fact_order;

后者 在  max_parallel_degree = 0 的会话里等于没写。看着加了 hint,实际什么都没发生。

反过来还有  PARALLEL(t1 0),显式禁止这张表并行。大表 join 一堆小维表的时候有用,把窗口留给大表。

并行度给多少是算出来的。 代价里叠了三项:Leader 收结果的代价、按 DOP 平方涨的通信代价、小表的效率折扣。我一般先给 4 或 8,看效果再往上探,一上来 16、32 的多半是在给自己找麻烦。

截图2_1.png

前三组:max_parallel_degree 是 4 不是 0,workers 10000,policy MANUAL

 

截图2_2.png

中间几组:hash join 开关 OFF,single_delete 开关 OFF,IN 阈值 5000,Bloom 上限 64MB

 

截图2_3.png

位掩码默认值,最后一行 rt_filter_on 为 0

 

二、拿一百万行试一把

两张表: fact_order 一百万行订单, dim_user 两万行维表, uid 均匀打在两万个用户上。

造数不用逐行 insert,LSM 引擎上太慢。拿一张 0 到 9 的辅助表交叉连乘,五次就是十万行,滚几轮凑到一百万。表结构和完整语句在配套脚本里,这儿不铺开。

造完先核行数,对不上后面全白跑。

截图3.png

三张表的行数:1000000 / 20000 / 20000

 

开跑之前把开关拍一屏存下来,后面任何"没生效"的怀疑,都从这屏开始排查。

SHOW VARIABLES LIKE 'max_parallel_degree';SHOW VARIABLES LIKE 'tdsql_enable_parallel_hash_join';SHOW VARIABLES LIKE 'single_delete_update_use_iterator';SELECT @@optimizer_switch LIKE '%join_runtime_filter=on%' AS rt_filter_on;

然后是同一条 SQL 的串行和并行对照:

SET SESSION max_parallel_degree = 8;
SELECT /*+ PARALLEL(8) */ u.city, COUNT(*) AS cnt, SUM(f.amount) AS amt
  FROM fact_order f JOIN dim_user u ON f.uid = u.id
 WHERE f.create_time >= '2025-01-01'
 GROUP BY u.city;

串行基线就是把这条的并行度归零、hint 去掉。

我这边跑了一轮:串行那条  8 rows in set (2.92 sec),并行那条  8 rows in set (0.48 sec)。同一张表、同一条 SQL、同一个客户端,唯一变的是并行度和那句 hint,2.92 秒到 0.48 秒,六倍。两边的 8 行结果一字不差, cnt 和  amt 全都对得上。

计时别用  SET @t = NOW(6) 包着,两次语句之间敲键盘的时间会算进去,我头一回就记出了个假数。看客户端  rows in set 那一行准得多。严格点应该交替跑三轮取稳定值,我只跑了一轮,你那边数据分布不同,数会不一样。

截图4_串行.png

串行:8 rows in set (2.92 sec)

 

截图4_并行.png

并行:8 rows in set (0.48 sec),结果与串行一字不差

 

三、并行 DML 要两个开关

MySQL 原生的 DML 压根不走窗口这条道,它有自己的通道。想让它并行,得先把它引到窗口这边来,再告诉它开几个窗口。所以要同时给两个东西,缺一个都不动。

SET SESSION single_delete_update_use_iterator = ON;
UPDATE /*+ PARALLEL(8) */ order_detail
 SET status = 3
 WHERE create_time < '2026-01-01';

不想污染会话,就把开关塞进  SET_VAR hint 里,一条 SQL 带过去。

这两件事是"与"的关系,不是"或":只给 hint 不给算子开关,或者只开开关不给 hint,都不会并行。

有一处口径对不上:并行 DML 的说明写的是"开启参数  single_delete_update_use_iterator(默认值 ON)后",系统变量表里写的是默认  false。别照抄, SHOW VARIABLES LIKE 'single_delete_update_use_iterator' 看一眼当前实例的真实值,比记哪边写的靠谱。

限制清单不短:只支持单表,不碰子查询、 LIMIT、 IGNORE、 RETURNING 和所有 INSERT 类;UPDATE 还不能改主键、唯一索引列、分区键,也不能改扫描路径用到的索引列。

杀伤力最大的一条是  ON UPDATE CURRENT_TIMESTAMP。业务表上挂一个自动维护的  update_time,是十几年的建表习惯, 只要表上有这一列,这条 UPDATE 就走不了并行。这不是配置问题,也不是 hint 写错了,是能力边界;要并行就得把这一列改成应用层显式赋值,改不改是业务决策,不是 DBA 一个人能定的。

版本说明里写的是 V21.6.4.0 起的 邀测功能,要走工单。我在 21.6.4.3 上直接跑通了,两个开关给上,计划是这个样子:

-> Gather (slice: 1, workers: 8)  (cost=2421.68..195706.27 rows=998537)
    -> Update fact_order (immediate) with filter: (fact_order.create_time < TIMESTAMP'2024-06-01 00:00:00')
        -> Table scan on fact_order, with range parallel scan

真跑一遍,164725 行改了 1.58 秒。

邀测名单这个事还是先确认,版本之间口径未必一样,别拿我这台的结果当通行证。

截图5.png

Gather 下面挂 Update fact_order (immediate)

 

截图5_1.png

164725 行,1.58 秒

 

并行 DML 里各 worker 负责扫描和修改,Leader 统一提交。行数改得越多,Leader 越可能成为瓶颈,几千万行那种规模的更新,串行反而可能更快。改完看  PQ_active_worker 这个状态变量和 CPU 水位。

四、Hash 建在哪儿,join 就在哪儿执行

这一项和并行 DML 不是一回事,别混。

过去 Hash Join 的麻烦在于:scan 能多线程并行,build 不行。优化器会把两边数据都收上来,在 Leader 上建一份 Hash 做 join,内存只占一份,可 join 那一段就是一个线程在跑,并行度开得再高也卡在这儿。换成窗口的说法:八个窗口把材料全交到一个窗口,由一个人慢慢核。

fig_hash.png

Hash 建在 Leader 上,join 就只能在一个线程里做

 

V21.6.4 改成共享 Hash Table:多个 worker 通过 CAS 原子操作并发填同一张表,无锁,Barrier 同步后各 worker 独立 probe。 build 真正并行化,内存只占一份。

计划里的标识是  Shared hash,看到它而不是单独的  Hash,就是走的新路径。

不过这一步我踩了个空。开关开了、关了,计划一点没变,一直停在  Batched key access inner join。原因不复杂:维表两万行,join 列又是主键,优化器觉得主键回表就够快,压根不走 hash join。PHJ 得先有 hash join 才谈得上出场。

我头一个想到的是把 BKA 按下去, NO_BKA 加  NO_BNL 一起给。结果计划退成  Nested loop inner join,逐行走  Single-row index lookup on u using PRIMARY,cost 才 3.31。 按掉 BKA 不等于逼出 hash join,主键点查便宜成这样,优化器没理由换路。

真正管用的是让 join 列没有索引。维表换成一张普通列关联的,这事本来也更接近真实业务,谁家维表的关联列正好是主键:

CREATE TABLE dim_user2 (id BIGINT PRIMARY KEY, uid BIGINT NOT NULL, city VARCHAR(32));
INSERT INTO dim_user2 SELECT id, (id * 37) % 20000, city FROM dim_user;

拿  f.uid = d.uid 去 join,这个  uid 上没有任何索引,hash join 才站得住。

截图6.png

开关前后两张计划:Hash 变 Shared hash,Gather 从 join 底下挪到 join 上面

 

截图6__mem_off.png

关闭:八个 worker 各扫一遍 20000 行维表,memory usage 515kB(点开放大)

 

截图6__mem_on.png

打开:Shared hash 底下只有 worker 7 扫了 20000 行,其余七个 rows=0(点开放大)

 

开关  tdsql_enable_parallel_hash_join 默认  false, SET SESSION 打开就生效。开了也只是允许优化器选这条路,最终仍按代价决定。

打开之后,计划长得完全不一样。 关的时候,f 和 d 各自并行扫完先汇聚到 Leader,再在 Leader 上建一份 Hash 做 join:

-> Aggregate: count(0)  (cost=530440253.81..530440253.81 rows=1)
    -> Inner hash join (f.uid = d.uid)  (cost=18158.50..81537708.90 rows=3500159302)
        -> Gather (slice: 1, workers: 8)
            -> Table scan on f, with range parallel scan
        -> Hash
            -> Gather (slice: 2, workers: 8)
                -> Table scan on d, with range parallel scan

开的时候, Gather 跑到 join 上面去了,join 挪进每个 worker 里跑:

-> Aggregate: count(0)  (cost=8465709.22..8465709.22 rows=1)
    -> Gather (slice: 1, workers: 8)
        -> Aggregate: count(0)
            -> Inner hash join (f.uid = d.uid)  (cost=3156.01..1449302.35 rows=54689989)
                -> Table scan on f, with range parallel scan
                -> Shared hash
                    -> Table scan on d, with range parallel scan

两处差别值得细看。 一是 join 从单点变成多点:关闭时两个  Gather 都压在 join 底下,等于先把两边收上来、在一个线程里做;打开后 join 在每个 worker 内部完成,最后只汇总一个  count。 二是 build 侧扫了几遍: EXPLAIN ANALYZE VERBOSE 里看  Table scan on d,关闭时八个 worker 各扫一遍 20000 行、 memory usage 515kB,打开后  Shared hash 底下只有 worker 7 在扫,其余七个  rows=0。同一份维表,从扫八遍变成扫一遍。

优化器估的成本也跟着从 5.3 亿掉到 847 万,不过那是估算值,不是耗时,别混着看。

但实测耗时反而变长了。 同一组数据,join 节点从 95 毫秒变成 163 毫秒,整条查询从 103 毫秒变成 172 毫秒。维表才两万行、515kB,共享哈希表那套 CAS 同步的开销,比省下来的那份内存贵。

所以这笔账要这么算: join 下推是真的,共享一份表也是真的;提速得等 build 侧大到单点扛不住。 两万行的维表,两种做法都不怎么费内存,反倒多付了协调成本。真等到 build 侧几十 G、单点建表就要 OOM 的时候,它才是救命的那一手。

单次测量,60 多毫秒的差距我不敢当结论,但这组数至少说明它不是万 能加速器。

五、Runtime Filter 的开关藏得有点深

Hash Join 的经典浪费:probe 侧扫了一亿行,join 完只剩几十万行。那九千多万行白扫了,白传了,白参与了计算。

Runtime Filter 说白了就是在门口先核一遍名单:build 侧建 Hash Table 的时候顺手建一个 Bloom Filter,把 join key 的取值集合塞进去,下推到 probe 侧的扫描路径上。扫的时候先查 Bloom Filter,名单上没有的直接不让进,不传输、不参与 join。

fig_bloom.png

维表先建名单,事实表边扫边核,不匹配的行不进算子

 

这条最容易漏,因为开关藏在一个不显眼的地方,而且默认关着:

SET SESSION optimizer_switch = 'join_runtime_filter=on';SELECT @@optimizer_switch LIKE '%join_runtime_filter=on%';

这个开关不打开,后面所有 hint 都是白写, JOIN_RUNTIME_FILTER 只能在这个开关已经打开的前提下进一步启用或禁用。还有一个前置条件: runtime_bloom_filter_max_size,单位 MB,默认 64,取值范围 0 到 128,设成 0 就整体不可用。

在  dim_user 上试同样没戏,Extra 列只有  Batched key access,runtime filter 一个字都没有,道理跟 PHJ 一样。换成  dim_user2 才出来:

-> Inner hash join (f.uid = d.uid) with join runtime filter: bloom(f.uid)
    -> Table scan on f, with range parallel scan
    -> Hash
        -> Table scan on d, with pushed condition: (d.city = '成都')

两行值得看。 with join runtime filter: bloom(f.uid) 说明过滤器建在  f.uid 上; pushed condition: (d.city = '成都') 说明维表先按城市滤到 2625 行,再拿这两千多行去建 Bloom Filter。 先滤小表、再给小表建名单、最后拿名单挡大表,顺序就这三步。

传统 EXPLAIN 的 Extra 列里也能看到  Using join runtime filter,旁边还带着  Using join buffer (hash join),两边对得上。

截图7.png

with join runtime filter: bloom(f.uid),维表先按城市滤到 2625 行

 

截图7_1.png

传统 EXPLAIN 里 f 表那一行的 Extra:Using join runtime filter + Using join buffer (hash join)

 

开关打开之后,优化器还会自己判断值不值得用:看 probe 表的预估行数和 build 侧的基数,收益不够就放弃。想强制就在 SQL 里加  JOIN_RUNTIME_FILTER(f),只对探测表生效,写到 build 表上不管用,这个方向别写反。

边界也得知道:只支持内连接且含等值条件,不支持 LEFT / RIGHT JOIN,不支持  NOT EXISTS、 NOT IN 改写出来的反连接,连接条件里不能有子查询,必须是快照读( SELECT ... FOR UPDATE 不行),探测表必须是 TDStore 的普通表。

星型模型的大事实表 join 小维表,是它最舒服的场景:维度表几万行建 Bloom Filter,事实表上亿行在扫描阶段就被砍掉大半。反连接、外连接为主的业务 SQL,这条能力基本用不上。

六、长 IN 列表那道 5000 的坎

应用层拼 ID 做批量查询, WHERE id IN (...) 里塞几千个值,这类 SQL 在业务系统里到处都是。MySQL 原生是拿一个名字对一次名单,来一个人从头核一遍,N 个元素就是 O(N)。

这一版加了一条自动改写:元素数量超过阈值时,转成基于 Hash 物化派生表的 semi-join,匹配复杂度从 O(N) 变成 O(1)。

阈值是  qt_inlist_to_join_threshold,默认 5000。这个数不算低,实际业务里 IN 列表多数在几百到两三千,够不到就不会自动转。要么把阈值调低,要么用 hint 强制:

EXPLAIN FORMAT=TREE
SELECT /*+ INLIST_TO_JOIN(@`select#1`) */ *  FROM fact_order WHERE id IN (1, 2, 3, 5);

变换生效后,计划里多出一层  Materialize: Table scan on tvc_0 下面挂着它,再往下是  Batched key access inner join 回表。跟预想的  scan on in-list 不是一个形态,实际看到什么就以什么为准。

截图8.png

默认走主键范围扫描;强开后多出 Materialize 和 tvc_0

 

hint 能绕过阈值、索引要求这类软约束,绕不过列类型(BLOB / JSON / GEOMETRY)和 OR 上下文这类硬约束。写之前先看列类型,类型不对加了也没用。

七、没并行的时候,去 trace 里找 cause

参数好抄,难的是确认它到底生没生效。并行这件事最坑的地方不是配置复杂,是 它不报错:hint 写错了、阈值没过、表达式不支持并行、路径里没 Collector,全都不报错,安安静静退回串行,结果还是对的。你没法从结果判断,只能从计划看。

EXPLAIN FORMAT=TREE 只认一个东西:有没有  Gather。有的话后面跟一个  workers: N,那就是实际 DOP;没有就是串行,不用往下看。

截图9.png

Gather (slice: 1, workers: 8),两层 Aggregate

 

扫描方式分  range parallel scan 和  partition parallel scan 两种,后者只有分区表能用;在非分区表上写  PARALLEL(f 8 PARTITION) 不报错,优化器默默忽略,这点容易误判。

真要调优,用  EXPLAIN ANALYZE VERBOSE。它会展开 per-worker 的统计,每个 PartialPlan 节点下面多出几行:

Slice 1 worker 0: (actual time=33.951..559.107 rows=142323 loops=1)

两件事一眼可见。一是  ranges: N /  partitions: N,实际切了多少片,切出来只有 2 片,那 8 个 worker 里有 6 个在闲着。二是各 worker 的  rows 差距,差得越多倾斜越重。

我这张图上  ranges: 8,八个 worker 的行数落在 11.7 万到 14.2 万之间,最多最少差两万四,算均匀。要是某一个拿了几百万、其他几个几万,那就得回头看看分片键选得对不对了。

Gather 下面出现两层  Aggregate,就是 worker 先局部聚合、Leader 再汇总的两阶段聚合。

数据倾斜是并行最常见的一种失效方式。8 个窗口里有一个碰上一摞复杂件,其他窗口早早收工,整体还得等它,并行度给到 16 也没用。

另外还有个  EXPLAIN ANALYZE VERBOSE RPC,按算子给 RPC 次数和延迟。分布式下慢不一定是算得慢,也可能是等网络,这两个问题的解法完全不同。

计划里没有 Gather,想知道为什么没有,去 trace 里看  considering 那段,它会把卡在哪写出来:

SET optimizer_trace = 'enabled=on';
SELECT /*+ PARALLEL(4) */ status, COUNT(*) FROM fact_order GROUP BY status WITH ROLLUP;
SELECT TRACE FROM information_schema.OPTIMIZER_TRACE;

并行决策在 JSON 的  parallel_plan 节点下。 chosen: true 是采纳了, chosen: false 会带一个  cause:

{ "considering": { "chosen": false, "cause": "not_supported_rollup" } }

照着  cause 改写法,比反复试参数快得多。

截图10_1.png

ROLLUP 结果:0~4 各 20 万,合计 100 万

 

截图10_2.png

parallel_plan 段:chosen=false,cause=not_supported_rollup

 

总结

这版在并行上给的东西,我按能不能上手分成三档。

升级就能用的:Runtime Filter、长 IN 列表转 semi-join、子查询改写、窗口函数并行。这几项不改 SQL,把开关打开就行。但 Runtime Filter 有个前提容易漏:计划得真走 hash join,走了 BKA 它就没地方落脚。那个默认  off 的开关,最该加进上线检查单。

要申请、要评估的:并行 DML。版本说明里还挂着邀测,我这台 21.6.4.3 是能跑的,164725 行改了 1.58 秒。限制清单不短,光是  ON UPDATE CURRENT_TIMESTAMP 这一条就卡掉一大批存量表。能用的场景很明确:数据订正、历史清理、状态批量刷新,表结构还得干净。用不上别硬凑,串行分批跑了这么多年,不差这一版。

要按代价算的:并行度。窗口不是开得越多越快,来办事的人不够,八个窗口都在等汇总那一个。

最后落到操作习惯上。改完参数先  EXPLAIN FORMAT=TREE 找 Gather,找不到就去 trace 里看  cause。这两步花两分钟,比在群里问"为什么我的并行没生效"快得多。

老规矩,知道写了些啥。


来自 “ ITPUB博客 ” ,链接:https://blog.itpub.net/31466763/viewspace-3188786/ ,如需转载,请注明出处,否则将追究法律责任。

下一篇: 没有了~
请登录后发表评论 登录
全部评论

注册时间:2017-06-07

  • 博文量
    393
  • 访问量
    418475