当优化器开始“动脑子“:DISTINCT去重这活儿,其实很多时候根本不用干

0 阅读16分钟

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

先扯个让我血压上来的事

上礼拜群里有个哥们丢了个SQL出来,问为啥跑这么慢。我一看,SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1。就这?a被WHERE钉成1了,b也被钉成1了,结果集里每条记录都长一模一样,你还DISTINCT啥呢?去重个寂寞啊。但数据库呢,你猜怎么着,老老实实全表扫一遍排序去重,几十毫秒才出来。我当时就跟群友说,优化器要是稍微"想想",推导出来a和b都已经是常量了结果最多一条,直接LIMIT 1返回不就完了?0.03毫秒的事。

后来翻KES的更新日志——说实话翻日志这种事我平时懒得干,那天不知道为啥手痒——发现V9R4C19还真就把这活给干了。不是一层,是两层优化。第一层DISTINCT改写GROUP BY,第二层更狠,判断目标列被常值固定的话直接LIMIT 1替代去重。30毫秒变0.03毫秒,一千倍。一千倍啊朋友们,这比什么加索引调参数暴力多了,纯粹是优化器在"推理"。

今天就唠唠这个事。不光聊KES怎么做的,还想聊聊更深的东西——优化器从"机械翻译"到"逻辑推理"这个演变方向。看着是个小功能,但折射出来的东西挺大的。还有跟达梦的对比,国产数据库之间优化器逻辑推理深度的差异,这玩意儿挺值得掰扯的。

DISTINCT为啥这么招人烦,说来话长

DISTINCT这个关键字吧,谁不会用?去重嘛,SQL入门第一天就学了。问题不在语法本身,在于数据库处理DISTINCT的方式太……咋说呢,太"老实"了,或者换个说法,太傻。

SELECT DISTINCT a, b FROM s1;

你写这么一句,数据库干了些啥?全表扫描s1,把所有行捞出来,排序,然后去重。数据量小你感觉不到啥,数据量一大排序本身就是性能杀手。时间复杂度O(N log N)听着还行,但N要是百万级甚至千万级呢?中间结果还得写临时段,内存不够就落盘,落盘就更慢了。而且你还没法用索引来帮它加速——除非你恰好有a和b的联合索引还恰好覆盖了查询,这种巧事实际业务里哪有那么多。

更让人整无语的是这种:

SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1;

WHERE已经把a和b锁死了好吧。a就是1,b就是1,结果集里每行都一样,去什么重?但传统优化器不管这些啊,它看到DISTINCT就老老实实走全套——扫描、排序、哈希,一个不落。你明知道答案就一个,它非要翻遍整本字典再告诉你。这跟考试知道答案还把草稿纸从头写到尾有啥区别?

我之前在一个项目里碰到过更离谱的。一个报表SQL,DISTINCT嵌了三层子查询,最外层DISTINCT的列在最里层WHERE里已经被等值条件固定了,但数据库还是傻乎乎地全量去重。当时跟同事吐槽说这要是优化器能自己推导出来"结果唯一"就好了,同事说醒醒吧,优化器哪有这脑子。没想到KES还真做了。有时候你还真别说,梦想这种东西……

KES的两层刀法,先稳后狠

KES对DISTINCT的优化分两层,我管它叫"先稳后狠"。为啥这么说呢,第一层是等价改写,语义完全一致,怎么改都不会出事,稳。第二层是推理消除,得靠逻辑推导判断"结果唯一",逻辑对了就狠,一千倍提升,狠得一批。

第一层:DISTINCT变GROUP BY,看着平平无奇但真管用

这层听着没啥新鲜的但实际很管用。DISTINCT和GROUP BY语义上本来就等价嘛——SELECT DISTINCT a, bSELECT a, b ... GROUP BY a, b是同一个意思。但GROUP BY有DISTINCT没有的好处:它能接上已有的键值消除和并行处理能力。

-- 你写的
SELECT DISTINCT a, b FROM s1;

-- KES内部给你改成
SELECT a, b FROM s1 GROUP BY a, b;

改成GROUP BY之后有啥好处呢?如果a或b恰好有主键约束或者唯一索引,KES的分组消除机制就派上用场了——分组键包含主键的话每组天然就一行,去重直接省掉,连分组操作都不用做。而且GROUP BY还能走并行执行,DISTINCT在很多数据库里是不支持并行去重的,数据量大的时候这一条就很要命。

就这一层改写,官方文档里写的实测数据:464毫秒降到249毫秒,差不多砍半。听着没一千倍那么夸张对吧,但胜在通用性强——基本上所有带DISTINCT的查询都能走这层,不挑场景。

-- 开启DISTINCT优化
SET kdb_rbo.rbo_rule = on;
SET kdb_rbo.enable_distinct_optimization = on;

嗯对,这个功能也是通过kdb_rbo系列参数控制的。默认关闭,得手动开。我一开始还纳闷为啥默认关,后来想了想也能理解——新功能嘛,先让用户自己试确认没问题再全局开,这个思路没问题。建议先session级别试试水,别上来就改kingbase.conf。

第二层:LIMIT 1替代去重,这个才是真正的狠活

这层是我今天最想聊的。当目标列被常值固定的时候,结果集最多就一条,去重完全没必要,直接LIMIT 1完事。

-- 你写的
SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1;

-- KES内部给你改成
SELECT a, b FROM s1 WHERE a=1 AND b=1 LIMIT 1;

整个执行逻辑从"扫全量数据再排序去重"变成了"找到一条满足条件的记录就返回"。差不多就是从"翻遍整本字典找字"变成了"翻到就合上"。实测数据:优化前30毫秒,优化后0.03毫秒。一千倍。我第一次看到这个数字的时候以为自己搞错了,反复确认了好几遍,就是0.03毫秒。什么索引优化什么参数调整能给你一千倍?做不到的。因为这不是在优化执行效率,是在消除一个根本不需要存在的操作。

单表场景已经够猛了,多表JOIN更夸张:

-- 多表JOIN场景
SELECT s1.a, s2.b FROM s1 
INNER JOIN s2 ON s1.a = s2.b AND s1.a = 5 
GROUP BY s1.a, s2.b;

-- 优化前:扫s1和s2筛a=5的数据,JOIN,再分组去重
-- 官方数据:12ms
-- 优化后:JOIN完直接LIMIT 1输出
-- 官方数据:0.08ms

150倍。而且数据量越大差距越大——优化前中间结果集跟着数据量涨,优化后反正就输出一条跟数据量没啥关系。客户现场那种百万级表的报表,一个DISTINCT上去跑好几秒,要是能走LIMIT 1替代,估计就是毫秒级出结果了。

等等,优化器怎么知道结果唯一的?

你可能会问——优化器怎么就敢判断"WHERE a=1 AND b=1意味着目标列被固定了"?这听着像人脑的推理啊,机器怎么做到的?

核心就俩技术:常量传递和谓词传递。这俩名字听着唬人,其实都是编译原理里挺基础的东西,但用在数据库优化器里效果确实炸裂。

常量传递好理解。WHERE里面写了a=1,那a这个列在当前查询范围内就是1,没跑。优化器把这个常量值"传递"到SELECT的目标列里——目标列a的取值被锁死了,就是1,不会变。就这么简单,但这步推导是关键中的关键。没有这一步,后面所有的推理都无从谈起。

谓词传递稍微绕一点,我举个例子。INNER JOIN的等值条件ON s1.a = s2.b AND s1.a = 5,已知s1.a等于5,而s1.a又等于s2.b,那s2.b也必然等于5——这就是谓词沿着等值链在表之间"传递"。你把这条链理清楚,优化器就能推导出s2.b也被固定了。

但这里头有个容易踩坑的地方——只有INNER JOIN的等值条件能做谓词传递,LEFT JOIN不行。为啥呢?左连接右表可能没有匹配行导致NULL补位,等值链不成立。s1.a=s2.b这个等式在LEFT JOIN里不是对所有行都成立的,右表没匹配到的时候s2.b是NULL不是5。这个一定要搞清楚,不然你写了个LEFT JOIN还指望优化器帮你推导常量,结果它跳过了你还纳闷为啥没生效,那就尴尬了。

把两个合起来看,优化器实际上在干一件事——构建一棵逻辑表达式树,然后沿着树做推导。a=1推导出a被固定 → b=1推导出b被固定 → 目标列{a,b}全被固定 → 结果最多一条 → 不需要去重 → LIMIT 1替代。整条推理链一步一步都是确定性的逻辑推导,不涉及任何概率估算。这一点很重要,后面会反复提到。

说个绕不开的事:跟达梦比到底差在哪

参考资料里有个跟达梦(DM)的对比测试,同样的SQL:

EXPLAIN SELECT DISTINCT a, b FROM distinct_1 WHERE a=1 AND b=1;

DM V8的执行计划显示,它还是把这条SQL当作标准DISTINCT来处理——全表扫描、排序、去重,该走的步骤一个不落。不支持自动转LIMIT 1。同样的查询条件,KES已经0.03毫秒返回了,DM还在那儿老老实实排序去重。

说实话这个对比嘛……也不能简单说"DM不行"。不同数据库优化策略的侧重点本来就不一样,DM可能在别的方向有它的优势,这个要客观看待。但至少从这个特定的"基于逻辑推理的冗余消除"能力来看,KES确实走在前面。

更深层的原因是啥呢?传统优化器,DM这种也包括在内,核心思路是"基于代价选方案"——你写了DISTINCT,它就在各种执行DISTINCT的方案里选代价最低的。更好的排序算法、更高效的哈希、也许加个并行,但从来没想过"这个DISTINCT根本不需要做"。这是范式层面的差异,不是谁强谁弱的问题,是思路不一样。

不过话说回来,纯基于代价的优化也有它的道理——覆盖面广,什么场景都能算一把,哪怕算错了至少有个兜底方案。逻辑推理只能处理它"想得通"的模式,想不通的就跳过不管了。所以最理想的状态是两者结合,后面细聊这个。

优化器这玩意儿,从"算得快"到"想得通"走了多远

聊到这我想把视角拉远一点。数据库查询优化器发展到今天,大致经历了几个阶段,但这个阶段的划分本身就有争议,我得说说这里头的复杂性。

早期优化器就是基于规则的(RBO),靠一套预定义的规则选执行方案——看到索引就走索引,看到排序就用排序合并,机械但可靠。规则是人写的,覆盖什么场景完全取决于写规则的人想到什么场景,没想到的就不管。但RBO至少有一个优点:确定性强,同样的SQL一定出同样的计划,不会今天走索引明天走全表扫描。

后来有了基于代价的优化器(CBO),枚举各种候选计划算代价挑最便宜的,更灵活了。但CBO的核心问题是——它的代价估算依赖统计信息,统计信息可能过期可能采样不准,你代价模型再精巧,喂进去的数据不准算出来的结果也不靠谱。我见过太多ANALYZE忘了跑导致优化器选错计划的case了,有次优化器以为某列选择性很高走了索引扫描,结果实际数据严重倾斜,索引扫描比全表扫描还慢三倍,报表直接超时了。

最近几年开始有基于学习的优化器(ABO/LBO),用机器学习来预测最优计划。但学习型优化器的冷启动问题到现在也没彻底解决——你部署一套新系统哪来的历史负载数据?没数据怎么训练?训练数据不够模型效果也不好。所以目前ABO在学术圈很热闹,工业界真正落地的还不多。

那么问题来了——在CBO和ABO之间,是不是就没什么可做的了?我觉得不是。KES的DISTINCT优化实际上代表了一个被低估的方向——基于逻辑推理的优化。它不像ABO那样需要训练数据,也不像CBO那样依赖统计信息,它靠的是确定性的逻辑推导,a=1就是1,不需要采样不需要估算不需要训练。

但这个方向的研究一直挺冷门的。我翻了翻能找到的论文和资料,发现早年数据库圈子里研究查询优化,大部分精力花在物理优化上——连接顺序选择、访问路径选择、选择率估算这些。逻辑优化也有,但主要是简单的规则改写,像子查询上拉、谓词下推、外连接消除这些,不太涉及深层的逻辑推理。后来有些系统开始搞语义优化,比如利用约束信息做冗余操作消除——如果去重列有唯一约束就直接省掉去重——这属于利用schema信息的确定性推导,KES的distinct消除(不是前面说的DISTINCT转GROUP BY,是另一个优化:当去重列有唯一性约束时直接省去重)就属于这类。但更深层的"基于谓词推导的冗余消除"一直不太受重视。

这里头有个学术上的争论挺有意思的。一拨人认为查询优化应该纯粹基于代价——让数字说话,规则改写再聪明也不能代替代价模型做最终决策,因为你没法保证改写在所有场景下都是最优的。另一拨人认为基于逻辑推理的改写更可靠——代价估算本身就不准啊,统计信息过期了你代价模型再精巧也是白搭,不如在逻辑层面先把确定性的冗余干掉再说。

两边的核心假设就不一样:前者假设代价模型足够准确能反映真实执行情况,后者假设逻辑推导比数值估算更可信因为它不依赖采样。这个分歧不是谁对谁错的问题,是底层认知框架不一样。就像两拨人看同一栋楼,一拨人看的是承重结构,另一拨人看的是装修风格,关注点不同得出的结论自然不同。

我个人偏后者,不是因为我讨厌代价模型——代价模型非常重要——而是因为我踩过太多统计信息不准的坑了。逻辑推理至少是确定性的,a=1就是1,不管统计信息怎么说。统计信息可能过期可能采样偏了,但a=1这个事实不会"过期"。

不过我也得承认纯逻辑推理的局限。代价模型虽然不准但覆盖面广,逻辑推理只能处理它"想得通"的模式。比如目前KES能处理的场景还是比较基础的——直接等值条件、INNER JOIN等值推导,这些是比较规整的模式。如果常值是通过函数间接得到的呢?比如WHERE UPPER(name) = 'TOM',优化器能不能推导出name被固定了?理论上UPPER(name)='TOM'能推出name='Tom'(假设大小写唯一映射),但这个推导涉及函数语义理解,目前还做不到。跨多层子查询的常量传递,通过CASE表达式推导的等价条件,EXISTS子查询隐含的唯一性约束,这些都更复杂。现阶段只能处理最直接的等值链推导,更深层的语义推理还有很大空间。

所以最理想的状态其实是两者结合——逻辑推理先把确定性的冗余消掉,代价模型再在剩下的方案里选优。KES目前就是这么干的,kdb_rbo.rbo_rule参数设成cost就是让优化器在逻辑改写和代价选择之间做平衡,不是一味地改写也不是一味地算代价。这个设计我觉得挺合理的。

再扯扯"常量传递"和"谓词传递"到底咋回事

我试着把推理过程画出来,虽然是在脑子里画的。

你给优化器一条SQL:

SELECT DISTINCT a, b FROM s1 WHERE a=1 AND b=1;

优化器拿到之后先构建查询的逻辑表达式树。WHERE条件被解析成一组谓词:{a=1, b=1}。然后遍历SELECT的目标列{a, b},对每个目标列检查——有没有谓词把它固定成常量?

  • 检查a:WHERE里有a=1,常量传递,a被固定为1 ✓
  • 检查b:WHERE里有b=1,常量传递,b被固定为1 ✓
  • 所有目标列都被固定 → 结果最多一条 → DISTINCT替换为LIMIT 1

INNER JOIN场景多一步谓词传递:

SELECT s1.a, s2.b FROM s1 
INNER JOIN s2 ON s1.a = s2.b AND s1.a = 5
GROUP BY s1.a, s2.b;

推理链:

  • s1.a=5(WHERE直接约束)
  • s1.a = s2.b(JOIN等值条件)
  • 谓词传递:s2.b = 5
  • 目标列{s1.a, s2.b}都被固定 → LIMIT 1

每一步都是确定性的逻辑推导,不涉及概率估算和统计信息。这就是它比CBO代价模型更可靠的地方。

但这事的难度,不是技术上难,是场景覆盖难。常值可能来自WHERE的直接约束,也可能来自INNER JOIN等值条件的间接推导,还可能存在多个谓词之间形成的谓词传递链——就是A=B, B=C, C=5这种多步推导。甚至目标列本身就是常值(比如SELECT 1, 2这种),不需要任何WHERE条件就能判断。这些场景组合起来判定逻辑挺复杂的,不是简单的if-else能搞定的。

未来的优化器应该是逻辑推理和代价估算的组合——先用逻辑推理消除确定性的冗余操作,再用代价模型在剩下的方案里选最优。KES的DISTINCT优化就是前者的一个缩影,虽然目前只能处理最基础的等值推导场景,但方向是对的。

折腾了挺久才把这套东西理清楚。搞明白之后再去看执行计划,感觉就不一样了——不是停留在"这个快那个慢"的层面了,而是能从逻辑推理的角度理解优化器为什么做了这个选择。这个视角我觉得挺值。