He3DB子查询优化:从原理到执行计划的全面解析
1. 子查询为什么总是成为慢查询的重灾区先说个我自己的经历。之前帮一个业务团队排查慢SQL那条SQL整体看下来就是一个普通的订单列表查询业务逻辑也不复杂但线上就是频繁告警。EXPLAIN分析之后问题出在WHERE条件里的一个IN子查询上——子查询本身扫描的行数不多但因为它和外部查询存在关联条件优化器没能把它转换成最高效的执行形态导致每一行外部数据都要重新执行一次子查询。这个场景在数据库内核里有个专门的说法子查询优化没做好。He3DB大云海山数据库在做内核分析的时候子查询优化是一个绕不开的模块。原因很简单子查询是SQL语言里最灵活、最常用的表达方式之一但恰恰是这种灵活性给优化器出了大难题。一个写得并不复杂的子查询如果优化器处理不当执行代价可以比最优化形态高出几个数量级。拿日常业务举例同样是查“有订单的用户”用EXISTS、用IN、用JOIN写出来的SQL语义几乎等价但执行计划可能天差地别。优化器的核心职责之一就是识别出这种语义等价关系把用户写得不够优化的SQL改写成语义不变但执行代价更低的形态。在正式进入He3DB的优化策略之前有必要先把子查询的分类理清楚。内核开发者和DBA看待子查询的角度不太一样——DBA关心的是这条SQL慢不慢内核开发者关心的是优化器在哪个环节、以什么逻辑处理这个子查询。按照优化器的视角子查询通常被分为这么几类标量子查询Scalar Subquery、集合子查询IN/NOT IN、存在性子查询EXISTS/NOT EXISTS以及FROM子句里的派生表。不同类型的子查询对应的优化策略完全不同适用的改写规则也不一样。分类这件事看起来基础但它决定了后续所有优化手段的切入点。比如标量子查询的目标是返回一个值它更多地依赖表达式预计算和缓存机制来处理IN子查询的优化重心是能否被改写成半连接SEMI JOINEXISTS子查询则要判断是否可以先提升Pullup到上层再参与连接顺序规划。理解了分类才算真正开始接触子查询优化也才能明白优化器在每一步决策背后的取舍逻辑。He3DB作为一个数据库内核项目它在子查询优化上的做法并没有脱离经典优化器的框架但在工程实现上做了不少贴合实际场景的选择。这篇文章我会把这块内容拆开来讲从一个开发者做内核分析的角度说清楚子查询优化的核心问题、主流策略、执行计划的长相以及那些优化器“不敢动”的场景。2. 一个子查询的性能问题本质上是“关联与解关联”的问题2.1 相关子查询和非相关子查询的代价分水岭子查询按是否引用外部查询的列分成相关子查询Correlated Subquery和非相关子查询Non-Correlated Subquery。这个区分极其重要因为它直接决定了子查询的执行代价。非相关子查询没有对外部列的依赖理论上可以只执行一次把结果集缓存下来供外层反复使用。这就像你先去超市买好一周的菜放冰箱里每天做饭直接取用不用每次做饭都跑一趟超市。相关子查询则不然它的过滤条件依赖外层当前行的值外层有多少行子查询理论上就要执行多少遍。假设外层有100万行子查询本身扫描1万行最坏情况下就是100万乘以1万也就是100亿行的扫描量。这是任何索引都救不回来的——问题出在执行形态上而不是出在单表扫描效率上。所以子查询优化的核心目标在绝大多数场景下就是四个字解除关联。把相关子查询改写成非相关形态或者更进一步把它和外层查询合并成一次连接执行。这就是所谓的子查询去相关Subquery Unnesting / De-correlation。2.2 优化器如何识别可去相关的形态去相关不是无条件的。优化器需要先做一系列合法性检查确认改写后的语义和原查询完全一致再进行变换。这里面最关键的一个检查点是外层查询的列的引用范围。如果子查询引用了外层列那么转换后这个外层列必须能通过连接条件访问到。以一个最典型的EXISTS子查询为例SELECT * FROM users u WHERE EXISTS ( SELECT 1 FROM orders o WHERE o.user_id u.id AND o.amount 100 );这个查询的语义是“找出所有下过金额大于100订单的用户”。子查询里的u.id是对外层列的引用显然是一个相关子查询。经典的改写方式是把它提升为内连接或半连接SELECT u.* FROM users u SEMI JOIN orders o ON o.user_id u.id AND o.amount 100;这里把子查询里的过滤条件下推到orders表上同时把外层列引用u.id变成连接条件。执行时优化器可以选择orders作为驱动表或探测表用hash semi join或index nested-loop semi join完成匹配。无论选哪种子查询都不再是逐行重复执行的了。2.3 去相关的收益到底能有多大我给一个直观的对比。假设users表有10万行orders表有500万行orders.user_id上有索引。采用逐行执行相关子查询的方式外层扫描10万行每行都要根据u.id去索引里查一次orders代价相当于10万次索引点查。而改写为半连接后先扫描users表和orders表各一遍做一次哈希匹配就结束代价是两个表的扫描加上哈希表的构建开销。在PostgreSQL类的优化器里代价模型通常以磁盘页读取为单位估算。逐行执行方式下即使每次索引点查只需要读2到3个页面累计下来也是20万到30万页的读取量而半连接方式下orders表全扫描可能需要数万页加上users表扫描和哈希构建整体代价在数量级上是接近的——但这已经比逐行执行低了一个量级。如果orders表数据量更大或者外层行数更多差距会进一步拉大。这里要声明一下上面的数值是我根据典型场景做的估算演示不同数据库的实际代价模型不一样He3DB底层基于PostgreSQL演进代价参数也有自己的调优空间。但结论是通用的——去相关改写做不做差别往往就在毫秒级和秒级之间。3. He3DB的子查询优化策略提升、改写、还是物化3.1 子查询提升Subquery Pullup的触发条件和执行路径在He3DB的优化器实现中最优先尝试的优化手段就是子查询提升。所谓提升就是把一个独立的子查询子计划树节点合并到上层查询的计划树中参与整体的连接顺序规划。这就像你原本把一堆零件分在两个盒子里现在倒进同一个大盒子里统一做装配规划零件之间的组合方式就多得多。提升能成功的前提是子查询的结构足够“简单”。具体来说以下几个方面是优化器重点检查的子查询的FROM列表是否只有单表——多表关联的子查询提升起来等价性证明会复杂很多子查询的SELECT列表中是否含有聚合函数、窗口函数、DISTINCT这类带语义限制的表达式子查询是否包含LIMIT/OFFSET这类子查询往往有“取前N行”的语义提升之后很难保持等价子查询是否含有volatile函数比如random()、now()这类每次调用都可能返回不同结果的函数提升会改变函数调用次数导致语义漂移。如果上述检查都通过优化器就会把子查询中的表放入上层查询的基表集合中把子查询的WHERE条件并入上层WHERE把子查询的目标列转换成上层查询的目标列或连接条件。这个过程在代码实现上涉及RTERange Table Entry的合并、条件表达式的拉平、连接树的重新构建是整个改写逻辑里最复杂的部分之一。3.2 IN子查询改写为半连接一条不能走错的路IN子查询的优化路径是He3DB优化器里另一个值得详细拆解的点。一个IN子查询比如SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE amount 100);逻辑等价于WHERE EXISTS (SELECT 1 FROM orders WHERE user_id users.id AND amount 100)于是也可以走半连接的改写路径。但要注意NOT IN和NOT EXISTS的改写逻辑完全不是一回事。NOT IN的语义是“不在给定集合中”这个语义在存在NULL时会变得非常微妙。假设子查询返回结果包含一个NULL那么id NOT IN (1, 2, NULL)的结果不是TRUE也不是FALSE而是UNKNOWN最终WHERE条件会过滤掉这一行。这在SQL语义里是严格规定的。如果优化器忽略NULL的存在把NOT IN直接改写成ANTI JOIN反连接就可能返回错误的查询结果——它会认为某些行“不匹配”而保留下来但按照SQL标准这些行应该因为NULL导致结果为UNKNOWN而被排除。所以He3DB对NOT IN子查询的改写非常谨慎。如果子查询的目标列上没有非空约束NOT NULL优化器要么保留子查询执行的原始形态要么改写时额外加上NULL判断逻辑。这一点在数据库内核里是被反复强调的语义边界也是很多人在自研优化器时容易踩的坑。3.3 标量子查询的缓存与物化策略标量子查询的优化路径则是另一套思路。假设查询里需要返回每个用户的最近一次下单时间SELECT u.id, (SELECT MAX(o.created_at) FROM orders o WHERE o.user_id u.id) AS last_order_time FROM users u;这种写法极其常见也是OLTP系统里容易被忽略的性能隐患。对于标量子查询He3DB的优化器会尝试把它改写为左连接加分组聚合LATERAL JOIN GROUP BY的形态让优化器有更灵活的执行计划选择。但如果因为某些原因无法改写执行器也会应用一层缓存机制同一个外层值对应子查询的执行结果会被缓存下来如果外层存在大量重复的关联值重复执行开销就能被明显摊薄。需要多提一句的是标量子查询里“多行返回”的特殊情况。SQL标准规定标量子查询如果返回多行会直接报错而不是取第一行。执行器在实现时必须保留这个检查逻辑这也是部分优化器不敢轻易改写标量子查询的原因之一——改写后如果执行路径变成了LIMIT 1的语义那么原本该报错的查询反而会“成功”这在合规性上是不可接受的。3.4 选择策略顺序为什么先尝试提升再尝试改写这里简单说一下优化器内部的策略选择顺序。He3DB在处理子查询时会按一个比较固定的优先级去尝试先判断能否直接提升让子查询的表进入上层连接搜索空间不能提升的话尝试改写为等价的形式比如IN转EXISTS、EXISTS转半连接都走不通的情况下保留子查询独立执行并利用参数化执行路径——也就是每次用当前外层行的参数值去执行子查询计划——来保证基本可用性。这个顺序的逻辑在于提升是最彻底的优化直接从结构上消除子查询改写次之它虽然没有消除子查询但给执行器提供了更高效的执行算子选择而参数化执行是保底方案保证任何语义合法的子查询都能被执行。这个层层递进的策略在工程实现上非常清晰也符合一个成熟数据库对安全性的要求——宁可少优化不可优化错。4. 从执行计划看子查询优化的真实效果4.1 优化前的执行计划长什么样光讲原理不够拿一个实际的执行计划来看会更直观。这里我用一个模拟场景users表有10万行orders表有300万行orders.user_id上有索引orders.status上有索引现在要查所有下过有效订单的用户。先看不做任何优化时的执行计划形态Seq Scan on users u Filter: (EXISTS (SubPlan 1)) SubPlan 1 - Index Scan using idx_orders_user_id on orders o Index Cond: (user_id u.id) Filter: (status valid)这个计划的问题不在于某个节点慢而在于结构性的低效外层users表每读一行都要执行一次SubPlan 1也就是一次索引点查。虽然每次点查本身不贵但乘以10万次之后总代价就不一样了。更要命的是SubPlan里的索引Cond是user_id u.id这个u.id在计划生成阶段还是参数占位每次执行时才绑定具体值优化器无法基于一个具体的常量值去选择更优的执行路径。4.2 优化后的执行计划形态再做子查询改写后执行计划变成这样Hash Semi Join Hash Cond: (u.id o.user_id) - Seq Scan on users u - Hash - Bitmap Heap Scan on orders o Recheck Cond: (status valid) - Bitmap Index Scan on idx_orders_status Index Cond: (status valid)改写后子查询不见了取而代之的是Hash Semi Join。orders表先按status valid过滤出候选行构建哈希表然后外层users表扫描一遍每条记录走一次哈希查找匹配上就返回。整个过程无论是外层还是内层都只扫描了一次执行代价变得可预测也更容易通过索引设计和统计信息来进一步调优。4.3 如何读懂EXPLAIN输出里和子查询相关的关键指标做内核分析也好做SQL调优也好读执行计划时我建议重点关注这几个和子查询相关的指标SubPlan出现在计划树中说明子查询仍是独立执行的优化器未能消除它这时就要警惕性能风险计划里出现InitPlan说明子查询是非相关的执行器在查询启动时只需要计算一次这种通常不是问题Semi Join、Anti Join节点出现说明IN/EXISTS子查询已经被改写为连接形态这是正向信号计划节点里的actual time和rows如果和外层的loops数值乘在一起仍然很大说明每个外层行都在重复执行子查询这就是性能瓶颈所在。很多DBA在调优时只看cost字段但我更建议同时看执行器反馈的actual rows和loops。cost是估算值可能因为统计信息不准而失真而loops直接反映子查询被重复执行的次数这是子查询性能问题最诚实的指标。5. 优化器“不敢动”的子查询安全边界与保守策略5.1 为什么有些子查询即使很慢优化器也不改写子查询改写不是万能的。我之前提到过volatile函数、LIMIT、NULL语义等问题这些都是优化器必须遵守的安全边界。还有一个比较隐蔽但实际工作中会遇到的场景子查询内部包含多个分支的UNION并且每个分支的语义不同这种情况改写起来难度极大优化器往往选择保守处理。保守策略在工程上的意义在于数据库内核的第一原则是正确性第二原则是性能。 一个改写如果在某些极端边界下会导致错误结果那么即使它在90%的业务场景下能带来性能提升优化器也必须拒绝改写。这个取舍对于从外部看内核的人来说很难理解——为什么一条慢SQL放着不管但真正做优化器的开发都知道引入一个错误改写比保留一个慢查询严重得多。慢查询还能通过加索引、改写SQL、调参数来解决错误的查询结果则可能直接造成业务数据事故。5.2 统计信息对子查询优化的影响优化器做任何改写决策都离不开统计信息。子查询改写尤其依赖对选择性的判断一个IN子查询的结果集有多大配合外层查询后预计会有多少行匹配这些估算直接决定了改写后的连接顺序和连接算法选择。有一个典型场景外围表很小子查询结果集很大那么半连接改写之后哈希表会占大量内存性能反而比逐行执行子查询更差。如果统计信息准确优化器能算出这个风险可能就会放弃改写。但如果统计信息过期比如表里数据从1万行涨到了1亿行而ANALYZE没执行优化器就会拿着过时的估算值做决策生成本质上是错误的执行计划。He3DB作为一个基于PostgreSQL生态演进的内核项目继承了统计信息收集的机制包括自动ANALYZE、多列统计信息、表达式统计信息等。但使用者需要明白一个道理统计信息是优化器决策的输入输入质量决定了输出质量。 数据库运维中那句“定期做统计信息收集”并不是老生常谈它直接影响了优化器敢不敢做子查询改写这些高阶优化。5.3 从内核源码角度看优化器的判断逻辑虽然这篇文章不需要贴大段源码但如果读者有阅读内核代码的基础我建议从几个关键函数入口入手去理解He3DB的子查询优化实现。PostgreSQL系优化器里处理子查询的入口函数主要围绕subquery_planner和SS_process_sublinks这几个模块展开。SS_process_sublinks负责把WHERE和JOIN条件里的子链接SubLink转换掉这是子查询优化的第一道工序。它会把表达式中的EXISTS/IN/ANY等子链接识别出来判断是否满足转换条件然后生成对应的SubPlan或者是进行提升。后续涉及到子查询提升的关键逻辑集中在pull_up_subqueries这个函数族里。这个函数会递归处理查询树中的子查询判断能否把子查询的RTE和条件合并到上层。不仅如此它还需要处理子查询中被引用的外层列——这个处理是否周全直接决定了提升后语义是否正确。具体到每个函数里的判断分支几乎都是对前面提到的那些安全边界的代码化表达有没有聚合、有没有LIMIT、有没有DISTINCT、有没有volatile函数、有没有窗口函数。逐个排除之后才会真正执行提升动作。理解了这些源码结构再看EXPLAIN输出就不仅仅是看执行计划长什么样而是能够大概推测优化器在哪个环节做了什么样决策、为什么做了这个决策。这种“从执行计划和源码交互验证”的能力是内核分析最有价值的部分。6. 实际案例分析一个IN子查询的完整优化链路6.1 场景设定和原始SQL为了把整个分析过程串起来我设计一个完整的案例。假设业务场景是电商后台需要统计“在最近30天下过有效订单的用户信息”SELECT u.id, u.name, u.email FROM users u WHERE u.id IN ( SELECT o.user_id FROM orders o WHERE o.created_at NOW() - INTERVAL 30 days AND o.status paid ) ORDER BY u.id;orders表数据量2000万行users表50万行。orders表上有(user_id)索引和(created_at)索引没有(status, created_at)的联合索引。6.2 优化器内部的决策路径拿这条SQL来做He3DB内核分析模拟优化器的决策过程第一步识别这是一个IN子查询且子查询引用外部表users吗不引用它是非相关子查询。非相关子查询的情况下最简单的策略是先单独计算子查询的结果集然后做半连接。第二步估算子查询的结果集大小。根据created_at的统计信息和直方图估算最近30天的订单量再通过status paid的选择率计算最终的结果集行数。第三步根据估算结果选择连接算法。如果子查询结果集足够小比如几十万行那么Hash Semi Join是合理选择如果结果集只有几千行Nested Loop Semi Join配合users表上的主键索引也可能更优。第四步评估是否有机会做更激进的优化。比如把o.user_id IN (SELECT u.id ...)改写成一个普通的JOIN之后再让优化器在更大的连接空间里搜索更优计划。这些探索是嵌套发生的每次改写都会触发一轮新的计划搜索。6.3 改写后的执行计划与性能对比在这个案例中如果子查询结果集估算为约20万行最终的执行计划会是这样Sort Sort Key: u.id - Hash Semi Join Hash Cond: (u.id o.user_id) - Index Scan using users_pkey on users u - Hash - Bitmap Heap Scan on orders o Recheck Cond: ((created_at 2025-01-01::timestamp) AND (status paid)) - BitmapAnd - Bitmap Index Scan on idx_orders_created_at Index Cond: (created_at 2025-01-01::timestamp) - Bitmap Index Scan on idx_orders_status Index Cond: (status paid)这个计划的执行路径是先用两个索引BitmapAnd定位到符合条件的订单集合构造成哈希表然后顺序扫描users表逐一探测哈希表最后对结果排序。整体上orders表只需要一次扫描虽然借助了bitmap结构users表也只需要一次扫描。相比逐行执行子查询这个计划的代价是可预测的而且可以通过优化索引来进一步降低。如果在这个案例里直接执行优化前的计划外层users表50万行每行执行一次对orders表的查询即使有索引累计的索引点查开销也会非常大。两者之间的性能差距在大多数场景下都是数量级的。6.4 这个案例告诉我们的内核分析思路从这个案例能提炼出一个通用分析框架拿到一条带子查询的慢SQL先区分相关子查询还是非相关子查询再看优化器有没有把它改写为连接形态最后检查执行计划里的loops数值和SubPlan节点。如果确实存在子查询反复执行的情况再去判断是不是因为某种安全边界阻止了改写。这个排查链路在He3DB的场景下同样适用换到别的数据库产品也一样成立。7. 调优和开发层面的一些实操心得7.1 规避子查询优化陷阱的SQL写法从使用者的角度有一些经验是可以在写SQL时直接落地的。虽然优化器越来越智能但主动避开已知的优化陷阱永远是成本最低的手段。第一个建议能用连接写清楚的业务优先考虑连接而不是标量子查询。很多ORM生成的代码习惯在SELECT子句里放子查询比如“查询用户列表并带出每个用户的订单数”。这种写法对优化器来说是典型的标量子查询场景即便能改写也增加了优化器的负担而且改写失败的代价很高。改成显式的GROUP BY加LEFT JOIN执行计划反而更容易控制。第二个建议涉及NOT IN的时候务必确认子查询列是否可能包含NULL。如果业务上不能保证非空优先用NOT EXISTS是更稳妥的写法——NOT EXISTS在语义上天然规避了NULL的干扰优化器改写时也少一层顾虑。这不是说NOT IN一定慢而是说NOT IN在NULL语义下容易让优化器变得保守从而放弃更优的执行形态。第三个建议避免在子查询里做不必要的DISTINCT。比如SELECT DISTINCT user_id FROM orders WHERE ...这个DISTINCT在语义上可能是不需要的因为它不改变IN子查询的结果集合。但它会让优化器认为子查询结果集存在去重操作从而放弃某些改写路径。去掉这个DISTINCT之后优化器可能直接就把它转成了Semi Join。7.2 如何通过索引设计配合子查询优化索引设计对于子查询优化的配合作用经常被忽略。很多人在设计索引时只考虑单表查询和JOIN不考虑子查询的执行形态。实际上子查询改写为连接之后连接的驱动表和探测表上的索引需求是完全不同的。以前面的案例来说改写后是users表驱动、orders表建哈希。但是换个场景如果估算的子查询结果集非常小优化器可能选择Nested Loop Semi Join这时候orders表上也最好有(user_id, status, created_at)这样的索引让它能够快速响应连接条件的探测。换句话说索引设计需要结合优化器可能生成的执行计划来制定而不是简单地为WHERE条件建索引。我通常的建议是对于高频的子查询场景优先保证子查询内部过滤条件的索引覆盖同时为可能的连接改写准备连接键上的索引。如果一个表经常作为子查询的探测侧出现连接键上的索引带来的收益会非常明显。7.3 内核开发者视角如何安全地扩展子查询优化器如果读者是内核开发者想往He3DB或类似项目里加入新的子查询改写规则我建议按这样的思路来推进。先梳理目标改写规则的语义等价条件列出所有可能破坏等价性的边界情况。这个阶段花的时间越多后续踩的坑越少。然后是在小规模测试集上反复验证改写前后结果一致包括空表、NULL、重复值、极端数据类型等场景。最后才是性能测试——确认改写方向正确之后再去评估它是否在真实负载上带来收益。内核开发中最容易出的问题是改写在“看起来没毛病”的情况下因为某个细节语义没考虑到而生成了错误结果。比如函数是否为IMMUTABLE、排序规则是否一致、collation是否匹配、子查询里是否有隐式类型转换——这些问题不通过极端测试很难暴露。我自己做过优化器相关的开发多次被这类边界条件教训过现在的习惯是每加一条改写规则先建造一个针对性的边界测试矩阵再谈性能。8. 子查询优化在不同负载模型下的表现差异8.1 OLTP场景请求数量大、单条SQL轻在OLTP场景里单条SQL的执行时间通常要求毫秒级子查询优化的目标是稳定可预期。OLTP系统往往走主键或索引点查子查询优化更多体现在ORM生成的复杂查询能正确识别简单形态而不是追求一条SQL从10秒降到1秒。因为OLTP场景里10秒的SQL本身就是事故了根本等不到优化器来救。在OLTP场景中我见得最多的问题不是优化器不干活而是优化器把原本简单的子查询改写成了一个代价更大的连接计划。比如小表驱动大表时Nested Loop可能比Hash Join更快但如果统计信息不准确优化器可能选择了错误的连接算法。这种情况下通过执行计划中的actual rows和loops来判断是否需要手动干预比盲目加索引更有效。8.2 OLAP/分析场景数据量大、SQL复杂OLAP场景是子查询优化的主战场。复杂报表查询里子查询往往嵌套多层每一层的改写决策都会影响最终执行计划的形态。在这个场景下He3DB这类数据库面临的最大挑战是多层子查询之间的关联消除和条件下推。举个例子一个三层嵌套的查询外层查销售汇总中间层查产品维度信息内层查订单明细。每一层都可能存在相关条件优化器需要逐层判断改写可行性而且每一层改写都会改变下层子查询的参数绑定方式。这个过程复杂度极高完全没有一个通用的“银弹”策略。实际业务中我见过的最有效的分析SQL优化手段仍然是先看执行计划找到最内层那个被反复执行的子查询节点然后手工改写SQL把相关子查询变成临时表或CTE给优化器一个更清晰的输入。8.3 分布式与云原生数据库对子查询的额外约束He3DB既然定位在大数据量场景就绕不开分布式或云原生架构对子查询的影响。在分布式执行框架下子查询所涉及的表可能分布在不同的数据节点上跨节点子查询的执行代价不仅包含计算代价还包括数据shuffle的网络开销。这也是为什么很多分布式数据库在文档里都会建议用户尽量避免跨节点相关子查询——因为分布式执行器处理相关子查询时往往需要在每个节点上广播驱动数据或者做多次跨节点传输代价呈指数级上升。He3DB在这方面的处理思路是在优化器层面尽量把可下推的子查询条件下推到存储节点让节点本地完成过滤只把必要的连接数据返回上层。从这个角度看子查询优化在分布式环境里不仅仅是“改写执行计划”还涉及“改写数据流”。这已经超越了经典优化器的范畴进入了对执行框架的全局规划。对使用者来说需要额外关注的不仅是代码层面的子查询优化逻辑还包括数据分布策略是否与子查询的访问模式匹配。9. 总结一些实战中值得养成的习惯最后零零散散地说一些我在做数据库内核分析、SQL调优过程中积累的习惯。这些内容不属于某一篇论文也不属于某一段源码但实际工作中非常受用。第一分析任何慢查询先看执行计划再下结论。不要因为子查询写法“看起来低效”就直接改SQL。执行计划能告诉你优化器实际做了什么很多看起来写得别扭的SQL优化器已经自动改写成高效形态了这时你再手工改写反而可能干扰它。第二养成看loops的习惯。EXPLAIN ANALYZE输出里loops数值是子查询执行次数的直接体现实。如果一个计划节点的loops高达几十万而单次执行只有零点几毫秒累计下来依然可观。找到那个被重复执行最多的节点往往就是性能瓶颈所在。第三关注统计信息的新鲜度。优化器再智能也依赖统计信息的输入。大表数据量发生数量级变化之后执行计划变化是常有的事。很多时候“某条SQL之前很快现在很慢”的原因就是统计信息过期导致的决策漂移而不是代码或索引出了问题。第四对子查询的改写结果保持敬畏。数据库优化器做一切改写都基于“语义等价”这个前提但在极其复杂的SQL和极端数据分布下优化器也可能犯错。这也解释了为什么各种数据库都会提供一些“逃生舱门”比如通过优化器开关来关闭某类改写让用户能够手工控制执行形态。遇到正常调优手段解决不了的问题检查一下是否有类似的开关往往比白费力气改SQL更有效。回到He3DB这个数据库产品上它的子查询优化能力延续了经典优化器的成熟思路又在工程实现上做了不少贴合大规模数据场景的取舍。作为内核分析者理解其子查询优化的判定逻辑、改写策略和边界条件是把握整个优化器设计思路的一条捷径。作为使用者掌握执行计划的阅读方法和调优习惯则能实实在在提升日常SQL优化的效率。子查询优化这个课题说大不大说小不小。往小了说它只是优化器里一个子模块往大了说它牵涉到SQL语义理解、代价模型、连接规划、执行器实现、统计信息管理等方方面面。把这一个模块吃透对理解一个数据库内核的总体架构会有事半功倍的效果。这也是我写这篇分析文章最大的初衷。