做了不少年数据库相关的工作,MySQL和PostgreSQL都深度用过,这几年陆陆续续接手了好几个从MySQL迁移到PostgreSQL的项目。这个主题最近确实很热,有人是冲着PostgreSQL更强的查询能力去的,有人是为了统一技术栈,也有人是因为合规或者国产化要求必须换库。不管出于什么原因,迁移这件事本身都不只是“把数据倒过去”那么简单,里面涉及表结构转换、SQL语法差异、数据校验、停机窗口设计、回滚预案,每一步都有坑。
这篇文章我打算按照我自己的实操经验,把从MySQL迁到PostgreSQL的完整流程拆开讲清楚。包括迁移前怎么评估、工具怎么选、表结构怎么转换、数据怎么同步、切库怎么切、出了问题怎么回滚,以及我在实际项目中踩过的那些坑。内容会比较长,但都是能直接落地的东西,适合正准备做迁移评估的架构师、负责执行迁移的DBA,以及被临时拉来干这个活的开发同学参考。
1. 为什么值得从MySQL迁到PostgreSQL
1.1 两个数据库的本质差异
先说结论:MySQL和PostgreSQL虽然都是关系型数据库,但设计哲学差别很大。MySQL的设计初衷是“轻量、快速、简单”,在互联网读多写少的场景下表现优秀,生态成熟,运维资料一堆。PostgreSQL则更像一个“什么都能干”的瑞士军刀,功能全面,SQL标准兼容度高,擅长复杂查询和数据分析,扩展能力极强。
如果你只是存一些简单业务数据,几十张表,查询条件单一,那MySQL完全够用,甚至更省心。但一旦涉及复杂报表、多层子查询、窗口函数、递归查询、JSON数据结构化查询这些场景,MySQL写起来很别扭,优化器也不一定配合你。PostgreSQL在这些方面是另一个水平,尤其是PG 12以后,优化器能力和并行查询越来越强,复杂SQL的响应速度往往能给你惊喜。
两者的核心差异我用一个表格归纳下:
| 对比维度 | MySQL | PostgreSQL |
|---|---|---|
| 事务隔离 | 默认Repeatable Read,实现方式为MVCC+锁 | 默认Read Committed,MVCC实现更成熟 |
| 复杂查询 | 优化器相对保守,子查询性能不稳 | 优化器强大,支持CTE、窗口函数、递归 |
| JSON支持 | JSON类型,功能有限 | JSONB类型,支持索引和高效查询 |
| 索引类型 | B-Tree为主,8.0支持倒排索引 | B-Tree、GIN、GiST、BRIN、部分索引、表达式索引 |
| 分区表 | 8.0开始支持,但运维成本高 | 原生支持声明式分区,10.0后很成熟 |
| 扩展能力 | 插件生态一般 | 扩展机制强大,PostGIS、pgvector等 |
| SQL标准 | 兼容性一般,方言多 | 兼容性高,SQL标准贴合度好 |
| 许可证 | GPL/商用双许可 | PostgreSQL License,宽松自由 |
这里面最关键的一个点是“优化器”。MySQL在复杂查询上的表现,说实话有点看运气,同样的SQL换个数据分布,执行计划可能就变了。PostgreSQL基于代价的优化器做得更精细,统计信息更丰富,复杂查询的稳定性明显更好。这也是很多团队从MySQL迁到PG的最直接原因。
1.2 什么情况下该迁,什么情况下不该迁
我见过不少团队是看到别人迁了,自己也想迁,结果搞了几个月一地鸡毛。所以在动手之前,先想清楚你到底为什么要迁。
适合迁移的场景有几个。第一,业务里复杂查询占比高,报表需求多,MySQL跑不动或者SQL写起来太痛苦。第二,需要JSONB、全文检索、地理空间这类高级特性,MySQL要么没有要么体验不佳。第三,有合规或者国产化要求,需要替换掉MySQL,PostgreSQL是常见选择。第四,团队要统一技术栈,减少多数据库维护成本。
不适合迁移的也明显。如果项目就是个简单的CRUD应用,几十张表,查询都很简单,MySQL运行得好好的,那迁移纯属给自己找事。尤其是团队里没人真正用过PostgreSQL的情况下,迁移后SQL写法、索引设计、调优手段都要重新学,隐性成本非常可观。
还有一种情况要特别提醒:如果核心诉求是“解决慢查询”,先别急着迁移。先看是不是SQL本身写得烂、索引没建对、数据模型设计不合理。很多时候把这些基础问题解决了,MySQL的性能还远没到瓶颈。为了一个建好索引就能解决的问题去迁移数据库,代价太大了。
2. 迁移前的准备:别急着动手
2.1 盘点要迁移的存量对象
迁移的第一步不是装PostgreSQL,而是把现有MySQL里的家底盘清楚。别嫌这步枯燥,后面所有计划都建立在这个清单之上。
我一般会先执行几条SQL把全貌摸出来。查看所有数据库和大小,统计每个库的表数量,找出大表,再列出所有视图、存储过程、触发器、定时事件。这些信息决定迁移的复杂度和工作量。
比如在MySQL里可以用这样一组查询来盘点:
-- 查看所有数据库及大小 SELECT table_schema, ROUND(SUM(data_length + index_length) / 1024 / 1024, 2) AS size_mb FROM information_schema.tables GROUP BY table_schema; -- 查看每个库的表数量 SELECT table_schema, COUNT(*) AS table_count FROM information_schema.tables WHERE table_type = 'BASE TABLE' GROUP BY table_schema; -- 找出超过1GB的大表 SELECT table_schema, table_name, ROUND((data_length + index_length) / 1024 / 1024 / 1024, 2) AS size_gb FROM information_schema.tables WHERE table_type = 'BASE TABLE' HAVING size_gb > 1 ORDER BY size_gb DESC; -- 列出所有存储过程、函数、触发器 SELECT routine_name, routine_type FROM information_schema.routines; SELECT trigger_name, event_object_table FROM information_schema.triggers;这些SQL在迁移规划阶段价值很大。我见过有人迁移到一半发现有个300GB的大表没评估进去,导致停机窗口严重超时。还有的发现业务里用了大量存储过程,而MySQL的存储过程和PG的PL/pgSQL语法差异不小,改造工作量被严重低估。
除了数据库对象本身,还要梳理应用层的SQL使用情况。这一步同样关键。如果应用是ORM框架(比如MyBatis、JPA、Hibernate)生成的SQL还好,改改方言配置就能跑。但如果项目里有大量手写SQL,尤其是复杂查询、动态拼接SQL,那就要逐条检查兼容性。我通常会让开发团队跑一个静态扫描,把项目代码里所有手写SQL提取出来,和类型转换清单做比对,提前标记出有风险的语句。
2.2 迁移工具选型对比
工具选得好,迁移成功一半。市面上可以用的方案大致分为四类,我按推荐程度排个序:
| 工具/方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| pgloader | 中小型库全量迁移 | 开源免费,支持自动类型转换、索引转换等 | 对超大库同步速度一般 |
| mysqldump + 手工转换 | 数据结构简单、表量少 | 可控性强,不需要额外安装工具 | 需要大量手工调整DDL |
| Navicat等GUI工具 | 快速小规模迁移 | 操作直观,点点鼠标就行 | 复杂对象支持差,类型映射粗糙 |
| Debezium + 自研同步脚本 | 大库、准实时迁移 | 支持增量同步,停机短 | 部署复杂度高,需要中间件运维 |
我个人的经验是,中小型项目首选pgloader,它能把MySQL的建表语句自动转换为PostgreSQL格式,大部分数据类型都能正确映射,索引、外键也能一并处理。真遇到转换不了的特殊类型,它会报错并告诉你原因,方便手工修正。
如果库特别大,数据量在TB级别,那pgloader在全量阶段会比较吃力,通常的做法是先做一次全量初始化,再用Debezium监听MySQL binlog做增量同步,最后在停机窗口内完成最终切换。这个方案对运维能力要求高,但能实现准不停服迁移。
还有一种情况是项目里MySQL用了一些PG完全没有对应的功能,比如某些存储引擎特性、自定义函数,这些对象需要提前在PG里用别的方案重写。这些特殊依赖都应该在盘点阶段标记出来,而不是等迁移时才发现。
2.3 搭建PostgreSQL目标环境
迁移前先把PG环境准备好,版本选择上我通常会选当前稳定版,PG 16或者更新的版本。Windows环境安装直接去官网下安装包即可,安装过程中记得选择安装pgAdmin和Stack Builder,后续管理方便很多。Linux环境用发行版的包管理器装也行,但建议直接使用PostgreSQL官方提供的APT/YUM源,版本更新而且不会有系统自带版本太旧的问题。
装完PG后,有几个参数我强烈建议在迁移前就调好。shared_buffers设置为核心内存的25%左右,work_mem根据并发和内存大小调整,maintenance_work_mem在迁移建索引时可以临时调大,wal_level如果是后续要做增量同步就必须设置为replica或者logical。另外一定要确认字符集选择UTF8,排序规则用合理的选项,这个在初始化数据库时就要确定,后面改起来非常麻烦。
注意:数据库字符集和排序规则是迁移中最容易忽略的一项。MySQL的utf8mb4对应PG的UTF8,但如果是中文排序、大小写敏感这类需求,需要在初始化数据库时规划好LC_COLLATE和LC_CTYPE,否则建完库再想改就麻烦了。
3. 迁移实操:从表结构到数据再到业务逻辑
3.1 表结构与数据类型映射
这是整个迁移里工作量最大、最琐碎的部分。MySQL和PostgreSQL的数据类型虽然名字看着像,但实际语义和长度定义有不少出入。我整理了一张常用的映射表,可以直接照着用:
| MySQL类型 | PostgreSQL类型 | 说明 |
|---|---|---|
| TINYINT | SMALLINT | MySQL的TINYINT是1字节,PG里最接近的是SMALLINT |
| TINYINT(1) | BOOLEAN | 如果业务把它当布尔值用,建议直接转BOOLEAN |
| SMALLINT/INT | SMALLINT/INTEGER | 长度修饰符省略,PG不关心显示宽度 |
| BIGINT | BIGINT | 无变化 |
| FLOAT/DOUBLE | REAL/DOUBLE PRECISION | 注意浮点精度踩坑 |
| DECIMAL/NUMERIC | NUMERIC | 基本兼容 |
| CHAR/VARCHAR | CHAR/VARCHAR | 注意VARCHAR长度定义,PG没有长度上限默认值 |
| TEXT | TEXT | 无变化 |
| DATETIME | TIMESTAMP | 对应不带时区的时间戳 |
| TIMESTAMP | TIMESTAMPTZ | 强烈建议用带时区类型 |
| DATE | DATE | 无变化 |
| TIME | TIME | 无变化 |
| ENUM | VARCHAR + CHECK约束 | PG有原生ENUM,但后续加值要ALTER TYPE,不灵活 |
| SET | TEXT + CHECK约束 | 没有直接对应,按业务拆解 |
| JSON | JSONB | JSONB支持索引,性能更好 |
| BLOB/LONGBLOB | BYTEA | 二进制大对象 |
| LONGTEXT | TEXT | 无变化 |
这里面最坑的是TINYINT(1)。MySQL里很多开发者用它表示布尔值,迁移到PG时如果直接转成SMALLINT,应用层用0和1判断还好,但如果是通过ORM映射成boolean字段的,就会出问题。我在一个项目里遇到过一次,迁完后某个接口突然报错,排查半天发现就是ORM框架把TINYINT(1)映射为Boolean,而PG那边是SMALLINT,数据能查出来但类型转换失败。所以迁移前一定要搞清每个TINYINT(1)字段在业务里的真实含义。
来看一个实际的DDL转换例子。MySQL的建表语句是这样的:
CREATE TABLE `orders` ( `id` INT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号', `user_id` BIGINT NOT NULL, `status` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '0-待支付 1-已支付', `total_amount` DECIMAL(10,2) NOT NULL, `remark` TEXT, `extra` JSON, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';转换成PostgreSQL的DDL:
CREATE TABLE orders ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, status BOOLEAN NOT NULL DEFAULT FALSE, total_amount NUMERIC(10,2) NOT NULL, remark TEXT, extra JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT uk_order_no UNIQUE (order_no) ); CREATE INDEX idx_user_id ON orders (user_id); COMMENT ON TABLE orders IS '订单表';注意几个细节。MySQL的AUTO_INCREMENT在PG里用GENERATED ALWAYS AS IDENTITY替代,这是PG 10开始支持的标准写法,比用SERIAL更规范。MySQL的默认值CURRENT_TIMESTAMP对应PG的NOW()。ON UPDATE CURRENT_TIMESTAMP这个行为PG没有原生支持,通常的做法是应用层更新时同时更新updated_at字段,或者用触发器实现。
3.2 约束、索引、自增主键的处理
索引迁移这块很多人只关心主键和普通索引,其实PG的索引能力远不止这些。如果你之前用MySQL只是为了主键查询和简单过滤,那PG的B-Tree索引表现不差。但如果业务里需要全文检索、JSON字段查询、数组包含这类操作,PG的GIN索引能带来质的提升,这是迁移后的一个红利。
不过索引迁移有个容易忽略的坑:MySQL里外键约束往往没建,因为InnoDB下建外键会影响写入性能,很多团队干脆不建。PG里如果迁移时直接把外键约束补上,迁移后写入变慢会很明显。我的建议是,如果业务确实依赖外键保证数据完整性,那就保留;如果只是为了“规范化”而建,实际应用层已经保证了完整性,那就不要建,保持和原来一致的性能表现。
还有一个需要特别留意的:MySQL的唯一索引允许NULL重复,而PG默认也是这样。但如果你在MySQL里用了“允许一个字段多个NULL但非NULL唯一”的业务逻辑,PG的行为和MySQL是一致的,这个不会出问题。可如果业务把NULL当普通值处理,那就要注意了,最好在迁移时用NULLS NOT DISTINCT选项创建唯一约束,PG 15开始支持这个特性。
3.3 存储过程与触发器的改造
MySQL存储过程和PostgreSQL的PL/pgSQL虽然都是过程化语言,但语法差异很大。MySQL的存储过程用BEGIN...END包裹、用DELIMITER声明结束符,变量用DECLARE定义、SELECT ... INTO赋值。PL/pgSQL则用$$作为函数体界定符,赋值用:=,返回值用RETURN,结构上更接近Oracle的PL/SQL。
举一个典型的例子,MySQL里一个简单的存储过程:
DELIMITER $$ CREATE PROCEDURE get_user_orders(IN p_user_id BIGINT) BEGIN SELECT id, order_no, total_amount FROM orders WHERE user_id = p_user_id; END$$ DELIMITER ;对应的PostgreSQL函数:
CREATE OR REPLACE FUNCTION get_user_orders(p_user_id BIGINT) RETURNS TABLE(id INTEGER, order_no VARCHAR, total_amount NUMERIC) AS $$ BEGIN RETURN QUERY SELECT o.id, o.order_no, o.total_amount FROM orders o WHERE o.user_id = p_user_id; END; $$ LANGUAGE plpgsql;这还算简单的。如果遇到复杂的存储过程,里面使用了游标、动态SQL、异常处理,改造的工作量可能会超出想象。所以我一直强调,迁移评估阶段要对存储过程按复杂度打分,提前预估改造工时。
触发器的差异同样值得注意。MySQL的触发器直接用CREATE TRIGGER,PG则要先写一个返回TRIGGER类型的函数,再用CREATE TRIGGER绑定。逻辑没变,但代码结构完全不同。另外PG的触发器函数里通过NEW和OLD行变量访问数据,这点和MySQL相似,不算难理解。
3.4 应用层SQL语句的兼容性调整
数据结构和存储过程改造完成后,应用层的SQL兼容性调整是另一个大工程。很多应用把MySQL特有的语法用得很顺手,到了PG下面这些SQL全部报错。下面这张表是我在项目中总结的高频差异点:
| MySQL写法 | PostgreSQL写法 | 说明 |
|---|---|---|
`field` | "field" | MySQL用反引号,PG用双引号 |
LIMIT 10, 20 | LIMIT 20 OFFSET 10 | MySQL的偏移分页语法PG不支持 |
GROUP BY宽松模式 | GROUP BY严格模式 | PG要求SELECT的列必须出现在GROUP BY中 |
IFNULL(a, b) | COALESCE(a, b) | 功能等价 |
CONCAT(a, b, c) | CONCAT(a, b, c)或a || b || c | 两者都支持,但||在MySQL里是逻辑或 |
NOW() | NOW()或CURRENT_TIMESTAMP | 基本兼容 |
INSERT ... ON DUPLICATE KEY UPDATE | INSERT ... ON CONFLICT (col) DO UPDATE | 语义类似,语法不同 |
REPLACE INTO | INSERT ... ON CONFLICT ... DO NOTHING/UPDATE | REPLACE会先删后插,容易丢失数据 |
DATE_FORMAT() | TO_CHAR() | 格式化函数差异大 |
%通配符在LIKE中 | 同样适用 | 兼容 |
UNSIGNED整数 | 无对应 | PG不支持无符号,需用CHECK约束模拟 |
这里我重点说两个容易踩坑的地方。
第一个是GROUP BY的严格模式。MySQL在ONLY_FULL_GROUP_BY默认关闭时,允许SELECT中列出不在GROUP BY里的列,而且不报错,只是返回的值不确定。很多开发者习惯了这种写法,业务代码里处处都是。迁移到PG后,这些SQL全部会报错,必须把每个非聚合列都加进GROUP BY,或者改成聚合函数包裹。这个过程非常折腾,我见过一个项目在迁移阶段改了几百条类似的SQL。
第二个是INSERT ... ON DUPLICATE KEY UPDATE。很多团队用它实现在并发场景下的幂等写入,MySQL里一行就能搞定。PG的等价写法是ON CONFLICT,但要注意必须指定冲突的约束或列名。举个例子:
MySQL的写法:
INSERT INTO orders (order_no, user_id, total_amount) VALUES ('NO10001', 1, 99.00) ON DUPLICATE KEY UPDATE user_id = VALUES(user_id), total_amount = VALUES(total_amount);PostgreSQL的写法:
INSERT INTO orders (order_no, user_id, total_amount) VALUES ('NO10001', 1, 99.00) ON CONFLICT (order_no) DO UPDATE SET user_id = EXCLUDED.user_id, total_amount = EXCLUDED.total_amount;注意PG里通过EXCLUDED关键字引用准备插入但发生冲突的那行数据,而不是MySQL的VALUES()函数。如果漏掉了ON CONFLICT后面的列名,PG会报错让你明确冲突目标。
4. 迁移数据:全量同步与校验
4.1 用pgloader做全量迁移
工具层面,我用的最多的是pgloader。这个工具专门用于从其他数据库迁移到PostgreSQL,对MySQL的支持很成熟,能自动做类型转换、关键字转义、索引转换,还能并行加载。
安装pgloader的方式,macOS直接brew install pgloader,Linux下可以从源码编译,Deployment版本容易找。Windows下稍微麻烦点,我建议在WSL环境里跑,或者直接放在Linux服务器上执行。
一条最基础的pgloader迁移命令是这样:
pgloader mysql://user:password@127.0.0.1:3306/source_db postgresql://user:password@127.0.0.1:5432/target_db它会把整个库的所有表、索引、约束一次性迁移过去,并在最后输出一份报告,显示每张表的行数、错误数、耗时等。如果你的库比较简单,这条命令就够用了。
但实际项目中通常需要更精细的控制,可以使用.load文件来定义迁移规则:
LOAD DATABASE FROM mysql://user:password@127.0.0.1:3306/source_db INTO postgresql://user:password@127.0.0.1:5432/target_db WITH include drop, create tables, create indexes, reset sequences, workers = 8, concurrency = 4, batch rows = 1000 SET PostgreSQL PARAMETERS maintenance_work_mem = '256MB', work_mem = '16MB' CAST type datetime to timestamptz using zero-dates-to-null, type tinyint(1) to boolean when maybe;这里说几个关键参数。create tables让pgloader自动生成目标表结构,reset sequences负责重建自增序列,workers和concurrency控制并行度,数据量大的时候适当调高能显著提升速度。CAST那段是自定义类型转换规则,比如把TINYINT(1)转成BOOLEAN,把无效的零日期转成NULL,这些规则在实际迁移中非常实用,因为很多老系统的数据质量并不理想。
pgloader跑完后,我会先看报告里的错误数。如果错误比较多,一般是某个字段的数据格式无法转换,比如MySQL的DATETIME里存了0000-00-00这种非法值,PG会拒绝导入。处理方式是在CAST规则里指定zero-dates-to-null,或者先在MySQL侧把这部分脏数据清理掉。
注意:pgloader在迁移超大的表时,单表加载速度和索引重建速度可能感人。我遇到过一张5亿行的日志表,全量加载加索引重建跑了将近10个小时。如果业务有这类大表,一定要提前压测,给停机窗口留足余量。
4.2 数据校验怎么做才靠谱
数据迁移完不代表万事大吉。校验是很多人会跳过或者敷衍的一步,但我每次都会做得很细。原因很简单:数据是公司资产,哪怕丢了几行,业务上线后出问题就是事故级别的故障。
我常用的校验方案分三层。第一层是行数校验,最简单但最容易发现问题。分别对MySQL和PG执行COUNT(*),对比每张表的行数是否一致。表多的时候可以用脚本批量比对,MySQL和PG都有information_schema,写个Python或Shell脚本,把每张表的行数拉出来做diff,几分钟就能完成。
第二层是抽样数据比对。每张表按主键范围或者随机抽取一定比例的行,逐字段对比内容。这个可以用工具,比如从MySQL导出CSV,再在PG里导入临时表对比。数据量大的表做全量比对不现实,抽样加明细抽查是性价比最高的方式。
第三层是业务探针。这个最灵活,也最能发现“数据没丢但业务不对”的问题。挑几个核心业务场景,写一些关键查询,在MySQL和PG上分别执行,对比返回结果。比如查某个用户的订单总数和总金额、查某天的销售汇总、查订单状态分布。这一步能发现字段类型映射导致的内容变化,比如浮点精度、时区、布尔值存储差异,这些靠行数校验发现不了。
数据校验通过后,还有一个很容易忽略的步骤:对PG的表执行ANALYZE刷新统计信息。PG的查询优化器严重依赖统计信息,如果刚迁移完就直接上线跑业务,很多SQL会因为统计信息缺失或过旧走错执行计划,性能表现会很差。迁移完第一时间对所有表跑一遍ANALYZE,这个动作虽然简单,但对后续线上性能有直接帮助。
-- 对全库所有表执行统计信息收集 ANALYZE;5. 停机切换与回滚预案
5.1 切换前检查清单
数据同步完成、校验通过之后,真正切库的前一刻,一定要有一份详尽的检查清单。我见过太多项目在切换当天出问题,不是数据库本身不行,而是准备工作有遗漏。下面是我每次切换前必查的清单:
- 应用层所有连接串是否已改为指向PG的IP和端口,能改配置文件的改配置,写死在代码里的要提前发版
- 数据库账号权限是否最小化配置完成,原来MySQL里的账号对应的PG账号和权限是否一致
- 自增序列是否已同步到正确位置,否则插入新数据时主键冲突
- 定时任务、消息队列的持久化表是否已经迁移,这部分经常被遗漏
- 监控系统和告警规则是否切换了数据源,如果监控还指向旧库,切换后就是两眼一抹黑
- 备份策略是否已生效,PG的备份机制和MySQL不一样,建议切换前先做一次完整备份验证
- 应用服务器的连接池配置是否兼容PG驱动,比如连接池的初始化SQL、验证语句是否还兼容
还有一个细节容易被忽略:原来MySQL的连接串和PG的不一样,应用的数据库驱动也要换掉。Java应用的MySQL驱动和PostgreSQL驱动是不同的JAR包,切换后要确保驱动版本正确、依赖无冲突。Python应用的pymysql和psycopg2也是完全不同的库。这些前置条件如果不准备好,切换时应用会直接连不上数据库。
切换流程上,我的建议是先做一次“试切换”。找一个业务低峰期,把应用从旧库切到新库运行一小段时间,观察日志、监控、慢查询,确认无异常后再切回旧库。这个动作能提前暴露90%以上的配置问题。正式切换时只是把同样的事情再做一遍而已。
5.2 回滚方案设计
再充分的准备,也必须有回滚方案。数据库迁移是一个高风险操作,谁也不能保证一切顺利。我的经验是,回滚方案要写在一页纸上,而且要让执行切换的人能在一分钟内找到。
最简单的回滚方案是:原MySQL库从迁移开始就一直保留,不做任何删除操作。如果切换后发现问题,应用连接串改回MySQL,数据层回退,业务恢复。这个方案的前提是,PG那边没有产生新的业务数据,否则两边的数据就分叉了。
如果PG侧已经跑了业务、写入了新数据,回滚就复杂了。这时候要决定是丢弃PG里这段时间的新数据,还是想办法同步回MySQL。丢弃新数据意味着这段时间的业务数据丢失,很多业务无法接受。所以现在做迁移项目时,我倾向于先让PG以只读或灰度方式运行,等稳定期过了再放开写入。这样回滚时只需要切回MySQL,不涉及数据分叉。
还要强调一点,MySQL侧在迁移期间不要停止备份。万一回滚需要用到MySQL,确保它的数据副本是完整的。迁移过程中对MySQL只做读操作的话,基本上不会影响它的数据安全,但备份是你最后的底牌,不能省。
6. 常见问题与排查技巧实录
迁移过程中遇到的问题千奇百怪,但很多都是共性的。我把这些年遇到的典型问题整理成一张速查表,方便大家按图索骥。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 迁移后中文乱码 | MySQL字符集不是utf8mb4,或PG连接串未指定UTF8 | 迁移前统一源库字符集,PG连接参数加client_encoding=UTF8 |
| 时间数据少了8小时 | DATETIME转TIMESTAMPTZ时未处理时区 | 明确PG的timezone配置,连接串统一时区 |
| TINYINT(1)字段被ORM识别为Boolean | 类型映射时未处理TINYINT(1) | 迁移时显式CAST为BOOLEAN |
| GROUP BY SQL报错 | MySQL宽松模式代码未兼容PG严格模式 | 修改SQL,把非聚合列加入GROUP BY |
| 反引号报错 | SQL里用了MySQL专属反引号 | 替换为PG的双引号或直接去掉 |
| 分页数据错乱 | LIMIT offset, count语法不兼容 | 改成LIMIT count OFFSET offset |
ON DUPLICATE KEY UPDATE报错 | PG语法不同 | 改用ON CONFLICT (col) DO UPDATE |
| 自增主键冲突 | 序列未重置到正确位置 | 执行setval同步序列值 |
| 连接串无法连接 | PG监听配置、pg_hba.conf或驱动版本问题 | 检查listen_addresses、pg_hba.conf放行规则、更换驱动 |
| 迁移后首次查询慢 | 统计信息未更新 | 执行ANALYZE刷新统计信息 |
| 大表加载慢 | pgloader并行度不够或索引重建耗时 | 调整workers参数,先导数据后建索引 |
实际项目里我踩过最惨的一个坑是在数据校验环节。当时只做了行数对比,所有表行数都对得上,就直接切换上线了。结果第二天运营反馈某张表的用户积分数据不对,排查发现是MySQL的DECIMAL(10,2)字段里存了一个超出精度范围的值,PG加载时自动做了四舍五入。行数没变,但数值变了。后来我在校验脚本里加了一个字段级sum校验,对关键数值字段做SUM()比对,从那以后再没出过类似问题。
还有一次是时区问题。MySQL的DATETIME不带时区信息,原来的应用在存储时间时用代码在内存里做了时区转换,表现没问题。迁移时我把DATETIME转换成了TIMESTAMPTZ,PG根据服务器的时区设置自动转换了时间,结果应用层又做了一次时区偏移,所有时间都变成了UTC+16的效果。这个问题的排查过程特别痛苦,因为单看PG里的数据是对的,单看应用逻辑也是对的,但合在一起就错了。最后通过打印SQL日志和数据库连接的timezone参数才定位到。
最后说一个排查心法:迁移出的问题,很多时候不在数据库本身,而是应用层对SQL方言的依赖。遇到诡异问题,第一反应不是去翻PG文档,而是先确认应用层有没有写了MySQL特定的SQL、有没有硬编码驱动配置、有没有在代码里拼了数据库特有的函数。这个思路能帮你在排查时少走很多弯路。
结尾
做了这么多迁移项目,我最大的体会是:从MySQL迁移到PostgreSQL,技术上的类型转换和SQL改造只是表面工作,真正决定成败的是前期的对象盘点和数据校验做得到不到位。很多项目迁完跑不起来,并不是因为PG不好,而是因为准备阶段偷了懒。
如果你的项目也准备启动迁移,我个人建议先在测试环境完整走两遍全流程。第一遍熟悉工具和流程,记录所有报错和耗时;第二遍处理掉之前的问题,重点验证增量同步和切换步骤。两遍跑完你心里就有底了,正式迁移时哪怕出点小状况,也知道该往哪个方向查。别嫌这个流程费时间,跟线上出事故的代价比起来,这点成本真的不值一提。