PageNumberPagination深分页变慢是因为其底层使用LIMIT OFFSET,如OFFSET 199980需扫描跳过前199980行,索引易失效、常退化为全表扫描,MySQL/PostgreSQL在OFFSET超10000时响应从5ms升至1200ms;而CursorPagination基于排序字段值游走,时间复杂度稳定O(1),但需严格匹配索引且不支持跳页。

为什么 PageNumberPagination 在深分页时变慢
因为 PageNumberPagination 底层用的是 LIMIT OFFSET 查询,比如请求第 10000 页、每页 20 条,实际执行的是 OFFSET 199980。数据库必须扫描并跳过前 199980 行才能取数据,索引失效风险高,执行计划常退化为全表扫描。
- MySQL/PostgreSQL 对
OFFSET超过 10000 的查询普遍响应明显变慢(实测从 5ms → 1200ms) - Django 的
Paginator和 DRF 默认分页器都共享这一缺陷,不是配置问题,是设计局限 - 即使加了
created_at索引,ORDER BY created_at LIMIT 20 OFFSET 199980仍无法避免回表或排序开销
用 CursorPagination 替换默认分页器
CursorPagination 不依赖页码,而是基于排序字段的值“游走”,每次只查下一页的起始位置,时间复杂度稳定在 O(1)。
- 必须指定严格单调且有索引的排序字段,例如
'-id'或'-created_at'(升序/降序影响游标生成逻辑) - 不能跳页,只支持
next/previous链接,适合 Feed 流、日志列表等场景 - 客户端需保存上一页返回的
cursor参数,下一次请求带上,不能手动拼page=100
示例配置:
from rest_framework.pagination import CursorPagination <p>class CustomCursorPagination(CursorPagination): page_size = 20 ordering = '-created_at' # 必须与数据库索引顺序一致 cursor_query_param = 'c' # 可选:缩短参数名
在视图中启用:
立即学习“Python免费学习笔记(深入)”;
class LogListView(ListAPIView):
queryset = LogEntry.objects.all()
serializer_class = LogSerializer
pagination_class = CustomCursorPagination
手动实现游标分页(绕过 DRF 限制)
当需要更灵活控制(如多字段排序、兼容旧 API)、或 CursorPagination 不满足业务时,可手写逻辑。核心是把“上一页最后一条记录的排序值”作为查询条件。
- 假设按
created_at DESC, id DESC排序,上一页最后一条是created_at='2026-04-12T10:30:00Z', id=88721 - 下一页查询应为:
WHERE (created_at, id) < ('2026-04-12T10:30:00Z', 88721) ORDER BY created_at DESC, id DESC LIMIT 21 - 注意:PostgreSQL 支持元组比较,MySQL 8.0+ 也支持,但低版本需拆成
created_at < '...' OR (created_at = '...' AND id < 88721) - 必须为
(created_at, id)建联合索引,否则性能无改善
简单封装示例:
def get_cursor_queryset(queryset, cursor=None, limit=20):
if cursor:
ts, pk = cursor.split('|')
queryset = queryset.filter(
models.Q(created_at__lt=ts) |
models.Q(created_at=ts, id__lt=int(pk))
)
return queryset.order_by('-created_at', '-id')[:limit + 1]
游标分页容易被忽略的三个细节
游标分页不是“开了就快”,漏掉任意一点都可能白忙活。
- 数据库索引必须覆盖排序字段且顺序严格匹配,例如
ordering = '-created_at'就要建INDEX ON table (created_at DESC),ASC/DESC 写反会导致索引不生效 -
cursor值本质是编码后的排序字段组合,不能被客户端篡改或解析,DRF 内部用 base64 加密,自定义实现时建议用django.core.signing.Signer签名防伪造 - 游标分页无法获取总条数(
count()会破坏性能优势),前端需放弃“共 XX 条,第 X 页”这类展示,改用“加载更多”或无限滚动
真正卡住性能的往往不是代码怎么写,而是没意识到 created_at 字段存在 NULL 值、时区未统一、或排序字段未加索引——这些会让游标查询退化成全表扫描。



















