Java深度分页性能优化:从问题本质到生产实践

一、问题本质:为什么深度分页会慢?

1.1 传统分页的执行过程

复制代码
-- 查询第10000页,每页10条
SELECT * FROM orders 
ORDER BY create_time DESC 
LIMIT 100000, 10;

MySQL执行流程:

  1. 扫描索引树,找到前100010条记录

  2. 丢弃前100000条,只保留最后10条

  3. 如果查询字段不在索引中,还需回表100010次

时间复杂度: O(N + M),其中N是偏移量,M是页大小。当N达到百万级时,性能急剧下降。


二、游标分页:核心解决方案

2.1 核心思想

用"最后一条数据的位置"替代"第N页的偏移量",将时间复杂度降为O(logN + M)。

复制代码
-- 传统方式(慢)
LIMIT 100000, 10

-- 游标方式(快)
WHERE create_time < '2024-01-01 12:00:00' 
   OR (create_time = '2024-01-01 12:00:00' AND id < 10086)
LIMIT 10

2.2 为什么需要两个值?

反例:只用时间字段的问题

复制代码
// 假设两条记录时间相同:
// 记录A: id=100, create_time=2024-01-01 12:00:00
// 记录B: id=101, create_time=2024-01-01 12:00:00

// 第一页返回 A, B,游标记录 lastTime=12:00:00
// 第二页查询:WHERE create_time < '12:00:00'
// 结果:A和B都被过滤,数据丢失!

正解:联合定位

复制代码
WHERE (create_time < '2024-01-01 12:00:00') 
   OR (create_time = '2024-01-01 12:00:00' AND id < 101)

三、完整Java实现

3.1 游标对象设计

复制代码
@Data
public class CursorPage<T> {
    private List<T> records;      // 当前页数据
    private String nextCursor;    // 下一页游标(Base64编码)
    private boolean hasNext;      // 是否有下一页
    private Integer pageSize;     // 每页大小
}

/**
 * 游标内部结构
 * 支持多字段排序,segments按排序优先级排列,最后一项必须是唯一键
 */
@Data
public class CursorPayload {
    private List<SortSegment> segments;
    
    @Data
    public static class SortSegment {
        private String field;      // 字段名:create_time / amount / id
        private Object value;      // 最后一条该字段的值
        private String order;      // ASC/DESC
    }
}

3.2 游标编解码工具

复制代码
@Component
public class CursorUtil {
    
    private final ObjectMapper mapper = new ObjectMapper();
    
    /**
     * 编码:将游标对象转为前端传递的字符串
     */
    public String encode(CursorPayload payload) {
        try {
            String json = mapper.writeValueAsString(payload);
            return Base64.getUrlEncoder().withoutPadding()
                        .encodeToString(json.getBytes(StandardCharsets.UTF_8));
        } catch (Exception e) {
            throw new RuntimeException("游标编码失败", e);
        }
    }
    
    /**
     * 解码:将前端传来的字符串转为游标对象
     */
    public CursorPayload decode(String cursor) {
        if (StrUtil.isBlank(cursor)) return null;
        try {
            String json = new String(
                Base64.getUrlDecoder().decode(cursor), 
                StandardCharsets.UTF_8
            );
            return mapper.readValue(json, CursorPayload.class);
        } catch (Exception e) {
            throw new IllegalArgumentException("非法的游标格式");
        }
    }
}

3.3 核心查询服务

复制代码
@Service
public class CursorPaginationService<T> {
    
    @Autowired
    private CursorUtil cursorUtil;
    
    /**
     * 通用游标分页查询
     * 
     * @param baseWrapper 基础查询条件(不含排序和分页)
     * @param sortRules 排序规则,最后一项必须是唯一键(如id)
     * @param cursor 前端传来的游标,首屏为null
     * @param pageSize 每页大小
     */
    public CursorPage<T> query(
            AbstractWrapper<T, ?, ?> baseWrapper,
            List<SortRule> sortRules,
            String cursor,
            int pageSize
    ) {
        // 参数校验
        Assert.notEmpty(sortRules, "排序规则不能为空");
        Assert.isTrue(pageSize > 0 && pageSize <= 100, "页大小必须在1-100之间");
        
        // 1. 解析游标
        CursorPayload payload = cursorUtil.decode(cursor);
        List<SortSegment> segments = convertToSegments(payload, sortRules);
        
        // 2. 构建查询条件
        AbstractWrapper<T, ?, ?> wrapper = baseWrapper.clone();
        
        // 3. 附加游标过滤条件(核心逻辑)
        if (!segments.isEmpty()) {
            applyCursorCondition(wrapper, segments);
        }
        
        // 4. 应用排序
        sortRules.forEach(rule -> 
            wrapper.orderBy(true, rule.isAsc(), rule.getColumn())
        );
        
        // 5. 查询(多取一条用于判断是否还有下一页)
        List<T> records = baseMapper.selectList(
            wrapper.last("LIMIT " + (pageSize + 1))
        );
        
        // 6. 封装返回结果
        return buildPage(records, sortRules, pageSize);
    }
    
    /**
     * 构建游标过滤条件
     * 生成:(field1 < val1) OR (field1 = val1 AND field2 < val2) OR ...
     */
    private void applyCursorCondition(
            AbstractWrapper<T, ?, ?> wrapper, 
            List<SortSegment> segments
    ) {
        wrapper.and(w -> {
            for (int i = 0; i < segments.size(); i++) {
                if (i > 0) w.or();
                
                // 前面所有字段的相等条件
                for (int j = 0; j < i; j++) {
                    SortSegment prev = segments.get(j);
                    applyEquals(w, prev.getColumn(), prev.getValue());
                }
                
                // 当前字段的比较条件
                SortSegment curr = segments.get(i);
                if ("ASC".equalsIgnoreCase(curr.getOrder())) {
                    w.gt(curr.getColumn(), curr.getValue());
                } else {
                    w.lt(curr.getColumn(), curr.getValue());
                }
            }
        });
    }
    
    private CursorPage<T> buildPage(
            List<T> records, 
            List<SortRule> sortRules, 
            int pageSize
    ) {
        CursorPage<T> result = new CursorPage<>();
        result.setPageSize(pageSize);
        result.setHasNext(records.size() > pageSize);
        
        // 有下一页时,去掉多取的那条
        if (result.isHasNext()) {
            records = records.subList(0, pageSize);
        }
        result.setRecords(records);
        
        // 生成下一页游标
        if (result.isHasNext() && !records.isEmpty()) {
            T lastRecord = records.get(records.size() - 1);
            CursorPayload nextPayload = buildPayload(lastRecord, sortRules);
            result.setNextCursor(cursorUtil.encode(nextPayload));
        }
        
        return result;
    }
    
    /**
     * 从最后一条记录提取游标值
     */
    private CursorPayload buildPayload(T record, List<SortRule> sortRules) {
        CursorPayload payload = new CursorPayload();
        List<SortSegment> segments = new ArrayList<>();
        
        for (SortRule rule : sortRules) {
            SortSegment segment = new SortSegment();
            segment.setField(rule.getField());
            segment.setOrder(rule.getOrder());
            segment.setValue(ReflectUtil.getFieldValue(record, rule.getField()));
            segments.add(segment);
        }
        
        payload.setSegments(segments);
        return payload;
    }
}

3.4 业务层使用示例

复制代码
@Service
public class OrderService {
    
    @Autowired
    private CursorPaginationService<Order> paginationService;
    
    public CursorPage<Order> queryOrderList(OrderQueryParam param, String cursor, int size) {
        // 1. 构建基础查询条件
        LambdaQueryWrapper<Order> wrapper = Wrappers.lambdaQuery();
        wrapper.eq(param.getStatus() != null, Order::getStatus, param.getStatus())
               .ge(param.getStartTime() != null, Order::getCreateTime, param.getStartTime());
        
        // 2. 定义排序规则(注意:最后一项必须是唯一键)
        List<SortRule> sortRules = Arrays.asList(
            new SortRule("createTime", "DESC"),  // 主排序:时间倒序
            new SortRule("id", "DESC")            // 副排序:ID倒序(确保唯一性)
        );
        
        // 3. 执行游标分页查询
        return paginationService.query(wrapper, sortRules, cursor, size);
    }
}

3.5 Controller层

复制代码
@RestController
@RequestMapping("/orders")
public class OrderController {
    
    @GetMapping("/list")
    public CursorPage<Order> list(
            @Valid OrderQueryParam param,
            @RequestParam(required = false) String cursor,  // 游标,首屏不传
            @RequestParam(defaultValue = "20") @Max(100) int size) {
        
        return orderService.queryOrderList(param, cursor, size);
    }
}

四、前端集成方案

流程图解

交互设计

复制代码
┌─────────────┐                    ┌─────────────┐                    ┌─────────────┐
│    前端     │ ──① 首次请求 ──→   │    后端     │ ──② 查询数据库 ──→  │    数据库   │
│  (无游标)   │                    │  cursor=null │                    │  LIMIT 21   │
└─────────────┘                    └─────────────┘                    └─────────────┘
                                          ↑                                │
                                          └──────── 返回10条数据 ────────────┘
                                          ↓
                                   ┌─────────────┐
                                   │ 封装nextCursor│ ← 提取最后一条数据的
                                   │ (Base64编码) │   create_time + id
                                   └─────────────┘
                                          ↓
┌─────────────┐                    ┌─────────────┐
│    前端     │ ←─③ 返回数据+游标 ───│    后端     │
│ 保存游标    │                    │             │
└─────────────┘                    └─────────────┘
       │
       ↓ 用户滚动/点击"加载更多"
       │
┌─────────────┐                    ┌─────────────┐                    ┌─────────────┐
│    前端     │ ──④ 传回游标 ───→   │    后端     │ ──⑤ 解析游标 ───→  │  生成WHERE   │
│ nextCursor  │                    │ 解码得时间+id │   WHERE time < ?   │  条件查询    │
└─────────────┘                    └─────────────┘   AND id < ?        └─────────────┘

4.1 无限滚动实现(Vue3示例)

复制代码
<template>
  <div class="order-list" ref="listRef">
    <div v-for="order in list" :key="order.id" class="order-item">
      {{ order.orderNo }} - {{ order.amount }}
    </div>
    
    <div v-if="loading" class="status">加载中...</div>
    <div v-else-if="!hasMore" class="status">没有更多了</div>
    <div v-else-if="error" class="status error" @click="loadMore">加载失败,点击重试</div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useIntersectionObserver } from '@vueuse/core'

const list = ref([])
const nextCursor = ref(null)
const hasMore = ref(true)
const loading = ref(false)
const error = ref(false)

// 加载数据
const loadMore = async () => {
  if (loading.value || !hasMore.value) return
  
  loading.value = true
  error.value = false
  
  try {
    const params = new URLSearchParams({ size: 20 })
    if (nextCursor.value) params.append('cursor', nextCursor.value)
    
    const res = await fetch(`/api/orders/list?${params}`)
    const data = await res.json()
    
    list.value.push(...data.records)
    nextCursor.value = data.nextCursor
    hasMore.value = data.hasNext
  } catch (e) {
    error.value = true
  } finally {
    loading.value = false
  }
}

// 首屏加载
onMounted(() => loadMore())

// 无限滚动监听
const { stop } = useIntersectionObserver(
  document.querySelector('.status'),
  ([{ isIntersecting }]) => {
    if (isIntersecting) loadMore()
  }
)

onUnmounted(() => stop())
</script>

4.2 传统分页改造(保留上下页)

复制代码
// 用栈保存历史游标,支持上一页
const cursorStack = ref([])

const loadNext = async () => {
  // 保存当前游标到栈
  cursorStack.value.push(nextCursor.value)
  await fetchData(nextCursor.value)
}

const loadPrev = async () => {
  // 弹出当前游标,回到上一个
  cursorStack.value.pop()
  const prevCursor = cursorStack.value[cursorStack.value.length - 1]
  await fetchData(prevCursor)
}

五、数据库优化要点

5.1 索引设计(关键)

复制代码
-- 必须建立复合索引,字段顺序与ORDER BY一致
CREATE INDEX idx_orders_time_id ON orders(create_time DESC, id DESC);

-- 如果带查询条件,将条件字段放前面
CREATE INDEX idx_orders_status_time_id 
ON orders(status, create_time DESC, id DESC);

5.2 执行计划验证

复制代码
EXPLAIN SELECT * FROM orders 
WHERE (create_time < '2024-01-01' OR (create_time = '2024-01-01' AND id < 100))
ORDER BY create_time DESC, id DESC 
LIMIT 10;

-- 确保Extra列不出现"Using filesort",type为"range"或"index"

六、高级场景与避坑指南

6.1 游标失效处理

复制代码
@ExceptionHandler
public Result handleCursorExpired(CursorExpiredException e) {
    // 游标指向的数据被删除或修改
    return Result.fail("DATA_CHANGED", "数据已更新,请刷新页面重试");
}

6.2 多排序规则切换

复制代码
// 用户切换排序方式时,必须重置游标
@GetMapping("/list")
public CursorPage<Order> list(
        @RequestParam String sortBy,  // createTime/amount/status
        @RequestParam(required = false) String cursor,
        @RequestParam(required = false) String prevSortBy) {  // 上次排序方式
    
    // 排序方式改变,游标失效
    if (!sortBy.equals(prevSortBy)) {
        cursor = null;
    }
    
    return orderService.query(sortBy, cursor);
}

6.3 分库分表场景

复制代码
// ShardingSphere下,强制路由避免全分片扫描
HintManager hintManager = HintManager.getInstance();
hintManager.addDatabaseShardingValue("orders", userId % 8);
hintManager.addTableShardingValue("orders", createTime.getMonth());

// 确保游标中的分片键与查询一致

6.4 与ES/ClickHouse的协作

表格

场景 方案
简单列表 MySQL游标分页
复杂筛选(多字段OR) Elasticsearch search_after
海量数据报表 ClickHouse预计算宽表
实时+历史数据 双写异构,路由分发

七、方案对比与选型

表格

方案 优点 缺点 适用场景
游标分页 性能稳定O(logN),无深度分页问题 无法跳页,需唯一键 信息流、时间线、消息列表
延迟关联 不改表结构,减少回表 仍有OFFSET开销 临时优化,浅分页场景
ES/搜索引擎 支持复杂筛选,水平扩展 一致性延迟,运维成本 电商搜索、多维度筛选
宽表预计算 查询极快 实时性差,存储冗余 报表、BI分析
业务限制 零技术成本 产品体验受限 后台管理,用户可接受

八、总结

游标分页是解决深度分页性能问题的黄金标准,其核心在于:

  1. 用位置替代偏移:通过最后一条数据的排序字段值定位,避免扫描无效数据

  2. 联合唯一键:必须结合唯一ID处理排序字段重复的情况

  3. 前后端协作:前端传递游标,后端解析并生成下一页游标

  4. 索引是前提 :复合索引(sortField, id)是性能保障

生产 checklist

  • \] 表必须有唯一键(或联合主键)

  • \] 排序字段值尽量不变(或接受数据跳跃)

  • \] 限制最大页大小(建议≤100)

相关推荐
爱丽_2 小时前
Redis 持久化与高可用:RDB/AOF、主从复制、哨兵与一致性取舍
java·后端·spring
伯远医学2 小时前
如何判断提取的RNA是否可用?
java·开发语言·前端·javascript·人工智能·eclipse·创业创新
盐水冰2 小时前
【烘焙坊项目】补充完善(1)- SpringAI大模型接入
java·后端·大模型
cch89182 小时前
C++与PHP:7大核心差异全解析
java·开发语言
-南帝-2 小时前
RocketMQ2.3.5+SpringBoot 3.2.11+ java17安装-集成-测试案例
java·spring boot·rocketmq
TRACER~852 小时前
项目实战:pandas+pytest+allure+adb
adb·pandas·pytest
斌糖雪梨2 小时前
spring registerBeanPostProcessors(beanFactory) 源码详解
java·后端·spring
Nontee2 小时前
面试准备(Reids存粹问题版)
java·面试
2601_949817922 小时前
spring-ai 下载不了依赖spring-ai-openai-spring-boot-starter
java·人工智能·spring