前阵子接手了一个内部系统改造,核心任务是把一套跑了快五年的MySQL 5.7存量库,完整挪到PostgreSQL 14上。整个过程从评估、工具选型、全量导出、增量同步到应用改造,踩了不少坑,也沉淀了一套可以复用的方法。今天就把这套从MySQL迁移到PostgreSQL的完整流程写出来,不绕弯子,直接按实操顺序讲。
这篇内容适合三类人:一是业务里确实遇到了MySQL瓶颈、正在考虑换库的技术负责人;二是接手了迁移任务、需要快速上手的开发或DBA;三是纯粹想搞清楚这两个库到底差在哪里、以后要不要换的学习者。我会把数据模型差异、SQL语法差异、迁移工具用法、增量双写方案、应用层改造和常见坑点都讲明白,争取你看完就能规划自己项目的迁移。
1. 迁移前必须想清楚的三件事
1.1 先回答“值不值”再动手
很多人一上来就问“怎么迁”,但更该先问“为什么迁”。MySQL和PostgreSQL都是优秀的开源关系型数据库,不存在谁能完全取代谁。从我实际接触的场景看,值得下决心迁的原因一般就这几种。
第一种是业务对复杂查询、报表统计、JSON数据处理要求很高。PostgreSQL在复杂关联查询、窗口函数、CTE(公共表表达式)、GIN索引上的能力明显强于MySQL,尤其是JSONB类型,可以直接对JSON字段建索引、做复杂过滤,这在电商订单属性、埋点事件这类灵活结构数据上特别好用。
第二种是数据完整性要求极高。PostgreSQL的约束实现更严格,比如外键、唯一约束、检查约束、排他约束,连“延迟约束”都支持,这在金融、财务类系统里是硬需求。MySQL的检查约束在8.0之前基本是摆设,外键在分库分表场景下也常常被放弃。
第三种是业务希望摆脱某个云厂商的绑定,或者要把数据库从商业版替换成完全开源的方案,避免授权成本。MySQL虽然是开源的,但它被Oracle收购后,很多团队对社区版未来演进有顾虑。PostgreSQL作为完全独立的开源项目,许可证更宽松,社区也很活跃。
如果你只是因为“听说PG性能更好”就想迁,那我要泼盆冷水。对于单纯的点查、写入、主从复制这类OLTP场景,MySQL并不差,甚至运维生态更成熟。迁移本身是有成本的,而且应用代码要改,风险不小。一定要有明确的业务指标支撑,比如“某个复杂报表在MySQL跑不动”“JSON查询响应太慢”等,再动手。
1.2 版本选择与基础环境准备
确定要迁之后,第一步是选版本。MySQL这边,我建议至少是5.7以上,如果是8.0,迁移时会少很多麻烦,因为8.0的字符集默认是utf8mb4,JSON类型也更成熟。PostgreSQL这边,建议直接用14或更高版本,15、16在性能、日志、增量备份上都有改进,14.2之后实战很稳。如果你需要pgvector这种插件,版本更不能太低。
然后是准备目标环境。我习惯先把PostgreSQL装在一台和源库资源配置接近或者略高的机器上,先用默认配置跑通流程,再根据压测调优。安装时要注意几个点:一是操作系统用户的权限规划,PostgreSQL默认不允许root用户直接操作;二是数据目录和表空间要单独挂盘,避免和系统盘抢I/O;三是提前想好字符集。
PostgreSQL创建数据库时,字符集建议直接用UTF8,排序规则用默认的即可。MySQL老库如果用的是latin1或者utf8mb4,导出数据之后一定要对比字符集,否则中文会变成乱码。这块我建议在迁移准备阶段就写进检查清单,后面数据校验时会省很多事。
工具选型上,最值得用的开源工具是pgloader,它能自动把MySQL的表结构、数据、索引、约束转换到PostgreSQL,比手动写脚本快得多。商业云平台自带的迁移服务我就不展开说了,因为不同厂商的操作界面差异较大,原理上其实都离不开“结构转换、全量复制、增量同步”这三板斧。
2. 盘点差异:SQL语法与数据类型的对照清单
2.1 数据类型映射:最容易被坑的一环
MySQL和PostgreSQL的数据类型看起来差不多,实际差异很大。最典型的就是整型和自增主键。
MySQL的TINYINT对应PG的SMALLINT,MySQL的INT对应PG的INTEGER,BIGINT两边一致。UNSIGNED无符号整型在PostgreSQL里没有直接对应,需要换成常规整型并配合CHECK约束来限制非负。实际迁移中,如果表里的UNSIGNED字段值本身不会超过有符号范围,直接降级成普通整型也行。
日期时间类型也要注意。MySQL的DATETIME存储的是“墙上时间”,不带时区;TIMESTAMP会受时区影响,而且范围只有1970到2038年。PostgreSQL里对应的是TIMESTAMP(不带时区)和TIMESTAMPTZ(带时区)。绝大多数业务场景,我建议迁移后统一用TIMESTAMPTZ,因为它在多时区部署、跨区域协作时更安全。如果原库用的是DATETIME且不关心时区,那用TIMESTAMP就够了。
字符串类型,MySQL的VARCHAR对应PG的VARCHAR,但要记得PG的VARCHAR(n)里的n是字符数而不是字节数,对于中文存储更友好。MySQL的TEXT、MEDIUMTEXT、LONGTEXT在PG里统一映射为TEXT,PG的TEXT没有长度限制,这反而省事。MySQL的BLOB对应PG的BYTEA。
自增主键是迁移中最常见的坑。MySQL用AUTO_INCREMENT,PG有两种写法:老版本用SERIAL(包括BIGSERIAL),新版本推荐用IDENTITY列,也就是GENERATED ALWAYS AS IDENTITY。我强烈建议新表直接用IDENTITY,更符合SQL标准,后续做逻辑复制、权限管理都更规范。
枚举类型也建议单独处理。MySQL的ENUM可以直接变成PG的ENUM,但后续要加一个枚举值,两边语法差异很大。我实际经验是,把ENUM转成VARCHAR加CHECK约束更灵活,业务代码改动也更少。布尔类型上,MySQL用TINYINT(1)表示真假,PostgreSQL原生支持BOOLEAN,迁移时直接转换,但要注意应用代码里查询条件的写法。
| MySQL类型 | PostgreSQL类型 | 特别说明 |
|---|---|---|
| TINYINT | SMALLINT | UNSIGNED需配合CHECK约束 |
| INT | INTEGER | 范围一致 |
| BIGINT | BIGINT | 常见主键类型 |
| DATETIME | TIMESTAMP | 不带时区 |
| TIMESTAMP | TIMESTAMPTZ | 推荐带时区 |
| VARCHAR(n) | VARCHAR(n) | n表示字符数 |
| TEXT系列 | TEXT | 无长度限制 |
| BLOB | BYTEA | 二进制大对象 |
| ENUM | VARCHAR + CHECK | 便于后续扩展 |
| TINYINT(1) | BOOLEAN | 布尔语义更清晰 |
| JSON | JSONB | 推荐使用JSONB |
2.2 常用SQL写法差异速查
SQL语句层面的差异直接影响应用代码改造量,我把高频差异整理成了一张速查表,迁移前挨个过一遍就知道自己项目要改多少SQL了。
首先是最容易踩的符号差异。MySQL里用反引号包裹表名和字段名,PostgreSQL用双引号,而且在PG里未加双引号的标识符会被自动转为小写。这意味着你原来MySQL里的UserTable`如果没加反引号,在PG里查就变成usertable。最稳妥的做法是统一用双引号显式指定大小写,但代价是每次都要写双引号,麻烦。我更推荐迁移时把所有表名、字段名统一改成小写加下划线风格,一劳永逸。
字符串拼接上,MySQL的||默认不是拼接符(除非开启PIPES_AS_CONCAT),用的是CONCAT函数;PG的||就是拼接符,同时也支持CONCAT函数。这个对于动态SQL和报表项目影响很大,排查时要特别留意。
分页查询方面,两边都支持LIMIT OFFSET,这块几乎不用改,算是比较省心的部分。但要注意,MySQL在OFFSET很大时性能很差,PG同样有深分页问题,建议后续优化成基于游标或键集分页。
INSERT语法差异也是一个重头。
MySQL的INSERT ... ON DUPLICATE KEY UPDATE在PG里对应INSERT ... ON CONFLICT (唯一键) DO UPDATE SET ...。这个语法更严格,必须指定冲突的约束或唯一索引,好处是更安全,不会随手把不相关的重复数据覆盖掉。REPLACE INTO在MySQL里是“删了再插”,PG里没有,需要用INSERT ... ON CONFLICT DO NOTHING或DO UPDATE替代。
| 场景 | MySQL写法 | PostgreSQL写法 |
|---|---|---|
| 标识符引用 | `user` | "user" 或小写user |
| 字符串拼接 | CONCAT(a, b) | a || b 或 CONCAT(a, b) |
| 空值判断 | IFNULL(a, 0) | COALESCE(a, 0) |
| 日期格式化 | DATE_FORMAT(d, '%Y-%m') | TO_CHAR(d, 'YYYY-MM') |
| 自增主键获取 | LAST_INSERT_ID() | RETURNING id |
| 插入冲突更新 | ON DUPLICATE KEY UPDATE | ON CONFLICT (id) DO UPDATE SET ... |
| 字符串截取 | SUBSTRING(s, 1, 3) | SUBSTRING(s FROM 1 FOR 3) |
| 更新多表 | UPDATE a JOIN b ON ... | UPDATE a SET ... FROM b WHERE ... |
还有一个高频差异是GROUP BY。MySQL默认允许SELECT非聚合列,比如SELECT name, age, COUNT(*) FROM t GROUP BY age这种在MySQL能跑,但PG会直接报错,必须把name加到GROUP BY里或者用聚合函数包裹。迁移后最容易出现的报错就是这个,应用日志里看见“column must appear in the GROUP BY clause”基本就是这个原因。
MySQL的UPDATE支持多表JOIN直接更新,PG需要借助FROM子句来实现同等效果。DELETE语句类似,MySQL的DELETE JOIN在PG里要通过USING子句来重写。
3. 迁移流程实战:从全量导出到增量同步
3.1 全量迁移:表结构、数据、索引一次搬完
环境准备好、差异也盘完了,接下来进入核心流程。我建议先在一个测试环境完整跑通一遍,记录下每个步骤的耗时和数据量,再上生产。
全量迁移我首推pgloader,它是专门为“从其他数据库迁到PostgreSQL”设计的工具,用起来非常简单。
# 安装 pgloader(Ubuntu/Debian 为例) sudo apt-get install pgloader # 创建迁移配置文件 mysql_to_pg.load配置文件里核心是定义源库和目标库的连接信息,以及一些转换规则。我贴一个最小可用的例子:
LOAD DATABASE FROM mysql://user:password@localhost:3306/mydb INTO postgresql://pguser:password@localhost:5432/mydb WITH include drop, create tables, create indexes, reset sequences, workers = 8, concurrency = 1, multiple readers per thread, rows per range = 50000 SET PostgreSQL PARAMETERS maintenance_work_mem = '1GB', work_mem = '128MB' CAST type datetime to timestamptz drop default drop not null using zero-dates-to-null, type tinyint to smallint using tinyint-to-smallint;这个配置做了几件事:先删除目标库同名表再创建,然后自动建索引,重置自增序列,采用并发读取。CAST部分是把MySQL的DATETIME转成PG的TIMESTAMPTZ,并把零日期转成NULL,因为MySQL允许0000-00-00这种值,PG不允许。
执行迁移:
pgloader mysql_to_pg.loadpgloader会在终端实时打印每个表的读取速度、行数、错误数。执行完后要重点看那些有error的表,大多数错误来自非法日期、重复数据、超大索引名。
如果不用pgloader,也可以用mysqldump导出SQL文件再手动转换,但那样工作量大得多。我的建议是:小项目、单机、无脑全量迁移,用pgloader;大项目、多实例、需要复杂转换映射的,可以先用工具跑一遍,再写自动化脚本弥补。pgloader不是万能的,比如分区表、视图、存储过程不会为你自动转换,这些要么提前处理,要么迁移后手工改造。
3.2 增量同步与双写策略:接近不停服的关键
很多系统没法接受长时间停机,最理想的方案是“旧库继续写,新库同步着,最后在一个凌晨窗口切换”。要实现这个,必须解决增量同步问题。
增量方案有三种思路。
第一种是最省事的业务双写。在应用层加一个开关,让写操作同时写MySQL和PostgreSQL,读操作仍走MySQL。这种方案适合应用代码可控、改动量能接受的团队,优点是灵活、不用解析binlog,缺点是双写会有一致性问题,而且应用代码要动。
第二种是用现成的数据同步工具。这类工具生态比前几年成熟了不少,但配置复杂,尤其是处理DDL变更、断点续传时比较折腾。
第三种是基于业务字段自己写增量任务。如果表里有自增ID或更新时间戳,可以定期跑一段脚本,把增量数据从MySQL抽到PG。这个方案最朴素,但胜在稳定。比如有一个updated_at字段,任务每5分钟执行一次:
-- 在目标库PG侧执行的定时拉取示意 INSERT INTO target_table (id, name, updated_at) SELECT id, name, updated_at FROM mysql_side_view WHERE updated_at > :last_sync_time AND updated_at <= :current_sync_time;实际操作中,我建议根据数据重要性和表大小混合使用。核心交易类表用双写保证实时性,普通配置表、日志表用定时增量任务就能满足。
双写方案看起来简单,有几个细节必须处理好。第一,双写要有幂等性,PostgreSQL侧用ON CONFLICT DO UPDATE,保证重复执行不会产生脏数据。第二,要加一个同步状态表,记录每条主键的同步版本号或最后同步时间,便于排查漏同步的数据。第三,双写失败时不能影响主流程,要以MySQL为准,PG侧失败先记录日志,后面用对账任务补。
3.3 校验与回滚:切换前必须过的最后关卡
切换前最重要的一件事,不是“切过去”,而是“比对两边数据”。我见过太多人辛辛苦苦导完数据,结果漏了一条索引,上线后慢查询把库拖垮。
数据校验我一般分三层做。第一层是行数校验,每个表COUNT(*)对比,这个只能发现明显差异。第二层是聚合校验,用MD5函数对所有关键字段做拼接再求哈希,对比两边是否一致。第三层是抽样明细比对,随机抽几条主键,逐字段比对。
-- PostgreSQL 侧计算表指纹 SELECT md5(string_agg(t.row_hash, '')) FROM ( SELECT md5(id::text || name || created_at::text) AS row_hash FROM target_table ORDER BY id ) t;字段里有NULL值时要小心,NULL拼接会变成NULL,导致整行哈希算不出来。可以用COALESCE包一层默认值。
切换策略上,我推荐“先灰度、再全量、留回滚”。生产环境先让5%到10%的只读流量打到PG,观察错误率和响应时间。确认没问题后,再在维护窗口把写流量切过来。切换窗口内,先把MySQL改成只读,把最后一段增量补到PG,然后做一次快速校验,再切应用连接。
回滚方案必须提前写好。最简单的方式是应用层保留双写开关,切到PG后发现重大问题,一键恢复写MySQL,PG侧继续同步但不承接读流量。这个方案能保证你在故障时不需要重新导入数据,回滚时间控制在分钟级。
4. 应用改造:存储过程、函数与连接层适配
4.1 存储过程和触发器的改造方案
如果你的业务用了大量存储过程,迁移成本会直线上升。MySQL的存储过程语法和PostgreSQL的PL/pgSQL风格差异很大,几乎不存在自动转换工具,基本靠人工重写,好在多数互联网业务已经把逻辑从数据库层挪到了应用层,存储过程占比不大。
MySQL和PG在存储过程上最明显的差异是分隔符和语法结构。MySQL用DELIMITER把整个过程包起来,PG直接用CREATE OR REPLACE FUNCTION+$$ ... $$来包裹函数体。
MySQL示例:
DELIMITER $$ CREATE PROCEDURE get_user_by_id(IN uid INT) BEGIN SELECT * FROM users WHERE id = uid; END$$ DELIMITER ;改写为PostgreSQL:
CREATE OR REPLACE FUNCTION get_user_by_id(uid INT) RETURNS TABLE(id INT, name TEXT, created_at TIMESTAMPTZ) LANGUAGE plpgsql AS $$ BEGIN RETURN QUERY SELECT u.id, u.name, u.created_at FROM users u WHERE u.id = uid; END; $$;变量声明方面,MySQL用DECLARE,PG在BEGIN ... END中间的DECLARE段里声明。异常处理上,MySQL用DECLARE EXIT HANDLER,PG用BEGIN ... EXCEPTION WHEN ... THEN,语义和使用方式都不太一样,重写时要特别注意事务行为,PG里异常处理会回滚当前子事务。
触发器是另一个高频改造点。MySQL触发器写在一对BEGIN END里,PG需要单独创建函数,再通过CREATE TRIGGER绑定到表上。PG的好处是触发器函数可以用多种语言编写,灵活性更强。
如果存储过程体量大且逻辑复杂,我的建议是不要逐行翻译,而是借这个机会把关键逻辑搬到应用层。数据库只保留基础的数据读写和约束,业务规则由服务代码控制。这样后续数据库升级、拆分、横向扩展都会轻松很多。迁移存储过程不是技术问题,而是架构升级的契机。
4.2 连接驱动、连接池与ORM适配
应用侧改造最重要的是数据库驱动和连接方式。
Java系应用,MySQL用com.mysql.cj.jdbc.Driver,PostgreSQL用org.postgresql.Driver,连接串格式差别很大。JDBC连接串,MySQL形如jdbc:mysql://ip:3306/db,PG是jdbc:postgresql://ip:5432/db。连接池Druid、HikariCP基本不用改,但要注意连接串里的参数,比如MySQL的useSSL、serverTimezone这些参数PG不认识,要去掉。PG连接串里通常要配currentSchema,因为PG的逻辑结构是多schema的,不像MySQL库和schema基本是一回事。
Python应用,MySQL常用pymysql、mysqlclient,PG常用psycopg2或asyncpg。psycopg2的游标返回值和MySQL的驱动有些差别,比如默认返回元组列表,需要把行工厂设成dict才能得到字典类型结果。SQLAlchemy的话,换方言和后端URL基本就能跑通。
ORM这层是迁移的“减震器”。如果你用的MyBatis,SQL写在XML里,改起来确实费劲,但也好在SQL都集中了,搜索替换就能找出大部分问题。用JPA/Hibernate的项目,框架会自动生成SQL,很多差异被屏蔽掉了,但遇到@Query注解里的自定义SQL时,还是要人工改一遍。
连接池参数也需要按PG特性调整。PostgreSQL每个连接都是一个独立的进程,内存占用高于MySQL的线程模型,所以连接池上限不要拍脑袋设成200、300。我一般建议核心服务连接池初始10、最大50左右,配合PG的max_connections一起规划。连接稳不住,数据库再强也白搭。
5. 常见问题排查与性能调优
5.1 高频报错与解决思路速查
迁移过程中遇到报错是常态,我把自己踩过的、带新人也常见的几个问题整理成一个速查表,方便你排查时快速定位。
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
column must appear in the GROUP BY clause | GROUP BY语法严格 | 把非聚合列加到GROUP BY或包进聚合函数 |
relation "xxx" does not exist | 表名大小写被折叠 | 统一使用小写表名,或用双引号包裹 |
ERROR: invalid input syntax for type timestamp | 日期值为0000-00-00 | 导数据时转成NULL,或改成合法时间 |
duplicate key value violates unique constraint | 迁移时约束已建立,数据有重复 | 先查重复数据,清理后再导 |
there is no unique or exclusion constraint matching the ON CONFLICT specification | ON CONFLICT需要指定唯一约束 | 确认冲突列上有唯一索引或主键 |
function now() does not exist | 常见于CURRENT_TIMESTAMP默认值问题 | 统一用CURRENT_TIMESTAMP或now() |
could not resize shared memory segment | 共享内存配置不足 | 调整docker或宿主机的shared memory限制 |
FATAL: sorry, too many clients already | 连接数打满 | 缩小连接池,或调大max_connections |
大小写问题是我见过最多的。MySQL的表名在Linux下区分大小写,Windows下不区分,项目换环境后容易埋雷。PG在这方面更严格,我的建议是迁移前直接写一个脚本,把所有表名、列名统一转成小写下划线格式,一步到位,省得后面每个SQL都加双引号。
还有一个很容易忽略的:自增序列不同步。pgloader会自动重置序列,但如果你是用自定义脚本迁移的,往往会忘记重置。结果就是应用往PG插入数据时,提示主键冲突。排查方法很简单,查看当前序列值是否小于表里最大ID:
-- 修正序列值 SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));时区问题也是重灾区。MySQL的DATETIME不带时区,写入什么就是什么,PG的TIMESTAMPTZ会按数据库时区转换。如果应用服务器、数据库、缓存用的时区不一致,很容易出现“时间差了8小时”的诡异问题。我的建议是PG数据库、应用JVM时区、连接串时区全部统一成UTC,展示层再转本地时区,这是最不容易出错的做法。
5.2 迁移后性能对比与PG关键参数调优
迁移完成不代表结束,性能验证和调优同样重要。我建议切流后跑一周的对比监控,重点关注慢查询数量、平均响应时间、CPU/内存占用、连接数变化。
PostgreSQL的调优思路和MySQL有些不同。MySQL最常用的是InnoDB缓冲池innodb_buffer_pool_size,PG对应的是shared_buffers,但PG同时依赖操作系统页面缓存,所以shared_buffers通常建议设置为内存的25%左右,而不是像InnoDB那样直接给70%。
工作内存方面,work_mem控制排序、哈希操作的内存。它和MySQL的sort_buffer_size类似,但PG的work_mem是按操作分配的,一个复杂查询可能同时用多个,所以不能调太大,否则在高并发下内存瞬间被打爆。
maintenance_work_mem用于维护操作,比如建索引、VACUUM,可以给大一些,1GB到2GB都是合理范围。
最需要理解的概念是MVCC和VACUUM。PG的MVCC和MySQL的InnoDB不一样,PG更新一行实际上是插入一个新版本,旧版本需要清理,这个清理过程就是VACUUM。autovacuum在默认配置下能工作,但如果你批量更新了大量数据,建议手动执行一次VACUUM ANALYZE,更新统计信息、清理死元组。
-- 手动清理并更新统计信息 VACUUM (ANALYZE, VERBOSE) your_table;在性能调优上,PG有一样MySQL羡慕的功能:更精确的EXPLAIN ANALYZE执行计划。它能显示实际行数和计划行数的差异,还能告诉你哪一步消耗时间最长、内存用了多少。排查慢SQL时,不要瞎猜,先看执行计划,再看索引有没有命中。PG支持部分索引、表达式索引、GIN索引,很多MySQL里只能靠“加冗余字段”解决的问题,在PG里可以靠索引设计优雅地解决。
最后提一下checkpointer。PG的checkpointer进程负责定期把脏数据刷到磁盘,它和MySQL的checkpoint机制类似,但日志叫法和触发条件不一样。如果你在日志里看到关于checkpoint的告警,通常不是进程本身出问题,而是磁盘I/O能力跟不上,或者checkpoint_timeout、max_wal_size设置得不合理。适当调大max_wal_size可以减少checkpoint频率,降低I/O峰值。
迁移这个事,技术方案再完美,人也可能出错。我个人体会是,给每一步都加一个“验证环节”,导出后验证行数,同步后验证指纹,切换前验证灰度,能解决绝大多数事故。数据库迁移不是一次性动作,而是一次完整的演练,所以测试环境先跑两遍,把每一步的日志和耗时都记录下来,等到真正动生产的那天,心里才有底。