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 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(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()查询走覆盖索引,避免回表。

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

目录
相关文章
|
18天前
|
运维 监控 Java
从单体地狱到微服务天堂:架构演进与拆分的核心原则+全链路实战落地
本文系统阐述微服务本质与渐进式演进路径:破除“盲目拆分”误区,强调业务驱动;详解单体→模块化→垂直拆库→非核心服务→核心服务的五步安全演进;提炼高内聚低耦合、数据自治、业务域对齐等七大落地原则;辅以电商实战代码与避坑指南。
272 6
|
28天前
|
Java 程序员 API
告别繁琐:五个提升效率的Java编码技巧
告别繁琐:五个提升效率的Java编码技巧
351 139
|
1月前
|
消息中间件 存储 Java
吃透 RocketMQ
本文全面介绍Apache RocketMQ分布式消息中间件的核心架构、底层原理和生产实践。首先解析RocketMQ四大核心组件(NameServer、Broker、Producer、Consumer)的职责与协作机制,重点剖析其高性能存储设计(CommitLog、ConsumeQueue、IndexFile)、刷盘策略和主从复制原理。随后详细讲解基于Dledger Raft协议的高可用集群部署方案,包含环境准备、配置优化和监控部署。
346 6
|
7天前
|
缓存 供应链 架构师
数据架构是什么?一文讲清数据架构和技术架构的区别
本文系统解析企业数字化核心框架——“4A架构”(业务、数据、应用、技术架构),阐明其严格递进的逻辑链:业务架构定方向(做什么)、数据架构转语言(数据化表达)、应用架构落功能(系统实现)、技术架构保运行(稳定支撑)。破除“重技术轻业务”误区,助企业构建贴合实际、可演进的数字化架构体系。
数据架构是什么?一文讲清数据架构和技术架构的区别
|
20天前
|
算法 Java 关系型数据库
JVM GC 深度破局:G1 与 ZGC 底层原理、生产调优全链路实战
本文深度解析JDK17主流GC:G1(默认,兼顾吞吐与延迟)与ZGC(革命性低延迟,STW&lt;1ms)。涵盖核心理论(可达性分析、三色标记)、内存布局、全流程机制(SATB写屏障 vs 染色指针+读屏障)、关键参数调优及生产选型指南,助你精准定位性能瓶颈,高效优化JVM。
430 4
|
24天前
|
存储 缓存 监控
JVM 运行时数据区全解:从底层原理到 OOM 根因定位全链路实战
JVM运行时数据区是Java内存管理的核心,分为线程私有区域(程序计数器、虚拟机栈、本地方法栈)和线程共享区域(堆、方法区)。不同区域有明确的OOM触发规则:堆内存不足引发Java heap space异常,元空间不足导致Metaspace异常,直接内存溢出表现为Direct buffer memory错误。排查OOM需结合异常类型、堆dump、GC日志等现场数据,使用MAT等工具分析内存泄漏点。
412 1
|
1月前
|
缓存 Java 开发者
吃透 Spring Bean 生命周期:从源码底层到实战落地
本文深度解析Spring 6.2.3 Bean生命周期,涵盖BeanDefinition注册、实例化、属性填充、Aware回调、BeanPostProcessor前后置处理、初始化(@PostConstruct/InitializingBean/init-method)、AOP代理、单例缓存及销毁全流程,结合源码、实战示例与生产问题排查,助你彻底掌握IoC核心机制。
456 3
|
1月前
|
SQL Java 测试技术
告别 CRUD 泥沼!DDD 领域驱动设计:从底层原理到生产级全链路落地实战
DDD是应对复杂业务的架构思想,核心是“领域优先、边界隔离”:通过战略设计(统一语言、限界上下文、上下文映射)划清业务边界;通过战术设计(实体/值对象、聚合根、领域服务等)落地高内聚、低耦合的代码。非银弹,适用于规则多、迭代快、协作难的场景。
799 1
|
5天前
|
JavaScript Linux API
零基础玩转OpenClaw部署及避坑指南:从阿里云到本地多系统搭建,附千问大模型API配置与免费模型接入方案
OpenClaw(原Clawdbot)作为2026年热门的开源AI执行网络,凭借其强大的任务执行与大模型集成能力,成为开发者与企业用户的首选AI工具之一。本文将详细介绍2026年阿里云轻量服务器部署及本地MacOS/Linux/Windows11部署OpenClaw的完整步骤,同时涵盖阿里云千问大模型API配置与免费大模型Coding Plan API配置方法,并针对部署过程中的常见问题提供解决方案,帮助用户快速完成OpenClaw的部署与使用。
353 4
|
19天前
|
弹性计算 人工智能 数据库
2026年阿里云服务器试用与购买活动参考:免费试用、新购优惠、特价云服务器续费政策解析
2026年阿里云为新老用户推出多项优惠政策,涵盖免费试用、新购优惠及特价云服务器续费。用户可零成本体验高品质云服务,新购时享受轻量应用服务器及长效特惠云服务器的空前折扣。特价云服务器续费政策明确透明,99元和199元档位新购续费同价,提供长期成本可预测性。此外,用户可领取阿里云优惠券,在部分云服务器活动价格基础上再获减免。阿里云构建了对新手友好、对老用户忠诚的优惠体系,助力个人和企业无忧上云。

热门文章

最新文章