
1. 先说结论我为什么把新项目的数据库换成了 PG在过去五六年里我手头几乎所有业务系统的默认数据库选型都是 MySQL用得也算顺手。真正让我开始认真审视 PostgreSQL是两件事的叠加一是在一个内部数据分析平台的设计评审上面对一批需要实时聚合、按标签筛选、还要在结果里嵌套 JSON 的接口MySQL 8.0 虽然已经能应付但写法总是别扭性能也要靠各种小聪明去凑二是当时需要把一套老系统从云上搬到自建机房顺手看了下 PostgreSQL 17 的官方文档发现它在分区表、逻辑复制、并行查询这些方向的完成度已经和我印象里的学术型数据库完全不是一回事了。后来我干脆把那个数据分析平台的底层存储直接给了 PostgreSQL用了大半年又陆续把几个中小型业务模块迁了过去。整个过程遇到的坑不少但总体收益远超预期。这篇文章不打算替任何人做MySQL 已死这类情绪化判断而是实打实把两边的差异、迁移路径、以及迁过去之后怎么调优说清楚。如果你也在纠结要不要从 MySQL 换到 PostgreSQL或者只是单纯想弄明白大厂为什么频繁在技术分享里提到 PG这篇文章应该能给你一个比较完整的参考。先说一个我觉得最核心的判断MySQL 和 PostgreSQL 都是优秀的关系型数据库但二者的设计哲学不同。MySQL 更偏向够用、简单、快对开发者很友好PostgreSQL 则把标准符合度、可扩展性、数据完整性放在更高的位置。方向没有优劣之分但落到具体业务上差异会被放大。下面我从数据结构、SQL 能力、运维方式三个角度边对比边讲我怎么做的选择。2. 两个数据库的真正差距在哪里数据结构、SQL 能力与事务模型2.1 从 JSON 和全文检索看刚需功能的完成度业务系统里最常见的需求之一是用 JSON 存一些非结构化数据比如用户的扩展属性、订单的动态字段。MySQL 8.0 的 JSON 类型已经可以做到校验和索引但有个很现实的问题MySQL 对 JSON 的索引实现方式是生成列 二级索引也就是说你想按 JSON 里的某个字段做条件过滤必须显式建一个生成列再在这个列上建索引。整条链路能跑通但 DDL 和维护成本都偏高而且文档里对 JSON 函数的覆盖也不算全。PostgreSQL 这边JSONB 是一个原生二进制存储类型直接支持 GIN 索引可以对 JSON 内部的 key 和 value 做高效检索。更关键的是PostgreSQL 的表达式索引可以和任意函数组合也就是说我不需要为了查询一个 JSON 字段专门维护一个生成列。举个例子我要查 orders 表里 payment_info 这个 JSONB 字段中 amount 大于 100 的订单-- MySQL 8.0 的写法需要先创建生成列 ALTER TABLE orders ADD COLUMN payment_amount DECIMAL(10,2) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(payment_info, $.amount))) STORED; CREATE INDEX idx_payment_amount ON orders(payment_amount); -- PostgreSQL 的写法直接建表达式索引 CREATE INDEX idx_orders_payment_amount ON orders ((payment_info - amount)::decimal);两者都能解决问题但明显 PG 的路径更短。类似的还有全文检索。MySQL 的全文索引只能用在 MyISAM 和 InnoDB 的 CHAR、VARCHAR、TEXT 列上对中文分词支持始终停留在能用但别较真的水平。PostgreSQL 原生支持 tsvector 和 tsquery配合中文分词插件比如 zhparser 或 pg_jieba可以构建出相当灵活的中文检索引擎。我在做那个数据分析平台的时候甚至没有额外引入 Elasticsearch因为单表几百万行的全文检索 PG 已经扛得住省掉了一套中间件的运维。2.2 窗口函数、递归查询和 GROUP BY 的灵活性对比这个差异可能是开发体验上最直观的一处。MySQL 8.0 虽然开始支持窗口函数但语法覆盖和 PG 相比还是偏保守。PG 里我能用FILTER (WHERE ...)从句在聚合函数里直接做条件过滤比如统计每类商品的订单总数和已支付订单数SELECT category_id, COUNT(*) AS total_orders, COUNT(*) FILTER (WHERE status paid) AS paid_orders FROM orders GROUP BY category_id;MySQL 里要实现同样的逻辑要么用SUM(CASE WHEN status paid THEN 1 ELSE 0 END)要么写多个子查询。两种写法最终结果一样但可读性差距巨大。窗口函数方面PG 在ROWS BETWEEN和RANGE BETWEEN的边界处理上更严谨还支持GROUPS模式这在处理时间序列、移动平均这类场景时非常实用。另一个经常被忽略的点是递归查询。PG 的WITH RECURSIVE自带了深度的隐式处理而 MySQL 8.0 虽然是支持了但某些边缘情况比如递归过程中同时做聚合容易触发文档里才找得到的限制。我做组织架构树、BOM 展开这类需求时PG 写起来基本就是教科书级别的顺畅。2.3 事务隔离、锁和 VACUUM设计哲学的分水岭事务隔离级别这一块MySQL 最常用的 REPEATABLE READ 在 InnoDB 里是依靠 next-key lock 实现的锁范围大高并发下间隙锁gap lock导致的死锁和阻塞时有发生。PostgreSQL 的 MVCC 实现完全不依赖锁来保证一致性READ COMMITTED 是最常见的默认隔离级别DML 之间冲突的概率比 MySQL 低很多。这里有一个对运维和业务同时都有影响的点MySQL 在高并发写场景下死锁报错是家常便饭应用层如果没做重试机制用户体验会很糟糕。我在把订单状态更新接口从 MySQL 迁到 PG 之后应用层基本不需要再写复杂的死锁重试逻辑了。但 PG 也有自己的债就是 VACUUM。多版本并发控制带来的旧版本数据需要垃圾回收PG 的autovacuum如果配置不当表膨胀会非常吓人。MySQL 虽然有 purge 线程但整体上对用户更透明。我见过不少从 MySQL 迁到 PG 的新手第一步就把autovacuum关掉理由是想提升性能结果跑了一个月表占用空间翻了好几倍。VACUUM 不是可以绕开的东西它和 PG 的 MVCC 深度绑定你应该调优它而不是禁用它。关于这一点我在后面调优章节会详细说。两个数据库还一个很重要的差异数据完整性约束。MySQL 对 CHECK 约束的支持一直到 8.0.16 才真正生效而且默认还是不强制的。PG 从第一天起就老老实实执行 CHECK、外键、唯一约束、排他约束Exclusion Constraint。排他约束是个很强大的东西MySQL 压根没有比如你可以在 PG 里约束同一时间同一会议室不能被两个会议占用直接用USING gist (room WITH , time_span WITH )一行搞定这在 MySQL 里只能靠应用层判断或者写复杂的锁逻辑。3. 从 MySQL 迁到 PG 的实战路径评估、结构迁移、数据搬移3.1 迁移前的评估清单先想清楚哪些该迁、哪些不该迁不是所有系统都适合迁到 PG。我在动手之前列过一个清单你可以直接参考应用以复杂查询、报表分析、地理空间数据、JSON 处理为核心诉求的适合迁。应用以极简单的点查和写入为主且对延迟极其敏感QPS 上万级别建议继续用 MySQL 或者考虑缓存层。应用深度依赖 MySQL 特有语法和内部机制例如GROUP_CONCAT、ON DUPLICATE KEY UPDATE、ENUM类型、AUTO_INCREMENT的特定行为迁移代价会比较高需要逐条改写。应用用了大量存储过程且没有测试用例兜底老实说 MySQL 的存储过程迁移到 PG 的 PL/pgSQL语法差异比想象中大得多成本要高不少。我们当时的做法是选了一个中等复杂度的模块先试点跑通后再决定要不要全面铺开。试点的模块涵盖了 JSON 存储、多表 JOIN、窗口函数、定时任务PG 里的 pg_cron 替代了 MySQL 的 EVENT等典型场景差不多两周时间完成改造第四周就切了生产流量。整个过程其实没有想象中痛苦但确实有几个环节要注意下面详细说。3.2 表结构迁移类型映射和自增列改造是重头戏类型映射是第一个绕不开的坎。MySQL 的INT、BIGINT、VARCHAR、DATETIME这些基础类型在 PG 里都有直接对应分别是INTEGER、BIGINT、VARCHAR、TIMESTAMP但有几个容易踩坑的地方MySQL 的TINYINT(1)通常被习惯性地当成布尔值用PG 里对应的是BOOLEAN迁移后建议直接用BOOLEAN否则后续写代码时容易混淆。MySQL 的DATETIME和TIMESTAMP差异巨大因为 MySQL 的 TIMESTAMP 有 2038 年问题并且受时区影响PG 的TIMESTAMP是自解释的TIMESTAMPTZ才是存带时区语义的时间。我建议统一改成TIMESTAMPTZ这样应用层逻辑会清爽很多。MySQL 的ENUM类型在 PG 里同样有原生ENUM但 PG 的 ENUM 一旦创建就想改值需要ALTER TYPE操作稍繁琐。如果你在 MySQL 里用 ENUM 只是为了限制取值迁移后我更推荐加一个CHECK约束灵活度更高。AUTO_INCREMENT对应 PG 的SERIAL或GENERATED BY DEFAULT AS IDENTITY。这里强烈建议直接用后者IDENTITY更符合 SQL 标准运维也更方便控制。写一个实际迁移时常用的 DDL 转换对照-- MySQL 写法 CREATE TABLE user_profile ( id INT AUTO_INCREMENT PRIMARY KEY, status ENUM(active, inactive) NOT NULL DEFAULT active, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); -- PostgreSQL 写法 CREATE TABLE user_profile ( id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, status TEXT NOT NULL DEFAULT active CHECK (status IN (active, inactive)), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );3.3 数据搬移工具pgloader 够用但校验不能省迁移工具的选择上开源方案里有几条路pgloader目前最成熟的 MySQL 到 PG 迁移工具安装简单缺点是如果要搬的数据量极大且表数量多中途某个表出错会导致整批失败需要分段重试。用 Maxwell / Debezium 做在线同步如果业务不能停写需要做增量同步。Debezium 可以监听 MySQL binlog把增量变更转发到 PG适合灰度切换场景。直接导出 CSV / SQL 再导入适用于小数据量。我当时用的是 pgloader因为我们的模块允许十分钟左右的写停机窗口。pgloader 的配置文件写法很直接LOAD DATABASE FROM mysql://user:passmysql_host:3306/my_db INTO postgresql://user:passpg_host:5432/my_db WITH include drop, create tables, create indexes, reset sequences, disable triggers, data only SET maintenance_work_mem 1GB, work_mem 256MB CAST type datetime to timestamptz drop default drop not null, type tinyint(1) to boolean using tinyint_to_boolean, type enum to text;但有一个隐患pgloader 在迁移数据时为了性能会禁用触发器和一些约束迁移完成后如果没重建好后续写入的数据可能违反约束而 PG 不会报错。所以迁移之后必须跑一遍完整性校验脚本比对行数、校验和、抽样比对字段值。我当时写了一个简单的 Python 脚本两边各跑一次SELECT COUNT(*)和CHECKSUM类似的操作再随机抽几百条记录做字段级对比确认无误才切流量。3.4 代码兼容层SQL 语法改写和 ORM 的适配Java 项目一般用 MyBatis 或 JPA 的比较多如果 SQL 写得比较规范迁移后大部分代码可以直接跑。但如果里面有 MySQL 特有的写法就要逐条改。我见过最多的问题有这几类LIMIT a, b在 PG 里不支持要改成LIMIT b OFFSET a。GROUP_CONCAT要改成STRING_AGG两者功能类似但参数顺序不同PG 里还能用DISTINCT和ORDER BY组合比 MySQL 灵活得多。INSERT ... ON DUPLICATE KEY UPDATE在 PG 里对应INSERT ... ON CONFLICT DO UPDATE SET ...。语法接近但细节需要注意比如 PG 的 ON CONFLICT 需要指定冲突的约束名。日期格式化函数不同MySQL 用DATE_FORMATPG 用TO_CHAR。MySQL 默认字符串比较不区分大小写根据 collation 而定PG 默认区分大小写可能导致原来能查出来的数据现在查不出来。解决办法通常是在查询时统一用LOWER()或者在创建索引时用LOWER(column_name)建表达式索引。ORM 方面我们项目主要用 MyBatis所以 SQL 都在 mapper XML 里改写难度相对可控。如果是 JPA/Hibernate则要留意 Hibernate 方言的切换大多数情况下切到PostgreSQLDialect后生成的 SQL 就能直接用。Rails 的 ActiveRecord 和 Django ORM 对 PG 的支持天然更好基本不用做额外适配。4. 迁过去之后的核心调优配置、索引与 VACUUM 策略4.1 内存参数和 work_mem别让排序和哈希成了性能杀手从 MySQL 迁移过来的人最容易低估 PG 的内存参数配置。MySQL 的 InnoDB 靠一个 buffer pool 撑住大部分读缓存而 PG 则把内存分成多个池子shared_buffers负责共享缓存块work_mem负责每个会话的排序和哈希操作maintenance_work_mem负责 VACUUM 和建立索引这类维护操作。work_mem配小了查询排序就会落盘性能跳水配太大了几十个并发会话直接占满内存。经验值是一个会话按 4MB 到 16MB 配再根据并发峰值压测调整。我在迁移后的第一次压测中发现原本在 MySQL 上跑 200ms 的一个分组统计查询在 PG 上跑出了 2 秒。用EXPLAIN ANALYZE一查问题出在 sort 阶段用了 disk我把work_mem从默认的 4MB 调到了 16MB查询立刻降到 300ms。这个排查思路对新手很实用遇到查询慢先看执行计划有没有Sort Method: external merge Disk或者Hash Join溢出到磁盘如果有优先调work_mem。shared_buffers一般设为物理内存的 25% 左右但同时要注意 PG 依赖操作系统缓存所以不要贪心设太高。同时还需要考虑effective_cache_size这个参数它告诉查询优化器系统有多少可用缓存直接影响优化器是否选择索引扫描。通常设为物理内存的 50%~75%。很多 DBA 在 MySQL 里习惯了内存越大越好的思路到 PG 这边要稍微克制一下。还有一个经常被忽略的参数是max_connections。PG 每个连接都是独立进程MySQL 是线程模型默认max_connections是 100如果并发连接上来几百个内存开销会很大。更好的方案是引入连接池比如 PgBouncer 或应用层的 HikariCP把后端连接数控制在 50 以内。这里有一个参数清单# postgresql.conf 示例根据服务器内存调整 shared_buffers 4GB # 总内存 16GB 时 effective_cache_size 12GB work_mem 16MB maintenance_work_mem 1GB max_connections 100 autovacuum_max_workers 4 synchronous_commit off # 允许丢失少量事务日志换取性能业务可接受时4.2 索引策略从单列索引思维升级到多列索引和表达式索引MySQL 的索引优化经验在 PG 里部分适用但不完全。最大的区别在于 PG 提供了更多索引类型和更精细的控制。我迁移后做得最值的一件事是审视了一遍原来在 MySQL 上建的索引用 PG 的多列索引和INCLUDE子句大大简化了索引数量。以订单查询为例业务里最常用的查询是按user_id和status过滤再按created_at倒序排序。MySQL 时我会建一个多列索引(user_id, status)再依赖 filesort或者干脆加一个(user_id, status, created_at)的宽索引。PG 里同样支持(user_id, status, created_at DESC)但更优雅的做法是CREATE INDEX idx_orders_user_status ON orders (user_id, status) INCLUDE (created_at);这里INCLUDE里的列不参与索引匹配只作为覆盖索引的一部分避免了排序时回表。索引更大但收益明显。另外PG 的Partial Index部分索引也很实用。如果 90% 的订单状态都是completed而业务只关心未完成的订单那就只给未完成的部分建索引CREATE INDEX idx_orders_pending ON orders (created_at) WHERE status IN (pending, processing);这个索引只有全表数据的 10% 大小查询效率自然高很多。MySQL 8.0 虽然也支持表达式索引和部分索引8.0.13 开始支持但生态成熟度和文档详细度仍不如 PG。4.3 autovacuum:不要关它学会和它相处前面提到PG 的 MVCC 机制会导致旧版本数据累积autovacuum 的作用就是清理这些死行。MySQL 用户常常无法理解为什么一个数据库需要专门的进程打扫卫生但 PG 的架构里这是绕不开的。正确的做法是调整 autovacuum 的触发阈值而不是关闭它。默认配置里autovacuum_vacuum_threshold是 50 行autovacuum_vacuum_scale_factor是 0.2也就是说单表超过 50 行时当死行比例超过 20% 就会触发 VACUUM。问题在于大表场景一张 1 亿行的表要等死行到 2000 万行才触发表膨胀已经非常明显了。建议对大表单独设置ALTER TABLE orders SET (autovacuum_vacuum_scale_factor 0.05, autovacuum_vacuum_threshold 10000);在和 autovacuum 相处的过程中我踩过最大的坑是长时间运行的事务。PG 的 VACUUM 不能清理被旧事务仍锁定的行版本如果有连接长时间开着事务没有提交比如一个应用连接池里的连接执行了BEGIN但忘了COMMITpg_stat_activity里可以看到state idle in transaction这些事务会阻止 VACUUM 清理相关行最终导致表和索引急剧膨胀。我在生产环境遇到过把一张 2GB 的表撑到 20GB 的情况排查半天发现是一个后台任务的连接一直没有释放。解决方案就是配置idle_in_transaction_session_timeout单位是毫秒我们设置成 60000也就是一分钟。idle_in_transaction_session_timeout 60000另外pg_stat_user_tables这个视图是排查膨胀问题的第一入口可以看n_dead_tup、last_autovacuum等字段。如果n_dead_tup持续很高说明 autovacuum 没跟上或者有上面说的事务阻塞问题。5. 到底哪些场景不适合换 PG别让技术热潮绑架了架构决策我在最开始说过不是所有系统都适合迁移。这里展开说说免得你看完前面章节心血来潮就把全公司系统都换了。根据我个人的观察以下这几类场景留在 MySQL 反而更合理第一超高并发、极简单点查场景。典型的如用户会话表、短信验证码记录、短链接映射。这一类 QPS 经常上万查询就是SELECT ... WHERE id ?这种单行查询写入就是INSERT单行。MySQL 在这个场景下表现非常成熟稳定InnoDB 的 B 树点查效率极高加上成熟的中间件生态MyCat、ShardingSphere扩展方案也很明确。PG 不是跑不过而是没必要折腾。第二复杂的分库分表体系已经稳定运行多年。如果业务已经基于 MySQL 做了大规模分库分表比如按用户 ID 分了几十张表替换成 PG 的成本会很高因为 PG 的分布式方案比如 Citus虽然成熟但迁移路径和现有体系并不完全兼容。稳定在这个领域是非常高的价值不要为了技术先进去动运行良好的基础设施。第三读写比例极端、延迟极其敏感的业务。典型的如广告竞价、游戏对战匹配、实时风控。这类场景通常内存数据库Redis才是主力MySQL 只是持久化兜底。这种情况下升级数据库带来的延迟提升非常有限真正要解决的是架构问题。我还会建议留意 PostgreSQL 的运维门槛。PG 的配置参数数量比 MySQL 多一个量级postgresql.conf里几百个参数新手很容易迷路。而 MySQL 开箱即用的默认配置在大多数场景下都够用。如果你的团队没有专门的 DBA或者 DBA 只熟 MySQL迁移前一定要评估好学习成本和运维事故风险。PG 不是不会出问题它只是出问题的维度和 MySQL 不同。6. 进阶功能体验分区表、逻辑复制与扩展生态迁移完基本业务之后可以逐步把一些 PG 的独特能力用起来这才是进阶之路的重点。6.1 分区表的易用性差异MySQL 8.0 的分区表功能仍然限制很多比如分区键必须是主键或唯一键的一部分哪些函数可以用在分区表达式上也有限制。PG 从 10 开始支持声明式分区到 12 之后已经比较成熟走的是和标准 SQL 一致的路子。我在订单表上按created_at做了月分区最老的自动挂起、新建分区自动创建CREATE TABLE orders ( id BIGINT, created_at TIMESTAMPTZ, amount NUMERIC(10,2) ) PARTITION BY RANGE (created_at); -- 挂在父表上的索引需要每个分区都建一次可以用 pg_partman 自动管理 SELECT partman.create_parent( p_parent_table : public.orders, p_control : created_at, p_type : native, p_interval : 1 month, p_premake : 3 );PG 的查询规划器可以做到分区裁剪只扫需要的分区这个能力在归档场景里特别省事。MySQL 的ALTER TABLE ... REORGANIZE PARTITION类操作在数据量大的时候会锁住整张表PG 这边加分区基本是即时的不需要重写表结构。6.2 逻辑复制的灵活度MySQL 的逻辑复制以 binlog 为主虽然生态成熟但配置环节多主从切换需要额外工具。PG 的逻辑复制自 10 版本起在架构上更干净发布端定义PUBLICATION订阅端定义SUBSCRIPTION数据以行级变更形式复制天然支持不同表结构之间的部署。我在做数据仓库实时同步的时候用 PG 的逻辑复制把一个业务库的几张维表同步到另一个分析实例配置比 MySQL 的 Canal 方案简单太多-- 发布端 CREATE PUBLICATION pub_orders FOR TABLE orders, order_items; -- 订阅端 CREATE SUBSCRIPTION sub_orders CONNECTION hostpg_source dbnameapp_db userrepl passwordxxx PUBLICATION pub_orders;而且 PG 的逻辑复制不依赖于 binlog_formatrow 这类和性能强相关的设置MySQL 切到 row 格式后 binlog 会变得很大复制冲突的处理也更直观。6.3 扩展生态从 PostGIS 到向量检索这是 MySQL 完全无法比拟的第二增长曲线。MySQL 的空间数据类型虽然能做基础的点、线、面存储但和 PostGIS 的差距是代际的坐标参考系、空间索引、空间分析函数、拓扑处理、栅格数据PostGIS 已经是一种完整的 GIS 基础设施。我的地理围栏业务直接用的 PostGIS包括判断用户位置是否落在某个多边形内一条 SQL 就完成延迟在毫秒级。更值得关注的是 PG 在向量检索场景的生态。pgvector扩展允许在 PG 里直接存向量数据并做余弦相似度搜索虽然单表千万级以上的向量检索性能不如专业的 Milvus、Qdrant但中小规模应用完全可以用 PG 一把梭省掉引入第二个存储系统的成本。我现在做的推荐系统的候选集召回就用了一个 PG 表存用户向量和物品向量配合 HNSW 索引实现了毫秒级召回。这些能力加在一起让 PG 在很多团队里成了一个库顶多个中间件的存在。对于预算和人力有限的中小型团队来说减少中间件数量就是减少运维负担这个优势不能小看。7. 一些让我印象深刻的坑和对应的处理思路最后集中写几个真实踩过的坑希望能省掉你排查的时间。第一个是大小写问题造成的字段不匹配。MySQL 里SELECT * FROM orders WHERE user_id 1返回的列名是user_id小写而 PG 里默认把未加引号的标识符折叠成小写所以如果你在迁移时用了大写表名或列名所有 SQL 都要用双引号包起来非常别扭。迁移前最好统一规范让所有标识符走小写。第二个是text类型的隐式转换问题。MySQL 的TEXT类型不能有默认值而 PG 的TEXT和VARCHAR在功能上等价且可以直接设默认值。但要注意PG 对TEXT和VARCHAR(n)之间的一些操作比如拼接可能会触发unknown类型的隐式转换偶尔会出现 SQL 直接执行没问题、走 JDBC 预处理参数时报类型错误的怪现象。解决办法是写 SQL 时显式带演员运算符或者统一应用层用setString传参。第三个是timestamp without time zone和timestamp with time zone的混淆。在 MySQL 里CURRENT_TIMESTAMP总是基于数据库服务器的时区应用层写代码时通常无感。PG 里如果字段是TIMESTAMP WITHOUT TIME ZONE而你的应用部署在不同时区写入和读取之间就会出现几个小时的偏移。我的建议是你想要什么语义就明确写出来需要记录时刻就用TIMESTAMPTZ它会自动转成 UTC 存储查询时转回当前会话时区需要记录墙钟时间才用TIMESTAMP。这个认知理顺了时间类 bug 基本就消失了。第四个是EXPLAIN的执行计划解读和索引命中差异。MySQL 的EXPLAIN输出简单直观typeref、rowsxxxPG 的EXPLAIN ANALYZE输出信息量大得多但也更复杂。刚迁移过来时我看到Seq Scan和Bitmap Heap Scan就觉得完了还是全表扫描后来才意识到优化器是根据成本模型选的有时小表全表扫描比走索引更快。不要下意识地排斥非索引扫描要看实际的执行时间和行估计误差。8. 写在最后如果让我重新做一次数据库选型我个人的感受是MySQL 仍然是极其优秀的默认选择傻瓜式运维、生态庞大、学习曲线平滑这些优势在很长时间内都不会消失。但一旦业务开始涉及复杂查询、区域数据分析、JSON 半结构化处理、GIS 或向量检索的需求PostgreSQL 的优势会随着数据量增长越来越明显。如果现在有人问我新项目库选什么我的建议是没有特别的 MySQL 依赖、团队愿意花两周补一下 PG 的知识那么直接选 PostgreSQL 16/17 不会让你后悔反过来如果你只是在已有 MySQL 体系上做增量优化那就把这个迁移计划往后放一放先把 MySQL 自身的慢查询、索引、SQL 质量处理好收益更大。至于从 MySQL 到 PG 的进阶之路我的核心体验是它不是一场推倒重来的革命而是一次用更标准的 SQL 和更强大的扩展重新审视业务模型的机会。迁移过程逼着你把以前依赖 MySQL 方言偷懒的 SQL 全部改写把数据类型的语义重新理清楚把索引设计从够用升级到精确。这些动作本身就很有价值即便最后你没有全面切到 PG过程中的梳理也会让系统变得更好。最后分享一个我在生产环境一直在用的检查习惯每隔一段时间翻一次pg_stat_activity看看有没有长时间 idle in transaction查一次pg_stat_user_tables的n_dead_tup评估 VACUUM 健康度再用EXPLAIN (ANALYZE, BUFFERS)跑一遍核心慢查询确认执行计划有没有因为数据分布变化而劣化。数据库迁移不是项目终点的庆祝时刻而是日常运维新常态的开始。带着这个心态去做选型和迁移比任何工具和命令都重要。