MySQL 迁移金仓,SQL 能正常执行,为什么查询结果却不一样?
去年参与过一个物流项目的数据库迁移,原来使用 MySQL,后来需要迁到金仓 KingbaseES。
项目里的数据库对象不少,除了业务表,还有存储过程、视图以及各种报表 SQL。
迁移之前,我们先用 KDMS 做了一轮兼容性评估。
评估结果整体还不错,大部分 SQL 没有明显的语法兼容问题。按照最开始的计划,完成对象转换和数据迁移后,再做一轮业务回归就可以准备切换了。
但真正开始核对业务数据的时候,还是发现了一些问题。
有几条 SQL 在 MySQL 和金仓上都能正常执行,没有任何报错,返回的记录却不完全一样。
一开始怀疑是数据迁移过程中漏了记录,后来检查了源库和目标库的数据,发现问题并不都出在数据同步上。
有些是空值处理的差异,有些是 SQL 本身存在不确定性,还有一些是字段类型和隐式转换带来的问题。
这些问题单独看都不复杂,但如果迁移前没有注意,到了业务回归阶段就比较折腾。
这次项目之后,我做数据库迁移时会特别关注一件事:
SQL 能执行,只能说明语法层面基本过关,不能直接说明迁移前后的执行结果一致。
下面挑几个比较容易忽略的地方聊聊。
一、空字符串和 NULL,先确认目标库怎么处理
先看一个简单的例子。
假设用户表里有一个手机号字段:
SQL
CREATE TABLE user_info (
id BIGINT PRIMARY KEY,
phone VARCHAR(32)
);
在 MySQL 中,下面两条记录的含义不同:
SQL
INSERT INTO user_info (id, phone)
VALUES (1, NULL);
INSERT INTO user_info (id, phone)
VALUES (2, '');
第一条记录的手机号是 NULL。
第二条记录的手机号是空字符串。
因此:
SQL
SELECT *
FROM user_info
WHERE phone = '';
可以查到第二条记录。
而:
SQL
SELECT *
FROM user_info
WHERE phone IS NULL;
查到的是第一条记录。
这个区别在业务代码里经常会被忽略。
尤其一些老系统,可能直接用空字符串表示“未填写”,也可能用 NULL 表示“未知”。
两种写法混在一起,平时在 MySQL 上运行没什么问题。
但迁移到其他数据库时,就需要特别关注目标数据库对空字符串的处理方式。
例如,Oracle 会将零长度字符串视为 NULL。KingbaseES 在特定兼容模式下也可能提供类似的处理机制。
如果迁移后的环境将空字符串转换为 NULL,那么原来依赖:
SQL
WHERE phone = ''
的查询,就可能出现结果变化。
这类问题不会一定表现为 SQL 报错。
更常见的情况是,SQL 正常执行,但原来能查到的记录现在查不到了。
怎么检查?
我一般会先在源库和目标库分别执行:
SQL
SELECT COUNT(*)
FROM user_info
WHERE phone IS NULL;
以及:
SQL
SELECT COUNT(*)
FROM user_info
WHERE phone = '';
确认两边的数据分布是否一致。
如果业务确实需要区分空字符串和 NULL,还需要检查金仓当前版本、兼容模式及相关参数配置。
不要直接照搬网上的参数修改命令。
先确认当前数据库实际采用什么规则,再决定是否需要调整配置或者修改业务 SQL。
二、GROUP BY 能执行,不代表取出来的值就是你想要的
这个问题在老 MySQL 项目里比较常见。
例如:
SQL
SELECT
user_id,
user_name,
COUNT(*)
FROM orders
GROUP BY user_id;
这条 SQL 想统计每个用户的订单数量,同时返回用户名。
看起来很正常。
但这里有个问题:
user_name 没有出现在 GROUP BY 中,也没有使用聚合函数。
如果数据库不能确定每个 user_id 对应唯一的 user_name,那么这个字段到底应该取哪一条记录?
在 MySQL 关闭 ONLY_FULL_GROUP_BY 的情况下,这类 SQL 可能被允许执行,但非聚合字段的取值并不一定符合业务预期。
而在启用了严格分组检查的环境中,数据库可能直接拒绝执行。
需要注意,MySQL 5.7.5 及之后版本默认启用 ONLY_FULL_GROUP_BY,并且能够识别部分函数依赖关系,因此不能简单认为所有 MySQL 环境都会允许这类写法。
迁移到金仓时,如果原来的 SQL 依赖宽松分组行为,就需要重新检查。
应该怎么改?
如果用户名本来就在用户表中,可以先完成订单聚合,再关联用户表。
SQL
SELECT
t.user_id,
u.user_name,
t.order_count
FROM (
SELECT
user_id,
COUNT(*) AS order_count
FROM orders
GROUP BY user_id
) t
LEFT JOIN users u
ON t.user_id = u.id;
这样写的好处是业务含义比较明确。
订单数量从订单表统计,用户名从用户表获取。
当然,前提是 users.id 能唯一确定用户记录。
如果原来的 SQL 是为了获取每个用户最新一笔订单对应的用户名,就应该使用明确的排序和取值规则,而不是依赖分组时数据库碰巧返回的某一行。
迁移时遇到这类 SQL,不要只想着怎么让它不报错,还要确认原来的业务到底想取什么值。
三、隐式类型转换,最容易把性能问题带出来
再说一个开发时很容易忽略的问题。
假设订单表里有一个字段:
SQL
order_no VARCHAR(64)
正常查询应该写成:
SQL
SELECT *
FROM orders
WHERE order_no = '10001';
但如果应用层传参时没有处理好类型,实际执行的 SQL 可能变成:
SQL
SELECT *
FROM orders
WHERE order_no = 10001;
字段是字符串,参数却是数字。
在 MySQL 中,字符串和数字比较时可能发生隐式类型转换。
如果数据库需要对字符串列进行数值转换,原来的字符串索引就可能无法用于普通的索引查找。
而且还有一个更麻烦的问题。
例如:
'10001'
'010001'
这两个字符串不同。
但在某些数值转换规则下,它们可能被转换成相同的数字。
这时候就不只是性能问题了,还可能影响查询结果。
迁移到金仓以后,具体转换行为还要看兼容模式和数据类型规则。
不同数据库对隐式转换的处理不一定相同。
所以这类 SQL 我一般不会直接依赖数据库兼容机制,而是先把应用层参数类型改正确。
例如 MyBatis 中:
XML
<select id="selectByOrderNo" resultType="Order">
SELECT *
FROM orders
WHERE order_no = #{orderNo,jdbcType=VARCHAR}
</select>
同时确保 Java 代码里的 orderNo 使用 String 类型。
如果字段本身是 BIGINT,那就按照数值类型传参。
这样至少可以避免一部分不必要的隐式转换。
还有一点需要注意
原文里提到“执行计划从嵌套循环变成哈希连接,导致查询结果错误”。
这个结论不太准确。
正常情况下,优化器选择不同的执行计划,应该只影响 SQL 的执行方式和性能,不应该改变符合 SQL 语义的查询结果。
如果出现结果差异,需要继续检查是否存在未定义的排序顺序、隐式转换、非确定性函数、数据版本差异或数据库实现问题。
不要直接把结果错误归因于 Hash Join。
四、分页查询没有唯一排序,迁移时特别容易暴露问题
这个坑我觉得值得单独拿出来说。
很多业务系统的分页查询都是这么写的:
SQL
SELECT *
FROM orders
ORDER BY create_time DESC
LIMIT 20 OFFSET 0;
第二页:
SQL
SELECT *
FROM orders
ORDER BY create_time DESC
LIMIT 20 OFFSET 20;
看起来没有问题。
但如果很多订单的 create_time 完全相同呢?
例如:
id create_time
101 2026-09-01 10:00:00
102 2026-09-01 10:00:00
103 2026-09-01 10:00:00
104 2026-09-01 10:00:00
只按照 create_time 排序,数据库并没有义务保证这几条记录之间的固定顺序。
MySQL 可能碰巧按照某种索引顺序返回。
换到金仓以后,由于执行计划、索引组织方式不同,返回顺序就可能发生变化。
甚至不换数据库,只要 MySQL 的执行计划发生变化,也可能出现类似问题。
如果两次分页查询的排序结果不一致,就可能导致某条记录在第一页出现过,第二页又出现一次。
或者某条记录刚好被两个页面跳过去。
业务方看到的现象就是:
“为什么有些订单查不到了?”
但数据库里其实并没有丢数据。
解决方式
给排序条件增加唯一键。
SQL
SELECT *
FROM orders
ORDER BY create_time DESC, id DESC
LIMIT 20 OFFSET 0;
第二页:
SQL
SELECT *
FROM orders
ORDER BY create_time DESC, id DESC
LIMIT 20 OFFSET 20;
这样对于同一份数据快照,排序结果就是确定的。
不过,如果分页期间有新订单插入或者原有订单被修改,OFFSET 分页仍然可能出现重复或遗漏。
对于数据量比较大、持续写入的订单表,还可以考虑基于游标的分页方式。
例如:
SQL
SELECT *
FROM orders
WHERE create_time < :lastCreateTime
OR (
create_time = :lastCreateTime
AND id < :lastId
)
ORDER BY create_time DESC, id DESC
LIMIT 20;
这里的 lastCreateTime 和 lastId 是上一页最后一条记录的排序值。
相比深度 OFFSET 分页,这种方式通常更适合持续增长的数据表。
但它也需要结合索引、数据更新方式和业务一致性要求来设计。
五、日期字段也不能只看类型名称
MySQL 和金仓都有日期时间类型。
但字段名称相似,不代表所有行为都一样。
例如 MySQL 的:
SQL
DATETIME
可以指定小数秒精度。
SQL
DATETIME(3)
DATETIME(6)
分别可以保存毫秒和微秒级的小数秒。
迁移的时候,需要确认目标字段的精度是否一致。
如果源库保存的是:
2026-09-01 10:20:30.123456
但目标字段只保留到秒,那么迁移后的数据就可能变成:
2026-09-01 10:20:30
对于普通后台管理系统,可能影响不大。
但如果业务依赖时间戳做增量同步、排序或者数据去重,就可能出问题。
另外,还需要区分 MySQL 的 DATETIME 和 TIMESTAMP。
它们在时区处理方面存在差异。
如果原来的系统依赖数据库会话时区,迁移后就需要确认应用连接、数据库会话和字段类型之间的关系。
我一般会重点检查:
字段类型
时间精度
数据库会话时区
JDBC 参数
Java 时间类型
尤其 Java 代码里如果混用了 Date、LocalDateTime、Timestamp,迁移时更应该把时间转换逻辑检查一遍。
六、还有一个经常被漏掉的地方:字段默认值
除了 SQL 查询,建表语句也值得检查。
例如 MySQL 中:
SQL
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
status VARCHAR(32) DEFAULT 'PENDING',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
);
迁移到金仓以后,需要确认字段默认值和自动生成行为是否与原来一致。
特别是:
SQL
DEFAULT CURRENT_TIMESTAMP
以及自增主键、表达式默认值、日期字段的自动更新行为。
有些问题在全量数据迁移时不会暴露。
因为迁移工具通常会把已有数据直接写入目标库。
但等到应用程序开始向新数据库插入数据时,默认值差异才会出现。
例如原来 MySQL 中某个字段有自动更新行为,迁移后没有保留相应机制。
历史数据全部正常,新产生的数据却开始不一致。
所以我一般会把“存量数据校验”和“新增数据行为校验”分开做。
不能只验证历史数据一致,就认为数据库迁移完成了。
七、迁移前,我现在会额外做一轮 SQL 结果比对
以前做数据库迁移,比较关注:
对象能不能转换
数据能不能同步
SQL 能不能执行
现在会再加一项:
相同输入条件下,源库和目标库返回的业务结果是否一致。
尤其是下面几类 SQL:
| 检查对象
|
重点关注
| | --- | --- | |
空值查询
|
NULL、空字符串及排序行为
| |
GROUP BY
|
非聚合字段、分组结果
| |
条件查询
|
隐式类型转换、字符串比较
| |
分页查询
|
排序唯一性、重复或遗漏
| |
日期查询
|
时间精度、时区、边界条件
| |
数据写入
|
默认值、自增列、自动更新时间
|
实际执行时,我通常会先从生产环境的 SQL 日志或者慢查询记录中整理出核心 SQL。
然后准备一份固定的测试数据,在 MySQL 和金仓环境中分别执行。
对比时不能简单地把两边返回的 JSON 字符串直接比较。
需要先明确哪些字段必须完全一致,哪些字段允许存在格式差异。
例如时间字段,如果两边只是输出格式不同,但实际表示的是同一个时间点,就不应该直接判定业务结果错误。
而订单金额、状态、数量、主键这些核心字段,则需要严格核对。
如果涉及金额计算,还要检查小数精度和舍入规则。
对于没有明确排序要求的查询,也不能把两边返回的行顺序不同直接当成数据错误。
这些细节提前处理好,后面的比对报告才有参考价值。
最后说几句
这次 MySQL 迁移金仓的项目,让我印象比较深的不是某一条 SQL 有多难改。
而是有些 SQL 明明执行成功了,业务结果却和原来不一样。
这种问题往往比直接报语法错误更费时间。
因为你需要先判断是数据迁移出了问题,还是 SQL 行为发生了变化,再进一步排查字段类型、排序规则、执行计划和应用层参数。
所以我现在做数据库迁移,除了兼容性评估,还会专门安排一轮结果比对。
尤其是订单、金额、库存、结算这些核心业务,不能只靠“SQL 执行成功”来判断迁移是否完成。
语法兼容解决的是代码能不能运行,结果比对解决的是业务还能不能按原来的方式运行。
这两件事都确认了,数据库迁移才算真正有了上线的基础。