在使用MyBatis-Plus进行大表分页查询时,你是否通过日志发现,分页插件总会先执行一条count()语句,且这条count()在千万级数据下耗时极长,严重拖慢整体响应?本文将从源码层面剖析MyBatis-Plus分页count()的执行机制,结合生产实战分析性能根因,并提供一套可落地的优化方案。
一、背景铺垫:MyBatis-Plus分页的基本流程
MyBatis-Plus通过MybatisPlusInterceptor分页插件实现物理分页,核心流程分为两步:
- count查询:先拦截原SQL,自动生成并执行
select count(0)语句获取总记录数; - 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和分页能力。
实现步骤
- 将
order_info表数据同步到ES(使用Canal、Flink CDC或Logstash); - 分页查询时,直接调用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语句存在子查询或未利用索引。生产环境中,我们应按优先级选择优化方案:
- 首选:自定义count语句,避免子查询,利用覆盖索引;
- 次选:如果不需要精确count,使用Redis缓存总记录数;
- 进阶:超大数据量使用Elasticsearch;
- 架构:读写分离、分库分表、游标分页。
通过以上方案,可将千万级表的count()耗时从数秒降至毫秒级,彻底解决分页性能瓶颈。
作者:果酱 ,专注Java核心技术、分布式架构、性能优化与生产实战。 本文原创首发于阿里云,公众号,CSDN,稀土掘金,未经授权禁止任何形式的转载、抄袭与洗稿。 觉得文章有帮助的同学,欢迎点赞👍 收藏⭐ 关注✅。