一、迁移的两种痛
把一个 MySQL 业务库迁到 KingbaseES,传统路径通常绕不开两样东西,一个导出导入的停机窗口,和一个额外部署的数据同步中间件。前者要业务方点头给时间,后者要多维护一个组件、多配一套规则。对中小库或者灰度试点来说,这两样都嫌重。
有没有更轻的办法?有,金仓自带的 mysql_fdw,全称 MySQL Foreign Data Wrapper,外部数据包装器。它让金仓能像访问本地表一样直接读远端 MySQL,于是迁移可以简化成一条 INSERT ... SELECT,不导文件,不装中间件,源库不停机。
这篇我在鲲鹏服务器上把这条路走通了。而且在最后的对账环节,还抓到了一个只数行数绝对发现不了的静默数据损坏。这个坑,才是整篇文章我最想分享的东西。
二、造一个「有刺」的迁移源
为了让测试有意义,我在 MySQL 侧造了一个电商库 shop,特意埋了几类迁移里最容易翻车的数据。

两张表,customers 8 行,orders 10 行。刺埋在这些地方,中文的名字和城市,NULL 的邮箱,decimal(12,2) 的精确金额,datetime 时间戳。最关键的一根刺在 orders 里,有一行 note 写的是 生日礼物🎁,带一个 utf8mb4 的 emoji,4 字节字符,整个字段 16 字节。你先记住它,后面它是主角。
三、mysql_fdw,不搬数据先挂载
迁移第一步不是搬数据,是让金仓能看见 MySQL。三条 DDL 就够了。
SQL
CREATE EXTENSION mysql_fdw;
CREATE SERVER shop_mysql FOREIGN DATA WRAPPER mysql_fdw
OPTIONS (host '127.0.0.1', port '3306');
CREATE USER MAPPING FOR system SERVER shop_mysql
OPTIONS (username 'fdw', password 'Fdw#2026');
然后为 MySQL 表建外部表,把 MySQL 类型映射成 KES 类型。挂载完成,直接在金仓里 SELECT。

\d ft_customers 能看到外部表的完整定义,列类型、所属 Server,还有 OPTIONS (dbname 'shop', table_name 'customers')。执行 SELECT 的时候,数据物理上还在 MySQL 里,SQL 由金仓执行、实时拉取。类型映射很自然,MySQL 的 int、decimal(12,2)、datetime、tinyint,分别落到 KES 的 int、numeric(12,2)、timestamp、smallint。
这一步真正的价值,是先建立了通路。有了通路,迁移、对账、增量追平全在同一条通路上完成,不需要第二个工具。
四、差点漏掉的 emoji
正当我准备一把梭迁移的时候,随手验了一下那行 emoji。
出事了。

金仓通过 fdw 读到的 note 是 生日礼物?,emoji 变成了问号。用字节数一验,事情就清楚了。MySQL 源里是 16 字节,hex 结尾 F09F8E81,正是 🎁 的 utf8mb4 四字节编码。fdw 读到的只有 13 字节,hex 结尾 3F,就是一个问号。
那 4 个字节,在传输途中被悄悄吃掉了。根因是 mysql_fdw 默认用 3 字节的 utf8 字符集连接 MySQL,遇到 4 字节的 utf8mb4 字符,emoji、部分生僻字、CJK 扩展区汉字,就地转成问号。
我先试着给 server 加一个 character_set 选项,被拒了,它不在合法选项列表里。但报错的 HINT 里列出的合法选项中,有一个 init_command,它能在每次建立连接时执行一条 SQL。于是有了这一句。
SQL
ALTER SERVER shop_mysql OPTIONS (ADD init_command 'SET NAMES utf8mb4');
再查,生日礼物🎁 回来了,16 字节,hex 结尾 F09F8E81,分毫不差。
这个坑最可怕的地方是静默。不报错,不中断,行数一分不少,只是每个 4 字节字符悄悄变成问号。如果迁移验证只对一下行数,甚至只对一下金额总和,它能一路蒙混过关,直到某天用户投诉,我备注里的表情怎么全变成问号了。迁移前先校准 fdw 字符集,这是我用这次经历换来的第一条铁律。
五、迁移,一条 INSERT 搞定
字符集校准好,迁移就是水到渠成的一条语句。

SQL
CREATE TABLE customers (...); -- KES 本地表
INSERT INTO customers SELECT * FROM ft_customers; -- 8 行
CREATE TABLE orders (...);
INSERT INTO orders SELECT * FROM ft_orders; -- 10 行
INSERT 0 8,INSERT 0 10,数据从 MySQL 直接流进金仓本地表。没有中间的 CSV 文件,没有 mysqldump,没有第三方同步工具,外部表本身就是管道。迁移完成后,本地表是纯粹的 KES 原生表,不再依赖 MySQL。
六、对账,为什么只数行数会出事
迁移完成不等于迁移正确。前面的 emoji 事件已经证明,数据可以在行数完全一致的情况下悄悄损坏。所以我做了三级对账,一级比一级严。

| 对账级别 | 能抓住什么 | 会漏掉什么 |
|---|---|---|
| ① 行数 count | 整行丢失、重复 | 行在但内容错,比如 emoji 变问号 |
| ② 数值列 SUM | 金额、数量类错误 | 字符串损坏、时间偏移 |
| ③ MD5 全表指纹 | 逐行逐列的任何差异 | 无 |
第三级是关键。我在金仓里对外部表(源)和本地表(目标)执行完全相同的 SQL,把每行拼成字符串、排序后求 MD5。因为两边都由金仓引擎执行,口径完全一致,只要有任何一个字节不同,指纹就会不同。
结果,customers 三项全等,8 行对 8 行,金额总和 374550.02 对 374550.02,MD5 指纹 d4835ee4... 完全相同。orders 也三项全等,包括那行 emoji,MD5 572d7a44... 相同。MD5 一致,等于逐行逐列宣告无损。假如我没修字符集就迁移,这里的 MD5 会立刻对不上。三级对账存在的意义就在这,让静默损坏藏不住。
但这里还有一个坑中坑。我的目标库建的是 MySQL 兼容模式,而在 MySQL 兼容模式下,|| 不是字符串拼接,是逻辑或。我第一版对账脚本顺手用了 id||'|'||name||... 这种写法拼行,算出来的 MD5 指纹其实是假的。我专门做过一个验证,故意把目标表某一行的 email 改成错误值,用 || 拼出来的指纹,源和目标居然依旧相同,逻辑或运算把真实的字段值吃掉了,篡改完全漏检。换成 concat(id,'|',name,...) 之后,指纹立刻对不上,篡改当场现形。
一个会漏检数据损坏的对账脚本,比不对账更危险 ,它给你的是虚假的安全感。所以在金仓 MySQL 兼容库里做对账,拼接一律用 concat(),别用 ||。
七、双轨共存与增量追平
真实迁移往往不能一刀切,需要一段灰度窗口,源库还在接单,新库先并行验证。mysql_fdw 天然支持这种双轨,因为那条通路一直在,随时能对账。演示一下。

我在 MySQL 侧新增了一单,id=99,模拟灰度期还在进来的订单。对账立刻兜住了,源库 11 行,目标 10 行,MD5 指纹对不上,差异秒级暴露。接着增量追平,INSERT INTO orders SELECT * FROM ft_orders WHERE id NOT IN (SELECT id FROM orders),只补目标缺的那一行。再对账,11 对 11,MD5 恢复一致。
源库持续写入,fdw 增量追平,MD5 对账兜底,这个循环就是平滑迁移窗口的核心机制。切换当天反复跑对账直到追平,确认一致之后,再把应用的连接串从 MySQL 换到金仓,风险可控。
八、结论
我用 mysql_fdw 走通了一条不停机、不装中间件的 MySQL 到金仓的迁移路径。外部表建通路,一条 INSERT...SELECT 迁数据,三级对账验正确,双轨增量做平滑,源库只读可回退。
最后再把那条教训放在这。行数一致,不等于数据无损。fdw 的字符集要先校准,对账拼接要用 concat(),这两个坑,我都替你踩过了。