国产化迁移避坑|Oracle迁KES第一坑:外连接消除让(+)语法悄悄变味
文章目录兼容是对前人努力的尊重是确保业务平稳过渡的基石然而这仅仅是故事的起点说实话搞国产化迁移这两年我经手的Oracle迁KES项目少说也有七八个了。每次项目复盘大家坐一块总结踩过的坑排在第一位的永远不是什么存储过程语法不兼容、也不是DECODE函数转换问题——而是外连接消除。这个东西太阴了。它不报错、不警告、不写日志就是悄悄把你的LEFT JOIN变成了INNER JOIN然后数据就少了。最要命的是Oracle迁过来的项目大量使用()语法这个语法天然就把所有条件混在WHERE里想不踩坑都难。先说个真事吧。去年下半年接了个城商行核心系统迁移的项目Oracle 11g迁KES V9大概三千多条SQL要过一遍。功能测试阶段一切正常每个模块单独跑都美得很报表也能出数。结果上线第二天风控部门就炸了——说授信额度报表数据对不上某些客户的明细记录凭空少了。整个团队排查了差不多两天翻数据同步日志、查ETL脚本、对比源库和目标库的数据量最后发现问题出在一条看起来人畜无害的SQL上-- Oracle原始写法()语法做外连接SELECTc.cust_name,l.loan_amount,r.risk_levelFROMcustomers c,loans l,risk_assessment rWHEREc.cust_idl.cust_id()ANDl.loan_idr.loan_id()ANDr.risk_levelHIGH;这条SQL在Oracle上跑了七八年没出过事。开发同事坚持说这查询的意思是查所有客户左关联贷款和风险评估信息然后筛出高风险的。但实际在KES里跑出来的结果那些没有风险评估记录的客户全没了直接从结果集里蒸发。问题出在最后一行。r.risk_level HIGH这个条件没有带()KES的优化器一看——右表assessment的risk_level列如果为NULL那NULL HIGH的结果是UnknownWHERE子句会把这些行全部过滤掉。既然外连接产生的NULL行最终都要被干掉那这个外连接在逻辑上就跟内连接完全等价了。优化器二话不说直接消除把外连接改成了内连接。你可能会问Oracle不也有外连接消除嘛为啥在Oracle上就没问题这就涉及到一个很微妙的地方了。Oracle的优化器确实也会做外连接消除但在()语法的处理上Oracle跟KES的优化器在某些边界场景下的行为其实不完全一致。Oracle在解析()语法时对于不带()的WHERE条件有时候会结合上下文做更宽容的理解特别是在多表关联、条件比较复杂的场景下。而KES的优化器更严格地遵循标准SQL语义——不带()的条件就是WHERE过滤条件该消除就消除不搞特殊。说实话这个差异很难一句话讲明白因为它不是Oracle支持KES不支持这种非黑即白的问题而是两个优化器在等价变换的边界判断上存在细微差异。但迁移的时候这种细微差异就够你折腾好几天了。为了把这事说透得先聊聊()语法到底是个啥东西。Oracle从很早的版本就开始用()来表示外连接这玩意儿是Oracle独创的写法SQL标准里根本没有这东西。规则很简单把()跟在可空侧表的列名后面表示这个表如果没匹配到数据就补NULL。比如-- Oracle () 写法t2是可空侧相当于LEFT JOINSELECT*FROMt1,t2WHEREt1.idt2.id();-- 等价于标准SQL的-- SELECT * FROM t1 LEFT JOIN t2 ON t1.id t2.id;看起来挺简洁的对吧但坑就在于()语法没有ON子句这个概念。所有的条件——不管你是连接条件还是过滤条件——全塞在WHERE里面。这就导致一个要命的问题你怎么区分哪个条件是连接规则哪个是结果筛选答案就是看()。带()的条件相当于放在ON里面不带()的相当于放在WHERE里面。听起来简单但当你面对一条关联了四五张表、七八个条件的SQL时漏掉一个()简直太容易了。来看几个典型的翻车现场。第一个右表过滤条件忘了带()-- 期望查所有订单左关联物流信息只看已签收的物流SELECTo.order_id,o.order_date,l.statusFROMorders o,logistics lWHEREo.order_idl.order_id()ANDl.statusSIGNED;-- 结果没有物流信息的订单全没了-- 原因l.status SIGNED 没带()触发外连接消除修复方法是把()补上-- 正确右表过滤条件也带上()SELECTo.order_id,o.order_date,l.statusFROMorders o,logistics lWHEREo.order_idl.order_id()ANDl.status()SIGNED;-- 等价于标准写法-- SELECT o.order_id, o.order_date, l.status-- FROM orders o LEFT JOIN logistics l-- ON o.order_id l.order_id AND l.status SIGNED;第二个更坑多表关联时中间层漏了-- 三表关联中间表的过滤条件漏带()SELECTa.dept_name,b.emp_name,c.salaryFROMdepartments a,employees b,salary_records cWHEREa.dept_idb.dept_id()ANDb.emp_idc.emp_id()ANDb.statusACTIVE;-- b.status ACTIVE 没带()直接把没有员工记录的部门干掉了这个场景特别坑因为开发人员的本意可能是只看在职员工的薪资记录但外连接被消除后那些连员工都没有的部门也一起消失了。结果就是部门维度报表少了好多行财务对账死活对不上。第三个OR条件里面混着()的-- OR条件混合使用每个分支都要带()SELECT*FROMt1,t2WHEREt1.idt2.id()AND(t2.type()AORt2.type()B);-- 注意OR里面的每个条件都得带()漏一个就完蛋说实话这种写法我自己看着都头大。OR条件里面每个分支都得记得加()稍不注意就漏了。而且这SQL的可读性极差过三个月回头看连你自己都搞不清楚当初想表达啥。那问题就来了——既然()语法这么坑干嘛不直接全部改成标准ANSI JOIN呢道理谁都懂但现实是很多Oracle项目的SQL代码库是十几年攒下来的几万条SQL里面大量使用()语法。迁移项目本身就有工期压力不可能一条条重写。所以大多数项目的做法是KES开Oracle兼容模式()语法保留原样先跑通再说。KES的Oracle兼容模式确实能解决大部分语法兼容问题。DECODE、NVL、ROWNUM、TO_DATE这些Oracle特色函数都能用sysdate也有对应的兼容处理。但外连接消除这个坑兼容模式帮不了你——因为它压根不是语法不兼容的问题是优化器语义判断的问题。SQL能正常执行不报错不告警但结果可能不对。这就引出一个比较深的问题了——关于查询优化器等价变换的学术争论其实由来已久了不是今天才有的话题。回到十来年前数据库优化器领域有一个挺核心的分歧优化器到底是应该激进一点还是保守一点这个争论到现在也没完全消停。激进那边的人认为只要数学上能证明等价优化器就该大胆做变换把性能榨干用户写SQL不用操心执行细节。保守那边则觉得优化器不该假设开发人员脑子里想的是啥宁可慢一点也别改变查询的语义行为。早期关于外连接消除的研究基本上围绕什么条件下能安全做这个变换来展开。所谓的Null-Rejecting条件——说白了就是WHERE中的条件能拒绝所有NULL值——比如对右表非空列做等值比较、不等值比较、IS NOT NULL这类的。只有当外连接产生的所有NULL行都能被WHERE条件干掉的时候消除才是安全的。但这里有个被长期忽视的问题。Null-Rejecting条件本身的判定并没有看起来那么简单。特别是多表关联、嵌套子查询、OR条件混合的场景下精确判断一个条件是不是真的拒绝了所有NULL行其实是一个相当复杂的逻辑推理过程。不同的数据库厂商在这块的实现精度是有差异的。有些早期研究的样本量也比较有限基本上是在两三张表的简单场景下做验证到了真实业务那种十几张表关联的复杂SQL结论未必还成立。另外关于外连接消除这个概念本身的界定也经历过一些混乱。窄派的人认为它就是指把LEFT JOIN改写成INNER JOIN这一种变换其他什么谓词下推、连接顺序重排那都不算。宽派的人觉得只要优化器做了什么操作使得外连接的语义被缩减了都应该纳入这个范畴。我个人在实际迁移项目里采用的是窄派定义因为这样更精确——迁移排查的时候你需要知道到底是哪一步变换出了问题如果把太多东西都归到外连接消除里面排查范围反而模糊了。但说实话这个选择也有代价就是有时候一些边界场景你不太确定该不该算进来分类上会有点纠结。后来随着CBO逐渐成为主流外连接消除的实现也从简单的规则匹配进化到了基于代价的决策。也就是说优化器不是发现能消除就消除而是算一下消除后代价更低才消除。这听起来更合理但实际上引入了新的不确定性——代价估算本身依赖统计信息统计信息要是不准呢有研究者指出在某些统计信息偏差较大的场景下基于代价的外连接消除决策可能跟基于规则的决策得出完全相反的结果。这算是理论框架在实际工程中不完全适用的一个例子。近些年随着国产数据库迁移需求爆发一个新的研究方向开始浮现跨数据库迁移中的优化器行为差异。这个方向之前几乎没人专门研究过。大家默认SQL标准是统一的同样的SQL在不同数据库上应该行为一致。但实际上SQL标准只规定了语法和基本语义对于优化器能做什么等价变换、什么条件下做标准留了很大空间。每家数据库厂商都在这个空间里做了自己的选择。Oracle的选择跟KES的选择不完全一样这不是谁对谁错的问题是标准没覆盖到的地方。说实话这个方向挺有价值的但目前被忽视得最厉害。因为迁移项目里遇到的很多坑本质上不是语法兼容性问题而是优化器行为差异。SQL能跑、语法没报错但执行计划变了结果就可能变了。这类问题比语法不兼容难排查多了因为它不报错——你的SQL成功执行了只是返回的数据少了。不过话说回来我也得说句公道话。KES的优化器做外连接消除的时候不是无脑消除的它做了很精确的Null-Rejecting分析。只有当WHERE条件确实能把外连接产生的NULL行全部过滤掉时才触发消除。像IS NULL这种语义上就是要保留NULL行的查询KES是不会乱来的-- IS NULL条件不会触发外连接消除SELECT*FROMt1,t2WHEREt1.idt2.id()ANDt2.nameISNULL;-- 这条SQL在KES里保留外连接语义-- 因为IS NULL的目的就是找右表为空的记录-- 优化器不会把这种查询转成内连接这种精确性是保证优化不出错的基础。只有Nullable-Side的非空列被WHERE条件约束时才触发消除IS NULL是明确的安全岛。那回到实际问题做Oracle迁移KES的项目怎么系统性地防范这个坑呢我总结了一套方法说实话不复杂但得有耐心。首先是迁移前做SQL审计。把所有包含()的SQL全部捞出来重点看WHERE子句里有没有不带()的右表条件。可以用脚本做初步筛选# 粗筛找出包含()的SQL文件grep-rn()*.sql# 再筛出WHERE子句中引用了右表但没带()的条件# 这个得人工看正则做不到精确识别正则只能做粗筛精确识别还得靠人工review或者专门的SQL解析工具。KES官方的迁移工具KDMS能做一部分语法层面的检查但对于()和外连接消除这种语义级别的问题还是得人工过一遍。然后是核心SQL做执行计划对比。在Oracle和KES上分别跑EXPLAIN看Join的类型一不一致-- KES上检查执行计划EXPLAINANALYZESELECTc.cust_name,l.loan_amount,r.risk_levelFROMcustomers c,loans l,risk_assessment rWHEREc.cust_idl.cust_id()ANDl.loan_idr.loan_id()ANDr.risk_levelHIGH;-- 关键看执行计划里的Join类型-- 如果出现 Hash Join 而不是 Hash Left Join-- 那就是外连接被消除了Oracle那边可以用EXPLAIN PLAN FOR或者直接看AWR报告里的SQL执行统计。重点对比Join算子的类型Oracle是Hash Join Outer而KES变成了普通Hash Join那就是中招了。还有就是结果集行数回归验证。上线初期把核心查询在Oracle和KES上的返回行数做个对比不用逐行比对数据先看行数对不对-- 快速行数对比-- Oracle端SELECTCOUNT(*)FROM(SELECTc.cust_name,l.loan_amount,r.risk_levelFROMcustomers c,loans l,risk_assessment rWHEREc.cust_idl.cust_id()ANDl.loan_idr.loan_id()ANDr.risk_levelHIGH);-- KES端跑同样的SQLSELECTCOUNT(*)FROM(SELECTc.cust_name,l.loan_amount,r.risk_levelFROMcustomers c,loans l,risk_assessment rWHEREc.cust_idl.cust_id()ANDl.loan_idr.loan_id()ANDr.risk_levelHIGH);-- 两边行数不一致KES少于Oracle → 大概率外连接消除说到这我想多提一嘴关于()语法本身的看法。虽然KES兼容了这个语法但如果是新项目真的建议直接用标准ANSI JOIN。ON和WHERE的职责分得清清楚楚SQL意图一目了然——ON管连接规则WHERE管结果筛选不会有()那种到底该不该带的纠结。老项目迁移的时候有条件的话也建议趁机把()改掉一劳永逸。不过就算改成标准语法如果你不理解ON和WHERE的区别照样踩坑。来看-- 改成标准语法了但条件位置写错SELECTc.cust_name,l.loan_amount,r.risk_levelFROMcustomers cLEFTJOINloans lONc.cust_idl.cust_idLEFTJOINrisk_assessment rONl.loan_idr.loan_idWHEREr.risk_levelHIGH;-- 这样写照样触发外连接消除因为条件还在WHERE里-- 正确写法右表过滤条件放ON里SELECTc.cust_name,l.loan_amount,r.risk_levelFROMcustomers cLEFTJOINloans lONc.cust_idl.cust_idLEFTJOINrisk_assessment rONl.loan_idr.loan_idANDr.risk_levelHIGH;-- 这样左表所有记录保留不会触发消除看到没语法换了坑还是同一个坑。所以外连接消除排第一不是因为它最复杂是因为不管你用啥语法写只要对连接条件和过滤条件的边界理解不到位它就能坑到你。最近金仓社区在搞征文大赛https://bbs.kingbase.com.cn/web-api/forum/10310写技术体验文章分享实战经验这种我觉得挺有意义的。像外连接消除这种坑踩过的人不说出来后来的人还得重新踩一遍。有兴趣可以去社区看看把迁移过程里遇到的坑写出来帮了别人也给自己留个记录。最后收个尾吧。Oracle迁移KES外连接消除为什么能排第一坑。()语法天然把连接条件和过滤条件混在一起漏带一个()就可能触发消除。Oracle老项目SQL代码库里的()用法量大面广排查工作量不小。而且这个坑不报错不警告SQL正常执行只是结果集行数变少了不做结果集对比验证很可能上线很久才发现问题。就算把()改成标准ANSI JOIN不理解ON和WHERE的语义区别还是会踩同样的坑。本质上是SQL语义理解问题不是语法问题。再加上Oracle和KES优化器在等价变换边界判断上存在细微差异同样的SQL行为可能不完全一致——这也不是谁对谁错是SQL标准留给优化器的自由空间导致的。我的建议就是迁移项目一定把包含外连接的SQL单独拎出来审计重点检查右表过滤条件的位置。执行计划对比加结果集行数验证做双重保险。宁可多花两天审计也别上线后才发现数据少了。说真的做国产化迁移这个活技术难题反而不是最难的。最难的是那些以前恰好没出问题的代码在原系统上安安静静跑了十几年换个数据库就炸了。外连接消除就是最典型的它不像语法错误那样立刻暴露而是像慢性病等你发现的时候可能已经出事了。不过换个角度想这些坑也倒逼我们去理解SQL的真正语义。以前在Oracle上靠运气跑通的SQL现在得把每条都理清楚了。长远来看对代码质量其实是好事就是过程痛苦了点。