Mybatis、MyBatis-Plus 深度分页性能问题与游标分页解决方案

【投稿赢 iPhone 17】「我的第一个开源项目」故事征集:用代码换C位出道! 10w+人浏览 1.6k人参与

1. 深度分页性能问题详解

1.1 问题根源分析

传统分页的工作原理
-- 传统 LIMIT 分页查询
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20;

MySQL 执行过程

  1. 扫描并排序前 1,000,020 条记录
  2. 跳过前 1,000,000 条记录(OFFSET 部分)
  3. 仅返回最后的 20 条记录

性能问题

  • 时间复杂度:O(n + m),其中 n 是 OFFSET 值,m 是 LIMIT 值
  • 内存消耗:需要在内存中维护大量临时数据
  • I/O 压力:即使有索引,也需要读取大量索引页
  • CPU 消耗:排序和跳过操作消耗大量 CPU 资源
性能对比数据
分页方式第1页第1000页第10000页第50000页
OFFSET 分页10ms200ms2000ms10000ms+
游标分页10ms12ms15ms18ms

1.2 MyBatis-Plus 中的深度分页问题

// MyBatis-Plus 传统分页示例
@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 使用 MyBatis-Plus 的 IPage 进行分页
     * 当 pageNum 很大时(如 50000),性能会急剧下降
     */
    public IPage<Order> getOrdersByPage(int pageNum, int pageSize) {
        // 构建分页对象
        Page<Order> page = new Page<>(pageNum, pageSize);
        
        // 设置排序
        page.addOrder(OrderItem.desc("create_time"));
        
        // 执行分页查询 - 深度分页时性能极差
        return orderMapper.selectPage(page, null);
    }
}

生成的 SQL

-- 当 pageNum=50000, pageSize=20 时
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20;

2. 游标分页解决方案

2.1 游标分页原理

核心思想记住位置,而不是计算位置

-- 游标分页:使用上一页最后一条记录的排序字段值作为起点
SELECT * FROM orders 
WHERE create_time < '2023-12-01 10:30:00'  -- 上一页最后一条记录的时间
ORDER BY create_time DESC 
LIMIT 20;

优势

  • 时间复杂度:O(log n + m),利用索引快速定位
  • 性能稳定:与数据总量无关,只与 LIMIT 值相关
  • 资源消耗低:不需要跳过大量记录

2.2 游标字段选择标准

选择标准(按优先级排序)
  1. 唯一性:字段值必须唯一,避免重复数据
  2. 有序性:字段具有天然的排序特性
  3. 索引支持:字段必须有索引(最好是复合索引)
  4. 稳定性:字段值在业务生命周期内不会改变
  5. 数据类型:优先选择数值型或时间型字段
推荐的游标字段类型
字段类型推荐程度说明
自增主键 ID⭐⭐⭐⭐⭐最佳选择,天然唯一、有序、有索引
创建时间⭐⭐⭐⭐良好选择,但需处理并发创建的相同时间问题
复合字段⭐⭐⭐⭐如 (create_time, id),解决时间重复问题
UUID⭐⭐不推荐,无序且占用空间大
业务字段一般不推荐,可能重复或变更

2.3 最佳游标字段选择策略

策略 1:单字段游标(推荐)
/**
 * 最佳实践:使用自增主键作为游标
 * 优点:天然唯一、有序、有主键索引
 */
public class OrderCursorPagination {
    
    /**
     * 基于 ID 的游标分页
     * @param lastId 上一页最后一条记录的 ID
     * @param pageSize 每页大小
     * @return 订单列表
     */
    public List<Order> getOrdersByIdCursor(Long lastId, int pageSize) {
        // SQL: SELECT * FROM orders WHERE id > #{lastId} ORDER BY id LIMIT #{pageSize}
        return orderMapper.selectByCursor(lastId, pageSize);
    }
}
策略 2:复合字段游标(处理时间重复)
/**
 * 复合游标:解决创建时间可能重复的问题
 * 使用 (create_time, id) 作为复合游标
 */
public class OrderCompositeCursorPagination {
    
    /**
     * 复合游标分页参数
     */
    public static class Cursor {
        private LocalDateTime lastCreateTime;
        private Long lastId;
        
        // 构造函数、getter、setter
        public Cursor(LocalDateTime lastCreateTime, Long lastId) {
            this.lastCreateTime = lastCreateTime;
            this.lastId = lastId;
        }
        
        public LocalDateTime getLastCreateTime() { return lastCreateTime; }
        public Long getLastId() { return lastId; }
    }
    
    /**
     * 基于复合游标的分页查询
     * @param cursor 上一页的游标信息
     * @param pageSize 每页大小
     * @return 订单列表
     */
    public List<Order> getOrdersByCompositeCursor(Cursor cursor, int pageSize) {
        return orderMapper.selectByCompositeCursor(cursor, pageSize);
    }
}

3. 完整实现示例

3.1 数据库表结构

-- 订单表结构
CREATE TABLE orders (
    id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '订单ID,自增主键',
    order_no VARCHAR(50) NOT NULL UNIQUE COMMENT '订单编号',
    user_id BIGINT NOT NULL COMMENT '用户ID',
    amount DECIMAL(10, 2) NOT NULL COMMENT '订单金额',
    status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '订单状态',
    create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    
    -- 索引优化
    INDEX idx_user_id (user_id),
    INDEX idx_create_time (create_time),
    INDEX idx_status_create_time (status, create_time)  -- 复合索引
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

3.2 MyBatis Mapper 实现

单字段游标分页
<!-- OrderMapper.xml -->
<mapper namespace="com.example.mapper.OrderMapper">
    
    <!-- 基于 ID 的游标分页查询 -->
    <select id="selectByIdCursor" resultType="com.example.entity.Order">
        SELECT 
            id,
            order_no,
            user_id,
            amount,
            status,
            create_time,
            update_time
        FROM orders 
        WHERE id > #{lastId}
        ORDER BY id ASC
        LIMIT #{pageSize}
    </select>
    
    <!-- 基于创建时间的游标分页查询(降序) -->
    <select id="selectByCreateTimeCursor" resultType="com.example.entity.Order">
        SELECT 
            id,
            order_no,
            user_id,
            amount,
            status,
            create_time,
            update_time
        FROM orders 
        WHERE create_time <![CDATA[<]]> #{lastCreateTime}
        ORDER BY create_time DESC
        LIMIT #{pageSize}
    </select>
    
    <!-- 复合游标分页查询 -->
    <select id="selectByCompositeCursor" resultType="com.example.entity.Order">
        SELECT 
            id,
            order_no,
            user_id,
            amount,
            status,
            create_time,
            update_time
        FROM orders 
        WHERE 
            create_time <![CDATA[<]]> #{cursor.lastCreateTime}
            OR (create_time = #{cursor.lastCreateTime} AND id > #{cursor.lastId})
        ORDER BY create_time DESC, id ASC
        LIMIT #{pageSize}
    </select>
    
    <!-- 获取总记录数(可选,用于显示总页数) -->
    <select id="countAll" resultType="java.lang.Long">
        SELECT COUNT(*) FROM orders
    </select>
</mapper>
MyBatis-Plus Mapper 实现
// OrderMapper.java
@Mapper
public interface OrderMapper extends BaseMapper<Order> {
    
    /**
     * 基于 ID 的游标分页查询
     * @param lastId 上一页最后一条记录的 ID
     * @param pageSize 每页大小
     * @return 订单列表
     */
    @Select("SELECT * FROM orders WHERE id > #{lastId} ORDER BY id ASC LIMIT #{pageSize}")
    List<Order> selectByIdCursor(@Param("lastId") Long lastId, @Param("pageSize") int pageSize);
    
    /**
     * 基于创建时间的游标分页查询
     * @param lastCreateTime 上一页最后一条记录的创建时间
     * @param pageSize 每页大小
     * @return 订单列表
     */
    @Select("SELECT * FROM orders WHERE create_time < #{lastCreateTime} ORDER BY create_time DESC LIMIT #{pageSize}")
    List<Order> selectByCreateTimeCursor(@Param("lastCreateTime") LocalDateTime lastCreateTime, 
                                       @Param("pageSize") int pageSize);
    
    /**
     * 复合游标分页查询
     * @param cursor 游标参数对象
     * @param pageSize 每页大小
     * @return 订单列表
     */
    List<Order> selectByCompositeCursor(@Param("cursor") OrderCompositeCursorPagination.Cursor cursor, 
                                      @Param("pageSize") int pageSize);
}

3.3 Service 层实现

/**
 * 订单服务类 - 游标分页实现
 */
@Service
public class OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    /**
     * 基于 ID 的游标分页查询
     * 适用于按创建顺序分页的场景
     * 
     * @param lastId 上一页最后一条记录的 ID,首次查询传 null 或 0
     * @param pageSize 每页大小,建议限制最大值(如 100)
     * @return 分页结果包装对象
     */
    public CursorPageResult<Order> getOrdersByIdCursor(Long lastId, int pageSize) {
        // 参数校验
        if (pageSize <= 0 || pageSize > 100) {
            pageSize = 20; // 默认每页20条
        }
        
        // 首次查询或 lastId 为 null 时,从最小 ID 开始
        if (lastId == null || lastId <= 0) {
            lastId = 0L;
        }
        
        // 执行游标分页查询
        List<Order> orders = orderMapper.selectByIdCursor(lastId, pageSize);
        
        // 构建分页结果
        boolean hasMore = orders.size() == pageSize;
        Long nextCursor = hasMore ? orders.get(orders.size() - 1).getId() : null;
        
        return new CursorPageResult<>(orders, nextCursor, hasMore);
    }
    
    /**
     * 基于创建时间的游标分页查询(降序)
     * 适用于按时间倒序显示最新数据的场景
     * 
     * @param lastCreateTime 上一页最后一条记录的创建时间,首次查询传 null
     * @param pageSize 每页大小
     * @return 分页结果包装对象
     */
    public CursorPageResult<Order> getOrdersByCreateTimeCursor(LocalDateTime lastCreateTime, int pageSize) {
        // 参数校验
        if (pageSize <= 0 || pageSize > 100) {
            pageSize = 20;
        }
        
        // 首次查询时,不设置时间条件
        if (lastCreateTime == null) {
            // 查询最新的 pageSize 条记录
            List<Order> orders = orderMapper.selectLatestOrders(pageSize);
            boolean hasMore = orders.size() == pageSize;
            LocalDateTime nextCursor = hasMore ? orders.get(orders.size() - 1).getCreateTime() : null;
            return new CursorPageResult<>(orders, nextCursor, hasMore);
        }
        
        // 执行时间游标查询
        List<Order> orders = orderMapper.selectByCreateTimeCursor(lastCreateTime, pageSize);
        boolean hasMore = orders.size() == pageSize;
        LocalDateTime nextCursor = hasMore ? orders.get(orders.size() - 1).getCreateTime() : null;
        
        return new CursorPageResult<>(orders, nextCursor, hasMore);
    }
    
    /**
     * 复合游标分页查询
     * 解决创建时间重复的问题,确保分页的准确性
     * 
     * @param cursor 复合游标对象,包含最后的时间和 ID
     * @param pageSize 每页大小
     * @return 分页结果包装对象
     */
    public CursorPageResult<Order> getOrdersByCompositeCursor(
            OrderCompositeCursorPagination.Cursor cursor, int pageSize) {
        
        if (pageSize <= 0 || pageSize > 100) {
            pageSize = 20;
        }
        
        // 首次查询
        if (cursor == null) {
            List<Order> orders = orderMapper.selectLatestOrders(pageSize);
            boolean hasMore = orders.size() == pageSize;
            OrderCompositeCursorPagination.Cursor nextCursor = hasMore ? 
                new OrderCompositeCursorPagination.Cursor(
                    orders.get(orders.size() - 1).getCreateTime(),
                    orders.get(orders.size() - 1).getId()
                ) : null;
            return new CursorPageResult<>(orders, nextCursor, hasMore);
        }
        
        // 执行复合游标查询
        List<Order> orders = orderMapper.selectByCompositeCursor(cursor, pageSize);
        boolean hasMore = orders.size() == pageSize;
        OrderCompositeCursorPagination.Cursor nextCursor = hasMore ? 
            new OrderCompositeCursorPagination.Cursor(
                orders.get(orders.size() - 1).getCreateTime(),
                orders.get(orders.size() - 1).getId()
            ) : null;
        
        return new CursorPageResult<>(orders, nextCursor, hasMore);
    }
    
    /**
     * 获取最新订单(首次查询使用)
     */
    public List<Order> selectLatestOrders(int pageSize) {
        return orderMapper.selectLatestOrders(pageSize);
    }
}

3.4 分页结果包装类

/**
 * 游标分页结果包装类
 * 通用的分页结果结构
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CursorPageResult<T> {
    
    /**
     * 当前页的数据列表
     */
    private List<T> data;
    
    /**
     * 下一页的游标值
     * 可以是 ID、时间戳或其他类型的游标
     */
    private Object nextCursor;
    
    /**
     * 是否还有更多数据
     * true 表示还有下一页,false 表示已是最后一页
     */
    private boolean hasMore;
    
    /**
     * 构造函数
     */
    public CursorPageResult(List<T> data, Object nextCursor, boolean hasMore) {
        this.data = data;
        this.nextCursor = nextCursor;
        this.hasMore = hasMore;
    }
    
    /**
     * 判断是否为第一页
     */
    public boolean isFirstPage() {
        return nextCursor == null && !data.isEmpty();
    }
    
    /**
     * 获取数据总数(可选,需要额外查询)
     */
    public Long getTotalCount() {
        // 如果需要总数,可以在这里添加查询逻辑
        // 但通常游标分页不需要精确总数
        return null;
    }
}

3.5 Controller 层实现

/**
 * 订单控制器 - 游标分页 API
 */
@RestController
@RequestMapping("/api/orders")
public class OrderController {
    
    @Autowired
    private OrderService orderService;
    
    /**
     * 基于 ID 的游标分页查询 API
     * 
     * 请求参数:
     * - lastId: 上一页最后一条记录的 ID(可选,首次查询不传)
     * - pageSize: 每页大小(可选,默认20,最大100)
     * 
     * 响应示例:
     * {
     *   "data": [...],
     *   "nextCursor": 123456,
     *   "hasMore": true
     * }
     */
    @GetMapping("/cursor/id")
    public ResponseEntity<CursorPageResult<Order>> getOrdersByIdCursor(
            @RequestParam(required = false) Long lastId,
            @RequestParam(defaultValue = "20") Integer pageSize) {
        
        CursorPageResult<Order> result = orderService.getOrdersByIdCursor(lastId, pageSize);
        return ResponseEntity.ok(result);
    }
    
    /**
     * 基于创建时间的游标分页查询 API
     * 
     * 请求参数:
     * - lastCreateTime: 上一页最后一条记录的创建时间(ISO 8601 格式,可选)
     * - pageSize: 每页大小(可选,默认20,最大100)
     */
    @GetMapping("/cursor/time")
    public ResponseEntity<CursorPageResult<Order>> getOrdersByCreateTimeCursor(
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastCreateTime,
            @RequestParam(defaultValue = "20") Integer pageSize) {
        
        CursorPageResult<Order> result = orderService.getOrdersByCreateTimeCursor(lastCreateTime, pageSize);
        return ResponseEntity.ok(result);
    }
    
    /**
     * 复合游标分页查询 API
     * 
     * 请求参数通过 JSON Body 传递:
     * {
     *   "lastCreateTime": "2023-12-01T10:30:00",
     *   "lastId": 123456
     * }
     */
    @PostMapping("/cursor/composite")
    public ResponseEntity<CursorPageResult<Order>> getOrdersByCompositeCursor(
            @RequestBody(required = false) CompositeCursorRequest request,
            @RequestParam(defaultValue = "20") Integer pageSize) {
        
        OrderCompositeCursorPagination.Cursor cursor = null;
        if (request != null) {
            cursor = new OrderCompositeCursorPagination.Cursor(
                request.getLastCreateTime(), 
                request.getLastId()
            );
        }
        
        CursorPageResult<Order> result = orderService.getOrdersByCompositeCursor(cursor, pageSize);
        return ResponseEntity.ok(result);
    }
}

/**
 * 复合游标请求参数类
 */
@Data
public class CompositeCursorRequest {
    private LocalDateTime lastCreateTime;
    private Long lastId;
}

4. 前端使用示例

4.1 JavaScript 前端实现

/**
 * 游标分页前端实现示例
 */
class OrderPagination {
    constructor() {
        this.currentPageData = [];
        this.nextCursor = null;
        this.hasMore = true;
        this.pageSize = 20;
    }
    
    /**
     * 加载下一页数据
     */
    async loadNextPage() {
        if (!this.hasMore) {
            console.log('没有更多数据了');
            return;
        }
        
        try {
            let url = '/api/orders/cursor/id';
            let params = new URLSearchParams();
            params.append('pageSize', this.pageSize);
            
            // 如果不是第一页,添加游标参数
            if (this.nextCursor !== null) {
                params.append('lastId', this.nextCursor);
            }
            
            const response = await fetch(`${url}?${params}`);
            const result = await response.json();
            
            // 更新分页状态
            this.currentPageData = result.data;
            this.nextCursor = result.nextCursor;
            this.hasMore = result.hasMore;
            
            // 渲染数据到页面
            this.renderOrders(result.data);
            
        } catch (error) {
            console.error('加载订单失败:', error);
        }
    }
    
    /**
     * 渲染订单数据到页面
     */
    renderOrders(orders) {
        const container = document.getElementById('orders-container');
        orders.forEach(order => {
            const orderElement = document.createElement('div');
            orderElement.innerHTML = `
                <div class="order-item">
                    <span>订单号: ${order.orderNo}</span>
                    <span>金额: ¥${order.amount}</span>
                    <span>时间: ${order.createTime}</span>
                </div>
            `;
            container.appendChild(orderElement);
        });
    }
    
    /**
     * "加载更多"按钮点击处理
     */
    onLoadMoreClick() {
        this.loadNextPage();
    }
}

// 初始化分页
const orderPagination = new OrderPagination();

// 首次加载
orderPagination.loadNextPage();

// 绑定"加载更多"按钮
document.getElementById('load-more-btn').addEventListener('click', () => {
    orderPagination.onLoadMoreClick();
});

4.2 API 调用示例

# 首次查询(获取第一页)
GET /api/orders/cursor/id?pageSize=20

# 响应
{
  "data": [
    {"id": 1, "orderNo": "ORD001", "amount": 99.99, "createTime": "2023-12-01T10:00:00"},
    {"id": 2, "orderNo": "ORD002", "amount": 199.99, "createTime": "2023-12-01T10:01:00"},
    ...
    {"id": 20, "orderNo": "ORD020", "amount": 299.99, "createTime": "2023-12-01T10:19:00"}
  ],
  "nextCursor": 20,
  "hasMore": true
}

# 第二次查询(获取第二页)
GET /api/orders/cursor/id?lastId=20&pageSize=20

# 响应
{
  "data": [
    {"id": 21, "orderNo": "ORD021", "amount": 149.99, "createTime": "2023-12-01T10:20:00"},
    ...
    {"id": 40, "orderNo": "ORD040", "amount": 89.99, "createTime": "2023-12-01T10:39:00"}
  ],
  "nextCursor": 40,
  "hasMore": true
}

5. 最佳实践总结

5.1 游标字段选择优先级

  1. 首选:自增主键 ID

    • 天然唯一、有序
    • 主键索引性能最佳
    • 实现最简单
  2. 次选:复合字段 (create_time, id)

    • 解决时间重复问题
    • 需要创建复合索引
    • 适用于按时间排序的业务场景
  3. 避免:纯时间字段

    • 可能存在并发创建的相同时间
    • 需要额外处理重复问题

5.2 性能优化建议

  1. 索引优化

    -- 单字段游标
    ALTER TABLE orders ADD INDEX idx_id (id);
    
    -- 复合游标
    ALTER TABLE orders ADD INDEX idx_create_time_id (create_time, id);
    
  2. 限制页面大小

    // 限制最大页面大小,防止恶意请求
    if (pageSize > 100) {
        pageSize = 100;
    }
    
  3. 业务层面限制

    // 对于不需要深度分页的业务,可以限制最大页数
    if (cursor != null && cursorValue < MIN_ALLOWED_CURSOR) {
        throw new IllegalArgumentException("不支持深度分页查询");
    }
    

5.3 适用场景

  • 社交动态流:微博、朋友圈等按时间倒序展示
  • 订单列表:电商平台的订单查询
  • 日志系统:系统日志的分页查询
  • 消息列表:聊天消息、通知消息等

5.4 不适用场景

  • 需要精确跳转到指定页码:如传统的页码导航
  • 需要显示总记录数:游标分页通常不计算总数
  • 随机访问:无法直接跳转到第 N 页

通过使用游标分页,可以彻底解决深度分页的性能问题,为用户提供流畅的分页体验,同时大大降低数据库的负载压力。

深度分页的本质问题是 “跳过大量数据” 的操作成本过高,解决方案的核心思想是 “记住位置,而不是计算位置”

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

龙茶清欢

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值