Skip to content

未命名


分页与多表查询

我们要做一个接口:分页返回任务列表,并在每条记录里带上任务所属“部门/公会”的名字。
换句话说:既要分页,又要把另一张表的字段并到一行里显示。

本质有两件事要做:

  • 多表mission(任务)里有 dept_id,需要通过 LEFT JOINdept(部门)拿到 dept.name → 显示为 deptName
  • 分页:列表要有 总条数当前页数据 两部分,常规做法是 两条 SQLcount(*) + 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.propertiesapplication.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/sizedeptName 来自 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 &gt;= #{begin}
    </if>
    <if test="begin == null and end != null">
      and m.entry_date &lt;= #{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 多次查询”的写法。

评论