Django 分页在大数据量下为什么会变慢?游标分页怎么做?
简化版
Django 的 Paginator 用的是「偏移分页」(LIMIT n OFFSET m),它在数据量大时会遇到两个必然的性能问题:① 每次分页都要执行一次 COUNT(*) 算总页数——在千万行的表上这一条就要几秒;② OFFSET 越大越慢——数据库必须扫描并丢弃前面所有行才能拿到你要的那一页,第 10000 页(OFFSET 200000)要扫 20 万行才返回 20 条,这就是**「深分页」问题**。解决方案有三个层次:① 去掉 COUNT——用 Django 的 Paginator(..., count=估算值) 或干脆改用「只判断有没有下一页」的方式(多取一条看看);② 改用「键集分页 / 游标分页」(keyset pagination)——不用 OFFSET 而是 WHERE id < 上一页最后一个 id ORDER BY id DESC LIMIT 20,利用索引直接定位、耗时与页码无关,这是无限滚动和 API 的标准做法;③ 用 iterator() 或分块处理做后台批处理。游标分页的代价是「不能跳页」(只能上一页/下一页),并且排序字段必须唯一且有索引(通常是 id 或 (created_at, id) 组合,加 id 是为了打破并列)。Django 自带的 Paginator 还有个细节:它会在你访问 page.object_list 时才切片查询,但 paginator.count/num_pages 会立刻触发 COUNT。DRF 提供了现成的 CursorPagination。核心记忆:OFFSET 要扫掉前面所有行所以深分页必慢;大表分页用「WHERE 上次的位置」代替 OFFSET;代价是不能跳页。
详细版
三种分页方式对比:
| 偏移分页(OFFSET) | 键集/游标分页 | 估算 + 无 COUNT | |
|---|---|---|---|
| SQL | LIMIT 20 OFFSET 200000 | WHERE id < ? LIMIT 20 | LIMIT 21(多取一条) |
| 深页耗时 | O(offset),越翻越慢 | O(1),恒定 | O(1) |
| 需要 COUNT | ✅ 慢 | ❌ | ❌ |
| 能跳页 | ✅ | ❌ 只能上下页 | ❌ |
| 数据变动时 | ❌ 会重复/漏数据 | ✅ 稳定 | ✅ |
| 适用 | 后台管理、页数不多 | API、无限滚动、大表 | 简单列表 |
# ① ★Django 内置 Paginator(偏移分页)★
from django.core.paginator import Paginator, EmptyPage
qs = Article.objects.order_by("-created", "-id") # ★必须有确定的排序★
paginator = Paginator(qs, 20)
paginator.count # ★立刻执行 SELECT COUNT(*)★
paginator.num_pages
page = paginator.get_page(request.GET.get("page")) # ★get_page 不会抛异常★
page.object_list # ★此时才执行 LIMIT 20 OFFSET (n-1)*20★
page.has_next(), page.next_page_number()
# ② ★跳过 COUNT:传入估算值(Django 4.1+)★
class FastPaginator(Paginator):
@cached_property
def count(self):
return estimate_rows("myapp_article") # ★用 PG 的 reltuples 估算★
# Django 4.1+ 也可以直接:
# Paginator(qs, 20, count=estimated) (通过子类或包装)
def estimate_rows(table):
with connection.cursor() as cur:
cur.execute("SELECT reltuples::bigint FROM pg_class WHERE relname=%s",
[table])
return cur.fetchone()[0] # ★毫秒级,但是估算值★
# ③ ★键集分页(游标分页)——核心写法★
def keyset_page(after_id=None, size=20):
qs = Article.objects.order_by("-id")
if after_id:
qs = qs.filter(id__lt=after_id) # ★用索引直接定位★
rows = list(qs[:size + 1]) # ★多取一条判断有没有下一页★
has_next = len(rows) > size
rows = rows[:size]
next_cursor = rows[-1].id if has_next and rows else None
return rows, next_cursor
# → SQL: SELECT ... WHERE id < 12345 ORDER BY id DESC LIMIT 21
# → ★无论第几页都是走索引取 21 行★
# ④ ★按非唯一字段排序时要加 tiebreaker★
qs = Article.objects.order_by("-created", "-id") # ★created 可能重复★
if cursor:
c_created, c_id = decode(cursor)
qs = qs.filter( # ★元组比较★
Q(created__lt=c_created) |
Q(created=c_created, id__lt=c_id)
)
# ★索引要建成 (created DESC, id DESC) 才能完全走索引★
# ⑤ ★DRF 的现成方案★
from rest_framework.pagination import CursorPagination
class ArticleCursorPagination(CursorPagination):
page_size = 20
ordering = "-created" # ★必须是唯一或近似唯一且有索引的字段★
cursor_query_param = "cursor"
# 其他:PageNumberPagination(偏移)、LimitOffsetPagination(偏移)
# ⑥ ★后台批处理:别用分页,用 iterator★
for a in Article.objects.order_by("id").iterator(chunk_size=2000):
process(a) # ★不缓存结果集,PG 上用服务端游标★
⚠️ 三个必须记住的点:①
OFFSET不是「跳过」,而是「取出来再丢掉」。数据库执行LIMIT 20 OFFSET 200000时,必须真正读取并排序前 200020 行,然后丢弃前 200000 行——所以耗时与OFFSET成正比,翻到越后面越慢,这是深分页问题的根源,和索引没关系(有索引也一样要沿着索引扫过去)。键集分页用WHERE id < 上次的最后一个 id把「跳过」变成了「用索引直接定位」,耗时与页码无关。②COUNT(*)在大表上很贵。MySQL InnoDB 没有行数的元信息,必须扫索引数一遍(千万行要几秒);PostgreSQL 因为 MVCC 也必须扫描可见性。而Paginator.count和num_pages会立刻触发这条查询——很多列表页的性能问题其实一半来自 COUNT。解法是估算(PG 的pg_class.reltuples、MySQL 的SHOW TABLE STATUS)或根本不显示总数(改成「有没有下一页」)。③ 偏移分页在数据变动时会重复或漏数据。如果你在看第 2 页时有人在列表顶部插入了 5 条新数据,那么第 3 页会重复显示第 2 页末尾的内容;反之删除则会漏掉数据。键集分页因为「锚定在具体的记录位置上」而没有这个问题——这是它在无限滚动和数据同步 API 里被普遍采用的重要原因,甚至比性能更关键。
完整版教学
一、OFFSET 为什么越翻越慢
★ 数据库执行 LIMIT 20 OFFSET 200000 的真实过程:
┌──────────────────────────────────────────────────────┐
│ 1. 按 ORDER BY 沿索引(或排序结果)★逐行扫描★ │
│ 2. 数一数:第 1 行…第 200000 行 ★全部读出来又丢掉★ │
│ 3. 从第 200001 行开始,取 20 行返回 │
└──────────────────────────────────────────────────────┘
★★OFFSET 不是"跳过",是"取出来再丢掉"★★
★ 实测量级(1000 万行的表,按 created 索引排序):
┌────────────────────┬──────────────┬────────────────┐
│ 查询 │ 扫描行数 │ 耗时(量级) │
├────────────────────┼──────────────┼────────────────┤
│ LIMIT 20 OFFSET 0 │ 20 │ ★~1ms★ │
│ LIMIT 20 OFFSET 1000│ 1020 │ ~5ms │
│ OFFSET 100000 │ 100020 │ ★~200ms★ │
│ OFFSET 1000000 │ 1000020 │ ★~2000ms★ │
│ ★键集:WHERE id<X★ │ ★20★ │ ★~1ms(恒定)★ │
└────────────────────┴──────────────┴────────────────┘
★ 结论:耗时 ≈ O(offset),★与页码线性增长★
★ 更糟的情况:★回表放大★
SELECT * FROM article ORDER BY created LIMIT 20 OFFSET 100000
如果 created 上有索引但要 SELECT *:
→ 沿 created 索引扫 100020 个索引项
→ ★每个都要回主键索引取整行★(如果优化器选择了这条路)
→ ★100020 次随机 IO★
✓ 优化技巧:★延迟关联(deferred join)★
SELECT * FROM article
JOIN (SELECT id FROM article ORDER BY created
LIMIT 20 OFFSET 100000) t USING (id);
→ ★子查询只扫索引(覆盖索引),不回表★
→ 只对最终的 20 行回表
→ ★能快几倍到几十倍★,但仍是 O(offset)
Django 里的写法:
ids = list(Article.objects.order_by("created")
.values_list("id", flat=True)[100000:100020]) # ★只取 id★
rows = Article.objects.filter(id__in=ids).order_by("created")
★ 为什么"加索引"救不了深分页:
索引能让数据库★不用排序★(沿索引顺序读)
但★仍然要沿着索引一个一个数过去★
→ ★索引解决的是"排序成本",不是"偏移成本"★
★ 这是很多人的误解:以为加了索引深分页就快了
★ 真正的解法:★把"数过去"换成"直接定位"★
OFFSET 200000 → ★WHERE id < 上一页最后一个 id★
索引的 B+ 树能★在 O(log n) 内定位到那个 id★,然后顺序读 20 行
→ ★与页码完全无关★
理解深分页的根源只需要一句话:OFFSET 不是「跳过」,而是「取出来再丢掉」。数据库执行 LIMIT 20 OFFSET 200000 时必须真正读取前 200020 行再丢弃前 20 万行,耗时与 offset 成线性关系——1000 万行的表上 OFFSET 1000000 要 2 秒,而键集分页恒定 1 毫秒。更糟的是回表放大:如果按二级索引排序又要 SELECT *,那 10 万个索引项每个都可能要回表取整行,变成 10 万次随机 IO;「延迟关联」技巧(子查询只取 id 走覆盖索引,再 JOIN 回来取整行)能快几倍到几十倍,但仍然是 O(offset)。这里要破除一个常见误解:加索引救不了深分页——索引解决的是「排序成本」,不是「偏移成本」,有索引也一样要沿着索引一个一个数过去。真正的解法是把「数过去」换成「直接定位」:B+ 树能在 O(log n) 内定位到某个 id,然后顺序读 20 行,与页码完全无关。
二、COUNT(*) 的代价与规避
★ 为什么 COUNT(*) 慢:
MySQL InnoDB:
★没有存储行数的元信息★(因为 MVCC,不同事务看到的行数不同)
→ ★必须扫一遍索引数★(会选最小的二级索引)
→ 1000 万行 ≈ ★1~5 秒★
PostgreSQL:
★同样因为 MVCC 要检查每行的可见性★
→ 全表或索引扫描
★ 加了 WHERE 条件的 COUNT 更慢(要过滤)
★ Paginator 什么时候触发 COUNT:
paginator = Paginator(qs, 20) # ★还没查★
paginator.count # ★→ SELECT COUNT(*)★
paginator.num_pages # ★→ 内部用 count★
page = paginator.get_page(2) # ★→ 也要 count(要判断页码合法性)★
page.object_list # → SELECT ... LIMIT 20 OFFSET 20
★ 所以一个普通列表页 = ★COUNT + 数据查询★ 两条 SQL
★ ★规避方案一:估算总数★
# PostgreSQL(★毫秒级★)
SELECT reltuples::bigint FROM pg_class WHERE relname = 'myapp_article';
# MySQL
SHOW TABLE STATUS LIKE 'myapp_article'; # Rows 列(★InnoDB 是估算★)
# 或 EXPLAIN 的 rows 估算
★ 误差:通常 ±10%(取决于 ANALYZE/统计信息的新旧)
✓ 展示成"约 1,200,000 条"就够了 —— ★用户根本不在意精确值★
class EstimatedPaginator(Paginator):
@cached_property
def count(self):
if not self.object_list.query.where: # ★无过滤条件才能估算★
return estimate_rows(self.object_list.model._meta.db_table)
return super().count
★ ★规避方案二:根本不显示总数★
只回答"★有没有下一页★":
rows = list(qs[:page_size + 1]) # ★多取一条★
has_next = len(rows) > page_size
rows = rows[:page_size]
★ 零 COUNT 开销
→ UI 改成「上一页 / 下一页」或「加载更多」
★ 这也是 Google 搜索结果的做法("约 xx 条结果"是估算的)
★ ★规避方案三:缓存 COUNT★
count = cache.get_or_set("article_count", lambda: qs.count(), 300)
★ 适合:总数变化不频繁、允许几分钟延迟
★ ★规避方案四:上限 COUNT★
# 最多数到 10000,超过就显示"10000+"
count = qs.values("pk")[:10001].count()
★ SQL: SELECT COUNT(*) FROM (SELECT pk FROM t LIMIT 10001) x
★ 保证最坏情况可控
★ 选择建议:
┌──────────────────────┬────────────────────────────┐
│ 后台管理,量不大 │ ★原样用 Paginator★ │
│ 前台列表,百万级 │ ★估算 或 上限 COUNT★ │
│ API / 无限滚动 │ ★不返回总数,只给 next 游标★ │
│ 需要精确总数的报表 │ ★预计算并缓存★ │
└──────────────────────┴────────────────────────────┘
COUNT(*) 慢是 MVCC 的必然结果——MySQL InnoDB 没有存储行数的元信息(因为不同事务看到的行数不同),必须扫一遍索引数;PostgreSQL 同样要检查每行的可见性。而 Paginator 的 count、num_pages、甚至 get_page() 都会触发它,所以一个普通列表页其实是 COUNT 加数据查询两条 SQL,性能问题常有一半来自 COUNT。四种规避方案各有场景:估算(PG 的 pg_class.reltuples、MySQL 的 SHOW TABLE STATUS,毫秒级、误差约 ±10%,展示成「约 120 万条」用户根本不在意)、根本不显示总数(多取一条判断 has_next,零开销,也是 Google 搜索结果的做法)、缓存 COUNT(总数变化不频繁时)、上限 COUNT(最多数到 10000,超过显示「10000+」,保证最坏情况可控)。
三、键集分页的完整实现
★ 最简单的形式(按自增主键倒序):
# 第一页
SELECT * FROM article ORDER BY id DESC LIMIT 21;
# 下一页(上一页最后一个 id = 9820)
SELECT * FROM article WHERE id < 9820 ORDER BY id DESC LIMIT 21;
★ 每次都只读 21 行 → ★恒定耗时★
★ ★关键要求:排序键必须"唯一 + 有索引"★
✗ ORDER BY created(★created 可能重复★)
→ WHERE created < '2026-01-01 10:00:00'
→ ★同一时刻的多条记录会被整体跳过或重复★
✓ ORDER BY created DESC, id DESC(★加 id 打破并列★)
→ 条件变成★元组比较★:
WHERE (created, id) < ('2026-01-01 10:00:00', 9820)
→ PG 支持行值比较(★最简洁★):
WHERE (created, id) < (%s, %s) ORDER BY created DESC, id DESC
→ MySQL/通用写法(★Django 的 Q 组合★):
WHERE created < %s OR (created = %s AND id < %s)
Django 实现:
from django.db.models import Q
def keyset(qs, cursor, size=20):
qs = qs.order_by("-created", "-id")
if cursor:
c_created, c_id = cursor
qs = qs.filter(Q(created__lt=c_created) |
Q(created=c_created, id__lt=c_id))
rows = list(qs[:size + 1])
has_next = len(rows) > size
rows = rows[:size]
nxt = (rows[-1].created, rows[-1].id) if has_next and rows else None
return rows, nxt
★ ★索引必须匹配排序★:
class Meta:
indexes = [models.Index(fields=["-created", "-id"],
name="idx_article_created_id")]
★ 索引的列顺序和方向要和 ORDER BY 一致,
否则数据库还是要排序 → ★键集分页的优势就没了★
★ 验证:EXPLAIN 里应该看到 ★Index Scan / Backward Index Scan★
而不是 Sort
★ ★游标的编码★(别把内部字段直接暴露):
import base64, json
def encode_cursor(created, id):
raw = json.dumps([created.isoformat(), id])
return base64.urlsafe_b64encode(raw.encode()).decode()
def decode_cursor(s):
created_s, id_ = json.loads(base64.urlsafe_b64decode(s))
return datetime.fromisoformat(created_s), id_
★ 好处:①不暴露内部主键 ②以后改排序字段不破坏 API 形态
★ 注意:★游标不是安全边界★,用户可以解码 —— 权限仍要单独校验
★ ★双向翻页★(上一页):
往前翻 = ★把比较方向和排序都反过来,取完再倒序★
# 上一页
qs.filter(Q(created__gt=c) | Q(created=c, id__gt=i)).order_by("created", "id")[:size]
rows = list(reversed(rows)) # ★结果要反转回来★
★ ★键集分页的代价(★必须知道★)★:
✗ ★不能跳到第 N 页★(没有"第 100 页"的概念)
✗ ★不能显示总页数★(除非另外算)
✗ ★排序字段必须能构成唯一序★
✗ ★换排序方式就要换游标格式★
✗ 不能"往回跳很多页"
✓ 换来:★恒定耗时 + 数据变动时不重不漏★
★ 什么时候值得上:
✓ ★API / 移动端无限滚动★(本来就没有跳页)
✓ ★数据同步 / 增量拉取★(不重不漏是刚需)
✓ ★千万级大表★
✗ 后台管理的表格(用户要跳页、量也不大)→ ★老老实实用 OFFSET★
键集分页的关键要求是「排序键必须唯一且有索引」。直接 ORDER BY created 是不行的——created 可能重复,同一时刻的多条记录会被整体跳过或重复;正确做法是 ORDER BY created DESC, id DESC 用 id 打破并列,条件变成元组比较(PostgreSQL 支持行值比较 WHERE (created, id) < (%s, %s) 最简洁,通用写法是 Q(created__lt=c) | Q(created=c, id__lt=i))。索引必须和排序完全匹配(列顺序和方向都要一致),否则数据库还是要排序、键集分页的优势就没了——用 EXPLAIN 验证应该看到 Index Scan 而不是 Sort。游标建议base64 编码(不暴露内部主键、以后改排序字段不破坏 API 形态),但要清楚游标不是安全边界,权限仍要单独校验。双向翻页的技巧是把比较方向和排序都反过来、取完再反转结果。最后要认清代价:不能跳页、不能显示总页数、换排序方式就要换游标格式——所以它适合 API、无限滚动、增量同步和千万级大表,后台管理表格还是老老实实用 OFFSET。
四、Django Paginator 的正确用法
★ 基本用法:
from django.core.paginator import Paginator
qs = Article.objects.order_by("-created", "-id") # ★必须显式排序★
paginator = Paginator(qs, 20)
page = paginator.get_page(request.GET.get("page"))
# ★get_page vs page 的区别:★
# page(n) → 非法页码抛 PageNotAnInteger / EmptyPage
# get_page(n) → ★非法→第1页,超范围→最后一页(不抛异常)★
★ ★必须显式 order_by 的原因★:
✗ 没有 ORDER BY 时,数据库★不保证顺序稳定★
→ 同一条记录可能在第 1 页和第 3 页★都出现★,或者★都不出现★
→ Django 会给出 UnorderedObjectListWarning
★ 而且排序键要★确定★(加 id 兜底),
否则 created 相同的行在不同查询里顺序可能不同
★ 模板里的分页:
{% for a in page_obj %}...{% endfor %}
{% if page_obj.has_previous %}
<a href="?page={{ page_obj.previous_page_number }}">上一页</a>
{% endif %}
第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页
{# ★4.2+ 提供 elided_page_range,避免渲染上万个页码★ #}
{% for n in page_obj.paginator.get_elided_page_range(page_obj.number) %}
{% if n == page_obj.paginator.ELLIPSIS %}…{% else %}<a href="?page={{ n }}">{{ n }}</a>{% endif %}
{% endfor %}
★ ★性能陷阱:分页 + prefetch_related★
✗ qs = Article.objects.prefetch_related("tags")
paginator = Paginator(qs, 20)
→ ★prefetch 只对当前页的 20 条生效★(好事)
→ ★但 paginator.count 会额外发 COUNT★
✓ 正常,注意别在分页前 list(qs)(★那会加载全部★)
★ ★ListView 的分页★:
class ArticleList(ListView):
model = Article
paginate_by = 20
ordering = ["-created", "-id"] # ★别忘了★
def get_queryset(self):
return super().get_queryset().select_related("author")
★ 模板变量:page_obj / paginator / object_list / is_paginated
★ ★大表下 Paginator 的两处慢★:
① paginator.count → ★COUNT(*)★
② page.object_list → ★LIMIT/OFFSET★(深页时慢)
✓ 组合优化:
class FastPaginator(Paginator):
@cached_property
def count(self):
return estimate_rows(...) # ★估算★
# 再限制最大页码,防止用户/爬虫翻到很深
MAX_PAGE = 500
page_no = min(int(request.GET.get("page", 1)), MAX_PAGE)
★ ★限制最大页码是很实用的一招★:
真实用户几乎不会翻到第 100 页
但★爬虫和恶意请求会★ —— ?page=999999 直接打爆数据库
✓ 超过阈值就重定向到第 1 页或返回 404
✓ 引导用户用★搜索和筛选★而不是翻页
用 Paginator 有两个必须做对的地方。一是必须显式 order_by——没有 ORDER BY 时数据库不保证顺序稳定,同一条记录可能在第 1 页和第 3 页都出现、或者都不出现(Django 会给 UnorderedObjectListWarning),而且排序键要确定(加 id 兜底)。二是 get_page() 优于 page()——前者对非法页码返回第 1 页、超范围返回最后一页,不抛异常。模板层面,4.2+ 的 get_elided_page_range() 能避免渲染上万个页码链接。大表下 Paginator 有两处慢(COUNT 和 OFFSET),组合优化是估算 count + 限制最大页码——限制最大页码是很实用的一招:真实用户几乎不会翻到第 100 页,但爬虫和恶意请求会(?page=999999 能直接打爆数据库),超过阈值就重定向或返回 404,并引导用户用搜索和筛选。
五、DRF 的三种分页与选择
★ DRF 内置三种:
┌──────────────────────────┬──────────────────────────────────┐
│ PageNumberPagination │ ?page=3 ★偏移,有 count★ │
│ LimitOffsetPagination │ ?limit=20&offset=40 ★偏移★ │
│ ★CursorPagination★ │ ?cursor=xxx ★键集,无 count★ │
└──────────────────────────┴──────────────────────────────────┘
from rest_framework.pagination import CursorPagination
class ArticleCursor(CursorPagination):
page_size = 20
max_page_size = 100
ordering = "-created" # ★必须:唯一或近似唯一 + 有索引★
cursor_query_param = "cursor"
class ArticleViewSet(ModelViewSet):
pagination_class = ArticleCursor
# 响应
{"next": "http://api/articles/?cursor=cD0yMDI2...",
"previous": null,
"results": [...]} # ★注意:没有 count★
★ ★CursorPagination 的内部机制(值得了解)★:
- 游标里编码了 ★position(排序字段值)+ offset(同值内的偏移)+ reverse★
- ★用 offset 处理排序字段重复的情况★(所以 ordering 不必绝对唯一)
- 但★重复值太多时 offset 会变大★,退化成小范围的 OFFSET
✓ 所以 ordering 仍应选★区分度高★的字段(created、id)
✗ 别用 ordering = "-status"(★只有几个取值 → 灾难★)
★ 选择:
✓ ★CursorPagination★:移动端信息流、大表、要求不重不漏
✓ PageNumberPagination:后台管理 API、前端要显示页码
✓ LimitOffsetPagination:需要灵活控制窗口(如可视化图表取数)
★ 前端配合(无限滚动):
let cursor = null;
async function loadMore() {
const url = cursor ? `/api/articles/?cursor=${cursor}` : "/api/articles/";
const data = await (await fetch(url)).json();
render(data.results);
cursor = new URL(data.next ?? "http://x/").searchParams.get("cursor");
}
★ 关键:★前端只保存 next 游标,不关心页码★
★ GraphQL / Relay 的 Connection 规范:
{ edges: [{node, cursor}], pageInfo: {hasNextPage, endCursor} }
★ 本质就是键集分页的标准化形式 —— ★业界共识★
★ 一个常被忽略的问题:★分页与筛选组合★
用户翻到第 5 页后改了筛选条件 → ★必须重置到第 1 页★
(否则可能落到空页或看到错乱的数据)
✓ 前端切换筛选时清空 cursor / page 参数
DRF 内置三种分页器,CursorPagination 就是键集分页的现成实现。它的内部机制值得了解:游标里编码了 position(排序字段值)+ offset(同值内的偏移)+ reverse,用 offset 来处理排序字段重复的情况——所以 ordering 不必绝对唯一,但重复值太多时 offset 会变大、退化成小范围的 OFFSET,因此 ordering 仍应选区分度高的字段(created、id),绝不能用 -status 这种只有几个取值的字段。选择上:移动端信息流和大表用 CursorPagination、后台管理 API 用 PageNumberPagination。GraphQL/Relay 的 Connection 规范(edges + pageInfo.endCursor)本质就是键集分页的标准化形式,可见这是业界共识。最后一个常被忽略的细节:用户翻到第 5 页后改了筛选条件必须重置到第 1 页,否则会落到空页或看到错乱的数据。
六、实践决策与其他技巧
★ 决策树:
需要分页吗?
├─ 是后台批处理 → ★不分页,用 iterator(chunk_size=2000)★
└─ 是给人看的列表
├─ ★数据量 < 10 万,要跳页★ → ★Paginator(原样用)★
├─ 数据量大但要跳页(后台) → Paginator + ★估算 count + 限制最大页码★
└─ ★不需要跳页(API/信息流)★ → ★键集分页 / CursorPagination★
★ 其他实用技巧:
① ★search + filter 优于深翻页★
与其让用户翻到第 200 页,不如给好用的筛选和搜索
→ ★产品设计层面的解法往往比技术优化更有效★
② ★导出用异步任务★
"导出全部" ≠ 翻 1000 页;用 Celery 后台生成文件
③ ★缓存热门页★
第 1~3 页占了 90% 的访问 → 缓存这几页的结果
④ ★物化/汇总表★
排行榜之类的固定视图,预计算成小表再分页
⑤ ★分区表★
按时间分区后,带时间条件的分页只扫一个分区
★ 排查一个"列表页很慢"的顺序:
① 打开 ★django-debug-toolbar★ 或 connection.queries
② 看是不是 ★N+1★(select_related / prefetch_related)
③ 看 ★COUNT 花了多久★
④ 看 ★OFFSET 是不是很大★
⑤ ★EXPLAIN★ 确认走了索引、没有 Sort、没有大量回表
⑥ 看返回的字段是不是过多(only/defer/values)
★ 一个对照实验(1000 万行):
┌────────────────────────────────────┬──────────┐
│ Paginator 第 1 页(含 COUNT) │ ~1200ms │
│ Paginator 第 1 页(估算 count) │ ★~5ms★ │
│ Paginator 第 5000 页(OFFSET 10 万) │ ~1400ms │
│ 延迟关联优化后的第 5000 页 │ ~250ms │
│ ★键集分页(任意位置)★ │ ★~3ms★ │
└────────────────────────────────────┴──────────┘
★ 数字说明一切:★COUNT 和 OFFSET 是两个独立的问题,要分别解决★
★ 一句话总结:
★"深分页慢的根源是 OFFSET 要『取出来再丢掉』、COUNT 要扫全表;
键集分页用『WHERE 上次的位置』把两者一起消灭,
代价是不能跳页 —— 所以 API 和信息流用键集,后台表格用 OFFSET。"★
决策树很清晰:后台批处理根本不该分页(用 iterator(chunk_size));数据量小且要跳页就原样用 Paginator;数据量大但要跳页(后台)用 Paginator 加估算 count 加限制最大页码;不需要跳页的 API 和信息流用键集分页。除了技术手段,产品设计层面的解法往往更有效:与其优化「翻到第 200 页」,不如提供好用的搜索和筛选;「导出全部」应该走异步任务而不是翻 1000 页;第 1~3 页占了 90% 的访问,缓存它们性价比最高。排查「列表页慢」的顺序是:先看 N+1、再看 COUNT 耗时、再看 OFFSET 大小、最后 EXPLAIN 确认走了索引没有 Sort。那张 1000 万行的对照表说明了最关键的一点:COUNT 和 OFFSET 是两个独立的问题,要分别解决——只优化其中一个,另一个仍会拖慢整个页面。
记忆钩子:「深分页慢的根源是★OFFSET 不是『跳过』而是『取出来再丢掉』★——LIMIT 20 OFFSET 200000 必须真读前 200020 行再丢弃 20 万行,★耗时 O(offset) 与页码线性增长★(1000 万行表上 OFFSET 100 万要 2 秒,键集分页恒定 1 毫秒)。★破除误解:加索引救不了深分页★——索引解决的是『排序成本』不是『偏移成本』,有索引也要沿着索引一个个数过去。列表页其实有★两个独立的性能问题★,要分别解决:★① COUNT(*)★——MySQL InnoDB 因 MVCC ★没有行数元信息必须扫索引数★(千万行几秒),而 Paginator 的 count/num_pages/get_page 都会立刻触发它;解法是★估算(PG 的 pg_class.reltuples / MySQL 的 SHOW TABLE STATUS,毫秒级、误差±10%,展示成『约120万条』)★、★不显示总数(多取一条判断 has_next,也是 Google 的做法)★、缓存、或★上限 COUNT(最多数到 10000)★。★② OFFSET★——解法是★键集/游标分页:WHERE id < 上一页最后一个 id ORDER BY id DESC LIMIT 20★,B+树 O(log n) 直接定位,★耗时与页码无关★。键集分页的★关键要求是排序键唯一且索引匹配★:不能只用 created(会重复导致跳过/重复),要 ★ORDER BY created DESC, id DESC 用 id 打破并列★,条件写成元组比较 ★Q(created__lt=c) | Q(created=c, id__lt=i)★,索引也要建成 (-created, -id) 且 EXPLAIN 里看到 Index Scan 而非 Sort。★它还有个比性能更重要的优点:数据变动时不重不漏★(偏移分页在有人插入新数据时第3页会重复第2页末尾的内容)。★代价是不能跳页、不能显示总页数★。所以:★API/无限滚动/增量同步/千万级大表用键集(DRF 的 CursorPagination,ordering 要选区分度高的字段,绝不能用只有几个取值的 status)★,★后台管理表格老老实实用 OFFSET★(但要★估算 count + 限制最大页码★防爬虫 ?page=999999 打爆数据库)。中间方案是★延迟关联★:子查询只取 id 走覆盖索引再 JOIN 回来,快几倍但仍是 O(offset)。★后台批处理根本不该分页,用 iterator(chunk_size=2000)★。」
七、常见误区与追问
- 误区:给排序字段加了索引,深分页就不慢了。 索引解决的是「排序成本」,不是「偏移成本」。加索引后数据库确实不用再做一次昂贵的
Sort(可以沿着索引的有序性直接读),但它仍然必须沿着索引一行一行数过去才知道第 200001 行在哪——OFFSET的语义就是「取出来再丢掉」。所以LIMIT 20 OFFSET 1000000无论有没有索引都要扫过 100 万个条目,耗时依然是 O(offset)。索引真正能帮上忙的地方是键集分页:WHERE id < 9820可以用 B+ 树在 O(log n) 内直接定位到那个位置,然后顺序读 20 行——这才是把「数过去」变成了「跳过去」。顺带一提,如果要SELECT *且按二级索引排序,深分页还会叠加回表放大(10 万次随机 IO),这时「延迟关联」(子查询只取 id 走覆盖索引,再 JOIN 回主表)能显著缓解,但改变不了 O(offset) 的本质。 - 误区:偏移分页只是慢一点,功能上是正确的。 它在数据会变动时会重复或漏数据,这是正确性问题而不是性能问题。设想用户正在看第 2 页(第 21~40 条,按创建时间倒序),此时有人发布了 5 条新文章插到列表最前面——用户点「下一页」拿到的第 3 页(
OFFSET 40)实际上从原来的第 36 条开始,于是第 2 页末尾的 5 条会重复出现。反过来如果有 5 条被删除,那么就会永久漏掉 5 条(用户根本不知道自己错过了什么)。对于「增量同步数据」这类场景,漏数据是不可接受的 bug。键集分页因为锚定在具体的记录位置上(WHERE id < 上次最后一个 id),完全没有这个问题——这往往比性能更重要,也是 GraphQL/Relay 的 Connection 规范采用游标的根本原因。 - 误区:
Paginator会自动做好一切,直接传 QuerySet 就行。 有两个坑。① 必须显式order_by:没有ORDER BY时数据库不保证多次查询的行顺序一致(PostgreSQL 尤其明显,行的物理位置会因 UPDATE 而变化),结果是同一条记录可能在第 1 页和第 3 页都出现,也可能一次都不出现——Django 会发出UnorderedObjectListWarning提醒。而且排序键要确定:order_by("-created")在created有重复值时顺序仍不稳定,应该加id兜底(order_by("-created", "-id"))。② 用get_page()而不是page():page()遇到非整数页码抛PageNotAnInteger、超范围抛EmptyPage,你得自己 try/except;get_page()会自动把非法值归为第 1 页、超范围归为最后一页,视图代码干净得多。 - 误区:总数必须精确显示,否则用户会觉得网站有问题。 实际上用户几乎不在意精确的总数——Google 搜索显示的「约 1,230,000 条结果」就是估算值,从来没人投诉过。而精确的
COUNT(*)在千万行表上要花 1~5 秒,常常占了整个列表页耗时的一半以上。四种更好的做法:① 估算——PostgreSQL 查pg_class.reltuples、MySQL 用SHOW TABLE STATUS的Rows(InnoDB 本来就是估算值),毫秒级返回、误差约 ±10%,展示成「约 120 万条」;② 上限 COUNT——qs.values("pk")[:10001].count(),超过就显示「10000+」,把最坏情况锁死;③ 缓存——总数变化不频繁时缓存几分钟;④ 干脆不显示——改成「上一页/下一页」或「加载更多」,多取一条判断有没有下一页即可。注意估算只对无过滤条件的查询有效,带WHERE时还是得实际数。 - 误区:既然键集分页更快,那所有列表都应该改成键集分页。 键集分页有实打实的功能损失:不能跳到第 N 页(没有「第 100 页」的概念,只能一页页翻)、不能显示总页数、排序字段必须能构成唯一序且有匹配的索引、换一种排序方式就要换一套游标格式、不能往回跳很多页。对于后台管理系统的表格,运营人员经常需要「跳到最后一页看最早的数据」「输入页码直接跳转」,强行改成游标分页是用技术优化损害了产品可用性——而且后台的数据量通常也不至于慢到不可接受。正确的选择标准是看交互形态:移动端信息流、无限滚动、API 增量拉取本来就没有跳页需求 → 键集分页;需要页码导航的后台表格 → 偏移分页(配合估算 count 和最大页码限制就够了)。
- 追问:键集分页在按非唯一字段排序时具体该怎么写? 核心是把「单值比较」升级成**「元组比较」。假设按
ORDER BY created DESC, id DESC,游标记录了上一页最后一条的(created=C, id=I),那么下一页的条件应该是「created严格小于 C」或者「created等于 C 但id小于 I」:PostgreSQL 支持行值比较,可以直接写WHERE (created, id) < (%s, %s)——最简洁,而且优化器能很好地利用复合索引;通用写法(MySQL、Django ORM)是Q(created__lt=C) | Q(created=C, id__lt=I)。两个配套要求:① 索引必须是(created DESC, id DESC)(列顺序和方向都要和ORDER BY一致),否则数据库还得排序,键集分页的优势就没了——用EXPLAIN确认看到的是Index Scan/Backward Index Scan而不是Sort;② 游标要编码两个值(建议 base64 包一层,既不暴露内部主键,以后改排序字段也不破坏 API 形态)。往回翻页的技巧是把比较方向和ORDER BY同时反过来,取到结果后再reversed()回来**。 - 追问:DRF 的
CursorPagination是怎么处理排序字段重复的? 它的游标里编码了三样东西:position(排序字段的值)、offset(在相同 position 内的偏移量)、reverse(方向)。查询时先用position做范围过滤定位到大致位置,再用offset在「排序值完全相同的那一批记录」里做小范围跳过——所以ordering字段不必绝对唯一,这比手写键集分页宽容一些。但代价是:当重复值很多时,offset会变得很大,那一段就退化成了小范围的 OFFSET 扫描。极端情况下如果你设ordering = "-status"(只有 3 个取值),游标里的 offset 可能达到几十万,性能会彻底崩掉。所以实践准则是:ordering必须选区分度高的字段(-created、-id,或组合),并且保证它上面有索引。另外注意CursorPagination的响应里没有count字段(这正是它快的原因之一),前端需要相应调整。 - 追问:如果要「导出全部数据」,该用分页循环吗? 不该。用分页循环导出有三个问题:① 深分页越到后面越慢(导出 100 万行要翻 5 万页,后面的页每页都要扫几十万行,总复杂度接近 O(n²));② 数据变动时会重复或漏行(导出过程持续几分钟,期间的插入删除会打乱偏移);③ 占用 Web 进程(HTTP 请求通常有 30~60 秒超时,导出必然超时)。正确做法是:① 用
iterator(chunk_size=2000)流式读取——它不把结果缓存进 QuerySet,在 PostgreSQL 上还会使用服务端游标,内存恒定;② 放到 Celery 之类的异步任务里执行,生成文件后通过邮件或站内通知给出下载链接;③ 只取需要的列(values_list或only)大幅省内存;④ 如果必须分批,用键集分页而不是偏移分页(WHERE id > 上次最大 id ORDER BY id LIMIT 5000),这样既恒定耗时又不重不漏。
八、加强记忆
深分页慢的根源是「OFFSET 不是跳过,而是取出来再丢掉」——LIMIT 20 OFFSET 200000 必须真的读取前 200020 行再丢弃前 20 万行,耗时 O(offset) 随页码线性增长(1000 万行的表上 OFFSET 100 万 要 2 秒,而键集分页恒定 1 毫秒)。这里要破除一个常见误解:加索引救不了深分页——索引解决的是「排序成本」而不是「偏移成本」,有索引也一样要沿着索引一个一个数过去。列表页其实有两个独立的性能问题,必须分别解决。① COUNT(*):MySQL InnoDB 因为 MVCC 没有行数元信息,必须扫索引数一遍(千万行要几秒),而 Paginator 的 count/num_pages/get_page() 都会立刻触发它;解法是估算(PG 查 pg_class.reltuples、MySQL 用 SHOW TABLE STATUS,毫秒级、误差 ±10%,展示成「约 120 万条」)、不显示总数(多取一条判断 has_next,也是 Google 的做法)、缓存、或上限 COUNT(最多数到 10000)。② OFFSET:解法是键集/游标分页——WHERE id < 上一页最后一个 id ORDER BY id DESC LIMIT 20,B+ 树 O(log n) 直接定位,耗时与页码无关。键集分页的关键要求是排序键唯一且索引匹配:不能只用 created(重复值会导致整批跳过或重复),要 ORDER BY created DESC, id DESC 用 id 打破并列,条件写成元组比较 Q(created__lt=c) | Q(created=c, id__lt=i)(PostgreSQL 可以直接 WHERE (created, id) < (%s, %s)),索引也要建成 (-created, -id),并用 EXPLAIN 确认是 Index Scan 而不是 Sort。它还有个比性能更重要的优点:数据变动时不重不漏——偏移分页在有人插入新数据时,第 3 页会重复第 2 页末尾的内容,删除则会永久漏行。代价是不能跳页、不能显示总页数。所以选择标准看交互形态:API、无限滚动、增量同步、千万级大表用键集分页(DRF 的 CursorPagination,ordering 要选区分度高的字段,绝不能用只有几个取值的 status);后台管理表格老老实实用 OFFSET,但要配上估算 count + 限制最大页码(防止爬虫用 ?page=999999 打爆数据库)。中间方案是延迟关联(子查询只取 id 走覆盖索引再 JOIN 回来,快几倍但仍是 O(offset))。最后记住:后台批处理和数据导出根本不该分页,用 iterator(chunk_size=2000) 加异步任务。