基于游标(Cursor)的方式来实现滚动分页
我们将使用基于游标(Cursor)的方式来实现滚动分页,这是现代应用(如微博、Twitter)推荐的做法,因为它能有效避免传统分页在数据增删时出现的重复或遗漏问题。
场景假设
我们有一个FeedItem
(信息流条目)列表,每条数据都有一个唯一且递增的ID(比如数据库自增ID、雪花算法ID或时间戳)。我们将模拟一个API,客户端每次请求带上最后一条记录的ID作为游标,以获取更早的数据。
1. 定义实体类
// 信息流条目实体
@Data
public class FeedItem {private List<?> list;private Long minTime;private Integer offset;
}// API响应封装类
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Result {private Boolean success;private String errorMsg;private Object data;private Long total;public static Result ok(){return new Result(true, null, null, null);}public static Result ok(Object data){return new Result(true, null, data, null);}public static Result ok(List<?> data, Long total){return new Result(true, null, data, total);}public static Result fail(String errorMsg){return new Result(false, errorMsg, null, null);}
}
2. 实现服务层 (Service)
这是核心逻辑。我们模拟一个数据访问层,假设数据已按ID降序排列(最新的在最前面)。
@Service
public class BlogServiceImpl extends ServiceImpl<BlogMapper, Blog> implements IBlogService {@Resourceprivate IUserService userService;@Resourceprivate StringRedisTemplate stringRedisTemplate;@Overridepublic Result queryBlogOfFollow(Long max, Integer offset) {// 1.获取当前用户Long userId = UserHolder.getUser().getId();// 2.查询收件箱String key = "blog:follows:" + userId;Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet().reverseRangeByScoreWithScores(key, 0, max, offset, 2);if (typedTuples == null || typedTuples.isEmpty()){return Result.ok();}// 3.解析收件箱:blogId, max(时间戳), offsetList<Long> ids = new ArrayList<>(typedTuples.size());Long minTime = 0L;int offsetFinal = 1;for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {String blogId = typedTuple.getValue();ids.add(Long.valueOf(blogId));long time = typedTuple.getScore().longValue();if (minTime == time){offsetFinal++;} else {minTime = time;offsetFinal = 1;}}// 4.查询笔记String idStr = StrUtil.join(",", ids);List<Blog> blogs = query().in("id", ids).last("order by field(id, " + idStr + ")").list();for (Blog blog : blogs) {// 4.1.查询blog有关的用户blog.setIcon(userService.getById(blog.getUserId()).getIcon());blog.setName(userService.getById(blog.getUserId()).getNickName());// 4.2.查询blog是否被点赞isBlogLiked(blog);}// 5.返回FeedItem feedItem = new FeedItem();feedItem.setList(blogs);feedItem.setOffset(offsetFinal);feedItem.setMinTime(minTime);return Result.ok(feedItem);}/*** 判断当前用户是否点赞* @param blog*/private void isBlogLiked(Blog blog) {// 1.获取登录用户Long userId = UserHolder.getUser().getId();if (userId == null) {// 用户未登录,无需查询是否点赞return;}// 2.判断当前登录用户是否已点赞String key = "blog:liked:" + blog.getId();//Boolean isMember = stringRedisTemplate.opsForSet().isMember(key, userId.toString());Double score = stringRedisTemplate.opsForZSet().score(key, userId.toString());blog.setIsLike(score!= null);}
核心逻辑解释:
filter(item -> item.getId() < cursor)
:这是实现滚动分页的灵魂。因为数据是降序排列(100, 99, 98…),我们要获取更早(更旧) 的数据,所以需要取ID小于当前游标的值。- 首次请求(
cursor = null
)直接取最前面的数据。 nextCursor
是本次返回列表的最后一条记录的ID。客户端下次请求时带上它,就能接着这个位置继续获取。
3. 实现控制器 (Controller)
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/blog")
public class BlogController {@Resourceprivate IBlogService blogService;/*** 查询当前用户所关注的用户所发布的博客*/@GetMapping("/of/follow")public Result queryBlogOfFollow(@RequestParam("lastId") Long max, @RequestParam(value = "offset", defaultValue = "0") Integer offset) {return blogService.queryBlogOfFollow(max, offset);}
}
4. 客户端调用示例
第一次请求:
GET /of/follow?&offset=1&lastId=1757685550066
- 响应:
第二次请求(使用第一次返回的 nextCursor
):
GET of/follow?&lastId=1757690145555
- 响应:
总结与关键点
- 游标选择:必须使用唯一、有序(通常递增) 的字段作为游标,如自增ID、创建时间戳。
- 排序方向:数据需要按游标字段降序(DESC) 排列,最新的在最前。
- 查询条件:下次查询的条件是
WHERE cursor_field < ?last_cursor? ORDER BY cursor_field DESC LIMIT ?limit?
。 - 优点:即使在第一页和第二页请求之间,有新的数据插入(ID=101),也不会影响你获取第二页的数据,因为你的查询条件是
ID < 98
,新插入的ID=101的数据不会出现在你的后续请求中,完美避免了重复和遗漏。 - 结束判断:当返回的列表为空,或返回的
nextCursor
为null
,或返回的项目数小于limit
时,客户端应停止请求,显示“已无更多内容”。