20260529 – 优化器搜索空间爆炸
现代分布式大数据引擎(OLAP)和传统关系型数据库(OLTP)在架构设计上最经典的一个分歧点了。吐槽的这个“坑”,在数据库研发圈子里被称为“优化器搜索空间爆炸”(Search Space Explosion)。
像 Spark Catalyst、Doris(特别是其新一代的 Nereids 优化器)、Presto 等现代引擎,底层几乎清一色采用了基于 Memo(备忘录) 和 Cascades(瀑布模型) 架构的 CBO(基于代价的优化器)。
这种设计在面对极端复杂的业务 SQL(比如几百个字段的宽表映射、几十次 JOIN 嵌套)时,确实经常会出现“活还没开始干,优化器先把自己累死(或者 OOM)”的蠢问题。
下面客观还原一下这两种模式的差异,以及为什么大数据引擎非得用这种容易“翻车”的 Memo 模式。
- 1. MySQL 模式:基于规则的“老司机直觉”(RBO/启发式)
MySQL 的优化器相对简单直接(哪怕后来引入了 CBO,骨子里还是重规则)。
怎么干活的: 就像一个经验丰富的老司机,看到 WHERE 就知道要把谓词下推,看到多表关联,通常就是按照索引情况和表大小,选个 Nested Loop 嵌套循环硬怼。它不会去穷举所有可能的路线。
优点: 解析和生成执行计划极快,通常只要几毫秒。不管你写了多长的 SQL,它扫一眼规则库,匹配上就直接开跑。
缺点: 极度依赖写 SQL 人的水平。如果 SQL 写的烂,或者隐式转换导致索引失效,它就会傻傻地走全表扫描。
- 2. Memo/Cascades 模式:极致的“强迫症导航仪”(现代 CBO)
Spark 和 Doris 用的 Memo 模型,其核心思想是“穷举与动态规划”。
怎么干活的: 当你提交一段包含数据清洗、多层过滤的代码(或者 SQL)时,优化器会把它转成一棵逻辑计划树。然后,Memo 会不断应用各种规则去生成等价的子树。
比如 A JOIN B JOIN C,它会在 Memo 里生成 (A JOIN B) JOIN C、A JOIN (B JOIN C)、B JOIN (A JOIN C) 等所有排列组合。
同时,它还要评估每一种组合下,用 Hash Join、SortMerge Join 还是 Broadcast Join 最省资源。
为什么会踩坑(执行计划过长/超时): 当你代码里有一个包含几十上百个字段的 withColumn 循环,或者超长的 CASE WHEN 映射时,这些逻辑操作的等价变换组合会呈指数级(O(N!))增长。Memo 这个备忘录里瞬间被塞入了成千上万个“可能的最优解”,导致 Driver 节点疯狂消耗 CPU 和内存去计算 Cost,最后要么 OOM 崩溃,要么代码生成(CodeGen)时超过了 JVM 8KB 的硬限制。
为什么 Spark/Doris 宁愿踩坑,也不退回 MySQL 的模式?
因为在分布式的大数据场景下,优化器“走错一步”的代价太惨痛了。
单机 vs 分布式: MySQL 走错执行计划,最多就是在一台机器上多扫几百万行数据,多跑几十秒。但在 Spark 或 Doris 中,如果是两张十亿级别的表 JOIN 顺序选错了,或者本该走 Broadcast(广播到各个节点)却走了 Shuffle(全网数据重分布),会导致几百台机器的网络直接瘫痪,几 TB 的数据在磁盘和网络间来回倒腾,任务跑几个小时都出不来。
两害相权取其轻: 引擎的设计者认为:“宁愿在发车前让 Driver 节点算个几十秒甚至几分钟的极优路线,也绝不敢让成百上千个 Executor 节点在路上瞎跑。” 只是他们往往低估了业务研发手写自动清洗脚本时,能把逻辑计划嵌套得有多深。
应对这种“蠢问题”的工程经验
在 Spark 或 Doris 中开发复杂的数据清洗和转化任务,防坑的核心思路就一条:人为斩断依赖链(Break the Lineage / Memo Space)。
物理斩断(最稳): 也就是你现在架构里做的,不要试图用一个巨无霸 SQL 或一挂到底的 DataFrame 把所有事做完。中间结果落盘(写入临时表、临时分区),下一个逻辑再从临时表读。
Spark 的 Checkpoint: 遇到超长 withColumn 或复杂迭代,在中间调用一次 df.checkpoint()。这会强制把前面的逻辑切断并物化到 HDFS,优化器就不会再去溯源前面那坨复杂的逻辑树了。
大宽表处理规避: 像你代码里那种几十个字段的类型映射,尽量别用 DataFrame 的链式 withColumn 组装。直接拼成一句干净的 SELECT CAST(a AS string) AS a, CAST(b AS int) AS b … FROM view,让解析器一次性解析,生成的逻辑树深度只有 1 层,完美绕过 Memo 爆炸。