8000 条 SQL 里,180 条外连接语义跑偏——Oracle 迁 KES 那些藏得最深的坑

8000 条 SQL 里,180 条外连接语义跑偏------Oracle 迁 KES 那些藏得最深的坑

我在这个迁移项目里建了一张陷阱追踪表。

到迁移结束,表里一共记录了 180 条"执行结果与预期不符"的 SQL,全部在改造阶段被拦截,没有一条带入生产。复盘会上我汇报这个数字的时候,项目负责人问了一句:"这 180 条里哪类问题最多?"

外连接消除。将近一半。

这不是 KES 特有的问题------Oracle、MySQL、PostgreSQL 都会做外连接消除,这是所有符合 SQL 标准的数据库的共同行为。但偏偏在 Oracle 迁 KES 这条路上,它踩中率最高、排查成本最大。三个助燃因素叠加在一起:Oracle 的 (+) 语法遗留、Oracle 优化器历史上某些非标准的启发式行为、以及视图封装带来的跨层问题。

这篇文章就把这些坑说清楚,顺手把我在项目里跑通的排查流程和改写方案一起输出,留给同样在做 Oracle 国产化改造的同行参考。

@toc


一、 项目背景与陷阱地图

先把我这次项目的背景讲一下------这样后面提到的坑点你能更好地代入场景。

  • 原系统:Oracle 11g + 12c 双版本混部,业务分布式跑在 20+ 应用服务器上;
  • 目标库:金仓 KES V9R1C10(企业版);
  • 代码量:SQL 存量约 8000+ 条(含视图、包体、触发器);
  • 业务边界:核心台账、审计报表、结算对账三条主线;
  • 时间窗口:8 周迁移期 + 2 周并行验证。

在整个迁移期里,我把踩过的陷阱做了一个分类地图。 按照"排查难度"(即从"看到异常"到"找到根因"的时间成本)排序,前五名是:

排名 陷阱类型 表现 排查难度
1 外连接消除 LEFT JOIN 结果集少数据 ★★★★★
2 NULL 与空串等价问题 Oracle 的空串 = NULL,KES 里空串 ≠ NULL ★★★★☆
3 WHERE 里函数副作用 依赖执行顺序的 set/get 函数不再"歪打正着" ★★★★☆
4 隐式类型转换差异 字符串与数字比较时的 index 失效 ★★★☆☆
5 ROWNUM vs. LIMIT 分页 排序前后差异导致分页不稳定 ★★★☆☆

本文以第一名"外连接消除"为核心展开,同时把 2-5 名的坑串起来讲,让整个迁移的隐性陷阱地图变得完整。

二、 第一个大坑:外连接消除------为什么从 Oracle 搬到 KES 这么容易出问题

外连接消除这个事,其实不是咱们国产数据库才有的。你看 Oracle、MySQL、PostgreSQL、SQL Server,还有金仓 KES,它们全都会做这个动作。那问题来了。为什么在 Oracle → KES 这条迁移路上特别容易踩坑呢?我回头想了想,主要是三个原因在里边起作用。

1. 老代码里的 (+) 语法让人看迷糊

Oracle 里面有一套挺特殊的外连接写法,就是 (+)。你把它放在 WHERE 里面的列后面,意思就是这一边是可以为空的:

sql 复制代码
-- Oracle 遗留语法:从右侧看,t2 是可空侧
SELECT * FROM t1, t2
WHERE  t1.id = t2.id(+)
  AND  t2.status = 'A';    -- 关键:这一行有没有 (+)?

这么写的话,其实会有两种不同的理解:

  • 第一种情况t2.status = 'A' 后面没带 (+)。这就相当于标准 SQL 里面的 WHERE t2.status = 'A'。这时候外连接就会被消除掉。
  • 第二种情况t2.status(+) = 'A' 后面(+)。这就相当于把条件挪到了 ON 子句里面。外连接就会保留下来。

在 Oracle 里面攒下来的那些老代码,这两种写法都特别多。有些写代码比较早的人,自己都不太清楚 (+) 到底代表啥。写出来的 SQL 其实是错的。但碰巧在 Oracle 里面,因为以前的一些优化逻辑,跑出来的结果反而是对的。等你要迁到 KES 的时候,情况变了。KES 是严格按照标准 SQL 的意思去跑的。以前那种瞎猫碰死耗子的情况,这下就全穿帮了。

怎么查 :你先写个脚本,把老 SQL 里面所有带 (+) 的语句全都抓出来列个表。然后去问写业务的人,这一处到底想干啥。接着把它改成标准的 SQL 写法。在 KES 里面的话,我一般建议大家全改成 ANSI JOIN 的语法(也就是 LEFT JOIN ... ON ...)。这样改完,就不会再有理解上的分歧了。

2. Oracle 优化器那些不按规矩来的习惯

Oracle 的优化器在判断要不要消除外连接的时候,有一些以前留下来的老习惯。比如它碰到自己写的函数(也就是 UDF)的时候。它判断这个函数会不会拒绝空值的策略,比标准 SQL 要松一些。这就导致了一个情况。在 Oracle 里面,有些 SQL 本来该消除的没消除。或者反过来,本来该保留的反而给消除了。

KES 的优化器不这样。它就是严格按照 ANSI/ISO 标准去判断拒空谓词的。那么问题就来了。你把 SQL 搬过来以后,某些句子的消除情况就跟 Oracle 不一样了。

咱们拿个最常见的例子来说:

sql 复制代码
-- Oracle 里 my_udf 是 DETERMINISTIC 但没有严格性标注
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id
WHERE  my_udf(t2.status) = 'A';

在 Oracle 里面,如果 my_udf 没有标明严格性。而且函数里面自己写了逻辑,遇到 NULL 就返回 'A'。这时候 Oracle 可能会把外连接留着。但是在 KES 里不一样。你得明确告诉数据库这个函数是不是 STRICTSTRICT 的意思就是,如果输入是 NULL,这函数就不执行了。KES 的优化器是看你这个声明来做判断的。如果你没声明 STRICT,KES 就觉得它不是拒空的。外连接就会被保留下来。

怎么查 :你自己写的那些 UDF 搬过来以后,必须手动加上 STRICT 或者 IMMUTABLE / STABLE / VOLATILE 这些属性。KES 支持的属性比 Oracle 要全很多。你标清楚了,优化器才能算得准。

3. 视图套视图引发的外层过滤问题

Oracle 建视图的时候要求没那么死。以前的老系统喜欢用视图把逻辑包一层。视图里面写的是 LEFT JOIN。你看视图本身的定义,一点毛病没有。但是用的时候就不一样了。业务代码去查这个视图的时候,往往会在外面套个 WHERE,去过滤视图里的字段。这种情况的话,其实就是跨层去消除外连接了。

sql 复制代码
-- 视图定义
CREATE VIEW v_user_order AS
SELECT u.user_id, u.user_name, o.order_id, o.order_amount, o.order_status
FROM   t_user u LEFT JOIN t_order o ON u.user_id = o.user_id;

-- 业务调用
SELECT * FROM v_user_order WHERE order_status = 'FINISHED';

在 Oracle 里面,视图要是被展开了,外连接一样会被消除。不过呢,有些人在老系统里用了 NO_MERGE 这个提示。这就强制不让视图展开。这么一搞,反而阴差阳错避开了消除。等你搬到 KES 来,情况又变了。NO_MERGE 这个提示到底起不起作用,你得重新看。要是你没把这个提示一起搬过来。KES 就会把视图展开,然后把外连接消除了。最后查出来的数据就对不上了。

怎么查:你得把 Oracle 里面所有的 hint 语句全都扒出来,列个单子。KES 是支持大部分标准 hint 的,语法差得也不多。但是这些搬过来的 hint,你不能觉得搬过来就能用。得一条一条去验证。

4. 我平时在项目里是怎么查的

针对上面说的这三个情况。我在项目里一般是按这么几个步骤来查的:

第一步,先静态扫一遍 :把所有 SQL 里面的 LEFT/RIGHT JOIN、还有 (+)、以及里面带了 LEFT JOIN 的视图,这三类全都挑出来。弄成一个重点关注的表。当时老代码大概有 8000 条。挑出来的重点大概有 1200 条。

第二步,去问业务到底要啥:拿着这 1200 条,去找业务那边一条一条对。问他们这句 SQL 到底想干嘛。标清楚到底是"外连接必须留着"还是"外连接可以消除"。这一步其实是最烦的,也最花时间。

第三步,跑执行计划看:在 KES 里面挨个跑 EXPLAIN。看它实际出来的执行计划,外连接到底没被消除。然后跟第二步标的意图去对。把那些"想的"跟"实际做的"不一样的地方找出来。

第四步,改 SQL 然后验证:把找出来的那些不对的 SQL 改掉(怎么改下面再说)。改完以后,用 EXCEPT 去对比新老数据。保证两边跑出来的东西一模一样。

这么一套走完。8000 条 SQL 里面,大概有 180 条外连接的意思出了偏差。不过好在这些全在改造的时候给拦住了。一条都没跑到生产环境里去。项目总结的时候,这算是我说的最有底气的一句话了。

三、 怎么改写:别跟外连接消除对着干

上面说完了怎么查。这一节咱们说说怎么改。改写这事儿,往往仅仅只是把 WHERE 里的条件挪到 ON 里那么简单吗?其实不是。你得看业务到底想干嘛。我一般把它分成四种情况来处理。

1. 类型 A:其实想写内连接,但手抖写成了 LEFT JOIN

这个情况最好办。业务那边本来就是要内连接的结果。只不过以前写代码的人写成了 LEFT JOIN。碰巧被优化器给消除成了内连接。这种情况的话,你直接把 LEFT JOIN 改成 INNER JOIN 就行了。执行计划不会有变化。而且别人一看代码就懂了。

sql 复制代码
-- 改写前
SELECT * FROM t1 LEFT JOIN t2 ON t1.id = t2.id WHERE t2.status = 'A';

-- 改写后
SELECT * FROM t1 INNER JOIN t2 ON t1.id = t2.id WHERE t2.status = 'A';

这么改有啥好处呢?就是代码好看了,也容易读了。以后别人来接手,看一眼就知道这代码想干嘛。不用再去扒执行计划猜了。

2. 类型 B:就是想外连接,但条件放错地方了

这个情况平时见得最多。业务的需求是,左表的数据一个都不能少。右表的数据先过滤一下,然后再连起来。那你怎么做呢?直接把 WHERE 里面的右表条件,挪到 ON 里面去:

sql 复制代码
-- 改写前(外连接被消除)
SELECT * FROM t_user u
LEFT   JOIN t_order o ON u.user_id = o.user_id
WHERE  o.order_status = 'FINISHED';

-- 改写后(外连接保留)
SELECT * FROM t_user u
LEFT   JOIN t_order o
       ON u.user_id = o.user_id
      AND o.order_status = 'FINISHED';

有个地方要注意:如果 WHERE 里面既有左表的条件,又有右表的条件。你只能把右表的条件挪到 ON 里。左表的条件还得留在 WHERE 那里。很多刚开始改 SQL 的人,这块特别容易搞错。

3. 类型 C:想找那些没匹配上的记录,靠 IS NULL 挡着

这种情况的话,就是业务想找"没下过单的用户",或者"没关联合同的客户"。这种 SQL 本身就是要靠外连接去补那些 NULL 值的:

sql 复制代码
-- 找出无订单用户
SELECT u.user_id, u.user_name
FROM   t_user u
LEFT   JOIN t_order o ON u.user_id = o.user_id
WHERE  o.order_id IS NULL;

KES 的优化器看到 IS NULL,它知道这玩意儿不是拒空谓词。所以它会把外连接留着。这种 SQL 你不用改。它本身这么写就是没问题的。

4. 类型 D:既要过滤掉一些,又要把 NULL 也留下来

业务的需求可能是这样的。他要订单状态是 FINISHED 的。但是呢,如果这个用户压根就没订单,他也想留下来。也就是说,这两种记录都要。这时候怎么办?你得用 COALESCE 或者 IS NOT DISTINCT FROM。让过滤条件对 NULL 友好一点:

sql 复制代码
-- 改写前(错误:只保留 FINISHED,NULL 记录也被过滤)
SELECT * FROM t_user u
LEFT   JOIN t_order o ON u.user_id = o.user_id
WHERE  o.order_status = 'FINISHED';

-- 改写后(正确:FINISHED 或 NULL 都保留)
SELECT * FROM t_user u
LEFT   JOIN t_order o ON u.user_id = o.user_id
WHERE  COALESCE(o.order_status, 'FINISHED') = 'FINISHED';

你写 COALESCE(o.order_status, 'FINISHED') 是啥意思呢?就是如果这玩意儿是 NULL,我就当它是 FINISHED。这样它就能穿过 WHERE 的过滤了。这条件一变,KES 优化器就觉得这不能消除外连接了。最后查出来的东西,就是"有 FINISHED 订单的用户"加上"没订单的用户"这两拨人。

四、 顺带说一下:Oracle 迁 KES 的另外四个高频陷阱

外连接消除这事就说完了。那么接着我再顺一下另外四个经常碰到的坑。这几个情况要是细说的话,每一个都能单独拿出来讲半天。所以这里我就先把"怎么发现问题"还有"怎么改"的要点给大家列一下。

1. NULL 与空串等价

在 Oracle 里面,''NULL 它俩是一回事。你写 col = '' 还是写 col IS NULL,出来的结果是一样的。但是到了 KES 这里呢,它是按标准的 SQL 来的。空串就是一个长度是 0 的字符串,它跟 NULL 不是一回事。

你要这么去查 :把代码里所有的 = ''<> ''|| '' 这三种写法全搜出来。然后一条一条去看,写这段代码的人是不是想判断 NULL。如果是想判断 NULL 的话,那就全都给它改成 IS NULL / IS NOT NULL

有个情况要注意 :就是你往表里插数据的时候。Oracle 会偷偷把 '' 变成 NULL 存进去。但是 KES 不会,KES 会老老实实存一个空串进去。所以如果碰到那种有 NOT NULL 约束的列,迁移的时候你得额外处理一下。

2. WHERE 里函数副作用

这个坑呢,我以前在别的文章里提过。也就是在 Oracle 里面,因为有 Package 会话级变量这个东西。很多人写 SQL 的时候,就会写成这样:在 WHERE 里面先去调一个 set 函数,然后再跟着调一个 get 函数。这种写法就是盯着执行顺序来的。但是迁到 KES 之后呢,情况变了。KES 里面会话变量默认是清零的。而且它执行顺序的一些细节也可能不一样。这就导致这类 SQL 基本上都跑不出结果了。

你要这么去查:把所有在 WHERE 里面带了 UDF 的 SQL 全拉出来列个表。重点去看那种"同一个 WHERE 里面出现了两个或者更多 UDF"的情况。一条一条去确认是不是有这种副作用依赖。如果有依赖的话,那就别写在 SQL 里了。把它拆出来,在业务代码里面显式去调 set,然后再单独去发 select 请求。

3. 隐式类型转换与索引失效

在 Oracle 里面,如果一个字符串的列去跟一个数字做比较,它会在底下偷偷做转换。比如像这样:

sql 复制代码
-- t.varchar_col 是 VARCHAR 类型
SELECT * FROM t WHERE varchar_col = 12345;

Oracle 会把那个 12345 变成 '12345',然后再去跟 varchar_col 比。这种情况下索引有时候还能用上。但是在 KES 里面呢,它做隐式转换的规矩跟 Oracle 不太一样。同样是这种 SQL,在 KES 里面可能就出现本来能走索引结果没走的情况。这就导致查询变得特别慢。

你要这么去查 :迁移完之后,先跑一轮基准测试。然后把耗时长短排个序,把前 100 名的 SQL 拿出来。重点去查里面的数据类型是不是对得上的。对不上的,就手动加一下 CAST,或者把参数的类型改对。

4. ROWNUM 与 LIMIT 的分页行为差异

在 Oracle 里面,大家写分页一般是这么写的:

sql 复制代码
SELECT * FROM (
    SELECT t.*, ROWNUM rn FROM (SELECT * FROM t ORDER BY id) t WHERE ROWNUM <= 20
) WHERE rn > 10;

但是 KES 里面它支持的是 LIMIT ... OFFSET ... 这种写法:

sql 复制代码
SELECT * FROM t ORDER BY id LIMIT 10 OFFSET 10;

迁移的时候有个事很容易忘。就是如果你没把外面那层 ORDER BY 一起带过来的话,查出来的顺序可能就乱了。为什么呢?因为 KES 底层存数据的物理顺序,它是看扫描算子的,这跟 Oracle 不一样。

你要这么去查 :把代码里所有带了 ROWNUM 的 SQL 都找出来。全部改成 LIMIT/OFFSET 的写法。然后还得确认一下排序列。得确保这个排序是确定的,比如说不光按时间排,还得把主键也加进去作为二次排序的条件。

五、 一份能直接拿去用的项目执行流程

上面的那些坑还有改写方法都说完了。最后呢,我给大家一份我自己在项目里实际在用的流程模板 。这个流程我们当时跑了大概 8 周的迁移期,证明是能跑通的。大家可以直接拿去用。

1. 迁移前:静态代码扫描 + 列清单

在还没开始迁之前,先用脚本把现有的 SQL 还有 PL/SQL 代码扫一遍。扫完之后,你会得到五张清单:

  • LEFT/RIGHT JOIN 加上 WHERE 里面引用了可空侧的 SQL;
  • 里面带了 (+) 这种语法的 SQL;
  • 里面有 = '' 或者 <> '' 的 SQL;
  • WHERE 里面带了 UDF 的 SQL;
  • 里面有 ROWNUM 的 SQL。

这五张清单弄出来之后,合在一起就是一个大池子。后面你要做的所有事情,都是围着这个池子转的。

2. 迁移中:意图核对 + 跑执行计划

针对池子里的每一条 SQL,你要做两件事:

  • 核对意图:去找写这个业务的人,问清楚他到底想干嘛。然后给它打上标签。比如标成"这个必须保留外连接"、"这个可以消除"、"这个 NULL 得留着"或者"这个空串得留着"。
  • 验证执行计划:在 KES 里面去跑一下 EXPLAIN。看一看它实际跑起来的行为,跟前面确认的意图是不是对得上的。对不上的那些 SQL,就把它扔到改写队列里面去。

3. 迁移后:EXCEPT 差集回归 + 生产灰度

改完之后呢,要用 EXCEPT 做一下差集回归。看看改之前和改之后是不是真的等价。就像这样:

sql 复制代码
-- 差集验证:改写前后是否严格等价
(SELECT * FROM query_before)
EXCEPT
(SELECT * FROM query_after);

(SELECT * FROM query_after)
EXCEPT
(SELECT * FROM query_before);

如果你跑出来差集里面有数据,那就说明改错了。得回到改写队列里重新看。如果差集是空的,那就没问题了。接着就可以进生产灰度了。也就是先切 5% 的流量过去,跑一个星期看看对账的数据对不对。没问题的话再切 30%,看两个星期。最后再全部切过去。

4. 迁移后:搞智能运维

迁移这事吧,不是说上线那一刻没报错就完了。其实后面三个月不出问题才算完。在这个阶段呢,我们就花了很多精力在搞智能运维这块的东西。比如说 SQL 慢查询的报警、执行计划回退的监控,还有外连接消除的实时探针。

说真的,自己动手写这些运维工具是挺好玩的。前段时间我看到金仓官方在搞一个**「2026 金仓数据库智能运维工具开发大赛」**。他们就是想让一线干活的人,把自己在实际业务里写的那些运维工具、脚本拿去投稿。我觉得这种搞法对我们这种做迁移的团队来说挺有用的。你想啊,我们踩过的坑,写的脚本,如果能通过比赛变成大家都能用的东西,让别人少踩坑,这其实就是对国产化挺实在的一件事。我们组现在就在整理一个"外连接消除实时探针"的脚本,准备拿去参赛。这算是我这半年弄出来觉得最有意思的一个附带产物了。

六、 总结

做 Oracle 迁 KES 这么一圈下来,我最想跟正在干同样活的同行说的一句话就是:别把国产化改造当成只是换个数据库这么简单。

其实它就是把你以前的代码拿出来重新审视一遍,把 SQL 的语义重新理一遍。也就是把你以前靠经验写的东西,往标准上靠。外连接消除只是这些事里面比较典型的一个坑,但并不是说只有这一个坑。什么 NULL 跟空串啊,WHERE 里面的函数副作用啊,隐式类型转换啊,分页不一样啊。这每一个情况背后,都是一段"在 Oracle 里面跑着没事,到了 KES 里面就出问题了"的经历。但也正是因为出了这些问题,我们才趁机把以前那些烂代码给修好了。

用金仓 KES 做这一轮迁移,我自己有两个感受比较深的地方:

  1. 优化器管得严但不过分:它是按 SQL 标准来的,你代码写错了它不会帮你兜着。但是它本身功能也够用,复杂一点的等价关系它也能认出来。这两点加在一起,就导致你迁过来的代码,它的行为你是能猜到的。
  2. 查问题的工具够用:什么 EXPLAIN 啊,执行统计啊,DDL 兼容视图啊都有。你排查的时候是有东西可以看的。我在项目里面用得最狠的就是 EXPLAIN ANALYZE。基本上碰到那些搞不懂的问题,都是靠它一点点抠出来的。

做国产化迁移,这其实只是个开头。迁完能跑了不算完。你要让它跑得快、跑得不宕机,这才是后面要一直干的事。希望这篇东西里面列的那些坑还有改法,能对正在干这活的兄弟们有点用。有什么别的问题,咱们可以在评论区接着聊。

相关推荐
逆境不可逃1 小时前
RAG 不只是向量数据库:讲透 Chunk、混合检索、重排、引用与权限治理
数据库·oracle
全堆鸿蒙2 小时前
鸿蒙应用开发实战【36】— 数据查询优化:predicates 链式调用
数据库·harmonyos·鸿蒙系统
91刘仁德2 小时前
MySQL 数据库基础操作知识点整理
数据库·mysql·oracle
IvorySQL10 小时前
PG 日报|新增 VACUUM 全维度统计信息补丁迭代
数据库·人工智能·postgresql·ivorysql
白露与泡影12 小时前
Redis 缓存实战:雪崩、穿透、击穿一次讲透
数据库·redis·缓存
赵渝强老师13 小时前
【赵渝强老师】使用Oracle可传输的表空间迁移数据
数据库·oracle
IpdataCloud14 小时前
跨区服玩家延迟高怎么办?用IP离线库自动分配到最近服务器
数据库·tcp/ip·github·php·ip
wuqingshun31415915 小时前
MYSQL默认的事务隔离级别是什么?为什么选择这个级别?
数据库·mysql
空中湖15 小时前
电池简史(五):极限之梦与终章——锂硫、锂空气、量子电池,以及你该知道的电池投资与生活指南
数据库·程序人生