1. 迁移方案概述:为什么我选择 Docker + pgloader
先说结论:如果你正在接手一个“把 MySQL 老库迁到 PostgreSQL”的任务,别再手动导出 SQL 再逐条改语法了。用 Docker 拉起两个数据库实例,再通过 pgloader 跑一次在线迁移,整个过程可以控制在半小时内完成,而且不需要在宿主机上安装任何数据库客户端和迁移工具依赖。
我最初接触这个需求是在某项目的数据中台改造中。业务方希望把一套运行多年的订单系统从 MySQL 迁到 PostgreSQL,原因也很现实:PostgreSQL 对复杂查询的支持更强,JSONB 数据类型能帮我们省下不少拆表的功夫,加上团队后续新项目全部基于 PostgreSQL 开发,统一技术栈是必然趋势。但迁移的难点不在于“导出再导入”,而在于保持数据结构完整、数据量不丢失、后续业务能无缝切换。
当时我评估过几条路:第一条是 mysqldump 导出后手工修改兼容性语法,这种方案对简单表还行,但一旦涉及外键、自增列、枚举类型、字符集差异,手动改 SQL 的成本非常高,而且容易遗漏字段转换;第二条是 ETL 工具如 DataX 或 NiFi,功能强大但配置复杂,对于这种一次性迁移任务有点“杀鸡用牛刀”;第三条就是 pgloader,它专门干 MySQL 到 PostgreSQL 迁移这件事,处理类型映射、索引重建、外键依赖的顺序问题都很成熟,配合 Docker 运行也不会污染宿主机环境。
pgloader 本身是一个用 Common Lisp 写的开源数据迁移工具,支持从 MySQL、SQLite、MS SQL 等数据源迁移到 PostgreSQL,最重要的特性是它能自动完成大部分类型转换、约束重建和索引生成,还支持断点续传和并行加载。在线迁移过程中,源库 MySQL 可以继续对外提供读写服务,迁移完成后只需要做一次短暂的业务切换窗口来追平增量数据。
整个方案的核心优势可以总结为三点:一是环境一致性,Docker 容器内的 MySQL 和 PostgreSQL 版本完全固定,不会有宿主机已有数据库实例的干扰;二是配置简单,pgloader 的配置文件是声明式的,写完并不需要编译执行;三是可观测性强,pgloader 在迁移过程中输出详细的数据报告,哪些表成功、哪些表失败、各花了多少时间,一目了然。
这篇博文适合谁看?如果你正在准备一次真实的 MySQL 到 PostgreSQL 迁移,或者只是想在本地搭一套环境练手熟悉 pgloader,又或者你需要给团队写一份可复现的迁移方案文档——这篇文章都能直接用。我会完整走一遍“容器环境准备 → pgloader 配置编写 → 迁移执行 → 数据校验 → 常见问题排查”的流程,把每一步的原理和坑都讲清楚。
2. Docker 环境搭建:MySQL 与 PostgreSQL 双容器联动
2.1 容器镜像与版本选型
迁移场景下最怕的就是版本差异导致的行为不一致。我建议源库和目标库都使用固定的、长期支持的版本。MySQL 这边选择 8.0 系列,PostgreSQL 选择对应项目需求的版本。用 Docker 跑的好处是可以在同一台机器上让两个不同生态的数据库共存,只要端口不冲突即可。
创建 Docker 网络这一步值得单独强调,因为 pgloader 容器要同时连接 MySQL 和 PostgreSQL,它们之间通过容器名互相访问比通过宿主机 IP 访问更稳定。命令很简单:
docker network create migration-net然后启动 MySQL 容器,挂载初始化脚本目录,设置字符集:
docker run -d \ --name mysql-source \ --network migration-net \ -e MYSQL_ROOT_PASSWORD=rootpass \ -e MYSQL_DATABASE=shopdb \ -e TZ=Asia/Shanghai \ -p 3306:3306 \ -v /data/mysql-init:/docker-entrypoint-initdb.d \ mysql:8.0这里有一个容易被忽略的细节:MySQL 容器的/docker-entrypoint-initdb.d目录下的脚本只有在数据目录首次初始化时才会执行,如果容器已经跑过一遍再挂载脚本是不会生效的。所以如果你有建库建表的 SQL,要在第一次启动之前就准备好。
PostgreSQL 容器同理,但端口要错开:
docker run -d \ --name pg-target \ --network migration-net \ -e POSTGRES_PASSWORD=pgpass \ -e POSTGRES_DB=shopdb \ -e TZ=Asia/Shanghai \ -p 5432:5432 \ -v /data/pg-data:/var/lib/postgresql/data \ postgres:14用POSTGRES_DB环境变量指定初始数据库名,pgloader 迁移时会自动创建对应的表结构,所以这里不需要预先在 PostgreSQL 中建表。
2.2 pgloader 容器获取与运行方式
pgloader 官方并没有在 Docker Hub 上发布正式镜像,但社区维护的镜像质量不错。我常用的是基于 Debian 构建的版本,包含完整的依赖,开箱即用。拉取和交互式进入容器验证连接的命令如下:
docker pull dimitri/pgloader:latest docker run -it --rm \ --network migration-net \ dimitri/pgloader:latest \ pgloader --version执行后会输出 pgloader 的版本号,确认容器可以运行。注意--rm参数让容器退出后自动删除,我们是借助它执行一次性任务,没必要保留容器。后续真正的迁移执行会把配置文件挂载进去:
docker run -it --rm \ --network migration-net \ -v $(pwd)/load.load:/load.load \ dimitri/pgloader:latest \ pgloader /load.load执行之后当前目录下的load.load文件就是我们的迁移配置文件。直接看这个运行方式,你会发现 pgloader 本身只是一个命令行工具,它不管任务编排,全靠配置文件告诉它要“从哪来,到哪去,怎么做”。这种“命令 + 配置文件”的组合天然适合写进自动化流水线,后续如果需要做定时增量迁移或者多环境复用,只要换配置文件的连接参数即可。
3. pgloader 迁移配置深度拆解
3.1 配置文件核心语法
pgloader 的配置不是类似 YAML 或 JSON 的通用格式,它有自己的一套 DSL(领域专用语言)。第一次接触的人看到.load文件会觉得有点怪,但本质上就是把 SQL 的语义翻译成了配置指令。下面是一份我在真实迁移中使用过的完整配置:
LOAD DATABASE FROM mysql://root:rootpass@mysql-source:3306/shopdb INTO postgresql://postgres:pgpass@pg-target:5432/shopdb WITH include drop, create tables, create indexes, reset sequences, disable triggers, preserve index names, foreign keys SET PostgreSQL PARAMETERS maintenance_work_mem to '128MB', work_mem to '12MB' SET MySQL PARAMETERS net_read_timeout = '120', net_write_timeout = '120' CAST type datetime to timestamptz drop default drop not null using zero-dates-to-null, type tinyint to integer using tinyint-to-boolean TYPEMOD 1;先说FROM和INTO这两行。连接串的格式是协议://用户名:密码@主机名:端口/数据库名,主机名这里直接填容器名mysql-source和pg-target,因为三个容器都接在同一个 Docker 网络里,这是容器间通信的最可靠方式。密码如果包含特殊字符,记得做 URL 编码,否则解析会出错。
WITH这一行是 pgloader 迁移行为的开关集合,每一项都有明确含义,我逐个解释:
include drop:如果目标 PostgreSQL 中已存在同名表,先 DROP 掉再重建。这个选项在重复执行迁移脚本时特别有用,保证迁移的可重入性。当然危险也在这里,如果你一不小心把生产库的连接串写进配置,那后果就是删表。所以执行前一定要确认连接串指向的环境。create tables:自动在 PostgreSQL 端创建表结构,不用手工写 DDL。create indexes:迁移完数据后自动创建索引,包括主键、唯一索引和普通索引。但对 MySQL 的索引名长度有一个历史坑:MySQL 的索引名可以超过 PostgreSQL 的 63 字节命名上限,会导致建索引失败,解决方式是加preserve index names选项时同时截断超长名称。reset sequences:MySQL 的自增列迁移到 PostgreSQL 会变成SERIAL或BIGSERIAL,对应的序列会重置到当前最大值之后,新插入的数据不会主键冲突。disable triggers:数据导入期间禁用 PostgreSQL 端触发器,加快导入速度,同时避免数据校验触发器在迁移中间阶段报错。foreign keys:让 pgloader 在数据加载完成后统一创建外键约束。这个顺序很重要,如果先建外键再灌数据,逐条插入时的约束检查会拖慢速度,而且数据源如果有暂时性的完整性缺口会导致任务中断。preserve index names:保留 MySQL 中的索引名称,默认 pgloader 可能对名称做归一化处理。
3.2 类型映射规则与 CAST 子句
类型映射是整个迁移中最需要重点关注的部分,也是踩坑最多的地方。MySQL 和 PostgreSQL 的类型系统差异不小,pgloader 有自己的默认映射表,但默认规则不一定符合你的业务预期,这时需要手动覆盖。
我配置中写了两条 CAST 规则,解释如下:
CAST type datetime to timestamptz drop default drop not null using zero-dates-to-null, type tinyint to integer using tinyint-to-boolean TYPEMOD 1;第一条解决的是 MySQL 的datetime类型迁移。MySQL 的datetime不带时区信息,而 PostgreSQL 中对应的是timestamp without time zone,pgloader 默认会映射过去。但我在业务中倾向于迁移成timestamptz(带时区的时间戳),因为后续应用层统一按 UTC 存储、展示,时区转换逻辑放到数据库层做更清晰。drop default drop not null是让 pgloader 先去掉原有的默认值和非空约束,避免迁移过程中因默认值函数不兼容报错。zero-dates-to-null处理 MySQL 里常见的0000-00-00这种零日期值,PostgreSQL 不认这种值,如果不处理会被当成非法日期直接报错。
第二条是tinyint到integer的映射。MySQL 的tinyint(1)通常被当作布尔值用,pgloader 有内置的tinyint-to-boolean转换函数,能把它自动转成 PostgreSQL 的boolean类型。但这里有个陷阱:如果原表里tinyint(1)实际存的是 0 和 1 之外的值(比如有的业务把 2 当“特殊状态”存),转成 boolean 就会丢失信息。所以我在配置中加了TYPEMOD 1,它表示只对tinyint(1)这个显示宽度为 1 的类型做布尔转换,其他tinyint统一按整数处理。这个细节很重要,不写TYPEMOD的话所有tinyint都会被尝试转成布尔值,一旦碰到状态字段存 3、4 这种值,迁移过程中就会报错。
除了上面两条自定义规则,pgloader 默认的类型映射表里还有一些关键行为值得提前知道:
char→text,PostgreSQL 里char和varchar性能没本质区别,统一用text更省心。mediumint→integer,bigint保持不变。enum和set→ 默认转成text,并自动生成一个 CHECK 约束来限制合法值。decimal→numeric,两者语义一致。longblob和mediumblob→ 转成bytea,二进制数据在 PostgreSQL 里用bytea类型存储。
有一个我特别想提醒的冷门知识:MySQL 的unsigned属性。比如int unsigned的取值范围是 0 到 4294967295,PostgreSQL 的integer只能存到 2147483647。pgloader 默认会处理这个差异,把int unsigned映射成bigint,但如果你的配置里手动写了 CAST,就可能把这个自动映射覆盖掉,导致数据迁移后溢出。所以写自定义 CAST 规则时,要么不要动整数类型,要么就干脆显式映射成bigint。
3.3 数据加载行为与性能参数调整
WITH子句还有一个容易被忽视的选项workers和concurrency,它们直接决定迁移速度。pgloader 默认会启动多个并行工作线程,一个线程负责读 MySQL 数据,另一个负责写 PostgreSQL,中间通过队列传递数据。默认值其实已经能跑得不错,但如果你迁移的是大表(几百万行以上),手动调参很有必要:
WITH workers = 8, concurrency = 1, batch size = 25000, batch concurrency = 4, max parallel create index = 4workers是总工作线程数,concurrency是每个表内并发的读取线程数。这里有一个经验性原则:concurrency不要设太高,因为 MySQL 的 InnoDB 在并发读同一个表时,锁竞争反而会让性能下降。batch size控制每个批次的行数,数据按批写入 PostgreSQL,减少提交次数。batch concurrency控制同时有几个批次在写,一般不要超过max parallel create index的值的两倍,否则最后建索引时资源不足。
SET PostgreSQL PARAMETERS子句用于在迁移期间临时调整数据库参数,迁移结束后不会永久生效。这里配置两参数的原因是:maintenance_work_mem影响 CREATE INDEX、VACUUM 等维护操作的排序内存,索引是迁移的最后阶段,给它多一点内存能大幅缩短等待时间;work_mem影响排序和哈希操作,比如ORDER BY和DISTINCT运算。内存分配遵循“够用就好”原则,如果机器内存有限,只调大maintenance_work_mem就行,work_mem设 12MB 是保守值。注意这些参数的单位必须是单引号包裹的字符串格式。
SET MySQL PARAMETERS里的net_read_timeout和net_write_timeout是为了应对慢查询场景。如果在 MySQL 侧执行全表扫描需要几分钟甚至更久,默认的网络超时时间(通常 30 秒)会导致连接中断,迁移直接失败。我遇到过 2000 万行的大表,第一次跑因为超时中断了,调大这两个参数后一次跑通。
4. 迁移执行:从启动到数据校验的完整流程
4.1 执行迁移并解读日志输出
配置写好后,执行命令就是那个docker run,真正跑起来后你会发现 pgloader 的输出风格类似进度面板,实时刷新各表迁移状态。日志会分成几个阶段:连接源库、评估源库模型、建目标表、加载数据、创建索引与约束。
一个正常的迁移过程大概会经历以下输出:
2025-xx-xx 10:23:01 LOG pgloader version "3.6.x" 2025-xx-xx 10:23:02 LOG Migrating from mysql://root@mysql-source:3306/shopdb 2025-xx-xx 10:23:02 LOG Migrating into postgresql://postgres@pg-target:5432/shopdb 2025-xx-xx 10:23:05 LOG Only table list is loaded, then pgloader will load data ... 这里输出每个表的迁移行数和状态 ... 2025-xx-xx 10:23:52 LOG Report summary: table name errors rows bytes total time ---------------------- ---------- --------- --------- -------------- fetch, shop 0 102400 12.3 MB 3.2s orders, shop 0 512000 24.8 MB 7.8s order_item 0 1536000 56.1 MB 12.5s最后的Report summary表格是整个迁移结果的核心信息,需要重点看的是errors列和rows列。errors只要不为 0,就要去日志里找对应的报错信息。pgloader 对错误表的处理是跳过继续执行后续表,所以哪怕有一张表失败,其他表也都能导入,只是这张表的数据会缺失。
执行完docker run后容器会退出,想要查看详细日志,建议在执行时加-v $(pwd)/migration.log:/tmp/migration.log之类的日志重定向,或者把标准输出和标准错误都重定向到文件里。我一般这样执行:
docker run -it --rm \ --network migration-net \ -v $(pwd)/load.load:/load.load \ dimitri/pgloader:latest \ pgloader /load.load > migration.log 2>&1这样日志落到宿主机当前目录的migration.log中,后续排查问题、复盘迁移数据量都方便。
4.2 迁移后的数据一致性校验
数据迁移完成不等于万事大吉,必须做系统性校验。我总结了一套三层校验方法,每层解决的问题不同,重要性依次递增。
第一层是行数校验。最直接的方法是对比源和目标的全表行数,但因为 MySQL 的 MyISAM 引擎有缓存的行数统计,InnoDB 没有,所以不能用SELECT COUNT(*) FROM information_schema.tables这种方式,必须实实在在跑一次COUNT(*)。对大表来说,COUNT 也可能挺慢,但这是必要的一次性开销。也可以抽样几个关键表验证。
第二层是类型和结构校验。在 PostgreSQL 端执行\d table_name查看表结构,重点验证自增列是否变成了SERIAL,有没有变成bigint之类的预想类型,外键是否带着原有名称创建。外键名称经常被忽略,但后续如果要做增量同步,外键名不一致会导致两边对不上。pgloader 的preserve index names能保留主键名,但外键名在默认情况下可能被改写,必要时在配置里加preserve foreign key names。
第三层是业务抽样校验。这里我会随机抽几行源表和目标表的数据做对比,特别是带有特殊类型的字段,比如datetime(3)(毫秒精度的时间)、decimal(10, 2)(金额字段)、json类型字段。我遇到过decimal字段精度迁移后发生变化的情况,原因是 MySQL 的decimal默认精度和 PostgreSQL 的numeric有细微差异,抽样校验能提前发现问题。
4.3 增量数据追平与业务切换窗口
pgloader 只做一次性全量迁移,它不会持续同步增量数据。所以在真实业务切换时,完整流程是:
- 选一个业务低峰期,先跑一次全量迁移(此时线上业务仍然在写入)。
- 记录全量迁移启动时间点。
- 在切换窗口内,暂停业务写入(或者只允许读操作)。
- 对新增的增量数据,用相同配置再跑一次 pgloader。这次因为目标表已经建好,且数据量小,跑得很快。
- 停掉 MySQL 的写入流量,把应用连接串切到 PostgreSQL。
- 短暂观察 PostgreSQL 的负载和错误日志,确认无异常后开放全部流量。
这套流程是我在多次迁移中验证过的可行方案。要注意的是第二次跑 pgloader 时,WITH配置里不应该再包含include drop和create tables,否则会把第一次迁移的表和数据清掉重来。正确做法是准备两份配置,一份用于全量初始化,一份用于增量追平,增量配置里把建表相关选项去掉,只保留数据加载和外键处理。
5. 实操踩坑记录:迁移过程中的常见问题与排查清单
5.1 字符集与中文乱码
这是迁移过程中的高频问题。MySQL 的库表通常默认使用utf8mb4字符集,而 pgloader 默认连接字符集可能跟随系统环境。迁移后如果发现中文变成了乱码或问号,大概率是字符集设置不一致。
解决办法是在 MySQL 连接串中显式加上字符集参数:
FROM mysql://root:rootpass@mysql-source:3306/shopdb?charset=utf8mb4另外还要确认 PostgreSQL 目标库的编码是UTF8,这个在创建容器时设置POSTGRES_DB的时候,默认就是 UTF8,不需要额外修改。但如果是用自己的init.sql创建的数据库,就要在初始化脚本里显式指定:
CREATE DATABASE shopdb WITH ENCODING 'UTF8' LC_COLLATE 'en_US.utf8' LC_CTYPE 'en_US.utf8' TEMPLATE template0;用TEMPLATE template0是为了避免复制默认模板的已有本地化设置。
5.2 主键冲突与自增序列错乱
迁移后插入数据时遇到主键冲突,最常见的原因是序列没有重置。pgloader 配置里写了reset sequences,但要注意它只能在迁移过程中生效。如果迁移后有手工插入过数据,或者第二次增量追平跑的时候没有带reset sequences,序列就可能落后于实际数据最大值。
手动修复序列的 SQL 是这样的:
SELECT setval(pg_get_serial_sequence('orders', 'id'), (SELECT max(id) FROM orders));pg_get_serial_sequence函数可以根据“表名 + 列名”找到配套的序列名。执行之后再插入新数据就不会撞车了。
5.3 大表迁移慢与超时中断
迁移 2000 万行以上的表,有几次我遇到了超时中断和速度越来越慢的问题。排查思路是:先看日志里是卡在读取阶段还是写入阶段。如果卡在读取阶段,检查 MySQL 的net_read_timeout;如果卡在写入阶段,检查 PostgreSQL 的 WAL 写入压力和磁盘 IO。迁移大表时 PostgreSQL 端的synchronous_commit可以临时调成off,减少每次提交的刷盘等待。迁移完再改回on,不会影响数据安全性,因为 pgloader 的批次提交本身有完整性保证。
遇到过的最隐蔽问题是行宽过大导致的批次失败。某张表里有多个TEXT字段,单行数据体积接近 100KB,默认的 25000 行批次会导致整个批次超过 PostgreSQL 的单个事务限制,报错“out of memory”或“canceling statement due to statement timeout”。解决办法是调小batch size:
WITH batch size = 5000小批次意味着更多次提交,会略微降低整体速度,但换来的是稳定性。这种场景就别贪快。
5.4 视图、存储过程与触发器的迁移
pgloader 官方说明中明确写着它只迁移表数据、索引和外键,不会迁移 MySQL 的视图、存储过程和函数。这容易让人忽略,因为跑完 pgloader 后 PostgreSQL 端看起来一切正常,但业务代码里引用的视图全部不存在。
我的处理办法是在迁移计划中增加一步对照:先导出源库的视图和存储过程定义(SHOW CREATE VIEW、SHOW CREATE PROCEDURE),然后在 PostgreSQL 端用等效语法手动重建。注意 MySQL 和 PostgreSQL 的存储过程语法差异很大,比如变量声明、异常处理、游标遍历方式,都不相同。这项工作需要在迁移前做好评估,如果是核心业务存储过程,建议先在 PostgreSQL 里写好替代版本并做功能验证。
5.5 时间字段与时区偏移问题
迁移后时间字段的值比源库偏移了 8 小时这类现象,是时区处理不一致导致的。MySQL 连接的time_zone参数与会话时区相关,如果连接串没指定时区,pgloader 会按系统默认时区解释datetime字面值。我在配置中建议显式指定:
SET MySQL PARAMETERS time_zone = '+08:00'同时目标 PostgreSQL 连接串也可以指定时区参数。如果使用timestamptz类型,PostgreSQL 会按照连接时区展示时间,但存储的 UTC 时间已经定了,不会出错。真正隐蔽的是timestamp without time zone迁移后,应用层如果按 UTC 解析就会产生偏移。提前在配置 CAST 阶段统一处理,能省掉后续排查的麻烦。
6. 迁移完成后的收尾工作与长期维护建议
迁移完成后有两件事需要立刻做:第一是备份 PostgreSQL 数据库,第二是清理 Docker 环境中的临时容器和网络。备份用pg_dump即可,这是新数据库上线前的标准化动作,万一后续操作失误还能回滚。
Docker 环境的清理包括停止并移除迁移时创建的容器和网络。--rm参数已经让 pgloader 容器退出了,但 MySQL 和 PostgreSQL 容器还在运行,根据业务需要决定是否保留。如果只是本地练手,直接docker rm -f就行;如果是测试环境,PostgreSQL 容器可以继续保留,用来验证应用功能。
关于长期维护我想多说一句:数据库迁移不是“跑完脚本发给运维就结束”的事。迁移完成后的至少一周内,我建议持续观察 PostgreSQL 的慢查询日志、连接池指标和磁盘增长速率,特别是原本依赖 MySQL 方言 SQL 的应用代码,可能会在 PostgreSQL 里产生意想不到的慢查询。下一步如果需要做 MySQL 和 PostgreSQL 的双写或增量同步,可以调研一下 Debezium 这类 CDC 工具,在源库开启 binlog 后把变更事件实时投递到 Kafka,再由消费端写入 PostgreSQL。pgloader 适合一次性历史数据迁移,CDC 才是增量同步的长久方案。
最后分享我个人的一个体会:凡是涉及数据库迁移的项目,一定要先在测试环境全流程演练至少两遍,第一遍跑通流程、记录耗时,第二遍按线上数据量压测并留好日志。真实切换窗口里你最不想遇到的就是“第一次执行才发现配置写错”这种低级事故。这行当没有捷径,准备得越细,切换当天越轻松。