PostgreSQL count(*) 很慢:精确计数、估算和汇总表怎么选(17.12 实测 300 万行)
PostgreSQL 没有表级精确计数器,COUNT(*) 必须扫当前可见行。在 PostgreSQL 17.12 测试库(orders 300 万行自造数据、175 MB、全在缓存里)实测:并行精确计数约 58–64 ms,pg_class.reltuples 估算不到 1 ms,按日汇总表求和约 0.08 ms(大约快 600 倍)。过滤计数只有选择率低时才会走 Index Only Scan。毫秒数只说明量级,不承诺生产耗时。
PostgreSQL 17.12 测试库实测(自造 3,000,000 行,表 175 MB,shared_buffers=256MB,Buffers 几乎全是 shared hit):SELECT count(*) 走 Parallel Seq Scan,第一次 EXPLAIN ANALYZE 151.677 ms,随后三次 60.337 / 57.779 / 59.830 ms。刚 ANALYZE 后 reltuples 和 n_live_tup 都是 3,000,000,读取分别 0.925 ms 和 0.492 ms。按日+状态汇总表 72 kB,sum(cnt) 0.078 ms。没有 created_at 索引时增量刷新要 129.904 ms,建索引后同样的 UPSERT 0.505 ms。count(1)、count(id) 没有实质加速。VACUUM 不会给整表 COUNT(*) 留下一个缓存总数。
适用对象
- 我的判断:精确 COUNT(*) 慢,是因为 MVCC 下必须看见当前可见的每一行,不是 SQL 写法写错了。改成 count(1) 或 count(id) 几乎没有好处。
- 只要数量级:读 pg_class.reltuples 或 pg_stat_all_tables.n_live_tup,并写明这是估算、最近一次 ANALYZE 是什么时候。本测试库刚 ANALYZE 完时估算等于精确值,生产有并发写入时会偏。
- 仪表盘要稳定快、又能接受短暂延迟:用汇总表(或物化视图)。本次 72 kB 的按日汇总表求和大约比精确计数快 600 倍。增量刷新必须有时间列索引,否则刷新本身会再扫一遍。
- 带 WHERE 的计数:选择率低才值得指望索引。本次 10% 的 cancelled 走 Index Only Scan(Heap Fetches: 0)约 17–26 ms;90% 的 paid 规划器仍选 Seq Scan。
精确 count(*) 慢,是因为它必须看见每一行,不是写法问题
PostgreSQL 用 MVCC 判断一行当前是否可见,所以精确的 COUNT(*) 不能靠一个表头上的计数器交差。它会走顺序扫描,或在条件合适时走能覆盖条件的索引。官方文档对聚合函数和仅索引扫描的说明,和这次计划里看到的 Parallel Seq Scan、Index Only Scan 对得上。
所以先问自己要的是哪一种数:现在此刻一分不差的总数、大概的数量级,还是仪表盘上可以有短暂延迟的稳定快查询。三种答案对应三种做法,不要一律上 COUNT(*)。
300 万行精确计数:有并行约 58–64 ms,第一次分析 152 ms
插入并 ANALYZE 之后,EXPLAIN (ANALYZE, BUFFERS) SELECT count(*) FROM orders 的计划是 Finalize Aggregate → Partial Aggregate → Parallel Seq Scan,3 个并行 worker,每个大约读 1,000,000 行,Buffers: shared hit=22389,Execution Time 151.677 ms。随后三次不带 EXPLAIN 的 SELECT count(*) 分别是 60.337 / 57.779 / 59.830 ms。
同一条语句把 max_parallel_workers_per_gather 设成 0 后(此时表已是 3,001,000 行),计划变成普通 Seq Scan,第一次 EXPLAIN ANALYZE 355.422 ms,随后 85.732 ms。并行在这台机器上有帮助,但即使有并行,精确计数仍然是几十毫秒级的全表扫描,不是读一个计数器。
| 场景 | 计划 | 耗时 |
|---|---|---|
| 第一次 EXPLAIN ANALYZE(3,000,000 行) | Parallel Seq Scan,3 个 worker | 151.677 ms |
| 随后三次 count(*) | 同上 | 60.337 / 57.779 / 59.830 ms |
| 关闭并行后第一次 EXPLAIN ANALYZE(3,001,000 行) | Seq Scan | 355.422 ms |
| 关闭并行后随后一次 | Seq Scan | 85.732 ms |
改成 count(1) 或 count(id) 几乎没有好处
count(1) 的计划仍然是 Parallel Seq Scan,EXPLAIN ANALYZE 140.088 ms,随后一次 60.084 ms。count(id) 随后一次 66.586 ms。和 count(*) 的 58–64 ms 比,看不出值得改写的差距。
我不会为了「听说 count(1) 更快」去改业务 SQL。要加速,得换问题:是不是其实只需要估算,或者该不该把总数预聚合。
只要数量级:reltuples 和 n_live_tup 都不到 1 ms
刚 ANALYZE 完,SELECT reltuples::bigint FROM pg_class WHERE relname = 'orders' 得到 3,000,000,耗时 0.925 ms;pg_stat_all_tables.n_live_tup 同样是 3,000,000,耗时 0.492 ms。同一时刻精确 count(*) 是 60.196 ms。
这次估算刚好等于精确值,是因为测试库刚跑完 ANALYZE,而且没有并发写入。生产上统计信息会过期,只能当数量级,不能当对账数字。用估算时,把最近一次 ANALYZE 时间一起显示出来。
| 方法 | 结果 | 耗时 |
|---|---|---|
| pg_class.reltuples | 3,000,000 | 0.925 ms |
| pg_stat_all_tables.n_live_tup | 3,000,000 | 0.492 ms |
| SELECT count(*) | 3,000,000 | 60.196 ms |
带 WHERE 的计数:90% 仍走全表扫描,10% 才走 Index Only Scan
COUNT(*) WHERE status = 'paid' 大约 2,700,000 行(90%)。没有 status 索引时是 Parallel Seq Scan,148.861 ms,随后 88.638 ms。建 orders_status_idx(1.409 s)并 VACUUM (ANALYZE)(362 ms,扫 22,389 页,0 个 dead tuple)之后,规划器对 paid 仍然选 Parallel Seq Scan(143.016 ms / 随后 81.133 ms)。高选择率走索引并不划算。
同样的索引,对 10% 的 cancelled(300,000 行)变成 Parallel Index Only Scan using orders_status_idx,Heap Fetches: 0,EXPLAIN ANALYZE 25.563 ms,随后 17.526 ms。仅索引扫描能成立,是因为 VACUUM 更新了可见性图;整表 COUNT(*) 在 VACUUM 之后却仍然是 Parallel Seq Scan(120.646 ms / 随后 64.119 ms)。VACUUM 不会给整张表留下一个可直接读取的精确总数。
| 条件 | 计划 | EXPLAIN ANALYZE / 随后一次 |
|---|---|---|
| status = 'paid'(约 90%) | Parallel Seq Scan | 143.016 ms / 81.133 ms |
| status = 'cancelled'(10%,300,000 行) | Parallel Index Only Scan,Heap Fetches: 0 | 25.563 ms / 17.526 ms |
| 无 WHERE 的整表 count(*) | 仍是 Parallel Seq Scan | 120.646 ms / 64.119 ms |
汇总表求和约 0.08 ms;增量刷新没有时间索引,会再变慢
CREATE TABLE daily_order_counts AS SELECT created_at::date AS d, status, count(*) AS cnt FROM orders GROUP BY 1, 2,创建 282.121 ms,表加唯一索引后一共 72 kB。SELECT sum(cnt) 的 Execution Time 0.078 ms,随后 0.096 / 0.113 ms,相对精确 count(*) 约 60 ms,大约快 600 倍。
再插入 1000 行模拟新的一天后,精确值和汇总表都是 3,001,000。但增量 UPSERT(按当天日期重算再写入)在没有 created_at 索引时要 129.904 ms。建 orders_created_idx(1.132 s)之后,同样的语句变成 Bitmap Index Scan,只读这 1000 行,Execution Time 0.505 ms。汇总表要快,刷新路径也得能走索引,否则你只是把慢从查询挪到了刷新。
| 动作 | 结果 | 耗时 |
|---|---|---|
| 创建按日+状态汇总表 | 72 kB | 282.121 ms |
| sum(cnt) 求精确总数的预聚合值 | 3,000,000 | 0.078 ms(随后 0.096 / 0.113 ms) |
| 无 created_at 索引的增量 UPSERT | 写入当天 1000 行对应汇总 | 129.904 ms |
| 有 created_at 索引后同样的 UPSERT | Bitmap Index Scan 读 1000 行 | 0.505 ms |
先定要哪种数,再选做法
对账、一次性校准、迁移前后核对行数:用精确 COUNT(*),接受它会扫可见行,必要时加过滤条件和合适的索引。
监控板上的「大概多少行」:用 reltuples 或 n_live_tup,并标明这是估算。
经常被打的仪表盘总数:用汇总表或物化视图,按日或按业务键预聚合,并用时间列索引做增量刷新。本次没有测 REFRESH MATERIALIZED VIEW 的代价,需要并发读写时再单独评估 CONCURRENTLY。
我选计数方式的四步
- 01
1
问要哪种数
一分不差的当前值、大概的数量级,还是可以有短暂延迟的稳定快查询。
- 02
2
先看计划和缓存
EXPLAIN (ANALYZE, BUFFERS) 看是 Seq Scan 还是 Index Only Scan,以及 shared hit / read。不要先改 count(1)。
- 03
3
估算或汇总
只要数量级就读 reltuples,并记下 ANALYZE 时间;要反复快查就建汇总表。
- 04
4
给刷新加索引
增量条件必须能走时间或主键索引,否则刷新会重新变慢。
以上耗时来自 PostgreSQL 17.12 测试库、300 万行自造数据、几乎全在缓存中的一次实测,只说明量级和相对关系。
免费 · 留邮箱领检查单
PostgreSQL 14 停更升级检查单(Excel 可直接打开)
- PG14 2026-11-12 停更,升 17 / 18 前后 19 项检查:只读预检、--check、回滚、补统计、回归
- 每项写清怎么查、通过标准、不处理会怎样(附测试库实测数字)
- 配合站上的只读预检 SQL 使用,每个业务库各跑一次
觉得有用?转给正在做这件事的同事或朋友
参考依据
以下来源用于确认市场趋势、政策背景和术语边界;具体落地方案仍以客户的数据范围、权限和交付目标为准。
常见问题
为什么 PostgreSQL 的 count(*) 会比想象中慢?
因为它必须判断当前可见的每一行(MVCC),没有表级精确计数器。本次 300 万行在缓存命中、有 3 个并行 worker 时,随后三次精确计数约 58–64 ms;关掉并行后第一次分析是 355 ms。
count(1) 或 count(主键) 会更快吗?
这次几乎不会。count(1) 随后一次 60.084 ms,count(id) 66.586 ms,和 count(*) 的 58–64 ms 在同一量级,计划都是扫表。
可以用 pg_class.reltuples 代替 count(*) 吗?
只能代替「大概有多少行」。它是 ANALYZE 或 VACUUM 留下的估算。本次刚 ANALYZE 完时等于精确值 3,000,000,读取 0.925 ms。生产有写入时会偏,对账不要用它。
VACUUM 之后 count(*) 会不会突然变快?
VACUUM 能更新可见性图,让满足条件的过滤计数走 Index Only Scan(本次 cancelled 的 Heap Fetches 为 0)。但整表 COUNT(*) 在 VACUUM 之后仍然是 Seq Scan,不会出现一个缓存好的精确总数。
仪表盘应该怎么做总数?
用汇总表或物化视图预聚合。本次 72 kB 的按日汇总表求和约 0.08 ms,大约比精确计数快 600 倍。增量刷新要有时间列索引,没有索引时本次刷新要 129.904 ms,有索引后是 0.505 ms。
下一步
推荐动作
你提供要计数的 SQL、表大小和 EXPLAIN ANALYZE(可脱敏),我们判断该用估算、汇总表还是改查询;生产变更由你方授权执行。不承诺耗时或优化比例。也可以先用免费 PG14 升级体检工具,或看 ¥59 性能排查学习包。