导语:
同一条 SQL,业务代码一字未动,执行时间从 32 秒降到 24 毫秒。这不是索引的功劳,也不是硬件的功劳,而是优化器在 SELECT 列表的角落里,安静地做完的一次改写。本文基于金仓数据库《标量子查询消除的实践与思考》,完整复盘这次优化的来龙去脉。
一、一个工程师都熟悉的场景
在很多客户业务中,SQL 常常是这样组织的:SELECT 列表里挂着多个标量子查询(每个只返回一个值),用来对主查询每行数据做进一步处理;而且这些子查询结构相似,只是输出列不同。
业务语义没毛病,可读性甚至很好。但执行层面藏着两个具体隐患:
- 对外层表(如 S11)的每一行记录都要执行一次子查询,记录越多越慢;
- 多个结构相似的子查询会被分别执行,造成资源浪费。
一句话:根本问题不在子查询本身,而在于子查询被不断重复地执行。
二、为什么不能直接改成 JOIN
把 SELECT 里的标量子查询改写成连接,听起来很自然。但内核层面必须先保证语义安全性(Equivalence),两个反例非常具体:
- 子查询返回值不是标量:原查询应当报错;一旦改成连接,不仅不报错还返回多行------语义不等价。
- 子查询使用
count:无匹配时count返回0,sum/max/min/avg返回NULL。若直接改成外连接,无匹配时会补NULL,与原始的0不一致。
结论:不是所有标量子查询都能消除,必须有严格的等价性判定。
传统优化器的执行策略很朴素------完整执行外层查询,对每一行执行一次子查询,多个子查询分别执行。问题恰恰就在这里:每次子查询访问的数据其实是相同的,却被反复执行。
三、三步法
金仓数据库在 V009R002C014 版本中引入了一套标量子查询消除机制,整体思路三步走,把正确性判定和性能改写严格解耦。
第一步|能不能优化:等价性判定
目标不是"尽可能多消除",而是只识别绝对安全的优化机会。分析子查询结构,对聚集、窗口、UNION 等复杂子查询做约束性判定。回答:"消除之后结果会不会变?"
第二步|如何优化:子查询转外连接
通过等价性校验后,把目标列中的相关标量子查询转换为内联视图 ,与外部相关表做左外连接,后续优化策略接管。
为什么是左外连接?因为外层每行必须保留------这正是前一步等价性约束的要求。
第三步|进一步优化:相似子查询合并
如果目标列中存在多个可合并的标量子查询,合并为一个内联视图再与外部连接,直接消掉"重复执行"。
四、一组让人安静下来的数据
复现脚本:
sql
create table t1(id numeric(10,1));
create table t2(id numeric(10,1));
insert into t1 values(generate_series(1,10000));
insert into t2 values(generate_series(1,10000));
select (select sum(id) from t2 where t1.id=t2.id) from t1;
测试结果:
- 子查询未消除 :对 t1 的每一条记录都要对 t2 进行一次全表扫描,需要对 t2 表扫描 1 万次,耗时 32 秒。
- 子查询消除后 :对表 t2 只需要执行一次扫描,总执行时间约 24 毫秒。
性能提升数量级明显------这是原文最克制也最准确的描述。
差距大约是 1300 倍。但更值得关注的不是倍率,而是:用户的 SQL 没有改一个字符,业务代码没有动一行,性能就跨越了一个数量级。这是优化器型优化和索引型优化最大的不同------它不要求业务做任何配合。
五、结语
标量子查询消除单独看是一个非常细的优化点,它不在发布会 PPT 上,也不会成为架构话题。但把它和近些年内核演进的其他动作放在一起看------CBO 细化、子查询解关联、连接重排序、谓词下推------会发现它们共享同一种气质:
- 把用户写得自然的 SQL,在内核层面悄悄改写成执行得高效的 SQL;
- 把正确性判定和性能改写严格分层,保守优先、宁缺毋滥;
- 优化的收益不依赖业务改造,老系统直接受益。
它不喧哗,不要求用户为它做任何事,只是在 SELECT 列表的某个角落里,把本来要跑 32 秒的查询,安静地变成 24 毫秒。
而这恰恰是好的基础软件应有的样子。