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

TDSQL Boundless 的 V21.6.4 是今年 8 月推的,重点不在容量和成本,在查询本身:Parallel Hash Join、Runtime Filter、并行 DML、长 IN 列表改写,都指向同一件事,把过去必须一个线程走完的那一段拆开。
并行这件事说白了就是开窗口办事:开八个窗口,队伍散开。但很多 SQL 的真实情况是,八个窗口都挂着牌,只有一个在办。
前面那篇 《胖头鱼的技术专栏-453 让冷数据"冷"得其所(20260730)》聊的是把数据搬到便宜的地方去,这次反过来,数据不搬了,让机器多干活。
不太顺耳的是,这些能力的开关默认基本是关的,而且不止一道。更麻烦的是它不报错:监控上 CPU 不高不低,结果一条不差,就这么串行跑完了。
跑下面这些数的是台 21.6.4.3 的实例,一百万行订单表加两万行维表,会话级 8 路并行。单机、单次测量,看倍数和现象,别拿去和别的环境比。

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

第一道是
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 能让你并行,不保证让你更快。

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 的多半是在给自己找麻烦。



两张表:
fact_order 一百万行订单,
dim_user 两万行维表,
uid 均匀打在两万个用户上。
造数不用逐行 insert,LSM 引擎上太慢。拿一张 0 到 9 的辅助表交叉连乘,五次就是十万行,滚几轮凑到一百万。表结构和完整语句在配套脚本里,这儿不铺开。
造完先核行数,对不上后面全白跑。

开跑之前把开关拍一屏存下来,后面任何"没生效"的怀疑,都从这屏开始排查。
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 那一行准得多。严格点应该交替跑三轮取稳定值,我只跑了一轮,你那边数据分布不同,数会不一样。


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 秒。
邀测名单这个事还是先确认,版本之间口径未必一样,别拿我这台的结果当通行证。


并行 DML 里各 worker 负责扫描和修改,Leader 统一提交。行数改得越多,Leader 越可能成为瓶颈,几千万行那种规模的更新,串行反而可能更快。改完看
PQ_active_worker 这个状态变量和 CPU 水位。
这一项和并行 DML 不是一回事,别混。
过去 Hash Join 的麻烦在于:scan 能多线程并行,build 不行。优化器会把两边数据都收上来,在 Leader 上建一份 Hash 做 join,内存只占一份,可 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 才站得住。



开关
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 多毫秒的差距我不敢当结论,但这组数至少说明它不是万 能加速器。
Hash Join 的经典浪费:probe 侧扫了一亿行,join 完只剩几十万行。那九千多万行白扫了,白传了,白参与了计算。
Runtime Filter 说白了就是在门口先核一遍名单:build 侧建 Hash Table 的时候顺手建一个 Bloom Filter,把 join key 的取值集合塞进去,下推到 probe 侧的扫描路径上。扫的时候先查 Bloom Filter,名单上没有的直接不让进,不传输、不参与 join。

这条最容易漏,因为开关藏在一个不显眼的地方,而且默认关着:
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),两边对得上。


开关打开之后,优化器还会自己判断值不值得用:看 probe 表的预估行数和 build 侧的基数,收益不够就放弃。想强制就在 SQL 里加
JOIN_RUNTIME_FILTER(f),只对探测表生效,写到 build 表上不管用,这个方向别写反。
边界也得知道:只支持内连接且含等值条件,不支持 LEFT / RIGHT JOIN,不支持
NOT EXISTS、
NOT IN 改写出来的反连接,连接条件里不能有子查询,必须是快照读(
SELECT ... FOR UPDATE 不行),探测表必须是 TDStore 的普通表。
星型模型的大事实表 join 小维表,是它最舒服的场景:维度表几万行建 Bloom Filter,事实表上亿行在扫描阶段就被砍掉大半。反连接、外连接为主的业务 SQL,这条能力基本用不上。
应用层拼 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 不是一个形态,实际看到什么就以什么为准。

hint 能绕过阈值、索引要求这类软约束,绕不过列类型(BLOB / JSON / GEOMETRY)和 OR 上下文这类硬约束。写之前先看列类型,类型不对加了也没用。
参数好抄,难的是确认它到底生没生效。并行这件事最坑的地方不是配置复杂,是 它不报错:hint 写错了、阈值没过、表达式不支持并行、路径里没 Collector,全都不报错,安安静静退回串行,结果还是对的。你没法从结果判断,只能从计划看。
EXPLAIN FORMAT=TREE 只认一个东西:有没有
Gather。有的话后面跟一个
workers: N,那就是实际 DOP;没有就是串行,不用往下看。

扫描方式分
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 改写法,比反复试参数快得多。


这版在并行上给的东西,我按能不能上手分成三档。
升级就能用的: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/ ,如需转载,请注明出处,否则将追究法律责任。