SpringBoot+MyBatis 开发核心知识点总结
SpringBoot+MyBatis 开发手记
这篇文章来自一个员工管理后台接口项目的实际开发记录。不追求面面俱到,只写那些真正在代码里反复出现、出了问题需要深刻理解才能排查的知识点。
一、PageHelper 分页的底层发生了什么
MyBatis 本身不做分页
MyBatis 只管 SQL 与 Java 对象的映射。你写的 SELECT * FROM employee,它完整地发给数据库,数据库返回全部结果。数据量小的时候无所谓,百万级别的时候数据库 IO 和内存会直接打满。
分页靠的是在 SQL 尾部追加 LIMIT offset, size,这件事由 PageHelper 插件完成。
两行代码的背后
1 | PageHelper.startPage(pageNum, pageSize); // 第一行 |
第一行做的事极其简单:创建一个 Page 对象(包含 pageNum 和 pageSize),放进当前线程的 ThreadLocal。它不发任何 SQL,不发任何网络请求,只做了一件事——往当前线程上贴了个”需要分页”的标签。
第二行执行时,MyBatis 的插件链被触发,PageHelper 的拦截器(PageInterceptor)拦截了 Executor.query() 方法。拦截器内部的执行顺序:
- 从 ThreadLocal 取出 Page 对象
- 把原始 SQL 包装成
原始SQL + LIMIT offset, size - 额外执行一条
SELECT COUNT(*) FROM (原始SQL)获取总数 - 将数据列表和总数封装进 Page 对象
- 清理 ThreadLocal(不清理会导致内存泄漏,后续复用该线程时可能读到脏数据)
LIMIT 的 offset 计算公式:offset = (pageNum - 1) * pageSize
深分页的问题
LIMIT 100000, 10 这种写法,MySQL 需要先扫描前 100010 条,再丢弃前 100000 条。当数据量和偏移量都很大时,查询会变得极慢。
解决方法:用游标分页代替偏移量分页。
1 | -- 不用 LIMIT offset, size |
代价是你没法跳页,只能上一页/下一页。根据产品的实际需求做取舍。
Page 对象的类型陷阱
PageHelper 返回的 Page 对象继承了 ArrayList。这意味着你可以把它直接赋值给 List 类型的变量,编译不会报错。但一旦你这么做了,getTotal() 就丢了。
1 | // 问题写法 |
如果项目有统一的返回体规范(比如 PageResult),始终封装一层再返回,不要把 Page 对象直接暴露到 Controller 层。
1 | public PageResult<EmployeeVO> pageQuery(EmployeePageQueryDTO dto) { |
二、接口路径:虚拟路径与参数提取
URL 路径不指向文件
Spring MVC 中 @PostMapping("/api/employee/status/{status}") 跟服务器文件系统没有任何关系。它只是一个 URL 模式,匹配成功后由 DispatcherServlet 分派给对应的 Controller 方法。
类上的 @RequestMapping 和方法上的 @GetMapping/@PostMapping 是拼接关系,不是覆盖关系:
1 |
|
@PathVariable vs @RequestParam
| @PathVariable | @RequestParam | |
|---|---|---|
| 取值位置 | URL 路径 /user/10 |
查询参数 /user?id=10 |
| 默认值 | 不支持 defaultValue | 支持 defaultValue |
| 多值 | 不支持 | @RequestParam List<Integer> |
| 场景 | 资源标识(哪个用户) | 查询条件(筛选、排序、分页) |
在员工管理接口中,查询列表用的就是 @RequestParam 而不是路径拼接:
1 |
|
三、AOP 自动填充公共字段
问题背景
一个典型的数据库表有四个公共字段:create_time、create_user、update_time、update_user。新增时要填全部四个,修改时要填后两个。每个 Service 方法里都手动 set 一遍,代码又丑又容易忘。
AOP 切面的方案:定义注解标记 Mapper 方法,切面拦截后通过反射自动给实体对象的公共字段赋值。
注解定义
1 |
|
用户 ID 如何传递
公共字段需要知道”当前操作的用户是谁”,这个信息通过 ThreadLocal 在请求生命周期内传递:
1 | public class BaseContext { |
在 JWT 拦截器的 preHandle 中解析 token 拿到用户 ID 存入 ThreadLocal,在 afterCompletion 中调用 remove() 清理。这一步如果忘了清理,线程池复用时下一个请求可能读到上一个用户的 ID——这种 Bug 极难复现和定位。
切面实现
1 |
|
使用和可能踩的坑
1 |
|
反射调用的 set 方法名必须跟实体类的字段名严格对应。字段叫 updateUser,set 方法就必须叫 setUpdateUser,参数类型也必须是 Long 而不是 Integer——否则抛 NoSuchMethodException。这个问题在新增字段后最容易出现:实体类加了字段,改了字段名,但忘了对应修改切面或者切面里写错了类型。
四、启动报错的高频原因
Bean 创建失败
报错类似 Field employeeMapper required a bean of type 'com.sky.mapper.EmployeeMapper' that could not be found。
检查清单,按频率排序:
- Mapper 接口没加
@Mapper注解 - 启动类没配
@MapperScan("com.sky.mapper") - Service 层没加
@Autowired注入 - 包路径不在扫描范围内
数据库连接失败
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 + 首字母大写, 参数类型必须跟字段类型完全一致(Long 和 long 不一样,LocalDateTime 和 Date 不一样)。
五、消息转换器的两个实用配置
日期格式化
Jackson 默认序列化 LocalDateTime 的结果不符合前端预期。在 WebMvcConfigurer 中扩展消息转换器:
1 |
|
Long 类型精度丢失
JavaScript 的 Number 类型安全整数范围是 -2^53 ~ 2^53,而 Java 的 Long 范围更大。雪花算法生成的 ID(18-19 位)传给前端后,最后几位会被截断变成不同的数字。
解决方法:序列化时把 Long 转成 String。
1 | SimpleModule module = new SimpleModule(); |
这个配置加上之后,所有 Long 类型都会序列化为字符串。如果你的前端之前按数字处理这些字段,需要同步改一下。
六、日常开发中的几个习惯
异常处理:全局异常处理器只放一个兜底的 @ExceptionHandler(Exception.class),其他按异常类型各写各的。业务异常统一继承 RuntimeException,不要在 Controller 里写 try-catch。
参数校验:用 @Valid + DTO 里的校验注解,不要让校验逻辑散落在 Service 代码里。校验失败时框架会自动抛出 MethodArgumentNotValidException,在全局异常处理器里统一捕获返回前端。
日志:关键操作(新增、修改、删除)打 INFO 日志时带上操作对象的 ID 或标识字段,不然后面排查问题只能靠猜。异常日志的 message 里不要只写”操作失败”,把关键参数也打进去。
