面试复习:游标是什么?什么是深度分页?如何用游标解决深度分页?(以 InnoDB 为例)


面试复习:游标是什么?什么是深度分页?如何用游标解决深度分页?(以 InnoDB 为例)

在数据库开发和优化中,游标(Cursor)和深度分页(Deep Pagination)是两个常见但容易让人混淆的概念,尤其在面试中常被提及。今天我们将深入探讨这两个概念的定义,如何用游标解决深度分页问题(以 MySQL 的 InnoDB 存储引擎为例),并结合 Spring Boot + MyBatis 的实际业务场景,展示具体的实现方案。

一、游标(Cursor)是什么?

游标就像一个"指针",用于在数据库查询结果集中逐步定位和读取数据。它在不同场景下有不同含义:

  1. 数据库内部的游标

    在关系型数据库(如 MySQL)中,执行 SELECT 查询时,数据库返回一个结果集,游标是内部用于跟踪读取位置的机制。

  2. 应用层面的游标

    在开发中,游标通常指分页查询中的标记(Marker),如基于 idcreated_at 的值,用于定位下一页数据的起点。

本文聚焦应用层面的游标,尤其在分页场景中的应用。


二、什么是深度分页?

深度分页是指分页查询中,当用户请求很靠后的页面(如第 1000 页)时,数据库需处理大量数据的问题。传统分页使用 LIMITOFFSET,例如:

sql 复制代码
SELECT * FROM users ORDER BY id ASC LIMIT 10 OFFSET 10000;

这表示跳过前 10,000 条记录,取第 10 条(第 1001 页,每页 10 条)。

深度分页的问题

在 InnoDB 中,这种方式在大数据量下性能低下:

  1. 全表扫描:InnoDB 需扫描前 10,010 条记录,丢弃 10,000 条。
  2. 索引效率下降 :即使有索引,OFFSET 越大,定位成本越高。
  3. 资源开销:频繁的 I/O 和内存占用拖慢查询。

这就是"深度分页问题":页数越深,性能越差。


三、如何用游标解决深度分页?

游标分页(Cursor-based Pagination)通过记录上一次查询的结束位置(游标),基于唯一有序字段(如 id)查询下一页数据,避免扫描无关记录。

实现步骤

users 表为例,按 id 升序分页,每页 10 条:

  1. 第一页

    sql 复制代码
    SELECT * FROM users ORDER BY id ASC LIMIT 10;

    最后一条 id(如 100)作为游标。

  2. 下一页

    sql 复制代码
    SELECT * FROM users WHERE id > 100 ORDER BY id ASC LIMIT 10;

    返回 101110,新游标为 110

  3. 重复:客户端传递游标,后端生成查询。

优势

  • 高效:利用索引定位,无需扫描前置数据。
  • 稳定:唯一字段保证结果一致性。

四、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_atid 组合)。
  • 返回 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);
    }
}

执行流程

  1. 首次请求GET /api/posts?userId=1,无 cursor,返回最新 20 条动态,nextCursor"2025-03-29 10:00:00_100"
  2. 下一页GET /api/posts?userId=1&cursor=2025-03-29 10:00:00_100,返回更早的 20 条。

优势

  • 高效 :利用 idx_user_created 索引,查询复杂度为 O(log N),不受页数影响。
  • 灵活 :支持复合排序(created_atid)。
  • 无 PageHelper 依赖 :手动实现游标逻辑,避开 OFFSET 瓶颈。

六、实际开发中的分页插件与深页问题

MyBatis 的 PageHelper 通过拦截 Executor 拼接 LIMITOFFSET,但不解决深页问题。例如:

java 复制代码
PageHelper.startPage(1001, 20);
List<Post> posts = postMapper.selectAll();

生成的 SQL 仍是 OFFSET 模式,深页性能依然低下。因此,在需要优化深页的场景中,手动实现游标分页更优。


七、总结

  • 游标:分页查询的定位标记。
  • 深度分页OFFSET 在大数据量下的性能瓶颈。
  • 游标解决深页:利用索引和唯一字段,避免扫描无关数据。
  • 实践:Spring Boot + MyBatis 可通过游标实现高效分页,适合动态列表等场景。
相关推荐
codingandsleeping5 小时前
浏览器的缓存机制
前端·后端
追逐时光者5 小时前
面试官问:你知道 C# 单例模式有哪几种常用的实现方式?
后端·.net
Asthenia04126 小时前
Numpy:数组生成/modf/sum/输出格式规则
后端
Asthenia04126 小时前
NumPy:数组加法/数组比较/数组重塑/数组切片
后端
Asthenia04126 小时前
Numpy:limspace/arange/数组基本属性分析
后端
Asthenia04126 小时前
Java中线程暂停的分析与JVM和Linux的协作流程
后端
Asthenia04126 小时前
Seata TCC 模式:RootContext与TCC专属的BusinessActionContext与TCC注解详解
后端
自珍JAVA6 小时前
【代码】zip压缩文件密码暴力破解
后端
今夜有雨.7 小时前
HTTP---基础知识
服务器·网络·后端·网络协议·学习·tcp/ip·http
Asthenia04127 小时前
Seata TCC 模式的空回滚与悬挂问题之解决方案-结合时序分析
后端