数据库选型讨论里有个流传很广的说法MySQL 单表过了两千万行就该分库分表否则查询会突然变慢像走到悬崖边一样掉下去。这个说法被反复引用慢慢变成了某种常识。但如果你真的在 PostgreSQL 上跑过几千万甚至上亿行的表会发现一个很别扭的事实——它好像没有那个明显的断崖点数据量翻几倍查询耗时是缓慢爬升的而不是某一天突然崩掉。这个差异不是玄学也不是谁比谁更先进这种粗糙结论能解释的。它背后是两种数据库在索引组织方式、数据页管理、查询规划器决策逻辑上的根本分歧。我这些年两边都做过不少迁移和调优的活踩过的坑足够写一本小册子。这篇就把这个断崖问题拆开讲清楚断崖到底是怎么形成的、PostgreSQL 为什么在结构上不容易出现它、以及更重要的——没有断崖不等于没有性能问题PG 在数据量大了之后有它自己的一套麻烦只是表现形式不一样。适合谁看正在做数据库选型的技术负责人、从 MySQL 迁到 PG 或者反向迁移的开发者、以及被两千万行这个数字困扰过、想搞清楚背后原理的同行。文中会涉及不少页结构、索引原理和实测思路我会尽量用生活化的类比把复杂的地方讲透基础一般的读者也能跟上。1. 先把两千万行断崖这个说法拆开看1.1 断崖的本质是索引层级跳变不是行数本身很多人把两千万行当成一个魔法数字好像数据库内部有个计数器到点就降速。实际完全不是这样。真正发生的事是B 树的层数从 3 层变成了 4 层每一次查询需要多读一个数据页磁盘 I/O 或者内存访问次数增加一次延迟就抬升一截。B 树是个多叉平衡树每个节点数据页能放多少个键取决于页大小和键的大小。以常见的 16KB 页、主键为 8 字节 bigint 为例一个非叶子节点大约能塞下上千个键值对还要算上页头、指针等开销。三层树能覆盖的行数量级大概在千万级四层就跳到百亿级。所谓两千万行断崖本质是你的数据刚好跨过了三层树容量上限树长高了一层。这里有个关键点容易被忽略树高一层带来的开销不是线性的而是每次查询都多一次页访问。如果这个页在 buffer pool 里代价是内存访问还好如果不在就是一次真实的磁盘随机读在机械盘上是毫秒级在 SSD 上也是几十微秒。高并发下这个差异会被放大得非常明显。1.2 为什么这个数字在不同环境里飘忽不定如果你去查资料会发现有人说一千万行就崩有人说五千万行还好好的。这不是他们在瞎说而是这个阈值本身就是变量。影响它的因素至少有这些影响因素对断崖位置的影响主键类型与长度主键越短单页容纳键越多断崖越晚出现页填充率页越满单页键越多但插入时分裂越频繁行宽行越宽叶子页容纳行越少但主要影响叶子层而非树高是否二级索引回表回表查询对树高更敏感覆盖索引则影响小硬件 I/O 特性SSD 上多一层页访问的绝对代价远小于机械盘所以两千万行只是某个特定配置下的经验值被口口相传成了普适规律。真正该记住的是树高跳变的临界点这个概念而不是那个具体数字。1.3 断崖感被放大的真正推手回表与随机 I/O光多读一层页其实还不至于让人喊断崖。真正让体感变差的是回表。在索引组织表聚簇索引结构里二级索引的叶子节点存的是主键值通过二级索引查非索引列时要先在二级索引树里找到主键再拿主键去主键树里查完整行。这一来一回树高每增加一层代价是翻倍的。更麻烦的是随机性。当数据量小、能全部装进内存时回表就是内存里跳几下几乎无感。数据量一大buffer pool 装不下回表就变成大量随机磁盘读。随机读的代价和顺序读差着数量级这时候性能曲线就不是缓慢上升而是陡然下坠——这才是断崖体感的真正来源。2. PostgreSQL 的堆表结构为什么天然避开这个陷阱2.1 堆表与索引组织表的根本分歧要理解 PG 为什么不容易出现断崖得先搞清楚它和 MySQL InnoDB 在存储结构上的根本区别。InnoDB 是索引组织表表数据本身就存在主键的 B 树叶子节点里主键索引即表。二级索引指向主键查询非索引列必须回表。PostgreSQL 是堆表表数据无序地堆在数据文件里所有索引包括主键索引都是独立的 B 树叶子节点存的是指向堆中行位置的指针ctid本质是页号和页内偏移。通过索引查到 ctid再去堆里取行。这个差异看起来只是实现细节但它对断崖的影响是决定性的。在 PG 里主键索引树的高度和表的总行数有关但索引本身不承载行数据所以索引页能容纳的键更多、树更矮。而且堆表的行是按插入顺序物理存放的索引扫描拿到一批 ctid 后如果这些 ctid 在物理上相邻堆访问可以变成顺序 I/O。2.2 索引更瘦树高增长更慢因为 PG 的索引叶子只存键值加 ctid6 字节不存整行所以同样的页大小下索引能容纳的条目比 InnoDB 的聚簇索引多得多。这意味着达到同样的树高PG 能覆盖更多的行。举个粗略的估算16KB 页主键 8 字节ctid 6 字节加上页头开销一个叶子页大概能放六七百个条目实际还受 fillfactor 影响。三层树覆盖的行数量级能到几亿。也就是说在 MySQL 已经开始喊两千万行要分表的区间PG 的主键索引可能还稳稳停在三层。注意这里说的是主键索引树高。二级索引在 PG 里同样是独立 B 树叶子存键值加 ctid结构一样瘦树高增长同样缓慢。2.3 顺序扫描与位图扫描削弱了树高的影响PG 的查询规划器在面对大表时并不总是走索引。当优化器估算出符合条件的行占比很高比如超过百分之几它会选择顺序扫描——直接从头到尾读堆文件。顺序扫描的代价和表大小近似线性和索引树高完全无关。还有一种是位图索引扫描先通过索引收集一批 ctid 到 bitmap 里按页号排序后再去堆里读。这样把随机 I/O 转化成了近似顺序 I/O大幅削弱了多一层树高带来的随机访问代价。这两条路径的存在让 PG 在数据量增长时有了更多退路。当索引路径变贵规划器会自动切换到顺序或位图路径性能曲线因此更平滑而不是死磕索引然后突然崩掉。2.4 一个必须说清的边界PG 不是没有性能拐点讲到这里必须泼一盆冷水免得读者产生PG 数据量再大也不会有问题的误解。PG 没有 MySQL 那种由树高跳变引发的陡峭断崖但它有自己的拐点只是形态不同buffer cache 命中率下降当热数据超出 shared_buffers 加操作系统页缓存物理 I/O 上升性能开始下滑。这是渐进的不是断崖。autovacuum 压力PG 的 MVCC 实现依赖版本链和清理表越大、更新越频繁vacuum 越吃力膨胀bloat会让扫描变慢。规划器统计信息失准大表上统计信息采样不足时可能选错执行计划表现为某些查询突然变慢但这和行数没有固定的数量关系。索引膨胀频繁更新导致索引页碎片化索引扫描效率下降。所以准确的说法是PG 把断崖换成了缓坡加若干局部坑。缓坡好应付局部坑需要针对性处理。这个区别对运维策略的影响很大后面会展开。3. 规划器决策平滑曲线的幕后推手3.1 基于代价的优化器如何预判断崖PG 的规划器是纯代价驱动的。它对每个可能的执行路径估算一个代价数字选最小的那个。这个代价模型里包含顺序页读取成本、随机页读取成本、CPU 元组处理成本等参数可通过seq_page_cost、random_page_cost等调整。关键在于规划器在生成计划时就已经把索引树高这个因素折算进了索引扫描的代价估算里。当表变大、索引变深索引路径的估算代价上升规划器会更早地转向顺序扫描或位图扫描。这种提前换路的机制正是性能曲线平滑的核心原因——它不会等到索引路径已经慢得离谱才切换。3.2 统计信息质量决定这条曲线是否可信规划器的估算准不准全看统计信息。PG 通过ANALYZE收集列的分布统计直方图、最常见值、distinct 值数量等。大表上如果统计信息陈旧估算就会失真可能该走顺序扫描的走了索引或者反过来。这里有个实操中很常见的坑大表上 autovacuum 的 analyze 触发阈值是按比例算的表越大两次 analyze 之间积累的变更越多统计信息越容易过时。我的做法是对关键大表单独设置更激进的 analyze 参数比如调低autovacuum_analyze_scale_factor让它在变更比例较小时就触发。ALTER TABLE big_table SET ( autovacuum_analyze_scale_factor 0.02, autovacuum_analyze_threshold 1000 );这个调整的意图是让统计信息更新更及时规划器决策更可靠避免因为估算偏差导致的假断崖——明明数据量没到临界点却因为选错计划而突然变慢。3.3 参数调优对曲线形态的实际影响random_page_cost这个参数值得单独说。它的默认值是 4.0隐含假设随机读比顺序读贵四倍这是机械盘时代的设定。在 SSD 上随机读和顺序读的差距远没有这么大通常调到 1.1 到 1.5 更合理。调低这个值会让规划器更倾向于使用索引扫描因为随机读变便宜了。这在 SSD 环境下往往能显著改善大表的查询性能因为它让规划器敢于走索引路径而不是过早退化成全表扫描。但要注意调得太低会导致规划器过度偏爱索引在小结果集上反而可能选错。提示调整random_page_cost前先用EXPLAIN (ANALYZE, BUFFERS)看几个典型查询的实际 I/O 模式确认瓶颈到底在随机读还是顺序读再决定调不调、调多少。盲目照搬别人的参数是最容易踩的坑。4. 实测对比两种结构在数据量爬升时的表现差异4.1 设计一个能暴露断崖的测试场景要观察断崖测试设计很关键。我一般会构造这样一组对照两张结构相同的表一张在 InnoDB 上一张在 PG 上主键都是自增 bigint带两三个二级索引。数据量从一百万开始按倍数递增到五千万每一步都重新灌数据、执行 ANALYZE、清空缓存或重启实例。查询分三类主键点查、二级索引等值查非索引列触发回表、范围扫描。每类查询跑多轮取稳定值记录 P50 和 P99。这个设计里清空缓存这一步很多人会省掉但它恰恰是暴露断崖的关键。如果缓存一直是热的所有页都在内存里树高增加带来的额外页访问几乎无感断崖就被掩盖了。真实生产环境里缓存不可能永远命中冷缓存下的表现才更接近最坏情况。4.2 结果解读断崖与缓坡的形态差异按上面的设计跑下来典型现象是这样的数据量InnoDB 二级索引回表 P99PG 二级索引回表 P99100 万平稳平稳500 万平稳平稳2000 万明显抬升树高跳变缓慢上升5000 万继续抬升回表随机 I/O 放大缓慢上升部分查询转位图扫描InnoDB 在跨过临界点后P99 会出现一个台阶式的抬升因为回表路径上多了一层页访问且随机 I/O 占比上升。PG 的曲线则更接近对数增长因为规划器在数据量增大过程中逐步把部分查询切换到顺序或位图路径摊平了抬升。需要说明的是这个对比不是要证明谁绝对更好。InnoDB 在纯主键点查、小范围查询上往往更快因为聚簇索引一次就能拿到整行不需要回表。PG 的堆表结构在这些场景下多一次堆访问。结构差异带来的是性能曲线形状的不同而不是简单的快慢之分。4.3 覆盖索引如何让两边都绕过断崖一个能同时改善两边表现的技巧是覆盖索引。如果查询需要的列全部包含在索引里就不需要回表树高的影响被大幅削弱。在 PG 里可以用INCLUDE子句做覆盖索引CREATE INDEX idx_covering ON orders (user_id) INCLUDE (status, created_at);这样查user_id对应的status和created_at时索引扫描就能直接返回不用回堆。代价是索引变大、写入变慢需要权衡。我的经验是高频且对延迟敏感的点查、列表查询值得为它建覆盖索引低频的报表类查询不如让它走顺序扫描。5. 数据量大了之后 PG 真正的麻烦在哪5.1 表膨胀与 vacuum 的长期拉锯PG 的 MVCC 靠保留行的多个版本实现更新和删除不会立刻回收空间而是留下死元组由 vacuum 清理。表越大、更新越频繁死元组积累越快表和索引都会膨胀。膨胀的直接后果是同样的逻辑数据量物理页数变多顺序扫描要读更多页索引扫描命中率下降。这个问题在超大表上尤其棘手因为一次完整的 vacuum 可能跑很久期间又产生新的死元组。我的处理思路是分而治之对大表按时间或 ID 范围做分区让 vacuum 能针对单个分区快速完成同时监控pg_stat_user_tables里的n_dead_tup对膨胀严重的表安排定期VACUUM (VERBOSE, ANALYZE)。SELECT relname, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / nullif(n_live_tup, 0) * 100, 2) AS dead_pct FROM pg_stat_user_tables ORDER BY n_dead_tup DESC LIMIT 20;这个查询能快速定位死元组比例高的表。经验上死元组占比超过百分之十就该关注超过百分之二十基本可以确定有膨胀问题。5.2 分区表把大问题切成小问题PG 的原生声明式分区在应对大数据量时非常实用。按范围分区后查询带上分区键时规划器能做分区裁剪只扫描相关分区扫描量大幅下降。同时每个分区是独立的表vacuum、索引重建都能分开做运维粒度更细。CREATE TABLE events ( id bigint GENERATED ALWAYS AS IDENTITY, created_at timestamptz NOT NULL, payload jsonb ) PARTITION BY RANGE (created_at); CREATE TABLE events_2024_01 PARTITION OF events FOR VALUES FROM (2024-01-01) TO (2024-02-01);分区的坑在于查询不带分区键时规划器要扫描所有分区反而可能比不分区更慢因为要合并多个分区的结果。所以分区键的选择必须贴合主要查询模式不能为了分区而分区。5.3 索引膨胀与重建时机的判断索引膨胀比表膨胀更隐蔽因为索引扫描慢下来时你很难第一时间想到是索引本身的问题。判断方法之一是看索引的物理大小和逻辑条目数的比例或者用pgstattuple扩展直接查索引的填充情况。重建索引REINDEX能解决膨胀但会锁表除非用REINDEX CONCURRENTLY。我的习惯是对写入频繁的大表索引定期用REINDEX INDEX CONCURRENTLY在低峰期重建避免膨胀累积到影响查询才处理。这个操作不阻塞读写代价是重建期间额外的 I/O 和 CPU 开销安排在业务低峰执行即可。6. 迁移与选型时我踩过的几个真实坑6.1 把 MySQL 的分表思维直接搬到 PG早年做过一个从 MySQL 迁到 PG 的项目团队习惯了单表过两千万就分表的规矩迁移时机械地按这个标准切了一堆分表。结果发现 PG 在几千万行的单表上跑得好好的分表反而带来了跨表查询、分布式事务、运维复杂度的一堆麻烦。后来把大部分分表合回单表配合分区和索引优化整体反而更简单、更快。这个教训是分表应该是被性能数据逼出来的决策而不是被一个数字吓出来的条件反射。在 PG 上先看实际查询的 P99 和 I/O 模式再决定要不要分。6.2 忽略 fillfactor 导致索引页分裂频繁PG 的索引页默认填充率较高插入时如果页满了就要分裂分裂会产生碎片、降低页利用率。对随机插入比如 UUID 主键的索引适当调低 fillfactor 能预留空间减少分裂。CREATE INDEX idx_uuid ON t (uuid_col) WITH (fillfactor 90);这个调整的意图是给后续插入留出页内空间让新条目尽量落在已有页里减少页分裂次数。代价是索引初始占用空间略大。对自增主键这种顺序插入的场景fillfactor 保持默认即可因为插入总是追加到最后一页很少分裂。6.3 用错连接池参数把数据库压垮这个坑和断崖没有直接关系但在大数据量场景下特别容易触发。连接数开太多每个连接都占用内存、争抢 CPU数据库在高并发下反而变慢。PG 每个连接是一个独立进程连接数过高的开销比 MySQL 的线程模型更大。我的经验是连接池大小控制在 CPU 核数的两到四倍配合合理的超时和排队策略比无脑开大连接数稳得多。注意连接池不是越大越好。数据库的并行处理能力有上限超过上限的连接只会增加上下文切换和锁竞争。宁可让应用层排队也不要让数据库层过载。7. 一套可复用的判断流程你的表到底该不该担心7.1 先测再判别信数字信曲线面对我的表要不要分这个问题我的标准流程是用生产数据规模或等比缩放的测试数据灌表执行ANALYZE。抓取 Top N 慢查询和核心业务查询用EXPLAIN (ANALYZE, BUFFERS)看实际执行计划和 I/O。在冷缓存和热缓存两种条件下分别测 P50、P95、P99。观察数据量翻倍时这些指标的变化形态——是台阶式跳变还是平缓上升。只有当曲线出现明显拐点、且拐点由结构性问题树高、回表、膨胀引起时才考虑分区或分表。这套流程的核心是用曲线代替数字。两千万行在别人的环境里是断崖在你的环境里可能什么都不是反之亦然。7.2 关键监控指标清单日常运维中我会盯这几个指标来判断大表是否健康指标含义异常信号n_dead_tup占比死元组比例超过 10% 需关注索引大小 / 表大小索引膨胀程度比例异常升高buffer cache 命中率内存命中情况持续低于 99% 需扩容或优化顺序扫描次数是否频繁全表扫核心查询出现大量 seq scan规划时间占比规划器开销复杂查询规划耗时过高这些指标通过pg_stat_*系列视图都能拿到配合定期采集就能画出趋势。趋势比单点值重要得多——单次命中率低可能是偶发持续下降才是问题。7.3 什么时候该分区什么时候该收手最后给一个我自己的判断标准供参考该分区单表超过几亿行、有明显的时间或范围查询模式、需要按范围归档或删除历史数据、vacuum 已经难以在合理时间内完成。不该分区查询模式杂乱没有统一的分区键、数据量在千万级且曲线平稳、团队没有分区表的运维经验。该考虑分库分表单机资源CPU、内存、I/O已经成为硬瓶颈、写入吞吐超出单实例能力、有明确的水平扩展需求。分区和分表都是手段不是目的。PG 的堆表结构让它在中等数据量区间有很好的平滑性这个优势应该被利用而不是被两千万行的教条浪费掉。真正需要警惕的是膨胀、统计信息失准、规划器选错计划这些缓坡上的坑它们不会让你一夜之间崩掉但会慢慢侵蚀性能需要靠持续的监控和针对性的调优来对付。我在实际项目里的体会是数据库性能问题很少是某一个数字触发的更多是一堆小问题叠加到临界点。与其盯着两千万行这个传说不如把精力放在理解自己数据的访问模式、把监控做扎实、把统计信息和 vacuum 管好。这几件事做到位无论用哪种数据库性能曲线都会比想象中可控得多。