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 开始前的三个前置确认
- 范围冻结:迁移窗口内暂停影响表结构的 DDL 变更(双写期间任何一侧结构漂移都是灾难)。
- 主键策略确认:双写要求所有表主键由应用生成(业务单号/雪花 ID),纯
AUTO_INCREMENT表必须先改造——这是很多人到双写阶段才发现的硬前提。 - 回滚预案先行:写清楚什么条件触发回滚、由谁决策、演练过没有。回滚方案不是迁移失败后的补救,是切流前的准入条件。
二、第一步: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 unsigned | bigint | 无符号上限 42 亿超 PG int,统一上探 |
bigint unsigned | numeric(20) | 雪花 ID 表别转 bigint(可能溢出),数值运算需求高的用 numeric |
datetime | timestamp | MySQL datetime 无时区语义;若业务跨时区用 timestamptz 并统一约定(见第八章坑 6) |
0000-00-00 零值日期 | zero-dates-to-null | 老库常见脏数据,先 SELECT COUNT(*) WHERE create_time='0000-00-00' 评估,不能转 NULL 的先修数 |
enum | varchar + CHECK 约束 | pgloader 默认转 varchar;建议迁移后补 CHECK (status IN (...)) 保住约束语义 |
json | jsonb | jsonb 支持索引与 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 UPDATE | INSERT ... ON CONFLICT (uk) DO UPDATE SET | ★ 冲突目标列必须显式声明,且依赖唯一索引存在 |
REPLACE INTO | ON CONFLICT ... DO UPDATE | REPLACE 是删+插,语义完全不同,必须逐条人工确认(会丢自增、触发器行为不同) |
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 5 | LIMIT 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 隐式行为差异:比语法更隐蔽的四项
- 空字符串与 NULL:MySQL 里
''和 NULL 语义常混用,PG 严格区分。WHERE remark != ''在 PG 会把 NULL 行也排除(三值逻辑),需显式AND remark IS NOT NULL或反向核对业务意图。 - 隐式类型转换:MySQL 允许
varchar_col = 123,PG 直接报错operator does not exist。扫描所有数字与字符串的裸比较,参数类型对齐。 - boolean 与 0/1:PG boolean 不接受
WHERE flag = 1,要写WHERE flag或WHERE flag = true。批量替换时小心 JDBC 参数类型。 - 中文排序: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();
}
}
}
三个设计要点:
- MySQL 永远先写、永远是"对不对"的裁决者。PG 只是被跟随的副本,双写期间 PG 任何故障都不影响业务——这保证了双写本身不是新的风险源。
- 不做分布式事务。两库一致性靠"最终对账"而不是强一致:补偿表记录失败操作,对账工具定时重放(幂等重放依赖第三步的 ID 策略)。
- 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 双写期间的三条纪律
- DDL 冻结:任何加列/改列必须等迁移结束,或两侧同步执行且通过评审。双写期间两侧结构不一致 = 数据静默丢失。
- ID 唯一来源:全部主键应用生成。若存在残留 AUTO_INCREMENT 列,双写时 MySQL 和 PG 各自分叉,重放补偿时必然冲突。
- 性能水位:双写 = 每笔写入两次。提前压测 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('|',...)) 大概率全表"不一致",因为类型格式化不同:
- decimal 尾零:MySQL
199.00,PG199.00(numeric 保留),但经驱动取出可能是199.0——指纹字段先 CAST 成统一格式(CAST(pay_amount AS CHAR)且两侧小数位对齐)。 - 时间格式:MySQL
2026-10-10 08:00:00与 PG2026-10-10 08:00:00+08——统一DATE_FORMAT/TO_CHAR成同一字符串。 - boolean/tinyint(1):
truevs1——统一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 上跑过至少一轮)。下线前三件事:
- 最终全量校验一次 diff = 0;
- mysqldump 全量备份 + binlog 归档到对象存储(保留期按合规要求,通常 ≥1 年);
- 摘除所有连接(监控采集、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 十个常见坑(每条都有人栽过)
- 大小写折叠:MySQL 表名/列名不区分大小写,PG 未加引号会折成小写;
downcase identifiers+ 代码里不用引号包大写名,一次治理到位。 - 序列忘重置:插入新订单主键冲突。pgloader
reset sequences+ 迁移后 setval 兜底(2.3 节)。 - 隐式类型转换报错:
varchar = 123直接operator does not exist,上线前扫描全部裸比较。 - 零值日期:
0000-00-00转不进 timestamp,pgloader 报错中断——先修数或 zero-dates-to-null,且评估业务侧"空日期"语义。 - GROUP BY 宽松写法:MySQL 宽松模式跑得好好的 SQL,PG 直接报错,是改造量最大的类别(3.1 表)。
- 时区:MySQL datetime 无时区,PG timestamp 也无时区,但 JVM/驱动/
CURRENT_TIMESTAMP的行为组合会让时间漂移 8 小时——建库统一 timezone 并在双写前用同一笔订单比对两库时间字段。 - 空字符串 vs NULL:
!= ''过滤行为不同(3.3 节),报表统计口径漂移的常见根源。 - 双写主键分叉:残留 AUTO_INCREMENT 导致补偿重放冲突——Checklist 的"主键应用生成"就是为它设的。
- pgloader 认证失败:MySQL 8 caching_sha2_password 兼容(2.4 节)。
- 校验只看当天:历史分片漏迁在最不设防的时刻爆炸(引言事故)——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://www.jiangyi.space/articles/2026/10/19/1792330391998.html
公众号:服务端技术精选
- 引言
- 一、全景:六步流程与六周时间线
- 1.1 流程总图
- 1.2 六周时间线与人力
- 1.3 开始前的三个前置确认
- 二、第一步:pgloader 全量迁移
- 2.1 pgloader 是什么、为什么选它
- 2.2 完整配置(可直接抄)
- 2.3 类型映射的坑,逐个说透
- 2.4 大表与失败重跑
- 三、第二步:应用层 SQL 改造
- 3.1 改造清单从哪来:扫描 + 前作对照
- 3.2 双方言共存的分支策略
- 3.3 隐式行为差异:比语法更隐蔽的四项
- 四、第三步:双写过渡期
- 4.1 双写架构与写顺序
- 4.2 全量与增量的衔接:断档窗口怎么补
- 4.3 双写期间的三条纪律
- 五、第四步:数据校验
- 5.1 三层校验体系
- 5.2 逐行 diff 的三个类型陷阱
- 5.3 校验工具化
- 六、第五步:灰度切流
- 6.1 先读后写,两段切
- 6.2 读切流的开关实现
- 6.3 写主切换的执行剧本(Runbook)
- 七、第六步:回滚方案与 MySQL 下线
- 7.1 回滚的物理前提:反向同步通道
- 7.2 回滚触发条件与决策机制
- 7.3 回滚演练:预发全流程走一遍
- 7.4 MySQL 归档下线:最后一步不能急
- 八、迁移 Checklist 与常见坑
- 8.1 迁移 Checklist(发布评审用)
- 8.2 十个常见坑(每条都有人栽过)
- 九、常见问题
- 9.1 中小项目(几百万行、单库)要不要走完整的六步?
- 9.2 双写期间 PG 写失败率升高怎么办?会不会拖慢主链路?
- 9.3 pgloader 迁到一半失败了,重跑会重复数据吗?
- 9.4 灰度读切流时,同一用户的数据在两侧都一致吗?为什么还要按用户而不是按请求灰度?
- 9.5 写主切换后,怎么监控"反向同步"的健康度?断了怎么办?
- 9.6 什么时候可以真正下线 MySQL?下线后发现数据问题还能救吗?
- 十、总结
- 六步速查卡
- 给团队的建议
- 一句话
评论