前言
写报表的同学对这种 SQL 肯定不陌生------
SELECT后面跟一串括号,每个括号里套一个小查询,各算各的指标。写起来顺手,读起来也清楚。但很多人不知道的是,这种写法在数据库层面其实非常低效,数据量一大就容易出问题。
下面我们就来聊聊金仓数据库怎么在优化器里解决标量子查询的性能问题。
文章目录
- 前言
-
- 一、标量子查询的写法很自然,代价却很高
-
- [1.1 一段典型的报表 SQL](#1.1 一段典型的报表 SQL)
- [1.2 标量子查询是什么](#1.2 标量子查询是什么)
- [1.3 到了 HTAP 时代,矛盾更突出](#1.3 到了 HTAP 时代,矛盾更突出)
- [二、为什么不能直接把子查询改成 JOIN](#二、为什么不能直接把子查询改成 JOIN)
-
- [2.1 子查询可能返回多行](#2.1 子查询可能返回多行)
- [2.2 COUNT 函数的返回值有特殊之处](#2.2 COUNT 函数的返回值有特殊之处)
- [2.3 小结](#2.3 小结)
- 三、传统优化器是怎么执行的
- 四、金仓数据库的优化方案
-
- [4.1 第一步:等价性判定](#4.1 第一步:等价性判定)
- [4.2 第二步:子查询改写为 LEFT JOIN](#4.2 第二步:子查询改写为 LEFT JOIN)
- [4.3 第三步:相似子查询合并](#4.3 第三步:相似子查询合并)
- 五、实测数据
- 六、总结
一、标量子查询的写法很自然,代价却很高
1.1 一段典型的报表 SQL
下面这种写法在做报表的场景里太常见了:
sql
SELECT
dept_id,
dept_name,
-- 最高薪资
(SELECT MAX(salary) FROM employees
WHERE employees.dept_id = departments.dept_id) AS max_salary,
-- 平均薪资
(SELECT AVG(salary) FROM employees
WHERE employees.dept_id = departments.dept_id) AS avg_salary,
-- 部门人数
(SELECT COUNT(*) FROM employees
WHERE employees.dept_id = departments.dept_id) AS headcount
FROM departments;
三个子查询挂在 SELECT 后面,每个负责一个指标。从业务角度看,这段代码组织得非常清晰。
但执行的时候会发生什么?如果 departments 表有 1 万行,那三个子查询各自要跑 1 万次------加起来 3 万次。更关键的是,三个子查询访问的是同一张 employees 表,连接条件也完全一样,只是最后取的聚合函数不同。等于同一张表被重复扫描了 3 万遍。
这就是标量子查询的核心问题:重复执行。
1.2 标量子查询是什么
简单说,标量子查询就是只返回单个值(一行一列)的子查询。它最常见的位置是 SELECT 子句里,给主查询的每一行附上一个计算值:
sql
SELECT
a.col1,
a.col2,
(SELECT aggregate_func(字段) FROM 表B WHERE 关联条件) AS 附加列
FROM 表A a;
这种模式在 OLAP 场景里用得很多------报表、看板、数据大屏后面的 SQL 经常这么写。但从执行模型上看,它是逐行触发子查询的:主查询出多少行,子查询就跑多少次。数据量小的时候没什么感觉,规模一上来性能就扛不住了。
1.3 到了 HTAP 时代,矛盾更突出
2026 年,HTAP(混合事务/分析处理)已经是数据库领域的主流方向。企业越来越倾向于在同一套数据库上同时处理交易和实时分析。
在这种架构下,标量子查询的问题就不仅仅是"报表慢一点"了------分析 SQL 占用的资源会直接影响到同一台数据库上的交易响应时间。以前分析库和交易库分开部署,报表跑得慢还能接受;现在混在一起,慢就意味着拖后腿。
所以优化器必须能在用户无感知的情况下,自动把这类低效的写法转换成高效的执行方式。金仓数据库的标量子查询消除机制就是针对这个问题设计的。
二、为什么不能直接把子查询改成 JOIN
既然标量子查询慢,那优化器直接把它改写成 JOIN 不就行了?
没那么简单。改写有一条硬性约束:改完的结果必须和改之前完全一致。这一点看似理所当然,实际操作中有两个很容易踩的坑。
2.1 子查询可能返回多行
标量子查询的语义是只返回一个值。如果实际执行时子查询返回了多行,数据库会直接报错:
sql
SELECT
dept_id,
(SELECT salary FROM employees
WHERE employees.dept_id = departments.dept_id) AS salary
FROM departments;
-- ERROR: more than one row returned by a subquery used as an expression
报错本身是正确的行为------子查询承诺返回单值,结果返回了多行,拒绝执行很合理。
但如果优化器把它改写成了 LEFT JOIN:
sql
SELECT d.dept_id, e.salary
FROM departments d
LEFT JOIN employees e ON e.dept_id = d.dept_id;
这时候多行不再触发报错,而是直接拼到结果集里。主查询的行数变了,但用户看不到任何错误提示------拿到的结果看起来很正常,实际是错的。这种情况比直接报错更危险。
2.2 COUNT 函数的返回值有特殊之处
另一个需要特别注意的场景是 COUNT:
sql
SELECT
dept_id,
(SELECT COUNT(*) FROM employees
WHERE employees.dept_id = departments.dept_id) AS headcount
FROM departments;
关键点在于:当子查询匹配不到任何记录时,不同聚合函数的返回值是不同的。
COUNT(*)会返回 0------一行一列,值为 0SUM、MAX、MIN、AVG会返回 NULL
如果把标量子查询改写成了 LEFT JOIN,右表匹配不到的那一侧补的是 NULL。这对 SUM、MAX 这些函数来说没问题,因为原始结果本来就是 NULL。但 COUNT 那边就不对了------原来应该是 0,改完变成了 NULL。
数字上只是差了一点点,但落到报表上就会多出莫名其妙的空值,遇到对账场景会很麻烦。
2.3 小结
所以优化器在动手之前,必须先做严格的检查:
- 子查询是不是在任何数据情况下都只返回一行
- 里面有没有用 COUNT,如果有需要做额外的转换处理
- 子查询内部有没有 UNION、窗口函数这类复杂结构,复杂度越高验证难度越大
通不过检查的子查询就保持原样不动。安全是底线。
三、传统优化器是怎么执行的
在讲金仓数据库的方案之前,先看看传统做法。
传统的执行策略可以概括为三步:
sql
-- 伪代码
FOR each row IN (SELECT * FROM departments):
max_salary := EXECUTE (SELECT MAX(salary) ... WHERE ...)
avg_salary := EXECUTE (SELECT AVG(salary) ... WHERE ...)
headcount := EXECUTE (SELECT COUNT(*) ... WHERE ...)
OUTPUT row + max_salary + avg_salary + headcount
END FOR
先把主查询跑完,然后对每一行结果逐个执行子查询。有几个子查询就跑几轮,彼此之间完全独立。
问题很明显:三个子查询访问的是同一张表,条件也一样,但每次都要重新扫描一遍。这就好比你每次只从超市买一样东西,买完回家一趟再去买下一样。明明可以一次搞定的事情,非得来回折腾。
数据量小的时候差异不大,但当主表有几十万行,这种执行模式对 I/O 和 CPU 的消耗就非常可观了。
四、金仓数据库的优化方案
金仓数据库在 V009R002C014 版本中引入了标量子查询消除机制。整体思路分三步:
- 判断能不能优化------做等价性验证,只动安全的子查询
- 改写执行方式------把子查询转成 LEFT JOIN
- 合并相似子查询------结构相同的子查询合成一个再连接
4.1 第一步:等价性判定
这一步的目标不是"尽可能多消除",而是只动那些确定安全的子查询。
具体来说要做三件事:
判断返回行数。 分析子查询的结构,确认它在任何可能的数据分布下都只返回一行。如果不确定,就不动。
处理复杂结构。 子查询里如果包含聚合函数、窗口函数、UNION 等,要做额外的约束检查------结构越复杂,出问题的概率越高。
特殊处理 COUNT。 前面提到过,COUNT 在没有匹配记录时返回 0 而非 NULL。这个差异需要专门处理,否则改写后结果会变。
sql
-- 可以消除的例子
-- 有 GROUP BY,每组必然只返回一行
(SELECT MAX(salary) FROM employees
WHERE employees.dept_id = d.dept_id
GROUP BY dept_id)
-- 主键关联,一定只匹配一行
(SELECT salary FROM employees
WHERE employees.id = d.manager_id)
-- 不能消除的例子
-- 没有 GROUP BY 也没有唯一约束,可能返回多行
(SELECT salary FROM employees
WHERE employees.dept_id = d.dept_id)
通不过验证的子查询会保留原样,不会强行优化。
4.2 第二步:子查询改写为 LEFT JOIN
通过验证的子查询进入改写阶段。思路是把 SELECT 后面的标量子查询提取出来,变成一个内联视图,再通过 LEFT JOIN 与主查询关联。
来看单个子查询的改写过程。
用户写的原始 SQL:
sql
SELECT
dept_id,
dept_name,
(SELECT MAX(salary) FROM employees
WHERE employees.dept_id = departments.dept_id) AS max_salary
FROM departments;
优化器内部改写为:
sql
SELECT
d.dept_id,
d.dept_name,
sub.max_salary
FROM departments d
LEFT JOIN (
SELECT dept_id, MAX(salary) AS max_salary
FROM employees
GROUP BY dept_id
) sub ON d.dept_id = sub.dept_id;
改写后,子查询只执行一次,算好所有部门的聚合结果,再通过 JOIN 一次性匹配上去。而且 LEFT JOIN 还可以继续利用索引、Hash Join 等优化手段------这些在原来逐行执行子查询的模式下是享受不到的。
4.3 第三步:相似子查询合并
实际业务中 SELECT 后面经常有多个子查询,它们的结构往往很相似------访问同一张表,用同一个连接条件,只是聚合函数不同。
对于这种情况,优化器会把它们合并成一个内联视图,只做一次 JOIN。
用户写的原始 SQL:
sql
SELECT
dept_id,
dept_name,
(SELECT MAX(salary) FROM employees
WHERE employees.dept_id = departments.dept_id) AS max_salary,
(SELECT AVG(salary) FROM employees
WHERE employees.dept_id = departments.dept_id) AS avg_salary,
(SELECT COUNT(*) FROM employees
WHERE employees.dept_id = departments.dept_id) AS headcount
FROM departments;
优化器内部合并改写为:
sql
SELECT
d.dept_id,
d.dept_name,
sub.max_salary,
sub.avg_salary,
sub.headcount
FROM departments d
LEFT JOIN (
SELECT
dept_id,
MAX(salary) AS max_salary,
AVG(salary) AS avg_salary,
COUNT(*) AS headcount
FROM employees
GROUP BY dept_id
) sub ON d.dept_id = sub.dept_id;
三个子查询合并之后,只需要一次表扫描、一次聚合、一次连接。和原来"各扫各的"相比,差距非常大。
五、实测数据
准备两张表,各 1 万条记录:
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));
测试 SQL:
sql
SELECT (SELECT SUM(id) FROM t2 WHERE t1.id = t2.id) FROM t1;
结果:
| 优化前 | 优化后 | |
|---|---|---|
| t2 扫描次数 | 10,000 次 | 1 次 |
| 执行时间 | 32 秒 | 24 毫秒 |
优化前,t1 每出一行就要对 t2 做一次全表扫描,1 万行就是 1 万次扫描,耗时 32 秒。优化后,标量子查询被改写为 JOIN,t2 只扫了一遍,耗时降到了 24 毫秒。
这个提升不是靠加索引、加硬件堆出来的,完全来自优化器在执行前对 SQL 做的等价改写------同一条语句,同样的数据,结果一样,但执行方式完全不同。
用 EXPLAIN 可以直观地看到执行计划的变化:
sql
EXPLAIN SELECT (SELECT SUM(id) FROM t2 WHERE t1.id = t2.id) FROM t1;
优化前的执行计划中会出现 SubPlan 节点------意味着对每一行都要重新执行子查询:
Seq Scan on t1
SubPlan 1
-> Aggregate
-> Seq Scan on t2 (filter: t1.id = t2.id)
优化后 SubPlan 消失了,变成了 Hash Left Join:
Hash Left Join
-> Seq Scan on t1
-> Hash
-> HashAggregate
-> Seq Scan on t2
六、总结
标量子查询消除做的事情可以概括为:把多次重复执行的子查询改写为只执行一次的聚合 + 连接,同时把结构相似的子查询做合并。
原来:N 个子查询 × M 行主表 = N×M 次扫描
现在:1 次聚合 + 1 次连接
金仓数据库在这个优化上遵循三个原则:
- 只动经过严格验证的子查询,结果必须一致
- 结构相似的子查询合并成一个内联视图
- 整个过程对用户透明,不需要改 SQL
在 HTAP 架构已经成为主流的今天,分析型 SQL 和交易型 SQL 跑在同一套数据库上。标量子查询这种在报表里大量使用的写法,如果处理不好,会直接拖累交易系统的响应时间。金仓数据库的这项优化,本质上就是让优化器在用户无感知的情况下,自动把低效的分析型写法转化为适合高并发执行的连接操作。