PageNumberPagination在千万级数据上变慢,因其依赖COUNT(*)全表扫描和OFFSET/LIMIT查询,数据库需跳过前N行再取结果,导致响应随页码线性恶化;应改用CursorPagination或键集分页。

为什么 PageNumberPagination 在千万级数据上会变慢?
因为 Django 默认分页器依赖 count() 和 OFFSET/LIMIT,而 count(*) 在大表上会触发全表扫描;OFFSET 1000000 要求数据库先跳过前一百万行,再取结果,MySQL/PostgreSQL 都会越往后越慢。
常见现象:第 1 页很快,翻到第 5000 页时响应超 5 秒,甚至触发 DB 连接超时。
- PostgreSQL 中
OFFSET性能随偏移量线性下降,无索引加速时几乎不可用 - MySQL 8.0+ 对
LIMIT offset, size优化有限,尤其配合ORDER BY时仍需临时排序 - Django 的
PageNumberPagination每次请求都调用queryset.count(),哪怕你只想要下一页链接
用游标分页(CursorPagination)替代页码分页
游标分页不依赖总数和偏移量,而是基于排序字段的值“锚定”位置,每次只查比上一次最后一条记录更大(或更小)的数据,天然避免 OFFSET。
适用前提:必须有唯一、单调(或严格有序)、可索引的字段作为游标依据,比如 id 或带纳秒精度的 created_at。
立即学习“Python免费学习笔记(深入)”;
- 在 ViewSet 中替换分页类:
pagination_class = CursorPagination - 必须显式指定
ordering,例如ordering = '-id'(升序/降序决定游标方向) - 客户端不再传
page=5,而是传cursor=cD0xMjM0NTY=(Base64 编码的上一页末尾 id) - 注意:无法跳转任意页,只能前后翻页;不支持获取总条数 —— 这不是缺陷,是设计取舍
手动实现基于 ID 的键集分页(Keyset Pagination)
比 CursorPagination 更轻量、可控,适合需要精确控制查询逻辑或兼容老客户端的场景。核心是用上一页最后一条的 id 做条件过滤,而不是 OFFSET。
示例(获取下一页,按 id 降序):
last_id = request.query_params.get('last_id')
if last_id:
queryset = queryset.filter(id__lt=int(last_id))
queryset = queryset.order_by('-id')[:20]
关键点:
-
id__lt或id__gt必须与order_by方向一致,否则漏数据或重复 -
id字段必须有索引(Django 默认主键已建 B-tree 索引) - 若排序字段可能重复(如
created_at),需组合唯一字段,例如filter(created_at__lt=last_time) | (created_at=last_time, id__lt=last_id) - 首次请求不带
last_id,后续每页返回本页最小(或最大)id给前端用于下一页
哪些操作会悄悄让分页失效?
即使用了游标或键集,以下写法仍会让查询退化为慢分页:
- 在分页前对 queryset 调用
.annotate()或.aggregate()—— 触发子查询,破坏游标语义 - 使用
.distinct()且未指定字段,导致 PostgreSQL 添加ORDER BY列进 DISTINCT,游标失效 - 在
CursorPagination中动态修改ordering(如根据参数切换字段),不同排序会导致游标无法衔接 - 前端传错游标值(如篡改 Base64 或传空字符串),Django 默认返回第一页,但可能漏掉首条数据 —— 应校验并返回 400
真正卡住的从来不是“怎么翻页”,而是没意识到分页背后的 SQL 是什么。一旦数据过了百万,OFFSET 就该被当成危险函数对待。


















