你的 SQL 里藏着多少重复计算?金仓数据库标量子查询消除实战解析
2026/6/8 6:28:31 网站建设 项目流程

一、问题是怎么来的

写 SQL 写多了的人大概都有这种感觉——业务逻辑一复杂,SELECT后面就跟了一堆子查询。每个子查询就返回一个值,看着清楚、写着方便,自己都觉得这 SQL 写得挺利索。

  • 可数据库引擎看到的完全是另一回事。

1.1 先看一个真实场景

比方说你手上有两张表:客户表和订单表。老板要一份报表,每位客户的累计消费多少、最后一次下单是什么时候。你顺手就写了这么一条:

SELECT(SELECTSUM(amount)FROMorders oWHEREo.customer_id=c.id)AStotal_amount,(SELECTMAX(order_date)FROMorders oWHEREo.customer_id=c.id)ASlast_orderFROMcustomers c;

意思很明白——每个客户去订单表里算一笔总账,再找一下最近下单时间。

但你有没有注意到,SELECT后面那两个子查询,干的事儿几乎一模一样?都是拿customer_id去订单表里捞数据,就是一个取SUM、一个取MAX

写法没毛病,结果也对。可问题就出在数据库怎么跑这条 SQL 上。

1.2 "逐行执行"到底有多慢

传统优化器拿到这条 SQL,干的事情是这样的:

  1. 先把customers表从头到尾扫一遍;
  2. 拿到第一行,跑一遍子查询;拿到第二行,再跑一遍;拿到第三行,又跑一遍……
  3. 有两个子查询?那就每个都这么来一轮。

算一笔账:客户表 10 万行,两个子查询各跑 10 万次,加起来 20 万次。问题是,这 20 万次查的根本就是同一张表、用的同一个条件,就输出列不一样——纯粹在做重复劳动。

这种执行方式有个专门的叫法:Row-by-row(逐行处理)。数据量小的时候感觉不出来,等数据涨起来了,开销跟着直线上升。

更要命的是,现在的数据库内核基本都用上了向量化执行引擎——这东西的设计思路是利用 CPU 的 SIMD 指令,一批一批地处理数据。但标量子查询非得一行一行来,等于硬把引擎拽回了最原始的工作方式,现代硬件的并行能力全白瞎了。

二、改成 JOIN 就行了?没那么简单

很多人第一反应都是:子查询慢那就改成JOIN呗,这有什么难的?

方向没问题。但真上手你就会发现,这事儿有两道坎不好过。说到底就四个字的事儿:语义等价。意思是改完之后的 SQL,不管什么数据、什么边界条件,结果必须跟原来一模一样,差一行都不行。

2.1 第一道坎:多行返回——一个报错一个不报错

标量子查询有个硬性规矩:只能返回一行一列。要是跑出来多行了,数据库直接给你甩个错误:

-- 假设某个客户有好几条订单记录-- 执行结果:ERROR: more than one row returned by a subquery used as an expressionSELECT(SELECTnameFROMordersWHEREcustomer_id=c.id)FROMcustomers c;

但你要是把它改成LEFT JOIN

-- 改成 JOIN 之后不报错了,结果变成多行SELECTo.nameFROMcustomers cLEFTJOINorders oONo.customer_id=c.id;

看出来没?同样的数据,一种写法报错,另一种写法悄悄多出好几行——语义根本不是一回事。优化器要是不管三七二十一就改写,该报错的查询反而"正常跑完了",只是结果不对。这在生产环境里可是大忌。

2.2 第二道坎:COUNT 的"小脾气"

COUNTSUMMAX它们碰到"没匹配到数据"时的反应不一样。看个例子就懂了:

-- 假设订单表里压根没有 customer_id = 999 的记录SELECT(SELECTCOUNT(*)FROMordersWHEREcustomer_id=999)AScnt,(SELECTSUM(amount)FROMordersWHEREcustomer_id=999)AStotal;

跑出来的结果长这样:

cnttotal
0NULL

区别在这:COUNT没匹配到时老老实实给你一个0,而SUM返回的是NULL

那问题来了——优化器要是把子查询消掉改成LEFT JOIN,右表没匹配上时就统一补NULLSUM本来就该返回 NULL,没毛病。但COUNT呢?原来好好的一个 0,现在变成 NULL 了,这不就改错了吗?

所以结论很干脆:不是所有标量子查询都能动,得先老老实实做等价性判定,通过检查的才能消除。

三、三步走——怎么安全地把子查询干掉

前面搞清楚了哪些能改、哪些不能改,接下来的流程就顺畅了。一共三步。

第一步:能不能改?先做等价性判定

这一步的原则就一个:宁可放过,不可改错

优化器对每个标量子查询逐个"体检",三项检查一项不通过就直接跳过:

// 标量子查询消除 —— 等价性判定核心逻辑(伪代码)boolis_safe_to_eliminate(SubQuery*sq){// 检查一:UNION、窗口函数、嵌套子查询?// 这些太复杂,别碰if(has_union(sq)||has_window_func(sq)||has_nested_subquery(sq))returnfalse;// 检查二:能不能保证至多返回一行?// SELECT 列全是聚合函数才行,或者 GROUP BY 有唯一性保证if(!guarantees_single_row(sq))returnfalse;// 检查三:有没有 COUNT(*)?// 这个函数无匹配时返回 0,LEFT JOIN 补 NULL 就不对了if(contains_count_star(sq)&&!has_group_by(sq))returnfalse;// 三项全过,安全,可以消除returntrue;}

逻辑不复杂,三项检查保底,安全第一。

第二步:怎么改?——子查询变成 LEFT JOIN

过了检查的子查询,接下来就动手改:把它转成一个内联视图,再跟外部查询做左外连接

还是拿前面那个客户订单的例子来说:

-- ========== 原始 SQL ==========SELECT(SELECTSUM(amount)FROMorders oWHEREo.customer_id=c.id)AStotal_amount,(SELECTMAX(order_date)FROMorders oWHEREo.customer_id=c.id)ASlast_orderFROMcustomers c;-- ========== 改写后 ==========SELECTv1.total_amount,v2.last_orderFROMcustomers cLEFTJOIN(SELECTcustomer_id,SUM(amount)AStotal_amountFROMordersGROUPBYcustomer_id)v1ONv1.customer_id=c.idLEFTJOIN(SELECTcustomer_id,MAX(order_date)ASlast_orderFROMordersGROUPBYcustomer_id)v2ONv2.customer_id=c.id;

前后对比一眼就能看出差别:

对比项改写前改写后
怎么执行每行触发一次子查询一次性 JOIN
orders 扫几次N 次(N = customers 行数)2 次
向量化引擎能用吗不行,逐行调用的行,批量处理

第三步:还能不能更快?——把相似的子查询合并

再看看第二步的结果——两个LEFT JOIN查的还是同一张orders表,关联条件一模一样,那干嘛要扫两遍?合一起不就得了:

-- ========== 合并后的最终形态 ==========SELECTv.total_amount,v.last_orderFROMcustomers cLEFTJOIN(SELECTcustomer_id,SUM(amount)AStotal_amount,MAX(order_date)ASlast_orderFROMordersGROUPBYcustomer_id)vONv.customer_id=c.id;

这一下,orders表只扫一遍,SUMMAX在同一次聚合里一起算完。从一行一行磨,变成一把梭——这才叫集合处理。

整个流程用伪代码串起来就是这样的:

// 标量子查询消除 —— 完整流程(伪代码)voideliminate_scalar_subqueries(Query*query){List*subqueries=collect_target_subqueries(query);// 第一步:逐个体检,判定能不能安全消除foreach(sq,subqueries){if(!is_safe_to_eliminate(sq))mark_unremovable(sq);}// 第二步:通过检查的,改写为 LEFT JOINforeach(sq,subqueries){if(is_removable(sq))rewrite_to_left_join(query,sq);}// 第三步:长得像的合并成一个merge_similar_subqueries(query);}

四、这事儿的意义不止于"改个写法"

到这儿你可能觉得,标量子查询消除不就是 SQL 层面换个写法嘛——子查询变JOIN,完了。

其实真不是。这项优化真正厉害的地方在于,它把查询计划调成了现代硬件最喜欢的样子

4.1 向量化引擎就爱"成批成批"地吃数据

老一代数据库用的是火山模型,一行一行往上"吐"数据,吐一行调一次函数。向量化引擎不这么干,它一次吞一批(Batch),几百上千行打包处理,函数调用次数直接砍到底。

但标量子查询天然就是逐行的——来一行跑一次子查询,引擎根本攒不出 Batch。消除以后变成JOIN操作,数据成批成批地流过去,向量化引擎才算真正派上了用场。

拿代码对比一下更直观:

// 老办法:火山模型,一行来一次Tuplenext_tuple(){Tuple t=child->next();// 取一行returneval_subquery(t);// 跑一次子查询}// 新办法:向量化模型,一批来一次Batchnext_batch(){Batch b=child->next_batch();// 一次取一批(比如 1024 行)returneval_join_batch(b);// 一把梭,整批 JOIN 全算完}

4.2 SIMD——一条指令干八份活

向量化引擎能跑得快,底层靠的是 CPU 的SIMD 指令集。原理一句话说清:一条指令同时对付好几个数据。比如 AVX2 指令,一条就能同时给 8 个整数做加法:

#include<immintrin.h>// 用 SIMD 给两批数据做加法voidsimd_sum_batch(int*a,int*b,int*result,intcount){inti=0;for(;i+8<=count;i+=8){__m256i va=_mm256_loadu_si256((__m256i*)(a+i));// 一次装 8 个数__m256i vb=_mm256_loadu_si256((__m256i*)(b+i));// 再装 8 个数__m256i vr=_mm256_add_epi32(va,vb);// 一条指令,8 组加法同时搞定_mm256_storeu_si256((__m256i*)(result+i),vr);// 存回去}// 剩下凑不够 8 个的,老老实实逐个算for(;i<count;i++)result[i]=a[i]+b[i];}

子查询消除之后,SUMMAXMIN这些聚合操作就可以在向量化引擎里用 SIMD 并行跑了。100 万行数据,理论上只要 12.5 万次 SIMD 运算就能搞定(100 万 ÷ 8),比逐行快了将近一个数量级。

所以你看,标量子查询消除看起来只是 SQL 变了个花样,实际上它是在帮查询计划铺一条能跑在 SIMD 快车道上的路。这才是这项优化的核心价值。

五、实际跑一把——数据说话

空口说白话没意思,直接上测试:

-- 准备两张表,各塞 10000 行数据CREATETABLEt1(idNUMERIC(10,1));CREATETABLEt2(idNUMERIC(10,1));INSERTINTOt1VALUES(generate_series(1,10000));INSERTINTOt2VALUES(generate_series(2,10000));-- 跑一条带标量子查询的 SQLSELECT(SELECTSUM(id)FROMt2WHEREt1.id=t2.id)FROMt1;

测试结果

怎么跑的t2 表扫了几次花了多久
子查询没消除10,000 次(每行扫一遍)32 秒
子查询消除后1 次(改写成 JOIN)24 毫秒

32 秒变 24 毫秒,差了 1300 多倍。

为啥差这么多?没消除的时候,t1每来一行都要跑一遍SELECT SUM(id) FROM t2 WHERE ...,等于把t2从头到尾扫一遍。1 万行乘 1 万行,光是比较操作就 1 亿次。消除之后,优化器把它改成了一次LEFT JOIN+GROUP BYt2只扫一次,配上哈希聚合直接出结果。

六、收个尾

标量子查询消除,说到底就干了两件事:

砍掉重复执行。原来一行一跑的子查询,改成只跑一次 JOIN,冗余计算从根上就没了。

合并相似结构。好几个子查询查同一张表、用同一个条件,合成一个内联视图,一遍扫描把所有聚合都算完。

再往深了说,这背后体现的是查询优化器一直遵循的原则:语义不能变,但执行计划越便宜越好。这项优化不仅解决了"子查询跑得慢"这个表层问题,更关键的是把查询计划调成了向量化引擎和 SIMD 喜欢的形态,让现代 CPU 的并行能力真正有了用武之地。

对每天写 SQL 的开发同学和 DBA 来说,好处很实在:你该怎么写还怎么写,用最顺手、最好读的方式组织 SQL 就行。优化器在底下默默帮你把逐行处理变成集合处理,你不用操心。

写法优雅和跑得快,这俩不矛盾。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询