面试复习:游标是什么?什么是深度分页?如何用游标解决深度分页?(以 InnoDB 为例)
在数据库开发和优化中,游标(Cursor)和深度分页(Deep Pagination)是两个常见但容易让人混淆的概念,尤其在面试中常被提及。今天我们将深入探讨这两个概念的定义,如何用游标解决深度分页问题(以 MySQL 的 InnoDB 存储引擎为例),并结合 Spring Boot + MyBatis 的实际业务场景,展示具体的实现方案。
一、游标(Cursor)是什么?
游标就像一个"指针",用于在数据库查询结果集中逐步定位和读取数据。它在不同场景下有不同含义:
-
数据库内部的游标
在关系型数据库(如 MySQL)中,执行
SELECT
查询时,数据库返回一个结果集,游标是内部用于跟踪读取位置的机制。 -
应用层面的游标
在开发中,游标通常指分页查询中的标记(Marker),如基于
id
或created_at
的值,用于定位下一页数据的起点。
本文聚焦应用层面的游标,尤其在分页场景中的应用。
二、什么是深度分页?
深度分页是指分页查询中,当用户请求很靠后的页面(如第 1000 页)时,数据库需处理大量数据的问题。传统分页使用 LIMIT
和 OFFSET
,例如:
sql
SELECT * FROM users ORDER BY id ASC LIMIT 10 OFFSET 10000;
这表示跳过前 10,000 条记录,取第 10 条(第 1001 页,每页 10 条)。
深度分页的问题
在 InnoDB 中,这种方式在大数据量下性能低下:
- 全表扫描:InnoDB 需扫描前 10,010 条记录,丢弃 10,000 条。
- 索引效率下降 :即使有索引,
OFFSET
越大,定位成本越高。 - 资源开销:频繁的 I/O 和内存占用拖慢查询。
这就是"深度分页问题":页数越深,性能越差。
三、如何用游标解决深度分页?
游标分页(Cursor-based Pagination)通过记录上一次查询的结束位置(游标),基于唯一有序字段(如 id
)查询下一页数据,避免扫描无关记录。
实现步骤
以 users
表为例,按 id
升序分页,每页 10 条:
-
第一页 :
sqlSELECT * FROM users ORDER BY id ASC LIMIT 10;
最后一条
id
(如100
)作为游标。 -
下一页 :
sqlSELECT * FROM users WHERE id > 100 ORDER BY id ASC LIMIT 10;
返回
101
到110
,新游标为110
。 -
重复:客户端传递游标,后端生成查询。
优势
- 高效:利用索引定位,无需扫描前置数据。
- 稳定:唯一字段保证结果一致性。
四、InnoDB 中的优化
InnoDB 的 B+ 树索引非常适合游标分页:
- 主键索引:若游标字段是主键,定位效率最高。
- 覆盖索引 :如
INDEX(id, name)
,避免回表。 - 排序优化 :确保
ORDER BY
字段已索引。
优化查询示例:
sql
SELECT id, name FROM users WHERE id > 100 ORDER BY id ASC LIMIT 10;
五、业务场景:Spring Boot + MyBatis 解决深分页
让我们结合一个实际业务场景:假设你开发一个社交平台,用户可以分页查看自己的动态列表(posts
表),按发布时间(created_at
)和 id
降序排列,每页 20 条。随着用户动态越来越多,传统分页(如 PageHelper)在请求第 1000 页时性能显著下降。我们将用游标分页优化。
表结构
sql
CREATE TABLE posts (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
content VARCHAR(255),
created_at TIMESTAMP NOT NULL,
INDEX idx_user_created (user_id, created_at, id)
);
需求
- 用户请求动态列表,传入
cursor
(上次最后一条的created_at
和id
组合)。 - 返回 20 条记录,支持"下一页"。
实现步骤
1. 定义实体和 DTO
java
// Post 实体
public class Post {
private Long id;
private Long userId;
private String content;
private Timestamp createdAt;
// getters 和 setters
}
// 分页请求 DTO
public class PostPageRequest {
private Long userId;
private String cursor; // 格式: "createdAt_id",如 "2025-03-29 10:00:00_100"
private int size = 20;
}
// 分页响应 DTO
public class PostPageResponse {
private List<Post> posts;
private String nextCursor; // 下一页游标
}
2. Mapper 接口
java
@Mapper
public interface PostMapper {
@Select("SELECT id, user_id, content, created_at " +
"FROM posts " +
"WHERE user_id = #{userId} " +
"AND (created_at < #{createdAt} OR (created_at = #{createdAt} AND id < #{id})) " +
"ORDER BY created_at DESC, id DESC " +
"LIMIT #{size}")
List<Post> selectPostsByCursor(@Param("userId") Long userId,
@Param("createdAt") Timestamp createdAt,
@Param("id") Long id,
@Param("size") int size);
}
3. Service 层
java
@Service
public class PostService {
@Autowired
private PostMapper postMapper;
public PostPageResponse getPostPage(PostPageRequest request) {
Long userId = request.getUserId();
int size = request.getSize();
Timestamp createdAt;
Long id;
// 解析游标
if (StringUtils.isEmpty(request.getCursor())) {
createdAt = new Timestamp(System.currentTimeMillis()); // 默认从最新开始
id = Long.MAX_VALUE;
} else {
String[] parts = request.getCursor().split("_");
createdAt = Timestamp.valueOf(parts[0]);
id = Long.parseLong(parts[1]);
}
// 查询
List<Post> posts = postMapper.selectPostsByCursor(userId, createdAt, id, size);
// 生成下一页游标
String nextCursor = null;
if (posts.size() == size) { // 如果返回满页,说明还有下一页
Post lastPost = posts.get(posts.size() - 1);
nextCursor = lastPost.getCreatedAt() + "_" + lastPost.getId();
}
return new PostPageResponse(posts, nextCursor);
}
}
4. Controller 层
java
@RestController
@RequestMapping("/api/posts")
public class PostController {
@Autowired
private PostService postService;
@GetMapping
public PostPageResponse getPosts(@RequestParam Long userId,
@RequestParam(required = false) String cursor) {
PostPageRequest request = new PostPageRequest();
request.setUserId(userId);
request.setCursor(cursor);
return postService.getPostsPage(request);
}
}
执行流程
- 首次请求 :
GET /api/posts?userId=1
,无cursor
,返回最新 20 条动态,nextCursor
如"2025-03-29 10:00:00_100"
。 - 下一页 :
GET /api/posts?userId=1&cursor=2025-03-29 10:00:00_100
,返回更早的 20 条。
优势
- 高效 :利用
idx_user_created
索引,查询复杂度为 O(log N),不受页数影响。 - 灵活 :支持复合排序(
created_at
和id
)。 - 无 PageHelper 依赖 :手动实现游标逻辑,避开
OFFSET
瓶颈。
六、实际开发中的分页插件与深页问题
MyBatis 的 PageHelper 通过拦截 Executor
拼接 LIMIT
和 OFFSET
,但不解决深页问题。例如:
java
PageHelper.startPage(1001, 20);
List<Post> posts = postMapper.selectAll();
生成的 SQL 仍是 OFFSET
模式,深页性能依然低下。因此,在需要优化深页的场景中,手动实现游标分页更优。
七、总结
- 游标:分页查询的定位标记。
- 深度分页 :
OFFSET
在大数据量下的性能瓶颈。 - 游标解决深页:利用索引和唯一字段,避免扫描无关数据。
- 实践:Spring Boot + MyBatis 可通过游标实现高效分页,适合动态列表等场景。