深浅模式
分页与多表查询
我们要做一个接口:分页返回任务列表,并在每条记录里带上任务所属“部门/公会”的名字。
换句话说:既要分页,又要把另一张表的字段并到一行里显示。
本质有两件事要做:
- 多表:
mission
(任务)里有dept_id
,需要通过 LEFT JOIN 到dept
(部门)拿到dept.name
→ 显示为deptName
。 - 分页:列表要有 总条数 和 当前页数据 两部分,常规做法是 两条 SQL(
count(*)
+select … limit
)或借助插件在一次调用里完成。
下面给出两种写法:先原生,再插件。
表结构如下:
sql
create table if not exists dept (
id int primary key auto_increment,
name varchar(50) not null
);
create table if not exists mission (
id int primary key auto_increment,
title varchar(100) not null,
dept_id int not null,
update_time datetime not null default current_timestamp on update current_timestamp,
constraint fk_mission_dept foreign key (dept_id) references dept(id)
);
insert into dept(name) values ('银月公会'), ('苍狼军团');
insert into mission(title, dept_id) values
('清剿哥布林洞窟', 2),
('护送银月学者', 1),
('修复古塔符阵', 1);
返回模型只放“页面要展示的样子”,不要把数据库表全盘端上来:
java
// 每行记录长这样:任务 + 部门名
public class MissionView {
private Integer id;
private String title;
private String deptName; // 由 join 得到
private java.time.LocalDateTime updateTime;
// getter/setter 省略
}
// 分页外壳:前端要 total + rows
public class PageBean<T> {
private long total;
private java.util.List<T> rows;
// 构造/getter/setter 省略
}
为了让控制台能看见执行的 SQL,字段自动对齐,建议在全局配置里打开这两个选项:
原生 MyBatis(使用 mybatis-config.xml
)
适用于没接 Spring 的项目,比如我们在 src/main/resources
里建的 mybatis-config.xml
。
在 <configuration>
节点中添加 <settings>
:
xml
<configuration>
<settings>
<!-- 控制台打印 SQL 与参数 -->
<setting name="logImpl" value="STDOUT_LOGGING"/>
<!-- 下划线字段自动映射为驼峰属性 -->
<setting name="mapUnderscoreToCamelCase" value="true"/>
</settings>
<!-- 其他配置略 -->
</configuration>
Spring Boot 项目(使用 application.properties
或 application.yml
)
Spring Boot 集成 MyBatis 时,这些配置可以直接放到配置文件里:
properties
# application.properties 写法
mybatis.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
mybatis.configuration.map-underscore-to-camel-case=true
或等价的 YAML 写法:
yaml
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
map-underscore-to-camel-case: true
区别只是语法,效果完全一样。
注意 log-impl
的值这里要写完整类名:org.apache.ibatis.logging.stdout.StdOutImpl
,
这是 MyBatis 内置的标准输出日志实现。
原生 MyBatis
思路是直的:第一页第 N 条,数据库只认 offset/size
;deptName
来自 LEFT JOIN
的别名。
1)Mapper
java
public interface MissionMapper {
long countAll(); // 统计总数
java.util.List<MissionView> pageAll(int offset, int size); // 当前页数据
}
2)XML 语句
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.wreckloud.wolfpack.mapper.MissionMapper">
<select id="countAll" resultType="long">
select count(*) from mission
</select>
<select id="pageAll" resultType="com.wreckloud.wolfpack.view.MissionView">
select
m.id,
m.title,
d.name as deptName,
m.update_time as updateTime
from mission m
left join dept d on m.dept_id = d.id
order by m.update_time desc
limit #{offset}, #{size}
</select>
</mapper>
3)分页服务
java
public class MissionServiceLike {
private final com.wreckloud.wolfpack.mapper.MissionMapper mapper;
public MissionServiceLike(com.wreckloud.wolfpack.mapper.MissionMapper mapper){ this.mapper = mapper; }
// page 从 1 开始更贴近日常使用
public PageBean<MissionView> list(int page, int pageSize) {
int offset = (page - 1) * pageSize;
long total = mapper.countAll();
java.util.List<MissionView> rows = mapper.pageAll(offset, pageSize);
PageBean<MissionView> bean = new PageBean<>();
bean.setTotal(total);
bean.setRows(rows);
return bean;
}
}
这样做的因果非常清楚:deptName 从 join 来,分页靠 limit,total 由 count 得。日志里你能看到两条 SQL 各自的参数与结果。
PageHelper 插件
如果项目用了 Spring Boot + MyBatis,可以用 PageHelper 简化分页。它通过线程上下文拦截下一条查询,在 SQL 上自动拼接 limit
并统计总数,代码更简。
当然可以,主子。我来给你写一个更聚焦在 Service 层改法的「PageHelper 快速上手」片段。
重点不放在理论和 Mapper,而是告诉初学者如何把原本写死的分页逻辑改成一行 PageHelper.startPage()
。
分页通常会这样写:
java
// 传统写法(mapper传start和pageSize)
int start = (page - 1) * pageSize;
List<MemberView> list = memberMapper.findPage(start, pageSize);
long total = memberMapper.count();
return new PageBean<>(total, list);
- 必须在 Mapper 里写两条 SQL(查列表 + 查总数);
start
要自己算;- SQL 可读性变差。
用 PageHelper,这些体力活可以全都干掉 👇
java
@Service
public class MemberService {
@Resource private MemberMapper memberMapper;
public PageBean<MemberView> page(int page, int pageSize) {
// 1. 开启分页拦截(对接下来的查询自动拼 limit)
PageHelper.startPage(page, pageSize);
// 2. 直接查数据,无需 limit
List<MemberView> list = memberMapper.findAll();
// 3. PageInfo 里已经包含 total
PageInfo<MemberView> info = new PageInfo<>(list);
// 4. 封装 PageBean 统一格式返回
PageBean<MemberView> bean = new PageBean<>();
bean.setTotal(info.getTotal());
bean.setRows(list);
return bean;
}
}
需要改动的,其实只有两步:
① Service 层:
删掉自己计算 start
的逻辑,换成 PageHelper.startPage()
。
② Mapper 层:
原本写 limit #{start}, #{pageSize}
的地方全部删掉。
只保留纯净的 SQL,比如 👇
java
@Select("select id, name, gender from member order by update_time desc")
List<MemberView> findAll();
1)引入依赖
xml
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>1.4.7</version>
</dependency>
2)Mapper
java
public interface MissionMapper {
// 注意:这里直接把 join 写在 SQL/Xml 里,方法本身不带分页参数
@org.apache.ibatis.annotations.Select("""
select m.id, m.title, d.name as deptName, m.update_time as updateTime
from mission m
left join dept d on m.dept_id = d.id
order by m.update_time desc
""")
java.util.List<MissionView> listWithJoin();
}
(或放到 XML 里写 <select id="listWithJoin">…</select>
,效果一致)
3)Service
java
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
@Service
public class MissionService {
@Resource private MissionMapper missionMapper;
public PageBean<MissionView> page(int page, int pageSize){
PageHelper.startPage(page, pageSize); // 开启分页:对“下一条查询”生效
java.util.List<MissionView> rows = missionMapper.listWithJoin();
PageInfo<MissionView> info = new PageInfo<>(rows); // 含 total/页数/是否有下一页等
PageBean<MissionView> bean = new PageBean<>();
bean.setTotal(info.getTotal());
bean.setRows(rows);
return bean;
}
}
这套写法把分页细节藏到插件里:你只负责写“要查什么”,分页由 PageHelper 接手。
注意它是线程上下文生效:startPage()
只影响在它之后、当前线程内的第一条查询调用。
条件分页查询
当王国的成员越来越多时,单纯的分页查询已经不够用了。
我们不仅想看到名单,还想——按名字搜索、按性别筛选、甚至按入职时间段查找。
这时就需要 条件分页查询。
目标:在任务管理系统中分页查询成员列表,支持姓名模糊搜索、性别筛选、时间范围查询,并按修改时间倒序排列。
查询目标与请求格式
在“成员管理”界面,用户可能会输入几个筛选条件:
- 姓名(模糊匹配,比如输入“雷”能查到“雷恩”)
- 性别(精确匹配)
- 入职时间范围(begin / end)
- 分页展示(每页显示固定条数)
对应的请求长这样:
GET /members?page=1&pageSize=10&name=雷&gender=1&begin=2023-01-01&end=2025-01-01
我们要做的,是在后端根据这些条件动态拼接 SQL。
有条件就带上,没条件就跳过;最后结果按更新时间倒序分页返回。
接收查询参数
如果把这些参数都一个个写在方法上,Controller 会显得很乱。
因此我们用一个参数对象来封装所有筛选项。
java
@Data
public class MemberQueryParam {
private Integer page = 1; // 页码
private Integer pageSize = 10; // 每页条数
private String name; // 姓名(模糊)
private Integer gender; // 性别(精确)
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate begin; // 入职开始时间
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate end; // 入职结束时间
}
这个对象一般放在项目的 dto(Data Transfer Object)层,命名上与业务对应。
com.wreckloud.wolfpack/
├── controller/ → 处理请求
├── service/ → 编写业务逻辑
├── mapper/ → 访问数据库
├── dto/ → 封装查询参数(MemberQueryParam)
└── common/ → 放分页等通用类(PageBean)
它只负责“接收请求参数”,不参与业务逻辑,也不会被持久化。
在 Controller 里,就能这样使用:
java
@GetMapping("/members")
public Result page(MemberQueryParam param){
log.info("条件分页查询:{}", param);
PageBean<MemberView> pageBean = memberService.page(param);
return Result.success(pageBean);
}
Spring 会自动把请求参数绑定到对象字段中,比如:
?name=雷&gender=1
会自动填进 param
里。
Service 层
分页这件事的“脉搏”在 Service:
它既决定何时启用分页,也负责把查询结果与分页元信息装成统一响应结构返回给 Controller。
我们继续用 PageHelper,PageHelper.startPage(page, pageSize)
会在当前线程里打一个拦截“标记”,只对紧随其后的那一条查询生效:
- 拦截器会先生成一条
select count(*) ...
统计总数; - 再对原 SQL 追加
limit ?, ?
完成分页。
因此,startPage
必须紧挨着你的那次查询。
java
@Service
public class MemberService {
@Resource private MemberMapper memberMapper;
public PageBean<MemberView> page(MemberQueryParam param) {
// 0) 容错与兜底(避免前端传 0/负数/超大页)
int page = (param.getPage() == null || param.getPage() < 1) ? 1 : param.getPage();
int pageSize = (param.getPageSize() == null || param.getPageSize() < 1) ? 10 : param.getPageSize();
// 给 pageSize 一个上限,防止一次拉太多(按需调整)
pageSize = Math.min(pageSize, 100);
// 1) 开启分页(只影响下一条查询)
PageHelper.startPage(page, pageSize);
// 2) 执行 Mapper 的动态 SQL(含 where / order by)
List<MemberView> rows = memberMapper.pageQuery(param);
// 3) 解析分页元信息(总条数、总页数、是否有下一页等)
PageInfo<MemberView> info = new PageInfo<>(rows);
// 4) 统一封装返回
PageBean<MemberView> bean = new PageBean<>();
bean.setTotal(info.getTotal());
bean.setRows(rows);
return bean;
}
}
Mapper 层
这次查询条件多、组合又灵活,我们把 SQL 放到 XML,用 <if>
/ <where>
等动态标签按需拼接——这样既清晰又安全。
<where>
:会自动处理多余的and
,不用担心空条件报错。<if>
:按条件拼 SQL。参数没传,这段语句就不会加上。
下面给出一个更健壮的实现(包含模糊匹配、精确匹配、范围查询,并能容忍只传 begin 或只传 end):
xml
<select id="pageQuery" resultType="com.wreckloud.wolfpack.view.MemberView">
select
m.id,
m.name,
m.gender,
d.name as deptName,
m.entry_date as entryDate,
m.update_time as updateTime
from member m
left join dept d on m.dept_id = d.id
<where>
<!-- 模糊匹配 -->
<if test="name != null and name != ''">
m.name like concat('%', #{name}, '%')
</if>
<!-- 性别筛选 -->
<if test="gender != null">
and m.gender = #{gender}
</if>
<!-- 时间范围:begin、end 同时存在 -->
<if test="begin != null and end != null">
and m.entry_date between #{begin} and #{end}
</if>
<!-- 单边范围:只传 begin 或 end -->
<if test="begin != null and end == null">
and m.entry_date >= #{begin}
</if>
<if test="begin == null and end != null">
and m.entry_date <= #{end}
</if>
</where>
order by m.update_time desc
</select>
模糊匹配的写法
如果模糊匹配直接写:
sql
m.name like `%#{name}%`
常导致控制台出现:
Preparing: SELECT ... WHERE m.name LIKE AND m.gender = ?
那是因为 %#{}
这种写法根本不会被识别为合法 SQL,MyBatis 拼完后就少了右侧参数,直接报错。
正确写法是:
sql
m.name like concat('%', #{name}, '%')
concat
是 MySQL 的字符串拼接函数,能在数据库端拼出'%雷%'
这种结构。#{}
是安全占位符,MyBatis 会预编译后再绑定值,不会被注入。
返回结构
PageBean
保持统一风格:total + rows
。
java
@Data
public class PageBean<T> {
private long total; // 符合条件的总记录数
private List<T> rows; // 当前页数据
}
几个容易翻车的小细节
1)startPage
的位置
它只对下一条查询生效;
中间如果穿插了别的 select,就会被“误分页”。因此写法上要紧贴查询。
2)排序与分页的先后
排序写在 XML 的 order by
,分页由 PageHelper 自动拼 limit
;
顺序应当是:where → order by → limit
。我们在 Mapper 已经照此安排。
3)极端页码与 pageSize
后端要有兜底:负数/0 页 → 回 1;超大 pageSize
→ 截断到上限(如 100/200)。
这既保护数据库,又让接口行为可预期。
4)Count 代价与优化
默认会做一次 count(*)
。当你的多表 join 很重时,这一步可能会慢:
- 可用缓存层挡住高频相同条件;
- 或在“已知不需要总条数”的场景,考虑改造接口为“游标/滚动分页”(此节不展开)。
5)只读查询不必加事务
这类分页查询一般不加 @Transactional
。
如果后续要在同一方法里做读写混合,再按事务边界设计。
6)线程与清理
PageHelper 用 ThreadLocal
保存分页标记,只影响当前线程;
一次查询结束后会自动清除,不需要手动 clear
。
但要避免“一次 startPage
多次查询”的写法。
评论