文章目录

兼容 是对前人努力的尊重 是确保业务平稳过渡的基石 然而 这仅仅是故事的起点
先说个事儿
说实话,干DBA这行这些年,我有个特别深的感触------很多时候咱加班不是因为问题有多难,而是因为那些"明明不该是个问题"的东西偏偏就成了问题。就说创建表空间吧,这活儿按理说不复杂吧?结果偏偏就因为一个目录没提前建好,整个部署流程直接卡住,你得中断操作、切到服务器终端、一层层mkdir、改属主、改权限,搞完回来再重试。要是单机还好说,集群环境下你试试?每个节点都得同步,漏一个就是部署失败。这种事情吧,说出去都丢人,但偏偏就是真实发生在生产环境里的事儿。
后来还碰到了标量子查询和distinct去重的性能问题,那才是真正让人头秃的。一个看着人畜无害的SQL,数据量一小跑得飞快,数据量一上来直接从秒级变分钟级甚至小时级。我那次碰到的是月底跑批,一个报表查询跑了300多秒还没出结果,业务方在群里疯狂@我,你懂的,那种无力感真的太折磨人了。
今天这篇文章我就把最近折腾KES这三个特性------表空间目录自动创建、标量子查询消除、distinct语句优化------的全过程给大家捋一捋。不是说它们各自有多复杂,而是当你把这几个点串起来看的时候,你会发现KES的优化器其实在做一件挺有章法的事情:它不是头痛医头脚痛医脚,而是在内核层面把这些常见的运维和性能痛点系统地给解决了。
表空间这活儿怎么就这么多坑
老版本那点破事
先说表空间吧。表空间是干嘛的呢?简单讲就是让数据库的数据文件可以放到不同位置上去,你可以把热数据放SSD,冷数据放机械盘,索引和数据分开放------这玩意儿在存储分层管理里是基本功。
但问题来了。在以前的KES版本里,你执行CREATE TABLESPACE的时候,必须先在操作系统层面把目录建好,不然直接报错。听上去好像也没啥,多一步mkdir -p嘛。但你想想实际的运维场景:
集群部署的时候,每个节点都要手动建目录,5个节点就是5次,漏一个就是主备切换之后表空间不可用。自动化运维脚本里你得先SSH到每台机器mkdir,再验证属主和权限,脚本复杂度蹭蹭涨。最要命的是容器化环境,容器的文件系统是临时的,你手动建目录根本不靠谱,容器一重启啥都没了。
我记得有次帮客户部署,8个节点的集群,结果有个节点的目录属主配错了,当时愣是没发现,直到做了主备切换之后才发现那个表空间读写异常,排查了半天才定位到是属主的问题。折腾了好久,头发都快揪秃了。
auto_createtblspcdir这参数怎么回事
后来KES新版本出了个参数叫auto_createtblspcdir,默认是开着的。说白了这个参数干的事情就是:你建表空间的时候如果目录不存在,数据库自动帮你创建。
听着像是个mkdir -p?其实内核做了不少事:
sql
-- 查看当前参数状态
SHOW auto_createtblspcdir;
-- 关掉自动创建(金融政务场景可能要关,合规要求)
ALTER SYSTEM SET auto_createtblspcdir = off;
SELECT pg_reload_conf();
-- 重新开启
ALTER SYSTEM SET auto_createtblspcdir = on;
SELECT pg_reload_conf();
这个参数是sighup级别的,改完pg_reload_conf()就生效,不用重启数据库。对生产环境来说这就很友好了,不用专门找维护窗口。
不是随便建的,有约束
当然了,不是说你随便写个路径它就给你建,人家内核做了好几层校验:
路径必须是绝对路径------这个好理解,相对路径谁也不知道当前目录在哪。
不能在data目录下------防止业务数据和系统文件混在一起,出问题了谁都分不清。
同一个物理目录不能被多个表空间绑定------存储逻辑隔离的基本要求。
只有超级用户才能建表空间------普通用户没这个权限,从源头防非法操作。
路径里已存在的目录,属主必须是数据库运行用户------这个挺关键,不是你的目录你不能动。
我试过几个场景,说实话效果还不错:
sql
-- 场景1:目录完全不存在,自动创建整条路径
CREATE TABLESPACE mysp1 LOCATION '/home/kes/data/test/test1/test2/test3/mysp1';
-- 场景2:上级目录存在一部分,只创建缺失的部分
-- 假设 /home/kes/data/test/test1 已经存在
CREATE TABLESPACE mysp2 LOCATION '/home/kes/data/test/test1/test2/test3/mysp2';
-- 场景3:大小写混合路径也能搞定
CREATE TABLESPACE mysp3 LOCATION '/home/kes/data/test/test1/TEst2';
CREATE TABLE cc(id INT, name VARCHAR(50)) TABLESPACE mysp3;
INSERT INTO cc VALUES(1,'xiaozhang'),(2,'xiaozhao'),(3,'xiaohong');
SELECT * FROM cc;
顺便说一下场景3,大小写混合这个在国产操作系统上还挺重要的。有些文件系统区分大小写有些不区分,KES这边测试下来对大小写混合路径的识别和读写都没问题。
参数关掉之后是什么行为
有些人可能要问,那我把这个参数关了呢?关了就回到老版本的行为------目录不存在就报错,你必须自己提前建好。这种模式在金融、政务这些对权限管控要求特别严的场景下反而更合适,因为你不希望数据库在服务器上随意创建目录,那审计那边没法交代。
所以这个设计我觉得挺合理的------默认开着方便大多数人,想关随时能关,而且关了之后的行为完全可预期。
标量子查询这坑我踩得够深的
那个让我翻车的SQL
接下来聊标量子查询消除。这玩意儿我得好好说说,因为我是真踩过坑的。
当时那个SQL大概长这样:
sql
SELECT
o.order_id,
o.customer_id,
o.order_amount,
(SELECT MAX(t.trade_time)
FROM trade_records t
WHERE t.customer_id = o.customer_id) AS last_trade_time
FROM orders o
WHERE o.create_time >= '2026-01-01';
看着子查询逻辑清晰,语义明确,对吧?我那时候也是这么想的。但在测试环境数据量小的时候跑得好好的,到了生产环境数据量差个五六倍,直接300多秒出不来结果。
我用EXPLAIN ANALYZE看了一下,好家伙,子查询节点上的loops次数跟外表行数一模一样------orders有多少行,这个子查询就执行多少次。10万行就是10万次子查询,哪怕每次只花3毫秒,10万次也是300秒。
为什么会这样
说到底这是传统执行引擎的问题。早年间的数据库都是火山模型,一行一行处理数据。你外表扫出来一行,就跑一次子查询,把结果拼上去,然后再扫下一行......这种行式处理在标量子查询面前就是灾难。
而且这里面有个非常核心的技术问题:语义等价性。你不能随便改写,改完了结果必须和原来一模一样。这里面有几个坑:
返回值非标量的风险。标量子查询定义为只返回一行一列,如果它返回了多行,原始SQL会报错。但如果你把它改成JOIN,多行数据不会报错,直接返回多行结果------这就不等价了。
COUNT函数的特殊性。这个真的很坑。COUNT在没有匹配记录时返回0,但SUM/MAX/MIN/AVG返回的是NULL。如果你不做特殊处理直接改写,COUNT的0就变成了NULL,业务数据直接错乱。
所以不是所有标量子查询都能消除的,必须先做严格的等价判断。
KES的三步走方案
KES在V009R002C014版本里正式加了标量子查询消除的机制,分三步:
第一步:等价性判定------能不能优化
优化器不会急着改写,而是先判断安不安全。检查子查询结构,对聚集、窗口、UNION这些复杂情况做约束判断,COUNT函数单独处理------通不过验证的子查询保持原样不动。
这个思路我觉得挺对的。优化器最怕的不是"没优化到",而是"优化错了"。宁可保守一点,也不能把结果搞错。
第二步:转成外连接------具体怎么优化
通过安全校验之后,把SELECT列表里的标量子查询提取出来变成内联视图,再和外表做左外连接:
sql
-- 原始SQL
SELECT
dept_id,
dept_name,
(SELECT MAX(salary) FROM employees
WHERE employees.dept_id = departments.dept_id) AS max_salary
FROM departments;
-- 优化器内部改写为
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这些后续优化手段------原来逐行执行子查询的模式下这些是享受不到的。
第三步:相似子查询合并------进一步省资源
这个是我觉得最实用的一个点。实际业务里SELECT后面经常挂好几个子查询,结构几乎一模一样,就是聚合字段不同:
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
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;
三个子查询合并成一个,只需要一次表扫描、一次聚合、一次连接。和原来"各扫各的"比,资源占用直接降下来了。
开启方式:
sql
SET kdb_rbo.rbo_rule = on;
SET kdb_rbo.enable_scalar_subquery_removal = on;
注意这个功能缺省是关着的,需要手动开启。
实测效果
我直接用官方给的测试用例跑了一下:
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
SELECT (SELECT SUM(id) FROM t2 WHERE t1.id=t2.id) FROM t1;
distinct去重这事也能优化
那个全表扫描的噩梦
distinct去重,太常见了。但很多场景下distinct写法存在明显的性能浪费。
sql
SELECT DISTINCT a, b FROM s1;
传统方案:先全表扫s1,然后对结果排序,再排序去重。数据量一大,全表扫描加上排序就是性能瓶颈。
但你想啊,如果a是主键呢?那(a,b)这个组合天然就是唯一的,distinct去重完全没必要。再进一步,如果WHERE条件已经把a和b都固定了:
sql
SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1;
a和b都被常值固定了,结果最多就一行,你还全表扫完再去重?这不是浪费是什么。
KES的两层优化
KES对distinct的优化分了两个层面:
第一层:distinct改写为group by
sql
-- 原始SQL
SELECT DISTINCT a, b FROM s1;
-- 优化后内部改写
SELECT a, b FROM s1 GROUP BY a, b;
你可能会说,distinct和group by不是一回事吗?大部分情况下语义是一样的,但group by有个好处------它能利用KES已有的主键消除和并行能力。如果a是主键,group by a,b里面b可以直接消除,因为按主键分组,每组必然只有一行,b加不加都一样。
实测数据:同样一条SQL,未转换前464ms,转换优化后249ms,将近快了一倍。
第二层:limit 1代替distinct和group by
这个更狠。当目标列被常值固定时,结果最多一行,直接用limit 1:
sql
-- 原始SQL
SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1;
-- 优化后
SELECT a, b FROM s1 WHERE a=1 AND b=1 LIMIT 1;
从全量扫描+排序去重,变成找到一条满足条件的记录就返回。
把这几个特性串起来看
说到这儿我想把这三个特性放一起聊聊。
你看啊,auto_createtblspcdir解决的是运维层面的效率问题------别让DBA把时间浪费在手动建目录这种低级操作上。标量子查询消除解决的是查询优化器层面的智能性问题------别让优化器傻乎乎地逐行执行子查询。distinct语句优化解决的也是查询优化的问题------别让数据库做没必要的全表扫描和排序。
这三个点看起来各管各的,但其实有个共同的主题:让数据库自己把该干的事干了,别把人拖进去。
从更宏观的视角来看,数据库优化这个领域的研究其实经历了几个阶段的演变。早期的研究主要关注物理层面的优化------怎么建索引、怎么选访问路径、怎么调整缓冲区大小。这个阶段的优化思路本质上是"给数据库更多资源"。后来研究者们发现,光给资源不够,SQL本身的写法才是问题的根源,于是逻辑优化开始受到重视------查询重写、子查询消除、谓词下推、等价变换这些技术开始出现。到了最近几年,随着硬件架构的变化(多核CPU、SIMD指令集、大内存),向量化执行和批处理成了新方向,而逻辑优化和物理优化需要结合起来才能让向量化引擎真正发挥作用。
这里面有个一直存在争论的问题:优化器应该做到什么程度?一种观点认为优化器应该尽可能智能化,自动识别各种优化机会;另一种观点认为过度优化反而可能导致执行计划不稳定,应该让DBA有更多控制权。
最后说两句
国产数据库这些年进步确实挺大的,至少从KES这几个特性来看,它不是在简单地堆功能,而是在内核层面有思考地解决实际问题。作为一个一线的DBA,我对这种方向是认可的。
当然也不能说就没有可以改进的地方了。比如标量子查询消除目前只针对SELECT列表中的子查询,WHERE和HAVING里的还没处理;distinct优化的常值判定在复杂嵌套场景下还有提升空间。这些我觉得后续版本应该会继续完善。