最近刚协助团队完成了一个从MySQL到人大金仓数据库的迁移项目,迁移过程中SQL语法差异是第一个绕不开的坎。你可能觉得不都是SQL吗,能有多大差别?真上手之后才发现,一个分页写法、一个空值判断函数,就让应用报错半天。这篇文章是这次迁移踩坑的记录,也是目前我见过最完整的MySQL与人大金仓SQL语法差异整理,适合正在做数据库国产化改造的开发、DBA、运维同学直接对照参考。
在开始之前先交代背景。我们手里的系统是Java + MySQL 8.0,SQL数量接近2000条,包含大量存储过程、触发器、复杂的统计报表SQL。迁移目标是大金仓(KingbaseES),团队里没有任何人是金仓的使用经验。整个迁移预算时间就一个月,一边要保证线上业务不停,一边要完成技术栈切换。前一周几乎都在跟语法差异较劲,后面才逐渐摸到规律。这篇文章就把这些规律和具体差异点全部列出来,每个差异点都附上代码对照,你可以直接把它当迁移手册用。
1. 为什么要做这份SQL差异对比
1.1 一个真实的迁移场景
我们接到任务时,首先面对的是存量系统改造。也就是说,系统已经用MySQL稳定运行了好几年,现在底层的数据库要换成人大会仓,应用代码不能大动,最理想的情况是把数据库驱动换掉、连接串改掉,业务逻辑层尽量保持不动。但现实是,任何一个用到了MySQL方言的SQL,到了金仓上都有可能变成一条语法错误。
举个最简单的例子,应用里非常常见的分页查询:
-- MySQL 写法 SELECT * FROM t_order ORDER BY create_time DESC LIMIT 20, 10;这条语句在金仓里直接报语法错误,金仓要求LIMIT和OFFSET的顺序反过来:
-- KingbaseES 写法 SELECT * FROM t_order ORDER BY create_time DESC LIMIT 10 OFFSET 20;类似这种“看着一样、用着不一样”的地方,加起来有一箩筐。如果项目里恰好是用了MyBatis并手写大量SQL,那这里的每一项差异都可能变成生产环境的一个BUG。
那有没有可能直接用ORM框架自动适配?分情况。如果用的是Spring Data JPA、Hibernate这类高度封装的框架,方言已经帮你处理了大部分差异;如果用的是MyBatis这种半自动框架,SQL写死了MySQL语法,那无论如何都要改一遍。我们项目就是后者,所以才有必要把差异点整理成一份文档,分发给所有开发,统一按规范改。
1.2 理解金仓内核有助于预判语法行为
先说一个判断语法差异的底层技巧。人大金仓数据库(KingbaseES)的内核实际上衍生自PostgreSQL,所以它在SQL行为上大量保持了PostgreSQL的习惯,同时还提供了一套Oracle兼容模式。这意味着,当你不确定某个写法在金仓上能不能用时,可以先按PostgreSQL的语法去试,大概率是可行的。
比如金仓中常见的字符串聚合函数STRING_AGG,就是PostgreSQL生态里的标准函数;再比如正则表达式运算符~,也是PostgreSQL的用法。这和MySQL的语法体系完全是两条路线,MySQL更接近早期的SQL标准加自己的扩展,金仓则是在PostgreSQL基础上做增强。
这样理解之后,我们做迁移时就有了一条判断原则:遇到不确定的SQL,先把它往PostgreSQL的标准写法上靠;如果涉及存储过程、包、触发器这类比较复杂的对象,再去看金仓的Oracle兼容模式。下文讲到的差异点,绝大多数也遵循这个规律。
2. 数据类型与建表语句的差异对比
2.1 整数类型与自增列的处理
MySQL里常见的小整数类型比较多,比如TINYINT、MEDIUMINT、INT、BIGINT,每个的存储范围不一样。但这几种类型在金仓里并不是都有对应项,尤其是TINYINT和MEDIUMINT,在金仓里根本没有这种类型。实际迁移时,我们统一把TINYINT替换成了SMALLINT,把MEDIUMINT替换成了INTEGER。
这里有个隐藏的坑:MySQL的TINYINT(1)经常被用来存储布尔值,但金仓里有专门的原生BOOLEAN类型。从TINYINT(1)迁到BOOLEAN之后,应用查询返回的结果从0/1变成了true/false,Java后端如果直接用Integer去接结果就会出问题。我们的处理方案是,应用代码里凡是接收状态字段的地方都改成Boolean,或者干脆不转BOOLEAN类型,继续使用SMALLINT,减少改动面。
自增列是另一个大坑。MySQL里简单写id INT AUTO_INCREMENT就可以,金仓里则有两种主流写法:
-- KingbaseES 写法一:SERIAL CREATE TABLE t_user ( id SERIAL PRIMARY KEY, name VARCHAR(64) ); -- KingbaseES 写法二:IDENTITY(推荐) CREATE TABLE t_user ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(64) );SERIAL是PostgreSQL的老写法,本质上会自动创建一个序列(sequence),列默认值取序列的下一个值。IDENTITY是SQL标准写法,金仓支持得也很好。我建议新开发表都用IDENTITY,因为语义更清晰。
还有一个必须提醒的点:迁移数据后序列的当前值和表里的主键最大值往往是对不上的。如果你用SERIAL的方式建表,数据从MySQL导入后没有手工同步序列值,接下来插入新数据就会报主键冲突。需要在导完数据后执行一次类似这样的操作:
SELECT setval('t_user_id_seq', (SELECT MAX(id) FROM t_user));这个问题在MySQL中不存在,因为AUTO_INCREMENT会自动跟着当前最大值走。在金仓里,序列是独立对象,不会自动感知数据变化,所以这也是迁移清单里必须核对的环节。
2.2 字符串、日期与布尔类型差异
字符串类型方面,MySQL里的VARCHAR、CHAR、TEXT在金仓中都有对应。有一个小差异值得注意:MySQL的TEXT类型在建表时不能设置默认值,但金仓的TEXT是可以带默认值的。所以如果从MySQL迁移过来遇到了“TEXT字段有默认值”这种情况,在MySQL那边是建不出来这样的表,但在金仓里可以正常执行。
日期时间类型是重灾区。MySQL常用DATETIME、TIMESTAMP、DATE,金仓里则常见TIMESTAMP、TIME、DATE。MySQL的DATETIME没有时区概念,而TIMESTAMP会随数据库时区转换;金仓的TIMESTAMP也有WITH TIME ZONE和WITHOUT TIME ZONE的区别,如果不指定,默认是没有时区的TIMESTAMP。
实际迁移中我们遇到一个比较典型的场景:MySQL里的datetime字段存储的是本地时间,到了金仓后如果谁不小心定义成了TIMESTAMP WITH TIME ZONE,然后数据库会话时区又和业务时区不一致,查出来的时间就会偏移几个小时。处理这类问题时,建议统一用TIMESTAMP WITHOUT TIME ZONE,应用层自己控制时区。
日期函数这块差异更大。MySQL里把日期格式化成字符串喜欢用DATE_FORMAT,到了金仓要改用TO_CHAR:
-- MySQL SELECT DATE_FORMAT(create_time, '%Y-%m-%d %H:%i:%s') FROM t_order; -- KingbaseES SELECT TO_CHAR(create_time, 'YYYY-MM-DD HH24:MI:SS') FROM t_order;注意两个格式符体系完全不一样:MySQL的%Y对应金仓的YYYY,MySQL的%H:%i:%s对应金仓的HH24:MI:SS。手工转换的时候很容易漏掉,建议全局搜索DATE_FORMAT、STR_TO_DATE、NOW等关键字逐个核对。
布尔类型前面提到过,金仓原生支持BOOLEAN,可以直接存true/false。MySQL没有原生BOOL,用TINYINT(1)代替。如果应用有返回布尔值的查询,建议都要检查一下接收类型。
2.3 一张建表语句的对照实例
把上面的差异综合一下,我直接做一个建表语句对照。左边是原MySQL表的写法,右边是迁移到金仓后的等效写法:
-- MySQL 原始写法 CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', name VARCHAR(64) NOT NULL COMMENT '姓名', age TINYINT DEFAULT 0, status TINYINT(1) DEFAULT 1 COMMENT '状态', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户表';-- KingbaseES 调整后 CREATE TABLE t_user ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name VARCHAR(64) NOT NULL, age SMALLINT DEFAULT 0, status SMALLINT DEFAULT 1, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); COMMENT ON TABLE t_user IS '用户表'; COMMENT ON COLUMN t_user.id IS '主键ID'; COMMENT ON COLUMN t_user.name IS '姓名'; COMMENT ON COLUMN t_user.status IS '状态';从这张表能看出几个关键改动:
- 去掉了ENGINE、CHARSET、COLLATE这些MySQL特有的存储引擎和字符集定义。
- 表注释和列注释不再写在字段定义里,而是拆成COMMENT ON TABLE和COMMENT ON COLUMN语句。
- TINYINT改成了SMALLINT,TINYINT(1)也先保守地用SMALLINT,而不是转成BOOLEAN。
ON UPDATE CURRENT_TIMESTAMP这个MySQL的特性,金仓不支持。如果确实需要更新时自动维护时间,得用触发器实现。
触发器的写法差异,后面会专门讲,这里先不展开。但建表阶段的取舍很重要,它决定了后续数据迁进来之后,应用能不能正常运行。
3. 查询与修改语句的语法差异
3.1 分页、排序与空值行为
分页前面已经举例了,MySQL的LIMIT offset, count和金仓的LIMIT count OFFSET offset是两个方向。这里多提一句,很多持久层框架会把分页参数拼接进SQL,如果你在MyBatis里自己写了limit #{offset}, #{pageSize}这种写法,迁移后必须改掉。
排序的空值行为也是一个容易踩的差异点。MySQL默认空值在升序排列时排在最前面,金仓则相反,空值默认排在最后面。假设你有一张订单表,需要对退款时间refund_time做升序排列,MySQL的结果是所有未退款订单(NULL)排在最上面;换到金仓后,所有已退款订单排上面,NULL排最后。如果业务上没有明说,这种顺序变化不会有人注意到,但产品经理如果拿数据对比时发现顺序不对,就会找过来排查。
解决办法是显式指定空值位置:
-- 升序,且NULL排前面 SELECT * FROM t_order ORDER BY refund_time ASC NULLS FIRST; -- 升序,且NULL排最后(金仓默认) SELECT * FROM t_order ORDER BY refund_time ASC NULLS LAST;把NULLS FIRST / NULLS LAST写清楚,行为就不会因为数据库不一样而变化。
还有一个字符串比较的差异:MySQL在部分排序规则下会忽略字符串尾部的空格,比如'abc' = 'abc '是成立的;金仓默认行为下,这两个字符串不相等。如果业务有用固定长度字符做比对或去重的逻辑,迁移后可能会查出和以前不一样的结果。我们当时就有一张用户表,某些字段因为历史原因填充了尾部空格,MySQL查出来的数据和金仓查出来的对不上,排查半天才定位到是空白符比较差异。
3.2 常用函数替换对照表
函数差异是SQL改写投入最大的部分。我把这次迁移中遇到的所有常用函数差异整理成了对照表,按这个表改就能覆盖大部分场景:
| 功能 | MySQL | KingbaseES | 补充说明 |
|---|---|---|---|
| 空值替换 | IFNULL(a, 0) | COALESCE(a, 0) | COALESCE是标准SQL,支持多个参数 |
| 条件判断 | IF(a > b, a, b) | CASE WHEN a > b THEN a ELSE b END | 也可以用金仓的IIF,但CASE最通用 |
| 日期格式化 | DATE_FORMAT(d, '%Y-%m-%d') | TO_CHAR(d, 'YYYY-MM-DD') | 格式符完全不同,需要逐个转换 |
| 字符串转日期 | STR_TO_DATE(s, '%Y-%m-%d') | TO_DATE(s, 'YYYY-MM-DD') | 格式符体系和上面一样 |
| 字符串截取 | SUBSTRING_INDEX(s, '.', 1) | SPLIT_PART(s, '.', 1) | SPLIT_PART是金仓里的常用函数 |
| 分组拼接 | GROUP_CONCAT(x) | STRING_AGG(x, ',') | 也可用LISTAGG,但STRING_AGG更稳 |
| 字符串连接 | CONCAT(a, b, c) | CONCAT(a, b, c) 或 a || b | 注意Oracle模式下||对NULL的处理 |
| 正则匹配 | s REGEXP 'pattern' | s ~ 'pattern' | ~*表示不区分大小写匹配 |
| 获取当前时间 | NOW() | NOW() 或 CURRENT_TIMESTAMP | 两边都通用,问题不大 |
| 取日期部分 | DATE(d) | CAST(d AS DATE) 或 d::DATE | 金仓也支持DATE(d),POSIX风格不一定都兼容 |
我在实际改写时有一个习惯:先全局搜索IFNULL,IF(,DATE_FORMAT,GROUP_CONCAT,REGEXP这些MySQL特征明显的函数,然后逐个替换成上面的标准写法。搜索关键字替换一遍之后,再跑一遍应用的测试用例,能过滤掉大部分兼容性问题。
这里单独说一下GROUP_CONCAT。MySQL的GROUP_CONCAT(x ORDER BY y SEPARATOR ',')写法,在金仓里对应的是STRING_AGG(x, ',' ORDER BY y)。顺序和分隔符的位置都不一样,转换时容易出错。
-- MySQL SELECT dept_id, GROUP_CONCAT(user_name ORDER BY user_id SEPARATOR ',') FROM t_user GROUP BY dept_id; -- KingbaseES SELECT dept_id, STRING_AGG(user_name, ',' ORDER BY user_id) FROM t_user GROUP BY dept_id;如果你在建表时用的是金仓的Oracle兼容模式,也可以使用Oracle风格的LISTAGG(x, ',') WITHIN GROUP (ORDER BY y)。但为了团队好维护,我建议整篇代码统一用一种风格,不要混用。
3.3 插入冲突与覆盖写法的差异
MySQL里有一个非常常用的语法INSERT ... ON DUPLICATE KEY UPDATE,用于在记录已存在时执行更新。这个写法金仓不支持,需要改写成PostgreSQL风格的INSERT ... ON CONFLICT:
-- MySQL INSERT INTO t_user (id, name, age) VALUES (1, '张三', 20) ON DUPLICATE KEY UPDATE age = VALUES(age); -- KingbaseES INSERT INTO t_user (id, name, age) VALUES (1, '张三', 20) ON CONFLICT (id) DO UPDATE SET age = EXCLUDED.age;两个细节要注意。第一,ON CONFLICT后面必须指定冲突列,这个列上要有唯一索引或主键约束,否则没法判断冲突。第二,EXCLUDED.age表示本次插入想写入的那一行数据,这对应MySQL侧的VALUES(age)。很多人在这里会写错,直接写成t_user.age,结果更新被旧值覆盖,数据一直没有变化,排查起来很隐蔽。
MySQL的REPLACE INTO也是特征明显的语法。它的逻辑是先尝试插入,如果主键或唯一键冲突就先删除旧记录再插入新记录。金仓里没有对应的REPLACE INTO,我们统一改写成INSERT ... ON CONFLICT的形式。
还有一个容易被忽视的点:MySQL默认UPDATE语句如果影响行数为0,不会报错;而金仓的扩展语法UPDATE ... FROM在某些写法下可能因为连接条件产生多条更新,实际更新行数会超出预期。这类问题不会直接报错,但会导致数据不一致,建议在做数据比对时把更新类SQL也纳入核对范围。
4. 存储过程、触发器与事务行为差异
4.1 存储过程的声明和结构差异
存储过程是迁移中最费时间的一块,因为MySQL的存储过程语法和金仓完全不是一个体系。MySQL里用DELIMITER改变语句分隔符,用BEGIN...END包裹过程体;金仓默认使用PL/pgSQL风格,过程体放在AS $$ ... $$之间,也可以使用Oracle兼容模式下的IS ... BEGIN ... END写法。
看一个最简单的例子:
-- MySQL 存储过程 DELIMITER $$ CREATE PROCEDURE sp_get_user_count(OUT cnt INT) BEGIN SELECT COUNT(*) INTO cnt FROM t_user; END$$ DELIMITER ;-- KingbaseES 存储过程 CREATE OR REPLACE PROCEDURE sp_get_user_count(OUT cnt INT) AS $$ BEGIN SELECT COUNT(*) INTO cnt FROM t_user; END; $$ LANGUAGE plpgsql;这里的差异点包括:
- MySQL里声明参数和变量直接用
DECLARE;金仓的参数直接写在括号里,变量在DECLARE段声明,而且金仓的变量赋值用:=,不能用SET。 - MySQL的存储过程体里每条SQL都以分号结尾,但整个过程用
DELIMITER避免冲突;金仓用$$包裹过程体,不需要改分隔符。 - 金仓存储过程末尾最好显式指定
LANGUAGE plpgsql,避免某些环境下默认语言解析出错。
如果组里还有人把存储过程写成Oracle风格,比如用CREATE OR REPLACE PROCEDURE ... IS ... BEGIN ... END;,那对金仓来说需要处于Oracle兼容模式下才能跑通。所以这里必须有一个统一约定:要么全用PL/pgSQL风格,要么全用Oracle风格,混写会非常痛苦。
4.2 游标与异常处理
游标这一块,MySQL的经典写法是DECLARE + OPEN + FETCH + CLOSE,金仓里当然也支持这种写法,但更简洁的方式是用FOR循环直接遍历查询结果。
-- MySQL 游标 DECLARE cur CURSOR FOR SELECT id FROM t_user; OPEN cur; FETCH cur INTO v_id; WHILE done = 0 DO -- 业务处理 FETCH cur INTO v_id; END WHILE; CLOSE cur;-- KingbaseES 游标 FOR v_id IN SELECT id FROM t_user LOOP -- 业务处理 END LOOP;用FOR循环的方式,金仓会自动打开、遍历并关闭游标,代码量一下少了一半。当然,PL/pgSQL里也有OPEN/FETCH/CLOSE的显式游标写法,但既然能用简洁的方式为什么不用。
异常处理差异也很典型。MySQL里写异常处理是通过DECLARE...HANDLER,比如DECLARE CONTINUE HANDLER FOR SQLEXCEPTION;金仓则在BEGIN...EXCEPTION...END块里处理:
-- KingbaseES 异常处理 BEGIN -- 业务代码 EXCEPTION WHEN others THEN -- 异常处理 RAISE NOTICE 'error occurred'; END;金仓的异常类型比MySQL丰富,比如WHEN unique_violation、WHEN foreign_key_violation等。如果原来的存储过程里只笼统地捕获SQLEXCEPTION,迁移后可以按业务需求拆分成更具体的异常分支,这属于迁移过程中的顺手优化。
4.3 事务与DDL回滚行为
事务行为上,MySQL有个特点:DDL语句会隐式提交当前事务。也就是说,你在一个事务里执行了UPDATE、再执行ALTER TABLE,这个UPDATE就被自动提交了,后面想ROLLBACK整体回滚是做不到的。金仓则不同,它支持事务内的DDL回滚,至少断开连接前DDL可以作为事务的一部分回滚。
这对应用意味着什么?如果有代码依赖“DDL之后前面的操作不可回滚”这种MySQL特性,迁移后行为会不一样。不过大多数业务系统不会刻意利用这一点,了解即可。
另外一个实际影响比较大的点是:金仓的DDL事务性会让一些迁移脚本的行为发生变化。比如你在一个事务里先建表、再插数据、再回滚,MySQL里建表语句已经悄悄提交了,表会留下来;金仓里整个事务回滚,表也不会存在。所以用脚本批量执行迁移时,务必明确脚本是否会执行COMMIT,否则很容易出现“数据没进去、表也没了”的困惑。
5. 从MySQL迁移到金仓的实操流程
5.1 迁移工具选型
我们在迁移前先调研了工具,发现金仓本身提供了官方的数据迁移工具,叫做KDTS(Kingbase Data Transfer Studio),支持从MySQL、Oracle等多种数据库迁移到金仓。这个工具可以连源库抽表结构、抽数据、生成目标库的对象定义,还能做一部分SQL语法转换。但这里要提醒一句:工具能解决的只是“机械转换”,业务逻辑里复杂的SQL改写,还是得靠人。
除了金仓官方工具,通用的ETL工具如Kettle、DataX也能做数据同步。我们的主迁移路径用的是官方工具处理表结构,用DataX做大数据量的数据搬迁,最后再手工处理视图、存储过程、触发器。
表格可以对比一下:
| 工具 | 适用阶段 | 优点 | 注意点 |
|---|---|---|---|
| KDTS | 表结构、数据、部分对象转换 | 官方支持、图形化 | 复杂存储过程转换成功率不高 |
| DataX | 数据全量/增量同步 | 稳定、支持断点续传 | 需要额外部署执行环境 |
| Kettle | 数据同步、清洗 | 灵活 | 大批量性能一般 |
| 手工SQL脚本 | 视图、函数、触发器 | 可控 | 耗时,依赖人员熟悉度 |
工具一定选带联机评估的,让工具先跑一次评估报告,它能列出哪些对象能转、哪些不能转。这份报告就是你后续安排人力的依据。
5.2 分阶段迁移步骤
我们的迁移步骤拆成五个阶段,每个阶段都有独立验收标准:
第一阶段是评估。先收集源库所有对象的清单,表、字段、索引、主外键、视图、函数、存储过程、触发器,甚至包括定时事件。把对象清单和依赖关系画清楚,明确哪些可以自动转换,哪些必须手工处理。这个阶段输出的就是迁移评估报告。
第二阶段是表结构迁移。用KDTS自动生成建表语句,然后按前面说的差异点逐一核对,重点检查自增列、默认值、注释、时间类型。表结构确认无误后,先建到目标金仓库,这时候不急着导数据。
第三阶段是数据迁移。全量数据用DataX先导一遍,导完分别统计源库和目标库的表行数做比对。如果是停机迁移,就全量一次性搬;如果要求不停机,就得做增量同步,源库MySQL开binlog,用同步组件把增量数据继续同步到金仓。我们这次为了控制风险采用的是夜间停机窗口,全量搬迁完之后,第二天业务直接切到金仓。
第四阶段是对象脚本迁移。把视图、存储过程、触发器、函数这些从MySQL的SHOW CREATE VIEW或者mysqldump导出来,先经过人工改写,再到目标库执行。这个阶段最容易出现语法错误,我建议每改写一个就单独执行测试,不要攒一堆一起执行,否则报错时都不知道是哪一个的问题。
第五阶段是功能验证。对应用做全量接口回归,核心接口最好有自动化测试。同时做一次源库和目标库的数据一致性比对,重点看几个容易出问题的字段,比如时间类型、布尔类型、浮点类型。发现问题回到对应环节修复,直到所有用例通过。
5.3 应用代码层的适配重点
数据库层迁移完成后,应用代码也要跟着改,这部分容易被低估。我们的经验是重点关注这几个地方:
连接配置:驱动从com.mysql.cj.jdbc.Driver换成金仓的驱动,连接URL协议变化,这个属于基础操作。
MyBatis里的方言SQL:所有手写SQL都要过一遍,重点是分页、函数、自增主键回填。MyBatis的useGeneratedKeys="true"和keyProperty="id"在MySQL下可以用,到了金仓如果走的是SERIAL或IDENTITY,用selectKey方式回填主键更稳妥:
<insert id="insertUser"> <selectKey resultType="java.lang.Integer" order="BEFORE" keyProperty="id"> SELECT nextval('t_user_id_seq') </selectKey> INSERT INTO t_user (id, name, age) VALUES (#{id}, #{name}, #{age}) </insert>这个写法对序列的依赖很强,所以前面提到的序列值初始化,必须在应用切换前完成,否则这里就会拿到重复的ID。
SQL规范化:给团队定一个开发规范,新写的SQL尽量避免使用数据库特有函数,能用标准SQL就写标准SQL;表名、字段名统一用小写,避免双引号;不要依赖MySQL的隐式类型转换特性。我们团队在迁移后定了一条规矩:凡是在代码里出现的SQL,必须同时能在MySQL和金仓上跑通才允许提交。
6. 迁移中常见问题与排查技巧实录
6.1 高频报错速查表
迁移过程中遇到十几个报错,我把最高频的整理成了一张表,方便你排查时直接对照:
| 错误现象 | 根本原因 | 解决方案 |
|---|---|---|
| LIMIT附近有语法错误 | MySQL分页顺序和金仓不一致 | 改成LIMIT count OFFSET offset |
| relation "t_user" does not exist | 大小写问题 | 统一小写表名,避免双引号 |
| function date_format does not exist | 函数不兼容 | 替换成TO_CHAR并调整格式符 |
| column "xxx" must appear in GROUP BY | MySQL宽松分组和金仓严格分组不同 | 用DISTINCT ON或改写分组逻辑 |
| INSERT has more expressions than target columns | 自增列默认值冲突 | 插入时排除自增列或用DEFAULT |
| invalid input syntax for type timestamp | 时间字符串格式差异 | 用TO_TIMESTAMP转换后再插入 |
| feature not supported in this mode | 用了Oracle模式不支持的特性 | 切换兼容模式或改写语法 |
这些报错里最坑的是GROUP BY相关错误。MySQL默认的ONLY_FULL_GROUP_BY是关闭状态(5.7之后默认开启,但很多老项目还开着宽松模式),允许select列不包含在group by里。金仓严格按照SQL标准,select列必须出现在group by中或聚合函数里。解决办法是改写SQL,把不需要分组的列用聚合函数包起来,或者调整查询逻辑。
6.2 大小写和字符集引发的诡异问题
大小写问题比想象中更隐蔽。MySQL在Linux下表名区分大小写,在Windows下不区分;金仓默认对表名、字段名忽略大小写,但如果你给对象名加了双引号,它就变成一个区分大小写的标识符。举个例子,开发在MySQL里写了一句SELECT * FROM T_USER,MySQL在Windows环境不区分大小写,能正常查出来;到了金仓,如果T_USER被双引号包住,它就会去精确找大写名称的表,结果relation不存在。
我们的建议是:全项目SQL统一用小写表名,并且不要加双引号。如果是从代码里动态拼接表名的,检查拼接逻辑里有没有硬转大写的动作。
字符集问题则集中在中文乱码上。金仓建库时默认字符集一般是UTF8,如果连接串里没有指定字符集,或者数据库本身的字符集是GBK之类,导进去的中文就会乱。连接串里建议显式加上字符集参数,比如?characterEncoding=UTF-8(具体参数名取决于驱动版本)。同时,批量导入时设置客户端的client_encoding和数据库实际编码保持一致。
这是我们实际遇到的一个案例:某张表有备注字段,源库是utf8mb4,目标库金仓创建时用默认编码没问题,但DataX同步时没有指定编码,结果一列文本全部变成了问号。后来在DataX的jdbcUrl里加上useUnicode=true&characterEncoding=utf8,重新同步一次才解决。
6.3 执行计划与统计信息
迁移数据之后,还有一个经常被忽略的问题:统计信息没有更新。MySQL在导入大量数据后InnoDB会逐步更新统计信息,金仓依赖优化器的统计信息做执行计划。如果数据导入后从没执行过ANALYZE,优化器可能还在用空表的统计信息,导致生成的执行计划很差,甚至出现全表扫描。
我处理慢SQL的步骤是先用EXPLAIN看执行计划,再用ANALYZE更新统计信息,最后再看一次执行计划。金仓的EXPLAIN输出和MySQL差别很大,MySQL里有type、key、rows这些指标,金仓里则是Seq Scan、Index Scan、cost=...这些。如果你看到一条SQL走了Seq Scan,而表数据量又不小,大概率就是缺索引或者统计信息不准。
EXPLAIN ANALYZE SELECT * FROM t_order WHERE user_id = 100;返回结果里如果出现Seq Scan on t_order,说明没有走索引。先检查有没有建索引,再执行ANALYZE t_order;,然后再看执行计划。这一步对迁移后的稳定性能提升非常关键。
另外,金仓里表数据量大的时候,要考虑刷新统计信息的频率。我们有几张千万级流水表,在导完数据后立刻执行全表ANALYZE加索引重建,执行计划才恢复正常。这点务必写进迁移验收清单。
结尾
这次迁移让我最深刻的体会是:SQL语法差异看起来是细节问题,但恰恰是这些细节决定了整个迁移是否顺利。与其等测试阶段被各种报错淹没,不如在迁移前就把差异清单发给团队,让每个开发改SQL时都有据可查。上面整理的这些MySQL与人大金仓差异点,基本覆盖了我们在这次迁移中遇到的所有高频问题,你现在做迁移可以直接拿来当检查表用。
最后再分享一个实用小技巧:在改写SQL时,不要只改语法,顺手把SQL规范统一一下,比如表名字段名不加引号、统一小写、把变化大的函数封装成数据库视图或者应用层统一处理。这样以后再做类似数据库切换,改动量会小很多。代码整洁的团队从来不是在切换数据库那一刻才开始规范SQL,而是在日常开发里就尽量避免对某一种数据库的强依赖。