迁库这件事,很多人第一反应是"不就是把数据倒过去吗",真上手了才会发现,从MySQL到PostgreSQL,最难的不是搬数据,而是搬完之后业务还能不能正常跑起来。我去年把一个跑了两年的订单系统从MySQL 8.0迁到了PostgreSQL 14,数据量大概1.2TB,前后花了两周多。这篇文章想把整个过程中踩过的坑、验证过的方案、还有那些文档里不会明说的细节,完整地梳理一遍。不管你是被PostgreSQL的JSONB、窗口函数、或者更严格的SQL标准吸引,还是因为公司技术栈统一、多云部署的需要,这篇都值得你花十分钟读完。目标只有一个:让你在动手之前,就知道自己会撞上哪些墙。
1. 为什么要迁:三个真实场景下的迁移动机与决策
迁移这事,最怕的不是技术难,而是动机不清晰。我见过太多人因为"听说PostgreSQL很强"就拍板迁移,结果迁到一半发现业务场景压根用不上这些新特性,白白折腾一个月。先冷静想清楚这几个问题。
1.1 场景一:复杂分析查询性能到了瓶颈
MySQL做OLTP很稳,但一旦涉及多表关联、子查询嵌套、开窗函数的复杂报表,优化器的表现就不太稳定。我之前有个统计报表,单条SQL要join八张表,里面还有三个子查询,在MySQL 8.0上跑一次要一分半钟。因为那个报表是给运营每天凌晨跑一次的,勉强还能忍。但后来需求变成实时看板,这个性能就扛不住了。
PostgreSQL的查询优化器做得更扎实,尤其擅长处理复杂JOIN和子查询,同一个报表迁过去之后,没做任何SQL改写,跑了大概二十秒,后面加了个物化视图,直接降到两秒以内。如果你的业务里有大量复杂分析场景,这会是迁移最核心的收益。
1.2 场景二:需要高级数据类型和更强的一致性保障
MySQL的JSON字段在8.0之前基本就是个"存储串"的存在,查询要用JSON_EXTRACT函数,索引效率也一般。PostgreSQL的JSONB是真正的二进制存储,支持GIN索引反向匹配,还支持在JSON内部字段上建索引,这个差别用过的都懂。
两个更典型的例子:一是PostgreSQL的表继承、分区裁剪、约束排他(EXCLUDE)这些特性,在MySQL里都没有对应物;二是PostgreSQL的MVCC实现和RR隔离级别下的表现,处理并发冲突时更符合"读不阻塞写、写不阻塞读"的直觉。如果你的业务对数据一致性、复杂查询能力有硬性要求,迁移的回报率非常高。
1.3 场景三:标准SQL兼容性和生态中立性
MySQL有一些自己的SQL方言,比如REPLACE INTO、INSERT ... ON DUPLICATE KEY UPDATE,这些语法换到别的数据库基本没法直接用。PostgreSQL更贴近SQL标准,应用层SQL写得好,未来换到Oracle、达梦这类数据库的改造成本会小很多。如果你的团队有长期的多云部署或多数据库支撑需求,这是一个值得认真考虑的战略选择。
我不建议的场景是:业务纯CRUD、单机数据量不到几十G、也没有复杂的SQL需求。这种项目迁到PostgreSQL不会带来明显收益,反而要承担迁移过程中的风险和人力成本。迁库不是炫技,是解决实际问题的手段。
2. 迁移前必做的一次技术盘点:两类数据库的本质差异
很多人以为迁移就是把数据类型一一对应,改改连接串就完事。这是最大的误区。MySQL和PostgreSQL表面上看都是关系型数据库,但底层架构和语义差异非常大。不理解这些差异,迁移后的系统就像换了发动机的旧车,跑起来总会有各种奇怪的问题。
2.1 存储引擎与MVCC实现差异
MySQL最常用的InnoDB是索引组织表(IOT),数据按主键聚簇存放,二级索引的叶子节点存的是主键值。PostgreSQL用的是堆表,数据按插入顺序存放在堆里,索引独立存储,索引项直接指向行的物理位置(通过ctid定位)。
这个差异带来的直接后果是:
- PostgreSQL的UPDATE会产生新行版本(并触发VACUUM清理旧版本),MySQL的UPDATE在InnoDB里是同页更新(大多数情况下);
- PostgreSQL的二级索引不需要回表就能找到行(通过索引项里的ctid),MySQL的二级索引回表是常态;
- 高频UPDATE写多读少的场景,PostgreSQL要小心VACUUM压力和表膨胀问题。
2.2 隔离级别与锁机制差异
MySQL InnoDB的默认隔离级别是Repeatable Read,但它的RR是用Next-Key Lock实现的,不光锁行,还锁间隙。PostgreSQL的默认隔离级别是Read Committed,它的RR级别实现方式完全不同——通过快照实现,不会阻塞读写。PostgreSQL在RR级别下可能出现序列化异常(serialization anomaly),而MySQL的RR则更倾向于锁等待和死锁。
在并发更新同一行数据的压力测试里,PostgreSQL的表现通常更平滑,不会出现一堆事务互相锁死的情况。这点在做并发压测的时候会明显感觉到。
2.3 数据字典和约束行为的差异
MySQL早期版本里,外键在MyISAM引擎下就是个摆设,InnoDB之后的约束检查也比较宽松。PostgreSQL对外键、唯一约束、CHECK约束的检查是严格执行的。迁移时如果源库有不规范的数据(比如违反唯一约束的重复记录、违反CHECK约束的非法值),在PostgreSQL里会直接插入失败。
这也是为什么我强烈建议:迁移之前,先在MySQL里做一轮数据质量检查,把重复记录、空字符串和NULL混用的问题都揪出来。否则数据加载到一半崩了,排查起来会很痛苦。
3. 迁移方案选型:为什么我把宝压在pgloader上
迁移方案网上能搜到一大把,但真正能用的主要就三类:物理迁移(冷备份恢复)、逻辑迁移(用工具导数据)、应用层双写切换。我这次用的是逻辑迁移里的pgloader,下面说说这三类的取舍逻辑。
3.1 物理迁移:最快但限制最多的路
MySQL的物理备份(比如xtrabackup的备份集)和PostgreSQL的物理备份(pg_basebackup)字节级格式完全不兼容,物理迁移只适用于从PostgreSQL到PostgreSQL的情况。如果你是从MySQL迁过来,这条路直接不用考虑。
3.2 应用层双写:最稳但是最累的路
双写方案是指业务代码同时写MySQL和PostgreSQL,跑一段时间后对比数据一致性,再把读流量切到PostgreSQL。好处是风险可控,坏处是业务代码要改两套写入逻辑,对团队开发量要求很高。适合那种不能接受任何停机时间的核心系统。我这次是内部业务系统,能接受两小时停机窗口,所以没选这条重量级的路。
3.3 pgloader:开源免费,专为迁库打造
pgloader是我最终的选择,理由很实际:
- 能直接从MySQL读取schema和数据,减少很多手工步骤;
- 内置数据校验流程,加载完能对比源库和目标库的数据行数;
- 支持在线迁移模式(但要小心锁表问题);
- 配置简单,核心逻辑就一个.load文件。
我用pgloader迁了1.2TB的数据,大概花了四五个小时。如果自己写脚本导CSV再灌进去,晚高峰时段的MySQL压力会把业务拖垮,pgloader的流式读取和批量写入反而是最安全的。
3.4 备选方案:ETL工具与手工迁移的适用场景
如果你所在的公司已经有成熟的ETL平台(比如DataX、Kettle、Airbyte),也可以走ETL通道做全量及增量同步。DataX的MySQLReader和PostgreSQLWriter组件都比较成熟,断点续传也做得好。手工写脚本的方式只适合数据量在百万级以内的小表,超过这个量级,批处理、断点、重试这些逻辑自己在脚本里写一遍成本太高。
我的建议很简单:数据量小于100G、表结构不复杂的,pgloader足够;数据量大于100G、又有实时增量需求的,上DataX或专业数据同步工具做全量加增量,最后在切换窗口做一次短暂的停写追平。
4. Schema迁移实战:建表语句、数据类型与默认值处理
迁移最繁琐的环节是schema。MySQL和PostgreSQL的类型体系虽然有交集,细节上差别很大。如果让pgloader全自动生成目标schema,它通常能跑通,但生成的结果可能不符合你的预期。我习惯的做法是:让pgloader先把schema建好,我再用脚本生成DDL做一轮人工review。
4.1 数据类型映射表:我整理的一份实测对照
| MySQL | PostgreSQL | 说明 |
|---|---|---|
| TINYINT | SMALLINT | MySQL的TINYINT只有1字节,对应PostgreSQL的SMALLINT合理;其实PostgreSQL没有专门的BOOL替代TINYINT(1)这种约定 |
| INT | INTEGER | 直映,没坑 |
| BIGINT | BIGINT | 直映 |
| VARCHAR(n) | VARCHAR(n) | 直映,注意PostgreSQL的VARCHAR不会自动截断,超出会报错;MySQL在非严格模式下会静默截断 |
| DATETIME | TIMESTAMP | 直映,注意时区语义,建议迁移前统一确认按哪个时区处理 |
| TIMESTAMP | TIMESTAMPTZ | 如果MySQL的TIMESTAMP是带时区语义的,建议迁到PostgreSQL的TIMESTAMPTZ |
| DECIMAL(p,s) | NUMERIC(p,s) | 直映,注意PostgreSQL需要显式指定精度 |
| ENUM | 视应用而定 | PostgreSQL有原生ENUM类型,但强烈建议换成VARCHAR+CHECK约束,原因下面说 |
| JSON | JSONB | 如果你只做存取,JSON够用;如果要在查询里用,一定是JSONB |
| BLOB | BYTEA | 直映 |
| TINYINT(1) | BOOLEAN | MySQL里TINYINT(1)经常被当成布尔用,迁移时最好显式转成BOOLEAN,语义更清晰 |
4.2 自增主键的处理:最容易翻车的地方
MySQL的AUTO_INCREMENT在PostgreSQL里对应两种方案:SERIAL系列(INT/BIGINT)和IDENTITY列(GENERATED AS IDENTITY)。我建议用后者,它是SQL标准语法,语义更清晰。
但这里有个坑:迁移历史数据时,MySQL已经产生的自增值会作为普通数据灌入PostgreSQL,而PostgreSQL的序列起始值不会自动跟着变。如果你不处理,下一个自增ID可能从1开始,直接撞上已有数据的主键。解决办法很直接,数据装载完成后,手工把序列设置到当前最大值:
SELECT setval('orders_id_seq', (SELECT max(id) FROM orders));这条命令我每次迁移后必跑一遍,否则线上第二天就会出现主键冲突。
4.3 字符集、排序规则和大小写敏感性
MySQL 8.0默认字符集utf8mb4,排序规则是utf8mb4_0900_ai_ci(末尾的ci是不区分大小写)。PostgreSQL的默认UTF8排序规则通常带locale区分,不同操作系统默认值不一样。最典型的影响是查询条件:
- MySQL:
SELECT * FROM users WHERE name = 'Smith'默认能匹配到'smith'; - PostgreSQL:
= 'Smith'默认区分大小写,必须用ILIKE或LOWER()处理。
迁移时如果要保持和原来一样的大小写不敏感行为,最稳妥的办法是在应用层统一用LOWER()函数,而不是依赖数据库排序规则。否则你没法保证每台部署机器上的locale都一致。
4.4 表注释、列注释和索引命名的规范化
MySQL允许注释写得很随意,PostgreSQL同样支持COMMENT ON。我建议迁移时把注释都带上,特别是枚举含义、状态字段的取值说明,不然两年后接手的人看到status = 3会懵。
索引命名建议统一改掉。MySQL默认的索引名是idx_xxx,PostgreSQL没有这个默认习惯,不同工具生成的名字千奇百怪。命名的好处很实际:将来定位慢查询,看索引名就知道它服务于哪个查询路径。
5. 数据迁移实战:用pgloader把1.2TB历史数据搬过去
Schema确认没问题,接下来就是大头——数据搬运。pgloader的安装很简单,在Ubuntu上直接apt install pgloader就行。然后写一个.load配置文件,把源库和目标库的信息都填进去。这里我截取一个精简版配置。
5.1 一份可以直接改用的pgloader配置
LOAD DATABASE FROM mysql://user:password@mysql_host:3306/dbname INTO postgresql://user:password@pg_host:5432/dbname WITH include drop, create tables, create indexes, reset sequences, disable triggers, batch rows = 5000, batch concurrency = 8 SET MySQL PARAMETERS net_read_timeout = '120', net_write_timeout = '120' , PostgreSQL PARAMETERS maintenance_work_mem = '1GB', work_mem = '128MB' CAST type datetime to timestamptz drop typemod keep default, type tinyint to smallint ;几个关键点我要提醒:
batch concurrency = 8:并发太高会把源库搞挂,建议从4开始调,观察源库的CPU和IO,再逐步往上加;disable triggers:目标库建了外键约束后,数据灌入顺序如果不对会报外键冲突,先禁用触发器可以避免很多麻烦,灌完再启用;reset sequences很大程度替代了我前面提到的setval手工步骤,但配置里不显式写,我仍然会用SQL再核一遍。
5.2 执行与监控:从哪里看进度、怎么掌握节奏
pgloader运行后会在终端打一个动态进度面板,显示每个表的行数、错误数、已用时间。但大批量迁移时,我更推荐用--load-lisp-file加载一个带日志输出的配置,把详细日志写到文件,方便后续排查。
执行命令:
pgloader --load-lisp-file my_loader.load --log-file migration.log迁移中途如果某个表报错,pgloader默认会跳过并继续,错误记录在日志里。我建议不要一口气跑完再去看日志,而是每10分钟瞄一眼错误率。一个典型情况:MySQL里某些字段值是非法UTF8序列,pgloader写入PostgreSQL时会报编码错误,这时候就得在CAST规则里加上with extra做清洗预处理。
5.3 数据校验:不能只看行数一样就以为万事大吉
行数一致只是第一道关卡。我迁移完习惯跑三组校验:
- 行数校验:每张表分别
COUNT(*); - 关键字段的聚合值校验:比如订单表的总金额、用户表的创建时间最大最小值,用SQL分别在两端跑一遍对比;
- 抽样校验:每张表随机抽100条记录,对比关键字段的MD5值。
在对比MD5这一步,我抓到过一个很隐蔽的坑:浮点数在MySQL和PostgreSQL的底层存储精度不完全一致,导致0.1 + 0.2这类运算结果的小数位末尾会有微小差别。如果你的业务对金额、坐标这类浮点字段有严格的等值条件,建议迁移时把所有浮点列改成NUMERIC类型,保证计算精度。
6. 业务代码适配:SQL差异、驱动更换与ORM层调整
数据搬完了,只是完成了40%的工作。剩下的大头是让应用层的代码在新数据库上正常跑。没有哪个项目能完全不做代码调整就完成迁移,我说的"没有"是真的没有。
6.1 驱动与连接方式
Java项目从mysql-connector-java换成postgresqlJDBC驱动,连接串从jdbc:mysql://...改成jdbc:postgresql://...,这块相对平滑。Python项目则从pymysql换成psycopg2或psycopg3。Go项目是go-sql-driver/mysql换成pgx。换驱动本身简单,真正的坑在SQL语法和类型映射上。
6.2 高频SQL差异清单:直接从踩坑记录里抄
| 类型 | MySQL写法 | PostgreSQL写法 |
|---|---|---|
| 自增主键返回值 | SELECT LAST_INSERT_ID() | INSERT ... RETURNING id |
| 分页 | LIMIT 10 OFFSET 20 | 通用写法一致,也可以用OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY |
| 字符串连接 | CONCAT(first_name, ' ', last_name) | `first_name |
| 判空 | IFNULL(col, 0) | COALESCE(col, 0) |
| 更新多表 | UPDATE t1 JOIN t2 ON ... SET t1.c = ... | UPDATE t1 SET c = ... FROM t2 WHERE ... |
| 插入冲突处理 | INSERT ... ON DUPLICATE KEY UPDATE c = VALUES(c) | INSERT ... ON CONFLICT (id) DO UPDATE SET c = EXCLUDED.c |
| 正则匹配 | REGEXP | ~或REGEXP(PG 15以后支持) |
| 布尔值 | WHERE flag = 1 | WHERE flag(如果flag是BOOLEAN类型) |
上面列出来的语法差异都有一个共同特点:它会影响SQL语义,但不会报错,所以特别容易漏。最危险的反而是那些"MySQL能跑PostgreSQL也能跑,但结果不一样"的语句。
6.3 ORM层:MyBatis、JPA等框架的处理思路
如果你用MyBatis,SQL通常是自己维护的,上面的表逐个替换就行。建议在Mapper XML里做一个全局搜索,把IFNULL、LAST_INSERT_ID、ON DUPLICATE KEY UPDATE全部扫出来,逐条确认。JPA/Hibernate这类生成SQL的框架通常内置了数据库方言,切换database-platform配置后,大部分关联查询会自动适配,但一些自定义@Query还是要手动改。
对于字段类型映射,我建议在ORM层做一次显式映射配置,而不是依赖自动映射。比如MySQL的TINYINT(1)在PostgreSQL里如果转成BOOLEAN,MyBatis的resultType如果是Integer,映射就会挂。这种问题在单元测试阶段很难暴露,必须用真实数据联调才会发现。
6.4 存储过程与触发器:MySQL的这套玩法,到了PostgreSQL要推倒重来
MySQL的存储过程语法基于SQL/PSM,PostgreSQL则是PL/pgSQL。两者差异很大,几乎没有自动转换工具能靠谱。我的建议是:尽量用应用层代码替代存储过程,如果确实必须保留,那就在PL/pgSQL里重新实现一遍。触发器同理,MySQL触发器和PostgreSQL触发器的行为差异也很多,PostgreSQL支持BEFORE/AFTER加FOR EACH ROW/FOR EACH STATEMENT,书写方式完全不同。
这套改造我做了快三天,是迁移中耗时最长的单项。如果你的系统里存储过程特别多,预算迁移时间时一定要把它算进去。
7. 迁移后的一周:从VACUUM策略到慢查询复盘
迁库完成不代表项目结束。甚至可以说,真正的问题排查是从切换之后才开始的。PostgreSQL和MySQL在运行机制上的差异,会在高负载下以各种形式暴露出来。
7.1 VACUUM和表膨胀:PostgreSQL的独有功课
MySQL的InnoDB有purge线程异步清理旧版本,PostgreSQL的VACUUM则是显式存在的重要机制。虽然autovacuum默认是开启的,但默认参数在迁移后的系统上未必合适。
我遇到过一个典型问题:有一张大表频繁UPDATE,默认的autovacuum阈值没有及时触发,表膨胀到原来的三倍,查询性能雪崩。解决方法是针对热点表单独调整:
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05); ALTER TABLE orders SET (autovacuum_vacuum_threshold = 1000);如果你的业务和订单系统类似,UPDATE频繁且单表数据量大,建议迁移后第一周,每周检查一次pg_stat_user_tables里的n_dead_tup和n_live_tup比值,比值超过0.2就要考虑手动VACUUM或调低触发阈值了。
7.2 ANALYZE和统计信息:不给它喂数据,它怎么知道怎么走索引
迁移后在大量历史数据灌入的情况下,自动ANALYZE收集到的统计信息可能不准确。最保险的做法是在迁移完成后对全库做一次显式ANALYZE:
vacuumdb --analyze-only --all -h pg_host -U user否则你会发现有些SQL走了全表扫描,性能惨不忍睹。特别是那些字段取值分布很不均匀的表(比如订单状态字段,90%都是已完成的),没有准确统计信息时查询计划基本就是抽奖。
7.3 EXPLAIN的变化:需要一段时间适应新体检工具
MySQL的EXPLAIN输出是一张扁平的表格,PostgreSQL的EXPLAIN是树状结构,还要结合缓冲区命中率一起看。我适应的办法很简单:把迁移前后最核心的20条慢SQL的查询计划打印出来做前后对比,这个对比能帮你快速感知到PostgreSQL哪些场景强、哪些场景需要注意。
一条适合PostgreSQL的分析查询示例:
EXPLAIN (ANALYZE, BUFFERS) SELECT c.customer_name, SUM(o.amount) FROM orders o JOIN customers c ON o.customer_id = c.id WHERE o.created_at >= '2024-01-01' GROUP BY c.customer_name ORDER BY SUM(o.amount) DESC LIMIT 10;重点看actual time、rows和buffers,如果某个节点actual rows和estimate rows差10倍以上,说明统计信息还是有问题,需要对相关表重新ANALYZE。
7.4 备份与恢复策略也要跟着换
MySQL时代你可能习惯了mysqldump定时全备。PostgreSQL的pg_dump功能类似,但我要提醒一个差异:pg_dump默认导出的SQL文件,恢复时要手动建库;建议用pg_dump -Fc生成自定义格式,配合pg_restore可以做到按表恢复。逻辑备份之外,物理备份pg_basebackup做全量基础备份,再配合wal_archiving做时间点恢复(PITR),才是生产环境的标准姿势。
备份策略调整好之后,至少要做一次恢复演练,不然等到真要恢复时才发现备份脚本有问题,那时候就晚了。
8. 迁移后的小结:那些我希望早点知道的事
文章写到这,核心内容差不多了。最后补几个我在整个迁移过程中体会最深的小细节,如果你马上也要做这件事,它们能帮你省几个下午的时间。
第一,不要在业务高峰期做schema变更和数据装载。听起来像是废话,但真的有人会在白天直接跑pgloader,结果把源库的IO打满,线上接口超时报警一片。我那次是挑了周末凌晨两点开始,虽然自己辛苦点,但安全。
第二,迁移前把MySQL那边所有utf8mb4的表过一遍字符集校验。PostgreSQL对非法字符的处理比MySQL严格,如果源库有数据是历史遗留的乱码,pgloader会在中途报错。
第三,切流量的时候,别一下子全切。先让5%的读流量走PostgreSQL,跑一天对比错误率,再逐步提高比例。我的切流脚本是用nginx的split_clients按用户ID哈希做灰度分配的,这样同一用户请求前后行为一致,不会出现同一个人在MySQL和PostgreSQL之间反复横跳。
迁库是个系统工程,最难的不是某一道技术题,而是把一堆细节串起来。你不必一回生二回熟,最好第一回就按"先盘点差异,再选方案,再做schema,再搬数据,再改代码,再观察运行"这个顺序走完。我踩过的坑你看到了,应该能少走不少弯路。