SpringBoot+MyBatis 开发手记

这篇文章来自一个员工管理后台接口项目的实际开发记录。不追求面面俱到,只写那些真正在代码里反复出现、出了问题需要深刻理解才能排查的知识点。

一、PageHelper 分页的底层发生了什么

MyBatis 本身不做分页

MyBatis 只管 SQL 与 Java 对象的映射。你写的 SELECT * FROM employee,它完整地发给数据库,数据库返回全部结果。数据量小的时候无所谓,百万级别的时候数据库 IO 和内存会直接打满。

分页靠的是在 SQL 尾部追加 LIMIT offset, size,这件事由 PageHelper 插件完成。

两行代码的背后

1
2
PageHelper.startPage(pageNum, pageSize);  // 第一行
employeeMapper.pageQuery(dto); // 第二行

第一行做的事极其简单:创建一个 Page 对象(包含 pageNum 和 pageSize),放进当前线程的 ThreadLocal。它不发任何 SQL,不发任何网络请求,只做了一件事——往当前线程上贴了个”需要分页”的标签。

第二行执行时,MyBatis 的插件链被触发,PageHelper 的拦截器(PageInterceptor)拦截了 Executor.query() 方法。拦截器内部的执行顺序:

  1. 从 ThreadLocal 取出 Page 对象
  2. 把原始 SQL 包装成 原始SQL + LIMIT offset, size
  3. 额外执行一条 SELECT COUNT(*) FROM (原始SQL) 获取总数
  4. 将数据列表和总数封装进 Page 对象
  5. 清理 ThreadLocal(不清理会导致内存泄漏,后续复用该线程时可能读到脏数据)

LIMIT 的 offset 计算公式:offset = (pageNum - 1) * pageSize

深分页的问题

LIMIT 100000, 10 这种写法,MySQL 需要先扫描前 100010 条,再丢弃前 100000 条。当数据量和偏移量都很大时,查询会变得极慢。

解决方法:用游标分页代替偏移量分页。

1
2
3
-- 不用 LIMIT offset, size
-- 改用 WHERE id > lastId LIMIT 10
SELECT * FROM employee WHERE id > 100000 LIMIT 10;

代价是你没法跳页,只能上一页/下一页。根据产品的实际需求做取舍。

Page 对象的类型陷阱

PageHelper 返回的 Page 对象继承了 ArrayList。这意味着你可以把它直接赋值给 List 类型的变量,编译不会报错。但一旦你这么做了,getTotal() 就丢了。

1
2
3
4
5
6
7
8
// 问题写法
List<Employee> employees = employeeMapper.pageQuery(dto);
// employees 能遍历,但拿不到总记录数

// 正确写法
Page<Employee> page = (Page<Employee>) employeeMapper.pageQuery(dto);
page.getTotal(); // 数据库总记录数
page.getResult(); // 当前页数据

如果项目有统一的返回体规范(比如 PageResult),始终封装一层再返回,不要把 Page 对象直接暴露到 Controller 层。

1
2
3
4
5
6
7
8
public PageResult<EmployeeVO> pageQuery(EmployeePageQueryDTO dto) {
PageHelper.startPage(dto.getPage(), dto.getPageSize());
Page<Employee> page = (Page<Employee>) employeeMapper.pageQuery(dto);
List<EmployeeVO> voList = page.getResult().stream()
.map(this::toVO)
.collect(Collectors.toList());
return PageResult.of(page.getTotal(), voList);
}

二、接口路径:虚拟路径与参数提取

URL 路径不指向文件

Spring MVC 中 @PostMapping("/api/employee/status/{status}") 跟服务器文件系统没有任何关系。它只是一个 URL 模式,匹配成功后由 DispatcherServlet 分派给对应的 Controller 方法。

类上的 @RequestMapping 和方法上的 @GetMapping/@PostMapping 是拼接关系,不是覆盖关系:

1
2
3
4
5
6
7
8
9
10
@RestController
@RequestMapping("/api")
public class EmployeeController {

@GetMapping("/employee") // 完整路径: GET /api/employee
public Result list() { }

@PostMapping("/employee/{id}") // 完整路径: POST /api/employee/10
public Result getById(@PathVariable Long id) { }
}

@PathVariable vs @RequestParam

@PathVariable @RequestParam
取值位置 URL 路径 /user/10 查询参数 /user?id=10
默认值 不支持 defaultValue 支持 defaultValue
多值 不支持 @RequestParam List<Integer>
场景 资源标识(哪个用户) 查询条件(筛选、排序、分页)

在员工管理接口中,查询列表用的就是 @RequestParam 而不是路径拼接:

1
2
3
4
5
6
7
@GetMapping("/employees")
public Result list(
@RequestParam(required = false) String name,
@RequestParam(required = false) Integer status,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "10") Integer size
) { }

三、AOP 自动填充公共字段

问题背景

一个典型的数据库表有四个公共字段:create_timecreate_userupdate_timeupdate_user。新增时要填全部四个,修改时要填后两个。每个 Service 方法里都手动 set 一遍,代码又丑又容易忘。

AOP 切面的方案:定义注解标记 Mapper 方法,切面拦截后通过反射自动给实体对象的公共字段赋值。

注解定义

1
2
3
4
5
6
7
8
9
10
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface AutoFill {
OperationType value(); // INSERT 或 UPDATE
}

public enum OperationType {
INSERT, // 填四个字段
UPDATE // 填修改时间和修改人
}

用户 ID 如何传递

公共字段需要知道”当前操作的用户是谁”,这个信息通过 ThreadLocal 在请求生命周期内传递:

1
2
3
4
5
6
7
public class BaseContext {
private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();

public static void setCurrentId(Long id) { threadLocal.set(id); }
public static Long getCurrentId() { return threadLocal.get(); }
public static void removeCurrentId() { threadLocal.remove(); }
}

在 JWT 拦截器的 preHandle 中解析 token 拿到用户 ID 存入 ThreadLocal,在 afterCompletion 中调用 remove() 清理。这一步如果忘了清理,线程池复用时下一个请求可能读到上一个用户的 ID——这种 Bug 极难复现和定位。

切面实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Aspect
@Component
public class AutoFillAspect {

@Pointcut("execution(* com.sky.mapper.*.*(..)) && @annotation(com.sky.annotation.AutoFill)")
public void autoFillPointCut() {}

@Before("autoFillPointCut()")
public void autoFill(JoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
AutoFill autoFill = signature.getMethod().getAnnotation(AutoFill.class);
OperationType operationType = autoFill.value();

Object[] args = joinPoint.getArgs();
if (args == null || args.length == 0) return;
Object entity = args[0]; // 约定第一个参数是实体对象

LocalDateTime now = LocalDateTime.now();
Long currentId = BaseContext.getCurrentId();

if (operationType == OperationType.INSERT) {
invokeSet(entity, "setCreateTime", now, LocalDateTime.class);
invokeSet(entity, "setCreateUser", currentId, Long.class);
invokeSet(entity, "setUpdateTime", now, LocalDateTime.class);
invokeSet(entity, "setUpdateUser", currentId, Long.class);
} else if (operationType == OperationType.UPDATE) {
invokeSet(entity, "setUpdateTime", now, LocalDateTime.class);
invokeSet(entity, "setUpdateUser", currentId, Long.class);
}
}

private void invokeSet(Object entity, String methodName, Object value, Class<?> paramType) {
try {
entity.getClass().getDeclaredMethod(methodName, paramType).invoke(entity, value);
} catch (Exception e) {
throw new RuntimeException("自动填充失败: " + methodName, e);
}
}
}

使用和可能踩的坑

1
2
3
4
5
6
7
8
9
@Mapper
public interface EmployeeMapper {
@AutoFill(OperationType.INSERT)
@Insert("INSERT INTO employee(name, username) VALUES(#{name}, #{username})")
void insert(Employee employee);

@AutoFill(OperationType.UPDATE)
void update(Employee employee);
}

反射调用的 set 方法名必须跟实体类的字段名严格对应。字段叫 updateUser,set 方法就必须叫 setUpdateUser,参数类型也必须是 Long 而不是 Integer——否则抛 NoSuchMethodException。这个问题在新增字段后最容易出现:实体类加了字段,改了字段名,但忘了对应修改切面或者切面里写错了类型。

四、启动报错的高频原因

Bean 创建失败

报错类似 Field employeeMapper required a bean of type 'com.sky.mapper.EmployeeMapper' that could not be found

检查清单,按频率排序:

  1. Mapper 接口没加 @Mapper 注解
  2. 启动类没配 @MapperScan("com.sky.mapper")
  3. Service 层没加 @Autowired 注入
  4. 包路径不在扫描范围内

数据库连接失败

Cannot create PoolableConnectionFactory (Access denied for user 'root'@'localhost')

最常见的三个原因:

  • MySQL 服务没启动(Windows 上 net start mysql
  • application.yml 里的数据库名跟实际库名不一致
  • 时区参数写错了(用 serverTimezone=Asia/Shanghai 而不是 UTC)

AOP 切面的 NoSuchMethodException

实体类的 set 方法名和反射调用的方法名不一致时会直接抛这个异常。确认规则:字段 xxx 的 set 方法名一定是 set + 首字母大写, 参数类型必须跟字段类型完全一致(Longlong 不一样,LocalDateTimeDate 不一样)。

五、消息转换器的两个实用配置

日期格式化

Jackson 默认序列化 LocalDateTime 的结果不符合前端预期。在 WebMvcConfigurer 中扩展消息转换器:

1
2
3
4
5
6
7
8
9
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));
objectMapper.setTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));
converter.setObjectMapper(objectMapper);
converters.add(0, converter); // 放到第一位保证优先使用
}

Long 类型精度丢失

JavaScript 的 Number 类型安全整数范围是 -2^53 ~ 2^53,而 Java 的 Long 范围更大。雪花算法生成的 ID(18-19 位)传给前端后,最后几位会被截断变成不同的数字。

解决方法:序列化时把 Long 转成 String。

1
2
3
4
SimpleModule module = new SimpleModule();
module.addSerializer(Long.class, ToStringSerializer.instance);
module.addSerializer(Long.TYPE, ToStringSerializer.instance);
objectMapper.registerModule(module);

这个配置加上之后,所有 Long 类型都会序列化为字符串。如果你的前端之前按数字处理这些字段,需要同步改一下。

六、日常开发中的几个习惯

异常处理:全局异常处理器只放一个兜底的 @ExceptionHandler(Exception.class),其他按异常类型各写各的。业务异常统一继承 RuntimeException,不要在 Controller 里写 try-catch。

参数校验:用 @Valid + DTO 里的校验注解,不要让校验逻辑散落在 Service 代码里。校验失败时框架会自动抛出 MethodArgumentNotValidException,在全局异常处理器里统一捕获返回前端。

日志:关键操作(新增、修改、删除)打 INFO 日志时带上操作对象的 ID 或标识字段,不然后面排查问题只能靠猜。异常日志的 message 里不要只写”操作失败”,把关键参数也打进去。