--- id: backend.mysql.scenario.deep-pagination title: 大表深分页和历史数据:游标分页、延迟关联、归档 type: scenario domain: backend stack: [mysql] tags: [深分页, 游标分页, 延迟关联, 归档, 大表] level: intermediate prerequisites: [backend.mysql.index-explain] est_hours: 2 related: [backend.mysql.ops-scale, backend.mysql.interview] week: [2, 14] --- # 大表深分页和历史数据:游标分页、延迟关联、归档 ## 业务背景与问题 一个 SaaS 后台的“操作记录 / 订单列表”页面,单表已积累数千万行。用户反馈:前几页很快,翻到很后面或者导出时接口超时;同时这张表的慢查询日志里大部分是 `LIMIT 大数, 20`。 ## 约束 - 列表需要按时间倒序展示,支持按用户、状态筛选。 - 产品希望保留“跳到第 N 页”,但能接受在超过一定页数后改成“加载更多”。 - 导出需要导出全部符合条件的数据。 - 不希望马上引入分库分表或搜索引擎。 ## 方案设计 1. **找原因**:`LIMIT 199000, 20` 需要先按索引顺序找到 199020 行(并回表),再丢掉前 199000 行,越往后越慢。 2. **游标分页(主方案)**:前端传上一页最后一条的 `(created_at, id)`,查询 `WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20`,配合 `(user_id, created_at, id)` 联合索引,每页都只扫 20 行。适合“加载更多”、无限滚动、App 列表。 3. **延迟关联(保留跳页时用)**:子查询只在覆盖索引上找出这一页的 id,再回表取整行,减少大量回表。仍然要扫描 offset 行,只是更便宜;同时限制最大页数(如 100 页)。 4. **导出**:用游标分页按批读取,后台异步生成文件,不在请求里同步完成。 5. **归档**:超过一定时间(如 1 年)的数据移到历史表或对象存储,主表只保留热数据;历史查询走单独的入口。 ## 关键代码 完整可运行对比见 [examples/deep-pagination/paginate.py](../examples/deep-pagination/paginate.py): ```sql -- 游标分页:时间相同的行用 id 区分,保证不重不漏 SELECT id, created_at, note FROM orders WHERE user_id = 1 AND (created_at < ? OR (created_at = ? AND id < ?)) ORDER BY created_at DESC, id DESC LIMIT 20; -- 延迟关联:先在覆盖索引上取 id,再回表 SELECT o.* FROM orders o JOIN (SELECT id FROM orders WHERE user_id = 1 ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 199000) t ON o.id = t.id ORDER BY o.created_at DESC, o.id DESC; ``` 在本地 10 万~20 万行的测试数据上,深页的游标分页比 `OFFSET` 快一到两个数量级(具体数字与机器有关,以你自己运行的结果为准)。 ## 取舍 | 方案 | 优点 | 缺点 | | --- | --- | --- | | 游标分页 | 每页耗时稳定,适合海量数据 | 不能直接跳到第 N 页;排序字段必须唯一或加 id 兜底 | | 延迟关联 | 保留跳页,改动小 | 仍随 offset 变慢,只是常数更小 | | 限制最大页数 | 简单直接 | 需要产品接受 | | 搜索引擎(ES 等) | 复杂筛选、全文检索 | 多一套系统,要做数据同步(见跨栈场景“数据同步”) | | 归档 | 主表变小,所有查询受益 | 历史数据查询入口要单独设计 | ## 易错点 - 只按 `created_at` 做游标:时间相同的多行会被跳过或重复,必须加 `id` 作为第二排序键。 - 游标条件和 ORDER BY、索引列顺序不一致:用不上索引。 - `SELECT COUNT(*)` 统计总页数在大表上也很慢:可以显示“约 xx 条”,或缓存计数。 - 导出在 HTTP 请求里同步完成:超时、占满连接。改为异步任务。 - 归档用一条大 `DELETE`:长事务、锁、主从延迟。按主键范围分批删除。 ## 面试怎么讲 > “列表接口深分页超时,慢查询日志里都是大 offset。原因是 LIMIT offset 要扫描并丢弃前面所有行。我把 App 和导出改成游标分页:传上一页最后一条的时间和 id,配合联合索引,每页耗时稳定;后台管理保留跳页,用延迟关联并限制最大页数;同时按时间归档一年前的数据,主表变小。” 追问准备:为什么要加 id?总数怎么显示?如果按多个字段排序怎么做游标(把所有排序字段都放进游标)?