← 返回题目列表

Django 分页在大数据量下为什么会变慢?游标分页怎么做?

中等 第 25 / 27 题 更新于 2026/08/01
Django分页Paginator游标分页深分页

简化版

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
SQLLIMIT 20 OFFSET 200000WHERE id < ? LIMIT 20LIMIT 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.countnum_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 同样要检查每行的可见性。而 Paginatorcountnum_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 仍应选区分度高的字段(createdid),绝不能用 -status 这种只有几个取值的字段。选择上:移动端信息流和大表用 CursorPagination、后台管理 API 用 PageNumberPaginationGraphQL/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 STATUSRows(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_listonly)大幅省内存;④ 如果必须分批,用键集分页而不是偏移分页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 没有行数元信息,必须扫索引数一遍(千万行要几秒),而 Paginatorcount/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 的 CursorPaginationordering 要选区分度高的字段,绝不能用只有几个取值的 status);后台管理表格老老实实用 OFFSET,但要配上估算 count + 限制最大页码(防止爬虫用 ?page=999999 打爆数据库)。中间方案是延迟关联(子查询只取 id 走覆盖索引再 JOIN 回来,快几倍但仍是 O(offset))。最后记住:后台批处理和数据导出根本不该分页,用 iterator(chunk_size=2000) 加异步任务