拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从MySQL到PostgreSQL:迁移全流程实战与避坑要点

从MySQL到PostgreSQL:迁移全流程实战与避坑要点

迁移这种事,做之前总觉得是“导出数据再导入,改改连接串就完事”,做之后才发现,真正让人熬夜的从来不是搬运数据本身,而是那些藏在 SQL 语义、驱动行为、运维模型里的隐性差异。我前后带团队做过两次从 MySQL 到 PostgreSQL 的完整迁移,第一次从排期到稳定运行花了三个月,第二次有了完整方法论文档,压缩到五周。这篇就是把两次实践沉淀下来的经验拆开讲:从要不要迁、怎么盘点、如何做结构转换、用什么工具搬数据,到应用层怎么改、上线后怎么校验和回滚,以及迁移完的运维要点。适合正在评估换库的技术负责人、DBA,也适合刚接触 PostgreSQL、想提前避坑的应用开发者。

先说一个总判断:MySQL 和 PostgreSQL 都是非常成熟的数据库,绝大多数场景下,选哪个都能满足业务。所以“从 MySQL 迁到 PostgreSQL”这个命题,前提一定是业务出现了 MySQL 解决起来很别扭、而 PostgreSQL 天生擅长的需求。后面的每一章,都围绕这个前提展开。

1. 为什么放着 MySQL 不用非要迁到 PostgreSQL:一次迁移的真实动机

1.1 触发迁移的典型业务信号

我经手的第一条迁移案例来自一个电商后台系统。MySQL 8.0,核心订单表 3000 多万行,后台运营人员的组合筛选经常拖到 5 秒以上。一开始团队怀疑是索引没建好,但排查后发现,真正把 MySQL 逼到死角的是两类需求:

第一是大量 JSON 半结构化数据。商品扩展属性、活动配置、买家标签都塞在 JSON 字段里,查询条件经常落在 JSON 内部的数组元素上,MySQL 的 JSON 类型虽然能做路径查询,但索引覆盖能力非常有限,执行计划经常走不了任何索引。第二是地理信息处理。做门店配送范围分析时,需要在经纬度上做距离排序和范围圈选,InnoDB 的索引结构本身不支持这类空间语义,只能靠应用层把数据捞出来硬算。

这两件事放到 PostgreSQL 里几乎是开箱即用:jsonb配 GIN 索引,PostGIS 配 GiST 索引,性能差距不是一个量级。类似的功能性诉求还包括:复杂报表里的窗口函数、递归 CTE 越来越高频,PostgreSQL 的优化器对复杂 JOIN 的选路明显更稳;数据完整性要求高,外键和 CHECK 约束需要被严格贯彻执行,而 MySQL 在部分历史配置下会出现“约束定义还在,实际校验却松散”的情况。

这些信号通常不是单独出现的。如果在你的系统里同时看到两三条,迁移就有了真实的价值支点;如果只是“听说 PG 更强”,建议先冷静。

1.2 迁移前先做的三件事:摸清实例、查 SQL、定窗口

决定迁移前,除了确认业务动机,还要把三件基础工作提前做完,否则排期全靠拍脑袋。

第一,摸清存量实例。到底有多少个 MySQL 实例?每个实例里哪些库是核心业务库、哪些已经没人维护?生产、测试、预发环境分别怎么管理?很多时候团队只迁了主力库,留下十几套边角库,后续运维反而更混乱。

第二,收集应用 SQL 清单。这一步在整个迁移里价值最高。把源库慢查询日志收集两周,整理出 Top 100 的 SQL,同时让各个业务线自查代码仓库里的 SQL 写法。后面所有兼容性改造、性能回归,都靠这份清单做底。

第三,定停服窗口。如果业务允许一个周末停服迁移,后面的增量同步那一层可以简化,全量搬完校验即可;如果要求在线迁移不中断,就要提前设计 binlog 增量同步方案。我见过不少项目在最开始没确认这点,做到一半发现停服窗口不够,只能临时加班赶增量方案,风险一下子高了很多。

1.3 什么情况下不建议迁

不是所有场景都适合迁移。遇到下面四类情况,我通常会明确劝退:

  • 核心业务全是简单 CRUD,单条 SQL 只跑主键查询,没有复杂聚合和 JSON 检索。MySQL 和 PostgreSQL 在这种场景下没有体感差异,迁移投入纯粹是浪费。
  • 存量存储过程和触发器数量巨大,且重度依赖 MySQL 专有函数。PostgreSQL 的 PL/pgSQL 确实更强,但等价改写的工作量很容易被低估。我见过一个系统 600 多个存储过程,迁移组最初乐观估计两周,实际用了两个月。
  • 团队没有 PostgreSQL 运维经验,且预算不允许补充人力。vacuum、连接进程模型、事务快照机制都和 MySQL 差异很大,出了问题现场查文档很难应对线上故障。
  • 周边工具链没有就绪。监控告警、备份恢复、中间件兼容、数据同步工具都要重新接,这些隐性成本经常被忽略。

一句话:先确认你遇到的是“MySQL 解决不了的问题”,而不是“你没把 MySQL 用好的问题”。前者迁移才有意义。

2. 迁移前的地图:版本选型、环境搭建与对象盘点

2.1 PostgreSQL 版本选择与实例初始化参数

版本选择上,目前建议直接上 PostgreSQL 16 或 17。新项目直接用 17,存量项目 16 也足够,不建议选 15 以下,版本越新,JSON 能力和优化器表现越好。安装层面,Windows 上用 EnterpriseDB 官方安装包最省事,搜索热词里经常出现“postgresql windows 安装 服务启动失败”,这种问题八成是 data 目录权限不对,或者 5432 端口被占用;Linux 上建议直接用官方 PGDG 源,例如 RHEL 系列:

# RHEL 9 / Rocky Linux 9 示例,其他 EL 版本对应替换 sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm sudo dnf install -y postgresql17-server postgresql17-contrib sudo /usr/pgsql-17/bin/postgresql-17-setup initdb sudo systemctl enable --now postgresql-17

装完第一件事不是急着建库,而是调整几个和 MySQL 使用习惯差异很大的初始化参数。shared_buffers我通常设为物理内存的 25%,但一般不超过 8GB;work_mem默认只有 4MB,如果业务排序和哈希操作多,建议按连接数评估后调到 16MB 甚至 64MB;maintenance_work_mem直接影响 VACUUM 和建索引速度,迁移期间至少给到 256MB。

MySQL 的调优思路相对集中,主要围着innodb_buffer_pool_size转;PG 的内存管理更分散,刚上手的人很容易困惑为什么所有参数都调了,性能还是上不去。关键认知是:shared_buffers只负责缓存数据页,大量的文件系统页缓存被操作系统接管,所以不能把 MySQL 那套“buffer pool 尽量大”的思路直接搬过来。

还有两个初始化时就要确认的点:字符集统一 UTF8,排序规则建议用C或C.UTF-8。如果业务对中文排序有特定要求,可以在 initdb 时指定 ICU 规则,不然后面 LIKE 查询和 ORDER BY 的默认行为会和你预期的完全不一样。

2.2 迁移对象全景清单:漏掉一个后面都是坑

如果只把表数据导过去,这个迁移一定不完整。我习惯在动任何工具之前,先画一张迁移对象全景图:

  • 表结构、视图、物化视图,以及物化视图的刷新逻辑
  • 索引,包括唯一索引、全文索引、前缀索引、函数索引
  • 主键、外键、唯一约束、非空约束、CHECK 约束、默认值
  • 存储过程、函数、触发器、事件计划任务(MySQL EVENT 在 PG 没有原生资源,需要评估用外部队列或 pg_cron)
  • 数据库用户、角色、权限、行级安全策略
  • 依赖的字符集、排序规则、MySQL 特有 SQL 模式

其中 SQL 模式是隐性炸弹。MySQL 的sql_mode影响字符串比较、日期严格校验和 GROUP BY 行为,PG 没有直接对应项,迁移后行为差异只能在 SQL 层消化,后面第 6 章会重点讲。

2.3 用数据字典做一次“结构体检”

动手迁移前,先用数据字典给自己做一次体检。MySQL 的information_schema能查出所有表、列、索引的基本信息,PG 同样兼容这套标准视图,但更深入的膨胀信息、索引使用情况要查系统表,比如pg_stat_user_tables和pg_index。

一个更省事的做法:先用pg_dump导出目标库结构,再人工 review。

pg_dump --schema-only -h localhost -U pguser -d target_db > schema.sql

这份 SQL 比任何可视化差异报告都直观,因为它把建表顺序、依赖关系、扩展加载完整呈现出来。源库侧用mysqldump --no-data导出结构,两边放到同一份对比脚本里逐一核对列名和类型映射。不要指望工具全自动完成这一步,结构转换的正确率直接决定后面数据迁移的顺利程度。

3. 结构转换的深水区:数据类型、自增列与隐式转换差异

3.1 字段类型映射表:照着改就对了

结构转换的第一步,是把 MySQL 字段类型一一映射成 PG 类型。我整理了一份在实际项目里验证过的对照表,并标注了容易踩坑的点:

MySQL 类型PostgreSQL 类型注意点
TINYINTSMALLINT如果原列是 TINYINT(1) 且当布尔用,建议直接用 BOOLEAN
INT / INTEGERINTEGER显示宽度 INT(11) 直接去掉
BIGINTBIGINT位宽一致
DECIMALNUMERIC精度和小数位数保持一致
FLOATREALPG 的 FLOAT 默认是 DOUBLE PRECISION 别名,单精度必须显式用 REAL
DOUBLEDOUBLE PRECISION对应关系明确
VARCHAR(N)VARCHAR(N)长度上限一致,但超长写入时 PG 报错更果断
CHAR(N)CHAR(N)尾部空格补齐行为两边有差异,建议统一去掉尾部空格
TEXTTEXTPG 的 TEXT 没有 64KB 包上限,相当于 MySQL 的 LONGTEXT
BLOB / LONGBLOBBYTEA类型改了,应用层读写 API 也需要改
DATETIMETIMESTAMP不带时区
TIMESTAMPTIMESTAMPTZ如果原字段存 UTC,建议直接转带时区类型
DATE / TIMEDATE / TIME基本等价
JSONJSONB推荐,但要注意写入时键的顺序会被重排
ENUM枚举类型或 VARCHAR + CHECKPG 枚举后续加值靠 ALTER TYPE,麻烦,能用 CHECK 就用 CHECK
SET关联表或数组MySQL 的 SET 找不到直接等价物,推荐拆关联表

这张表里最容易忽略的是 FLOAT。MySQL 的FLOAT是单精度,PG 里如果直接写FLOAT得到的是双精度,单精度必须用REAL,否则数值精度和索引选择都会改变。

3.2 自增主键的三种写法与序列同步问题

MySQL 的自增主键是最典型的迁移点:

CREATE TABLE orders ( id INT AUTO_INCREMENT PRIMARY KEY, ... ) ENGINE=InnoDB;

到 PG 里,等价写法有三类:

-- 方式一:SERIAL 伪类型,最快但不够严谨 CREATE TABLE orders ( id SERIAL PRIMARY KEY, ... ); -- 方式二:GENERATED BY DEFAULT AS IDENTITY(推荐) CREATE TABLE orders ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, ... ); -- 方式三:GENERATED ALWAYS AS IDENTITY(禁止手动插入) CREATE TABLE orders ( id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY, ... );

方式二和方式三的关键区别是:BY DEFAULT 允许显式插入 id,ALWAYS 会强制使用序列生成值。迁移过程中如果要保留原主键值,建议用 BY DEFAULT,否则大批量导入历史数据时主键冲突会让人心态爆炸。

导入完成后,真正容易漏掉的是同步序列值。PG 的序列和表是分离对象,即使你插入了 id=300000 的数据,序列可能还停在 1,应用层下一条 INSERT 就会报唯一约束冲突。需要手动对齐:

SELECT setval(pg_get_serial_sequence('orders', 'id'), (SELECT max(id) FROM orders));

很多用 ORM 自动建表的项目迁移后没有这一步,线上第一个新增数据就炸,建议把它写进迁移脚本的收尾动作。

3.3 字符集、排序规则与大小写敏感的坑

MySQL 最常见的排序规则是utf8mb4_general_ci,默认字符串比较不区分大小写。也就是说,WHERE name = 'ABC'能匹配到'abc',唯一索引上'ABC'和'abc'会被当成同一个值。PG 默认排序规则区分大小写,切过去之后会出现两个典型现象:一是唯一索引不再拦截仅大小写不同的值,业务层唯一逻辑可能冲突;二是登录名、用户名校验等环节突然查不到数据,因为之前依赖了不区分大小写的隐式比较。

解决办法有三类:把查询统一改成lower(name)并建表达式索引;安装citext扩展,让特定字段使用不区分大小写的类型;或者在迁移时通过 COLLATE 指定不区分大小写的规则。我的经验是:字段少、查询模式简单时用 citext 最省事;字段多时还是统一lower()加表达式索引更可控,因为 citext 的索引和排序行为会让后续优化器选路变得更难预测。

3.4 索引与约束迁移:PG 不会替你做的事

MySQL InnoDB 有个隐藏行为:建外键时,如果列上还没索引,InnoDB 会自动创建索引。PG 不会,外键列上的索引必须手动建,否则 UPDATE 或 DELETE 父表时,子表会做全表扫描,线上性能落差非常大。迁移时建议把外键列和常用过滤条件列提前建好索引。

另一个常见差异是前缀索引。MySQL 允许INDEX idx_name (name(10)),PG 原生 B-tree 不支持带长度的前缀索引,但可以用表达式索引代替:

CREATE INDEX idx_name_left10 ON users (left(name, 10));

代价是应用层查询必须写成同样的表达式才能命中索引。至于全文索引,MySQL 的FULLTEXT在 PG 里对应tsvector加 GIN 索引,这只是 DDL 替换,查询语法也要从MATCH...AGAINST改成to_tsvector和plainto_tsquery的组合。

约束方面还要注意默认值函数差异。MySQL 的CURRENT_TIMESTAMP可以直接作为 timestamp 的默认值,PG 同样支持;但一些历史表里写死DEFAULT 0的 timestamp 字段,在 PG 里需要改成合法的字面量或保持可空,否则建表直接报错。

4. 数据迁移的三种实操路线:pgloader、mysqldump 加工与增量工具

4.1 pgloader:一键迁移的上限和下限

如果迁移的表结构比较常规,没有特别复杂的自定义函数和视图,pgloader 是最省力的起点。它天然处理 MySQL 到 PostgreSQL 的转换,能自动完成类型映射、建表、搬索引和约束,甚至能重置序列。

一个典型配置长这样:

LOAD DATABASE FROM mysql://root:password@localhost:3306/source_db INTO postgresql://pguser:password@localhost:5432/target_db WITH include drop, create tables, create indexes, reset sequences, workers = 8, concurrency = 1, max rows per insert = 500 CAST type datetime to timestamptz drop not null using zero-dates-to-null, type date drop not null using zero-dates-to-null; SET PostgreSQL PARAMETERS maintenance_work_mem = '256MB', work_mem = '16MB';

这里有两个细节值得注意。CAST语句把 MySQL 无法表示的'0000-00-00 00:00:00'自动转成 NULL,这是脏数据最常见的来源,不处理的话导入会卡死。另外source_db和target_db必须在同一个数据库实例里,pgloader 没有跨库推送的概念。

pgloader 的局限也很明显:对视图、函数、触发器、事件的支持很弱,通常只负责表结构和数据;超大表迁移速度虽然可以调整 workers 提升,但和物理导入相比还是慢。我的使用习惯是拿它做中小型系统的整体搬迁,大型系统反而更倾向下面的组合路线。

4.2 mysqldump 导出 + SQL 文本改写的应急路线

不少网上教程会建议 mysqldump 加--compatible=postgresql,这里先纠正一个误区:mysqldump 的--compatible选项并不支持 postgresql,它只支持 oracle、ansi、no_table_options 等组合,即便指定 ansi 也不会自动生成 PG 兼容语法。真正可行的“文本改写”路线是分段处理:

第一步,用 mysqldump 只导出数据,不导出表结构:

mysqldump -u root -p --no-create-info --skip-add-locks \ --skip-lock-tables --complete-insert --hex-blob source_db > data.sql

第二步,用 sed 或 perl 清理反引号和 MySQL 专有转义:

sed -i 's/`//g' data.sql

第三步,用 psql 导入,建议包在事务里,失败可以整体回滚。

数据量大时,文本 INSERT 导入远不如 CSV 中转高效。MySQL 侧用SELECT ... INTO OUTFILE导出 CSV,PG 侧用 COPY 导入,速度能差一个数量级:

COPY target_table (col1, col2, col3) FROM '/data/source_table.csv' WITH (FORMAT csv, HEADER true, NULL 'NULL');

如果表里有二进制字段,CSV 中转要小心编码,更推荐让 pgloader 这类专用工具处理 bytea 映射,别用文本中转硬碰。

4.3 在线迁移与增量同步:把停服时间从 8 小时压到 10 分钟

很多业务不允许停服一晚上做迁移,这时必须做增量同步。整体思路是“先全量、后增量、再切换”。

  • 全量同步:挑业务低峰时段,用上述方式把存量数据搬过去。
  • 增量同步:源库开启 binlog,消费 binlog 变更在目标库重放。开源方案里 Debezium 最常用,通过 MySQL binlog 把变更事件发到 Kafka,下游消费后写入 PG;pg_chameleon 是专门做 MySQL 到 PG 实时复制的轻量工具,配置相对简单,适合中小系统。
  • 切换确认:增量延迟追平后,停源库写入,追平最后一段增量,再切应用流量。

整个时间线可以这样估算:

阶段动作预计耗时
准备建库建表、权限、连接串预埋0.5 小时
全量pgloader 或 COPY 导入存量2-6 小时不等
增量binlog 同步持续运行直到追平
校验行数、校验和、抽样比对1 小时内
切换停写、追平、切读、观察10-30 分钟

增量同步工具不是银弹。binlog 里的 DDL 变更、超大事务、特殊字符都需要在消费端容错。我就见过 Debezium 因为源库一条 ALTER TABLE 执行时间过长,导致 binlog 积压,整个 Kafka topic 重建。所以即便有增量工具,也建议保留源库作为热备至少两周,切完不要急着销毁。

5. 应用层改造:连接串、驱动、连接池与 ORM 适配

5.1 各语言驱动替换清单与连接串写法

数据库换了,应用层第一件事就是换驱动。这一步比想象中简单,但连接串参数带来的坑不少。

语言MySQL 驱动PostgreSQL 驱动备注
Javamysql-connector-jorg.postgresql:postgresql连接池配置几乎不变
PythonPyMySQL / mysqlclientpsycopg2 / psycopg3事务行为有差异,需要逐段检查
Node.jsmysql2pg回调风格略有变化
Gogo-sql-driver/mysqljackc/pgx/v5pgx 性能更好,推荐
.NETMySqlConnectorNpgsqlEF Core 提供器要换

Java 里的典型替换:

// 旧 String url = "jdbc:mysql://localhost:3306/source_db?useSSL=false&serverTimezone=Asia/Shanghai"; // 新 String url = "jdbc:postgresql://localhost:5432/target_db?sslmode=prefer";

注意 PG 的 JDBC URL 不需要指定 serverTimezone,驱动默认按照数据库 session 的 timezone 处理。如果代码里大量依赖 MySQL 的时区转换逻辑,切到 PG 后反而要检查时间字段到底是不是带时区,避免展示层时间整体偏移。

Python 侧,psycopg2 和 PyMySQL 的事务风格差异很容易引发线上故障。PyMySQL 进入with connection块后并不会自动开启事务,psycopg2 却会自动 commit 或 rollback。这个差异会把一批“原来能跑、迁后丢数据”的案例带出来,改代码时必须逐段检查事务边界。

5.2 连接池与 PG 进程模型的匹配

MySQL 的连接是线程模型,连接池开到 200 甚至更多问题不大。PG 是进程模型,每一条后端连接对应一个操作系统进程,内存开销明显更高。很多团队迁移后第一反应是“怎么这么占内存”,其实就是连接池开太大了。

我通常把应用连接池最大连接数控制在 20 到 50,PG 服务端max_connections调大到 200 左右,给运维脚本、监控、手动查询留出余量。这里有个容易被忽略的联动:work_mem是按连接计算的。如果work_mem=64MB、连接数 200,理论排序内存峰值就有 12.8GB,这还没算其他内存。所以调高 work_mem 时,连接数必须同步控制。

如果业务里有大量短连接场景,比如 Serverless 函数周期性地建连,建议在 PG 前面加一层 PgBouncer,把数据库后端连接数压到可控范围。迁移期间临时跑的同步任务很容易把后端连接占满,直接触发max_connections报错。

5.3 ORM 迁移中的隐形改动

以 Java 生态最常见。Spring Boot + JPA 项目换库时,要在 application.yml 里改两项:

spring: datasource: url: jdbc:postgresql://localhost:5432/target_db driver-class-name: org.postgresql.Driver jpa: database: POSTGRESQL hibernate: ddl-auto: validate

SQL 里如果有自定义方言或 MySQL 特有函数,要逐个排查。Hibernate 对 PG 的 jsonb 类型默认支持一般,如果实体里有 String 字段要存 JSON,建议引入 hibernate-types 或直接把字段类型映射成自定义的 JsonbType。MyBatis 相对好一些,因为#{}占位符两边通用,但 XML 里写死的 MySQL 函数还是要逐个改。SQLAlchemy 项目换库最顺,连接 URL 从mysql+pymysql://改成postgresql+psycopg2://,大部分声明式模型可以复用,但 Enum 和 JSON 类型的映射要看 SQLAlchemy 版本差异。

5.4 高频 SQL 写法差异对照

先给一份最常见的对照表:

MySQL 写法PostgreSQL 写法说明
IFNULL(expr, 0)COALESCE(expr, 0)等价,COALESCE 支持多参数
IF(cond, a, b)CASE WHEN cond THEN a ELSE b ENDPG 没有 IF 函数
DATE_FORMAT(now(), '%Y-%m-%d')TO_CHAR(now(), 'YYYY-MM-DD')格式串语法完全不同
DATE_ADD(now(), INTERVAL 1 DAY)now() + INTERVAL '1 day'注意单引号
GROUP_CONCAT(name SEPARATOR ',')STRING_AGG(name, ',')STRING_AGG 内部支持 ORDER BY
SUBSTRING_INDEXsplit_part或substring + position语义不同,需要改写
a || ba || bMySQL 下默认当逻辑或,PG 是字符串连接符
LIMIT 10 OFFSET 20LIMIT 10 OFFSET 20语法兼容

反引号是另一处高频坑。MySQL 用反引号包裹字段名,PG 不加引号的标识符会被转成小写,加双引号则严格区分大小写。曾经有个字段叫OrderCount,MySQL 里用反引号写没问题,PG 里没加双引号,所有查询都变成ordercount,一夜之间全报列不存在。遇到驼峰字段名,要么全局加双引号,要么趁迁移改成下划线命名,别留历史包袱。

6. SQL 兼容性整改:同样语义、不同写法的典型差异

6.1 GROUP BY 宽松模式的消失:最让人崩溃的一条

如果评选“从 MySQL 迁 PG 最容易翻车的一条规则”,我投 GROUP BY。MySQL 默认允许 SELECT 出没有参与 GROUP BY 的非聚合列:

SELECT user_id, user_name, order_id, COUNT(*) FROM orders GROUP BY user_id;

这在 MySQL 里能跑,user_name和order_id取的是分组内某一行,结果不确定但不会报错。PG 会直接报错:非聚合列必须出现在 GROUP BY 里或用于聚合函数。

解决办法没有捷径,只能逐条改写:要么把所有非聚合字段放进 GROUP BY,要么改成MAX(user_name)这类聚合写法。如果业务真的想要最细粒度的行,更合理的做法是先按 user_id 分组后再自关联。这类 SQL 通常藏在报表系统里,数量大、难发现。建议迁移前用静态扫描工具把 SQL 清单拉出来逐条过,不要光靠测试环境跑用例。

6.2 空值排序、分页与单行函数的行为差异

排序差异最隐蔽。MySQL 里ORDER BY col ASC时,NULL 默认排在前面;PG 默认升序时 NULL 排在最后。比如一个列表页按最后登录时间升序排序,MySQL 会把从未登录用户排在最前,PG 会排到最后,产品和运营第二天就会发现统计口径变了。解决方案是显式声明排序规则:

ORDER BY last_login_at ASC NULLS LAST;

分页部分,LIMIT/OFFSET 语法两边兼容,但大数据量分页性能都不好。MySQL 的常用优化是走主键游标(WHERE id > last_id LIMIT 20),PG 同样适用,而且 PG 的 keyset pagination 实现很标准,迁移时推荐顺手把分页接口改成游标模式,反正逻辑类似,改造量不大。

单行函数差异里最坑的是日期和字符串。DATE_FORMAT在 PG 里不存在,必须换成TO_CHAR,格式串从%Y-%m-%d变成YYYY-MM-DD,这个缩放经常导致报表日期出现“前一天”或“全空”的现象。SUBSTRING_INDEX也没有直接对应,用split_part改写时要注意分隔符不存在时的行为差异。

6.3 事务隔离级别与锁机制差异对业务的影响

MySQL InnoDB 默认隔离级别是 REPEATABLE READ,PG 默认是 READ COMMITTED,但这不是关键差别。真正影响业务的是 REPEATABLE READ 下 PG 的快照语义和 MySQL 不同。

MySQL 的 REPEATABLE READ 在大多数情况下靠锁和间隙锁防止幻读,更新冲突时事务会等待。PG 的 REPEATABLE READ 基于快照隔离,不用间隙锁,快照建立时看不到的行,事务内永远看不到。经典场景是:两个事务同时更新同一行,MySQL 那边后到的事务会等待并最终成功,PG 这边可能直接抛 serialization failure,应用层如果没有重试机制,用户就会看到更新失败。

所以迁移后,凡是涉及高并发“先读后写”的业务逻辑,建议在应用层增加乐观锁重试。如果不想改太多代码,可以把这些事务的隔离级别降到 READ COMMITTED,配合SELECT ... FOR UPDATE保底。反过来也要提醒 DBA:不要把全局隔离级别默认改成 SERIALIZABLE,PG 的 SERIALIZABLE 是真正的 SSI 实现,并发性能开销明显,业务没充分测试前不要开。

7. 数据校验、业务验收与灰度切换

7.1 怎么证明数据没丢没多:三层校验法

数据导入完成后,不要只信工具日志里那行“成功导入”,更不要用一个count(*)就宣告结束。我常用三层校验法。

第一层是行数校验。每张表分别统计count(*)、max(id)、min(id),两边对比。PG 的count(*)在大表上同样扫全表,建议分批跑,避免拖慢业务。

第二层是特征值校验。选大表的业务主键做抽样,分别算 ID 集合的差集和并集,或者对关键数值字段做 SUM 对比。比如订单表按天抽样,对比几天的金额合计、状态分布。这比简单 count 更能发现重复导入或字段错位。

第三层是工具辅助比对。PG 的 FDW 生态里有 mysql_fdw,可以把 MySQL 表包成外部表,直接在 PG 里跑 SQL 比对差异。但 MySQL 到 PG 的 FDW 安装配置并不简单,小项目不值得。多数情况下,用脚本同时连两个库,把摘要结果拉回来做 diff,几十张表几分钟就能跑完,更实用。

业务验收阶段,建议把源库慢查询日志 Top 100 的 SQL 在新的 PG 环境重放一遍,对比执行时间和执行计划。这一步既是兼容性验证,也是性能回归,能提前暴露大多数隐藏 SQL 问题。

7.2 灰度切流与双写设计

切流最稳妥的方式不是“某天晚上一把梭”,而是灰度。典型做法:

  • 第一阶段:双写。应用层把写操作同时发到 MySQL 和 PG,读操作继续走 MySQL。这个阶段用真实业务流量验证结构差异。
  • 第二阶段:读流量灰度。把 5% 或某个分片的读流量切到 PG,观察接口耗时和错误率,逐步放大到 50%。
  • 第三阶段:切换写主库。停 MySQL 写入开关,所有写流量切到 PG,MySQL 保持只读热备。

双写阶段最大的坑是幂等和顺序。MySQL 和 PG 两边的自增序列各自增长,双写时不能依赖数据库生成主键,否则两边 id 对不上,后续比对没法做。通常的做法是应用层用分布式 ID 生成器生成主键,双写两边都写入同一个 ID。如果做不到,至少先用离线同步工具代替双写,不要强行上双写方案。

7.3 回滚预案:切换不是一口气跑完的

灰度切换的好处是回滚窗口足够长。我的习惯是 MySQL 侧至少保留两周热备,期间所有变更单独记录。回滚触发条件提前写清楚,比如“订单写入失败率超过 0.5% 持续 5 分钟”或“核心报表延迟超过阈值”,不要让值班同学现场做判断。

回滚动作也要提前演练:停止 PG 写入,恢复应用双写或直接切回 MySQL,再按差异量倒灌最后一段增量数据。这里容易出问题的是回滚后 MySQL 里已经存在双写阶段产生的重复数据,需要准备按业务主键去重的脚本。我手里三个项目都把回滚预演列入了迁移前 Checklist,这个习惯至少救了一次上线危机。

8. 迁移后的运维要点:vacuum、统计信息与备份策略

8.1 autovacuum 与 bloat:维护模式完全不同

MySQL InnoDB 也清理旧版本数据,但 DBA 基本不用关心内部机制。PG 的 MVCC 实现会把旧版本留在数据文件里,必须靠 VACUUM 清理,否则表会越来越胀,这就是 bloat。

刚迁移完的头两周最容易出问题。批量导入产生大量死元组,如果 autovacuum 没跟上,查询执行计划会越来越差。启动项目前先检查大表统计信息:

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum FROM pg_stat_user_tables ORDER BY n_dead_tup DESC;

如果n_dead_tup持续走高,考虑调低autovacuum_vacuum_scale_factor和autovacuum_vacuum_threshold,或者对大表做一次手工 VACUUM。注意VACUUM FULL会锁表,绝对不要在业务高峰期执行,一般只用于 bloat 严重且能申请维护窗口的时候。

8.2 统计信息收集与执行计划变化

迁移完不要急着切换流量,先对所有业务表做一次ANALYZE。批量导入很多时候会破坏统计信息的均匀性,PG 优化器采样不准,可能选出很差的 JOIN 顺序。导入完直接执行:

ANALYZE;

超大表如果字段值分布极不均匀,默认default_statistics_target=100可能不够用,可以针对列提高统计目标:

ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000; ANALYZE orders;

执行计划差异没法偷懒:把慢查询日志拉出来,在两边分别 EXPLAIN ANALYZE。刚开始会有不少 SQL 在 PG 上的计划比 MySQL 差,常见原因是行数估算偏差、work_mem 不足导致排序落盘、或者函数写法导致索引失效。调整参数后计划会很快改善,花一两天专门调慢查询,是迁移上线前性价比最高的工作。

8.3 备份恢复与监控体系调整

MySQL 常用的备份工具是 mysqldump 和 xtrabackup,PG 对应的是 pg_dump、pg_dumpall 和 pg_basebackup。建议备份策略在迁移前就接好,别等上线后再补。日常备份基础上,至少每周做一次恢复演练。数据损坏不可怕,可怕的是备份从没验证过。

监控项基本是替换式迁移:MySQL 的连接数、慢查询、锁等待,对应 PG 的pg_stat_activity、pg_stat_statements、pg_locks。慢查询日志在 PG 里最接近的替代是pg_stat_statements加auto_explain扩展,可以记录每条 SQL 的执行计划和耗时,便于日常巡检。磁盘监控要额外关注 WAL 目录增长,PG 的 WAL 累积和 MySQL 的 binlog 有点相似,但清理策略完全不同,不要拿 binlog 的经验硬套pg_wal。

我在两次迁移中最深的体会是:数据库迁移的难点从来不在“把数据搬过去”,而在“让业务代码以新数据库的方式运行”。如果团队没有预留足够的 SQL 改造和回归时间,再好的迁移工具也救不了上线夜的慌乱。

最后分享一个实战技巧:迁移前,先把源库慢查询日志完整收集两周,整理出 Top 100 的 SQL,然后在 PostgreSQL 上用真实数据逐条跑一遍 EXPLAIN,提前把不兼容的语法清单列出来。这份清单是整场迁移工程里最值钱的资产,比任何工具文档都实用。如果你想启动类似的迁移,建议第一步不是装 PG 测试环境,而是先做一次应用 SQL 盘点。把时间花在悬崖前面,比挂在悬崖下面补救要值得多。

返回列表