PG 迁移落地全流程:MySQL → PostgreSQL 迁移实战 + 回滚方案

引言

"迁移演练一切正常,正式切流后第 3 天,客服接到用户投诉:同一笔订单在'我的订单'里能看到,点进详情页却是'订单不存在'。排查两小时,发现读流量已经 100% 切到 PG,但一条 9 个月前的历史订单在 MySQL 里有、PG 里没有——当初 pgloader 跑全量时,这张表刚好漏了两个分片,而我们的校验脚本只对了'当天数据'。"

这是 2024 年我们订单库迁移中最深刻的一次教训:迁移的成败不在"搬数据"本身,而在流程的严密性——全量、增量、校验、切流、回滚,任何一环的侥幸都会变成线上的故障。这次迁移的对象是 5000 万行订单库(20 张核心表 + 80 张次要表),从决定迁移到 MySQL 归档下线共 6 周,全程零停机、零数据丢失,期间还真实触发了一次回滚演练。

系列前三篇解决了"为什么迁"(《为什么从 MySQL 迁移到 PostgreSQL》)、"语法差在哪"(《MySQL→PostgreSQL 语法迁移对照》)、"迁了值不值"(《MySQL vs PG 性能横评》),这篇补上最后一环——怎么迁。完整流程六步:pgloader 全量迁移 → 应用层 SQL 改造 → 双写过渡 → 数据校验 → 灰度切流 → 回滚方案与下线,每步给出可抄的配置/代码和真实踩过的坑。先给结论:迁移工程的本质不是技术升级,而是一场"零信任"的数据搬家——不信任任何单一工具、不信任任何一次校验、且从第一天起就准备好"搬错了怎么办"。


一、全景:六步流程与六周时间线

1.1 流程总图

┌────────────────────────────────────────────┐
                    │            MySQL(唯一真源)                 │
                    └────────────────────────────────────────────┘
T-3周  ① SQL 改造    应用代码支持双方言(分支管理,预发验证)
T-2周  ② 全量迁移    pgloader:MySQL ──全量快照──▶ PG
T-2周  ③ 双写过渡    应用同时写 MySQL(主)+ PG(跟随),读仍走 MySQL
T-1周  ④ 数据校验    行数 + 分段聚合 + 抽样逐行 diff,连续 3 天全绿
T日    ⑤ 灰度切流    读 1%→10%→50%→100% 切 PG → 写主切换(PG 为主)
T+2周  ⑥ 回滚保障    MySQL 降级为反向同步目标,观察期后归档下线

1.2 六周时间线与人力

阶段耗时关键产出责任角色
评估与语法改造2 周双方言分支、改造清单(对照《语法迁移对照》10 项)应用开发
pgloader 全量 + 类型映射调优3 天迁移配置文件、类型映射表DBA
双写版本开发与预发验证1 周双写组件 + 补偿机制应用开发
数据校验工具3 天对账脚本(行数/聚合/逐行 diff)DBA + 应用
灰度切流 + 观察期2 周切流开关、监控大盘、回滚预案SRE + DBA
MySQL 归档下线2 周后最终备份归档、连接摘除DBA

1.3 开始前的三个前置确认

  1. 范围冻结:迁移窗口内暂停影响表结构的 DDL 变更(双写期间任何一侧结构漂移都是灾难)。
  2. 主键策略确认:双写要求所有表主键由应用生成(业务单号/雪花 ID),纯 AUTO_INCREMENT 表必须先改造——这是很多人到双写阶段才发现的硬前提。
  3. 回滚预案先行:写清楚什么条件触发回滚、由谁决策、演练过没有。回滚方案不是迁移失败后的补救,是切流前的准入条件。

二、第一步:pgloader 全量迁移

2.1 pgloader 是什么、为什么选它

pgloader 是专用的"MySQL → PostgreSQL"迁移工具,单二进制即可完成建表、类型转换、数据搬运、建索引、重置序列全流程,内置并行批处理,5000 万行实测约 25 分钟。相比 mysqldump + 手工改 SQL,或写脚本分批搬运,pgloader 的优势是类型映射可声明、断点可重跑、日志详细到每张表。替代方案:数据量 TB 级用 AWS DMS / 自研 CDC 管道;只迁几张小表用 mysql --batch + \copy 也够。

2.2 完整配置(可直接抄)

-- order_db.load:pgloader 配置文件
LOAD DATABASE
     FROM mysql://migrate_user:Mig_Pass_2026@mysql-prod:3306/order_db
     INTO postgresql://migrate_user:Mig_Pass_2026@pg-prod:5432/order_db

-- 包含建表/建索引/重置序列;不 drop 目标库已有表(生产禁用 include drop!)
WITH create tables,
     create indexes,
     reset sequences,              -- ★ 把 MySQL 自增最大值同步为 PG 序列起点
     downcase identifiers,         -- MySQL 不区分大小写 → PG 统一小写,防大小写折叠坑
     batch rows = 50000,           -- 批行数
     prefetch rows = 20000,
     workers = 8,                  -- 并行 worker
     concurrency = 2               -- 每表并发线程
     exclude table names like 'tmp_%', 'bak_%', 'log_2023%'   -- 排除临时/备份表

SET PostgreSQL PARAMETERS
    maintenance_work_mem to '1GB',     -- 建索引加速
    work_mem to '64MB'

-- 类型映射:按库实际情况声明,逐表核对
CAST type datetime when (= precision 0) to timestamp drop not null using zero-dates-to-null,
     type tinyint when (= precision 1) to boolean drop typemod keep default,
     type tinyint to smallint keep typemod,
     type mediumint to integer keep typemod,
     type bigint unsigned to numeric(20) keep typemod,      -- ★ 无符号大整数
     type int unsigned to bigint keep typemod,              -- ★ 无符号整数上探一位
     type json to jsonb,
     type enum to varchar keep default,
     type binary to bytea;
pgloader --verbose order_db.load 2>&1 | tee pgloader_$(date +%Y%m%d).log

2.3 类型映射的坑,逐个说透

MySQL 类型推荐映射踩坑说明
tinyint(1)boolean业务上确实是 0/1 开关才转 boolean;存了 0/2/3 的"伪 boolean"要保持 smallint,否则数据报错
int unsignedbigint无符号上限 42 亿超 PG int,统一上探
bigint unsignednumeric(20)雪花 ID 表别转 bigint(可能溢出),数值运算需求高的用 numeric
datetimetimestampMySQL datetime 无时区语义;若业务跨时区用 timestamptz 并统一约定(见第八章坑 6)
0000-00-00 零值日期zero-dates-to-null老库常见脏数据,先 SELECT COUNT(*) WHERE create_time='0000-00-00' 评估,不能转 NULL 的先修数
enumvarchar + CHECK 约束pgloader 默认转 varchar;建议迁移后补 CHECK (status IN (...)) 保住约束语义
jsonjsonbjsonb 支持索引与 GIN,是《性能横评》里 JSON 场景 PG 大幅领先的前提
自增主键bigserial + reset sequences忘记 reset 会导致 PG 序列从 1 开始,插入即主键冲突
-- 迁移完成后必做的三件验证
SELECT pg_get_serial_sequence('t_order', 'id');              -- ① 确认序列存在
SELECT setval(pg_get_serial_sequence('t_order','id'),
       (SELECT COALESCE(MAX(id),1) FROM t_order));           -- ② 兜底重置序列(reset失败时手工执行)
SELECT table_name, column_name, data_type FROM information_schema.columns
WHERE table_name='t_order' ORDER BY ordinal_position;        -- ③ 逐列核对类型

2.4 大表与失败重跑

  • 中断重跑:pgloader 重跑前需清空目标表(它不会自动续传)。实践是包一层脚本:重跑前 TRUNCATE 目标表 + 重置序列,保证幂等。
  • 超大表拆分:单表 5 亿行以上建议按主键范围分片自迁(\copy + 自定义 WHERE),pgloader 单任务更适合亿级以内。
  • MySQL 8 认证兼容:老版本 pgloader 不支持 caching_sha2_password,报 "authentication failed" 时升级 pgloader 到 3.6.9+,或给迁移账号临时用 mysql_native_password。

三、第二步:应用层 SQL 改造

3.1 改造清单从哪来:扫描 + 前作对照

语法差异的完整对照直接用《MySQL→PostgreSQL 语法迁移对照》的 10 项清单,这里只讲工程化改造策略。第一步是用工具把"方言 SQL"全部找出来:

# 静态扫描常见 MySQL 方言(人工再逐条确认)
grep -rn "ON DUPLICATE KEY\|REPLACE INTO\|IFNULL\|DATE_FORMAT\|NOW()\|RAND()\|LAST_INSERT_ID\|GROUP_CONCAT\|`" --include="*.xml" --include="*.java" src/main/java/

重点改造项与 PG 写法:

MySQL 写法PG 写法说明
`col` 反引号"col" 或裸列名配合 pgloader downcase identifiers,直接去引号最省事
ON DUPLICATE KEY UPDATEINSERT ... ON CONFLICT (uk) DO UPDATE SET★ 冲突目标列必须显式声明,且依赖唯一索引存在
REPLACE INTOON CONFLICT ... DO UPDATEREPLACE 是删+插,语义完全不同,必须逐条人工确认(会丢自增、触发器行为不同)
IFNULL(a,b)COALESCE(a,b)标准函数,两边通用
DATE_FORMAT(t,'%Y-%m-%d')TO_CHAR(t,'YYYY-MM-DD')注意 MySQL 日期格式符与 PG 完全不同
NOW()NOW()通用;CURDATE() → CURRENT_DATE
RAND()RANDOM()
LAST_INSERT_ID()INSERT ... RETURNING id★ PG 必用 RETURNING,MyBatis 用 useGeneratedKeys="true" keyProperty="id" 同样生效(JDBC 层自动走 RETURNING)
GROUP_CONCAT(x SEPARATOR ',')STRING_AGG(x::text, ',')注意类型 cast
SELECT * FROM t GROUP BY a(宽松模式)必须补齐非聚合列★ PG 永远是 only_full_group_by 语义,宽松写法直接报错,是改造量最大的一类
LIMIT 10 OFFSET 5LIMIT 10 OFFSET 5通用;但 PG 深分页优化建议改游标(见《PG 性能调优实战》)

3.2 双方言共存的分支策略

过渡期应用要同时服务两个库,推荐分支 + 构建期选择而不是运行时 if-else:

master                    ← MySQL 方言(回滚保底分支)
feature/pg-migration      ← PG 方言(改造后的主干)

发布物按库类型选择构建分支:
  双写期版本 = MySQL 分支代码 + PG 侧 SQL 全部走"容错执行"
  (双写组件内封装,见第四章,业务代码里不出现 if(pg) else(mysql))

关键纪律:MyBatis XML 不写任何数据库判断逻辑(databaseId 会让同一条业务逻辑出现两份,漂移风险极高);确有方言差异的语句,封装到 Mapper 层独立 statement,用数据源路由选择,业务 Service 无感知。

3.3 隐式行为差异:比语法更隐蔽的四项

  1. 空字符串与 NULL:MySQL 里 '' 和 NULL 语义常混用,PG 严格区分。WHERE remark != '' 在 PG 会把 NULL 行也排除(三值逻辑),需显式 AND remark IS NOT NULL 或反向核对业务意图。
  2. 隐式类型转换:MySQL 允许 varchar_col = 123,PG 直接报错 operator does not exist。扫描所有数字与字符串的裸比较,参数类型对齐。
  3. boolean 与 0/1:PG boolean 不接受 WHERE flag = 1,要写 WHERE flag 或 WHERE flag = true。批量替换时小心 JDBC 参数类型。
  4. 中文排序:MySQL utf8mb4_general_ci 与 PG 默认 C/zh_CN 排序结果不同,涉及 ORDER BY name 的列表页要确认 collation(LC_COLLATE = 'zh_CN.UTF-8' 建库,或显式 COLLATE)。

四、第三步:双写过渡期

4.1 双写架构与写顺序

应用
 │ ① MySQL 先写(唯一真源,事务内)
 │ ② PG 跟随写(同事务但失败不回滚主流程)
 ▼
补偿记录表(PG 写失败/超时时落库,对账工具重放)
@Component
public class DualWriteExecutor {

    @Transactional(transactionManager = "mysqlTxManager", rollbackFor = Exception.class)
    public void createOrder(OrderDO order) {
        mysqlOrderMapper.insert(order);            // ① 主库,失败=业务失败
        try {
            pgOrderMapper.insert(order);           // ② PG 跟随,同一物理事务但异常被吞
        } catch (Exception e) {
            // 失败不阻断业务:记补偿,等对账重放
            log.error("PG双写失败 orderNo={}", order.getOrderNo(), e);
            compensationMapper.record("INSERT", "t_order", order.getOrderNo(),
                                      JSON.toJSONString(order));
            metrics.counter("dualwrite.fail", "op", "insert").increment();
        }
    }
}

三个设计要点:

  1. MySQL 永远先写、永远是"对不对"的裁决者。PG 只是被跟随的副本,双写期间 PG 任何故障都不影响业务——这保证了双写本身不是新的风险源。
  2. 不做分布式事务。两库一致性靠"最终对账"而不是强一致:补偿表记录失败操作,对账工具定时重放(幂等重放依赖第三步的 ID 策略)。
  3. UPDATE/DELETE 天然幂等可直接重放;INSERT 靠唯一索引 + ON CONFLICT DO NOTHING 保证重放不重复。

4.2 全量与增量的衔接:断档窗口怎么补

这是流程上最容易出错的一环:pgloader 全量迁移需要 25 分钟,期间新订单持续写入,直接迁移必然产生断档。 两种衔接方案,按团队基础设施选:

方案 A(推荐,有 binlog 通道):
  ① Canal/Debezium 监听 MySQL binlog,从固定位点起暂存变更
  ② pgloader 全量迁移(记录迁移开始的 binlog 位点 T0)
  ③ CDC 从 T0 重放增量到 PG,追平后进入持续同步
  ④ 双写组件此时才接管写入(CDC 与双写用幂等保证不重叠不遗漏)

方案 B(无 CDC,应用层补偿):
  ① 应用先上"双写旁路"版本:写 MySQL 同时把每笔变更写入补偿表(不写 PG)
  ② pgloader 全量迁移
  ③ 对账工具按补偿表/updated_at 重放断档增量到 PG
  ④ 重放追平 → 双写组件开启实时跟随

本案例选了方案 B(不想为一次性迁移引入 CDC 集群),补偿表重放约 40 万条增量、耗时 18 分钟。无论哪种方案,"迁移完成的判定标准"都是:对账脚本连续 3 天全绿,而不是"pgloader 显示 success"。

4.3 双写期间的三条纪律

  1. DDL 冻结:任何加列/改列必须等迁移结束,或两侧同步执行且通过评审。双写期间两侧结构不一致 = 数据静默丢失。
  2. ID 唯一来源:全部主键应用生成。若存在残留 AUTO_INCREMENT 列,双写时 MySQL 和 PG 各自分叉,重放补偿时必然冲突。
  3. 性能水位:双写 = 每笔写入两次。提前压测 PG 侧容量(参考《性能横评》的并发写数据),并给 MySQL/PG 都留出熔断空间——PG 侧故障只降级为"停跟随 + 记补偿",绝不反压业务。

五、第四步:数据校验

5.1 三层校验体系

层级方法频率通过标准
L1 行数分表/分条件 COUNT每小时两侧差 = 0(排除在途写入的时间窗口)
L2 聚合主键分段内 SUM/MAX/MD5 指纹每天全部分段一致
L3 抽样随机 N 万行逐字段 diff每天抽样 + 切流前全量差异 = 0
-- L1:行数对比(注意取同一快照时间,低峰执行减少在途差异)
-- MySQL:  SELECT COUNT(*) FROM t_order WHERE create_time < '2026-10-10 00:00:00';
-- PG:     SELECT COUNT(*) FROM t_order WHERE create_time < '2026-10-10 00:00:00';

-- L2:分段指纹(每 10 万行一段,比对聚合特征)
-- MySQL:
SELECT CONCAT(COUNT(*), '_', COALESCE(SUM(id),0), '_', COALESCE(MAX(pay_amount),0))
FROM t_order WHERE id BETWEEN 0 AND 100000;
-- PG(类型显式对齐,防 decimal 尾零差异):
SELECT CONCAT(COUNT(*), '_', COALESCE(SUM(id),0), '_', COALESCE(MAX(pay_amount::text),'0'))
FROM t_order WHERE id BETWEEN 0 AND 100000;

5.2 逐行 diff 的三个类型陷阱

直接对两库做 MD5(CONCAT_WS('|',...)) 大概率全表"不一致",因为类型格式化不同:

  1. decimal 尾零:MySQL 199.00,PG 199.00(numeric 保留),但经驱动取出可能是 199.0——指纹字段先 CAST 成统一格式(CAST(pay_amount AS CHAR) 且两侧小数位对齐)。
  2. 时间格式:MySQL 2026-10-10 08:00:00 与 PG 2026-10-10 08:00:00+08——统一 DATE_FORMAT / TO_CHAR 成同一字符串。
  3. boolean/tinyint(1):true vs 1——统一 CASE WHEN flag THEN '1' ELSE '0' END。
-- 统一格式化后的逐行指纹(以 10 条抽样为例,工具化后全量跑)
-- MySQL: SELECT MD5(CONCAT_WS('|', id, order_no, status,
--          CAST(pay_amount AS CHAR), DATE_FORMAT(create_time,'%Y-%m-%d %H:%i:%s')))
--        FROM t_order WHERE id IN (...);
-- PG:    SELECT MD5(CONCAT_WS('|', id, order_no, status,
--          pay_amount::text, TO_CHAR(create_time,'YYYY-MM-DD HH24:MI:SS')))
--        FROM t_order WHERE id IN (...);

5.3 校验工具化

校验脚本每天自动跑、结果进监控大盘,连续 3 天 L1/L2/L3 全绿才进入切流评审。同时保留"断档修复"能力:发现差异行后按主键从 MySQL 重读到 PG(ON CONFLICT DO UPDATE 覆盖),修复后二次校验。切流评审时 L3 要求一次全量逐行 diff(5000 万行约 2 小时,深夜执行)——引言里那次漏分片事故,就是 L1 用"当天数据"偷工减料造成的,行数校验必须覆盖全时间范围,历史分片一个都不能少。


六、第五步:灰度切流

6.1 先读后写,两段切

阶段一:读切流(风险低,可随时回退)
  读开关按用户尾号灰度:read_from_pg = 1% → 10% → 50% → 100%
  每档观察 ≥1 天:错误率、P99、客服反馈、抽样 diff
  双写保持不变(MySQL 仍是写主)

阶段二:写主切换(一次性动作,不可细分)
  低峰执行:应用写主从 MySQL 切为 PG(配置中心一键切换)
  同一时刻 MySQL 转入"反向跟随"(见第七章)
  观察窗口 48 小时全量盯盘

6.2 读切流的开关实现

@RefreshScope
@Service
public class DataSourceRouter {
    @Value("${migration.read-pg-percent:0}")      // 配置中心秒级生效
    private int readPgPercent;

    public boolean readFromPg() {
        // 按用户尾号灰度,同用户始终走同侧,避免同会话数据视图跳动
        int bucket = Math.abs(Objects.requireNonNull(
                UserContext.current().getUserId()) % 100);
        return bucket < readPgPercent;
    }
}

同一用户固定一侧很关键:双写是同步的、两侧数据一致,但读视图跳动(列表来自 PG、详情来自 MySQL)会让"看起来不一致"的错觉演变成客诉——引言那次投诉的本质就是切流粒度按请求而不是按用户。

6.3 写主切换的执行剧本(Runbook)

T-30min  全量逐行 diff 最后一次,确认 0 差异
T-10min  客服/值班群公告,监控大盘置顶
T-0      配置中心切换 writePrimary = PG(秒级生效)
T+1min   验证:新订单落 PG(抽查 10 单)、MySQL 反向跟随正常
T+5min   核心链路回归:下单/支付/退款/对账 job 各跑一轮
T+30min  复盘会(不通过 → 立即执行回滚剧本,见第七章)
T+48h    观察期通过 → 双写下线,MySQL 只读

写切换本身是"单向门",执行前必须确认三件事:双向同步链路已就绪(回滚的物理前提)、PG 侧连接池/容量压测达标(《PG 高可用实战》的三件套已就位)、回滚决策人与触发条件已书面确认。


七、第六步:回滚方案与 MySQL 下线

7.1 回滚的物理前提:反向同步通道

读切流阶段回滚很轻(读开关归零即可)。写切换之后的回滚才是生死考验——此时新数据只写 PG,想回 MySQL 必须把切写后的变更反向同步回去:

切写前:MySQL ──双写跟随──▶ PG        (MySQL 为主)
切写后:PG ────反向同步────▶ MySQL     (PG 为主,MySQL 只读镜像)

两种反向同步实现,二选一:

方案实现延迟适用
应用层反向双写切写时双写组件反向:主写 PG、跟随写 MySQL毫秒级双写组件现成,推荐首选
CDC 反向通道Debezium 监听 PG WAL → 回放 MySQL;或 pglogical秒级应用层双写不可行的遗留系统

本案例用应用层反向双写:双写组件本身是双向对称的,切写只是交换主从角色,补偿/对账机制原样复用。反向同步必须在切写前就部署并演练——等发现要回滚了才开始搭同步通道,数据早丢了。

7.2 回滚触发条件与决策机制

切流评审时书面确认(写进 Runbook,不接受现场争论):

【P0 立即回滚】
  - PG 侧数据不一致被证实(对账 diff > 0 且无法即时修复)
  - 核心链路错误率 > 1% 持续 5 分钟,且定位为 PG 侧原因
  - PG 性能劣化导致下单 P99 > 3s 持续 10 分钟(压测外的新瓶颈)

【P1 观察决策(30 分钟内)】
  - 非核心功能缺陷(可热修的 SQL 兼容问题)
  - 连接池/资源水位告警但未损业务

【决策链】DBA 提案 → 迁移 Owner(技术负责人)拍板 → 执行剧本
  回滚动作 = 配置中心一键切回(writePrimary = MySQL + 读开关归零)+ 复盘

7.3 回滚演练:预发全流程走一遍

正式切流前 1 周,在预发环境完整演练:切写 → 模拟故障(PG 侧注入错误率)→ 触发回滚 → 校验 MySQL 数据完整 → 恢复正向双写。演练验证的不只是工具,还有团队在压力下的执行速度——正式环境那次"3 天后的历史订单缺失",从发现到回滚完成 11 分钟,就是演练练出来的。

7.4 MySQL 归档下线:最后一步不能急

切写成功后 MySQL 保持反向只读镜像运行 2~4 周(覆盖一个完整的业务周期:周结/月账单/对账 job 全部在 PG 上跑过至少一轮)。下线前三件事:

  1. 最终全量校验一次 diff = 0;
  2. mysqldump 全量备份 + binlog 归档到对象存储(保留期按合规要求,通常 ≥1 年);
  3. 摘除所有连接(监控采集、BI 报表、 forgotten 脚本——SHOW PROCESSLIST 逐个清点),实例降配保留 1 个月观察后再释放。

八、迁移 Checklist 与常见坑

8.1 迁移 Checklist(发布评审用)

【准备阶段】
□ 迁移范围冻结(DDL 冻结公告,窗口期内无表结构变更)
□ 主键全部应用生成(无残留 AUTO_INCREMENT 依赖)
□ 类型映射表逐表评审(unsigned/datetime/enum/json 零默认转换)
□ 脏数据预检:零值日期、非法枚举、超长字段 COUNT 评估
□ 双方言分支建立,预发环境 PG 侧全回归通过

【全量迁移】
□ pgloader 配置评审(禁 include drop;exclude 临时表;reset sequences)
□ 迁移账号最小权限 + MySQL8 认证兼容性预验证
□ 序列重置验证(MAX(id) 对齐)
□ 迁移日志归档(每张表的 rows/skipped 数字留档)

【双写】
□ 写顺序:MySQL 主写 + PG 跟随 + 失败记补偿,业务零阻断验证
□ 补偿表重放幂等验证(INSERT 用 ON CONFLICT)
□ DDL 冻结监控(任何 alter 触发告警)
□ 双写失败率监控 < 0.01%(PG 侧故障演练过)

【校验】
□ L1 行数全时间范围(含历史分片,不许只看当天)
□ L2 分段指纹(decimal/时间/boolean 格式化对齐)
□ L3 逐行 diff 全量 1 次 + 抽样 N 天
□ 对账大盘连续 3 天全绿

【切流】
□ 读开关按用户灰度(非按请求),每档观察 ≥1 天
□ 写主切换 Runbook 书面化(决策人/触发条件/动作序列)
□ 反向同步通道部署且演练过
□ 回滚演练在预发全流程通过

【下线】
□ 观察期 ≥2 周,覆盖周结/月账单
□ 最终全量校验 diff = 0
□ mysqldump 归档(保留期 ≥1 年)
□ SHOW PROCESSLIST 清点连接后摘除

8.2 十个常见坑(每条都有人栽过)

  1. 大小写折叠:MySQL 表名/列名不区分大小写,PG 未加引号会折成小写;downcase identifiers + 代码里不用引号包大写名,一次治理到位。
  2. 序列忘重置:插入新订单主键冲突。pgloader reset sequences + 迁移后 setval 兜底(2.3 节)。
  3. 隐式类型转换报错:varchar = 123 直接 operator does not exist,上线前扫描全部裸比较。
  4. 零值日期:0000-00-00 转不进 timestamp,pgloader 报错中断——先修数或 zero-dates-to-null,且评估业务侧"空日期"语义。
  5. GROUP BY 宽松写法:MySQL 宽松模式跑得好好的 SQL,PG 直接报错,是改造量最大的类别(3.1 表)。
  6. 时区:MySQL datetime 无时区,PG timestamp 也无时区,但 JVM/驱动/CURRENT_TIMESTAMP 的行为组合会让时间漂移 8 小时——建库统一 timezone 并在双写前用同一笔订单比对两库时间字段。
  7. 空字符串 vs NULL:!= '' 过滤行为不同(3.3 节),报表统计口径漂移的常见根源。
  8. 双写主键分叉:残留 AUTO_INCREMENT 导致补偿重放冲突——Checklist 的"主键应用生成"就是为它设的。
  9. pgloader 认证失败:MySQL 8 caching_sha2_password 兼容(2.4 节)。
  10. 校验只看当天:历史分片漏迁在最不设防的时刻爆炸(引言事故)——L1 必须全时间范围。

九、常见问题

9.1 中小项目(几百万行、单库)要不要走完整的六步?

可以裁剪,但裁剪的是"工具重量"不是"流程纪律"。数据量小且允许 10~30 分钟停写窗口的项目:pgloader 全量(分钟级)→ 停写窗口内补最后增量 → 校验 → 一次切流,双写与灰度可整体跳过。但三层校验和回滚预案不能省——数据量越小,越容易有"就这么点数据不可能错"的侥幸。建议的简化底线:行数全量校验 + 抽样 diff + MySQL 保留 2 周只读。

9.2 双写期间 PG 写失败率升高怎么办?会不会拖慢主链路?

双写组件的设计保证了 PG 侧任何故障都不阻断业务:跟随写失败只记补偿 + 打点。观察到失败率 > 0.01% 的正确动作是暂停跟随、保留补偿、对账修复后恢复,而不是加重试硬扛(重试风暴会放大故障)。主链路耗时影响控制在几毫秒级(PG 内网写单表 P99 < 5ms),若超预期说明连接池或 PG 侧配置有问题,先查连接数(《PG 高可用实战》pgBouncer)再查锁等待。

9.3 pgloader 迁到一半失败了,重跑会重复数据吗?

会,pgloader 不做断点续传。正确姿势:重跑前 TRUNCATE 目标表 + 重置序列,保证"整表重迁"幂等(2.4 节脚本)。千万不要"只重跑失败的那几张表"——表间外键/关联数据可能部分一致,半新半旧比全旧更难校验。大表频繁失败的,改走分片自迁方案。

9.4 灰度读切流时,同一用户的数据在两侧都一致吗?为什么还要按用户而不是按请求灰度?

双写同步完成,理论上两侧实时一致。按用户灰度的原因有二:① 排除"请求级跳动"造成的体验错觉与客诉(列表和详情来自不同库的观感差异);② 出问题时影响面收敛在可解释的灰度名单内,便于对比排查。另外注意双写是"跟随写"语义,极端场景(PG 瞬断走补偿)会有秒级滞后,按用户固定一侧能保证同一用户的会话内视图稳定。

9.5 写主切换后,怎么监控"反向同步"的健康度?断了怎么办?

反向同步健康度三个指标:延迟(跟随写耗时 P99)、补偿表增量(> 0 即有失败待重放)、对账 diff(切写后每天跑)。断链的表现是补偿表持续增长——告警阈值设补偿 > 100 条持续 5 分钟即 P1。恢复动作:先止血(PG 主写不受影响)、再重放补偿、最后对账确认追平。这正是"应用层反向双写"优于 CDC 的地方:故障模式简单、重放工具现成、不依赖额外中间件的可用性。

9.6 什么时候可以真正下线 MySQL?下线后发现数据问题还能救吗?

标准是"覆盖完整业务周期 + 回滚窗口关闭":观察期 ≥2 周,期间周结/月账单/所有定时任务在 PG 上至少成功跑过一轮,且最后一次全量校验 diff = 0。下线 ≠ 删数据:mysqldump 归档 + binlog 留存后,实例降配冷备保留 1 个月。冷备期间发现数据问题,仍可从归档恢复到临时实例做比对修复——只是不再有"秒级切回"的能力,这也是观察期必须充分的原因。


十、总结

六步速查卡

【① SQL 改造】
  方言对照用前作 10 项清单 + grep 扫描;双方言走分支不写 if-else
  重点:ON CONFLICT / RETURNING / GROUP BY 严格模式 / 隐式转换 / '' vs NULL

【② pgloader 全量】
  禁 include drop;reset sequences + setval 兜底
  类型映射逐表评审:tinyint(1)→boolean、unsigned 上探、json→jsonb、零值日期预修
  MySQL8 认证兼容;失败重跑 = TRUNCATE + 整表重迁

【③ 双写】
  MySQL 主写 + PG 跟随 + 失败记补偿,业务零阻断
  全量与增量衔接:CDC 追平 或 补偿表重放
  DDL 冻结;主键应用生成;双写失败率监控

【④ 校验】
  L1 行数全时间范围 → L2 分段指纹 → L3 逐行 diff
  指纹先统一格式化(decimal 尾零/时间格式/boolean)
  连续 3 天全绿才切流

【⑤ 灰度切流】
  先读后写:读按用户尾号 1%→10%→50%→100%,每档观察 ≥1 天
  写主切换一次性动作,Runbook 书面化,三确认(反向同步就绪/容量达标/回滚决策人)

【⑥ 回滚与下线】
  回滚物理前提 = 反向同步通道(切写前部署并演练)
  触发条件书面化:数据不一致/核心错误率/性能劣化
  预发全流程回滚演练;观察期 ≥2 周;归档后再摘连接下线

给团队的建议

项建议
立项迁移是流程工程:评估(《为什么迁移 PG》)→ 语法(《语法迁移对照》)→ 本篇落地,三篇缺一不可
机制回滚预案是切流准入条件,不是事后补救;决策人/触发条件书面化
校验三层校验工具化进监控大盘;"连续 3 天全绿"是唯一切流标准
双写组件化沉淀(主从/补偿/重放/对账),下次迁移复用,不做一次性脚本
演练正式切流前预发全流程演练(含回滚触发),演练不通过不准上线
复盘每个坑回填进 Checklist,本篇 8.1 清单即历次事故的沉淀
心态零信任:不信任工具 success 日志、不信任单次校验、不信任"应该不会出错"

一句话

MySQL 迁 PostgreSQL 的六步流程,每一步都在回答一个"不信任"的问题:pgloader 全量迁移回答"数据搬完了吗"——但 success 日志只是起点,类型映射逐表评审(tinyint(1)、unsigned、零值日期)、序列重置、MySQL 8 认证兼容,任何一个默认值都可能让"搬完"变成"搬错";应用层 SQL 改造回答"代码能跑吗"——ON CONFLICT 替换 ON DUPLICATE KEY、RETURNING 替换 LAST_INSERT_ID、GROUP BY 严格模式是三大主战场,而空字符串 vs NULL、隐式类型转换这类行为差异比语法替换更隐蔽,双方言靠分支管理而不是运行时判断;双写过渡回答"增量丢了吗"——MySQL 主写 + PG 跟随 + 失败记补偿的架构让双写本身不构成新风险,全量与增量的断档用 CDC 追平或补偿表重放,主键应用生成是硬前提;三层校验回答"数据对吗"——行数必须覆盖全时间范围(历史分片漏一片就是线上事故)、分段指纹必须先统一 decimal/时间/boolean 的格式化、连续 3 天全绿才是唯一切流标准;灰度切流回答"用户受影响了吗"——先读后写、按用户而非按请求灰度、写主切换是一次性单向门,执行前三确认;回滚方案回答"错了怎么办"——反向同步通道必须在切写前部署并演练,触发条件书面化、预发全流程演练,把 11 分钟的回滚变成肌肉记忆;最后 MySQL 归档下线前的 2 周观察期,覆盖完整业务周期才算真正告别。迁移工程的本质不是一次技术升级,而是一场用流程对抗侥幸的搬家——工具能搬数据,只有"零信任 + 可回滚"的流程纪律能搬得心安。

互动话题:你们经历过 MySQL→PG(或反方向)的迁移吗?卡在哪一步——语法改造量超预期、双写一致性、还是校验工具建设?有没有"回滚真的救了一命"的故事?评论区聊聊你的迁移经历,你踩的坑可能就是别人下个月的血泪。


标题:PG 迁移落地全流程:MySQL → PostgreSQL 迁移实战 + 回滚方案
作者:jiangyi
地址:http://jiangyi.space/articles/2026/10/19/1792330391998.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消