标量子查询消除与向量化:一个被低估的协同效应

文章目录



兼容 是对前人努力的尊重 是确保业务平稳过渡的基石 然而 这仅仅是故事的起点


前两天凌晨三点多我还在群里跟人对线,起因是有人说"子查询消除不就是改写成JOIN嘛有啥好讲的"。我当时就急了------你只看到了逻辑改写那层皮,底下的东西你没摸到。后来吵了半天对方也没服,算了,我把想说的写出来吧。

其实我之前也不是一下就悟到这事的。去年帮一个银行哥们儿调报表SQL,那会儿关注点全在"怎么把子查询干掉让查询少扫几遍表",结果偶然看到消除前后的执行计划形态差异,整个人愣住了------不是快慢的问题,是执行模型完全变了。从参数化嵌套循环变成了JOIN加聚合,数据流可以按批次跑了。我当时还嘀咕了一句"这不就是向量化引擎最想吃的数据形态吗",但那会儿只是直觉,后来花了好一阵子才把这块想清楚。

先说标量子查询怎么跑的吧,不然后面没法聊

那个银行的风控报表SQL大概长这样:

sql 复制代码
SELECT t1.cust_id,
  (SELECT SUM(t2.txn_amount) FROM t2 WHERE t2.cust_id = t1.cust_id),
  (SELECT COUNT(*) FROM t2 WHERE t2.cust_id = t1.cust_id),
  (SELECT AVG(t2.txn_amount) FROM t2 WHERE t2.cust_id = t1.cust_id)
FROM t1
WHERE t1.region = 'EAST';

他跟我说跑了四十多分钟。t1五十万行,三个子查询就是一百五十万次独立执行。每行数据过来,启动子查询1,等,启动子查询2,等,启动子查询3,等------然后下一行。这就是所谓的"一行一启动"。我第一次看到这种执行方式的时候脑子里就冒出一个词:Row-by-row。数据库圈子里这玩意儿被骂了好几十年了,但藏子查询里面就不太容易看出来。

说到Row-by-row我得插一句------大概九十年代末那会儿学术圈就开始讨论set-oriented和row-oriented的区别了。不过那时候硬件条件有限,大家更关心IO优化,减少磁盘扫描。后来2000年代有些系统搞出了Apply算子的概念,对外表每行r执行一个E®,其实就是把嵌套循环形式化了。那会儿的研究者关注的是Apply removal能把执行计划打开,让优化器有更多选择空间,而不是死磕set-oriented execution。思路挺先进的,但跟硬件层面没什么挂钩。

然后2010年代向量化执行引擎兴起了,大家才意识到执行模型对CPU利用率影响有多大。但有个事挺奇怪的------我翻了不少论文,子查询消除和向量化执行这两个方向几乎没人放在一起看。要么只讲逻辑改写,要么只讲向量化算子,两者之间的协同关系是一片空白。这也是我写这个的原因之一吧。

等下我好像前面说得不太对,补充一下。Apply removal那个方向其实也有批评的声音,有人指出核心目标不应该是直接追求set-oriented,而是打开执行计划搜索空间让优化器自己选。我觉得两边都有道理,目的不矛盾,侧重点不同而已。

CPU到底在干嘛

说到这我必须得聊聊CPU的事,不然你理解不了后面为什么消除这么关键。

传统那个Volcano模型------就是每个算子调next()拿一行------有几个致命问题。函数调用开销,五十万行就五十万次next(),每次压栈弹栈,CPU流水线全断。缓存不友好,行式存储一行里啥字段都有,你就算个SUM,整行都得搬进缓存,旁边那些无关字段把缓存行占了。SIMD完全用不上,一行一行算SUM,向量寄存器空转。

sql 复制代码
-- 行式执行大概这样
for each row in outer_table:        -- 逐行
    result1 = exec_subquery1(row)   -- 启动子查询1
    result2 = exec_subquery2(row)   -- 2
    result3 = exec_subquery3(row)   -- 3
    output(row, result1, result2, result3)

向量化引擎怎么搞?一次拿一批,典型4096或8192个元素。为啥这数字?匹配缓存行和SIMD寄存器宽度。AVX-512同时处理16个int32,4096是16的整数倍。同类型值连续排列,CPU预取器能精准预测下个地址,缓存命中率拉满。

sql 复制代码
-- 向量化,大概意思
for each batch in data_stream:
    col_a = batch.column('txn_amount')   -- 列式取值,连续内存
    result = simd_sum(col_a)             -- 一条指令算16个
    output_batch(result)

函数调用从O(N)降到O(N/BatchSize),缓存从随机蹦迪变成顺序访问,SIMD从零变成8到32倍加速------但前提是你得喂给它适合批处理的数据。标量子查询那种一行一启动的模式,你连批都组不起来。

写到这我突然想起来前两天群里还有人问"为什么向量化引擎对子查询SQL没什么加速效果",答案就在这------不是引擎不行,是你喂的数据形态不对。

消除这事,远不止逻辑改写

KES的标量子查询消除,表面看是逻辑等价改写------把子查询变成LEFT JOIN加内联视图。但底下发生的事是执行模型切换:从参数化嵌套循环变成了流水线式JOIN+AGG。这个结构天然适合向量化。

原始计划里,外层吐一行,子查询根据参数值重新执行一遍,每个子查询实例有自己的执行状态,你没法把不同参数值的结果打包成一批。就算子查询内部做了向量化,调用开销和上下文切换也够把收益抹平。

我打个不太恰当的比方------你有个巨快的计算器但每次只能算一道题,还得排队一道一道递,计算器再快也架不住五十万次排队切换。消除之后呢?子查询变成独立内联视图,先扫描、分组、聚合把结果算好存着,然后外层跟结果做JOIN。数据按Batch流动,SIMD的并行能力在每个环节都能发挥。

回到银行的例子,改写后:

sql 复制代码
-- 消除后
SELECT t1.cust_id,
  COALESCE(agg.total_amt, 0),
  COALESCE(agg.txn_cnt, 0),
  agg.avg_amt
FROM t1
LEFT JOIN (
  SELECT cust_id,
    SUM(txn_amount) AS total_amt,
    COUNT(*) AS txn_cnt,
    AVG(txn_amount) AS avg_amt
  FROM t2
  GROUP BY cust_id     -- 一次性聚合,向量化批量处理
) agg ON t1.cust_id = agg.cust_id
WHERE t1.region = 'EAST';

内联视图里GROUP BY聚合,数据按cust_id分组后连续排列,同组txn_amount在内存里挨个放着,向量化引擎一批一批读进来做SUM。外层LEFT JOIN也能用批量hash join------先对t1建hash表,再一批一批扫内联视图结果去probe。从头到尾都是批处理。

哦对了,说到这个银行哥们儿------他那个SQL消除之后从四十多分钟降到几秒,给我发消息说"卧槽这也太快了吧"。我回他"你谢SIMD吧",他肯定没听懂。无所谓了。

其实我当时想的是另一回事来着。我一开始以为加速就是因为少扫了几遍t2,后来仔细看执行计划才反应过来------真正质变的是执行模型切换。从逐行参数化调用到批量流水线处理,这不是量变。这个认知转折我记得很清楚,那天晚上盯着监控图表,CPU利用率从3%飙到80%多,我整个人都不好了------之前那3%说明CPU基本上在空转等IO等上下文切换。

KES向量化底子怎么样

光说理论不接地气。KES ADC分析型分布式集群,行列混合存储加四级并行:分片级→节点级→实例级→CPU指令级。前三层横向扩展,最后一层就是SIMD向量化。Scan、Join、Agg核心算子都有SIMD-aware的代码路径,x86和ARM上平均加速1.8到2.3倍。AP节点自动转列式格式,扫描带宽翻倍,列存页还有5到10倍压缩。

有个细节我觉得值得单独提------KES的向量化不是只跑x86的,鲲鹏飞腾的NEON指令集也做了适配。宽度不如AVX-512但核心数多,批量处理的思路通用。国内信创背景下这事儿重要,你不能优化了x86的SIMD到了鲲鹏上又打回原形吧。

等一下我好像说错了,重新捋一下------四级并行的最后一层不光是SIMD,应该说SIMD是其中最关键的组成部分,还包括其他的指令级优化比如分支预测优化之类的。但SIMD占比最大,就这么说吧。

代价模型:KES不是看到就消除

这点很关键。KES不会看到标量子查询就无脑消除,优化器基于代价估算来决策。

原始方案代价大概是:外层扫描代价 + 外层行数 × 子查询单次执行代价。行数是乘在上面的,行数越多越惨。转换方案代价大概是:外层扫描 + 子查询表扫描分组聚合 + 连接代价。注意没有"行数×子查询代价"这个乘法项了,子查询只执行一次。

但转换不是永远划算。我碰到过,外表就几行数据,子查询基表反而特别大。消除之后变成"扫描大表聚合再JOIN",比对外层几行各执行一次子查询还慢。子查询带索引的话几次索引查找可能比全表扫描加聚合快。KES的做法是消除候选生成之后分别算两种方案成本选便宜的。这个"分别计算"听着简单,统计信息不准的话代价模型就可能判断错误。这个坑我踩过------统计信息过期,优化器以为外表十万行实际几十行,消除了反而更慢,整无语了。所以如果涉及子查询消除的SQL性能异常,先ANALYZE一下相关表,让优化器有靠谱的输入。

我前面提到那个"概念界定"的争论,现在可以展开说了。什么叫"标量子查询消除"?有人定义成逻辑改写,有人定义成算子变换,还有人认为应该包含物理执行层面的批处理适配。我倾向后一种------因为如果只看逻辑改写,没法解释同样的改写在行存和列存上性能差异那么大。逻辑改写是因,执行模型适配是果,加一起才是完整的优化。不过这个观点群里争论挺大的,有人觉得我在扩大概念边界,随他们吧。

工程上的坑

消除听着美好,落地坑不少。

多层嵌套子查询------子查询里面又套子查询,KES得递归处理,先消内层再消外层,顺序不能乱。递归深度太大优化器编译时间可能变长,碰到过嵌套四五层的SQL,光解析优化就花了好几秒。

sql 复制代码
-- 多层嵌套,一层一层剥
SELECT t1.id,
  (SELECT SUM(amount) FROM t2 WHERE t2.id = t1.id
   AND t2.type = (SELECT type_id FROM t3 WHERE t3.code = t1.code))
FROM t1;

WHERE和HAVING子句里的标量子查询------理论上也能消,但比SELECT列表里的复杂得多,可能影响行过滤和分组逻辑,不能简单提成外连接。KES对这种情况处理还比较保守。

还有子查询引用外层多个表的情况------条件同时引用外层两个表,GROUP BY键和连接条件必须把所有引用的外层表都纳入,不然分组粒度不对聚合结果就出错。改写逻辑比单表引用复杂不少。

sql 复制代码
-- 引用外层多表,GROUP BY得加两个外层字段
SELECT t1.id, t2.name,
  (SELECT SUM(amount) FROM t3 WHERE t3.key1 = t1.key AND t3.key2 = t2.key)
FROM t1 JOIN t2 ON t1.fk = t2.id;

含DISTINCT没聚合的子查询、含LIMIT/OFFSET的,KES不做消除。DISTINCT语义消除后不好保证等价,LIMIT的话改写成JOIN后行的顺序数量都不好控制。等值条件合并支持,IN、BETWEEN这种目前不支持。

限制看着多,但换个角度------KES只对绝对安全的优化机会动手,宁可少优化也不优化出bug。数据库优化器最怕的不是不够快,是结果不对。这个工程哲学我认同。

实操层面说几点

扯了这么多说点实际的。

报表SQL里SELECT后面挂多个子查询查同一张表且连接条件一样------这是消除的最佳场景,KES优化器自动合并成单个内联视图做LEFT JOIN,不改代码也能享受。前提是版本V009R002C014及以上。

统计信息维护前面提了,代价模型依赖它。定期ANALYZE,大表数据量变化频繁的场景尤其要注意。

WHERE子句中的标量子查询目前KES可能不会自动消除,手动改写JOIN时注意语义等价性------特别是COUNT空值处理和子查询返回多行的边界情况。

AP场景收益最大,大量数据聚合计算向量化批处理优势最明显。TP场景外表行数少的话消除收益有限甚至可能因额外聚合开销变慢,看具体代价估算。

监控上关注执行计划里有没有SubPlan或InitPlan节点------大量出现说明子查询没被消除,可能触发了安全限制,检查一下是不是包含DISTINCT、LIMIT这些不支持消除的结构。

sql 复制代码
-- 看执行计划,关注SubPlan
EXPLAIN ANALYZE
SELECT t1.id,
  (SELECT SUM(t2.val) FROM t2 WHERE t2.id = t1.id)
FROM t1;

-- 消除了你会看到Hash Join或Merge Join
-- 没消除就是SubPlan + Seq Scan嵌套结构

算了不纠结这个了,往下说。

再想一下这个事

我觉得数据库内核的进化有时候就这样------某个优化特性的价值,会随其他基础设施的演进而被重新定义。标量子查询消除在行存时代就是个"少扫几次表"的优化,但向量化引擎成为主流的今天,它的意义完全不同了。把SQL从行式处理推到集合处理轨道上,让向量化引擎真正发挥作用。

KES在这件事上做了个挺漂亮的技术闭环------优化器负责逻辑改写和代价决策,向量化引擎负责物理执行,单独看哪边都"也就那样",放一起看差距拉到千倍级别。这就是内核现代化的缩影吧。

不过我也不是特别确定"千倍级别"这个说法准不准,取决于具体场景。有些极端情况确实能到,但大多数场景几百倍差不多。这里我其实也不是特别确定,但印象中是这样。

折腾了好久才把这块理清楚。搞明白之后看执行计划不再是"这个快那个慢",能从CPU视角理解为什么快为什么慢。推荐大家也试试这个视角,真的会不一样。

相关推荐
zero.cyx1 小时前
软件设计师(4)数据库
数据库
.小小陈.2 小时前
MySQL 高频考点:表连接与索引全解析
数据库
阳光九叶草LXGZXJ2 小时前
达梦数据库-学习-57-读写数据页超时告警排查(page[x,x,xxxxxx] disk write uses)-DSC集群版
linux·运维·服务器·数据库·sql·学习
Omics Pro2 小时前
前沿学科:量子生物学!
大数据·数据库·人工智能·windows·redis·量子计算
霸道流氓气质2 小时前
Spring 事务提交后执行异步操作:原理、陷阱与最佳实践
数据库·spring
无小道2 小时前
Redis——list相关指令
数据库·redis·缓存
阳光九叶草LXGZXJ2 小时前
达梦数据库-堆栈看问题-01-asmapi_asm_extent_load
linux·运维·数据库·sql·学习
你的保护色2 小时前
ensp之STP、RSTP、MSTP协议实验
java·服务器·数据库
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ2 小时前
获取容器mysql管理员密码命令
数据库·mysql