
1. 项目背景与整体设计思路1.1 为什么在这个时间节点选择Spring Boot 3我最近在一个新项目里把技术栈切到了 Spring Boot 3 MyBatis-Plus 3.5.9整体体验下来确实有不少值得说的东西。先说结论如果你是一个新启动的 Java 后端项目现在可以直接上 Spring Boot 3 了不用担心生态不成熟的问题。Spring Boot 3 从 2022 年底发布至今经过几个大版本的迭代稳定性和生态兼容性都已经过了最佳磨合期MyBatis-Plus 3.5.9 也是在这个背景下对 Spring Boot 3 做了完整适配的关键版本。Spring Boot 3 最大的变化是底层基于 Spring Framework 6JDK 基线提升到了 17。这意味着很多老项目里用的javax前缀包名全部改成了jakarta如果你过去写过import javax.servlet.*这样的代码迁移到 Spring Boot 3 时第一步就是把这些导入改成jakarta.servlet.*。这个改动本身很机械但涉及面广特别是你自己封装了拦截器、过滤器、监听器这些组件的时候每一处都要检查。另外一个值得关注的点是 Spring Boot 3 默认支持原生镜像编译AOT虽然我们这次没有直接用 GraalVM但如果你未来有云原生场景的部署需求提前站在 Spring Boot 3 这一侧后面做适配的成本会低很多。选型本质上是做一个取舍是用成熟的 Spring Boot 2.x MyBatis-Plus 老版本继续“稳”还是用 Spring Boot 3 这条新主线。我的建议是老项目没必要强行迁移风险大于收益但新项目直接选 Spring Boot 3后续两年内不会有技术债务。1.2 MyBatis-Plus 3.5.9 在整套架构中的角色MyBatis-Plus 在 Java 后端的地位类似于“增强版 MyBatis 工具箱”。它做的事情本质上是把 MyBatis 中大量重复的 CRUD 模板操作做了抽象和封装让你不用每个实体类都写一份 XML 映射文件和对应的 Mapper 接口方法。3.5.9 这个版本有几个值得注意的更新点修复了旧版本中分页插件在某些极端 SQL 场景下的拦截异常增强了saveBatch批量操作在非 MySQL 数据库上的兼容性同时对 Spring Boot 3 的自动配置做了更细致的条件化处理。从架构设计角度看MyBatis-Plus 扮演的是“数据访问层半成品”的角色——它把你的 Mapper 接口变成了一组现成的方法集合包括selectById、selectList、insert、updateById、deleteById这些基础操作不需要你写任何 SQL。但需要清醒认识到MyBatis-Plus 解决的是 80% 的简单 SQL 场景剩下的 20% 复杂查询仍然需要你手写 SQL。在项目规划阶段就要把这个边界划清楚否则团队里容易出现“为了不用写 SQL 而硬用 LambdaQueryWrapper 拼复杂条件”的畸形代码。我这次项目的整体设计思路是用 MyBatis-Plus 提供通用 CRUD 骨架用自定义 Service 层做业务扩展用 XML 文件承载复杂查询。这套组合既能保证日常开发的效率又不会在遇到复杂业务时被框架能力卡住脖子。2. 环境准备与依赖配置实战2.1 JDK 版本与开发环境选型Spring Boot 3 的 JDK 基线是 17这是硬性门槛。如果你本机还在用 Java 8那第一步就是装 JDK 17。这里有一个实操层面比较重要的点JDK 17 和 JDK 8 在长期支持LTS策略上都属于长期支持版本但 Spring Boot 3 官方推荐的搭配是 JDK 17 或更新版本。项目里我建议直接用 JDK 17因为 JDK 21 虽然也已经进入 LTS 序列但部分云厂商的基础镜像还停留在 JDK 17 层面部署环境的兼容性要提前确认。另外开发工具方面IDEA 需要升级到较新的版本2023.1 以后对 Spring Boot 3 项目的识别更友好VSCode 用户注意安装 Spring Boot Extension Pack 和 Java Extension Pack 的最新版本。这里有个小坑旧版本 IDEA 在导入 Spring Boot 3 项目时Maven 自动导入阶段有时会报Unresolved dependency: org.springframework.boot:spring-boot-starter-parent这类错误多半不是依赖本身的问题而是 IDEA 的 Maven 插件缓存太旧导致的清一下缓存重启就好。2.2 Maven 依赖配置与踩坑记录直接上依赖配置这是我在项目里实测可用的版本组合parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.4/version relativePath/ /parent properties java.version17/java.version mybatis-plus.version3.5.9/mybatis-plus.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-spring-boot3-starter/artifactId version${mybatis-plus.version}/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 在 Spring Boot 3 集成时不能再使用老版本的mybatis-plus-boot-starter必须使用mybatis-plus-spring-boot3-starter。这个 starter 从 MyBatis-Plus 3.5.4 版本开始提供专门针对 Spring Boot 3 的自动配置机制做了适配。如果搞混了启动时大概率会报ClassNotFoundException: jakarta.servlet.ServletException之类的问题因为老版本 starter 依赖的是 javax 包体系。另一个细节是 MySQL 驱动。Spring Boot 3.x 的依赖管理默认拉取的是com.mysql:mysql-connector-j这也是官方推荐使用的新坐标。如果你原来项目里用的是mysql:mysql-connector-java在 Spring Boot 3 中需要更换坐标。这个坐标变更并不复杂但不要忽略。2.3 配置文件编写数据源与 MyBatis-Plus 关键配置依赖配好后下一步是写application.yml。我习惯性地把 MyBatis-Plus 相关的配置单独整理出来方便后面对照调整spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl global-config: db-config: id-type: assign_id logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 mapper-locations: classpath*:/mapper/**/*.xml type-aliases-package: com.example.demo.entitymap-underscore-to-camel-case这个配置建议在 3.5.x 版本中显式开启。虽然 MyBatis-Plus 默认已经开了驼峰映射但显式写出来能让团队里所有人都明确这个约定。id-type: assign_id用的是雪花算法生成 ID这是分布式场景下的合理默认值比数据库自增主键更适合在分库分表场景中保持全局唯一。逻辑删除这块建议优先配置好logic-delete-field指定实体类中的逻辑删除字段名logic-delete-value和logic-not-delete-value定义删除和未删除的值。注意逻辑删除和唯一约束有天然冲突比如用户表里手机号加了唯一索引逻辑删除后手机号字段还在数据里下次新用户注册同一手机号时就会唯一索引冲突。这个问题不是 MyBatis-Plus 特有而是逻辑删除方案的固有问题后续文章里我会单独展开。3. 通用 CRUD 服务封装与实战解析3.1 BaseMapper零 SQL 完成单表 CRUDMyBatis-Plus 最核心的价值在于BaseMapperT接口。你的 Mapper 接口只要继承它就自动获得了十几个单表 CRUD 方法。看一个实际例子public interface UserMapper extends BaseMapperUser { // 这里不需要写任何方法selectById/selectList/insert/updateById/deleteById 都自带 // 复杂查询仍然自己写 ListUser selectByCondition(Param(name) String name, Param(age) Integer age); }对应实体类Data TableName(t_user) public class User { TableId(type IdType.ASSIGN_ID) private Long id; private String name; private Integer age; private String email; TableLogic private Integer deleted; }这里有个注解细节TableName(t_user)用来指定实体类对应的表名。如果代码里表名字段和数据库表名不一致这个注解必须写否则 MyBatis-Plus 会默认把User映射到user表运行时直接报“表不存在”。TableId(type IdType.ASSIGN_ID)指定主键生成策略用雪花算法自动填充 ID。如果不加这个注解且主键字段名不是idMyBatis-Plus 在插入时会因为不知道哪个字段是主键而报错。之前项目里遇到过一个小问题数据库表设计里主键字段叫user_id实体类字段叫userId这种情况就必须用TableId(value user_id)显式指定。不要指望 MyBatis-Plus 能从userId智能推测出它对应user_id还是id注解写清楚最保险。3.2 Service 层封装避免每个 Service 重复造轮子Service 层的封装是提升开发效率的关键一步。MyBatis-Plus 提供了IServiceT接口和ServiceImplM extends BaseMapperT, T实现类基于它们可以快速构建一套标准的 Service 层。但实际开发中我更推荐在这个基础上再做一层简单的抽象统一处理返回结果和异常public interface IBaseServiceT extends IServiceT { V V getByIdDetail(Long id, ClassV voClass); boolean saveBatchQuietly(CollectionT entityList); }不过这里要克制一个冲动不要试图把 Service 层设计得过于“通用”。Service 层的核心职责是承载业务逻辑如果一个方法所有实体类都能用同一个模板完成那它大概率属于 BaseMapper 的能力边界不需要在 Service 层重复包装。我见过不少项目在 Service 层封装了一堆saveOrUpdateBatch、listByWrapper的透传方法最后维护成本比直接用 BaseMapper 还高。通用 CRUD 的正确姿势是让框架帮你处理那些完全无业务逻辑的单表操作业务方法则老老实实在各自 Service 里写。3.3 通用 CRUD 接口的一层表现层设计很多项目会在 Controller 层直接注入 Mapper 来执行查询这种直连数据层的方式维护起来会非常痛苦。一个更合理的方式是在系统边界层做统一封装包括统一响应结构、统一异常拦截、统一的参数校验入口。我建议 Controller 层这么设计RestController RequestMapping(/api/users) RequiredArgsConstructor public class UserController { private final UserService userService; GetMapping(/{id}) public ResultUserVO getUserById(PathVariable Long id) { User user userService.getById(id); if (user null) { return Result.fail(404, 用户不存在); } UserVO vo new UserVO(); BeanUtils.copyProperties(user, vo); return Result.ok(vo); } PostMapping public ResultLong createUser(Validated RequestBody UserCreateDTO dto) { User user new User(); BeanUtils.copyProperties(dto, user); userService.save(user); return Result.ok(user.getId()); } DeleteMapping(/{id}) public ResultVoid deleteUser(PathVariable Long id) { userService.removeById(id); return Result.ok(null); } }这个设计有几个要点Controller 不直接操作 Mapper而是调用 Service入参用 DTO 而非实体类避免前端字段直通数据表造成信息过曝出参用 VO 而非实体类避免把deleted、create_time这类字段直接返给前端。BeanUtils.copyProperties这种拷贝方式会造成一定的性能开销但可读性高适合中小规模项目需要极致性能的场景可以后续改成 MapStruct。4. 核心功能实现分页、批量与 LambdaQueryWrapper4.1 分页插件配置与分页查询实操分页是实际项目中使用频率最高的功能MyBatis-Plus 的分页插件也一直是它的王牌能力。分页插件不是默认开启的需要手动配置拦截器Configuration MapperScan(com.example.demo.mapper) public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }如果没有注册这个拦截器调用page方法时数据能查出来但不会执行分页统计返回的total会是 0这个坑非常隐蔽。我见过不止一个同事在启动日志里没看到SELECT COUNT(*)语句还以为是 MyBatis-Plus 的 bug实际上就是拦截器没注册。分页查询的标准写法PageUser page new Page(current, size); LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(name), User::getName, name) .ge(age ! null, User::getAge, age) .orderByDesc(User::getCreateTime); PageUser result userMapper.selectPage(page, wrapper);分页插件生成的 COUNT 语句在某些复杂 SQL 下会报错比如查询里包含distinct或者多表 join 时。3.5.9 版本在 count 优化上做得很好但如果遇到自定义 SQL 的分页统计不准时可以考虑用InterceptorIgnore注解跳过拦截器处理或者自己写一个独立 count 查询。实际上 3.5.9 的PaginationInnerInterceptor对 join 查询的 count 处理已经足够聪明它会尝试优化成轻量的子查询统计。还有一个值得注意的细节分页插件默认的maxLimit是不受限的。如果调用方传了一个非常大的current和size一次查询可能要扫描大量数据。建议在插件上设置一下最大限制PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L);这样超过 500 条的单页查询会自动被限制住避免恶意或误操作导致数据库压力陡增。4.2 批量操作saveBatch 与自定义批量 SQL批量插入是高频场景之一。MyBatis-Plus 的IService.saveBatch方法可以批量保存实体集合底层默认执行的是逐条 INSERT虽然框架内部用 ExecutorType.BATCH 模式做了一轮优化但在数据量较大时性能还是不够理想。测试下来在 MySQL 上插入一万条数据逐条插入需要 10 秒以上改成拼接多值 INSERT 语句后可以压到 1 秒以内。如果对批量插入性能有要求推荐自定义 SQLinsert idinsertBatch INSERT INTO t_user (id, name, age, email, deleted) VALUES foreach collectionlist itemitem separator, (#{item.id}, #{item.name}, #{item.age}, #{item.email}, 0) /foreach /insert对应 Mapper 接口int insertBatch(Param(list) ListUser users);使用这个方式要注意 SQL 长度限制。MySQL 的 max_allowed_packet 默认是 64MB每一批插入 500~1000 条是比较稳妥的量级。我项目里封装一个批量插入方法每次分批 500 条循环插入既保证了单次 SQL 的体量不会超限又保证了整体插入效率Transactional(rollbackFor Exception.class) public void batchInsertUsers(ListUser userList) { if (CollectionUtils.isEmpty(userList)) { return; } ListListUser partition Lists.partition(userList, 500); for (ListUser batch : partition) { userMapper.insertBatch(batch); } }注意事务控制批量操作一定要加上Transactional并且要明确rollbackFor Exception.class。默认的Transactional只对RuntimeException回滚如果业务方法抛了受检异常则不会触发回滚这是个高频坑点。批量更新方面saveBatch对应的updateBatchById也是逐条 UPDATE数据量大时性能同样堪忧。真正高效的批量更新方案通常需要基于 CASE WHEN 语法在 XML 里用 foreach 动态拼接多个更新条件这是 MySQL 8.0 中比较推荐的做法。4.3 LambdaQueryWrapper 的取舍与复杂查询边界LambdaQueryWrapper 是 MyBatis-Plus 中最常用的构造器它用 Lambda 表达式引用实体类字段避免在代码里以字符串硬编码数据库字段名。随手写一个组合查询ListUser users userMapper.selectList(new LambdaQueryWrapperUser() .eq(User::getStatus, 1) .likeRight(User::getName, 张) .between(User::getAge, 18, 35) .in(User::getRoleId, Arrays.asList(1L, 2L, 3L)) .orderByDesc(User::getCreateTime) .last(limit 10));likeRight会生成LIKE 张%走索引比前置通配符%张更友好。.last(limit 10)这种写法虽然方便但要注意如果拼接了用户可控的字符串会有 SQL 注入风险绝对不要让任何变量进入.last()方法。复杂查询的边界在 AbstractWrapper 上需要特别注意。举个例子OR 条件组合、子查询这些场景用 Wrapper 拼起来的可读性很差。当 Wrapper 出现三层以上嵌套、或者需要关联多张表时就该回到 XML 文件手写 SQL。这是我项目里一条不成文的硬性约定团队代码 review 时也会重点检查这块。随意设置一个经验值单表查询且条件在 5 个以内用 LambdaQueryWrapper超过这个阈值或者涉及 join就建立对应 VO 类 XML 手写 SQL。5. 日志配置logback 与 log4j2 实战对比5.1 Spring Boot 3 中如何切换日志框架日志配置在真实项目里远比大多数人想象的更重要。Spring Boot 3 默认使用 Logback 作为日志实现同时支持 Log4j2 切换。两者的核心差异Logback 更稳定、接入零成本异步日志配置简洁Log4j2 在高并发场景下吞吐量优于 Logback且支持无垃圾日志模式但配置复杂度更高。网上搜“springboot3 log4j2”能搜到一堆配置教程这里直接给结论中小型项目用默认的 Logback 完全足够压测阶段如果发现日志同步写入会降低吞吐量就开启 Logback 异步写入追求极致性能且团队有能力维护复杂配置时再考虑 Log4j2。日志框架切换的代价不仅是依赖调整还包括配置格式、归档策略、与 skylog/SLS 等日志收集服务的对接方式全面变化这些隐形成本经常被忽略。如果确实要切换到 Log4j2需要手动排除默认日志依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId exclusions exclusion groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-logging/artifactId /exclusion /exclusions /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-log4j2/artifactId /dependency切换之后需要新建log4j2-spring.xml配置文件。Spring Boot 环境下 Log4j2 的配置文件命名有讲究必须使用log4j2-spring.xml而不是log4j2.xml这样才能触发 Spring Boot 对 Log4j2 的自动增强处理。5.2 logback-spring.xml一套可复制到生产环境的配置如果你选择留在 Logback我推荐直接把这个logback-spring.xml文件复制到src/main/resources下把应用名改掉就能直接用?xml version1.0 encodingUTF-8? configuration springProperty scopecontext nameAPP_NAME sourcespring.application.name defaultValueapp/ property nameLOG_HOME value${LOG_PATH:-./logs}/${APP_NAME}/ appender nameCONSOLE classch.qos.logback.core.ConsoleAppender encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%thread] %logger{36} - %msg%n/pattern charsetUTF-8/charset /encoder /appender appender nameFILE_INFO classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/info.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern${LOG_HOME}/info.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory30/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%thread] %logger{36} - %msg%n/pattern charsetUTF-8/charset /encoder filter classch.qos.logback.classic.filter.LevelFilter levelINFO/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter /appender appender nameFILE_ERROR classch.qos.logback.core.rolling.RollingFileAppender file${LOG_HOME}/error.log/file rollingPolicy classch.qos.logback.core.rolling.TimeBasedRollingPolicy fileNamePattern${LOG_HOME}/error.%d{yyyy-MM-dd}.log/fileNamePattern maxHistory60/maxHistory /rollingPolicy encoder pattern%d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%thread] %logger{36} - %msg%n/pattern charsetUTF-8/charset /encoder filter classch.qos.logback.classic.filter.LevelFilter levelERROR/level onMatchACCEPT/onMatch onMismatchDENY/onMismatch /filter /appender root levelINFO appender-ref refCONSOLE/ appender-ref refFILE_INFO/ appender-ref refFILE_ERROR/ /root logger namecom.example.demo.mapper levelDEBUG/ /configuration这里有几个关键细节。springProperty可以从application.yml中读取配置把应用名拼进日志目录这样多应用共享一个日志目录时不容易混淆。FILE_INFO和FILE_ERROR分开写排查问题时直接看 error.log不用在大文件里翻找异常信息。把com.example.demo.mapper包的日志级别设为 DEBUGMyBatis 会打印完整 SQL 语句开发环境调试非常有用。但在生产环境中建议去掉或者至少改成 INFO否则 SQL 日志量大到能拖慢应用。如果你要打印 MyBatis-Plus 的 SQL 日志还需要在application.yml中设置mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl设置之后Mapper 包名下的 DEBUG 日志就会被输出到控制台。使用StdOutImpl会直接打印到标准输出不走日志框架生产环境不要配置这个。6. 常见问题与排查心得6.1 高频报错速查表整理一份近半年在实际项目中遇到的最常见问题直接对应解决办法报错/现象根本原因解决方案Invalid bound statement (not found)Mapper 接口与 XML 文件映射失败检查 XML 文件的 namespace 是否与接口全限定名一致确认mapper-locations配置路径正确确保 XML 中方法 id 与接口方法名一致Table xxx doesnt exist实体类映射的表名不正确检查数据库表名是否与实体类驼峰转换后结果一致不一致时使用TableName显式指定ClassNotFoundException: javax.servlet.*使用了旧版 MyBatis-Plus starter改用mybatis-plus-spring-boot3-starter分页查询 total0分页拦截器未注册添加MybatisPlusInterceptor注册PaginationInnerInterceptor使用saveBatch插入非常慢逐条 INSERT 性能瓶颈改用自定义多值 INSERT 批量 SQL每批 500 条逻辑删除后唯一索引冲突逻辑删除方案的固有问题方案一把逻辑删除字段加入唯一索引方案二用删除时间戳手机号做唯一约束方案三改为物理删除启动时打印大量无用的查询日志Mapper 包 DEBUG 日志未关生产环境设置 Mapper 包级别为 INFO去掉 log-impl 或改为 Slf4jImpl 并关闭 DEBUG 级别输出时间字段查询范围不正确时区配置不对JDBC URL 加serverTimezoneAsia/Shanghai统一全局时区6.2 几个容易让新手头晕的非直观问题第一个问题是实体类字段中 boolean 类型与数据库字段的映射关系。Java 的boolean类型在 MyBatis-Plus 中会自动映射到数据库的tinyint(1)但如果在 Lombok 的 Data 下生成的是isDeleted()方法名某些配置下可能跟 MyBatis 的属性解析不一致。建议尽量用Integer表示逻辑标志位避免这类隐性坑。第二个问题是删除方法与TableLogic的联动。配置了逻辑删除后deleteById方法不会真正执行 DELETE 语句而是生成一条 UPDATE 语句把逻辑删除字段置为 1。这意味着后续所有查询都会自动追加deleted0条件。但如果 SQL 是通过 XML 自定义写的就需要手动在 where 条件里加deleted0否则会把逻辑删除的数据也查出来。3.5.9 版本对自定义 SQL 中的逻辑删除判断没有做自动增强这是很多人排查了半天才发现的问题。第三个问题是特殊字符转义。MyBatis-Plus 的like方法传入包含%、_的字符串时不会自动转义会导致 LIKE 查询匹配到意外数据。需要手动处理String escapedName name.replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_);这在搜索场景里算是高频问题了用户输入一个%就可能把全表数据查出来不仅性能有问题还有数据泄露风险。6.3 性能优化的几个实操建议除了前面提到的批量插入优化还有几个性能细节值得注意。第一避免在循环里单条查询数据库。常见的 N1 问题在 ORM 框架中大量存在MyBatis-Plus 提供了listByIds方法一次查询获取主键集合对应的全部数据。循环里拼selectById的做法在数据量增大后性能会肉眼可见地下滑。第二查询时只查需要的字段。MyBatis-Plus 的 LambdaQueryWrapper 支持.select()指定查询列LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.select(User::getId, User::getName, User::getAge);当表字段很多、且某条业务链路上不需要全部字段时这个操作能明显减少网络传输和实体转换开销。尤其在设计 VO 查询时这种精确选列的方式让 SQL 意图非常清晰。第三数据库表必须建立合适的索引。MyBatis-Plus 的分页 ORDER BY 字段如果不走索引数据量过百万后深分页会非常慢。建议分页排序字段加复合索引必要时可以使用 MySQL 8.0 的降序索引特性并尽量避免深偏移比如用WHERE id #{lastId} LIMIT 20方式代替LIMIT 100020, 20。6.4 MyBatis-Plus 3.5.9 与 Spring Boot 3 的版本兼容性兼容性这个问题值得单独说。Spring Boot 3.x 版本更新很快从 3.0 到 3.2 再到 3.3每个小版本都会调整部分自动配置逻辑。MyBatis-Plus 3.5.9 官方声明支持的 Spring Boot 版本是 3.0 到 3.3。使用 Spring Boot 3.2.x 搭配 3.5.9 是我目前最推荐的生产组合稳定性经过大规模验证。如果你在 Spring Boot 3.4 或更高版本上使用 MyBatis-Plus 3.5.9建议先跑一遍完整的 CRUD 集成测试再放行。因为 Spring Boot 3.4 之后对自动配置文件和配置属性的绑定方式做了调整有些 starter 的配置项可能出现被忽略或警告的情况。实际上MyBatis-Plus 3.5.9 之后官方也发布了更新版本如果项目用了较新的 Spring Boot考虑升级 MyBatis-Plus 到兼容版本会更省心。另外一个容易被忽略的兼容性问题是Jakarta Validation 的版本。Spring Boot 3 默认使用jakarta.validation约束注解javax.validation的注解不能用了。Controller 的Validated和 DTO 里的NotBlank、NotNull如果导入了旧的 javax 包运行时直接抛 NoClassDefFoundError。IDEA 自动导入有时会帮你选中旧坐标写完代码后检查一下 import 语句。7. 项目实践中的个人体会这套技术栈在我实际项目里跑了半年多说几点掏心窝子的体会。Spring Boot 3 MyBatis-Plus 3.5.9 的组合让后端 CRUD 开发确实提速不少。一个标准模块的开发流程可以压缩到建表 → 建实体类 → 建 Mapper 接口 → 建 Service 类 → 建 Controller全程不需要写任何 SQL分页和排序也通过 Wrapper 快速完成。一个中等复杂度的管理后台两个人两周就能把所有基础接口做完这在过去 MyBatis 手写 SQL 的年代是不可想象的。但这套框架也有它的“软肋”。MyBatis-Plus 的便利性会让一部分开发者在业务逻辑本已复杂时仍然试图用 Wrapper 去解决一切问题导致 SQL 的可读性和性能双双下降。我见过一个统计报表查询用 LambdaQueryWrapper 拼了六七个条件、两个子查询最后生成的 SQL 执行耗时三秒多。这种场景接手的人会很痛苦——Wrapper 不是 SQL你没法像读 XML 那样直观地看到查询逻辑。所以我的原则很明确能简单 Wrapper 解决的用 Wrapper一旦查询边界触碰到多表 join、复杂子查询、动态排序立即转到 XML。我选型时最看重的其实是 MyBatis-Plus 背后那套约定优于配置的思想。它会替你规范表名、字段名、主键策略、逻辑删除这些规范一旦被团队接受代码风格会高度统一。与其说它是一个框架不如说它是在帮你推进一套数据访问层的最佳实践。但前提是团队里得有人真正理解这些约定背后的原理而不是只知道照着文档写。最后分享一个小技巧把 MyBatis-Plus 的 SQL 日志在本地开发时始终打开养成看一眼执行 SQL 的习惯。很多隐蔽的问题比如字段没走索引、比如多出了一条 count 查询、再比如逻辑删除没生效看一眼日志就懂了。我在 review 同事代码时第一步就是让他把相关接口的 SQL 日志贴出来几句话就能说清问题。这个习惯建议每个刚接触 MyBatis-Plus 的开发者尽早养成。