PageNumberPagination在大数据量下变慢,因其先执行COUNT(*)全表扫描获取总数,再用LIMIT OFFSET查询(如OFFSET 199980需跳过前199980行),索引易失效、常退化为全表扫描,导致MySQL/PostgreSQL响应从5ms升至1200ms。

为什么 PageNumberPagination 在大数据量下会变慢?
因为 Django 默认的分页器会先执行 SELECT COUNT(*) 获取总条数,再用 LIMIT OFFSET 拉取数据。当表有千万级记录、且 OFFSET 很大(比如第1000页)时,数据库仍要扫描前 99900 条才能跳到目标位置,COUNT(*) 本身也可能全表扫——这两步都不可忽视。
用游标分页(CursorPagination)替代页码分页
游标分页不依赖页码和总数,而是基于排序字段的值做“断点续传”,避免 OFFSET 跳跃和 COUNT(*)。它要求排序字段必须唯一、非空、有索引(推荐用 id 或带时间戳的组合字段)。
实操建议:
- 在视图类中把
pagination_class换成CursorPagination - 显式设置
ordering,例如ordering = '-created_at', '-id'(注意:必须含主键或唯一字段兜底,防止重复游标) - 前端不再传
page=123,而是传上一页响应里的next或previousURL 中的cursor参数 - 不支持跳转任意页,但对“加载更多”“上拉刷新”这类场景更高效、更稳定
手动优化 LimitOffsetPagination 的 COUNT 查询
如果业务强依赖页码(比如 SEO 或用户手动输页码),可绕过慢 COUNT(*):用近似行数(如 PostgreSQL 的 pg_class.reltuples)或缓存总数(配合信号监听增删改),但要注意数据一致性风险。
立即学习“Python免费学习笔记(深入)”;
更稳妥的做法是限制最大可查页数,例如:
class CustomPageNumberPagination(PageNumberPagination):
max_page_size = 100
page_size_query_param = 'page_size'
<pre class="brush:php;toolbar:false;">def paginate_queryset(self, queryset, request, view=None):
# 防止用户请求 page=1000000 导致 OFFSET 过大
page_number = request.query_params.get(self.page_query_param, 1)
if int(page_number) > 500:
raise ValidationError('page number too large')
return super().paginate_queryset(queryset, request, view)
同时确保 ordering 字段上有联合索引,例如 CREATE INDEX CONCURRENTLY ON mytable (status, created_at DESC, id DESC); —— 这能让 LIMIT OFFSET 真正走索引扫描,而非顺序扫描。
什么时候该放弃 ORM 分页,直接写原生 SQL?
当排序逻辑复杂(涉及多表 JOIN、聚合、窗口函数)、且数据量持续增长时,Django ORM 的分页封装反而成为瓶颈。此时应考虑用 connection.cursor() 手写带键集(keyset)分页的 SQL:
例如按时间分页,上一页最后一条是 created_at='2024-05-01 10:20:30', id=8888,下一页就查:
SELECT * FROM mytable
WHERE (created_at, id) < ('2024-05-01 10:20:30', 8888)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这种写法不依赖 OFFSET,性能恒定;但需自行处理边界、去重、排序稳定性等细节,ORM 原有抽象层基本失效。
真正难的不是换哪种分页方式,而是判断「当前排序字段是否具备足够区分度」以及「业务能否接受无总数、无跳页」——这两个问题没想清楚,换什么方案都会在某天卡住。


















