跳到主内容

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 / SMALLINTSMALLINTPG 无 1 字节无符号类型,状态字段建议直接 SMALLINT
INT UNSIGNEDBIGINTPG 没有无符号整型,存 IP、时间戳等大数必须升位
BIGINT UNSIGNEDNUMERIC 或 BIGINT超过 BIGINT 上限只能用 NUMERIC,主键场景极罕见
DATETIMETIMESTAMPTZ带 6 位微秒精度;PG 推荐统一带时区,避免跨时区歧义
TIMESTAMPTIMESTAMPTZMySQL 的 TIMESTAMP 有 2038 问题且受时区影响,务必显式转换
AUTO_INCREMENTGENERATED ALWAYS AS IDENTITY不要用旧的 SERIAL,IDENTITY 语义更清晰且符合 SQL 标准
ENUM('a','b')TEXT + CHECK 约束原生枚举类型增删值需 ALTER TYPE,运维不便,CHECK 更灵活
JSONJSONBPG 的 JSON 只存原文,JSONB 去重解析后支持索引与运算符
DOUBLE / FLOATDOUBLE PRECISION / REAL金额字段趁机统一改成 NUMERIC(18,4)
TINYTEXT/TEXTTEXTPG 的 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 提为主库。整个切换期间旧库只降级不停机,随时可以回滚,这是心理安全感的来源。

避坑总结 ​

  1. 迁移前先做 SQL 全量扫描,别等报错驱动改造。用 pgloader 预演加 sqlglot 静态扫描,把保留字、方言函数、隐式转换三类问题在评估期清点完毕,工时估算才不会失真。
  2. 无符号整型一律升位 BIGINT,别赌数据上限。INT UNSIGNED 存的时间戳、IP 在 PG 里放不下 INT,上线后溢出是灾难级事故;类型映射时宁可保守。
  3. TIMESTAMP 一律 TIMESTAMPTZ,导入前清洗零日期。时区歧义和 0000-00-00 是数据校验环节最常见的失败原因,越早清洗成本越低。
  4. 双写必须配对账,重试队列必须有兜底终态。只写不对账等于裸奔;对账用分段哈希先圈范围、行级 diff 再定位,效率高一个数量级。
  5. 灰度切读观察期不要压缩。执行计划差异导致的性能问题往往只在真实流量下暴露,每一档灰度至少观察一个完整的业务高峰,再进下一档。

写在最后 ​

MySQL 迁移 PostgreSQL 的技术难度其实不高,难的是把差异清点得足够全、把切换节奏控制得足够稳。类型映射表和语法差异清单可以在评估阶段就完成九成工作,双写与对账则决定了切换当晚你能否睡着觉。把这篇文章的清单带进你的迁移评审会,很多坑在写第一行代码之前就能绕开。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。