分页查询是后台系统最常见的功能之一。列表页、搜索页、管理后台、日志查询都会用到分页。MyBatis 中分页实现并不复杂,但如果 SQL、索引和分页方式设计不当,数据量大后会出现查询变慢、数据库 CPU 升高和接口超时。

本文介绍 MyBatis 分页查询的基本写法、总数查询、深分页问题和常见优化方法。

基础分页 SQL

MySQL 中常见分页写法:

1
2
3
4
5
select id, username, email, created_at
from users
where status = #{status}
order by created_at desc
limit #{offset}, #{pageSize}

offset 的计算:

1
int offset = (pageNo - 1) * pageSize;

Mapper 接口:

1
2
3
4
5
6
7
8
9
public interface UserMapper {
List<UserDO> selectPage(
@Param("status") Integer status,
@Param("offset") Integer offset,
@Param("pageSize") Integer pageSize
);

long countByStatus(@Param("status") Integer status);
}

XML:

1
2
3
4
5
6
7
8
9
10
11
12
13
<select id="selectPage" resultType="com.example.UserDO">
select id, username, email, created_at
from users
where status = #{status}
order by created_at desc
limit #{offset}, #{pageSize}
</select>

<select id="countByStatus" resultType="long">
select count(1)
from users
where status = #{status}
</select>

Service 组装结果:

1
2
3
4
5
6
public PageResult<UserVO> pageUsers(Integer status, int pageNo, int pageSize) {
int offset = (pageNo - 1) * pageSize;
long total = userMapper.countByStatus(status);
List<UserDO> records = userMapper.selectPage(status, offset, pageSize);
return PageResult.of(total, pageNo, pageSize, convert(records));
}

参数边界校验

分页参数必须限制范围:

1
2
pageNo = Math.max(pageNo, 1);
pageSize = Math.min(Math.max(pageSize, 1), 100);

不要允许前端传 pageSize=100000。这会导致数据库扫描大量数据,也可能让接口返回过大 JSON。

索引设计

分页 SQL 的性能很依赖索引。对于:

1
2
where status = ?
order by created_at desc

可以考虑联合索引:

1
create index idx_users_status_created_at on users(status, created_at);

索引设计要结合真实查询条件。如果还有租户字段:

1
2
3
where tenant_id = ?
and status = ?
order by created_at desc

索引可能需要调整为:

1
create index idx_users_tenant_status_created on users(tenant_id, status, created_at);

使用 explain 查看执行计划:

1
2
3
4
5
6
explain
select id, username, email, created_at
from users
where status = 1
order by created_at desc
limit 0, 20;

重点关注是否使用索引、扫描行数和是否出现 Using filesort

避免 select *

分页列表通常只需要展示部分字段。不要写:

1
2
select *
from users

应该只查询页面需要字段:

1
2
select id, username, email, created_at
from users

字段越多,IO、网络传输和对象映射成本越高。大字段如简介、配置 JSON、图片内容更不应该出现在列表查询中。

深分页问题

当页码很大时:

1
limit 1000000, 20

数据库需要跳过前面 1000000 行,再返回 20 行,性能会明显下降。

如果业务允许,可以使用游标分页,也叫基于上一页最后一条记录的分页:

1
2
3
4
5
6
select id, username, email, created_at
from users
where status = #{status}
and created_at < #{lastCreatedAt}
order by created_at desc
limit #{pageSize}

第一页不传 lastCreatedAt,下一页传上一页最后一条记录的 created_at

如果排序字段可能重复,建议加上 id 作为稳定排序:

1
2
3
4
5
6
7
8
9
select id, username, email, created_at
from users
where status = #{status}
and (
created_at < #{lastCreatedAt}
or (created_at = #{lastCreatedAt} and id < #{lastId})
)
order by created_at desc, id desc
limit #{pageSize}

对应索引:

1
create index idx_users_status_created_id on users(status, created_at, id);

游标分页不能直接跳到任意页,但非常适合信息流、日志列表和移动端下拉加载。

count 查询优化

count(*) 在大表上也可能很慢,尤其是条件复杂或无法使用索引时。

优化方式:

  • count 查询只保留必要条件,不要 join 无关表。
  • 为 count 条件建立合适索引。
  • 对实时性要求不高的总数使用缓存。
  • 对深分页场景不返回精确总数,只返回是否有下一页。

有些后台页面并不需要精确总数,可以使用 pageSize + 1 查询:

1
2
3
4
5
select id, username, email, created_at
from users
where status = #{status}
order by created_at desc
limit #{limit}

其中 limit = pageSize + 1。如果返回数量大于 pageSize,说明还有下一页。

使用分页插件的注意点

MyBatis 常用分页插件可以减少手写 offset 和 count 的代码。但插件不是性能优化工具,它只是帮你生成分页 SQL。

使用分页插件时仍然要关注:

  • 原始 SQL 是否合理。
  • 是否存在不必要的 join。
  • order by 是否使用索引。
  • count SQL 是否过慢。
  • pageSize 是否限制。

不要因为用了插件就忽略执行计划。

小结

MyBatis 分页查询的基础是 limit offset,pageSizecount,但真正影响线上性能的是索引、字段选择、排序方式和深分页。普通后台列表可以使用 offset 分页,并限制 pageSize;大数据量滚动加载应优先考虑游标分页;所有核心分页 SQL 都应通过 explain 检查执行计划。