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