外观
MySQL 迁移 PostgreSQL 避坑实录:类型映射、语法差异与双写切换一文搞懂
把一套跑了五年的 MySQL 业务库搬到 PostgreSQL,最难的不是导数据,而是那些文档上不会写、跑起来才炸的细节。我们团队今年上半年完成了订单中台的迁移:源库是 MySQL 8.0,约 400 张表、2TB 数据,目标版本是 PostgreSQL 17。整个过程前后花了六周,其中真正花在「搬数据」上的时间不到两天,剩下的时间几乎都在处理类型映射偏差、SQL 语法兼容和双写期间的数据对账。这篇文章把完整过程和踩过的坑一次性讲清,希望你的迁移周报不用像我们第一版那样惨烈。
先说为什么值得迁。MySQL 依然是优秀的数据库,但 PostgreSQL 在复杂查询优化器、JSONB 半结构化能力、GIS 支持、并行查询和扩展生态上的优势,让很多业务在选型时天平开始倾斜;再加上部分企业受数据库许可成本影响主动寻找替代方案,迁移需求近几年明显变多。PG 16/17 的逻辑复制、性能与可观测性都已相当成熟,迁移窗口期的工具链也足够完备,这已不是十年前那种「九死一生」的工程。
迁移路线对比:停机搬迁、双写与 CDC 怎么选
迁移前第一个决策是路线。按停机容忍度和数据一致性要求,主流方案有三条:
| 维度 | 一次性停机迁移 | 应用层双写切换 | CDC 逻辑复制 |
|---|---|---|---|
| 实现复杂度 | 低,脚本导完即止 | 中,需改造写入路径 | 中高,需部署同步组件 |
| 停机时长 | 小时级,取决于数据量 | 接近零 | 接近零 |
| 数据一致性 | 迁移窗口内可校验 | 依赖对账任务兜底 | 最终一致,延迟秒级 |
| 回滚难度 | 回滚到旧库即可 | 保留双写即可回滚 | 需维护反向链路 |
| 适用场景 | 小库、可接受停机的内部系统 | 中小规模在线业务 | 大规模、强连续性业务 |
三条路线并不互斥。我们的实际做法是混合:先用逻辑复制把存量数据灌入 PG 并持续追平增量,应用层再叠加双写做精确对账,最后灰度切读、切写。CDC 负责大盘数据,双写负责敏感表(订单、资金)的逐行校验,两层兜底换来的是切换当晚的从容。
类型映射:九成的坑集中在这张表里
类型系统是 MySQL 和 PG 差异最大的地方,也是迁移工时估算最容易失真的环节。下面是实战中反复踩坑后的映射清单:
| MySQL 类型 | 推荐映射 | 踩坑说明 |
|---|---|---|
| TINYINT / SMALLINT | SMALLINT | PG 无 1 字节无符号类型,状态字段建议直接 SMALLINT |
| INT UNSIGNED | BIGINT | PG 没有无符号整型,存 IP、时间戳等大数必须升位 |
| BIGINT UNSIGNED | NUMERIC 或 BIGINT | 超过 BIGINT 上限只能用 NUMERIC,主键场景极罕见 |
| DATETIME | TIMESTAMPTZ | 带 6 位微秒精度;PG 推荐统一带时区,避免跨时区歧义 |
| TIMESTAMP | TIMESTAMPTZ | MySQL 的 TIMESTAMP 有 2038 问题且受时区影响,务必显式转换 |
| AUTO_INCREMENT | GENERATED ALWAYS AS IDENTITY | 不要用旧的 SERIAL,IDENTITY 语义更清晰且符合 SQL 标准 |
| ENUM('a','b') | TEXT + CHECK 约束 | 原生枚举类型增删值需 ALTER TYPE,运维不便,CHECK 更灵活 |
| JSON | JSONB | PG 的 JSON 只存原文,JSONB 去重解析后支持索引与运算符 |
| DOUBLE / FLOAT | DOUBLE PRECISION / REAL | 金额字段趁机统一改成 NUMERIC(18,4) |
| TINYTEXT/TEXT | TEXT | PG 的 TEXT 无长度上限且性能无差,不必再用 VARCHAR(N) 区分 |
有两个隐蔽问题值得单独强调。第一是隐式类型转换:MySQL 里 WHERE phone = 13800000000 能走索引,PG 里数字与字符串比较会直接报错,迁移后这类写法全部要改成显式传字符串。第二是 0000-00-00 零日期:MySQL 默认容忍它,PG 会直接拒绝导入,导数前必须清洗,我们的方案是统一替换为 NULL 并在应用层补齐兜底逻辑。
语法差异清单:改造成本最高的 Top 项
类型之外,SQL 方言差异决定着 DAO 层改造工作量。下面这段对比代码浓缩了迁移中改得最多的几类写法:
sql
-- MySQL 写法 → PostgreSQL 写法
-- 1. UPSERT:ON DUPLICATE KEY UPDATE 换成 ON CONFLICT
-- MySQL:
-- INSERT INTO stock (sku, qty) VALUES (1001, 5)
-- ON DUPLICATE KEY UPDATE qty = qty + 5;
INSERT INTO stock (sku, qty) VALUES (1001, 5)
ON CONFLICT (sku) DO UPDATE SET qty = stock.qty + EXCLUDED.qty;
-- 2. 分组拼接:GROUP_CONCAT 换成 STRING_AGG,默认无序需显式指定
SELECT dept_id, STRING_AGG(name, ',' ORDER BY name)
FROM employee GROUP BY dept_id;
-- 3. 反引号换双引号,且 PG 的小写标识符折叠规则不同
SELECT order_no, amount FROM "order" WHERE created_at >= '2026-01-01';
-- 4. 日期函数名差异:DATE_ADD 换成区间运算
SELECT now() - INTERVAL '7 days';
-- 5. 空值兜底:IFNULL 换成标准 COALESCE
SELECT COALESCE(nickname, username, '匿名用户') FROM users;其中「保留字当表名」是最阴险的一类:order、user、desc 在 PG 里是保留字,不加双引号直接语法报错。建议迁移评估阶段用 pgloader 或 sqlglot 做一次全量 SQL 扫描,把含保留字、含方言函数的语句提前清点出来,而不是等上线后逐条报错逐条修。另外 PG 的 GROUP BY 严格模式不允许 SELECT 非聚合列,MySQL 宽松模式下能跑通的 sloppy SQL 迁移后全部要重写——这反而是好事,趁迁移把历史欠账的技术债一并清掉。
双写切换实操:四阶段灰度 + 逐行对账
我们最终采用的切换节奏分四个阶段:只读双写 → 对账清零 → 灰度切读 → 全量切写。写入路径的改造核心是「PG 写失败必须报警,但短暂失败不阻断 MySQL 主流程」,用异步补偿保证最终一致:
python
# 双写封装示例:主库 MySQL 成功后,PG 写入异步执行并对账
import logging
from concurrent.futures import ThreadPoolExecutor
logger = logging.getLogger("dual-write")
_executor = ThreadPoolExecutor(max_workers=8, thread_name_prefix="pg-dual-write")
def write_order(order: dict):
"""订单写入:MySQL 为主,PG 异步跟随,失败进重试队列"""
order_id = save_to_mysql(order) # 主库写入,失败直接抛异常回滚事务
_executor.submit(_sync_to_pg, "orders", order_id, order)
def _sync_to_pg(table: str, row_id: int, payload: dict):
try:
upsert_to_postgres(table, row_id, payload)
except Exception as exc: # PG 侧异常只记录,不回传主流程
logger.error("PG 双写失败 table=%s id=%s err=%s", table, row_id, exc)
push_to_retry_queue(table, row_id) # 进重试队列,由对账任务兜底对账任务建议直接用哈希比对而非逐字段比对:按主键分段,对每段计算业务字段的聚合哈希,不一致的段再下沉做行级 diff。2TB 的库全量跑一轮行级比对要十几个小时,而分段哈希二十分钟就能圈出差异范围。切读阶段按流量灰度(5% → 30% → 100%),每档观察至少一天;切写前最后做一次双库冻结秒级比对,确认零差异后再把 PG 提为主库。整个切换期间旧库只降级不停机,随时可以回滚,这是心理安全感的来源。
避坑总结
- 迁移前先做 SQL 全量扫描,别等报错驱动改造。用 pgloader 预演加 sqlglot 静态扫描,把保留字、方言函数、隐式转换三类问题在评估期清点完毕,工时估算才不会失真。
- 无符号整型一律升位 BIGINT,别赌数据上限。INT UNSIGNED 存的时间戳、IP 在 PG 里放不下 INT,上线后溢出是灾难级事故;类型映射时宁可保守。
- TIMESTAMP 一律 TIMESTAMPTZ,导入前清洗零日期。时区歧义和
0000-00-00是数据校验环节最常见的失败原因,越早清洗成本越低。 - 双写必须配对账,重试队列必须有兜底终态。只写不对账等于裸奔;对账用分段哈希先圈范围、行级 diff 再定位,效率高一个数量级。
- 灰度切读观察期不要压缩。执行计划差异导致的性能问题往往只在真实流量下暴露,每一档灰度至少观察一个完整的业务高峰,再进下一档。
写在最后
MySQL 迁移 PostgreSQL 的技术难度其实不高,难的是把差异清点得足够全、把切换节奏控制得足够稳。类型映射表和语法差异清单可以在评估阶段就完成九成工作,双写与对账则决定了切换当晚你能否睡着觉。把这篇文章的清单带进你的迁移评审会,很多坑在写第一行代码之前就能绕开。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。