SQL 语法全兼容,但结果就是不一样——国产化迁移里最难缠的九类隐患
SQL 语法全兼容但结果就是不一样——国产化迁移里最麻烦的九类隐患有一类迁移的问题其实挺让人头疼的。它不报错也没有告警。SQL 跑完了甚至还会弹出一个提示说“执行成功影响了 X 行”。但是呢业务那边拿着新老系统的数据一对比发现就是不对。少了几行或者多了几行再或者是某一列的数值偏了。这个时候你去查语法去查字符集又去查时区、查连接池。查了半天啥也没发现。这类问题查起来特别费劲。为什么呢原因在于你得同时搞懂三件事SQL 执行的语义标准、优化器是怎么跑的、还有不同数据库之间的差异。少懂一个都不行。在做过几个迁移项目之后我就把碰到的这类不出错的问题给整理了一下。一共分了九类。这篇文章呢就是我整理出来的东西。这九个坑每一个我都配了案例还有原因分析。另外也说了一下在金仓 KES 里面是什么表现以及怎么改。尽量让这篇东西能拿来就直接当避坑手册用。文章目录SQL 语法全兼容但结果就是不一样——国产化迁移里最麻烦的九类隐患一、 九个容易出问题的 SQL 逻辑坑二、 A 类优化器行为差异A1 外连接消除A2 谓词下推的边界差异A3 子查询解嵌套三、 B 类SQL 语义标准差异B1 NULL 与空串等价B2 三值逻辑短路差异B3 排序中的 NULL 位置四、 C 类函数与表达式行为差异C1 WHERE 里函数副作用与执行顺序C2 字符串与数字的隐式类型转换C3 日期时间与时区解析差异五、 一套平时干活能用的排查办法1. 第一层静态代码扫描2. 第二层执行计划与执行结果对比3. 第三层生产灰度与实时监控六、 总结一、 九个容易出问题的 SQL 逻辑坑先给大家看一下整体的情况。我把这九类坑按照“它们在哪个层面起作用”分成了三大块A 类优化器行为差异3 个A1 外连接消除A2 谓词下推的边界差异A3 子查询解嵌套引起的语义微变B 类SQL 语义标准差异3 个B1 NULL 与空串等价B2 三值逻辑短路差异B3 排序中的 NULL 位置C 类函数与表达式行为差异3 个C1 WHERE 里函数副作用与执行顺序C2 字符串与数字的隐式类型转换C3 日期时间与时区解析差异下面我们就一个一个来说。二、 A 类优化器行为差异优化器行为差异这个东西在做国产化迁移的时候是最难提前防住的。它不是语法写错了。也不是数据有问题。其实就是数据库自己觉得这么跑会更快然后自己做了一个决定。不同数据库判断怎么跑更快的标准是不一样的。这就会导致最后出来的结果有差别。A1 外连接消除表现带有 LEFT JOIN 或者 RIGHT JOIN 的 SQL 迁到 KES 之后查出来的结果集少了很多行。原因SQL 里面的 WHERE 条件对可空侧的列做了一个拒绝 NULL 的过滤。比如写了 A或者 100。KES 的优化器一看发现这个 SQL 其实就跟内连接是一样的。于是它就把外连接改写成内连接去跑了。那原来靠外连接补 NULL 留下来的那些左表记录就全被 WHERE 给刷掉了。踩坑案例-- 意图所有用户附带展示已完成订单信息SELECTu.user_id,o.order_amountFROMt_user uLEFTJOINt_order oONu.user_ido.user_idWHEREo.order_statusFINISHED;写这行代码的意思是“所有用户都得出来只有 FINISHED 的订单才显示”。但实际跑出来的结果变成了“只有那些有 FINISHED 订单的用户才出来”。改写方案把条件挪到 ON 子句里面去SELECTu.user_id,o.order_amountFROMt_user uLEFTJOINt_order oONu.user_ido.user_idANDo.order_statusFINISHED;KES 兼容特点KES 的优化器是按 ANSI/ISO 标准来的。在判断是不是拒空条件的时候它比有些老版本的 Oracle 还要准。你跑一下 EXPLAIN 就能直接看到JOIN 的节点是不是被改成了内连接。这是查这种问题最管用的办法。A2 谓词下推的边界差异表现那种带了子查询或者视图的复杂 SQL迁过来之后执行计划跟以前不一样了。有些过滤条件被挪了个位置这就导致结果对不上了。原因不同的数据库对于“哪个条件能够挪到下面哪一层去执行”规矩是不一样的。特别是如果你的子查询或者视图里面带了LIMIT、ORDER BY、DISTINCT或者是聚合函数。这时候要判断条件能不能挪下去情况就挺复杂的。踩坑案例-- 意图查询前 100 个订单里状态为 FINISHED 的SELECT*FROM(SELECT*FROMt_orderORDERBYcreate_timeDESCLIMIT100)subWHEREsub.order_statusFINISHED;在老系统里面它可能不往下挪条件。也就是先不管状态直接拿前 100 个订单。然后再从这 100 个里面去过滤 FINISHED 的。但是在 KES 里面呢如果优化器把WHERE sub.order_status FINISHED给塞到子查询里面去了。那就变成先去过滤出 FINISHED 的订单然后再取前 100 条。这跑出来的结果就完全不是一回事了。改写方案用OFFSET 0或者是用MATERIALIZEDKES 支持的 CTE 物化写法来不让它往下推WITHsubASMATERIALIZED(SELECT*FROMt_orderORDERBYcreate_timeDESCLIMIT100)SELECT*FROMsubWHEREorder_statusFINISHED;KES 兼容特点KES 对 CTE 物化这个语法的支持是没问题的。在做国产化改造的时候CTE 是个能保证语义不出岔子的好东西。我在项目里一般是定了个规矩就是复杂的子查询全都得改成 CTE。这么干这种下推的坑就能避开。A3 子查询解嵌套表现那种带了 EXISTS / IN / ANY 子查询的 SQL迁完之后跑出来的行为不一样。特别是当你的子查询里面混进了 NULL 值的时候。原因优化器会试着把子查询给拆开。改写成 semi-join 或者是 anti-join 去跑。这样跑起来会快很多。但是如果子查询查出来的数据里面有 NULL。那IN和NOT IN的意思就跟你想的有点不一样了。踩坑案例-- 意图查询没有关联合同的客户SELECT*FROMt_customerWHEREcustomer_idNOTIN(SELECTcustomer_idFROMt_contract);如果t_contract.customer_id里面有 NULL 的话这条 SQL 查出来的会是一个空集。为什么呀因为NOT IN (NULL, ...)算出来的结果一直都是 Unknown。这是 SQL 三值逻辑的标准搞法。Oracle、MySQL、KES 在这点上表现是一样的。但是呢在你的测试环境里面如果刚好没碰到 NULL 数据。你就一直发现不了这个问题。等到生产环境里冒出来一条 NULL整个功能就废了。改写方案改成写NOT EXISTS或者是用LEFT JOIN IS NULL-- 改写为 NOT EXISTSSELECT*FROMt_customer cWHERENOTEXISTS(SELECT1FROMt_contract ctWHEREct.customer_idc.customer_id);-- 或者改写为 LEFT JOINSELECTc.*FROMt_customer cLEFTJOINt_contract ctONct.customer_idc.customer_idWHEREct.customer_idISNULL;KES 兼容特点KES 对NOT EXISTS的语义还有执行优化都是支持的。你改成NOT EXISTS之后一般跑起来比NOT IN要快。而且碰到 NULL 也不会出问题。这也是我在做迁移的时候通常让大家去用的写法。三、 B 类SQL 语义标准差异B 类的坑跟优化器没关系。其实就是不同的数据库在实现 SQL 语义标准的时候做法不一样。这种问题比较偏底层。而且你很难绕过去。也就是说就算你把优化器全给关了这些差异也还是在那里的。B1 NULL 与空串等价表现那种写了WHERE col 或者col 的 SQL迁到 KES 之后返回来的结果不一样了。原因在 Oracle 里面空串跟 NULL 是一回事。但是 KES 是按 SQL 标准来的。它觉得空串就是一个长度是 0 的值跟 NULL 不是一回事。所以col 在 Oracle 里面其实就等于col IS NULL。但在 KES 里面它就真的是在找“长度为 0 的空串”。踩坑案例-- Oracle 原意查询 remark 为空的记录SELECT*FROMt_logWHEREremark;-- Oracle返回 remark 为 NULL 的记录-- KES返回 remark 为长度 0 空串的记录如果没有这类数据返回空集改写方案要判断是不是空全都得用IS NULLSELECT*FROMt_logWHEREremarkISNULL;如果你确实想找“空串或者 NULL”那就用COALESCESELECT*FROMt_logWHERECOALESCE(remark,);KES 兼容特点KES 里面有几个配置项可以用。你可以在会话级别或者库级别让它去模仿 Oracle 把空串当 NULL 的做法。这样迁过来的老代码就能先跑起来具体的参数名字你去翻一下 KES 官方手册里 Oracle 兼容那一章。但是呢从长远来看我建议还是把代码改了让它符合标准。别一直靠着兼容层过日子。B2 三值逻辑短路差异表现SQL 里面用 AND 或者 OR 连了好多条件。其中有些条件碰到了 NULL。迁完之后返回来的结果不一样了。原因SQL 用的是三值逻辑。当有 NULL 混进来的时候AND 和 OR 到底短不短路情况就比较绕True AND Unknown UnknownFalse AND Unknown False短路True OR Unknown True短路False OR Unknown Unknown不同的数据库在处理这种短路的时候细节上是不一样的。如果你的 SQL 里面带了有副作用的 UDF那结果可能就不一样了。踩坑案例-- WHERE 里第一个条件涉及 NULLSELECT*FROMtWHEREt.col1AANDexpensive_udf(t.col2)0;如果 t.col1 里面有 NULL。那t.col1 A算出来就是个 Unknown。后面跟着的 AND 结果就得看expensive_udf返回啥了。这个时候不同的数据库可能会做出不同的选择。它可能去跑那个expensive_udf也可能不跑。如果你的 UDF 里面有修改数据的操作那差异就出来了。改写方案千万别在 WHERE 里面写那种有副作用的 UDF。这个事我在后面 C1 那里会仔细说。就算是只读的 UDF你也最好给它标上STABLE或者是IMMUTABLE。KES 兼容特点在 KES 里你可以给 UDF 明确标上四种属性。一个是STRICT就是输入是 NULL 的话直接返回 NULL。一个是IMMUTABLE就是同样的输入永远给出同样的输出。还有STABLE就是在同一条 SQL 里面输入一样输出就一样。最后是VOLATILE就是每次调都可能不一样。你标得越明白优化器就越知道该怎么处理。B3 排序中的 NULL 位置表现你写了一个ORDER BY col。查出来的结果里面NULL 值排在哪里跟以前的系统不一样了。这就会影响到你做分页还有取 TopN 的结果。原因SQL 标准里面其实没有死规定 NULL 在排序的时候得放在哪Oracle 默认的搞法ASC 的时候 NULL 放最后DESC 的时候 NULL 放最前KES 跟标准的 PostgreSQL 一样ASC 的时候 NULL 放最后DESC 的时候 NULL 放最前MySQL 反着来ASC 的时候 NULL 放最前DESC 的时候 NULL 放最后。踩坑案例-- 意图按 priority 升序取前 10 条SELECT*FROMt_taskORDERBYpriorityASCLIMIT10;如果 priority 里面有 NULL。在 MySQL 里面你取出来的前 10 条可能全都是 NULL 的数据。但是在 KES 里面取出来的前 10 条是 priority 值最小的那 10 条非 NULL 数据。这两个意思就差得远了。改写方案你自己手动指定 NULL 放哪里SELECT*FROMt_taskORDERBYpriorityASCNULLSLASTLIMIT10;或者是用COALESCE把 NULL 变成一个具体的数去排SELECT*FROMt_taskORDERBYCOALESCE(priority,999999)ASCLIMIT10;KES 兼容特点KES 是支持NULLS FIRST / NULLS LAST这种写法的。这也是 SQL:2003 标准里面的一部分。我在项目里面一般会要求大家只要 ORDER BY 的那个列里面有可能有 NULL就必须手动写清楚 NULL 放哪。别让数据库自己去猜。四、 C 类函数与表达式行为差异C 类的坑是跟数据库自带的函数或者你写的 UDF 怎么跑有关系。这种坑是最容易被漏掉的。为什么呢因为大家觉得函数嘛看着名字就知道它该干嘛。但是不同的数据库对同一个函数的内部实现往往差别很大。C1 WHERE 里函数副作用与执行顺序表现WHERE 里面一块调了好几个 UDF。其中一个是去“设状态”的另一个是去“取状态”的。迁到 KES 之后查出来的结果忽对忽错。原因SQL 这种语言是声明式的。也就是说WHERE 里面的条件到底先算哪个后算哪个不是看你写在前面还是后面。优化器有权利为了跑得快自己去排个序。如果你写的 UDF 里面带了改状态的操作那不管在哪个数据库里其实都是很悬的。只不过不同的数据库出问题的那个点不一样而已。踩坑案例-- 危险写法依赖 set_id 先执行、get_id 后执行SELECT*FROMtWHEREt.idget_id()ANDset_id(t.value)1;在 Oracle 里面有可能因为 Package 的会话变量还留着以前的值瞎猫碰上死耗子居然能跑出结果来。但是在 KES 里面呢因为会话变量被清空了而且优化器可能会把计算顺序换一下。这样一搞结果可能就是空的。改写方案把改状态的那部分逻辑挪到 SQL 外面去-- 应用层先调用 set再单独执行 selectCALLpkg_abc.set_id(v_value);SELECT*FROMtWHEREt.idpkg_abc.get_id();KES 兼容特点KES 在 UDF 的属性系统上比 Oracle 给的选项还要多像 IMMUTABLE / STABLE / VOLATILE / STRICT 都有。另外它还有个LEAKPROOF的属性。这个是用来控制函数在 RLS 场景下能不能被下推的。这其实给我们在做国产化改造的时候把代码写得更规矩提供了一个很好的底层的支持。C2 字符串与数字的隐式类型转换表现那种写了WHERE varchar_col 12345的 SQL迁完之后跑得特别慢或者结果不对了。原因碰到字符串和数字放在一起比大小的时候不同数据库决定怎么转换的规矩是不一样的Oracle它喜欢把数字变成字符串也就是变成varchar_col 12345这样索引可能还能用上KES它可能会把 varchar_col 变成数字去比。这么一搞索引就废了。而且如果 varchar_col 里面刚好有不是数字的字符它直接就报错了还有些数据库直接抛一个类型不匹配的错。踩坑案例SELECT*FROMtWHEREvarchar_col12345;在 Oracle 里面可能还能走 varchar_col 上面的索引。在 KES 里面可能就直接全表扫描了。要是 varchar_col 里面有字母那就直接报错。改写方案自己手动加 CASTSELECT*FROMtWHEREvarchar_colCAST(12345ASVARCHAR);-- 或者SELECT*FROMtWHEREvarchar_col12345;KES 兼容特点KES 里面也有配置项可以用。你可以在会话级别开一点隐式转换的兼容具体参数名字看 KES 手册。但是我其实挺不建议这么干的。最好是在写业务代码的时候就把类型弄对。这样就从根上把隐式转换给干掉了。SQL 写得明白点优化器也好处理。C3 日期时间与时区解析差异表现跟日期时间有关的 SQL迁完之后查出来的时间差了 8 个小时或者别的时区差。或者是把字符串转成时间的时候表现不一样了。原因不同数据库在处理时区的时候默认的策略是不一样的Oracle 有三种TIMESTAMP、TIMESTAMP WITH TIME ZONE、还有TIMESTAMP WITH LOCAL TIME ZONEKES 跟 SQL 标准一样有两种TIMESTAMP和TIMESTAMPTZ另外会话时区、库时区、还有操作系统时区这三者到底听谁的不同数据库的优先级也不太一样。踩坑案例-- 意图获取当前时间SELECTSYSDATEFROMDUAL;-- Oracle返回 OS 时区的当前时间-- KESSYSDATE 语义可能被兼容为 CURRENT_TIMESTAMP返回会话时区时间如果以前的系统是跑在 UTC 的操作系统上的。现在 KES 的会话时区设成了 CST。那查出来的时间就差了 8 个小时。改写方案用明确的CURRENT_TIMESTAMP或者是NOW()存数据的时候统一用TIMESTAMPTZ带时区的。要展示的时候再按业务需要的时区去转在连上数据库初始化会话的时候显式写一句SET TIME ZONE Asia/Shanghai。KES 兼容特点KES 对 SQL 标准的TIMESTAMPTZ类型还有AT TIME ZONE语法都是支持的。同时它也兼容 Oracle 的SYSDATE和SYSTIMESTAMP这两个函数。迁过来之后呢我建议是慢慢改改成标准的写法。这样的话以后你的代码要换到别的库也能好搬一点。五、 一套平时干活能用的排查办法九个坑都说完了。那我们回到怎么干活这个层面来。在项目里面怎么去把这些坑一个一个找出来然后干掉呢我平时用的是一套“分三层去查”的办法。1. 第一层静态代码扫描拿正则表达式或者用 SQL 解析器把现有的代码全扫一遍。对着这九个坑每一个都拉出一张“看着有点可疑的清单”。写正则的时候别指望它 100% 准。宁可多圈进来一些看着没问题的也别把有问题的漏掉了。如果你的代码也就几百上千行那人工看一眼就行了。要是上万行了一般就得自己写点脚本工具或者是买商用的 SQL 分析软件来扫了。2. 第二层执行计划与执行结果对比对于那张可疑清单里面的每一条 SQL你要做两件事比对执行计划在老系统和 KES 里面各跑一次 EXPLAIN。看一看两边的 JOIN 类型、过滤条件放在哪了、用的是啥扫描算子是不是一样的比对结果差集用同一份测试数据跑一下 EXCEPT。看一看查出来的数据是不是一模一样的。只要发现不一样就把这条 SQL 扔到改写的队列里面去。3. 第三层生产灰度与实时监控改完之后就到了生产灰度这一步了。这一步里面最要紧的事就是盯着看——看看慢查询的排行有没有变看看业务那边对账的指标对不对看看执行计划有没有乱跳可以用 KES 自带的 SQL 执行统计功能去跟踪 SQL 的指纹还有执行计划的变化看看你写的那些 UDF 被调了多少次每次花多久。我在项目里面搭的这套盯着看的玩意儿其实就是一套给国产化数据库用的运维工具。它把前面扫代码的规则、后面动态监控的探针、还有报警的规则全拼在一块了。这样在灰度的时候万一有哪个坑触发了马上就能发现。现在这一整套东西我们组正在整理。准备拿去参加前阵子金仓官方搞的那个“2026 金仓数据库智能运维工具开发大赛”。我觉得这种比赛挺好的。一线干活踩过的坑、写的脚本如果能拿出来给大家用这是一件挺实在的事。另外呢金仓社区里面有个“同行者计划”我也经常去看。迁移的时候碰到拿不准的问题去社区发个帖子让原厂的人答一下比自己在那瞎猜快多了。还有金仓社区现在也在搞征文。如果你也做过国产化迁移也踩过坑不妨写一写投进去。把经验留下来让别人少踩点坑。这对大家来说都是好事。六、 总结把传统的数据库迁到国产化这边来真的不是“能跑起来就没事了”这么简单。你光看表面SQL 语法的兼容度好像都到了 95% 以上了。但是呢真正决定这活干得行不行的其实是剩下那 5% 不容易看出来的 SQL 逻辑坑。你能不能把它们找出来评估一下然后改掉。这才是关键。这篇文章里面说的九个坑也只是平时常见的一部分。每一个坑背后都有 SQL 语义标准在那摆着。也有各个数据库自己设计时候的一些取舍。其实你去做迁移改造也就是借这个机会把以前那些乱写的代码按 SQL 标准重新梳理一遍而已。站在现在这个时间点看金仓 KES 在做国产化迁移的时候有三个地方给我的感觉是很直接的优化器管得严能猜到它怎么跑它不会去替你写的错代码兜底。也不会搞些超出标准的推理。你代码迁过来它怎么跑你是能心里有数的。兼容的东西给得挺全对于 Oracle、MySQL、PostgreSQL 以前那些老旧的写法它都给了兼容层。这样迁的时候就能顺滑一点。查问题的工具够用什么 EXPLAIN 啊执行统计啊系统视图啊跟主流数据库都对齐了。你要去排查问题是有东西可以拿来用的。有这么几个特点在做国产化改造这事才变得能干。但是呢真正让项目没出问题的还是你得有一套干活的方法而且得守规矩——静态扫描要做意图要核对执行计划要比对EXCEPT 要跑灰度要上监控要盯。这几步你少干一步都不行。数据库迁移这条路挺长的。希望这篇文章里面列出来的这九个坑还有那个分三层去查的办法能给在干同样活的兄弟们一点帮助。大家一起把国产化改造这事干好。