bigywater.tech
Django · 49

Django 分页为什么会触发 COUNT(*)

Django 分页为什么每次都多一条 COUNT(*)?大表上翻到最后一页会慢多少?有什么替代?这篇在 100 万行的表上量每一部分。

14.1 ms
100 万行 COUNT(*) 的耗时;第 1 页整体 14.4 ms 几乎都是它
0.26 → 42.5 ms
取第 1 页 → 第 40000 页的 SQL 耗时(OFFSET 越深越慢)
0.32 ms
keyset 分页(WHERE created > … LIMIT 25)的耗时,与深度无关

Paginator 发出的 SQL

实测 · 本机执行输出
   SELECT COUNT(*) AS "__count" FROM "lab_item"
   SELECT "lab_item"."id", "lab_item"."name", "lab_item"."created" FROM "lab_item" ORDER BY "lab_item"."id" ASC LIMIT 25 OF
同一个 Paginator 再取第 4 页时,新增的 COUNT 查询: 0 (count 是缓存的)
p.count = 1000000  p.num_pages = 40000

Paginator.count 是 cached_property,每个 Paginator 对象只查一次。但每个请求通常新建一个 Paginator,所以每个请求都会 COUNT(*)。

耗时

实测 · 本机执行输出
SELECT COUNT(*):                          14.1 ms
第 1 页(LIMIT 25):                        0.26 ms
第 20000 页(OFFSET 499975):               21.4 ms
第 40000 页(OFFSET 999975):               42.5 ms
Paginator(qs, 25).page(1) 整体(含 COUNT):   14.4 ms
Paginator(qs, 25).page(40000) 整体:        57.5 ms
用 keyset(WHERE created > 999975 ORDER BY 索引列 LIMIT 25): 0.32 ms
过滤后的 COUNT(created < 500000 条件):     18.8 ms

PostgreSQL 的 COUNT(*) 需要扫描(索引或表)满足条件的所有行,行数越多越慢。OFFSET 需要先取出并丢弃前面的行。都是本机单次测量,数字只反映这台机器上这份数据的量级。

替代与细节

实测 · 本机执行输出
未排序的 QuerySet: ["Pagination may yield inconsistent results with an unordered object_list: <class 'lab.models.Item'> QuerySet."]
越界页 → EmptyPage: That page contains no results
子类里把 count 写死之后,COUNT 查询数: 0

要「上一页 / 下一页」的场景用 keyset;需要页码时可以缓存 count、或提供近似值(子类化 Paginator 重写 count)。分页永远要 order_by,并且排序键要唯一(否则页与页之间可能重复或遗漏)。

分步演示:分页的代价

分页与 COUNT
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7 / Django 6.1.1(默认用 SQLite,其它数据库会在文中注明),用 django.test.Client 或真实的本机服务器进程实测;计时用 time.perf_counter,数字每次运行会有小幅抖动。PostgreSQL 16.15(本机临时实例,端口 9424),100 万行(id 主键、created 有索引),ANALYZE 之后;每项取 5 次最快
    • 没有验证:MySQL 的 COUNT 与 OFFSET 代价、近似计数(pg_class.reltuples)、并发写入下 keyset 与 OFFSET 的一致性差异、无限滚动的 cursor 实现细节
    京ICP备2026067502号-1京公网安备11010502063487号