--- title: "08-自适应瀑布流下拉加载游标查询" created: 2025-12-02 tags: - 项目 aliases: - 自适应瀑布流下拉加载游标查询 --- # 自适应瀑布流下拉加载游标查询 ## 游标查询 游标查询是一种数据库查询方式,它允许逐行处理查询结果集,而不是一次性将所有数据加载到内存中。这种方式特别适合处理大数据量的场景,能够有效减少内存占用和提升性能。 游标查询的特点 逐行处理:游标可以像指针一样指向结果集中的某一行,支持逐行读取或操作。 分批加载:通过游标,可以按需加载数据,避免一次性加载过多数据导致性能问题。 状态保持:游标会维护当前的位置,方便后续继续读取未处理的数据。 游标查询的应用场景 大数据量分页:传统分页(如 LIMIT 和 OFFSET)在数据量较大时性能较差,因为每次查询都需要跳过前面的记录。而游标查询可以通过保存上一次查询的状态,直接从上次结束的地方继续加载。 动态加载:如瀑布流、无限滚动等场景,用户下拉加载更多内容时,后端需要返回与之前不重复的数据。 游标查询的工作原理 假设我们有一个图片表 images,字段包括 id 和 created\_at,我们需要实现一个瀑布流加载功能: 初始请求: 用户首次访问时,后端查询最新的几条数据(如前 10 条)。 返回这些数据的同时,记录最后一条数据的关键信息(如 id 或 created\_at),作为游标的起点。 后续请求: 用户下拉加载更多时,前端将上次返回的游标值(如最后一条数据的 id 或 created\_at)传递给后端。 后端根据游标值,查询比该值更旧的数据,并返回新的结果集。 ```sql -- 初始请求:获取最新的 10 条数据 SELECT id, created_at, url FROM images ORDER BY created_at DESC LIMIT 10; -- 后续请求:根据游标值加载更多数据 SELECT id, created_at, url FROM images WHERE created_at < '上次返回的最后一条数据的时间' ORDER BY created_at DESC LIMIT 10; ``` 游标查询的优点 高效性:避免了传统分页中使用 OFFSET 导致的性能下降问题。 无重复数据:通过游标值确保每次加载的数据不会重复。 灵活性:适用于动态加载、实时更新等场景。 游标查询的注意事项 唯一性:游标字段(如 id 或 created\_at)必须是唯一的,或者结合其他字段确保唯一性。 排序稳定性:查询时需要明确的排序规则(如 ORDER BY),否则可能导致数据顺序混乱。 并发问题:如果数据频繁更新,可能会出现游标失效的情况,需要设计合理的锁机制或版本控制。 ## 后端 本质是添加了一个id>cursor 或 id tags; /** * 搜索关键字 */ @Size(max = 30, message = "搜索关键字长度不能超过30") private String searchText; /** * 游标ID */ private String cursor; /** * 每页数量 */ @NotNull(message = "pageSize 不能为空") @Min(value = 1, message = "pageSize 至少为 1") @Max(value = 20, message = "pageSize 最大为 20") private int pageSize = 12; /** * 页面数据(每次多查一条数据 以判断是否是最后一页) */ private Page page = new Page(1, pageSize + 1); /** * 排序字段 */ @NotNull(message = "sortField 不能为空") @Pattern(regexp = "^(id)$", message = "sortField字段值不合法,允许的值为 id") private String sortField; /** * 排序方式 */ @NotNull(message = "sortOrder 不能为空") @Pattern(regexp = "^(ascend|descend)$", message = "sortOrder字段值不合法,允许的值为 ascend|descend") private String sortOrder; private static final long serialVersionUID = 1L; } ``` ### controller ```java /** * 游标查询获取图片列表(供用户使用) */ @PostMapping("/listByCursor") @PreAuthorizeRole("USER") public BaseResponse listPicturesByCursor( @RequestBody @Valid PictureCursorRequest pictureCursorRequest) { PictureCursorResponse pictureCursorResponse = pictureService.listPicturesByCursor(pictureCursorRequest); return ResultUtils.success(pictureCursorResponse); } ``` ### service ```java /** * 获取图片列表 * * @param pictureCursorRequest 图片查询请求 * @return 图片列表 */ PictureCursorResponse listPicturesByCursor(@Valid PictureCursorRequest pictureCursorRequest); ``` ### serviceimpl cursor记录当前返回的最后一个数据id 下次查询传入该id 查询大于该id的数据 `queryWrapper.gt("id", cursor);` 如果记录下一数据id 查询大于等于该id的数据 `queryWrapper.ge("id", cursor);` 会导致循环链表无限循环的bug 如果已经是最后一个数据 那么下个位置是0 再调用又从头开始了 简单方法是用一个isLast字段记录是否为最后一个数据 在前端请求前判断是否需要进行请求 ```java private void isContainsTags(QueryWrapper queryWrapper, List tags2) { List tags = tags2; if (CollUtil.isNotEmpty(tags)) { for (String tag : tags) { if (!isValidTag(tag)) { throw new IllegalArgumentException("Invalid tag: " + tag); } } queryWrapper.and(qw -> { for (String tag : tags) { String jsonArrayStr = JSONUtil.toJsonStr(Collections.singletonList(tag)); qw.or().apply("JSON_CONTAINS(tags, {0})", jsonArrayStr); } }); } } @Override public PictureCursorResponse listPicturesByCursor(PictureCursorRequest pictureCursorRequest) { QueryWrapper queryWrapper = new QueryWrapper<>(); // 处理基本查询条件 queryWrapper.eq(StrUtil.isNotBlank(pictureCursorRequest.getCategory()), "category", pictureCursorRequest.getCategory()); String searchText = pictureCursorRequest.getSearchText(); if (StrUtil.isNotBlank(searchText)) { queryWrapper.and(qw -> qw.like("name", searchText).or().like("introduction", searchText)); } isContainsTags(queryWrapper, pictureCursorRequest.getTags()); // 处理游标条件 String cursor = pictureCursorRequest.getCursor(); if (cursor == null || cursor.isEmpty()) { cursor = "0"; // 初始游标设为0,兼容升序/降序 } boolean isAsc = "ascend".equals(pictureCursorRequest.getSortOrder()); // 根据排序方向选择gt或lt if (isAsc) { queryWrapper.gt("id", cursor); } else { queryWrapper.lt("id", cursor); } // 设置排序 queryWrapper.orderByAsc("id"); if (!isAsc) { queryWrapper.orderByDesc("id"); } // 分页设置(pageSize + 1) int pageSize = pictureCursorRequest.getPageSize(); Page pagePlus = new Page<>(1, pageSize + 1); Page page = this.page(pagePlus, queryWrapper); // 判断是否最后一页 boolean isLast = page.getRecords().size() <= pageSize; // 提取数据 List pictures = page.getRecords(); if (!isLast) { pictures.remove(pictures.size() - 1); // 去除多查的一条 } // 计算新游标 String newCursor = isLast ? "0" : String.valueOf(pictures.get(pictures.size() - 1).getId()); // 用户信息预加载 Set userIdSet = pictures.stream() .map(Picture::getUserId) .filter(Objects::nonNull) .collect(Collectors.toSet()); Map userMap = userIdSet.isEmpty() ? Collections.emptyMap() : userService.listByIds(userIdSet).stream() .collect(Collectors.toMap(User::getId, user -> user)); // 转换为VO List pictureVOs = pictures.stream() .map(p -> { PictureVO vo = PictureVO.fromPicture(p); User user = userMap.get(p.getUserId()); vo.setUser(user != null ? userService.convertToLoginUserVO(user) : null); return vo; }) .collect(Collectors.toList()); // 构建响应 return buildResponse(newCursor, isLast, pictureVOs); } private PictureCursorResponse buildResponse(String newCursor, boolean isLast, List pictureVOs) { PictureCursorResponse response = new PictureCursorResponse(); response.setCursor(newCursor); response.setRecords(pictureVOs); response.setLast(isLast); return response; } ``` ### vo ```java package com.zwnsyw.yunpicturebackend.model.vo; import lombok.Data; import java.util.List; @Data public class PictureCursorResponse { /** * 游标 */ private String cursor; /** * 是否为最后一个数据 */ private boolean isLast = false; /** * 图片列表 */ private List records; } ``` ### 遗留问题: 默认cursor为0 实际只能应对升序的情况 如果是降序 cursor应该初始化为什么?不太好确定 ## 前端 使用组件 实现瀑布流 瀑布流原理参考大佬的文章 ```typescript ``` ### 遗留问题: 瀑布流本质是 宽度由页面计算大小 决定展示多少列 每列的宽度其实是固定的 而错落有致的效果实际上是因为 ![[image-b0d4561c.png]] 介绍文本的长度(一行或两行)决定每个卡片高度不一致 多个不一致之后 导致多个列之间不是死板的整齐划一 给人带来错落有致的美感 列宽固定:每列的宽度是固定的,但列的数量会根据屏幕宽度动态调整。 高度自适应:每个图片的高度根据其内容自动调整,导致不同列的高度不一致。 填充算法:当新元素加入时,选择当前高度最短的那一列进行填充,从而形成错落有致的效果。 很难实现这种既保持宽度一致 又不缩放宽图 见缝插针布局的效果 ![[image-1114aadf.png]] 另外 当前实现版本为 图片显示完全后 点击加载 而非触底自动加载 后续再实现 #### done ```javascript ``` --- **项目分区导航**:⬅️ [[07-图片模块|07-图片模块]] | 08-自适应瀑布流下拉加载游标查询 | ➡️ [[09-async|09-async]]