mysql深度分页问题解决-结合django分析

8 阅读5分钟

MySQL 深度分页(Deep Pagination)指在数据量巨大时(如百万、千万级),使用 LIMIT offset, count 查询靠后的页码(如第 10000 页),性能会急剧下降甚至出现查询超时。

一、问题本质:为什么深度分页慢?

SELECT * FROM orders 
ORDER BY created_at DESC 
LIMIT 1000000, 20;

MySQL 执行流程:

  1. 全表扫描/索引扫描:按 created_at 排序,扫描 1,000,020
  2. 回表操作:通过主键回表取完整数据(如果是二级索引)
  3. 丢弃数据:丢弃前 1,000,000 行,只保留最后 20 行

随着 offset 增大,扫描的数据量线性增长,IO 和 CPU 开销飙升。


二、解决方案对比

方案时间复杂度适用场景缺点
游标分页O(limit)无限滚动、下一页/上一页无法跳页、无法显示总页数
延迟关联O(offset+limit)必须跳页但 offset < 10万仍有性能开销、实现稍复杂
覆盖索引O(offset+limit)仅需索引字段数据不全,需二次查询
业务限制-所有列表页牺牲用户体验

三、具体实现方案

方案 1:游标分页(Cursor / Keyset Pagination)⭐ 推荐

利用上一页的最后一条记录作为下一页的查询条件,避免扫描 offset 行。

SQL 实现:

-- 第一页
SELECT * FROM orders 
ORDER BY id DESC 
LIMIT 20;

-- 获取最后一条 id 为 9980,查询下一页
SELECT * FROM orders 
WHERE id < 9980  -- 利用索引直接定位
ORDER BY id DESC 
LIMIT 20;

Django ORM 实现:

# views.py
def order_list(request):
    cursor = request.GET.get('cursor')  # 上一页最后一条的 ID
    
    queryset = Order.objects.all().order_by('-id')
    
    if cursor:
        queryset = queryset.filter(id__lt=cursor)
    
    # 多字段游标(如按时间+ID)
    # if cursor:
    #     last_time, last_id = cursor.split(',')
    #     queryset = queryset.filter(
    #         Q(created_at__lt=last_time) | 
    #         Q(created_at=last_time, id__lt=last_id)
    #     )
    
    orders = queryset[:21]  # 多查1条用于判断是否还有下一页
    has_next = len(orders) > 20
    orders = list(orders[:20])
    
    next_cursor = orders[-1].id if orders and has_next else None
    
    return JsonResponse({
        'data': [{'id': o.id, ...} for o in orders],
        'next_cursor': next_cursor,
        'has_next': has_next
    })

优点: 性能恒定,无论查第 1 页还是第 100 万页,都只需扫描 20 行。 缺点: 不支持跳转到指定页码(如直接跳到第 50 页)。


方案 2:延迟关联(Deferred Join)

先通过覆盖索引查询主键 ID,再通过 ID 关联取完整数据,减少回表次数。

SQL 实现:

-- 传统方式(慢):需要回表 1000020 次
SELECT * FROM orders 
ORDER BY created_at DESC 
LIMIT 1000000, 20;

-- 优化方式:先查 ID(覆盖索引,不回表),再 JOIN
SELECT o.* 
FROM orders o
INNER JOIN (
    SELECT id FROM orders 
    ORDER BY created_at DESC 
    LIMIT 1000000, 20
) tmp ON o.id = tmp.id;

执行计划对比:

  • 传统:扫描 1000020 行,回表 1000020 次
  • 优化:子查询扫描 1000020 行(仅用索引),回表 仅 20 次

Django ORM 实现:

# 方式 1:使用子查询(Django 1.11+)
from django.db.models import Subquery

ids = Order.objects.order_by('-created_at').values('id')[1000000:1000020]
orders = Order.objects.filter(id__in=Subquery(ids))  # 注意:这里无序,需再排序

# 方式 2:两次查询(更推荐,避免 IN 性能问题)
ids = list(Order.objects.order_by('-created_at').values_list('id', flat=True)[1000000:1000020])
orders = Order.objects.filter(id__in=ids).order_by('-created_at')

方案 3:覆盖索引优化

如果列表页只需要展示索引中的字段(如只显示订单号和金额),根本不需要回表。

-- 假设索引为 idx_created_at_id(created_at, id, order_no, amount)
SELECT id, order_no, amount FROM orders 
ORDER BY created_at DESC 
LIMIT 1000000, 20;

性能: 扫描 1000020 行,但全部在内存中完成,无需回表,速度提升 10-100 倍。


方案 4:业务层限制(最实用)

绝大多数业务场景中,用户极少翻到 100 页以后。

策略:

  1. 限制最大页码:只允许查看前 100 页(Google 搜索也是如此)
    if page > 100:
        raise ValidationError("只能查看前 100 页,请使用筛选条件缩小范围")
    
  2. 提供筛选条件:让用户通过时间范围、状态等条件过滤,而非翻页

方案 5:数据归档(终极方案)

对于超大数据量(如日志、历史订单):

  • 冷热分离:热数据(近 3 个月)在 MySQL,冷数据归档到 ClickHouseElasticsearch
  • 分表:按时间分表,查询时路由到对应表

四、性能测试数据对比

假设表 orders 有 1000 万条数据:

页码传统 LIMIT延迟关联游标分页
第 1 页10ms10ms1ms
第 1000 页50ms30ms1ms
第 10000 页500ms200ms1ms
第 100000 页5s+2s1ms

五、Django 完整示例:游标分页封装

from django.db import models
from django.http import JsonResponse
from functools import wraps

class CursorPaginator:
    """游标分页装饰器"""
    def __init__(self, ordering='-id', page_size=20):
        self.ordering = ordering
        self.page_size = page_size
    
    def __call__(self, view_func):
        @wraps(view_func)
        def wrapper(request, *args, **kwargs):
            # 获取游标(上一页最后一条记录的排序字段值)
            cursor = request.GET.get('cursor')
            
            # 获取基础 QuerySet(由 view_func 提供)
            queryset = view_func(request, *args, **kwargs)
            
            # 应用排序
            if self.ordering.startswith('-'):
                field = self.ordering[1:]
                order_by = '-' + field
                filter_cond = {f'{field}__lt': cursor} if cursor else {}
            else:
                order_by = self.ordering
                filter_cond = {f'{self.ordering}__gt': cursor} if cursor else {}
            
            queryset = queryset.order_by(order_by)
            if cursor:
                queryset = queryset.filter(**filter_cond)
            
            # 多查一条判断是否有下一页
            items = list(queryset[:self.page_size + 1])
            has_next = len(items) > self.page_size
            items = items[:self.page_size]
            
            return {
                'items': items,
                'next_cursor': getattr(items[-1], field if self.ordering.startswith('-') else self.ordering) if items and has_next else None,
                'has_next': has_next,
                'has_prev': bool(cursor)
            }
        return wrapper

# 使用示例
@CursorPaginator(ordering='-created_at', page_size=20)
def get_orders(request):
    return Order.objects.filter(status=request.GET.get('status', 'paid'))

六、总结建议

  1. 首选项游标分页(无限滚动场景),性能最优,实现简单
  2. 次选项:如果必须支持跳页,使用延迟关联 + 限制最大页码(如最多 100 页)
  3. 架构层面:大数据量列表考虑引入 ElasticsearchClickHouse,MySQL 专注事务处理
  4. 索引检查:确保 ORDER BY 字段有索引,最好是覆盖索引

对于 Django 项目,推荐使用 django-cursor-pagination 库,它封装了游标分页的复杂逻辑(处理多字段排序、边界情况等)。