最近折腾完一件事,把一套跑了挺久的 MySQL 库整体迁到 PostgreSQL。工具用的是 Navicat Premium 的数据传输功能,界面点点点,表结构和数据基本一个多小时就搬过去了。但迁移完成那一刻,我一点都不觉得轻松——Navicat 做的是“搬运”,不是“适配”。MySQL 和 PostgreSQL 虽然都是关系型数据库,可在数据类型、主键生成方式、SQL 写法、标识符大小写规则上差别非常大。真正让人掉头发的,是搬完之后这一大堆后续处理:序列值不对、布尔字段变整数、日期格式不兼容、SQL 老是报错、应用连不上库。这篇笔记就是这些后续处理的实际记录,给正在做类似迁移、或者准备做的朋友一个参考。
1. 先想清楚:迁移完成的“搬运”和“适配”之间少了什么
1.1 Navicat 数据传输到底做了什么
Navicat 的数据传输不是我凭空吹它,它是真的方便:连上源 MySQL 库,再连上目标 PG 库,选中表、视图、函数,点开始,它就会自动建表、导数据、建索引。对大多数普通表来说,这个过程是能跑通的。但这里有个认知误区——很多人以为数据传输跑完就算迁移成功了,其实它只是把 MySQL 的表结构映射成了 PG 的“近似结构”,然后把行数据复制过去。
为什么说是“近似”?因为这两种数据库的类型体系、约束语义、存储引擎概念都不一样。比如 MySQL 的AUTO_INCREMENT是表列属性,PG 里没有这个属性,只有序列(sequence)配合默认值来实现自增。Navicat 会尝试生成一个序列挂在字段默认值上,但生成完以后,序列的当前值、所有权、权限这些细节不一定和你表里的最大 ID 对齐,更不保证后续插入正常。再比如 MySQL 的tinyint(1)很多人拿来当布尔值用,Navicat 常常把它映射成smallint或integer,那你程序里原本期待 true/false 的逻辑就会出问题。
所以我把“迁移完成”分成两层:
- 搬运完成:表结构和数据已经在 PG 里存在,能 SELECT。
- 适配完成:类型正确、约束有效、序列正常、SQL 可运行、应用连接无异常、性能可接受。
大多数教程和工具只能帮你做到第一层,第二层必须自己来。
1.2 迁移后需要立即确认的三类问题
不管源库是什么,迁移到 PG 之后我都会按下面三类去检查,顺序就是这个顺序,乱不得。
第一类:结构完整性。表有没有全部建出来?主键还在吗?外键有没有丢?索引有没有因为类型映射失败而少了?序列有没有正常工作?触发器、存储过程在 Navicat 里很多时候迁移失败,需要自己查日志确认。
第二类:数据一致性。记录行数对不对得上?关键字段有没有溢出?中文有没有变乱码?时间字段有没有在 MySQL 里存了 PG 不认的0000-00-00导致导入报错?是不是有部分表因为异常被跳过了?
第三类:程序可运行性。原来项目里用了哪些 MySQL 专有语法?IFNULL、ON DUPLICATE KEY UPDATE、LIMIT 20, 10这种写法在 PG 里还能不能跑?ORM 的方言配没配?数据库账号权限够不够?search_path是不是预期的 schema?
别一上来就闷头改代码。很多时候程序报错并不是 SQL 的问题,而是结构层面就没弄干净——序列没设好、字段类型不对、权限没给,改代码是越改越乱。
1.3 后续处理的优先级怎么排
我这次迁移的实践顺序是这样的,基本覆盖了从工具结束到应用跑通的全过程:
- 先在 PG 上跑一遍结构核查 SQL,查表、查主键、查序列、查索引,把结构问题清掉。
- 再对齐数据:行数对比、关键字段抽样、边界值检查。
- 结构数据确认干净后,才动 SQL 和代码适配。
- 最后才压一压查询和写入,确认 PG 统计信息正常,性能不是玄学。
这个顺序非常重要。如果结构不对就直接改 SQL,你会把“结构问题”误判成“SQL 问题”,白排查半天。我把这些步骤拆开,逐个说细节。
2. 结构层面:类型映射和主键,是迁移后最大的坑
2.1 自增主键与序列:迁移完必须检查的第一件事
MySQL 的AUTO_INCREMENT在 PG 里靠序列实现。Navicat 迁完以后,PG 里通常会出现一个表名_id_seq的序列对象,字段默认值类似nextval('users_id_seq'::regclass)。看起来像模像样,实际有相当概率翻车。
我遇到的情况是:迁移后表里最大主键是 5000,序列当前值却还是 1。这时候新插入一条数据,PG 会尝试用主键 1 去插入,马上撞上已有记录,报duplicate key value violates unique constraint "users_pkey"。如果你在原系统里插入新用户立刻报主键冲突,不用猜,十有八九是序列值没同步。
修复方法很简单,把序列值拨到当前最大值后面:
SELECT setval( pg_get_serial_sequence('public.users', 'id'), (SELECT COALESCE(MAX(id), 1) FROM users) );pg_get_serial_sequence会找到指定表的字段绑定的序列,这样不会写死序列名。第二参数可以传一个子查询,这样一张表一条 SQL 就搞定。如果是海量表都可能有这个问题,用 DO 块批量处理更省事:
DO $$ DECLARE tbl TEXT; col TEXT; BEGIN FOR tbl, col IN SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = 'public' AND column_default LIKE 'nextval(%' LOOP EXECUTE format( 'SELECT setval(pg_get_serial_sequence(%L, %L), (SELECT COALESCE(MAX(%I), 1) FROM %I))', tbl, col, col, tbl ); END LOOP; END $$;这个批量脚本的原理很简单:找出所有默认值带nextval的列,对每一列做一次 setval。跑完之后建议再测一张表,插入一条测试记录再回滚,确认自增正常:
BEGIN; INSERT INTO users (username) VALUES ('__seq_test__') RETURNING id; ROLLBACK;能返回 id 说明序列没问题,ROLLBACK 之后测试数据不会留下。像这种自增链路,如果 Navicat 迁移时正好处理得不好,你越早发现越好,不然等程序上线才发现,新用户注册全部撞主键,那就被动了。
注意:迁移后的主键字段类型多大概率是
integer。如果你原表是 MySQL 的int(11),数据量已经很大,建议借这次迁移直接升级成bigint,不然几年后 int 上限一满,PG 里改类型比 MySQL 还麻烦。
2.2 大小写与引号问题:Navicat 留下的隐形雷
PG 对未加引号的标识符一律转换成小写存储和匹配。MySQL 里表名的大小写行为则取决于系统配置,很多项目在 Windows 或 macOS 下开发时,建表语句写成create table UserInfo,库表名就是驼峰或大写的。Navicat 从 MySQL 迁到 PG,会把表名、字段名原样带上引号建出来,比如"UserInfo"。看起来没毛病,但问题来了:
你用SELECT * FROM UserInfo查询,PG 会把未加引号的UserInfo自动转成小写userinfo,然后告诉你relation "userinfo" does not exist。你必须每次都写SELECT * FROM "UserInfo"才能命中。手写 SQL 都已经够烦了,再让 ORM 里的实体映射去处理这种引号大小写,简直是给自己挖坑。
我处理这种问题的经验是:迁移前后都尽量把表名、字段名统一成小写。如果在 MySQL 阶段还没迁,就先在主库上把驼峰表名改成小写,应用侧同步改。如果已经迁到 PG 了,就用ALTER TABLE "UserInfo" RENAME TO user_info这类语句重建一遍命名。
如果项目体量很大、改动不动,退而求其次的办法是让你的 SQL 层永远带引号访问“精确大小写”的名字。这个方案我也用过,短期能跑,但后续写代码的人非常容易踩坑,每次排查都要先怀疑大小写,维护成本很高。
强调一下:这里不是 Navicat 的锅,是 PG 的标识符规则决定的。别指望工具能帮你把历史命名债一次清完,工具迁移完后花一两个小时规范命名,比以后每次报错都查大小写划算得多。
2.3 几个容易出问题的边界类型
迁完结构,我每次都会用信息模式查一遍所有字段类型,看看有没有被映射成“能用但不对劲”的类型:
SELECT table_name, column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' ORDER BY table_name, ordinal_position;重点看几类字段。
tinyint(1) 到底是不是布尔。MySQL 里tinyint(1)经常被框架当布尔用,存 0 或 1。Navicat 迁到 PG 多数情况下映射成smallint,少数情况映射成integer。如果你希望用真正的 PGboolean类型,可以手动转:
ALTER TABLE users ALTER COLUMN is_active TYPE boolean USING is_active::boolean;但要注意,如果字段里混着 2、3 这种值,::boolean转换的行为是:0 变 false,非 0 变成 true。要是里面有脏数据,转完后排查会很痛苦,所以转换前先查一遍:
SELECT DISTINCT is_active FROM users;如果只有 0/1,直接转没问题。
时间类型带不带时区。MySQL 的datetime不带时区,Navicat 通常映射成 PG 的timestamp without time zone。这个映射本身正确,但如果你的业务有跨时区需求,建议迁移后评估一下要不要改成timestamptz。改动并不复杂,但一定要先确认应用层读取时间的逻辑,别把timestamptz换完以后展示出来的时间差 8 小时,又花一个晚上排查。
unsigned int / unsigned bigint。PG 没有无符号整数类型,Navicat 可能映射成numeric或者bigint。如果是numeric,后续统计查询的效率会略低,而且程序里拿到的类型也可能有变化。建议人工评估后把真正是整数的列统一改成bigint。
MySQL 的“零日期”问题。这是最容易在导入阶段就爆掉的雷。MySQL 允许datetime存0000-00-00 00:00:00,PG 的 timestamp 类型不接受这种“零日期”。如果你源库里正好有这种历史数据,Navicat 在导数据时就会报错,甚至会中断当前表。处理办法是迁移前先更新源表,把零日期改成 NULL:
UPDATE orders SET paid_at = NULL WHERE paid_at = '0000-00-00 00:00:00';这样的更新做完再做迁移,导入不会有问题。如果已经迁移一半失败了,先处理源库,再单独重传失败的表。
3. 数据与约束核对:先别急着让程序跑起来
3.1 行数核对与抽样校验
Navicat 迁移完以后,日志里会显示每张表导了多少行。但我不会只依赖那个数字,也不建议只看“任务完成”就收工。常规做法是写两个统计查询,MySQL 出一份,PG 出一份,逐表对比:
MySQL 端:
SELECT 'users' AS tbl, COUNT(*) AS cnt FROM users UNION ALL SELECT 'orders', COUNT(*) FROM orders UNION ALL SELECT 'order_items', COUNT(*) FROM order_items;PG 端同样的写法跑一遍,然后对比数字。表多的时候,我更喜欢直接查系统表的估算值先扫一眼差异:
SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY relname;但这个值是估算,只适合粗筛。真正核对还是用 count 或按业务键抽样。比如订单表,对比最大订单号、最近一周订单量、某个状态的数量分布,都比单纯 count 更有意义。抽样校验我通常会看“边界值”:最大 ID、最小创建时间、最长字符串字段这几类,因为迁移工具最容易在边界值上翻车。
3.2 索引、约束与触发器逐项核查
结构迁移很少能把所有对象一次搬干净。Navicat 在处理 PG 和 MySQL 的索引差异时,比较常见的情况有:MySQL 全文索引迁过去变成普通索引或迁移失败;多列索引的前缀长度这种 MySQL 特有写法在 PG 里无法实现;函数索引从 MySQL 迁过去后定义异常;唯一索引偶尔丢失了唯一属性。
对比索引最直接的办法是查系统目录:
SELECT tablename, indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' ORDER BY tablename, indexname;然后把 MySQL 端的SHOW INDEX FROM 表名导出成清单,一条一条对。不想这么原始的话,把两边的索引定义导出成文本 diff 一下也行。关键是别遗漏唯一索引,一旦唯一约束丢了,程序里的“防重复”逻辑就失效了,脏数据进来后想再修就麻烦。
触发器是另一个重灾区。MySQL 的触发器语法和 PG 完全不同,Navicat 对触发器的迁移基本靠不住,我在实践中遇到过几个触发器在日志里标红失败。应对方式很简单:先让数据传完,触发器后面手工重写。重写的时候注意 PG 触发器要配一个返回trigger的函数,再CREATE TRIGGER ... BEFORE INSERT OR UPDATE ... EXECUTE FUNCTION ...,结构和 MySQL 触发器差得远,别想着直接拷贝。
3.3 权限与 search_path:应用连不上库的隐藏原因
表迁移完成后,owner 是执行迁移的用户。如果你在应用连接串里用的是另一个账号,大概率会遇到permission denied for table users这种错误。这是因为 PG 的权限体系里,表上的 DML 权限和序列上的 usage/select 权限要分别给。
一次性授权可以用下面的方式:
GRANT USAGE ON SCHEMA public TO app_user; GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO app_user; GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO app_user;注意ALL TABLES只对执行时刻已经存在的表生效。如果后续还有新建表,建议设默认权限:
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;另外一个不太容易第一时间想到的问题:PG 连接默认搜索路径是"$user", public,如果表本来在默认库的 public 下还好,万一 Navicat 迁移时把表建到了其他 schema 里(有些情况下它会用你源库名建 schema),那应用执行SELECT * FROM users时根本找不到表,报错却是“表不存在”。排查方法是在连接串里显式指定 schema:
jdbc:postgresql://127.0.0.1:5432/mydb?currentSchema=public或者给账号设置默认搜索路径:
ALTER ROLE app_user SET search_path TO public;权限和 search_path 这两个点,不属于 Navicat 迁移失败的范畴,工具日志根本不会提醒你,但它们是“迁移后应用连不上”的高频原因,排查顺序一定要靠前。
4. SQL 与应用层适配:实际工作量最大的一环
4.1 常用函数替换清单
迁移后最花时间的是把代码里的 SQL 换成 PG 方言。其实大部分简单查询两边都能跑,但涉及函数、分页、更新的写法差异非常明显。我整理了一张常用替换表,做迁移的同学可以直接保存参考:
| 场景 | MySQL 写法 | PostgreSQL 写法 |
|---|---|---|
| 判空兜底 | IFNULL(a, 0) | COALESCE(a, 0) |
| 日期格式化 | DATE_FORMAT(d, '%Y-%m-%d') | TO_CHAR(d, 'YYYY-MM-DD') |
| 拼接字符串 | CONCAT(a, b) | a || b或CONCAT(a, b) |
| 分组取非聚合列 | SELECT a, COUNT(*) ... GROUP BY b | SELECT a, COUNT(*) ... GROUP BY a, b |
| 插入冲突更新 | INSERT ... ON DUPLICATE KEY UPDATE | INSERT ... ON CONFLICT (id) DO UPDATE SET ... |
| 联表更新 | UPDATE t JOIN u ON ... SET ... | UPDATE t SET ... FROM u WHERE ... |
| 分页 | LIMIT 20, 10 | LIMIT 10 OFFSET 20 |
| 字符串位置 | LOCATE(sub, str) | STRPOS(str, sub) |
| 时间差 | TIMESTAMPDIFF(DAY, a, b) | EXTRACT(DAY FROM (b - a)) |
这里面最坑的其实是分页。MySQL 老代码里LIMIT offset, count这种低级写法不少,PG 直接报语法错误,必须改成LIMIT count OFFSET offset。如果项目里有很多这种 SQL,建议全局搜索LIMIT后手工过一遍。
ON DUPLICATE KEY UPDATE这类写法,PG 要用标准 SQL 的ON CONFLICT替代,语义也有些差别。MySQL 是根据主键或唯一索引判断冲突,PG 的ON CONFLICT可以指定具体列或约束,应用层改造时最好把“以哪个唯一键作为冲突判定条件”想清楚再改。
4.2 GROUP BY 的严格度和布尔查询
这个问题程序跑起来才会暴露,而且会成片报错。MySQL 在关闭ONLY_FULL_GROUP_BY的宽松模式下,允许SELECT id, name, COUNT(*) FROM users GROUP BY age这种不严谨的写法,PG 从第一天起就禁止这种写法,会明确报错:列必须出现在 GROUP BY 子句中或用于聚合函数。
解决办法不是去改 PG 配置,而是把 SQL 改规范。如果原来的 SQL 真的需要“取某个分组里的一条记录”,PG 里有多种更清晰的写法,比如DISTINCT ON或者窗口函数。迁移本身就是一次整理 SQL 的机会,我个人建议借机把这种不规范的查询都重写掉。
布尔字段的查询差异也要注意。MySQL 里你写WHERE deleted = 0是因为 deleted 是tinyint(1),迁到 PG 后如果转成boolean,WHERE deleted = 0就会报operator does not exist: boolean = integer。改成WHERE deleted = false或者直接WHERE NOT deleted才行。如果应用层 SQL 全部是= 0/= 1风格,我很建议迁过去以后保留smallint,别追求“纯正布尔”,否则改造量会很大。
4.3 连接串、驱动与 ORM 配置调整
程序连不上 PG 或连上后报类型错误,通常是连接层配置没有跟上。拿 Java 项目举例,JDBC 驱动要从 MySQL 驱动换成 PG 驱动,连接串格式变化如下:
jdbc:mysql://127.0.0.1:3306/mydb jdbc:postgresql://127.0.0.1:5432/mydb驱动类名从com.mysql.cj.jdbc.Driver改成org.postgresql.Driver。使用连接池的,还要检查池配置里有没有写死的 driverClassName。
ORM 层面,MyBatis 类项目要注意 XML 里写死的数据库方言函数;Spring Data JPA / Hibernate 项目要把hibernate.dialect配成 PG 方言,比如:
spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect有时候还会遇到参数类型报错:column "id" is of type bigint but expression is of type character varying。这个在迁移初期很常见,因为 PG 类型检查比 MySQL 严格得多。如果是临时快速验证,JDBC 连接串可以加一个参数stringtype=unspecified,让 PG 在参数化查询时更宽容一些。但我不建议长期依赖这个参数,它本质上是绕过了 PG 的类型推断,性能和维护上都有隐患。正确的做法是把程序里插入语句的参数类型理顺,配合 ORM 的字段类型注解。
Python 项目用 psycopg2 或者 SQLAlchemy,核心也是两点:连接参数里的 host/port/dbname 换掉,以及 SQL 里涉及的 MySQL 专有函数替换成 PG 版本。没有其他玄学。
5. 常见问题与排查技巧实录
5.1 新插入数据就报主键冲突
现象:应用新增记录时偶发duplicate key value violates unique constraint,查表里的最大 id 又明明很大。
原因基本锁定在序列值没有跟上表内已有数据。Navicat 建序列时,不保证序列当前值等于表内最大 id。修复方法前面已经写了,核心就是setval(pg_get_serial_sequence(...), max(id))。改完后务必要实际插入一条测试数据验证。
这个问题的排查成本其实很低,但我见过不少团队误以为是 ORM 缓存问题,折腾了半天。记住,新库首次出现这种报错,90% 先查序列。
5.2 时间字段为 0000-00-00 导致导数据失败
现象:Navicat 在传输某张表时报错,日志里提示日期字段非法,任务中断。
原因:MySQL 允许零日期,PG 的 timestamp 不接受这种值。处理方法我在 2.3 已经说过,先更新源表零日期为 NULL,再重传失败的表。这里补充一点:如果你没法改源表(比如是生产库),可以在 SQL 查询导出阶段用IF(paid_at='0000-00-00 00:00:00', NULL, paid_at)先把值转掉再导出。Navicat 的数据传输虽然不支持这种自定义转换,但你可以改成导出 SQL 文件,在导出 SQL 里写查询。
5.3 应用账号报 permission denied
现象:PG 里用迁移账号登录能查数据,切换应用账号后查不到表,报权限不足。
原因:PG 权限是独立的,迁移账号创建的表的 owner 是它自己,其他账号拿不到默认权限。解决办法就是我在 3.3 里写的GRANT语句,同时记得给序列授权。另外如果账号是通过ALTER DEFAULT PRIVILEGES设过默认权限才建的,那这个账号要重新建或者重新设默认权限,默认权限只对设置之后新建的对象生效。
5.4 迁移后查询明显变慢
现象:同样的表、同样的数据量,PG 查询比 MySQL 还慢,甚至出现离谱的执行计划。
原因:迁移过程是大量插入操作,PG 表的统计信息还没有更新,优化器在不知道数据分布的情况下选了错误执行计划。处理方式非常朴素:
VACUUM ANALYZE;这行命令会更新统计信息并回收由于大批量插入产生的膨胀空间。如果还有大表查询慢,可以再单独对关键大表执行ANALYZE table_name。很多团队在迁移后立刻开始调索引、改 SQL,忽略了这个最基础的步骤,实际上VACUUM ANALYZE跑完后执行计划正常了,问题可能就消失了。
另外,如果迁移时把索引一起导进来了,但表数据量很大,你会发现导入期间索引维护很重。经验做法是:超大表先只导结构和数据,索引留到数据导完后再手动创建,速度会快很多,而且能保证索引构建时的统计信息更准确。Navicat 数据传输里可以取消勾选“包含索引”,后续手工CREATE INDEX。
5.5 中文乱码
现象:迁移后中文变成问号或乱码。
原因:源库字符集、目标库字符集、连接客户端字符集三者有一个不一致。迁移前确认 MySQL 原库使用utf8mb4,PG 库统一用UTF8。Navicat 在数据传输的高级选项里可以指定编码,迁移前就设成 UTF8。如果发现某一列已经乱码,先查源库是否真的是 utf8mb4,如果源端就是乱的,修源端再重新迁移,不要在 PG 里尝试人工修复。PG 库创建时用CREATE DATABASE mydb WITH ENCODING 'UTF8' TEMPLATE template0;,能避免很多默认模板带来的编码隐患。
5.6 Navicat 日志中部分表传输失败
现象:整体任务提示完成或有错误标红,部分表的数据行数不对。
办法:点开数据传输日志,逐条看失败的 SQL 报错。多数失败原因就是前面说的零日期、字符集、或者是某个字段类型兼容问题。把源表问题修好后,在 Navicat 里单独选中这些表重新传输,不要整库重跑,省时间也降低风险。留意日志里“成功”“跳过”“失败”三种状态,不要只看任务窗口的绿色勾。
我的几点实操心得
Navicat 的 MySQL 到 PostgreSQL 迁移,工具本身足够可靠,但数据库迁移这件事,工具只负责“搬运”,是否“能用”得靠人去确认。我每次做完迁移,都会按“结构 → 数据 → SQL → 权限/连接 → 性能”这条线逐项过一遍,哪怕日志全绿也要查。序列值同步、大小写规范、零日期清理、默认权限设置这四件事,几乎每次迁移都会遇到,建议你做成固定的检查清单,别偷懒。
最后再分享一个习惯:迁移完成后我会在测试环境直接跑一遍核心业务链路——注册一个用户、下一张单、做一次后台查询。很多问题不是看出来的,是跑出来的。等这轮冒烟测试过了,再考虑切换正式流量。折腾完这一整套,你再回头看 Navicat 那个“传输完成”的提示,心情会踏实很多。