SQL 语法全兼容,但结果就是不一样——国产化迁移里最难缠的九类隐患

SQL 语法全兼容,但结果就是不一样------国产化迁移里最麻烦的九类隐患

有一类迁移的问题,其实挺让人头疼的。它不报错,也没有告警。SQL 跑完了,甚至还会弹出一个提示说"执行成功,影响了 X 行"。但是呢,业务那边拿着新老系统的数据一对比,发现就是不对。少了几行,或者多了几行,再或者是某一列的数值偏了。这个时候你去查语法,去查字符集,又去查时区、查连接池。查了半天,啥也没发现。

这类问题查起来特别费劲。为什么呢?原因在于你得同时搞懂三件事:SQL 执行的语义标准、优化器是怎么跑的、还有不同数据库之间的差异。少懂一个都不行。在做过几个迁移项目之后,我就把碰到的这类不出错的问题给整理了一下。一共分了九类。

这篇文章呢,就是我整理出来的东西。这九个坑,每一个我都配了案例,还有原因分析。另外也说了一下在金仓 KES 里面是什么表现,以及怎么改。尽量让这篇东西能拿来就直接当避坑手册用。

@toc


一、 九个容易出问题的 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 给刷掉了。

踩坑案例

sql 复制代码
-- 意图:所有用户,附带展示已完成订单信息
SELECT u.user_id, o.order_amount
FROM   t_user u
LEFT   JOIN t_order o ON u.user_id = o.user_id
WHERE  o.order_status = 'FINISHED';

写这行代码的意思是"所有用户都得出来,只有 FINISHED 的订单才显示"。但实际跑出来的结果变成了"只有那些有 FINISHED 订单的用户才出来"。

改写方案:把条件挪到 ON 子句里面去:

sql 复制代码
SELECT u.user_id, o.order_amount
FROM   t_user u
LEFT   JOIN t_order o
       ON u.user_id = o.user_id
      AND o.order_status = 'FINISHED';

KES 兼容特点:KES 的优化器是按 ANSI/ISO 标准来的。在判断是不是拒空条件的时候,它比有些老版本的 Oracle 还要准。你跑一下 EXPLAIN 就能直接看到,JOIN 的节点是不是被改成了内连接。这是查这种问题最管用的办法。

A2 谓词下推的边界差异

表现:那种带了子查询或者视图的复杂 SQL,迁过来之后,执行计划跟以前不一样了。有些过滤条件被挪了个位置,这就导致结果对不上了。

原因 :不同的数据库,对于"哪个条件能够挪到下面哪一层去执行",规矩是不一样的。特别是如果你的子查询或者视图里面带了 LIMITORDER BYDISTINCT 或者是聚合函数。这时候要判断条件能不能挪下去,情况就挺复杂的。

踩坑案例

sql 复制代码
-- 意图:查询前 100 个订单里状态为 FINISHED 的
SELECT * FROM (
    SELECT * FROM t_order ORDER BY create_time DESC LIMIT 100
) sub
WHERE sub.order_status = 'FINISHED';

在老系统里面,它可能不往下挪条件。也就是先不管状态,直接拿前 100 个订单。然后再从这 100 个里面去过滤 FINISHED 的。但是在 KES 里面呢,如果优化器把 WHERE sub.order_status = 'FINISHED' 给塞到子查询里面去了。那就变成先去过滤出 FINISHED 的订单,然后再取前 100 条。这跑出来的结果就完全不是一回事了

改写方案 :用 OFFSET 0 或者是用 MATERIALIZED(KES 支持的 CTE 物化写法)来不让它往下推:

sql 复制代码
WITH sub AS MATERIALIZED (
    SELECT * FROM t_order ORDER BY create_time DESC LIMIT 100
)
SELECT * FROM sub WHERE order_status = 'FINISHED';

KES 兼容特点:KES 对 CTE 物化这个语法的支持是没问题的。在做国产化改造的时候,CTE 是个能保证语义不出岔子的好东西。我在项目里一般是定了个规矩,就是复杂的子查询全都得改成 CTE。这么干,这种下推的坑就能避开。

A3 子查询解嵌套

表现:那种带了 EXISTS / IN / ANY 子查询的 SQL,迁完之后跑出来的行为不一样。特别是当你的子查询里面混进了 NULL 值的时候。

原因 :优化器会试着把子查询给拆开。改写成 semi-join 或者是 anti-join 去跑。这样跑起来会快很多。但是,如果子查询查出来的数据里面有 NULL。那 INNOT IN 的意思,就跟你想的有点不一样了。

踩坑案例

sql 复制代码
-- 意图:查询没有关联合同的客户
SELECT * FROM t_customer
WHERE  customer_id NOT IN (SELECT customer_id FROM t_contract);

如果 t_contract.customer_id 里面有 NULL 的话,这条 SQL 查出来的会是一个空集 。为什么呀?因为 NOT IN (NULL, ...) 算出来的结果一直都是 Unknown。这是 SQL 三值逻辑的标准搞法。Oracle、MySQL、KES 在这点上表现是一样的。但是呢,在你的测试环境里面,如果刚好没碰到 NULL 数据。你就一直发现不了这个问题。等到生产环境里冒出来一条 NULL,整个功能就废了。

改写方案 :改成写 NOT EXISTS 或者是用 LEFT JOIN + IS NULL

sql 复制代码
-- 改写为 NOT EXISTS
SELECT * FROM t_customer c
WHERE  NOT EXISTS (SELECT 1 FROM t_contract ct WHERE ct.customer_id = c.customer_id);

-- 或者改写为 LEFT JOIN
SELECT c.* FROM t_customer c
LEFT   JOIN t_contract ct ON ct.customer_id = c.customer_id
WHERE  ct.customer_id IS NULL;

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 的空串"。

踩坑案例

sql 复制代码
-- Oracle 原意:查询 remark 为空的记录
SELECT * FROM t_log WHERE remark = '';
-- Oracle:返回 remark 为 NULL 的记录
-- KES:返回 remark 为长度 0 空串的记录(如果没有这类数据,返回空集)

改写方案 :要判断是不是空,全都得用 IS NULL

sql 复制代码
SELECT * FROM t_log WHERE remark IS NULL;

如果你确实想找"空串或者 NULL",那就用 COALESCE

sql 复制代码
SELECT * FROM t_log WHERE COALESCE(remark, '') = '';

KES 兼容特点:KES 里面有几个配置项可以用。你可以在会话级别,或者库级别,让它去模仿 Oracle 把空串当 NULL 的做法。这样迁过来的老代码就能先跑起来(具体的参数名字,你去翻一下 KES 官方手册里 Oracle 兼容那一章)。但是呢,从长远来看,我建议还是把代码改了,让它符合标准。别一直靠着兼容层过日子。

B2 三值逻辑短路差异

表现:SQL 里面用 AND 或者 OR 连了好多条件。其中有些条件碰到了 NULL。迁完之后返回来的结果不一样了。

原因:SQL 用的是三值逻辑。当有 NULL 混进来的时候,AND 和 OR 到底短不短路,情况就比较绕:

  • True AND Unknown = Unknown
  • False AND Unknown = False(短路)
  • True OR Unknown = True(短路)
  • False OR Unknown = Unknown

不同的数据库在处理这种短路的时候,细节上是不一样的。如果你的 SQL 里面带了有副作用的 UDF,那结果可能就不一样了。

踩坑案例

sql 复制代码
-- WHERE 里第一个条件涉及 NULL
SELECT * FROM t
WHERE  t.col1 = 'A' AND expensive_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 放最后。

踩坑案例

sql 复制代码
-- 意图:按 priority 升序取前 10 条
SELECT * FROM t_task ORDER BY priority ASC LIMIT 10;

如果 priority 里面有 NULL。在 MySQL 里面,你取出来的前 10 条可能全都是 NULL 的数据。但是在 KES 里面,取出来的前 10 条是 priority 值最小的那 10 条非 NULL 数据。这两个意思就差得远了。

改写方案:你自己手动指定 NULL 放哪里:

sql 复制代码
SELECT * FROM t_task ORDER BY priority ASC NULLS LAST LIMIT 10;

或者是用 COALESCE 把 NULL 变成一个具体的数去排:

sql 复制代码
SELECT * FROM t_task ORDER BY COALESCE(priority, 999999) ASC LIMIT 10;

KES 兼容特点 :KES 是支持 NULLS FIRST / NULLS LAST 这种写法的。这也是 SQL:2003 标准里面的一部分。我在项目里面一般会要求大家,只要 ORDER BY 的那个列里面有可能有 NULL,就必须手动写清楚 NULL 放哪。别让数据库自己去猜。

四、 C 类:函数与表达式行为差异

C 类的坑,是跟数据库自带的函数,或者你写的 UDF 怎么跑有关系。这种坑是最容易被漏掉的。为什么呢?因为大家觉得函数嘛,看着名字就知道它该干嘛。但是不同的数据库,对同一个函数的内部实现,往往差别很大。

C1 WHERE 里函数副作用与执行顺序

表现:WHERE 里面一块调了好几个 UDF。其中一个是去"设状态"的,另一个是去"取状态"的。迁到 KES 之后,查出来的结果忽对忽错。

原因:SQL 这种语言是声明式的。也就是说,WHERE 里面的条件到底先算哪个后算哪个,不是看你写在前面还是后面。优化器有权利为了跑得快,自己去排个序。如果你写的 UDF 里面带了改状态的操作,那不管在哪个数据库里,其实都是很悬的。只不过不同的数据库,出问题的那个点不一样而已。

踩坑案例

sql 复制代码
-- 危险写法:依赖 set_id 先执行、get_id 后执行
SELECT * FROM t
WHERE  t.id = get_id()
  AND  set_id(t.value) = 1;

在 Oracle 里面,有可能因为 Package 的会话变量还留着以前的值,瞎猫碰上死耗子,居然能跑出结果来。但是在 KES 里面呢,因为会话变量被清空了,而且优化器可能会把计算顺序换一下。这样一搞,结果可能就是空的。

改写方案把改状态的那部分逻辑挪到 SQL 外面去

sql 复制代码
-- 应用层:先调用 set,再单独执行 select
CALL pkg_abc.set_id(v_value);
SELECT * FROM t WHERE t.id = pkg_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 里面刚好有不是数字的字符,它直接就报错了;
  • 还有些数据库:直接抛一个类型不匹配的错。

踩坑案例

sql 复制代码
SELECT * FROM t WHERE varchar_col = 12345;

在 Oracle 里面,可能还能走 varchar_col 上面的索引。在 KES 里面,可能就直接全表扫描了。要是 varchar_col 里面有字母,那就直接报错。

改写方案:自己手动加 CAST:

sql 复制代码
SELECT * FROM t WHERE varchar_col = CAST(12345 AS VARCHAR);
-- 或者
SELECT * FROM t WHERE varchar_col = '12345';

KES 兼容特点 :KES 里面也有配置项可以用。你可以在会话级别开一点隐式转换的兼容(具体参数名字看 KES 手册)。但是我其实挺不建议这么干的。最好是在写业务代码的时候就把类型弄对。这样就从根上把隐式转换给干掉了。SQL 写得明白点,优化器也好处理。

C3 日期时间与时区解析差异

表现:跟日期时间有关的 SQL,迁完之后查出来的时间差了 8 个小时(或者别的时区差)。或者是把字符串转成时间的时候,表现不一样了。

原因:不同数据库在处理时区的时候,默认的策略是不一样的:

  • Oracle 有三种:TIMESTAMPTIMESTAMP WITH TIME ZONE、还有 TIMESTAMP WITH LOCAL TIME ZONE
  • KES 跟 SQL 标准一样,有两种:TIMESTAMPTIMESTAMPTZ
  • 另外,会话时区、库时区、还有操作系统时区,这三者到底听谁的,不同数据库的优先级也不太一样。

踩坑案例

sql 复制代码
-- 意图:获取当前时间
SELECT SYSDATE FROM DUAL;
-- Oracle:返回 OS 时区的当前时间
-- KES:SYSDATE 语义可能被兼容为 CURRENT_TIMESTAMP,返回会话时区时间

如果以前的系统是跑在 UTC 的操作系统上的。现在 KES 的会话时区设成了 CST。那查出来的时间就差了 8 个小时。

改写方案

  • 用明确的 CURRENT_TIMESTAMP 或者是 NOW()
  • 存数据的时候,统一用 TIMESTAMPTZ(带时区的)。要展示的时候,再按业务需要的时区去转;
  • 在连上数据库初始化会话的时候,显式写一句 SET TIME ZONE 'Asia/Shanghai'

KES 兼容特点 :KES 对 SQL 标准的 TIMESTAMPTZ 类型,还有 AT TIME ZONE 语法都是支持的。同时它也兼容 Oracle 的 SYSDATESYSTIMESTAMP 这两个函数。迁过来之后呢,我建议是慢慢改,改成标准的写法。这样的话,以后你的代码要换到别的库,也能好搬一点。

五、 一套平时干活能用的排查办法

九个坑都说完了。那我们回到怎么干活这个层面来。在项目里面,怎么去把这些坑一个一个找出来然后干掉呢?我平时用的是一套"分三层去查"的办法。

1. 第一层:静态代码扫描

拿正则表达式,或者用 SQL 解析器,把现有的代码全扫一遍。对着这九个坑,每一个都拉出一张"看着有点可疑的清单"。写正则的时候,别指望它 100% 准。宁可多圈进来一些看着没问题的,也别把有问题的漏掉了。如果你的代码也就几百上千行,那人工看一眼就行了。要是上万行了,一般就得自己写点脚本工具,或者是买商用的 SQL 分析软件来扫了。

2. 第二层:执行计划与执行结果对比

对于那张可疑清单里面的每一条 SQL,你要做两件事:

  • 比对执行计划:在老系统和 KES 里面,各跑一次 EXPLAIN。看一看两边的 JOIN 类型、过滤条件放在哪了、用的是啥扫描算子,是不是一样的;
  • 比对结果差集:用同一份测试数据,跑一下 EXCEPT。看一看查出来的数据是不是一模一样的。

只要发现不一样,就把这条 SQL 扔到改写的队列里面去。

3. 第三层:生产灰度与实时监控

改完之后,就到了生产灰度这一步了。这一步里面最要紧的事就是盯着看------

  • 看看慢查询的排行有没有变;
  • 看看业务那边对账的指标对不对;
  • 看看执行计划有没有乱跳(可以用 KES 自带的 SQL 执行统计功能,去跟踪 SQL 的指纹还有执行计划的变化);
  • 看看你写的那些 UDF 被调了多少次,每次花多久。

我在项目里面搭的这套盯着看的玩意儿,其实就是一套给国产化数据库用的运维工具。它把前面扫代码的规则、后面动态监控的探针、还有报警的规则全拼在一块了。这样在灰度的时候,万一有哪个坑触发了,马上就能发现。

现在这一整套东西,我们组正在整理。准备拿去参加前阵子金仓官方搞的那个"2026 金仓数据库智能运维工具开发大赛"。我觉得这种比赛挺好的。一线干活踩过的坑、写的脚本,如果能拿出来给大家用,这是一件挺实在的事。

另外呢,金仓社区里面有个"同行者计划",我也经常去看。迁移的时候碰到拿不准的问题,去社区发个帖子,让原厂的人答一下,比自己在那瞎猜快多了。还有,金仓社区现在也在搞征文。如果你也做过国产化迁移,也踩过坑,不妨写一写投进去。把经验留下来,让别人少踩点坑。这对大家来说都是好事。

六、 总结

把传统的数据库迁到国产化这边来,真的不是"能跑起来就没事了"这么简单。你光看表面,SQL 语法的兼容度好像都到了 95% 以上了。但是呢,真正决定这活干得行不行的,其实是剩下那 5% 不容易看出来的 SQL 逻辑坑。你能不能把它们找出来,评估一下,然后改掉。这才是关键。

这篇文章里面说的九个坑,也只是平时常见的一部分。每一个坑背后,都有 SQL 语义标准在那摆着。也有各个数据库自己设计时候的一些取舍。其实你去做迁移改造,也就是借这个机会,把以前那些乱写的代码,按 SQL 标准重新梳理一遍而已。

站在现在这个时间点看,金仓 KES 在做国产化迁移的时候,有三个地方给我的感觉是很直接的:

  1. 优化器管得严,能猜到它怎么跑:它不会去替你写的错代码兜底。也不会搞些超出标准的推理。你代码迁过来,它怎么跑你是能心里有数的。
  2. 兼容的东西给得挺全:对于 Oracle、MySQL、PostgreSQL 以前那些老旧的写法,它都给了兼容层。这样迁的时候就能顺滑一点。
  3. 查问题的工具够用:什么 EXPLAIN 啊,执行统计啊,系统视图啊,跟主流数据库都对齐了。你要去排查问题,是有东西可以拿来用的。

有这么几个特点在,做国产化改造这事才变得能干。但是呢,真正让项目没出问题的,还是你得有一套干活的方法,而且得守规矩------静态扫描要做,意图要核对,执行计划要比对,EXCEPT 要跑,灰度要上,监控要盯。这几步你少干一步都不行。

数据库迁移这条路挺长的。希望这篇文章里面列出来的这九个坑,还有那个分三层去查的办法,能给在干同样活的兄弟们一点帮助。大家一起把国产化改造这事干好。

相关推荐
kobe_OKOK_20 小时前
django外键字段会自动在数据库字段后面加上_id
数据库·django·sqlite
<小智>1 天前
及时做APP开发实战(十二)-LazyForEach懒加载优化实践
数据库·鸿蒙
龙仔7251 天前
人大金仓KingbaseES V8 手动单库备份&恢复操作笔记
数据库·笔记·oracle·人大金仓
夏贰四1 天前
数据库迁移怎样降低人力投入?数据库迁移怎么实现全量增量同步?
数据库·数据库迁移
汇智信科1 天前
图谱、向量、关键词、SQL多路一体,FastKG垂域召回率100%拉满
网络·数据库·ai编程·汇智信科·hsim·fastclaw·汇智龙虾
笨蛋不要掉眼泪1 天前
MySQL架构揭秘:慢查询日志详解
数据库·mysql·架构
pulinzt1 天前
Tableau的基础使用
数据库·numpy
糖果店的幽灵1 天前
【langgraph 从入门到精通graphApi 篇】LangGraphAPI 方式调用 - 初识与核心概念
数据库·人工智能·langgraph
TlSfoward1 天前
TLSFOWARD TLS指纹
开发语言·数据库·爬虫·搜索引擎·https·php