MyBatis-Plus 大表分页 count () 性能瓶颈深度解析

0 阅读6分钟

在使用MyBatis-Plus进行大表分页查询时,你是否通过日志发现,分页插件总会先执行一条count()语句,且这条count()在千万级数据下耗时极长,严重拖慢整体响应?本文将从源码层面剖析MyBatis-Plus分页count()的执行机制,结合生产实战分析性能根因,并提供一套可落地的优化方案。

一、背景铺垫:MyBatis-Plus分页的基本流程

MyBatis-Plus通过MybatisPlusInterceptor分页插件实现物理分页,核心流程分为两步:

  1. count查询:先拦截原SQL,自动生成并执行select count(0)语句获取总记录数;
  2. limit分页:根据总记录数和分页参数,在原SQL后添加limit offset, size执行分页查询。

这一机制在小表下无感知,但在大表(千万级+)下,count()往往成为性能瓶颈。

二、底层原理:MyBatis-Plus分页count()的生成逻辑(基于v3.5.5)

我们从源码层面看count语句是如何生成的:

2.1 核心拦截器:MybatisPlusInterceptor

分页插件的核心是MybatisPlusInterceptor,它会在SQL执行前拦截,调用CountExecutor生成count语句:

// 源码片段:com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor
@Override
public Object intercept(Invocation invocation) throws Throwable {
    // ... 省略前置逻辑
    // 执行count查询
    if (count) {
        countExecutor.execute(executor, mappedStatement, parameter, rowBounds, resultHandler, boundSql);
    }
    // 执行分页查询
    // ... 省略后续逻辑
}

2.2 Count语句生成策略:CountSqlParser

CountSqlParser负责解析原SQL并生成count语句,默认策略如下:

  • 优化场景:如果原SQL是单表查询,且无group byhavingunion等,会直接优化为select count(0) from 表 where 条件
  • 默认场景:否则会生成select count(0) from (原SQL) tmp子查询。

问题根源:默认场景下的子查询count(),在大表下会导致全表扫描或临时表开销,性能极差。

三、生产实战:问题复现与根因分析

3.1 场景模拟(千万级订单表)

我们有一张order_info表,数据量1200万,结构如下:

CREATE TABLE `order_info` (
  `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
  `order_no` varchar(64NOT NULL COMMENT '订单号',
  `user_id` bigint NOT NULL COMMENT '用户ID',
  `amount` decimal(10,2NOT NULL COMMENT '订单金额',
  `status` tinyint NOT NULL COMMENT '订单状态',
  `create_time` datetime NOT NULL COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_create_time` (`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

3.2 基础代码实现

// 实体类
@Data
@TableName("order_info")
public class OrderInfo {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String orderNo;
    private Long userId;
    private BigDecimal amount;
    private Integer status;
    private LocalDateTime createTime;
}

// Mapper
public interface OrderInfoMapper extends BaseMapper<OrderInfo> {
}

// Service
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderInfoService {
    private final OrderInfoMapper orderInfoMapper;

    public Page<OrderInfopageOrders(int pageNum, int pageSize, Long userId) {
        Page<OrderInfopage = new Page<>(pageNum, pageSize);
        LambdaQueryWrapper<OrderInfowrapper = Wrappers.lambdaQuery();
        wrapper.eq(OrderInfo::getUserId, userId)
               .orderByDesc(OrderInfo::getCreateTime);
        Page<OrderInforesult = orderInfoMapper.selectPage(page, wrapper);
        log.info("分页查询完成,总记录数:{}", result.getTotal());
        return result;
    }
}

// 分页插件配置
@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

3.3 问题复现与根因分析

执行pageOrders(1, 10, 1001L),查看日志:

DEBUG c.b.m.e.p.p.PaginationInnerInterceptor - 执行count查询:SELECT COUNT(0FROM order_info WHERE (user_id = ?)
DEBUG c.b.m.e.p.p.PaginationInnerInterceptor - 执行分页查询:SELECT id,order_no,user_id,amount,status,create_time FROM order_info WHERE (user_id = ?) ORDER BY create_time DESC LIMIT ?

看似count语句被优化了(单表无group by),但如果条件变为status = 1(无索引),或者原SQL有join,count性能会骤降。

更典型的慢场景:原SQL有join,比如关联用户表查询:

@Select("SELECT o.*, u.username FROM order_info o LEFT JOIN user_info u ON o.user_id = u.id WHERE o.user_id = #{userId}")
Page<OrderInfoVO> pageOrderVO(Page<OrderInfoVO> page, @Param("userId") Long userId);

此时MyBatis-Plus生成的count语句会是:

SELECT COUNT(0FROM (SELECT o.*, u.username FROM order_info o LEFT JOIN user_info u ON o.user_id = u.id WHERE o.user_id = ?) tmp

子查询+临时表,在千万级数据下,count()可能耗时数秒。

四、生产踩坑与避坑:5大优化方案

4.1 方案一:自定义count语句(最有效)

核心思路:避免子查询,直接写count语句,利用索引。

在Mapper中自定义count方法,MyBatis-Plus会自动识别:

// Mapper接口
public interface OrderInfoMapper extends BaseMapper<OrderInfo> {
    Page<OrderInfoVOselectOrderVOPage(Page<OrderInfoVO> page, @Param("userId") Long userId);
    Long selectOrderVOCount(@Param("userId") Long userId);
}
<!-- OrderInfoMapper.xml -->
<mapper namespace="com.example.mapper.OrderInfoMapper">
    <select id="selectOrderVOPage" resultType="com.example.vo.OrderInfoVO" count="selectOrderVOCount">
        SELECT o.id, o.order_no, o.user_id, u.username, o.amount, o.status, o.create_time
        FROM order_info o
        LEFT JOIN user_info u ON o.user_id = u.id
        WHERE o.user_id = #{userId}
        ORDER BY o.create_time DESC
    </select>

    <select id="selectOrderVOCount" resultType="java.lang.Long">
        SELECT COUNT(0)
        FROM order_info o
        WHERE o.user_id = #{userId}
    </select>
</mapper>

4.2 方案二:利用覆盖索引优化count()

核心思路:让count()查询走覆盖索引,避免回表。

如果经常按statuscreate_time分页查询:

-- 添加覆盖索引
ALTER TABLE order_info ADD INDEX idx_status_create_time (status, create_time);

此时count查询会走覆盖索引,无需回表。

4.3 方案三:不需要精确count时,使用缓存或估算

核心思路:对于列表页,用户不关心总记录数的精确值,可接受近似值或缓存。

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderInfoService {
    private final OrderInfoMapper orderInfoMapper;
    private final RedisTemplate<String, Object> redisTemplate;
    private static final String ORDER_COUNT_KEY = "order:count:userId:";

    public Page<OrderInfo> pageOrders(int pageNum, int pageSize, Long userId) {
        Page<OrderInfo> page = new Page<>(pageNum, pageSize);
        String key = ORDER_COUNT_KEY + userId;
        Long total = (Long) redisTemplate.opsForValue().get(key);
        if (total == null) {
            LambdaQueryWrapper<OrderInfo> wrapper = Wrappers.lambdaQuery();
            wrapper.eq(OrderInfo::getUserId, userId);
            total = orderInfoMapper.selectCount(wrapper);
            redisTemplate.opsForValue().set(key, total, 1, TimeUnit.HOURS);
        }
        page.setSearchCount(false);
        LambdaQueryWrapper<OrderInfo> wrapper = Wrappers.lambdaQuery();
        wrapper.eq(OrderInfo::getUserId, userId)
               .orderByDesc(OrderInfo::getCreateTime);
        Page<OrderInfo> result = orderInfoMapper.selectPage(page, wrapper);
        result.setTotal(total);
        return result;
    }
}

4.4 方案四:超大数据量,使用搜索引擎(Elasticsearch)

核心思路:将数据同步到ES,利用ES的count和分页能力。

实现步骤

  1. order_info表数据同步到ES(使用Canal、Flink CDC或Logstash);
  2. 分页查询时,直接调用ES的searchAPI,使用from/sizesearch_after分页,同时获取hits.total.value作为总记录数。

优势:ES的count性能极高,即使亿级数据也能毫秒级返回。

4.5 方案五:分库分表后的count处理

如果做了分库分表(如ShardingSphere),count()需要跨库统计,此时:

  • 方案1:使用ShardingSphere的COUNT聚合函数,它会自动在各分库执行count并汇总;
  • 方案2:将总记录数缓存到Redis,定期通过离线任务统计各分库的count并汇总更新。

五、性能优化进阶:从架构层面解决

5.1 读写分离:count查询走从库

将count查询和分页查询路由到从库,减轻主库压力:

spring:
  shardingsphere:
    datasource:
      names: master,slave
      master:
        type: com.zaxxer.hikari.HikariDataSource
        jdbc-url: jdbc:mysql://master-host:3306/db
        username: root
        password: xxx
      slave:
        type: com.zaxxer.hikari.HikariDataSource
        jdbc-url: jdbc:mysql://slave-host:3306/db
        username: root
        password: xxx
    rules:
      readwrite-splitting:
        data-sources:
          ds0:
            static-strategy:
              write-data-source-name: master
              read-data-source-names: slave
            load-balancer-name: round_robin

5.2 深度分页优化:避免大offset

当offset很大(如limit 1000000, 10),即使count快,分页也慢,此时可使用:

  • 游标分页:使用id > lastId代替offset,如SELECT * FROM order_info WHERE id > #{lastId} ORDER BY id LIMIT 10
  • ES search_after:适用于ES场景。

全文总结

MyBatis-Plus大表分页count()慢的核心根因是自动生成的count语句存在子查询或未利用索引。生产环境中,我们应按优先级选择优化方案:

  1. 首选:自定义count语句,避免子查询,利用覆盖索引;
  2. 次选:如果不需要精确count,使用Redis缓存总记录数;
  3. 进阶:超大数据量使用Elasticsearch;
  4. 架构:读写分离、分库分表、游标分页。

通过以上方案,可将千万级表的count()耗时从数秒降至毫秒级,彻底解决分页性能瓶颈。


作者:果酱 ,专注Java核心技术、分布式架构、性能优化与生产实战。 本文原创首发于阿里云,公众号,CSDN,稀土掘金,未经授权禁止任何形式的转载、抄袭与洗稿。 觉得文章有帮助的同学,欢迎点赞👍 收藏⭐ 关注✅。