MySQL 8.0 还是 PostgreSQL 16?我们把 3.2 亿行订单表迁过去,踩了 11 个坑,附一张选型打分表

🔑 关键词:MySQL 8.0, PostgreSQL 16, 数据库选型, 数据迁移, 分区表

📖 摘要:一个 9 人后端团队把 3.2 亿行订单从 MySQL 8.0.32 迁到 PostgreSQL 16.2 的完整记录,包含同配置实测数据、11 个真实踩坑、以及一张可以直接抄走的选型打分表。

MySQL 8.0 还是 PostgreSQL 16?我们把 3.2 亿行订单表迁过去,踩了 11 个坑

图片

先说清楚我们是什么体量,不然全是空谈

我们做的是 B2B 订单 SaaS,研发 23 人,后端 9 个。老系统 2018 年上线,MySQL 5.7.22,后来升到 8.0.32,一台 8 核 32G 的云数据库主库加一台只读从库。到 2023 年底,orders 主表 3.2 亿行,数据 168GB、二级索引 96GB,order_items 4.7 亿行。写入峰值 1800 TPS,读 6000 QPS 左右。慢查询面板上一周稳定有 200 多条超过 1 秒的,基本都是 status + created_at 这类组合条件的报表查询。这体量说出来大概不入大厂法眼,但对一个 9 人的后端团队来说,已经足够让每一次 DDL 都变成一场跨部门会议。

导火索很蠢。2024 年 3 月我们想把 orders 表的 remark varchar(255) 扩到 varchar(1024),客服那边要存长备注。问题是 varchar 的长度前缀会从 1 字节变成 2 字节,InnoDB 走不了 INPLACE,只能 COPY 算法重建全表。运维同学本来打算用 pt-online-schema-change,跑了两个小时报磁盘空间不足——那个实例当时只剩 190GB 空闲,而重建一张数据加索引 264GB 的表,还得额外留触发器日志的空间。最后换 gh-ost,从晚上 11 点折腾到凌晨 3 点 40 分,中间从库延迟最高 52 分钟。第二天早会没人说话,产品经理问了一句「现在能改需求了吗」,没人接话。

那次之后我开始认真看 PostgreSQL。倒不是因为它快,是因为我随手在测试库上敲了一条 alter table orders alter column remark type varchar(1024);,0.4 秒返回。就这一条,让我盯着终端愣了半天。PG 里改 varchar 长度上限只是改 catalog,不重写数据;MySQL 里同样一句话能让你干到天亮。

实测数据:别信营销号,单机性能差距真没多大

图片

我们在两台同配置的机器上做了对比,16 核 64G、NVMe SSD,一边 MySQL 8.0.32,一边 PostgreSQL 16.2,数据是同一份 3.2 亿行订单的脱敏快照。工具用 sysbench 1.0.20 和 pgbench,各跑三遍取中位数。几个我记得住的数:单条主键查询,MySQL 0.41ms,PG 0.33ms;批量插入 10 万行(JDBC batch size 1000),MySQL 21.7 秒,PG 8.9 秒,这块 PG 的 WAL 写入路径确实更省;全表 count(*),MySQL 6 分 12 秒,PG 5 分 48 秒,谁也别笑谁,这个量级两家都得老老实实扫。

真正拉开差距的是组合条件查询。我们有个「查某租户上个月已完成订单」的接口,SQL 大概是 where tenant_id = ? and status = 3 and created_at between ? and ?。MySQL 走 idx_tenant_status 回表,P99 1.8 秒;PG 上我建了 tenant_id + created_at 的复合索引,再加一个 where status = 3 的 partial index,P99 掉到 340 毫秒。注意这不是「PG 比 MySQL 快 5 倍」这种鬼话,是因为 PG 允许我把 status = 3 做成部分索引——只有已完成订单才进索引,那个索引从 96GB 缩到 11GB,还能在时间维度上叠一层 BRIN。MySQL 没这个能力,你只能在索引设计上做妥协,比如把状态拆成单独的表,或者接受回表。

所以我的判断是:单机 OLTP 性能两家差在正负 20% 以内,选型会上拿 benchmark 说事的基本在浪费大家时间。真正该比的是「你这套查询模式能不能被这个索引体系表达出来」。

11 个坑,我挑最要命的几个展开

1)sequence 没同步。这是最蠢也最常见的一个。迁移完第一次插入就报 duplicate key,因为自增序列还停在 1。修一行就行:select setval('orders_id_seq', (select max(id) from orders));,但如果你是凌晨三点发现的,这行字能让你心跳到 140。

2)标识符大小写。PG 会把没加引号的标识符折叠成小写。我们早期 Hibernate 生成的是大写表名,全炸。最后统一改成小写下划线,实体上老实写 @Table(name = "orders")

图片

3)xid 回卷,这个是真会停机的。我们的 audit_log 表写入量很大,autovacuum 一直没跟上,age 涨到 1.2 亿才被监控抓到。后来把这张表的 autovacuum_vacuum_scale_factor 从默认 0.2 调到 0.02,autovacuum_vacuum_cost_limit 提到 2000,才算稳住。

4)隐式类型转换没了。MySQL 里 where order_no = 12345(order_no 是 varchar)能跑,PG 直接甩你一个 operator does not exist: character varying = integer。上线前我们全量扫了一遍代码里的 SQL 拼接。

5)PgBouncer 的 prepared statement。transaction 模式下 JDBC 的 prepareThreshold 默认是 5,跑一会儿就报 prepared statement S_1 already exists。两个解法:把 PgBouncer 升到 1.21 以上开 max_prepared_statements,或者直接把 prepareThreshold 设成 0 关掉服务端预编译。我们选了前者,但踩了三天。

6)没有前缀索引。MySQL 可以 KEY (url(20)),PG 没这功能。要么建表达式索引 substring(url,1,20),要么上 pg_trgm 扩展。

7)长事务。PG 是原地更新,一个跑了 40 分钟的报表事务能把 vacuum 死死挡住,那张表三天膨胀了 3 倍。MySQL 有 undo log 和 purge 线程,一样会膨胀,但表现没那么直接,所以你的开发习惯不会立刻被惩罚——迁到 PG 之后,所有报表查询都必须改写走只读副本,或者加 statement_timeout。

图片

8)时间戳语义。MySQL 的 datetime 存什么就是什么,PG 的 timestamptz 会按 UTC 存、按会话时区返回。我们历史数据里混了一批本地时间,迁完报表整整差 8 小时,财务那边先发现的。

9)分区表要兜底。PG 10 之后的声明式分区很好用,但默认分区不存在时插入会直接报错。建一个 orders_default 分区保命,成本几乎为零。

10)JSON 顺序。我们有个第三方回调接口,依赖 JSON 原始字符串的 key 顺序做签名校验。原来在 MySQL 里那一列是 varchar 存的原始文本,迁到 PG 我顺手换成了 jsonb——key 顺序没了,签名全挂。这个坑纯粹是我自己手贱,但值得写出来。

11)EXPLAIN 的成本单位。两家的 cost 完全不是一个东西,别拿 MySQL 的 rows 去比 PG 的 cost。想看真实 IO 得开 track_io_timing = on,配合 pg_stat_statements 看 shared_blks_read,那个数才靠谱。

我的观点可能不太讨好

图片

选型讨论里最常见的错误是拿性能说事。可到了 3.2 亿行这个量级,两家单机性能差不到 20%,真正决定生死的是「出事那天你能不能自己救回来」。PG 的坑更硬,出事了中文搜不到答案,得去翻官方文档、翻 pgsql-hackers 邮件列表,或者去 IRC 上等人回话。MySQL 的坑更软,基本都有人踩过,哪怕是一篇写得很烂的博客,也能给你指个方向。对中小团队来说,这个差别比性能差得远。

我甚至想说一句更得罪人的:PG 不是给「技术更强」的团队准备的,是给「运维习惯更正规」的团队准备的。你要是从来没配过监控告警、没写过 runbook、没有 on-call 轮值,迁过去大概率是一场灾难。反过来,如果你的业务是在一个库上跑五种完全不同的查询模式,那 PG 的优化器和索引能力(partial index、expression index、GIN、BRIN)是实打实的降维打击,我上面那个 1.8 秒到 340 毫秒就是例子,靠的不是加机器,是一个 11GB 的部分索引。

我们最后是迁了。8 个人,5 个月,写坏过两套双写链路,回滚过一次,我在第 3 个月有两天认真怀疑过自己是不是在给自己找项目。事后看是值的,但我不想说「强烈推荐」这种话。

一张可以抄走的选型打分表

打分是 1 到 10,权重那列你按自己团队情况改。

维度 权重 MySQL 8.0 PostgreSQL 16
单机 OLTP 性能 15 8 8
复杂查询与索引表达力 20 6 9
中文运维资料可得性 15 9 5
在线 DDL 10 7 7
生态工具链成熟度 10 9 7
扩展性与插件 10 6 9
团队现有熟悉度 20 9 4

图片

加权下来 MySQL 大约 7.65 分,PG 大约 7.05 分。看到没?在我们这个团队的真实情况下,继续用 MySQL 其实分数更高。我们最后还是迁了 PG,是因为我给自己加了一条表外的判断:未来两年我们打算把报表和风控都塞进同一个库,这个需求让「复杂查询与索引表达力」的权重从 20 涨到 35——那 PG 就反超了。

在线 DDL 那栏我给两家都打 7 分,是因为它们各擅胜场:MySQL 8.0.12 之后支持 INSTANT 加列,PG 11 之后带默认值加列也不重写;但 MySQL 改 varchar 长度前缀会 COPY,PG 改 varchar 上限只是元数据。你那句最常见的 DDL 是哪种,就往哪边加分。

如果你也要迁,这是我踩完坑写的 checklist

  1. 迁移脚本最后一步必须 setval 所有 sequence,让 DBA 之外的一个人复核。
  2. 标识符统一小写,别指望 ORM 帮你兜。
  3. 上生产前开 track_io_timingpg_stat_statements,不然你连慢在哪都不知道。
  4. autovacuum 别用全局默认,按表调,写多读少的表 scale_factor 给到 0.02 以下。
  5. 连接池换 PgBouncer,提前想清楚 prepared statement 怎么办。
  6. 双写期间用逻辑复制(pgoutput),别用触发器,触发器在 4 亿行表上的延迟你受不了。
  7. 灰度按租户切,绝对不要按流量百分比切——按百分比切你会同时得罪一半客户。
  8. 上线前跑一遍 pgbench 的 select-only 和 tpcb-like,心里有个底数。
  9. 报表类 SQL 全部加 statement_timeout,默认给 30 秒,跑不完就让它死。
  10. 提前写好回滚方案,并且真的演练一次。我们的回滚预案演练花了 6 个小时,值。

最后说句实在的:数据库选型没有正确答案,只有「你这个团队在这个时间点能不能扛住它」。把这句话想明白,比看一百篇 benchmark 有用。

🏷️ 标签: