78 lines
4.3 KiB
Markdown
78 lines
4.3 KiB
Markdown
---
|
||
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?总数怎么显示?如果按多个字段排序怎么做游标(把所有排序字段都放进游标)?
|