深分页的四种解法,以及为什么说它多半是个伪需求

一条 LIMIT 1000000, 20 拖垮数据库:深分页的四种解法,以及为什么说它多半是个伪需求

慢 SQL 告警响了,点开一看:SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20,执行 8 秒。表也有索引,SQL 也没写错,为什么翻到第 5 万页就把库拖垮了?这篇文章讲清楚深分页慢的真正原因、四种解法各自的适用边界(游标分页给出从 SQL、后端到前端可直接照抄的完整实现 ),以及一个更扎心的问题:用户真的在翻第 5 万页吗?

一、先搞清楚:LIMIT 1000000, 20 到底慢在哪

很多人以为 LIMIT offset, size 是"跳过 offset 条,取 size 条",跳过总该很快吧?错在"跳过"两个字上------MySQL 没有跳过这个能力,它是老老实实取出前 1000020 条,然后扔掉前 100 万条。

具体到执行过程,以二级索引 idx_create_time 为例:

  1. idx_create_time 索引树上顺序扫描 1000020 个索引项;
  2. 每个索引项只有 create_time 和主键 id,SELECT * 需要的其他字段没有------于是每一条都要拿着主键回表去聚簇索引查整行,这是 100 万次随机 IO;
  3. 100 万行完整数据取出来后,丢掉前 100 万条,返回 20 条。

慢的核心是回表 100 万次,其次是白扫 100 万行。更糟的是,当优化器发现回表太多,可能直接放弃索引改走全表扫描 + filesort,雪上加霜。

用 EXPLAIN 验证一下,rows 列会诚实地告诉你它打算扫多少行------offset 越大,这条 SQL 的成本随页码线性增长,翻得越深越慢,这就是"深分页"问题。

二、解法一:游标分页(Keyset Pagination)------从 SQL 到前端的完整闭环

思路转换:不告诉数据库"跳过多少条",而是告诉它"从哪条之后开始"。前端每次翻页把上一页最后一条的排序键带回来。它性能是天花板(后面细讲),但要真正落地,SQL、接口契约、前端三层都得配合,下面给一套可以直接抄走跑起来的完整实现。

2.1 SQL 层:起点定位代替偏移扫描

sql 复制代码
-- 第一页
SELECT * FROM orders ORDER BY id DESC LIMIT 20;

-- 下一页:带上上一页最后一条的 id = 8964213
SELECT * FROM orders WHERE id < 8964213 ORDER BY id DESC LIMIT 20;

WHERE id < ? 直接在索引树上定位起点,扫 20 行就结束------无论翻到第几页,成本恒定,第 1 页和第 100 万页一样快。

一个必须注意的细节:排序键必须唯一 ,否则翻页会丢数据。按 create_time 排序时,同一秒可能有多条记录,游标切在中间就漏了。解法是加 id 做二级排序:

sql 复制代码
-- 需要联合索引:ALTER TABLE orders ADD INDEX idx_ct_id(create_time, id);
SELECT * FROM orders
WHERE create_time < '2026-08-24 10:00:00'
   OR (create_time = '2026-08-24 10:00:00' AND id < 8964213)
ORDER BY create_time DESC, id DESC
LIMIT 20;

也可以写成行值比较 (create_time, id) < (?, ?),更简洁,但 MySQL 部分版本对行值比较的索引利用不佳,上面展开写法的索引命中最稳,下文代码统一用展开写法。

2.2 接口契约:游标要做成"不透明 token"

接口契约是闭环的关键。很多团队第一版直接让前端传 lastId + lastCreateTime 两个参数,能跑,但有两个隐患:排序规则一改前端全得跟着改;字段裸奔在 URL 里,用户能篡改。正确姿势是把游标编码成不透明字符串(opaque cursor),前端只负责原样带回,不理解、不拼接、不修改:

arduino 复制代码
GET /api/orders?cursor=eyJ0IjoiMjAyNi0wOC0yNCAxMDowMDowMCIsImlkIjo4OTY0MjEzfQ&size=20

响应:
{
  "code": 0,
  "data": {
    "list": [ ... 20 条订单 ... ],
    "nextCursor": "eyJ0IjoiMjAyNi0wOC0yNCAwOTo1OToxMiIsImlkIjo4OTY0MTg3fQ",
    "hasMore": true
  }
}

约定三条:首页不传 cursor;下一页传上次响应的 nextCursor;hasMore 为 false 就停。

2.3 后端完整实现(Spring Boot + MyBatis)

一共五个类,从下往上给全。

① 游标对象 Cursor------就是排序键的快照:

java 复制代码
public class Cursor {

    /** 上一页最后一条的 create_time */
    private LocalDateTime t;
    /** 上一页最后一条的 id,同一时刻多条记录时用它切分 */
    private Long id;

    public Cursor() {}

    public Cursor(LocalDateTime t, Long id) {
        this.t = t;
        this.id = id;
    }

    public LocalDateTime getT() { return t; }
    public void setT(LocalDateTime t) { this.t = t; }
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
}

② 编解码器 CursorCodec------JSON 序列化后走 URL 安全的 Base64,前端拿到的就是一段无意义字符串:

java 复制代码
public class CursorCodec {

    private static final ObjectMapper MAPPER = new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

    /** 生成游标:入参是本页最后一条记录 */
    public static String encode(Order last) {
        try {
            Cursor c = new Cursor(last.getCreateTime(), last.getId());
            byte[] json = MAPPER.writeValueAsBytes(c);
            return Base64.getUrlEncoder().withoutPadding().encodeToString(json);
        } catch (JsonProcessingException e) {
            throw new IllegalStateException("cursor encode failed", e);
        }
    }

    /** 解析游标:首页(null/空串)返回 null;非法游标按参数错误处理 */
    public static Cursor decode(String cursor) {
        if (cursor == null || cursor.isBlank()) {
            return null;
        }
        try {
            byte[] json = Base64.getUrlDecoder().decode(cursor);
            return MAPPER.readValue(json, Cursor.class);
        } catch (Exception e) {
            throw new BizException(ErrorCode.INVALID_CURSOR);
        }
    }
}

③ 通用返回结构 CursorPage<T>------所有游标分页接口共用:

java 复制代码
public class CursorPage<T> {

    private List<T> list;
    private String nextCursor;  // hasMore=false 时为 null
    private boolean hasMore;

    public static <T> CursorPage<T> of(List<T> list, String nextCursor, boolean hasMore) {
        CursorPage<T> page = new CursorPage<>();
        page.list = list;
        page.nextCursor = nextCursor;
        page.hasMore = hasMore;
        return page;
    }

    public List<T> getList() { return list; }
    public String getNextCursor() { return nextCursor; }
    public boolean isHasMore() { return hasMore; }
}

④ Mapper------接口加 XML,游标为 null 走首页分支:

java 复制代码
@Mapper
public interface OrderMapper {
    List<Order> listByCursor(@Param("c") Cursor cursor, @Param("limit") int limit);
}
xml 复制代码
<select id="listByCursor" resultType="com.demo.entity.Order">
    SELECT id, order_no, user_id, amount, status, create_time
    FROM orders
    <where>
        <if test="c != null">
            create_time &lt; #{c.t}
            OR (create_time = #{c.t} AND id &lt; #{c.id})
        </if>
    </where>
    ORDER BY create_time DESC, id DESC
    LIMIT #{limit}
</select>

⑤ Service + Controller ------size + 1 判断 hasMore,避免 COUNT:

java 复制代码
@Service
public class OrderQueryService {

    @Resource
    private OrderMapper orderMapper;

    public CursorPage<OrderVO> listOrders(String cursor, int size) {
        Cursor c = CursorCodec.decode(cursor);           // 首页为 null

        // 多查一条,用于判断后面还有没有,省掉一次 COUNT(*)
        List<Order> rows = orderMapper.listByCursor(c, size + 1);

        boolean hasMore = rows.size() > size;
        if (hasMore) {
            rows = rows.subList(0, size);
        }

        String next = hasMore
                ? CursorCodec.encode(rows.get(rows.size() - 1))
                : null;

        List<OrderVO> voList = rows.stream().map(OrderVO::from).toList();
        return CursorPage.of(voList, next, hasMore);
    }
}
java 复制代码
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    @Resource
    private OrderQueryService orderQueryService;

    @GetMapping
    public Result<CursorPage<OrderVO>> list(
            @RequestParam(required = false) String cursor,
            @RequestParam(defaultValue = "20") @Max(100) int size) {
        return Result.ok(orderQueryService.listOrders(cursor, size));
    }
}

Order 实体、OrderVOResult 是各项目都有的常规代码,按你们自己的写法来即可;size 记得加上限,防止一次拉几万条。)

三个设计要点:

  • size + 1 判断 hasMore :多取一条就知道后面还有没有,深分页场景下 COUNT(*) 本身就是慢查询,能省则省;
  • 游标无状态:内容就是排序键快照的 Base64,不占服务端存储,天然支持多端并发翻页;在意篡改就在编码时追加一段 HMAC 签名、解码时校验;
  • 排序规则变更只改后端:游标怎么编解码前端毫不知情,这就是"不透明"的价值。

2.4 前端层:无限滚动 + 三个必做的状态控制

前端拿到的心智模型很简单:一个 nextCursor 一路传下去。以最常见的无限滚动为例:

javascript 复制代码
const state = {
  list: [],
  cursor: null,      // 首页为 null
  hasMore: true,
  loading: false,    // 防重复请求
};

async function loadMore() {
  // ① 没有更多、或上一次请求还没回来,直接不发
  if (!state.hasMore || state.loading) return;
  state.loading = true;
  try {
    const { data } = await fetch(
      `/api/orders?size=20${state.cursor ? `&cursor=${encodeURIComponent(state.cursor)}` : ''}`
    ).then(r => r.json());

    state.list.push(...data.list);   // ② 追加而不是替换
    state.cursor = data.nextCursor;  // ③ 只保存,不解析
    state.hasMore = data.hasMore;
  } finally {
    state.loading = false;
  }
}

// 触底加载(IntersectionObserver 监听列表底部的哨兵元素)
new IntersectionObserver(
  entries => entries[0].isIntersecting && loadMore(),
  { rootMargin: '200px' }   // 提前 200px 预加载,滚动不断流
).observe(document.querySelector('#sentinel'));

// 下拉刷新:三个状态一起重置,回到首页
function refresh() {
  state.list = [];
  state.cursor = null;
  state.hasMore = true;
  return loadMore();
}

对应的 HTML 骨架只需要一个列表容器和一个哨兵元素:

html 复制代码
<ul id="order-list"><!-- 渲染 state.list --></ul>
<div id="sentinel"></div>  <!-- 进入视口就触发 loadMore -->

前端必须做对的三件事,少一件就会出线上问题:

  1. loading 锁防重复请求:快速滚动会连续触发触底,不加锁就会用同一个 cursor 发两次请求,列表出现重复数据;
  2. 刷新必须重置 cursor:下拉刷新只清列表不清 cursor,用户会从中间某页开始看,还以为丢数据了;
  3. cursor 只存不解析:任何"从 cursor 里抠 id"的代码都是给未来埋雷,后端一改编码就崩。

另外两个加分项:游标分页天然对插入友好------传统 offset 分页在翻页间隙有新数据插入时会整体后移导致重复/漏读,游标分页锚定的是"某条数据之后",新插入的数据不会挤乱后面的页;列表项用稳定 key(业务 id)渲染,即使偶发重复也不至于闪屏。

2.5 这套闭环的边界

它天然不支持跳页 ------你只有"下一页",没法直接跳到第 500 页,因为你不知道第 500 页的游标是什么。这不是实现缺陷,是原理决定的。所以它最适合:信息流、App 无限下拉、订单/消息列表------这些场景用户本来就只会连续翻页,而这恰好覆盖了 C 端绝大多数列表。

三、解法二:延迟关联------必须支持跳页时的次优解

后台管理系统经常真的需要"跳到第 N 页",游标方案用不了。这时用延迟关联(deferred join):先在覆盖索引里把 20 个主键找出来(不回表),再用这 20 个主键去取整行:

sql 复制代码
SELECT o.*
FROM orders o
INNER JOIN (
    SELECT id FROM orders
    ORDER BY create_time DESC
    LIMIT 1000000, 20
) tmp ON o.id = tmp.id;

子查询只扫 (create_time, id) 索引本身,索引项小、顺序 IO、零回表,100 万条扫得飞快;回表从 100 万次降到 20 次。实测千万级表上,8 秒的查询能降到几百毫秒。

它没有改变"扫描量随页码线性增长"的本质,只是把最贵的回表干掉了------所以是"次优解":能接受、不完美,适合必须跳页的后台场景。

四、解法三:产品层面掐掉深分页------大厂的真实选择

翻一下你每天在用的产品:

  • Google / 百度搜索结果,最多只让你翻几十页;
  • 淘宝商品列表,翻到 100 页就到底了;
  • Elasticsearch 干脆内置了 max_result_window = 10000,from + size 超过一万条直接报错

这不是他们不会优化,而是他们想明白了一件事:没有正常用户会一页页翻到第 5 万页。 会翻到那么深的,要么是爬虫,要么是想要全量数据的人(见解法四)。所以最省钱的方案是产品经理在需求评审时把"支持任意跳页"划掉,限制最大页数 + 引导用户用筛选条件缩小范围。

顺带说 ES:需要深翻的场景用 search_after(原理就是游标分页),全量遍历用 PIT + search_after,别硬调 max_result_window------那是把 OOM 风险调大的开关。

五、解法四:识破伪装------"翻到底"的用户其实想要导出

一个真实规律:凡是执着于翻到很深页码的用户,真实诉求几乎都是"我想要全部数据"------对账、审计、做报表。让他一页页翻是双输:他翻得手酸,你的库被深分页查询打穿。

正确姿势是把这个需求显式化:提供异步导出 。用户点导出,后台任务用游标分页(WHERE id > ? LIMIT 1000)批量捞数据写文件,完了通知下载。库的压力平稳可控,用户拿到的还是完整数据,体验反而更好。

六、决策表

场景 推荐方案 成本随页码增长?
信息流 / App 下拉 / 只需上下页 游标分页(keyset),接口用不透明 cursor 否,恒定
后台系统必须跳页 延迟关联 + 限制最大页数 线性,但斜率小得多
搜索类产品 限制页数 + 筛选引导(ES 用 search_after) ---
用户想要全量数据 异步导出任务(内部走游标批量取)

一句话总结:

深分页优化的最高境界,是让深分页请求根本不发生。 技术上,游标分页把 O(offset) 变成 O(1)------但它是一个前后端契约,SQL 换写法、接口给不透明 cursor、前端管好 loading/cursor/hasMore 三个状态,闭环才算成立;延迟关联把回表砍到只剩一页。而更重要的是产品上先问一句------用户到底是想看第 5 万页,还是想要全部数据?前者限制页数,后者给导出。SQL 调优解决的是症状,需求澄清解决的才是病根。

最后

去你们的慢 SQL 平台搜一下 LIMIT 后面跟着六位数的查询,看看它们来自哪个页面、真实用户是谁------大概率你会发现,要么是爬虫,要么是一个本该做成导出功能的列表页。

你们项目里深分页是怎么处理的?有没有被 LIMIT 100万 打穿过?评论区聊聊。

相关推荐
苏三说技术5 小时前
为什么越来越多人用 OpenSearch?
后端
大白805 小时前
JS 内存泄漏排查:用真实案例教你如何用 Chrome DevTools 定位并解决,顺带搞懂闭包和 GC
后端
泡海椒5 小时前
Lambda 极简调用:不定义接口直接执行 curl 命令,JQuick-Curl 让 Java HTTP 调用更短更快
后端
步行cgn5 小时前
Maven 中:作为父项目和作为依赖的本质区别
后端
65岁退休Coder7 小时前
LangGraph v1.2.9 节点容错策略 & 流式输出 & 持久化记忆管理
后端·python·langchain
元界metalite9 小时前
SpringBoot整合RocketMQ-毒丸消息还要无限重试吗
后端
SimonKing9 小时前
升级Spring Boot 4后,从 Jackson 2 到 3,到底有哪些变化
java·后端·程序员
YIAN9 小时前
Docker + Nginx 核心原理扫盲:从环境隔离到反向代理,运维面试必考点
后端·docker·面试
万物智能9 小时前
启动链路与分区—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
后端·架构