一、问题本质:为什么深度分页会慢?
1.1 传统分页的执行过程
-- 查询第10000页,每页10条
SELECT * FROM orders
ORDER BY create_time DESC
LIMIT 100000, 10;
MySQL执行流程:
-
扫描索引树,找到前100010条记录
-
丢弃前100000条,只保留最后10条
-
如果查询字段不在索引中,还需回表100010次
时间复杂度: O(N + M),其中N是偏移量,M是页大小。当N达到百万级时,性能急剧下降。
二、游标分页:核心解决方案
2.1 核心思想
用"最后一条数据的位置"替代"第N页的偏移量",将时间复杂度降为O(logN + M)。
-- 传统方式(慢)
LIMIT 100000, 10
-- 游标方式(快)
WHERE create_time < '2024-01-01 12:00:00'
OR (create_time = '2024-01-01 12:00:00' AND id < 10086)
LIMIT 10
2.2 为什么需要两个值?
反例:只用时间字段的问题
// 假设两条记录时间相同:
// 记录A: id=100, create_time=2024-01-01 12:00:00
// 记录B: id=101, create_time=2024-01-01 12:00:00
// 第一页返回 A, B,游标记录 lastTime=12:00:00
// 第二页查询:WHERE create_time < '12:00:00'
// 结果:A和B都被过滤,数据丢失!
正解:联合定位
WHERE (create_time < '2024-01-01 12:00:00')
OR (create_time = '2024-01-01 12:00:00' AND id < 101)
三、完整Java实现
3.1 游标对象设计
@Data
public class CursorPage<T> {
private List<T> records; // 当前页数据
private String nextCursor; // 下一页游标(Base64编码)
private boolean hasNext; // 是否有下一页
private Integer pageSize; // 每页大小
}
/**
* 游标内部结构
* 支持多字段排序,segments按排序优先级排列,最后一项必须是唯一键
*/
@Data
public class CursorPayload {
private List<SortSegment> segments;
@Data
public static class SortSegment {
private String field; // 字段名:create_time / amount / id
private Object value; // 最后一条该字段的值
private String order; // ASC/DESC
}
}
3.2 游标编解码工具
@Component
public class CursorUtil {
private final ObjectMapper mapper = new ObjectMapper();
/**
* 编码:将游标对象转为前端传递的字符串
*/
public String encode(CursorPayload payload) {
try {
String json = mapper.writeValueAsString(payload);
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(json.getBytes(StandardCharsets.UTF_8));
} catch (Exception e) {
throw new RuntimeException("游标编码失败", e);
}
}
/**
* 解码:将前端传来的字符串转为游标对象
*/
public CursorPayload decode(String cursor) {
if (StrUtil.isBlank(cursor)) return null;
try {
String json = new String(
Base64.getUrlDecoder().decode(cursor),
StandardCharsets.UTF_8
);
return mapper.readValue(json, CursorPayload.class);
} catch (Exception e) {
throw new IllegalArgumentException("非法的游标格式");
}
}
}
3.3 核心查询服务
@Service
public class CursorPaginationService<T> {
@Autowired
private CursorUtil cursorUtil;
/**
* 通用游标分页查询
*
* @param baseWrapper 基础查询条件(不含排序和分页)
* @param sortRules 排序规则,最后一项必须是唯一键(如id)
* @param cursor 前端传来的游标,首屏为null
* @param pageSize 每页大小
*/
public CursorPage<T> query(
AbstractWrapper<T, ?, ?> baseWrapper,
List<SortRule> sortRules,
String cursor,
int pageSize
) {
// 参数校验
Assert.notEmpty(sortRules, "排序规则不能为空");
Assert.isTrue(pageSize > 0 && pageSize <= 100, "页大小必须在1-100之间");
// 1. 解析游标
CursorPayload payload = cursorUtil.decode(cursor);
List<SortSegment> segments = convertToSegments(payload, sortRules);
// 2. 构建查询条件
AbstractWrapper<T, ?, ?> wrapper = baseWrapper.clone();
// 3. 附加游标过滤条件(核心逻辑)
if (!segments.isEmpty()) {
applyCursorCondition(wrapper, segments);
}
// 4. 应用排序
sortRules.forEach(rule ->
wrapper.orderBy(true, rule.isAsc(), rule.getColumn())
);
// 5. 查询(多取一条用于判断是否还有下一页)
List<T> records = baseMapper.selectList(
wrapper.last("LIMIT " + (pageSize + 1))
);
// 6. 封装返回结果
return buildPage(records, sortRules, pageSize);
}
/**
* 构建游标过滤条件
* 生成:(field1 < val1) OR (field1 = val1 AND field2 < val2) OR ...
*/
private void applyCursorCondition(
AbstractWrapper<T, ?, ?> wrapper,
List<SortSegment> segments
) {
wrapper.and(w -> {
for (int i = 0; i < segments.size(); i++) {
if (i > 0) w.or();
// 前面所有字段的相等条件
for (int j = 0; j < i; j++) {
SortSegment prev = segments.get(j);
applyEquals(w, prev.getColumn(), prev.getValue());
}
// 当前字段的比较条件
SortSegment curr = segments.get(i);
if ("ASC".equalsIgnoreCase(curr.getOrder())) {
w.gt(curr.getColumn(), curr.getValue());
} else {
w.lt(curr.getColumn(), curr.getValue());
}
}
});
}
private CursorPage<T> buildPage(
List<T> records,
List<SortRule> sortRules,
int pageSize
) {
CursorPage<T> result = new CursorPage<>();
result.setPageSize(pageSize);
result.setHasNext(records.size() > pageSize);
// 有下一页时,去掉多取的那条
if (result.isHasNext()) {
records = records.subList(0, pageSize);
}
result.setRecords(records);
// 生成下一页游标
if (result.isHasNext() && !records.isEmpty()) {
T lastRecord = records.get(records.size() - 1);
CursorPayload nextPayload = buildPayload(lastRecord, sortRules);
result.setNextCursor(cursorUtil.encode(nextPayload));
}
return result;
}
/**
* 从最后一条记录提取游标值
*/
private CursorPayload buildPayload(T record, List<SortRule> sortRules) {
CursorPayload payload = new CursorPayload();
List<SortSegment> segments = new ArrayList<>();
for (SortRule rule : sortRules) {
SortSegment segment = new SortSegment();
segment.setField(rule.getField());
segment.setOrder(rule.getOrder());
segment.setValue(ReflectUtil.getFieldValue(record, rule.getField()));
segments.add(segment);
}
payload.setSegments(segments);
return payload;
}
}
3.4 业务层使用示例
@Service
public class OrderService {
@Autowired
private CursorPaginationService<Order> paginationService;
public CursorPage<Order> queryOrderList(OrderQueryParam param, String cursor, int size) {
// 1. 构建基础查询条件
LambdaQueryWrapper<Order> wrapper = Wrappers.lambdaQuery();
wrapper.eq(param.getStatus() != null, Order::getStatus, param.getStatus())
.ge(param.getStartTime() != null, Order::getCreateTime, param.getStartTime());
// 2. 定义排序规则(注意:最后一项必须是唯一键)
List<SortRule> sortRules = Arrays.asList(
new SortRule("createTime", "DESC"), // 主排序:时间倒序
new SortRule("id", "DESC") // 副排序:ID倒序(确保唯一性)
);
// 3. 执行游标分页查询
return paginationService.query(wrapper, sortRules, cursor, size);
}
}
3.5 Controller层
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping("/list")
public CursorPage<Order> list(
@Valid OrderQueryParam param,
@RequestParam(required = false) String cursor, // 游标,首屏不传
@RequestParam(defaultValue = "20") @Max(100) int size) {
return orderService.queryOrderList(param, cursor, size);
}
}
四、前端集成方案
流程图解
交互设计
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 前端 │ ──① 首次请求 ──→ │ 后端 │ ──② 查询数据库 ──→ │ 数据库 │
│ (无游标) │ │ cursor=null │ │ LIMIT 21 │
└─────────────┘ └─────────────┘ └─────────────┘
↑ │
└──────── 返回10条数据 ────────────┘
↓
┌─────────────┐
│ 封装nextCursor│ ← 提取最后一条数据的
│ (Base64编码) │ create_time + id
└─────────────┘
↓
┌─────────────┐ ┌─────────────┐
│ 前端 │ ←─③ 返回数据+游标 ───│ 后端 │
│ 保存游标 │ │ │
└─────────────┘ └─────────────┘
│
↓ 用户滚动/点击"加载更多"
│
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 前端 │ ──④ 传回游标 ───→ │ 后端 │ ──⑤ 解析游标 ───→ │ 生成WHERE │
│ nextCursor │ │ 解码得时间+id │ WHERE time < ? │ 条件查询 │
└─────────────┘ └─────────────┘ AND id < ? └─────────────┘
4.1 无限滚动实现(Vue3示例)
<template>
<div class="order-list" ref="listRef">
<div v-for="order in list" :key="order.id" class="order-item">
{{ order.orderNo }} - {{ order.amount }}
</div>
<div v-if="loading" class="status">加载中...</div>
<div v-else-if="!hasMore" class="status">没有更多了</div>
<div v-else-if="error" class="status error" @click="loadMore">加载失败,点击重试</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'
const list = ref([])
const nextCursor = ref(null)
const hasMore = ref(true)
const loading = ref(false)
const error = ref(false)
// 加载数据
const loadMore = async () => {
if (loading.value || !hasMore.value) return
loading.value = true
error.value = false
try {
const params = new URLSearchParams({ size: 20 })
if (nextCursor.value) params.append('cursor', nextCursor.value)
const res = await fetch(`/api/orders/list?${params}`)
const data = await res.json()
list.value.push(...data.records)
nextCursor.value = data.nextCursor
hasMore.value = data.hasNext
} catch (e) {
error.value = true
} finally {
loading.value = false
}
}
// 首屏加载
onMounted(() => loadMore())
// 无限滚动监听
const { stop } = useIntersectionObserver(
document.querySelector('.status'),
([{ isIntersecting }]) => {
if (isIntersecting) loadMore()
}
)
onUnmounted(() => stop())
</script>
4.2 传统分页改造(保留上下页)
// 用栈保存历史游标,支持上一页
const cursorStack = ref([])
const loadNext = async () => {
// 保存当前游标到栈
cursorStack.value.push(nextCursor.value)
await fetchData(nextCursor.value)
}
const loadPrev = async () => {
// 弹出当前游标,回到上一个
cursorStack.value.pop()
const prevCursor = cursorStack.value[cursorStack.value.length - 1]
await fetchData(prevCursor)
}
五、数据库优化要点
5.1 索引设计(关键)
-- 必须建立复合索引,字段顺序与ORDER BY一致
CREATE INDEX idx_orders_time_id ON orders(create_time DESC, id DESC);
-- 如果带查询条件,将条件字段放前面
CREATE INDEX idx_orders_status_time_id
ON orders(status, create_time DESC, id DESC);
5.2 执行计划验证
EXPLAIN SELECT * FROM orders
WHERE (create_time < '2024-01-01' OR (create_time = '2024-01-01' AND id < 100))
ORDER BY create_time DESC, id DESC
LIMIT 10;
-- 确保Extra列不出现"Using filesort",type为"range"或"index"
六、高级场景与避坑指南
6.1 游标失效处理
@ExceptionHandler
public Result handleCursorExpired(CursorExpiredException e) {
// 游标指向的数据被删除或修改
return Result.fail("DATA_CHANGED", "数据已更新,请刷新页面重试");
}
6.2 多排序规则切换
// 用户切换排序方式时,必须重置游标
@GetMapping("/list")
public CursorPage<Order> list(
@RequestParam String sortBy, // createTime/amount/status
@RequestParam(required = false) String cursor,
@RequestParam(required = false) String prevSortBy) { // 上次排序方式
// 排序方式改变,游标失效
if (!sortBy.equals(prevSortBy)) {
cursor = null;
}
return orderService.query(sortBy, cursor);
}
6.3 分库分表场景
// ShardingSphere下,强制路由避免全分片扫描
HintManager hintManager = HintManager.getInstance();
hintManager.addDatabaseShardingValue("orders", userId % 8);
hintManager.addTableShardingValue("orders", createTime.getMonth());
// 确保游标中的分片键与查询一致
6.4 与ES/ClickHouse的协作
表格
| 场景 | 方案 |
|---|---|
| 简单列表 | MySQL游标分页 |
| 复杂筛选(多字段OR) | Elasticsearch search_after |
| 海量数据报表 | ClickHouse预计算宽表 |
| 实时+历史数据 | 双写异构,路由分发 |
七、方案对比与选型
表格
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 游标分页 | 性能稳定O(logN),无深度分页问题 | 无法跳页,需唯一键 | 信息流、时间线、消息列表 |
| 延迟关联 | 不改表结构,减少回表 | 仍有OFFSET开销 | 临时优化,浅分页场景 |
| ES/搜索引擎 | 支持复杂筛选,水平扩展 | 一致性延迟,运维成本 | 电商搜索、多维度筛选 |
| 宽表预计算 | 查询极快 | 实时性差,存储冗余 | 报表、BI分析 |
| 业务限制 | 零技术成本 | 产品体验受限 | 后台管理,用户可接受 |
八、总结
游标分页是解决深度分页性能问题的黄金标准,其核心在于:
-
用位置替代偏移:通过最后一条数据的排序字段值定位,避免扫描无效数据
-
联合唯一键:必须结合唯一ID处理排序字段重复的情况
-
前后端协作:前端传递游标,后端解析并生成下一页游标
-
索引是前提 :复合索引
(sortField, id)是性能保障
生产 checklist:
-
\] 表必须有唯一键(或联合主键)
-
\] 排序字段值尽量不变(或接受数据跳跃)
-
\] 限制最大页大小(建议≤100)