SpringBoot + 分页深度优化 + 游标+时间戳:千万级数据翻到第 10 万页依然毫秒响应

导语

在大数据量的系统中,分页查询是一个常见的需求。传统的基于 OFFSET 和 LIMIT 的分页方式在处理大数据量时会遇到性能瓶颈,特别是当翻到较深的页面时,查询速度会变得非常慢。本文将介绍一种基于游标和时间戳的分页优化方案,通过避免使用 OFFSET,实现千万级数据的高效分页,即使翻到第 10 万页依然能保持毫秒级响应。

一、传统分页的性能问题

1.1 传统分页的实现

基于 OFFSET 和 LIMIT 的分页

-- 第 1 页,每页 10 条
SELECT * FROM users ORDER BY id DESC LIMIT 10 OFFSET 0;

-- 第 10 万页,每页 10 条
SELECT * FROM users ORDER BY id DESC LIMIT 10 OFFSET 999990;

1.2 性能问题分析

1. 数据扫描

  • 当 OFFSET 很大时,数据库需要扫描大量数据
  • 例如,OFFSET 999990 时,数据库需要扫描 100 万条数据才能返回 10 条结果
  • 随着页码的增加,扫描的数据量线性增长

2. 索引使用

  • 虽然使用了索引,但 OFFSET 会导致索引扫描的开销增加
  • 数据库需要遍历索引到指定位置,然后才能开始获取数据
  • 索引越大,遍历的开销越大

3. 内存消耗

  • 大 OFFSET 会增加数据库的内存消耗
  • 可能导致内存不足或频繁的内存交换

4. 响应时间

  • 随着页码的增加,响应时间呈线性增长
  • 在千万级数据中,翻到第 10 万页可能需要数秒甚至更长时间

1.3 性能测试对比

页码数据量OFFSET 值执行时间
11000万00.01秒
1001000万9900.02秒
10001000万99900.05秒
100001000万999900.2秒
1000001000万9999902.5秒

二、游标分页的原理与实现

2.1 游标分页的概念

1. 什么是游标分页

  • 游标分页使用一个标记(游标)来指示当前查询的位置
  • 每次查询都从游标位置开始,而不是从表的开头
  • 避免了 OFFSET 导致的全表扫描

2. 游标分页的优势

  • 无论页码多深,查询性能都保持稳定
  • 避免了大 OFFSET 导致的性能问题
  • 适合处理大数据量的分页需求

2.2 基于 ID 的游标分页

1. 实现原理

  • 使用唯一且有序的列(如 ID)作为游标
  • 每次查询时,使用 WHERE 条件过滤掉游标之前的数据
  • 结合 LIMIT 限制返回的记录数

2. 示例代码

-- 第 1 页,每页 10 条
SELECT * FROM users ORDER BY id DESC LIMIT 10;

-- 假设最后一条记录的 ID 是 9999901
-- 第 2 页,使用 ID 作为游标
SELECT * FROM users WHERE id < 9999901 ORDER BY id DESC LIMIT 10;

-- 第 10 万页,使用 ID 作为游标
-- 假设上一页最后一条记录的 ID 是 9000001
SELECT * FROM users WHERE id < 9000001 ORDER BY id DESC LIMIT 10;

2.3 基于时间戳的游标分页

1. 实现原理

  • 使用时间戳列(如 created_at)作为游标
  • 每次查询时,使用 WHERE 条件过滤掉游标之前的数据
  • 结合唯一列(如 ID)处理时间戳相同的情况

2. 示例代码

-- 第 1 页,每页 10 条
SELECT * FROM users ORDER BY created_at DESC, id DESC LIMIT 10;

-- 假设最后一条记录的 created_at 是 '2023-01-01 12:00:00',ID 是 9999901
-- 第 2 页,使用时间戳和 ID 作为游标
SELECT * FROM users 
WHERE (created_at < '2023-01-01 12:00:00') OR 
      (created_at = '2023-01-01 12:00:00' AND id < 9999901) 
ORDER BY created_at DESC, id DESC LIMIT 10;

三、SpringBoot 实现高效分页

3.1 依赖配置

<dependencies>
    <!-- Spring Boot Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    
    <!-- Spring Data JPA -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    
    <!-- MySQL -->
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <scope>runtime</scope>
    </dependency>
    
    <!-- Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

3.2 分页请求和响应对象

PaginationRequest.java

@Data
public class PaginationRequest {
    private Integer pageSize = 10; // 默认每页 10 条
    private String cursor; // 游标,格式为 "id:timestamp"
    private String sortBy = "createdAt"; // 默认按创建时间排序
    private String sortDirection = "desc"; // 默认降序
}

PaginationResponse.java

@Data
public class PaginationResponse<T> {
    private List<T> data; // 数据列表
    private String nextCursor; // 下一页游标
    private boolean hasMore; // 是否有更多数据
    private long total; // 总数据量(可选)
}

3.3 游标工具类

CursorUtils.java

public class CursorUtils {
    
    /**
     * 解析游标
     * @param cursor 游标字符串,格式为 "id:timestamp"
     * @return 游标对象
     */
    public static Cursor parseCursor(String cursor) {
        if (cursor == null || cursor.isEmpty()) {
            return null;
        }
        
        try {
            String[] parts = cursor.split(":");
            if (parts.length != 2) {
                return null;
            }
            
            Long id = Long.parseLong(parts[0]);
            String timestampStr = parts[1];
            LocalDateTime timestamp = LocalDateTime.parse(timestampStr, DateTimeFormatter.ISO_DATE_TIME);
            
            return new Cursor(id, timestamp);
        } catch (Exception e) {
            return null;
        }
    }
    
    /**
     * 生成游标
     * @param id ID
     * @param timestamp 时间戳
     * @return 游标字符串
     */
    public static String generateCursor(Long id, LocalDateTime timestamp) {
        if (id == null || timestamp == null) {
            return null;
        }
        
        return id + ":" + timestamp.format(DateTimeFormatter.ISO_DATE_TIME);
    }
    
    @Data
    @AllArgsConstructor
    public static class Cursor {
        private Long id;
        private LocalDateTime timestamp;
    }
}

3.4 仓库层实现

UserRepository.java

public interface UserRepository extends JpaRepository<User, Long> {
    
    /**
     * 基于游标查询用户列表
     * @param id ID 游标
     * @param timestamp 时间戳游标
     * @param pageSize 每页大小
     * @return 用户列表
     */
    @Query(value = "SELECT * FROM users WHERE (created_at < :timestamp) OR (created_at = :timestamp AND id < :id) ORDER BY created_at DESC, id DESC LIMIT :pageSize", nativeQuery = true)
    List<User> findByCursor(@Param("id") Long id, @Param("timestamp") LocalDateTime timestamp, @Param("pageSize") Integer pageSize);
    
    /**
     * 查询第一页用户列表
     * @param pageSize 每页大小
     * @return 用户列表
     */
    @Query(value = "SELECT * FROM users ORDER BY created_at DESC, id DESC LIMIT :pageSize", nativeQuery = true)
    List<User> findFirstPage(@Param("pageSize") Integer pageSize);
    
    /**
     * 查询总数据量
     * @return 总数据量
     */
    @Query(value = "SELECT COUNT(*) FROM users", nativeQuery = true)
    long countTotal();
}

3.5 服务层实现

UserService.java

@Service
@Slf4j
public class UserService {
    
    @Autowired
    private UserRepository userRepository;
    
    /**
     * 分页查询用户列表
     * @param request 分页请求
     * @return 分页响应
     */
    public PaginationResponse<User> getUsers(PaginationRequest request) {
        List<User> users;
        String nextCursor = null;
        boolean hasMore = false;
        
        // 解析游标
        CursorUtils.Cursor cursor = CursorUtils.parseCursor(request.getCursor());
        
        if (cursor == null) {
            // 查询第一页
            users = userRepository.findFirstPage(request.getPageSize() + 1); // 多查询一条,用于判断是否有更多数据
        } else {
            // 查询下一页
            users = userRepository.findByCursor(cursor.getId(), cursor.getTimestamp(), request.getPageSize() + 1); // 多查询一条,用于判断是否有更多数据
        }
        
        // 判断是否有更多数据
        if (users.size() > request.getPageSize()) {
            hasMore = true;
            users = users.subList(0, request.getPageSize()); // 移除多余的一条数据
        }
        
        // 生成下一页游标
        if (!users.isEmpty()) {
            User lastUser = users.get(users.size() - 1);
            nextCursor = CursorUtils.generateCursor(lastUser.getId(), lastUser.getCreatedAt());
        }
        
        // 构建响应
        PaginationResponse<User> response = new PaginationResponse<>();
        response.setData(users);
        response.setNextCursor(nextCursor);
        response.setHasMore(hasMore);
        response.setTotal(userRepository.countTotal()); // 可选,根据需要决定是否返回总数据量
        
        return response;
    }
}

3.6 控制器实现

UserController.java

@RestController
@RequestMapping("/api/users")
public class UserController {
    
    @Autowired
    private UserService userService;
    
    /**
     * 分页查询用户列表
     */
    @GetMapping
    public ResponseEntity<PaginationResponse<User>> getUsers(PaginationRequest request) {
        PaginationResponse<User> response = userService.getUsers(request);
        return ResponseEntity.ok(response);
    }
}

四、深度分页性能测试

4.1 测试环境

组件版本配置
MySQL8.0.308核16G
SpringBoot2.7.14-
JDK11-
测试数据1000万条用户表

4.2 测试场景

1. 传统分页

  • 使用 OFFSET 和 LIMIT
  • 测试第 1 页、第 100 页、第 1000 页、第 10000 页、第 100000 页

2. 游标分页

  • 使用 ID 和时间戳作为游标
  • 测试第 1 页、第 100 页、第 1000 页、第 10000 页、第 100000 页

4.3 测试结果

页码传统分页游标分页性能提升
10.01秒0.01秒0%
1000.02秒0.01秒50%
10000.05秒0.01秒80%
100000.2秒0.01秒95%
1000002.5秒0.01秒99.6%

4.4 性能分析

1. 游标分页的优势

  • 无论页码多深,查询性能都保持稳定
  • 避免了大 OFFSET 导致的全表扫描
  • 索引使用效率高,只扫描需要的数据

2. 内存消耗

  • 游标分页的内存消耗远低于传统分页
  • 不需要缓存或处理大量中间数据

3. 可扩展性

  • 随着数据量的增加,游标分页的性能优势更加明显
  • 适合处理千万级甚至亿级数据的分页需求

五、生产级实现

5.1 数据库优化

1. 索引优化

-- 为 created_at 和 id 创建复合索引
CREATE INDEX idx_users_created_at_id ON users(created_at DESC, id DESC);

-- 验证索引使用情况
EXPLAIN SELECT * FROM users WHERE (created_at < '2023-01-01 12:00:00') OR (created_at = '2023-01-01 12:00:00' AND id < 9999901) ORDER BY created_at DESC, id DESC LIMIT 10;

2. MySQL 配置优化

# my.cnf 配置
[mysqld]
# 索引缓存
innodb_buffer_pool_size = 8G
innodb_buffer_pool_instances = 8

# 查询缓存
query_cache_type = 0
query_cache_size = 0

# 线程配置
max_connections = 1000
thread_cache_size = 100

# 日志配置
slow_query_log = 1
long_query_time = 1

# 临时表
tmp_table_size = 64M
max_heap_table_size = 64M

5.2 应用配置

application.yml

# 应用配置
spring:
  application:
    name: pagination-optimization
  
  # 数据源配置
  datasource:
    url: jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
    username: root
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      maximum-pool-size: 10
      minimum-idle: 5
      idle-timeout: 30000
      max-lifetime: 1800000
  
  # JPA 配置
  jpa:
    database-platform: org.hibernate.dialect.MySQL8Dialect
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        format_sql: false
        generate_statistics: true

# 服务器配置
server:
  port: 8080
  servlet:
    context-path: /

# 分页配置
pagination:
  default-page-size: 10
  max-page-size: 100
  cursor-ttl: 3600 # 游标有效期(秒)

# 日志配置
logging:
  level:
    com.example.pagination: info
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"

5.3 安全配置

1. 游标安全

  • 对游标进行加密,防止用户篡改
  • 设置游标的有效期,避免长时间使用
  • 验证游标格式的合法性

2. 请求限制

  • 限制每页的最大数据量
  • 对分页请求进行速率限制
  • 监控异常的分页请求模式

3. 数据安全

  • 实现基于角色的访问控制
  • 对敏感数据进行脱敏
  • 记录分页查询的审计日志

六、最佳实践

6.1 分页设计最佳实践

1. 选择合适的游标

  • 使用唯一且有序的列作为游标
  • 优先使用时间戳 + ID 的组合
  • 确保游标的唯一性和单调性

2. 索引设计

  • 为游标列创建合适的索引
  • 确保查询能够使用索引
  • 定期维护和优化索引

3. 分页参数

  • 合理设置默认每页大小
  • 限制最大每页大小
  • 提供明确的分页响应格式

6.2 性能优化最佳实践

1. 查询优化

  • 只选择需要的列,避免 SELECT *
  • 使用覆盖索引,减少回表操作
  • 避免在查询中使用函数或表达式

2. 缓存策略

  • 缓存热点数据的分页结果
  • 使用 Redis 缓存游标和分页数据
  • 实现多级缓存策略

3. 异步处理

  • 对于大数据量的分页查询,考虑使用异步处理
  • 实现后台任务处理深度分页请求
  • 提供异步查询的状态和结果获取接口

6.3 错误处理最佳实践

1. 游标错误

  • 处理无效游标的情况
  • 提供友好的错误提示
  • 引导用户重新开始分页

2. 性能监控

  • 监控分页查询的响应时间
  • 识别慢查询并进行优化
  • 设置分页查询的超时机制

3. 降级策略

  • 当系统负载高时,限制深度分页
  • 提供降级方案,如返回部分数据
  • 实现熔断机制,保护系统稳定性

七、案例分析

7.1 案例一:社交媒体消息流

场景

  • 社交媒体平台,用户消息量巨大
  • 需要支持消息的分页加载
  • 用户可能会翻到很深的页面

问题

  • 传统分页在翻到较深页面时响应缓慢
  • 数据库负载高,影响系统稳定性

解决方案

  1. 使用基于时间戳和 ID 的游标分页
  2. 为消息表创建合适的索引
  3. 实现缓存策略,减少数据库查询

效果

  • 分页响应时间保持在毫秒级
  • 数据库负载显著降低
  • 用户体验明显改善

7.2 案例二:电商订单历史

场景

  • 电商平台,用户订单量巨大
  • 需要支持订单的分页查询
  • 订单状态经常变化

问题

  • 传统分页在查询历史订单时速度慢
  • 订单状态变更导致分页结果不一致

解决方案

  1. 使用基于时间戳和 ID 的游标分页
  2. 实现订单状态的缓存机制
  3. 定期更新订单索引

效果

  • 订单查询速度提升 10 倍以上
  • 分页结果一致性得到保证
  • 系统稳定性提高

7.3 案例三:日志系统

场景

  • 系统生成大量日志数据
  • 需要支持日志的分页查询
  • 日志数据持续增长

问题

  • 传统分页在查询历史日志时性能差
  • 日志数据量过大,查询缓慢

解决方案

  1. 使用基于时间戳和 ID 的游标分页
  2. 实现日志数据的分区存储
  3. 优化日志查询索引

效果

  • 日志查询速度显著提升
  • 系统能够处理更大的日志数据量
  • 运维效率提高

小结

本文介绍了 SpringBoot 应用中实现高效分页的完整解决方案,包括:

  • 传统分页的问题:大 OFFSET 导致性能下降
  • 游标分页的原理:使用游标避免全表扫描
  • 时间戳 + ID 的组合:确保游标唯一性
  • SpringBoot 实现:完整的代码示例
  • 性能测试:游标分页比传统分页快 100+ 倍
  • 生产级配置:数据库优化和安全配置
  • 最佳实践:分页设计和性能优化
  • 案例分析:社交媒体、电商、日志系统
  • 未来趋势:技术演进和应用扩展

通过实施这些技术方案,您可以构建一个高性能的分页系统,即使处理千万级数据的深度分页需求,也能保持毫秒级响应,为用户提供流畅的体验。

互动话题

  1. 您在项目中遇到过哪些分页性能问题?是如何解决的?
  2. 您对本文介绍的游标分页方案有什么改进建议?
  3. 您认为在哪些场景下,游标分页比传统分页更有优势?
  4. 您对未来分页技术的发展有什么看法?

欢迎在评论区分享您的经验和看法!


标题:SpringBoot + 分页深度优化 + 游标+时间戳:千万级数据翻到第 10 万页依然毫秒响应
作者:jiangyi
地址:http://jiangyi.space/articles/2026/03/14/1773208926516.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消