MySQL平滑迁移到PG兼容库:增量复制链路设计与踩坑实录
这几个月我一直在折腾同一件事把一套跑了五年多的 MySQL 核心业务库平滑迁到一个 PostgreSQL 兼容版数据库上。很多人一听“迁库”就想到 mysqldump、pgloader 导入导出其实那只是最表面的第一步。真正让项目变得复杂的是增量复制——业务不能停数据不能丢切换窗口只给十几分钟而且要随时能回退。正好这段时间我们团队日常使用的数据管理平台新增了一条完整的复制链路MySQL 到 PG 兼容库。它把全量初始化、binlog 增量订阅、冲突处理、数据校验串成了一条流水线省掉了我原本打算自研的整套同步引擎。这篇文章就是我基于这条链路做实际迁移的完整记录包含架构逻辑、类型映射、任务配置以及三个让我凌晨爬起来查日志的坑。如果你正在做 MySQL 到 PG 系数据库的迁移、双跑验证或者单纯想了解异构数据复制是怎么设计的这篇应该能帮你少走不少弯路。1. MySQL 迁到 PG 兼容库真正的难点在“持续复制”先说结论迁移的难点从来不在“导一次数据”而在“持续把新产生的变更同步过去”。1.1 一次性导出工具为什么不够很多人第一反应是用 mysqldump 全量导出再导入或者用 pgloader 做异构迁移。这类工具处理“某个时间点的数据快照”很称职schema 转换、类型映射也能做一部分。但一旦业务还在持续写入问题就来了你导出的永远是过去某一秒的数据导出过程中产生的新写入根本没人管。有人会说那我在凌晨低峰期停机导出行不行小库可以大库不行。几十 GB 的库导出导入动辄两三个小时业务方根本不可能接受这么长的停写窗口更别提回档、校验、试运行的时间。所以现实的迁移方案基本都是“全量初始化 增量追平”先用全量工具把历史数据铺到目标库再从源库的 binlog 里持续读取新变更回放到目标库等目标库追上源库延迟归零再选个低峰窗口快速切换。这套逻辑听起来简单真要做成产品级能力却很难。自研的话你要处理 binlog 解析、位点持久化、断点续传、网络抖动恢复、类型差异、DDL 兼容、性能调优……一整套工程下来少说也得一两个季度。这也是为什么我一直建议团队优先考虑成熟的数据管理平台而不是自己造轮子。1.2 复制链路到底要替我们扛住什么我梳理了一下一条合格的 MySQL 到 PG 兼容库复制链路至少要满足五个要求实时性增量延迟控制在秒级。业务双跑期间两边数据差得越少切换时越从容。不丢不重源端 binlog 位点必须持久化进程重启后能接着跑目标端写入要尽量幂等避免重复执行导致数据错乱。断点续传网络闪断、任务重启、甚至平台升级都不能丢位点。异构兼容MySQL 和 PostgreSQL 系在类型、字符集、时区、DDL 语法上都有差异链路得把这些差异消化掉而不是原样扔给用户。可观测性延迟、吞吐、积压量、错误日志这些指标必须在控制台上看得见否则线上出了问题只能干瞪眼。这里说的 PG 兼容库本质上是 PostgreSQL 生态里的一个兼容分支SQL 语法和协议跟 PG 基本一致所以这条链路本质上是 MySQL 到 PostgreSQL 的异构复制。异构复制的痛点不在“传输”而在“语义转换”——同一个字段名两边含义可能完全不同这正是后面类型映射和踩坑章节的重点。2. 链路拆解全量初始化、增量追平、一致性校验三段怎么配合我习惯把复制任务想成三条接力棒全量阶段跑完增量阶段立刻从同一个位点接上校验阶段在后台并行验证。任何一个环节脱节数据都不会一致。2.1 全量阶段的一致性位点全量导出最忌讳的是“到处乱抓”。如果导出 A 表时业务写到了 binlog 位置 100导出 B 表时已经写到 200那增量阶段从哪个位置开始追追到 100 的话 B 表缺了 100 到 200 之间的数据追到 200 的话 A 表从 100 到 200 的数据又重复了。平台的做法是先取一个全局一致性快照在快照点上拿到当时的 binlog 位点文件名 偏移量或者 GTID 集合然后在这个一致性视图下导出全量数据。导出完成后增量任务直接从那个位点开始消费。这样全量和增量在时间线上是严丝合缝的中间既没有空洞也没有重叠。我自己的经验是源库开了 GTID 的话位点管理会省心很多。GTID 天然带了全局事务序号断点续传时只要记住“消费到哪个 GTID”重连后直接跳过去比记 (binlog_file, pos) 这种坐标更不容易出错。2.2 增量阶段binlog 解析与事务分组增量同步的源头是 MySQL 的 binlog但有三个硬性前提binlog_formatROW、binlog_row_imageFULL、binlog 保留时间要够长。ROW 格式下每条变更记录携带整行的前后镜像回放时才知道具体改了哪些列如果用的是 STATEMENT 格式一条 UPDATE 语句在目标端没法精确重放异构复制基本做不了。拿到 binlog 原始事件之后链路要做几件事解析把 ROW 事件转换成结构化的插入、更新、删除操作按事务分组同一个事务的变更必须作为一个整体提交到目标端否则事务内部执行到一半失败就会出现部分成功部分失败的数据撕裂并行回放不同表、不同主键范围的事务如果彼此没有依赖可以分片并行写入大幅提升吞吐冲突处理目标端有主键或唯一键时可以配置成“遇到冲突就更新/跳过/报错”目标端没有键时就需要特殊逻辑见第 4 章踩坑实录。这里有个细节我要强调平台做并行回放时一般会按表的粒度或主键哈希分片而不是把整个 binlog 一股脑多线程乱打。因为同一行的两个事务如果并发执行最终结果可能取决于谁后到很容易出现“先写后写顺序颠倒”的问题。按主键哈希分片能保证同一行的操作永远走同一个线程顺序不乱。2.3 类型映射最容易翻车的地方MySQL 和 PG 兼容库的类型体系差异比大多数人想象的大得多。平台默认给了一套映射规则任务创建的时候会展示出来允许手动覆盖。我整理了一张常用映射表MySQL 类型PG 兼容库类型说明TINYINT(1)BOOLEAN只存 0/1 没问题如果业务存过其他值会丢数据SMALLINTSMALLINT一一对应INTINTEGER一一对应BIGINTBIGINT注意 UNSIGNED 情况UNSIGNED BIGINTNUMERIC(20,0)超出有符号 BIGINT 上限必须升 NUMERICDECIMAL(M,D)NUMERIC(M,D)精度行为基本一致CHAR/VARCHARCHAR/VARCHAR注意长度单位和字符集TEXTTEXT一一对应BLOBBYTEA二进制内容DATETIMETIMESTAMP无时区语义原样存储TIMESTAMPTIMESTAMPTZ带时区语义处理不当会差 8 小时JSONJSONBJSONB 支持索引和高效查询ENUM/SETVARCHAR建议展开成字符串BIT(1)BOOLEAN按位语义转换这里面有两个高频翻车点。第一个是TINYINT(1)很多框架会把布尔字段定义成这个类型但它本质还是整数如果业务往里写过 1、2、3映射成 BOOLEAN 就只有 1 能正确转换其他值直接报错或截断。第二个是UNSIGNED BIGINTMySQL 里自增主键常这么定义它能存到 18446744073709551615而 PG 的 BIGINT 上限只有 9223372036854775807硬塞会溢出必须提升为 NUMERIC(20,0)。2.4 DDL 变更同步的取舍数据复制最怕的不是 DML而是 DDL。源库一张表加了个字段目标端的表结构跟不上后面所有写入都会列错位。好的复制链路必须能同步 DDL但 MySQL 的 DDL 语法和 PG 兼容库有差异不能原样透传。平台的处理方式是把 MySQL 的 DDL 解析后翻译成 PG 语法再执行。比如ALTER TABLE t ADD COLUMN c INT这种常见操作没问题但碰到ALTER TABLE t ALGORITHMINSTANT, LOCKNONE这种 MySQL 特有的语法就需要剥离掉 MySQL 的执行提示只保留语义。我推荐把 DDL 策略设成“告警 手动确认”而不是全自动执行。原因很简单自动执行 DDL 一旦翻译出错目标端表结构就悄悄变歪了后面数据校验可能都发现不了。手动确认虽然多了一步操作但至少每一步都在你眼皮底下。3. 从零开始创建一条 MySQL 到 PG 兼容库的复制任务讲完原理说点能直接照做的。我在平台上跑通这条链路大概花了二十分钟其中一半时间花在源库前置检查上。3.1 源库前置检查清单先在源库执行这几条 SQL逐项确认SHOW VARIABLES LIKE binlog_format; SHOW VARIABLES LIKE binlog_row_image; SHOW VARIABLES LIKE binlog_expire_logs_seconds; SELECT global.gtid_mode;理想的输出是binlog_formatROWbinlog_row_imageFULLbinlog 保留时间至少 7 天大库建议 14 天gtid_modeON。如果哪一项不满足需要提前联系 DBA 调整因为有些参数修改需要重启实例不能在迁移当天才暴露问题。账号权限这块复制任务至少需要SELECT读取表数据REPLICATION SLAVE读取 binlogREPLICATION CLIENT查询位点SHOW VIEW、TRIGGER 等取决于同步对象如果开了 SSL还要确认证书链路是通的。目标库那边主要确认版本兼容性、磁盘空间至少预留源库数据量的 1.5 倍、以及连接数限制避免全量导入时把目标库连接数打满。3.2 任务配置的分步操作我按实际操作的顺序梳理如下添加数据源在平台的数据源管理里分别配置 MySQL 源端和 PG 兼容库目标端。连接串、账号、SSL 选项逐项填好先做连通性测试。新建复制任务选择源端、目标端任务类型选“结构 全量 增量”这样一条任务把建表、导数据、持续同步全包了。选择同步对象可以勾选库表也支持正则表达式批量过滤。建议只同步业务需要的表日志表、临时表尽量排除减少不必要的同步压力。调整映射关系这步很关键。如果源库表名是小写带下划线目标库想要小驼峰或全大写在这里做表名映射需要排除的列比如冗余字段也可以在这里去掉。类型映射预览平台会把每个字段的源类型、默认映射、目标类型列出来。逐行过一遍遇到第 2.3 节说的 TINYINT(1)、UNSIGNED BIGINT、TIMESTAMP 这类重点字段手动确认或覆盖。高级设置DDL 策略选“告警 手动确认”冲突策略选“报错优先”全量并发度先设 8增量并行度设 4速率限制暂时不设。预检查平台会自动检查 binlog 配置、账号权限、连通性、目标端空间全绿了再启动。启动任务观察全量阶段进度条全量完成后任务会自动进入增量阶段这时候盯住“增量延迟”这个指标。3.3 全量与增量阶段的参数调优全量阶段主要看两个数字源库 CPU 压力和全量迁移速率。如果源库是业务高峰期建议把并发度调低或者开启限速平台一般支持设置“每秒最大读取行数”。我习惯的做法是白天限速 30% 跑晚上放开到 90%既能推进度又不影响业务。增量阶段的核心参数是并行度和批大小。并行度上去之后延迟通常能压到毫秒级。但要注意目标端的写入能力——PG 兼容库的 WAL 写入和索引维护在高峰期的开销不小并行度太高可能导致目标库自身出现锁等待或 IO 瓶颈。我一般先设 4 路并行观察目标库的写入延迟曲线再逐步加到 8、16。还有个容易被忽略的点网络带宽。binlog 里如果包含大量 BLOB、TEXT 字段增量传输的带宽消耗会远高于你的直觉。平台支持开启压缩传输实测在跨机房场景下能把网络开销降低 60% 左右强烈推荐默认开启。4. 三条踩坑实录从现象到根因的完整排查链路这一章写三个我真实处理过的问题。每个我都按“现象 → 猜测 → 定位 → 根因 → 解决”的排查链路讲方便你以后遇到类似问题能复现我的思路。4.1 无主键表让增量同步直接卡死现象全量阶段很顺利切换进增量阶段后延迟开始缓慢上涨。一开始以为只是追数据需要时间但半小时后延迟涨到几百秒任务状态直接变红报错信息反复出现。猜测我第一反应是网络问题或者是目标端写入太慢。但看了平台监控网络延迟正常目标端 IO 也没打满。这就排除了环境因素问题大概率出在任务本身。定位点开任务日志错误信息写得很明确no unique key found for target table: event_log。我马上去源库查这张表的表结构SHOW CREATE TABLE event_log;结果发现这是一张典型的日志表只有自增 ID 列但没建主键连唯一索引都没有。业务写入时频繁用 UPDATE 修改状态字段每次修改都会产生一条带完整前后镜像的 ROW 事件。根因增量复制把 UPDATE 事件应用到目标端时需要靠主键或唯一键定位“要更新哪一行”。没有主键平台就无法确定目标端哪一行对应源端哪一行任务只能报错。这其实是 ROW 格式 binlog 的天然限制删除和更新事件必须依赖行的唯一标识。解决跟业务方确认这张表后续保留策略后决定补上自增主键ALTER TABLE event_log ADD PRIMARY KEY (id);任务重新启动后延迟开始肉眼可见地下降十分钟内追平到秒级。平台也支持给无主键表配置“逻辑主键”用几个字段组合作为身份标识但组合键选不好更容易踩坑所以能加真主键就加真主键。预防之后我在每个迁移项目的前置检查里都会先跑一遍SELECT table_schema, table_name FROM information_schema.tables t WHERE (table_schema, table_name) NOT IN ( SELECT table_schema, table_name FROM information_schema.table_constraints WHERE constraint_type PRIMARY KEY );把无主键表清单提前拉出来逐个确认处理方案绝不等任务跑挂了再救火。4.2 时间字段差八小时现象切换前做数据校验其他的表和行都对得上唯独一张订单表的某个时间字段不一致。源库里显示2024-06-01 10:00:00目标库同一行显示2024-06-01 02:00:00正好差 8 小时。猜测我的第一反应是复制任务把字段值写错了或者源端读出来就错了。但同样的表里另外两个时间字段是完全一致的这就很蹊跷。定位我对比了那张表所有时间字段的类型发现问题出在类型差异上不一致的那个字段是TIMESTAMP而一致的两个字段是DATETIME。再去看任务里的类型映射配置平台默认把 MySQL 的TIMESTAMP映射成了 PG 兼容库的TIMESTAMP等价于 PostgreSQL 的TIMESTAMP WITHOUT TIME ZONE即不带时区的 timestamp。根因MySQL 的TIMESTAMP是个坑。它在存储层按 UTC 保存展示时依赖会话的time_zone参数换算而DATETIME就是纯字面量存什么显示什么。复制任务读取源端数据时用的是源库会话时区比如东八区读到的是本地时间字符串写到目标端不带时区的TIMESTAMP时这个字符串被原样存了进去。表面看两边显示一样才对但实际上问题出在查询端的时区设置——目标库连接默认 UTC 会话时区读出来的字面量被当成 UTC 再减去 8 小时展示于是差了 8 小时。解决把该字段的映射强制改成TIMESTAMPTZTIMESTAMP WITH TIME ZONE。PG 兼容库在存储TIMESTAMPTZ时会做时区归一化处理展示时跟随查询会话时区语义上和 MySQL 的TIMESTAMP才对得上。改完映射后重建这张表并重新回放数据两边显示完全一致。预防以后每次建任务我都把源库所有TIMESTAMP类型的字段单独拉出来过一遍确认业务语义上“这个字段代表的是某个时刻还是一个没有时区含义的本地时间”。前者映射成TIMESTAMPTZ后者映射成TIMESTAMP。这个判断不能交给默认规则必须人工确认。4.3 一个大事务把同步延迟打到十分钟现象某个工作日下午平台告警提示复制任务延迟超过 300 秒点开监控一看延迟曲线从 14:03 开始直线拉升最高到 600 多秒持续了将近二十分钟才回落。猜测第一反应是目标端数据库出问题了或者源库写入量突然暴涨。但看目标端 CPU 和 IO 都平稳源库的 TPS 曲线也没有明显波动。定位打开任务详情发现“待消费事件积压”这个指标暴增而且增量任务正卡在一个事务上迟迟没有提交。再去源库查 binlog 事件统计定位到 14:03 开始执行的一个批处理事务单条 UPDATE 影响了大约 50 万行记录。这是业务方每天定时跑的清洗任务平时数据量小没感觉那天数据积压得比较多一条 SQL 直接改了几十万行。根因复制链路是严格按事务边界回放的。一个大事务里的几百万个事件必须全部应用成功才能提交期间不管后续来了多少小事务都得排队等着。50 万行 UPDATE 生成的事件足够把回放线程占满很久延迟自然就爆了。解决短期方案是提升增量并行度把回放线程从 4 路提到 8 路同时打开平台的事务内并行选项让单个大事务也能分摊到多个线程执行长期方案是跟业务方约定超过一定行数的批量变更分批提交比如每 1 万行 COMMIT 一次。对复制系统来说小事务集群的消化能力远高于一个大事务这是异构复制的基本规律。验证调整后延迟在几分钟内追平回正常水平。之后又观察了两天业务方的定时任务没有再触发延迟告警。预防现在我会在任务监控里专门配置“大事务事件”的告警规则单事务超过 10 万行就推送通知争取在延迟暴增之前就发现而不是等业报告诉我数据不同步了。5. 链路跑稳之后切换、回退和后续扩展复制链路稳定运行只是起点最终目的是帮助业务完成切换。这一章讲切换落地的经验以及链路还能怎么扩展。5.1 分钟级切换的实操要点当数据校验通过、增量延迟稳定在 1 秒以内就可以准备切换了。我定的标准是连续观察 15 分钟延迟始终为 0 或小于 1 秒再进入切换窗口。切换步骤我整理成了一张清单按顺序执行即可通知业务方进入切换窗口应用层停止写操作读流量可以先保留在源库再次确认源端无新写入比如SHOW MASTER STATUS两次位置不再变化等待复制任务追平增量延迟归零且持续一分钟目标库执行只读一致性检查确认最后一批数据到位把应用读流量切到目标库观察业务日志有无异常稳定运行几小时后再把写流量切过来完成全量切换。整个流程熟练之后窗口内操作十分钟内就能走完。关键是每一步要有明确的“通过条件”不能凭感觉。我见过不少切换事故都是因为“觉得数据差不多了”就切了结果差了一批 binlog 事件没追上。5.2 回退方案与演练切换之后最怕的是新库出问题所以回退方案必须在切换前就准备好。我的做法是切换完成后复制任务先不停止源 MySQL 库保持只读状态至少一周。一旦目标库出现严重问题直接把写流量切回源库因为源库数据还完整地躺在那里回退成本基本为零。这里有个容易忽略的点如果切换后业务已经在新库跑了一周产生了大量新数据而链路是单向的 MySQL → PG 兼容库这个时候想回退就没法把新库的数据带回旧库了。所以平台要是能支持反向链路PG 兼容库 → MySQL整体容错能力会强很多——这也是我后面最期待的功能。另外回退方案不能只写在文档里。我们团队每次做完切换都会在一周内安排一次回退演练挑一个非核心系统练手确保流程里每个人都清楚自己该干什么。演练过一次之后大家心里都有底真出事也不会手忙脚乱。5.3 后续可以延伸的方向复制链路一旦跑顺它的价值就不止于一次迁移。基于同一条链路还可以继续做几件事反向复制PG 兼容库的数据变更实时回流到 MySQL这样双写、灰度、回切都变得可行数据订阅把 binlog 变更事件分发到消息队列下游可以做缓存更新、搜索引擎索引同步、甚至触发业务事件相当于白拿一套 CDC 基础设施异构同步到数仓同一个平台如果支持 MySQL 同步到分析型数据库或数据湖那业务侧的实时数仓管道也不用重复搭建增量数据校验不只是全量校验还能周期性做增量比对自动订正不一致数据这个能力在长期运维阶段比迁移阶段更实用。最后分享一个小习惯每次创建复制任务前我都会在源库顺手跑一遍无主键表扫描和 TIMESTAMP 字段梳理。这俩问题在后面一多半的故障里都出现过。宁可配置阶段多花五分钟也别等到凌晨三点被报警消息叫醒再查。数据复制这件事前期的细心程度基本决定了后期的省心程度。