2026/10/13 7:03:40

Spring Boot 3 + MyBatis-Plus SSM实战:从环境配置到批量插入与排查

Spring Boot 3 + MyBatis-Plus SSM实战:从环境配置到批量插入与排查 第七天的项目标题写着 springboot3 MyBatis-Plus 的 SSM 实战实际上就是要把 Spring、Spring MVC、MyBatis 这套经典三件套用最现代的方式重新组合一遍。过去我们写 SSM卷得最多的是各种 XML 配置web.xml、spring-mvc.xml、mybatis-config.xml每个都上百行部署还要打成 war 包塞进 Tomcat。换到 Spring Boot 3 以后内嵌容器和自动配置把这些流程压缩到几行配置而 MyBatis-Plus 又把持久层里最重复的单表增删改查直接封装好了剩下的活就是写业务。这个项目虽然是个用户管理模块的练手级别但覆盖了框架整合、统一响应、分页插件、批量插入、逻辑删除这些真实开发里每天都在用的点适合刚学完 Spring 基础想跑通完整项目的同学也适合被老 SSM 配置折磨过、想看看新组合长什么样的老手。1. 从 SSM 到 Spring Boot 3这套组合到底变了什么1.1 老 SSM 的经典分层和痛点SSM 的名字由来是 Spring Spring MVC MyBatis。它的分层思路到今天也是主流Controller 接收请求Service 处理业务Mapper 访问数据库页面或 JSON 做输出。这套东西本身没毛病问题出在工程化和配置成本上。老项目里要手动配置 web.xml 来注册 DispatcherServletSpring 容器怎么加载、事务怎么切、MyBatis 的 SqlSessionFactory 怎么注进去全部依靠 XML。我第一次搭 SSM 环境的时候光是把三个 XML 文件的标签捋明白就花了两三天而且不同版本的 Spring 对 schema 头非常敏感一个地址连不上就直接启动失败。更别说还要引入各种第三方 jar 包版本冲突能折腾一整天。这种“配置地狱”其实是框架历史沿革造成的。Spring 本来就是为了解决 EJB 的复杂而诞生但早期 Spring 的 XML 方案本身又引入了一堆配置。真正的转机是 Spring Boot 的自动配置机制它把“最常见的默认值”预置好内嵌 Tomcat 让你不用再装外部容器java -jar 一个命令就启动。再配合 Maven 的 starter 依赖聚合以前要手工拼的十几个 jar现在一个 starter 就带齐了。1.2 Spring Boot 3 带来的关键变化Spring Boot 3 和 2.x 不是一个量级的升级最直接的一条硬规则是JDK 必须 17 以上。如果你的开发机还停留在 Java 8那这个项目根本跑不起来。其次是包名从 javax.* 换成了 jakarta.*这意味着所有老的项目要迁移时光 import 替换就是一波体力活。第三是 Spring Boot 3 底层对应 Spring Framework 6对第三方组件兼容性要求更高很多老版本的 mybatis-spring、pagehelper 都不能直接用。正因为如此MyBatis-Plus 从 3.5.3 版本开始专门提供了mybatis-plus-spring-boot3-starter名字里带着 boot3你要是拿旧版的 starter 往 Spring Boot 3 项目里塞大概率会遇到ClassNotFoundException: jakarta.servlet之类的报错。版本这个东西我建议直接去官网看支持矩阵。实际开发时最稳妥的组合是JDK 17 Spring Boot 3.2.x MyBatis-Plus 3.5.x。这几个版本经过社区大量项目验证坑比较少。除非业务真的需要否则不要追最新的小版本抢鲜版往往意味着某些已知问题还没收敛。1.3 实战项目的目录设计与分层约定老 SSM 项目常见做法是把包拆得很细controller、service、mapper 各一层到今天这个习惯依然值得保留。第七天的项目我按下面这个结构放代码com.example.demo ├── controller │ └── UserController.java ├── service │ ├── UserService.java │ └── impl │ └── UserServiceImpl.java ├── mapper │ └── UserMapper.java ├── entity │ └── User.java ├── config │ ├── MybatisPlusConfig.java │ └── MyMetaObjectHandler.java ├── dto │ └── UserSaveDTO.java └── common ├── Result.java └── PageResult.java这么拆的最大好处是依赖单向流动Controller 依赖 ServiceService 依赖 Mapper谁都不反向依赖。刚开始写代码时可能觉得包太多但是等项目里有了订单、商品、权限一堆模块之后你就能感受到这种分层的价值——出了问题直接定位层不用在一个巨型类里上下翻。我见过不少项目图省事把 SQL 写在 Controller 里当时确实是爽了后面想要加个事务都不知道从哪里下手。分层不是给别人看的是给三个月后的自己看的。2. 环境搭建与关键依赖配置2.1 依赖清单与版本选择第七天项目用 Maven 管理核心依赖就三块。第一块是 web 启动器解决 Spring MVC 和 Tomcat第二块是 MyBatis-Plus 的 Boot3 启动器第三块是数据库驱动我这里用 MySQL所以引入 mysql-connector-j。完整片段如下parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version3.5.5/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependencies这里要特别提醒一下MyBatis-Plus 3.5.5 在 Maven 坐标上是com.baomidou:mybatis-plus-spring-boot3-starter不是mybatis-plus-boot-starter。后者的 Spring Boot 3 适配版本虽然也能搜到但踩坑概率大。凡是启动报Failed to introspect Class或NoSuchMethodError先检查是不是这个依赖混用了。2.2 application.yml 中 MP 相关的关键配置配置文件是 Spring Boot 项目里少数必须手工写清楚的地方。数据库连接我用 HikariCPSpring Boot 2 之后就默认集成不需要额外引依赖直接配置spring.datasource即可。MyBatis-Plus 相关的配置我习惯贴在mybatis-plus节点下spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.demo.entity几个配置项分别对应什么map-underscore-to-camel-case解决数据库下划线字段名和 Java 驼峰属性名的映射不加的话查询结果里created_time只能留在 null 里logic-delete-field声明全局逻辑删除字段这样调用deleteById时 MP 会自动把 SQL 改成 UPDATEmapper-locations告诉 MP 去哪加载 XML 文件type-aliases-package让 XML 里写实体别名时可以省略包名。这些配置都属于“不用可能不报错但迟早要出事”的类型宁可一开始就配上。2.3 日志配置logback-spring.xml 还是 log4j2第七天项目里日志选型也是绕不开的。Spring Boot 3 默认使用 Logback这也是官方最省事的方案因为日志框架已经内置在spring-boot-starter-logging里你只要放一个配置文件就能控制全局输出。网上经常有人问要不要换 Log4j2老实说除非公司统一要求或你有特殊性能诉求否则真没必要折腾默认 Logback 够用且稳定。如果你决定用 Logback文件名一定要叫logback-spring.xml不要叫logback.xml。区别在于前者允许使用springProfile标签可以在不同环境应用不同配置。我项目里的简化片段如下configuration appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n/pattern /encoder /appender appender nameFILE classch.qos.logback.core.rolling.RollingFileAppender filelogs/app.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePatternlogs/app.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n/pattern /encoder /appender root levelINFO appender-ref refCONSOLE/ appender-ref refFILE/ /root /configuration如果团队确实用 Log4j2引入spring-boot-starter-log4j2之后要在spring-boot-starter-web里排除spring-boot-starter-logging否则两个框架同时存在类路径冲突会让你看到一堆SLF4J: Class path contains multiple SLF4J bindings的警告日志还不会按预期输出。这个属于典型的“生产事故级小配置”排查起来非常恶心。3. 基于 MyBatis-Plus 的核心业务实现3.1 实体类表名、主键、逻辑删除、版本号先创建一个用户表字段包括 id、name、email、age、deleted、create_time 等。实体类代码并没有多少技术含量但几个注解的含义必须搞清楚Data TableName(user) public class User { TableId(type IdType.AUTO) private Long id; private String name; private String email; private Integer age; TableLogic private Integer deleted; TableField(fill FieldFill.INSERT) private LocalDateTime createTime; TableField(fill FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; Version private Integer version; }TableName处理实体类和表名不一致的问题TableId声明主键策略TableField(fill ...)配合自动填充处理器可以在 insert 或 update 时自动写入时间字段省得每个插入语句都手动 set 一次Version是乐观锁标记更新时会自动带上版本条件WHERE version ?防止并发覆盖。这里最容易翻车的点是如果字段名和数据库列名不同你不但要加TableName还得给字段补TableField(user_name)否则 MP 默认按驼峰转下划线去匹配匹配不上就会报字段无效。3.2 Mapper 与 ServiceBaseMapper 和 IService 的正确姿势第七天项目的 Mapper 接口继承BaseMapperUser一行没写就成了一个满血 CRUD 工具。这里要说清楚一个很多人误解的点BaseMapper封装的都是单表操作比如selectById、selectList、insert、updateById、deleteById它们帮我们省掉了最重复的 SQL但不是让你从此不写 SQL。遇到多表 join 或复杂条件统计时要么在 Mapper 里加自定义方法并用 XML 实现要么用 MP 的 Wrapper 构造条件。Mapper public interface UserMapper extends BaseMapperUser { ListUser selectByName(Param(name) String name); }对应的 XML 文件放在resources/mapper/UserMapper.xmlnamespace 必须和接口全限定名一致?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper select idselectByName resultTypecom.example.demo.entity.User SELECT * FROM user WHERE name #{name} /select /mapperService 层我直接用IService和ServiceImpl这套现成的模板ServiceImplUserMapper, User已经实现了IServiceUser并且注入了BaseMapper的实例。这样写的好处是saveBatch、lambdaQuery、page这些方法开箱即用不用自己再包装一层。你唯一需要关心的是在自定义 Service 接口里别把事务写散——一个完整的业务操作应该放在一个方法里配上Transactional而不是在 Controller 里多次调用 Service 的原子方法。3.3 Controller 层统一响应与参数校验Controller 是入口最重要的两个习惯是统一响应结构、入口做参数校验。第七天项目我写了一个小的ResultT类包含 code、message、data 三个字段所有接口都返回这个包装。不要返回裸对象否则前端处理异常时非常痛苦。RestController RequestMapping(/user) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } GetMapping(/{id}) public ResultUser get(PathVariable Long id) { return Result.ok(userService.getById(id)); } PostMapping public ResultBoolean save(Validated RequestBody UserSaveDTO dto) { User user new User(); BeanUtils.copyProperties(dto, user); return Result.ok(userService.save(user)); } }注意我用了构造器注入而不是Autowired字段注入这在 Spring 官方文档里是被推荐的它让依赖关系在构造阶段就明确暴露测试时也不用反射。参数校验上用Validated 实体里的NotBlank、Email这类注解省掉手写 if 判断。这是我比较推荐的姿势老项目里 Controller 里堆了几十行 parse 和 verify看着就头大。4. 批量插入与性能优化实战4.1 saveBatch 到底是快还是慢第七天项目里最值得深挖的其实是批量插入。MyBatis-Plus 提供的saveBatch用起来确实方便一句代码就能插入几十条数据但很多人没搞明白它的实现方式。默认情况下saveBatch是循环执行单条 insert也就是一次一次往数据库发 SQL。数据量小的时候没感觉一旦到了几万条网络往返和 SQL 解析开销会让你看到很吓人的耗时。真正的性能拐点在 JDBC 连接参数上。MySQL 驱动在默认情况下不允许把多条 insert 语句合并成一个批量请求必须手动在连接串上加rewriteBatchedStatementstrue。我在前面写连接 URL 时特意加了这个参数加了之后saveBatch会把同一张表的批量 insert 改写为INSERT INTO user (...) VALUES (...), (...), (...)这样的大语句速度能提升一个量级。给你一个我印象深刻的对比数字本地 MySQL 插入 1 万条用户数据不加参数时耗时大约是 3 秒多加了 rewriteBatchedStatements 之后压到了 0.5 秒以内。所以如果你的项目准备用saveBatch第一件事就是检查连接串有没有这个参数没有的话先补上再谈其他优化。4.2 用 SQL 注入器实现真正的批量插入不过rewriteBatchedStatements只对 JDBC batch 生效saveBatch默认还是一遍遍走 MyBatis 执行器。如果不想依赖驱动行为另一种常见方案是自定义 SQL 注入器。MyBatis-Plus 允许通过继承AbstractMethod并注册新的注入方法把一条INSERT INTO t (...) VALUES (...) , (...)拼出来。public class InsertBatchMethod extends AbstractMethod { Override public MappedStatement injectMappedStatement(Class? mapperClass, Class? modelClass, TableInfo tableInfo) { // 拼接字段列表和值列表利用 foreach 批量生成 String sql scriptINSERT INTO tableInfo.getTableName() ( StringUtils.join(tableInfo.getFieldList(), ,) ) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.email}, #{item.age})/foreach/script; return addInsertMappedStatement(mapperClass, modelClass, insertBatch, sql); } }这只是个示意片段实际还要处理主键策略、字段填充等问题。从维护角度讲我建议优先用saveBatch只有在数据量确实很大、且对性能有硬指标时才上注入器。自定义注入器写错一个括号就是全项目 SQL 崩盘。如果你只是想快速落地MP 自带的saveBatch加了连接参数后已经足够应对绝大多数业务场景。4.3 批量操作里的隐藏坑批量插入还容易踩两个坑。第一个是逻辑删除字段如果你的实体里定义了TableLogic批量插入时 MP 会把deleted字段自动补成 0这本来是好事。但如果你在数据库表里给deleted字段设置了默认值 1 或者不允许为空批量插入时可能因为字段缺失而报错。建议实体里全部显式声明不要指望数据库默认。第二个坑是自动填充字段在批量场景下的行为。TableField(fill FieldFill.INSERT)配合MetaObjectHandler会在单条插入时生效但批量插入时部分版本不会对填充字段统一处理最终导致 insert SQL 里缺少 create_time 列。排查方法很简单打开 SQL 日志看实际生成的语句不要靠猜。这类问题你要是盯着控制台看通常一眼就能发现缺了哪一列。5. 常见问题与排查技巧实录5.1 分页插件失效分页是实战里的高频需求MP 提供PaginationInnerInterceptor。新版 Boot3 配置方式如下Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }很多人分页不生效的症状是调用userService.page(page, wrapper)后返回的记录数正常但 SQL 里没有出现 LIMIT其实是因为项目里同时引用了旧的 pagehelper或者配置类没有被 Spring 扫描到。还有一类情况是自定义 SQL 用Page作为参数但忘记把Page放在第一个参数位置MP 就不会对它做分页包装。记住一点分页参数必须是查询方法的第一个参数返回类型里的泛型列表才会被替换为分页后的结果。5.2 MyBatis-Plus 和 Spring Data JPA 怎么选我和不少后端同学聊过这个话题很多人纠结 JPA 还是 MP。简单做个对照维度MyBatis-PlusSpring Data JPASQL 控制力完全可控支持 XML 和注解面向对象为主复杂 SQL 走 JPQL/原生 SQL动态 SQL支持条件构造器、XML 标签非常灵活需要 Specification 或手写 JPQL复杂条件较啰嗦学习曲线低懂 SQL 就上手偏高要理解持久化上下文、懒加载等概念多表查询直接写 join思路简单用关系映射容易踩 N1适合团队SQL 功底强的老 Java 团队追求领域模型的团队、Spring Data 生态重度用户迁移成本实体类简单SQL 直观事务边界、缓存机制迁移时要额外小心我的建议是如果团队里大部分人写 SQL 比写 HQL 顺手或者项目里有大量报表、多表统计查询选 MyBatis-Plus 效率更高。如果一个新项目完全由领域模型驱动、关联关系复杂且希望尽量减少原生 SQLJPA 也完全合理。最怕的不是选谁而是项目里两套混着用那才是真正的维护灾难。5.3 自动填充不生效、事务自调用失效自动填充不生效的常见原因是自定义MetaObjectHandler没有注册成 Bean。你需要写一个处理器Component public class MyMetaObjectHandler implements MetaObjectHandler { Override public void insertFill(MetaObject metaObject) { strictInsertFill(metaObject, createTime, LocalDateTime.class, LocalDateTime.now()); strictInsertFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } Override public void updateFill(MetaObject metaObject) { strictUpdateFill(metaObject, updateTime, LocalDateTime.class, LocalDateTime.now()); } }事务失效的场景则更隐蔽。如果你在UserServiceImpl里写了一个方法内部又直接this.someTransactionalMethod()调用另一个带Transactional的方法此时this是原始对象而不是 Spring 的代理对象事务注解不会生效。解决办法要么把事务方法拆到另一个 Service要么注入自身的代理或者干脆把事务直接加在入口方法上。另外Transactional加在非 public 方法上也白搭和异常被 catch 后吞掉都不回滚这三点是面试题常客也是开发事故的高发点。5.4 日志无法输出 SQL 的排查最后聊个非常常见的小问题明明log-impl: org.apache.ibatis.logging.stdout.StdOutImpl已经配了但控制台死活不打印 SQL。原因一般有两个一是配置文件没有生效检查是不是把mybatis-plus.configuration.log-impl写错了层级二是日志框架的全局级别设置把 debug 信息拦截了。Spring Boot 的默认 level 是 INFOMyBatis 的日志实现通过 INFO 或者 DEBUG 输出如果你的 root level 被调成 WARNSQL 自然就没了。排查时先看日志文件里有没有 MyBatis 相关的 logger再看自己的 logback 文件里是不是把com.example.demo级别设成了 ERROR。这类问题看着小但现场排查起来特别耗时间所以我在项目初始就直接把配置写全避免后续反复试。在这个第七天的实战项目里我最深的感受是SSM 这个概念本身没有过时变的是载体和效率。以前我们要花大量时间在框架配置和环境串联上现在 Spring Boot 3 MyBatis-Plus 把底层琐事抹平反而更需要我们把精力放在分层设计、批量性能、事务边界、日志规范这些真正影响线上质量的地方。最后再分享一个小技巧调试时把log-impl换成StdOutImpl直接看 SQL上线前再换成Slf4j或者去掉这个小改动能省下你无数次的断点调试时间。另外如果条件允许把同一个项目分别用老 SSM 配置方式和 Boot3 写一遍你会发现很多“为什么”都不用刻意记对比一下自然就明白了。