
迁移这种事最怕的不是迁移过程中报错而是迁移工具给你弹出个绿色对勾然后在场所有人都以为事儿办完了。我这边最近刚用 Navicat 把一套跑了三年的 MySQL 业务库往 PostgreSQL 搬数据量不算大也就两百多张表、几百万行但真正耗时间的反而是迁移完成之后的那些“善后工作”。如果你也正打算干这件事或者已经在迁移后的一堆报错里抓耳挠腮这篇笔记应该能帮你省下不少排查时间。我会沿着迁移后实际会踩到的点从序列、类型、SQL 方言、函数改写、权限 schema到最后的验收清单一条条讲清楚。1. 从“迁移成功”到“真能用”之间还隔着一道鸿沟先泼一盆冷水Navicat 的“数据传输”功能本质上是把源端的表结构按它自己的映射规则翻译成 PostgreSQL 的建表语句再批量执行 INSERT 把数据灌进去。这个过程中它能把大部分基础数据类型、主键、索引、默认值带过来但它带不过来很多东西。我最开始就是吃了这个亏看到迁移报告显示“任务成功完成”就兴冲冲去启动业务后端结果十分钟内数据库连接池里全是异常日志刷了好几屏。这个阶段踩到的问题我大概归了一下类你迁移后多半也会遇到只是顺序不同自增主键没有对应的序列sequence插入数据时要么报 duplicate key要么主键为空。字段类型映射不符合业务预期比如 MySQL 的tinyint(1)被转成了boolean导致程序里 Integer 类型接收时直接转换失败。函数、存储过程、触发器基本全军覆没语法结构差异太大Navicat 只负责表数据这些对象要么根本没迁要么迁过来也编译不过。应用里的 SQL 写法还是 MySQL 方言反引号、ON DUPLICATE KEY UPDATE、IFNULL这些东西在 PG 里根本不认识。账号权限、schema 归属、序列权限全都要重新打扫一遍因为表虽然过来了但应用连上去未必有权限插入数据。所以所谓“迁移后续处理”处理的就是上述这些 Navicat 没干完、也没法替你干完的活儿。别指望有任何工具能一条龙做到位工具最多解决“数据搬移”解决不了“语义平移”。这篇文章我会按实际改造的先后顺序来写先把最致命的自增序列处理掉然后逐项过类型、SQL、函数、权限和验证这也是我这次迁移过程中总结下来效率比较高的一个处理顺序。2. 主键自增失效第一个就位的拦路虎序列重构实操2.1 问题的表象和根因迁移完成后你做的第一件事大概率是往一张带自增主键的表里插一条测试数据。MySQL 里写好AUTO_INCREMENT的字段在 PostgreSQL 中对应的是serial或者identity底层依赖一个独立的序列对象来生成下一个值。Navicat 在迁移时通常会把 MySQL 的INT AUTO_INCREMENT直接映射成 PostgreSQL 的integer但并不会自动创建并绑定好对应的sequence。这就导致两个典型表现如果字段设置了默认值可能默认值是一个不存在的序列引用插入数据时直接报错如果字段没有默认值你插入时不主动给主键赋值那主键就是 NULL如果刚好还有非空约束就会插入失败。当时我这边的情况是表结构看起来一切正常主键确实是主键但你在 pgAdmin 里看它的默认值什么都看不到。我在现场排查的时候就写了两条 SQL专门用来找出所有“缺少默认序列”的自增字段SELECT tc.table_schema, tc.table_name, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name kcu.constraint_name AND tc.table_schema kcu.table_schema WHERE tc.constraint_type PRIMARY KEY AND tc.table_schema public ORDER BY tc.table_name;把所有主键列捞出来之后再去 pgAdmin 里逐个检查这些列的默认值定义凡是空的都是需要补序列的。2.2 创建序列并绑定到列的完整操作PostgreSQL 里把序列绑到列上标准做法是两步先建序列再把序列的下一个值设置为该列的默认值。假设你要修复的表叫orders主键列叫id完整命令如下-- 1. 创建序列序列名建议用 表名_列名_seq 这种约定 CREATE SEQUENCE IF NOT EXISTS orders_id_seq START WITH 1 INCREMENT BY 1; -- 2. 将列的默认值绑定到序列的下一个值 ALTER TABLE public.orders ALTER COLUMN id SET DEFAULT nextval(orders_id_seq::regclass); -- 3. 把序列的当前值同步到表内已有的最大 id防止后续插入时主键冲突 SELECT setval(orders_id_seq, COALESCE((SELECT MAX(id) FROM public.orders), 1));第三步非常关键。很多人在迁移后第一次插入新数据就撞上duplicate key value violates unique constraint就是因为序列是从 1 开始计数的而表里已有的最大 id 已经到十万了。setval就是把序列指针直接拨到当前数据最大值的位置保证下一条新记录拿到的值不会和旧数据冲突。如果你的表数量多手动一张张处理不现实。我当时是先用前面那条 SQL 把候选主键列查出来再按“表名 主键列名”拼了一个脚本批量生成CREATE SEQUENCE和ALTER TABLE语句。你也可以在 pgAdmin 的 Query Tool 里用DO块动态生成这里给出我当时用的一个参考写法DO $$ DECLARE r record; seq_name text; BEGIN FOR r IN SELECT tc.table_name, kcu.column_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON tc.constraint_name kcu.constraint_name AND tc.table_schema kcu.table_schema WHERE tc.constraint_type PRIMARY KEY AND tc.table_schema public LOOP seq_name : r.table_name || _ || r.column_name || _seq; EXECUTE format(CREATE SEQUENCE IF NOT EXISTS %I, seq_name); EXECUTE format( ALTER TABLE public.%I ALTER COLUMN %I SET DEFAULT nextval(%I::regclass), r.table_name, r.column_name, seq_name ); EXECUTE format( SELECT setval(%I, COALESCE((SELECT MAX(%I) FROM public.%I), 1)), seq_name, r.column_name, r.table_name ); END LOOP; END $$;注意这个脚本假设主键列是数字类型并且所有表都在publicschema 下。如果你有多个 schema记得把tc.table_schema条件放开并且在ALTER TABLE时把 schema 名拼进去。2.3 一个小习惯序列的归属最好一次性理清绑定完序列之后还建议顺手把序列的 owner 改掉避免后续迁移账号或者备份恢复时出现所有权问题。PG 里可以用ALTER SEQUENCE ... OWNED BY来声明这个序列归属于哪张表的哪个列。这样以后你DROP TABLE时关联的序列也会自动删除不会留下垃圾对象。ALTER SEQUENCE orders_id_seq OWNED BY public.orders.id;我迁移的表里大概有六十多张是需要自增主键的处理完序列之后插入测试数据立刻恢复正常这一步做完整个库才算是“勉强能写数据”的状态。3. 字段类型体检tinyint、datetime 和 unsigned 都在等你处理3.1 Navicat 的类型映射和你以为的不太一样序列搞定之后下一步一定要做整体字段类型审查。Navicat 有自己的类型映射表但它的映射偏向“能兼容”而不是“符合业务”。我在实际操作中发现几个特别容易出问题的映射建议你迁移后重点排查。第一是tinyint(1)。MySQL 里tinyint(1)非常特殊程序员经常拿它当布尔值用存 0 和 1。但 Navicat 导到 PG 后有的版本会映射成boolean有的会映射成smallint这取决于你用的是哪个版本、走的什么驱动。如果你的后端代码里把这个字段当 Integer 读而 PG 那边是 booleanMyBatis 或 JDBC 在结果集映射时就会直接报Cannot convert boolean to Integer。我当时查了半天发现好几张表的状态字段全变成了 boolean最后是批量ALTER改回smallint才解决。第二是datetime和timestamp的精度差异。MySQL 的datetime默认精度是秒PG 的timestamp默认是微秒精度。大多数时候你不感知但如果你有跨系统对账或者 Java 里用了LocalDateTime序列化到前端微秒尾巴会导致比对不一致。第三是unsigned整数。MySQL 里int unsigned能存到 4294967295而 PG 的integer上限只有 2147483647。如果那张表的自增主键或者数值字段已经超过了 PGinteger的上限Navicat 映射成integer后数据写入时就会直接溢出报错。这种字段必须手动改成bigint。反过来规避也行就是迁移时在 Navicat 里先手动把目标类型指定成bigint但如果你已经迁完了就只能用ALTER去改。3.2 用一条 SQL 把所有可疑字段拉出来与其一张张表用眼睛看我更建议直接查information_schema.columns把所有字段类型过一遍把明显不合适的筛选出来。我当时用的一条查询是这样的SELECT table_name, column_name, data_type, udt_name, character_maximum_length AS max_len, numeric_precision AS num_prec FROM information_schema.columns WHERE table_schema public AND ( data_type IN (boolean, smallint, integer) OR data_type LIKE timestamp% OR data_type USER-DEFINED ) ORDER BY table_name, ordinal_position;拿到清单之后逐个确认。这里给你一个我自己的处理优先级参考这张表是我在迁移笔记里现抄的原 MySQL 类型Navicat 常见映射潜在问题建议调整为tinyint(1)boolean后端按 Integer 读时报错smallinttinyint / smallintsmallint一般没事但要确认业务是否有负数smallintint unsignedinteger溢出风险数据丢了才发现就晚了bigintbigint unsignednumericPG 没有 unsigned 版本numeric(20,0)datetimetimestamp without time zone微秒精度尾巴可能影响比对timestamp(0) without time zoneenum(a,b)character varying约束丢失脏数据可能混进来新建 CHECK 约束set(...)character varying完全丢失集合语义拆关联表或改数组jsonjsonb / jsonjsonb 性能更好但语义略有差别jsonbvarchar(255)character varying(255)基本正常注意字符集换算按需扩展text 带字符集text长度无限制但校验规则丢失可确认是否需要保留enum和set这种类型PG 原生虽然支持CREATE TYPE ... AS ENUM但 Navicat 通常不会自动帮你做而是直接把字段降级成varchar。如果原业务对这两个类型的取值有严格约束你迁完之后必须手动补CHECK约束ALTER TABLE public.user_status ADD CONSTRAINT chk_user_status_value CHECK (status IN (active, disabled, pending));这种约束必须在正式业务跑起来之前补完否则应用层绕过了校验、直接往库里写脏值后期数据治理会非常痛苦。3.3 时间类型时区问题千万别忽略如果你们的业务库有跨时区查询需求MySQL 里timestamp是带时区转换的基于会话时区而 PG 的timestamp without time zone存的就是字面值完全不带时区概念。Navicat 映射默认会到timestamp without time zone也就是说你在 MySQL 里查到的时间迁过来之后看到的“墙上时钟时间”通常是一致的但一旦你的应用连接串设置了TimeZonePG 会在内部做一次转换结果可能比你预期的少了 8 小时或者多 8 小时。如果你拿不准业务上怎么用最稳妥的做法是统一改成timestamptz即timestamp with time zone并且在应用连接串里显式指定时区例如 JDBC 的serverTimezoneAsia/Shanghai。这样从协议层就把时间的语义钉死了避免后续因为部署环境不同出现“同一张表不同环境查到不同时间”的诡异问题。4. SQL 方言改造让应用代码停止报错的关键战场数据层修到能写能读之后下一步就是应用层的 SQL。这一部分看着不起眼但实际是迁移后最耗时间的环节因为错误不在数据库里而在代码仓库的各个角落。PostgreSQL 的 SQL 语法严格程度比 MySQL 高不少很多东西 MySQL 忍了PG 直接甩你一脸syntax error。4.1 标识符反引号和大小写问题MySQL 允许 SQL 里写table_namePG 里没有反引号这个东西标准做法是不加引号或者用双引号。更麻烦的是大小写PG 的规则是不加双引号的标识符会被自动折叠成小写。也就是说你在 MySQL 里建了表UserInfoNavicat 迁过来后表名可能变成UserInfo带了双引号也可能变成userinfo取决于它导出时用的引号策略。如果你在应用里的 SQL 是SELECT * FROM UserInfo而 PG 那边的实际表名是userinfo你会得到一个relation userinfo does not exist之类的错误。我当时用了一个土办法排查直接把整个代码仓库里搜索反引号grep -rn --include*.java --include*.xml --include*.sql .凡是命中反引号的地方全部要改。大小写问题更隐蔽建议迁完后在 PG 里执行SELECT tablename FROM pg_tables WHERE schemaname public ORDER BY tablename;把表名列表拉出来和 MySQL 那边的原始表名做一次脚本比对肉眼检查哪些表名的大小写发生变化。4.2 最经典的插入冲突语法替换MySQL 程序员最熟悉的INSERT ... ON DUPLICATE KEY UPDATE在 PG 里对应的是INSERT ... ON CONFLICT (列名) DO UPDATE SET ...。两者语义接近但细节不同。MySQL 的ON DUPLICATE KEY UPDATE不需要指定冲突列只要任意唯一索引冲突都会触发更新PG 的ON CONFLICT必须明确指出冲突的列或者约束名。这是改造时的重灾区。举个例子MySQL 写法INSERT INTO user_score (user_id, score, updated_at) VALUES (1001, 85, NOW()) ON DUPLICATE KEY UPDATE score VALUES(score), updated_at NOW();PG 里要改成INSERT INTO user_score (user_id, score, updated_at) VALUES (1001, 85, NOW()) ON CONFLICT (user_id) DO UPDATE SET score EXCLUDED.score, updated_at NOW();注意两个变化一个是多了冲突列(user_id)另一个是把VALUES(score)改成了EXCLUDED.score。“EXCLUDED”代表的是本次尝试插入但被冲突挡下来的那一行这个命名我第一次用时也愣了一下后来记成“被排除在表外的行”就好理解了。如果你没有业务上的唯一键冲突只需要“插入或忽略”那就更简单把INSERT IGNORE换成ON CONFLICT DO NOTHINGINSERT INTO user_log (log_id, content) VALUES (1, hello) ON CONFLICT (log_id) DO NOTHING;4.3 条件函数和空值处理IFNULL 与 IFMySQL 里的IFNULL(a, b)、IF(cond, a, b)在 PG 里统统不存在。PG 的标准替代是COALESCE(a, b)和CASE WHEN cond THEN a ELSE b END。大部分情况下是机械替换但要注意IFNULL在 MySQL 里有个隐式的类型转换换成COALESCE后 PG 对两个参数的类型一致性要求更高如果你写COALESCE(int_col, unknown)会直接报invalid input syntax。我当时搜代码时用了几个正则批量替换把IFNULL(换成COALESCE(把IF(按上下文逐个手改——后者千万别无脑替换因为 PG 没有同名函数很容易把嵌套逻辑搞乱。4.4 分组、拼接和模糊匹配的差异GROUP BY 在 MySQL 里非常宽松你甚至可以只 GROUP BY 一个字段然后 SELECT 一堆非聚合字段MySQL 会随机给你取一行。PG 默认不允许这么干必须保证 SELECT 中的非聚合字段全部出现在 GROUP BY 里。迁移后的报表类 SQL 是重灾区几乎每个涉及到GROUP BY的查询都要人工核对一遍。字符串聚合函数也要换MySQL 的GROUP_CONCAT在 PG 里没有对应的是STRING_AGG。写法上有个小坑-- MySQL SELECT dept_id, GROUP_CONCAT(user_name ORDER BY user_id SEPARATOR ,) FROM emp GROUP BY dept_id; -- PG 里等价写法 SELECT dept_id, STRING_AGG(user_name, , ORDER BY user_id) FROM emp GROUP BY dept_id;PG 的STRING_AGG是把 order by 直接放在聚合函数的参数里语法位置和 MySQL 不大一样我一开始还写错过把 order by 放到整个语句最后面导致排序没生效。模糊匹配方面MySQL 的LIKE默认不区分大小写取决于表的 collation而 PG 的LIKE是区分大小写的如果你需要不区分大小写要用ILIKE。这个坑非常隐蔽因为LIKE不会报错只是查出来的结果少了很多人排查半天才发现是大小写问题。我把改造过程中总结的常见映射放在下面可以直接当速查表用MySQLPostgreSQL备注ON DUPLICATE KEY UPDATEON CONFLICT (col) DO UPDATE SET ...必须指定冲突列INSERT IGNOREON CONFLICT DO NOTHING需指定冲突列IFNULL(a, b)COALESCE(a, b)注意类型一致IF(cond, a, b)CASE WHEN cond THEN a ELSE b END嵌套时小心改写GROUP_CONCAT(expr)STRING_AGG(expr, delim)排序写法不同LIKE xx%LIKE xx%/ILIKE xx%看是否需要忽略大小写NOW()NOW()/CURRENT_TIMESTAMPPG 返回带时区或微秒DATE_FORMAT(ts, %Y-%m-%d)TO_CHAR(ts, YYYY-MM-DD)格式串符号不同LIMIT a, bLIMIT b OFFSET a分页写法差异CURDATE()CURRENT_DATE语法兼容但推荐后者columncolumn或不加引号反引号必须去GROUP BY宽松非聚合列严格模式SELECT 列表必须匹配5. 函数与触发器重写最难啃的兼容性硬骨头5.1 先盘点家里有多少“存量对象”如果你原库里的业务逻辑大部分是写在应用层的只是偶尔几个存储过程做统计那这块工作量不大但如果是老系统习惯把所有复杂逻辑都放在数据库里那这一步就是整个迁移中最痛苦的部分。Navicat 迁移函数和触发器非常不可靠它甚至可能直接因为语法编译失败而跳过。所以迁完之后第一步是盘点存量对象。查函数数量和触发器数量SELECT n.nspname AS schema_name, p.proname AS function_name, pg_get_function_arguments(p.oid) AS args, l.lanname AS language FROM pg_proc p JOIN pg_namespace n ON n.oid p.pronamespace JOIN pg_language l ON l.oid p.prolang WHERE n.nspname public ORDER BY p.proname;触发器SELECT event_object_table AS table_name, trigger_name FROM information_schema.triggers WHERE trigger_schema public ORDER BY event_object_table;我当时看下来的结果很现实十几个存储过程、二十多个触发器Navicat 一个都没成功迁移。这并不怪工具因为 MySQL 的存储过程语言和 PL/pgSQL 之间的语法差异太大了工具根本没有办法做语义层面的自动翻译。5.2 语法改写里的几个高频差异点这里我不打算给你一个完整的 PL/pgSQL 教程只讲迁移时最常见的几个差异能覆盖大部分改造场景变量声明。MySQL 的存储过程里用DECLARE声明变量但整体更随意PG 里要在DECLARE段显式写出变量名和类型并且函数体必须是BEGIN ... END;包裹每个语句结束要加分号。参数引用方式。MySQL 的存储过程参数直接用名字而 PG 里函数参数默认可以用$1、$2来引用如果你给参数起了名字就直接用名字。不过要注意PG 函数体内变量名如果和参数同名会报confusing相关错误所以起名时要避开。异常处理。MySQL 里用DECLARE EXIT HANDLER FOR SQLEXCEPTIONPG 里是BEGIN ... EXCEPTION WHEN OTHERS THEN ... END;。语法完全不一样改起来不复杂但逻辑上要想清楚特别是事务回滚的粒度。触发器创建语法。MySQL 的触发器创建在表级别通过BEFORE INSERT ON table FOR EACH ROW定义PG 的创建步骤是先写一个返回trigger类型的函数再用CREATE TRIGGER把这个函数挂到表上。这等于多了一层封装。举个例子一个在插入订单时自动更新订单表更新时间的触发器PG 写法是这样的CREATE OR REPLACE FUNCTION func_set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at : NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trg_orders_set_updated_at BEFORE UPDATE ON public.orders FOR EACH ROW EXECUTE FUNCTION func_set_updated_at();注意 PostgreSQL 12 之前的版本写的是EXECUTE PROCEDURE12 之后推荐用EXECUTE FUNCTION这两个语法在 PG 12 都能跑但新代码建议写FUNCTION免得以后升级时遇到 deprecation 警告。游标的处理逻辑也有差异。MySQL 的游标声明和使用比较直接PG 里通常用FOR ... LOOP循环就能替代掉除非你确实需要手动控制游标的打开关闭。我在实际改造过程中大部分存储过程最后都被简化成了几个INSERT ... SELECT或者WITH临时表查询比硬搬语法要简洁得多。5.3 建议能上应用层就别在函数里死磕这里给你一个非常实际的建议迁移函数和存储过程时先别急着逐字翻译。先看这个存储过程到底在做什么如果它只是封装了一个统计查询那完全可以在应用层直接用一条 PG SQL 代替没必要保留成数据库对象。如果是必须保留的触发器和定时任务再认真改写成 PL/pgSQL。我这次迁移的经验是十几个存储过程里只有三四个真正的数据归档逻辑需要保留其他全部“化整为零”拆成了应用服务里的普通查询方法。这样后续维护成本低很多也不容易再踩方言坑。6. 账号、权限和 schema数据库“换心”后的外围手术6.1 schema 结构怎么摆MySQL 里的“database”和 PG 里的“database”不是一个层级。MySQL 一张表位于库.表PG 是库.schema.表默认 schema 是public。Navicat 迁移时默认会把表都灌到目标库的publicschema 下面。如果你的业务比较简单只有一个库那用public就行不用额外折腾。但如果你原来 MySQL 里就有多个 database迁到 PG 之后最好按原来的 database 拆成多个 schema这样表之间的逻辑隔离保留下来权限也好控制。我当时是把原来的四个库拆成了四个 schemapublic放基础数据剩下的按业务域各建一个。拆的时候只要执行CREATE SCHEMA IF NOT EXISTS sales; CREATE SCHEMA IF NOT EXISTS ops;然后把对应的表用ALTER TABLE ... SET SCHEMA挪过去。这里最需要注意的是表的默认序列绑定。很多序列默认创建在public下表挪去其他 schema 后默认值里的nextval(xxx_seq::regclass)如果引用的是带 schema 的完整序列名一般没问题但如果引用的只是短名PG 会按search_path去找可能找不到导致插入报错。最好在挪表之后把序列也一起挪到同一个 schemaALTER SEQUENCE sales.orders_id_seq SET SCHEMA sales;6.2 账号和权限Navicat 没帮你干的事Navicat 迁移表和数据但不会迁移 MySQL 的用户账号和授权因为两者的用户体系完全不兼容。所以你必须在 PG 里重建应用账号并且把权限一项项授过去。这里有个新手特别容易踩的坑只授了表的SELECT、INSERT、UPDATE、DELETE结果一插入数据就报permission denied for sequence。原因很简单自增主键用的序列是有独立权限的必须单独授权。命令是这样的-- 创建应用账号 CREATE USER app_user WITH PASSWORD your_password; -- schema 使用权限 GRANT USAGE ON SCHEMA public TO app_user; GRANT USAGE ON SCHEMA sales TO app_user; -- 表的增删改查 GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA sales TO app_user; -- 序列的使用权限这步尤其容易漏 GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA sales TO app_user;如果你后续还会新建表希望应用账号自动获得权限可以把相应 schema 的默认权限也设置一下ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO app_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT USAGE, SELECT ON SEQUENCES TO app_user;6.3 search_path连接串里必须写对PG 判断“一张没带 schema 前缀的表到底去哪找”靠的是search_path。当你的表不在public下而应用连接的默认 search_path 又只是$user, public时应用发SELECT * FROM orders就会直接报表不存在。解决办法有两个一是应用连接串里把 search_path 带上比如 JDBC 是这样jdbc:postgresql://host:5432/dbname?currentSchemasales二是在账号级别把 search_path 固定下来ALTER ROLE app_user SET search_path TO sales, public;我个人更推荐第二种因为业务账号一登录就自动落在正确的 schema省得每个连接池都要配置一遍也减少以后有人漏配导致查错表的概率。6.4 连接层配置同步调整别忘了把数据库连接池的参数也过一遍。PG 默认max_connections一般只有 100如果应用原来在 MySQL 上开 200 个连接直接切到 PG 可能把连接数打满。同时PG 的 JDBC 驱动对事务隔离级别、statement_timeout这些参数的处理和 MySQL 也有差别建议在正式切换前先做一轮压测别等到业务高峰期才发现连接池配置需要调。7. 最终验收从行数对比到业务回归一个都不能少7.1 全表行数一致性校验迁移最怕数据“少了一口”。虽然 Navicat 有迁移报告但我建议还是自己独立验一遍不要只信工具的报告。最朴素也最有效的办法是逐表COUNT(*)两边做对比。MySQL 侧执行SELECT orders AS tbl, COUNT(*) AS cnt FROM orders UNION ALL SELECT users, COUNT(*) FROM users;PG 侧执行同样的语句然后让脚本把两边结果做 diff。表少的话手动挨个看也行表多就写个 Python 脚本连两边的库自动拉出来对比。这里提醒一句对比行数时千万要按表名对齐因为两边COUNT出来的结果集顺序不一定一致人工对比很容易看花眼。7.2 抽样数据内容比对行数一致只能说明“数量对”不能说明“内容对”。特别是日期时间、浮点数、text编码这些类型很容易出现肉眼不可见的转换误差。抽样比对的方法不复杂在 MySQL 和 PG 各取同一张表同一批主键的数据。用MD5函数把选出来的字段拼接成字符串计算哈希值。两边哈希值一致基本可以判定这批数据一致。PG 侧的拼接要注意类型转换数值和时间字段要显式转成文本再拼SELECT id, md5(COALESCE(name, ) || | || COALESCE(score::text, )) AS row_hash FROM sample_table ORDER BY id;MySQL 侧对应写法SELECT id, MD5(CONCAT(COALESCE(name, ), |, COALESCE(CAST(score AS CHAR), ))) AS row_hash FROM sample_table ORDER BY id;注意 MySQL 的COALESCE也是存在的所以两边语法比较接近就是类型转换写法不同。7.3 关键约束和索引完整度校验Navicat 迁移带过来的索引和约束未必 100% 和源库一致。尤其是 MySQL 里一些隐式索引比如外键列自动建的索引、UNIQUE 约束的命名、部分索引都可能丢失。我建议迁移后把两边索引清单拉出来对比。PG 侧查索引和其他数据库一样用的是pg_indexesSELECT tablename, indexname FROM pg_indexes WHERE schemaname public ORDER BY tablename, indexname;MySQL 侧用SHOW INDEX FROM ...或者查information_schema.statistics。对比重点放在唯一索引是否齐全、联合索引的字段顺序是否一致、主键是否都在。索引缺失不会让你数据出错但会让线上查询性能直接崩掉这种问题在压测阶段才会暴露出来。7.4 自增写入验证与业务回归静态校验全部通过之后做一轮动态验证。找一张核心业务表手动插入一条记录确认主键能自动生成、不冲突、时间字段正常写入。然后跑一遍应用的核心链路——登录、下单、列表查询、报表导出——把能点的按钮都点一遍。这一步的意义在于验证真实业务场景下的 SQL 兼容性而不是只验证数据库本身。我这次迁移还有一个体会如果原系统有定时任务哪怕是在应用层写的也建议在切换后提前手动触发一次看看有没有因为 SQL 方言问题报错。定时任务通常用的是低频率大批量 SQL最容易踩到前面提到的GROUP_CONCAT、GROUP BY严格模式之类的坑。7.5 别忘了回收过剩权限最后是小细节迁移验收期间为了方便排查可能临时给测试账号开了超级权限或者超范围授权。正式上线前记得把这些权限收掉按最小权限原则重新校准一遍。我见过不止一次迁移完事好几个月才发现测试账号还挂着超级用户权限后面被安全扫描揪出来只能连夜改密码、重建账号。8. 迁移收尾的小经验这些事最好在正式切换前做掉如果你已经读到这说明你的迁移大概率已经走到后半程了。最后分享几个我在这次迁移中总结的“如果在切换前重来一次我会早点做”的事情希望能帮你少走几圈回头路。第一业务切换前至少做一次完整的“演练切换”把所有步骤走一遍并记录耗时。我这次因为时间紧张第一次演练和正式切换都挤在同一个周末导致心态上比较被动。演练切换时你会发现很多只有“真正跑一遍”才能暴露的问题比如某些库表序列没绑、某些历史数据在类型转换上直接报错。这些问题提前发现正式切换时就只是“再跑一遍已知流程”而已。第二旧库不要急着动至少保留两周以上。迁移后的回滚方案不是“改个连接串就行”那么简单的万一发现新库有某些深层次的语义差异旧库还在随时可以回切。两周之后确认业务稳定、数据增长正常再考虑清理旧库资源。旧库保留期间最好把它设为只读防止两边都在写数据导致后续对不上账。第三迁移完成后的首次VACUUM ANALYZE一定要做。PG 的查询计划依赖统计信息数据搬完以后表的统计信息是空或者旧的直接跑线上 SQL 可能选错执行计划。执行一句全库VACUUM (ANALYZE);花不了几分钟但对后续查询性能的帮助非常明显。我切换当天就忘了这茬结果晚上一个关键报表查询跑了快十秒执行计划一查全表扫描。跑完 analyze 之后同样的 SQL 降到了毫秒级。第四文档里别忘了记下所有改动点。这次迁移我全程在一个笔记里记录每张表的特殊处理比如哪些表改了字段类型、哪些序列重新绑定、哪些函数是手工重写的。最后这个笔记成了回滚和排障时最关键的参考资料比迁移报告有用得多。数据和逻辑都搬过来、跑起来还只是第一步真正的难点是把 PG 的特性利用起来让系统的下半辈子过得比 MySQL 时期更舒坦。希望这份笔记能帮你在“后续处理”阶段少踩几个坑。以后如果再迁一次我大概率会在迁移前就把类型映射清单、序列重建脚本、SQL 改造点先整理成模板这样每次换库都能又快又稳。