site logo

Marico's space

为什么在 PostgreSQL 中添加索引无法解决慢速 COUNT(*) 的问题

算法解析 2026-09-11 14:49:25 4

最近折腾 PostgreSQL 的 COUNT(*) 性能问题,踩了几个坑,这篇把问题说清楚。

COUNT(*) 看起来是个再简单不过的操作:

SELECT COUNT(*)
FROM orders;

查询只要返回一个数字,但 PostgreSQL 可没法从某个内部计数器里 O(1) 地读出来。

要得到精确数量,PostgreSQL 必须确定哪些行对该查询可见。在大表上,这部分工作量会成为总执行时间的一大部分。

而这个问题并不会因为加个索引就消失。

真正有用的问题不是"我有没有索引",而是:

PostgreSQL 实际上需要检查多少行才能算出这个 count——能不能减少这部分工作?

为什么 COUNT(*) 在 PostgreSQL 中可能很慢

PostgreSQL 使用 MVCC(多版本并发控制)来管理并发访问。

这让多个事务可以同时工作,每个事务都能看到一致的数据库视图。但这也意味着行的可见性取决于查询运行时的快照。

所以 PostgreSQL 无法直接回答:

SELECT COUNT(*)
FROM orders;

靠读一个存在表元数据里的精确计数器来完成。

要返回精确结果,它必须处理这些行——或者处理代表这些行的索引结构——来确定哪些行属于可见结果集。

在小表上,这个成本可以忽略不计。

在大表上——比如上亿行——这个工作量就开始变得可观了。

这就引出了一个重要的区别:

COUNT(*) 返回单行,并不意味着只处理了单行。

用 EXPLAIN ANALYZE 分析 COUNT 查询

在动手加索引之前,先看看 PostgreSQL 实际在做什么。

假设有这样的查询:

SELECT COUNT(*)
FROM orders
WHERE status = 'completed';

可以用这个来分析:

EXPLAIN (ANALYZE, BUFFERS)
SELECT COUNT(*)
FROM orders
WHERE status = 'completed';

目标不是找默认就有 Index Scan。要关注的是:

  • 扫描类型是什么
  • 估算行数 vs 实际处理行数
  • 被过滤器筛掉的行数
  • 缓冲区活动情况
  • 总执行时间

PostgreSQL 很可能选择:

Seq Scan on orders

如果它估算顺序读比用索引更便宜。这不意味着优化器判断失误——完全取决于有多少行匹配条件。

索引什么时候能真正改善 COUNT

假设有一千万条订单,但只有三万条状态是 pending

SELECT COUNT(*)
FROM orders
WHERE status = 'pending';

这里过滤器大幅缩小了工作集。status 上的索引可以让 PostgreSQL 跳过大部分表:

CREATE INDEX idx_orders_status
ON orders(status);

根据统计信息、值分布和页面状态,PostgreSQL 可能会使用基于索引的访问路径——在合适条件下,甚至可能是 Index Only Scan。

但重要的是,别把这当成保证:

有索引不等于就一定会走 Index Only Scan。

优化器选择的是它估算成本最低的执行计划。

Index Only Scan 和可见性地图

Index Only Scan 能跳过大量表访问,因为查询需要的值已经在索引本身里了。

对于这样的 count:

SELECT COUNT(*)
FROM orders
WHERE status = 'pending';

status 上的索引包含了定位相关条目所需的数据。但 PostgreSQL 仍然必须遵守 MVCC 的可见性规则——这就是可见性地图(visibility map)发挥作用的地方。

PostgreSQL 跟踪哪些页面只包含对所有相关事务都可见的行。当一个页面被标记为 all-visible 时,Index Only Scan 可以跳过访问堆来逐行检查可见性。

这就是为什么说"VACUUM 更新索引"是错误的。索引在表变化时本来就保持最新。VACUUM 真正维护的是让 PostgreSQL 避免某些堆访问的可见性信息。

可以直接从执行计划里看到这一点:

Heap Fetches: 0

如果 Heap Fetches 数值很高,意味着 PostgreSQL 用了 Index Only Scan,但仍然不得不访问表页面来确认可见性。

COUNT 和过滤器的真正问题:选择性

现在换个场景。假设 90% 的订单都是 completed

SELECT COUNT(*)
FROM orders
WHERE status = 'completed';

这时候 status 上的索引收益就小得多。为什么?因为即使 PostgreSQL 能快速定位所有 status = completed 的条目,这些条目几乎涵盖了整张表。

索引并没有消除足够多的工作。

这就是为什么 PostgreSQL 可能仍然偏好 Seq Scan,即使存在看起来匹配 WHERE 子句的索引。

正确的问题从来不是:

有没有索引?

而是:

这个索引实际上能把 PostgreSQL 需要处理的数据量缩减多少?

选择性比索引的存在与否重要得多。

什么时候用局部索引更合理

局部索引(partial index)在你频繁查询一个定义明确的小数据子集时非常有用。

假设只有一小部分订单是 pending

SELECT COUNT(*)
FROM orders
WHERE status = 'pending';

可以创建:

CREATE INDEX idx_orders_pending
ON orders(id)
WHERE status = 'pending';

这个索引不包含所有订单——只包含匹配 status = 'pending' 的。如果 pending 只是很小的子集,这个索引会比覆盖整张表的索引小得多。对于使用完全相同谓词的查询,PostgreSQL 处理的数据结构要精简得多。

但如果你为一个占表 90% 的值建局部索引,情况就完全不同了——你维护的索引几乎覆盖了每一行,节省的潜力也就微乎其微。

局部索引只有在真正反映了足够高选择性的访问模式时才能发挥作用。

带多个过滤条件的 COUNT

当 count 组合多个条件时,情况就更有意思了:

SELECT COUNT(*)
FROM orders
WHERE account_id = 42 AND status = 'pending' AND created_at >= DATE '2026-09-01';

单列索引可能不够。可以考虑复合索引:

CREATE INDEX idx_orders_account_status_created
ON orders(account_id, status, created_at);

但别直接照搬这个顺序。先衡量。列的顺序应该反映数据的实际过滤方式、分布特点,以及应用真实的查询模式。

正确的工作流仍然是:

query
↓
EXPLAIN ANALYZE
↓
rows processed
↓
selectivity
↓
index design
↓
new EXPLAIN ANALYZE

而不是:

slow query
↓
create index
↓
hope

索引也解决不了 COUNT 的情况

这里有个硬限制。如果真的需要统计大表的一大部分,PostgreSQL 必须处理大量数据才能得到精确结果。

索引可以改变如何访问这些数据。但不能让属于 count 范围的行凭空消失。

如果一个应用不停地跑:

SELECT COUNT(*)
FROM orders;

对一张超大表还期望近乎即时的响应,那得先想想:每次请求都跑精确 count 是不是合理的模型。换个思路——预计算计数器、用 pg_class.reltuples 做近似 count、或者干脆接受某些场景不需要实时精确值。

你们线上环境怎么处理这个?维护预计算的计数器,还是 count 真的重要时就接受成本?留言说说你的做法。