SpringBoot + 分页游标 + 时间戳排序:替代 OFFSET,千万级数据高效翻页实战

大家好,我是服务端技术精选的作者。今天咱们聊聊一个在数据查询中极其常见却又十分头疼的问题:深分页查询。

传统分页的性能陷阱

在我们的日常开发工作中,经常会遇到这样的场景:

  • 订单列表翻到第1000页,数据库直接卡死
  • 后台管理系统查询用户数据,翻到后面越来越慢
  • 移动端下拉刷新,数据量越大加载越慢
  • 报表系统分页查询,第一页和最后一页性能差异巨大

传统的OFFSET/LIMIT分页方式在数据量较大时性能急剧下降,这是因为数据库需要扫描前面所有的记录才能定位到目标位置。今天我们就来聊聊如何用游标分页解决这个问题。

为什么OFFSET分页会变慢

让我们先看看传统分页的原理:

-- 传统分页,随着offset增大,性能急剧下降
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;

数据库需要:

  1. 扫描前1000000条记录
  2. 跳过这1000000条记录
  3. 返回接下来的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页
传统OFFSET5ms50ms500ms5000ms+
游标分页5ms8ms8ms8ms

从对比可以看出,游标分页的性能基本不受页码影响,始终保持在较低水平。

注意事项

在使用游标分页时,需要注意以下几点:

  1. 排序字段唯一性:确保排序字段组合能够唯一确定记录顺序
  2. 索引优化:为游标字段创建合适的索引
  3. 数据一致性:处理数据更新可能带来的重复或遗漏问题
  4. 缓存策略:合理使用缓存提升性能
  5. 前端适配:改变用户交互习惯,从页码跳转改为滑动加载

最佳实践

  1. 优先使用时间戳排序:按时间倒序是最常见的分页需求
  2. 复合游标兜底:时间戳相同时使用ID作为第二排序条件
  3. 预加载优化:提前加载下一页数据提升用户体验
  4. 监控告警:监控分页查询性能,及时发现潜在问题

总结

游标分页是解决深分页性能问题的有效方案,特别适合数据量大的场景。通过合理的设计和优化,我们可以实现毫秒级的分页查询响应,大大提升用户体验。

记住,游标分页改变了传统的分页交互模式,需要前端和后端共同配合才能发挥最佳效果。

希望这篇文章对你有所帮助!如果你觉得有用,欢迎关注【服务端技术精选】公众号,获取更多后端技术干货。


标题:SpringBoot + 分页游标 + 时间戳排序:替代 OFFSET,千万级数据高效翻页实战
作者:jiangyi
地址:http://jiangyi.space/articles/2026/01/22/1769147855290.html

    0 评论
avatar