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

简介: 本文深入剖析MyBatis-Plus大表分页中count()性能瓶颈的根源,从源码解析其默认子查询机制,结合千万级订单表实战案例,提出5大可落地优化方案:自定义count语句、覆盖索引优化、Redis缓存估算、ES替代及读写分离等,助你将count耗时从数秒降至毫秒级。

在使用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 by、having、union等,会直接优化为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(64) NOT NULL COMMENT '订单号',
 `user_id` bigint NOT NULL COMMENT '用户ID',
 `amount` decimal(10,2) NOT 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<OrderInfo> pageOrders(int pageNum, int pageSize, Long userId) {
       Page<OrderInfo> page = new Page<>(pageNum, pageSize);
       LambdaQueryWrapper<OrderInfo> wrapper = Wrappers.lambdaQuery();
       wrapper.eq(OrderInfo::getUserId, userId)
              .orderByDesc(OrderInfo::getCreateTime);
       Page<OrderInfo> result = 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(0) FROM 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(0) FROM (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<OrderInfoVO> selectOrderVOPage(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()查询走覆盖索引,避免回表。

如果经常按status和create_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/size或search_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,稀土掘金,未经授权禁止任何形式的转载、抄袭与洗稿。 觉得文章有帮助的同学,欢迎点赞👍 收藏⭐ 关注✅。

目录
相关文章
|
前端开发 Java 关系型数据库
【Mybatis-Plus】mybatisplus更新时,实体字段为空,数据库不更新的解决方案
【Mybatis-Plus】mybatisplus更新时,实体字段为空,数据库不更新的解决方案
1914 0
|
NoSQL Linux Redis
linux下后台启动redis
linux下后台启动redis
747 0
|
存储 关系型数据库 MySQL
MySQL 处理大数据表的 3 种方案,写的太好了,建议收藏!!
MySQL 处理大数据表的 3 种方案,写的太好了,建议收藏!!
1575 0
|
11月前
|
JSON 缓存 Java
Spring Boot集成 Swagger2 展现在线接口文档
Swagger是一款用于生成和管理API文档的开源工具,解决前后端分离架构中接口文档滞后问题。通过集成Swagger2,可自动生成实时在线文档,并支持在线测试接口,提升开发效率。本文详解Spring Boot中集成Swagger2的配置与常用注解,如@Api、@ApiOperation等,实现接口文档自动化,便于团队协作与维护。
|
7月前
|
SQL 关系型数据库 MySQL
MySQL IN 里塞 10000 个值?90% 开发者都踩过的坑,底层原理 + 全场景解决方案一次讲透
本文深入剖析MySQL IN子句的底层执行逻辑,纠正常见误区(如“1000值硬限制”“IN=OR”),揭示大IN列表引发的性能、稳定性及安全风险,并提供分批查询、临时表JOIN、关联/EXISTS查询、流式查询四类场景化解决方案,附实测性能对比与生产避坑指南。
1032 4
|
存储 缓存 监控
MySQL服务器配置优化:my.cnf参数调优指南
本文深入解析了MySQL核心配置参数及性能优化技巧,涵盖内存结构、调优原则、存储引擎优化、查询性能优化等内容,通过实战案例帮助读者构建高性能MySQL服务器配置,解决常见的性能瓶颈问题。
1571 0
|
11月前
|
缓存 NoSQL Redis
千万级数据表的count(*)查询优化
针对千万级数据表`user_factor_auth_record`的COUNT查询性能问题,可通过“避免实时计数、独立计数表、Redis缓存”三大方案优化。优先从业务层面取消总条数展示,减轻数据库压力;若需精确值,可借助事务维护独立计数表,或定时缓存至Redis,分摊开销、提升查询效率。
736 5
|
前端开发
SpringBoot2.3.1集成Knife4j接口文档
SpringBoot2.3.1集成Knife4j接口文档
1397 44
|
SQL Java 数据库连接
MyBatisPlus-聚合查询、分组查询及等值查询
MyBatisPlus-聚合查询、分组查询及等值查询
3551 0
|
Java Apache
Java 使用word模板创建word文档报告教程
Java 使用word模板创建word文档报告教程
1322 0
Java 使用word模板创建word文档报告教程