Files
zhuangang/topics/backend/mysql/scenarios/deep-pagination.md
T

78 lines
4.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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?总数怎么显示?如果按多个字段排序怎么做游标(把所有排序字段都放进游标)?