SpringBoot + 分页游标 + 时间戳排序:替代 OFFSET,千万级数据高效翻页实战
大家好,我是服务端技术精选的作者。今天咱们聊聊一个在数据查询中极其常见却又十分头疼的问题:深分页查询。
传统分页的性能陷阱
在我们的日常开发工作中,经常会遇到这样的场景:
- 订单列表翻到第1000页,数据库直接卡死
- 后台管理系统查询用户数据,翻到后面越来越慢
- 移动端下拉刷新,数据量越大加载越慢
- 报表系统分页查询,第一页和最后一页性能差异巨大
传统的OFFSET/LIMIT分页方式在数据量较大时性能急剧下降,这是因为数据库需要扫描前面所有的记录才能定位到目标位置。今天我们就来聊聊如何用游标分页解决这个问题。
为什么OFFSET分页会变慢
让我们先看看传统分页的原理:
-- 传统分页,随着offset增大,性能急剧下降
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
数据库需要:
- 扫描前1000000条记录
- 跳过这1000000条记录
- 返回接下来的20条记录
当offset很大时,扫描和跳过的开销非常大,这就是深分页的性能问题。
游标分页解决方案
游标分页的核心思想是:不使用OFFSET,而是通过上一页的最后一个记录的排序字段值作为游标,查询下一页的数据。
1. 基础游标分页实现
@Data
public class CursorPageRequest {
private String cursor; // 游标值
private Integer size = 20; // 每页大小
private String sortField = "id"; // 排序字段
private String sortOrder = "DESC"; // 排序方向
}
@Data
public class CursorPageResult<T> {
private List<T> data;
private String nextCursor; // 下一页游标
private Boolean hasMore; // 是否还有更多数据
private Long total; // 总数(可选)
}
@Service
public class OrderService {
public CursorPageResult<Order> getOrdersByCursor(CursorPageRequest request) {
// 构建查询条件
QueryWrapper<Order> wrapper = new QueryWrapper<>();
// 添加游标条件
if (request.getCursor() != null) {
if ("DESC".equalsIgnoreCase(request.getSortOrder())) {
wrapper.lt(request.getSortField(), request.getCursor());
} else {
wrapper.gt(request.getSortField(), request.getCursor());
}
}
// 排序
if ("DESC".equalsIgnoreCase(request.getSortOrder())) {
wrapper.orderByDesc(request.getSortField());
} else {
wrapper.orderByAsc(request.getSortField());
}
// 查询(多查一条用于判断是否有更多数据)
List<Order> orders = orderMapper.selectList(wrapper.last("LIMIT " + (request.getSize() + 1)));
boolean hasMore = orders.size() > request.getSize();
if (hasMore) {
orders = orders.subList(0, request.getSize());
}
// 构建结果
CursorPageResult<Order> result = new CursorPageResult<>();
result.setData(orders);
result.setHasMore(hasMore);
if (!orders.isEmpty()) {
Order lastOrder = orders.get(orders.size() - 1);
result.setNextCursor(String.valueOf(getFieldValue(lastOrder, request.getSortField())));
}
return result;
}
private Object getFieldValue(Order order, String fieldName) {
try {
Field field = Order.class.getDeclaredField(fieldName);
field.setAccessible(true);
return field.get(order);
} catch (Exception e) {
throw new RuntimeException("获取字段值失败", e);
}
}
}
2. 时间戳排序优化
对于按时间排序的场景,时间戳游标分页效果最佳:
@Service
public class TimelineService {
public CursorPageResult<TimelineItem> getTimelineItems(CursorPageRequest request) {
QueryWrapper<TimelineItem> wrapper = new QueryWrapper<>();
// 时间戳游标条件
if (request.getCursor() != null) {
Long cursorTime = Long.parseLong(request.getCursor());
wrapper.le("create_time", new Timestamp(cursorTime));
}
// 按时间倒序排列
wrapper.orderByDesc("create_time");
wrapper.orderByDesc("id"); // 防止时间戳相同的情况
// 查询数据
List<TimelineItem> items = timelineMapper.selectList(
wrapper.last("LIMIT " + (request.getSize() + 1)));
boolean hasMore = items.size() > request.getSize();
if (hasMore) {
items = items.subList(0, request.getSize());
}
CursorPageResult<TimelineItem> result = new CursorPageResult<>();
result.setData(items);
result.setHasMore(hasMore);
if (!items.isEmpty()) {
TimelineItem lastItem = items.get(items.size() - 1);
result.setNextCursor(String.valueOf(lastItem.getCreateTime().getTime()));
}
return result;
}
}
复合游标实现
1. 多字段游标
@Data
public class MultiFieldCursor {
private Long timestamp;
private Long id;
public static MultiFieldCursor from(String cursor) {
if (cursor == null) return null;
String[] parts = cursor.split(":");
if (parts.length != 2) return null;
return new MultiFieldCursor(Long.parseLong(parts[0]), Long.parseLong(parts[1]));
}
public String toCursor() {
return timestamp + ":" + id;
}
public MultiFieldCursor(Long timestamp, Long id) {
this.timestamp = timestamp;
this.id = id;
}
}
@Service
public class CompositeCursorService {
public CursorPageResult<Order> getOrdersWithCompositeCursor(CursorPageRequest request) {
QueryWrapper<Order> wrapper = new QueryWrapper<>();
if (request.getCursor() != null) {
MultiFieldCursor cursor = MultiFieldCursor.from(request.getCursor());
if (cursor != null) {
// 先按时间比较,时间相同时按ID比较
wrapper.and(w -> w.lt("create_time", new Timestamp(cursor.getTimestamp()))
.or()
.eq("create_time", new Timestamp(cursor.getTimestamp()))
.lt("id", cursor.getId()));
}
}
wrapper.orderByDesc("create_time").orderByDesc("id");
List<Order> orders = orderMapper.selectList(
wrapper.last("LIMIT " + (request.getSize() + 1)));
boolean hasMore = orders.size() > request.getSize();
if (hasMore) {
orders = orders.subList(0, request.getSize());
}
CursorPageResult<Order> result = new CursorPageResult<>();
result.setData(orders);
result.setHasMore(hasMore);
if (!orders.isEmpty()) {
Order lastOrder = orders.get(orders.size() - 1);
MultiFieldCursor nextCursor = new MultiFieldCursor(
lastOrder.getCreateTime().getTime(),
lastOrder.getId()
);
result.setNextCursor(nextCursor.toCursor());
}
return result;
}
}
性能优化策略
1. 索引优化
-- 为游标字段创建索引
CREATE INDEX idx_orders_create_time_id ON orders(create_time DESC, id DESC);
-- 复合查询索引
CREATE INDEX idx_orders_status_time_id ON orders(status, create_time DESC, id DESC);
2. 缓存优化
@Service
public class CachedCursorService {
@Cacheable(value = "cursor_pages", key = "#request.cursor + '_' + #request.size")
public CursorPageResult<Order> getCachedPage(CursorPageRequest request) {
return getOrdersByCursor(request);
}
@CacheEvict(value = "cursor_pages", allEntries = true)
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
// 订单创建时清除相关缓存
}
}
3. 预加载优化
@Component
public class PrefetchService {
@Async
public void prefetchNextPage(CursorPageRequest currentRequest) {
// 预加载下一页数据到缓存
CursorPageRequest nextRequest = new CursorPageRequest();
nextRequest.setCursor(currentRequest.getNextCursor());
nextRequest.setSize(currentRequest.getSize());
CursorPageResult<Order> nextPage = getOrdersByCursor(nextRequest);
// 缓存下一页数据
cacheService.put("prefetch:" + nextRequest.getCursor(), nextPage);
}
}
前端集成方案
1. 前端分页组件
// Vue.js 示例
export default {
data() {
return {
cursor: null,
hasMore: true,
loading: false,
orders: []
}
},
methods: {
async loadMore() {
if (!this.hasMore || this.loading) return;
this.loading = true;
try {
const response = await this.$http.post('/api/orders/cursor', {
cursor: this.cursor,
size: 20
});
const { data, nextCursor, hasMore } = response.data;
this.orders.push(...data);
this.cursor = nextCursor;
this.hasMore = hasMore;
} catch (error) {
console.error('加载失败:', error);
} finally {
this.loading = false;
}
}
}
}
2. 无限滚动实现
<div class="infinite-scroll-container" v-infinite-scroll="loadMore" :infinite-scroll-disabled="!hasMore">
<div v-for="order in orders" :key="order.id" class="order-item">
<!-- 订单信息 -->
</div>
<div v-if="loading" class="loading">加载中...</div>
<div v-if="!hasMore" class="no-more">没有更多数据</div>
</div>
特殊场景处理
1. 跳页支持
游标分页无法直接跳转到指定页码,但我们可以通过其他方式实现:
@Service
public class JumpPageService {
public CursorPageResult<Order> getPageByNumber(int pageNumber, int pageSize) {
if (pageNumber <= 10) {
// 前10页使用传统分页
return getTraditionalPage(pageNumber, pageSize);
} else {
// 超过10页引导用户使用滑动加载
throw new BusinessException("请使用滑动加载查看更多数据");
}
}
public CursorPageResult<Order> getNearbyPage(Long targetId, int pageSize) {
// 根据ID定位到附近的位置
Order targetOrder = orderMapper.selectById(targetId);
if (targetOrder == null) {
return new CursorPageResult<>();
}
// 从目标记录附近开始查询
QueryWrapper<Order> wrapper = new QueryWrapper<>();
wrapper.le("create_time", targetOrder.getCreateTime())
.orderByDesc("create_time")
.orderByDesc("id");
List<Order> nearbyOrders = orderMapper.selectList(
wrapper.last("LIMIT " + (pageSize * 2)));
// 找到目标记录在结果中的位置,返回前后数据
return buildCursorResult(nearbyOrders, targetId, pageSize);
}
}
2. 数据更新处理
@Service
public class ConsistentCursorService {
public CursorPageResult<Order> getConsistentPage(CursorPageRequest request) {
// 添加时间窗口,避免数据更新导致的重复或遗漏
QueryWrapper<Order> wrapper = new QueryWrapper<>();
if (request.getCursor() != null) {
Long cursorTime = Long.parseLong(request.getCursor());
// 设置一个小的时间窗口,避免刚好在这个时间点插入的数据被遗漏
wrapper.le("create_time", new Timestamp(cursorTime + 1000)); // 1秒窗口
}
// 其他查询逻辑...
return result;
}
}
性能对比
| 方案 | 第1页 | 第100页 | 第1000页 | 第10000页 |
|---|---|---|---|---|
| 传统OFFSET | 5ms | 50ms | 500ms | 5000ms+ |
| 游标分页 | 5ms | 8ms | 8ms | 8ms |
从对比可以看出,游标分页的性能基本不受页码影响,始终保持在较低水平。
注意事项
在使用游标分页时,需要注意以下几点:
- 排序字段唯一性:确保排序字段组合能够唯一确定记录顺序
- 索引优化:为游标字段创建合适的索引
- 数据一致性:处理数据更新可能带来的重复或遗漏问题
- 缓存策略:合理使用缓存提升性能
- 前端适配:改变用户交互习惯,从页码跳转改为滑动加载
最佳实践
- 优先使用时间戳排序:按时间倒序是最常见的分页需求
- 复合游标兜底:时间戳相同时使用ID作为第二排序条件
- 预加载优化:提前加载下一页数据提升用户体验
- 监控告警:监控分页查询性能,及时发现潜在问题
总结
游标分页是解决深分页性能问题的有效方案,特别适合数据量大的场景。通过合理的设计和优化,我们可以实现毫秒级的分页查询响应,大大提升用户体验。
记住,游标分页改变了传统的分页交互模式,需要前端和后端共同配合才能发挥最佳效果。
希望这篇文章对你有所帮助!如果你觉得有用,欢迎关注【服务端技术精选】公众号,获取更多后端技术干货。
标题:SpringBoot + 分页游标 + 时间戳排序:替代 OFFSET,千万级数据高效翻页实战
作者:jiangyi
地址:http://jiangyi.space/articles/2026/01/22/1769147855290.html
0 评论