Spring Boot + MyBatis 整合实践:配置、动态SQL与排错指南

📅 发布时间:2026/10/1 13:36:45
Spring Boot + MyBatis 整合实践:配置、动态SQL与排错指南
最近被问得最多的一个问题大概就是“Spring Boot 项目里用 Mybatis 到底怎么组织才规范”。这个组合在国内 Java 业务系统里几乎成了默认配置但很多人是跟着脚手架点出来的能跑却说不清里面发生了什么一旦碰到 SQL 复杂一点、字段映射对不上或者启动报Invalid bound statement就只能在网上反复搜效率极低。这篇文章是我个人在多个项目里折腾 Spring Boot Mybatis 的完整实践记录包括核心配置、Mapper 组织、动态 SQL、缓存、批量插入、多数据源以及我踩过的一些坑。适合刚接触 Spring Boot 的读者按步骤复现也适合已经写了不少 CRUD、想系统梳理一遍的人查漏补缺。内容基于常见实践展开希望对正在用这个组合的人有点参考价值。1. 为什么 Spring Boot 项目里大家爱用 Mybatis1.1 从 JDBC 到 Mybatis省掉的不只是样板代码先回忆一下最原始的 JDBC 写法Class.forName加载驱动、DriverManager.getConnection拿连接、PreparedStatement绑定参数、executeQuery执行、再挨个从ResultSet里getString、getInt取字段最后还要在finally里关连接、关 Statement、关 ResultSet。一个最简单的查询能写二十几行其中真正的业务逻辑不到三分之一剩下的全是重复样板。而且连接忘记关闭、异常处理不严谨都是老项目的家常便饭。后来 JPA/Hibernate 解决了自动映射和建表的问题但又带来另一个问题SQL 被抽象走了你想动一点复杂的查询不是写 JPQL 就是拼 Specification调试成本并不低。Mybatis 走的是“半自动”路线SQL 自己写映射可以灵活配置。国内业务系统里复杂查询多、报表多、团队普遍熟悉 SQLSQL 可控意味着性能可调、可评审所以这个组合一直很流行。我个人的体会是用 Mybatis 不丢 SQL 能力又省掉了 JDBC 的体力活这是它最核心的价值。1.2 Spring Boot 自动装配帮我们做了什么传统 SSM 整合 Mybatis需要手工写mybatis-config.xml、SqlSessionFactoryBean、MapperScannerConfigurer、数据源 BeanSpring 配置文件里光这些就要写一长串版本稍微对不上就启动失败。Spring Boot 出现后Mybatis 官方提供了mybatis-spring-boot-starter引入依赖后主要的配置都收敛到application.yml启动时由MybatisAutoConfiguration自动完成 SqlSessionFactory、SqlSessionTemplate 和 Mapper 扫描的注册。这就是热词里常说的“Springboot 自动装配原理”。你不需要知道底层细节也能把项目跑起来但理解它之后排错思路会完全不一样。比如你配置了mapper-locations却一直说 XML 找不到其实就是自动装配读取配置的路径和你实际的目录没对上。这个原理我在第 6 章再展开。2. 三步搭起一个能跑的 Spring Boot Mybatis 项目2.1 依赖与配置第一次也能一次过Maven 项目里引入两个核心依赖mybatis-spring-boot-starter和数据库驱动。以 MySQL 为例pom.xml 中大概是这样的dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency版本这里有一个常见的坑如果是 Spring Boot 2.x 项目用 2.x 版本的 starter 比较稳如果是 Spring Boot 3.xJava 版本要求 17package 从javax换成了jakartastarter 也要选 3.x。很多人照着老教程在 Spring Boot 3 项目里配 2.x 的 starter启动没问题但某些注入会莫名失败。接着是application.yml这是整个整合配置的核心spring: datasource: url: jdbc:mysql://localhost:3306/test?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl几个配置项说明一下。mapper-locations是 XML 映射文件的路径我习惯把 XML 统一放在src/main/resources/mapper下。type-aliases-package会自动注册实体别名后面 XML 里写resultTypeUser就不用写一长串全限定类名。map-underscore-to-camel-case解决数据库下划线字段到 Java 驼峰属性的自动映射比如user_name映射到userName这个强烈建议开启。log-impl是打印 SQL 的配置开发环境很有用。如果不想装 MySQL本地可以用 H2 内存库快速验证把 driver 换成org.h2.Driverurl 换成jdbc:h2:mem:testdb即可。2.2 从一个简单的用户表开始写 Mapper假设有一张用户表user包含id、username、password、nickname、create_time字段。实体类大概是public class User { private Integer id; private String username; private String password; private String nickname; private LocalDateTime createTime; // getter / setter 省略 }Mapper 接口最简单的方式是加Mapper注解然后在接口里声明方法Mapper public interface UserMapper { User findById(Integer id); int insert(User user); }对应的UserMapper.xml长这样mapper namespacecom.example.demo.mapper.UserMapper select idfindById resultTypeUser select id, username, password, nickname, create_time from user where id #{id} /select insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid insert into user(username, password, nickname) values(#{username}, #{password}, #{nickname}) /insert /mappernamespace必须与 Mapper 接口的全限定名一致id必须与接口方法名一致这两处错了就是最经典的Invalid bound statement (not found)。#{}是预编译参数占位符防止 SQL 注入绝大多数场景都应该用它。useGeneratedKeystrue配合keyPropertyid可以让数据库自增主键自动回填到 User 对象的 id 属性。如果你不想写 XMLMybatis 也支持纯注解Select(select * from user where id #{id}) User findById(Integer id);简单 CRUD 用注解很舒服但复杂动态 SQL 还是建议 XML具体怎么选第 3 章细说。2.3 Service 层调用与事务的小细节Mapper 写好后Service 层的调用有一个非常容易忽略的点事务和参数传递。一个典型的注册逻辑Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } Transactional(rollbackFor Exception.class) public void register(String username, String password) { // 这里可以加校验逻辑比如先查是否存在同名用户 User user new User(); user.setUsername(username); user.setPassword(password); userMapper.insert(user); } }Transactional默认只能在抛出 RuntimeException 或 Error 时回滚如果业务里抛出的是 checked Exception必须声明rollbackFor Exception.class否则你会发现数据明明出错了事务却提交了。还有一个坑Mapper 方法如果参数多于一个比如findByNameAndPwd(String username, String password)XML 里直接写#{username}在部分版本里会报错或取不到值。稳妥的做法是加Param注解User findByNameAndPwd(Param(username) String username, Param(password) String password);这样 XML 里的参数名就是你指定名字而不是隐晦的param1、param2。我现在的习惯是所有多参数 Mapper 方法一律加Param宁可多写几个字也不让队友猜。3. 复杂查询怎么组织XML、注解与动态 SQL 的取舍3.1 注解和 XML到底该怎么分这个问题我见过很多团队讨论。有一种项目全是注解接口上写一长串Select带动态条件的 SQL 用script标签硬塞进去方法签名长得没法看改一次 SQL 得把注解整体重写一遍。另一种项目极端到所有 SQL 都进 XML连select * from user where id ?也单独建一个 XML文件数量多且维护成本高。我给一个比较实用的分法单表简单 CRUD、固定条件的查询可以用注解代码量小、读起来直接一旦出现动态 where、多表 join、批量插入、foreach立刻转到 XML。XML 的优势不只是清晰它还能写注释、统一格式化、和接口隔离多人协作时不容易冲突。实际项目中我一般把 SQL 比较多、条件复杂的模块全部用 XML注解只留给一些非常稳定的查询。团队里只要约定统一两种混用其实没有问题最怕的是没有约定同一个接口里一半注解一半 XML。3.2 动态 SQL 是 Mybatis 的立命之本动态 SQL 是 Mybatis 最实用的能力。最常用的就是whereif的组合解决“条件可选”的查询select idsearchUsers resultTypeUser select * from user where if testusername ! null and username ! and username like concat(%, #{username}, %) /if if testminCreateTime ! null and create_time gt; #{minCreateTime} /if /where order by id desc /select这里必须强调where标签会自动处理首个子条件前面的and也就是说如果第一个if不生效它不会留下一个孤零零的WHERE and xxx。如果用普通的where 1 1也不是不行但在 SQL 评审时容易被吐槽性能上绝大多数场景也没差别只是习惯问题。更新场景用set标签很省心update idupdateUser parameterTypeUser update user set if testnickname ! nullnickname #{nickname},/if if testpassword ! nullpassword #{password},/if /set where id #{id} /updateset会自动去掉最后一个多余的逗号不用自己处理这种反人类细节。批量插入用foreach这也是热词里大家常搜的点insert idbatchInsert insert into user(username, password, nickname) values foreach collectionlist itemitem separator, (#{item.username}, #{item.password}, #{item.nickname}) /foreach /insert分页这块最直接的做法是查 count 和查列表两条 SQL或者使用limit加两个参数select idfindPage resultTypeUser select * from user where username #{username} order by id desc limit #{offset}, #{size} /select如果项目里用了 PageHelper注意PageHelper.startPage(pageNum, pageSize)必须紧跟要分页的那条查询语句它底层通过 ThreadLocal 保存分页参数靠拦截器改写 SQL一旦中间夹杂其他查询分页参数就乱了。物理分页永远比把数据全查出来再内存分页靠谱这个不用犹豫。3.3 resultMap别只会 resultType很多人写查询一直用resultType碰到字段不一致就在 SQL 里加别名这种做法能解决一部分问题但遇到嵌套对象就无能为力了。resultMap才是处理映射关系的正规手段。比如一个用户可以拥有多个角色查询后希望 User 对象里直接带一个roles集合resultMap idUserRoleMap typeUser id propertyid columnid/ result propertyusername columnusername/ collection propertyroles ofTypeRole id propertyid columnrole_id/ result propertyroleName columnrole_name/ /collection /resultMap select idfindUserWithRoles resultMapUserRoleMap select u.id, u.username, r.id as role_id, r.role_name from user u left join user_role ur on u.id ur.user_id left join role r on r.id ur.role_id where u.id #{id} /selectid标签用来标识主键列collection映射一对多association映射多对一。这里有一个细节子查询的列一定要取别名比如r.id as role_id否则 ResultSet 里同时出现多个id列Mybatis 取列值时容易错乱。另外如果只是为了仪表盘之类的统计select count(*) from ...返回一个 Map 也能用但count(*)这种列名作为 key 真的很别扭强烈建议写别名count(*) as total。用 Map 接收结果始终是“权宜之计”核心业务还是定义清晰的实体和resultMap更好维护。4. 那些用过才懂的高级功能与对应的坑4.1 TypeHandler让 Java 类型和数据库类型自动对齐热词里有人搜“mybatis 中 typehandler 的工作流程图”。用文字梳理一下这个流程执行 SQL 前ParameterHandler会调用对应 TypeHandler 的setNonNullParameter把 Java 参数值写进PreparedStatement查询返回时ResultSetHandler调用 TypeHandler 的getNullableResult从ResultSet里取出 JDBC 值并转换成 Java 对象。整个过程对业务代码透明。最常见的自定义 TypeHandler 场景是枚举。默认情况下 Mybatis 处理枚举用的是EnumTypeHandler数据库存的是枚举的name也有人在配置里开EnumOrdinalTypeHandler那存的是枚举下标。这两个方案我都不太推荐因为只要你改了枚举名字或调整顺序历史数据就对不上了。尽量自定义一个 TypeHandler让数据库存一个稳定的 codeMappedJdbcTypes(JdbcType.VARCHAR) MappedTypes(StatusEnum.class) public class StatusEnumTypeHandler extends BaseTypeHandlerStatusEnum { Override public void setNonNullParameter(PreparedStatement ps, int i, StatusEnum parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, parameter.getCode()); } Override public StatusEnum getNullableResult(ResultSet rs, String columnName) throws SQLException { return StatusEnum.of(rs.getString(columnName)); } Override public StatusEnum getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return StatusEnum.of(rs.getString(columnIndex)); } Override public StatusEnum getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return StatusEnum.of(cs.getString(columnIndex)); } }然后在配置里指定 TypeHandler 所在包mybatis: type-handlers-package: com.example.demo.handler另一个常见场景是 JSON 字段实体里有个ListString数据库存的是 varchar 或 json 类型同样可以写 TypeHandler 负责序列化和反序列化。这种能力看着不起眼但在实际项目里能省掉大量手工转换代码。4.2 一级缓存和二级缓存能开吗怎么开一级缓存默认开启作用域是 SqlSession。Spring 整合 Mybatis 后正常情况下一个事务对应一个 SqlSession所以在一个事务里连续查询同一条记录第二次会直接命中一级缓存不查数据库。这个功能默认就很合理。但有个隐蔽的坑一级缓存存的是对象引用不是深拷贝。如果你把查询出来的对象字段改了再拿同一个 SqlSession 查一次返回的还是同一个对象看到的已经是改过的数据。所以业务代码里尽量不要直接修改 Mapper 返回的实体要修改就复制一份或者 new 对象。二级缓存默认关闭作用域是 Mapper 的 namespace。想开启的话在 XML 里加一句cache/就行。它看起来性能提升明显但风险就在于缓存一致性多表关联查询时A 表的查询结果存在 UserMapper 的二级缓存里如果 B 表的数据变了而 UserMapper 的缓存没有失效你会查到旧数据。分布式环境下默认的本地缓存也不适合共享所以我个人很少开二级缓存宁可用 Redis 做业务缓存。表格对比一下缓存类型作用域默认状态失效条件使用建议一级缓存SqlSession开启执行增删改、不同 SqlSession、手动 clearCache保持默认注意别改缓存引用对象二级缓存Mapper Namespace关闭该 namespace 下任意增删改、缓存回收策略读多写少、单表简单、非分布式再考虑4.3 批量插入的正确姿势热词里很多人搜“mybatis-plus 批量”大概率是面试或项目优化时发现循环单条插入太慢。在纯 Mybatis 下推荐用foreach拼接一条insert into ... values (...),(...)配合应用层分片每 500 条执行一次避免单条 SQL 太大int batchSize 500; for (int i 0; i users.size(); i batchSize) { ListUser subUsers users.subList(i, Math.min(i batchSize, users.size())); userMapper.batchInsert(subUsers); }为什么不推荐 for 循环里单条 insert因为每条 SQL 都要走一次网络往返、一次预编译几百上千条数据的性能差距非常大。foreach方案一条 SQL 搞定数据库执行计划也更好。Mybatis 的ExecutorType.BATCH也是一种方案它在同一个会话里积攒多条 SQL 再统一提交理论上可以减少往返但在 Spring 环境下要单独配置SqlSessionTemplate而且要小心自增主键的getGeneratedKeys拿不到使用复杂度偏高。Mybatis-Plus 的saveBatch底层也是分包批量插入如果你的项目本来就用 Plus直接用它省力得多。4.4 多数据源读写分离从零到能用的方案很多项目一开始只有一个库后期想搞读写分离。不要一上来就引入 ShardingSphere 这种重框架大多数场景可以用AbstractRoutingDataSource ThreadLocal 自己实现。思路是这样的配置两个 DataSource主库primary、从库read然后自己写一个RoutingDataSource继承AbstractRoutingDataSource在determineCurrentLookupKey里从 ThreadLocal 中拿当前请求要用的 key。再配一个动态数据源的路由给 Mybatis 的 SqlSessionFactory。业务层用 AOP 切 Service 方法标注了ReadOnly的方法走read其余走primary方法结束清理 ThreadLocal。这里有两个容易踩的坑。第一写操作开了事务之后连接在事务开始时就已经绑定到主库事务内的读操作切不到从库。所以读方法上尽量不要加Transactional或者要让动态数据源在事务边界之前切好。第二主从同步有延迟刚插入的主库数据马上从从库读可能查不到。业务上比较敏感的数据可以强制走主库或者缓存里做好标记。顺带说一句热词里的“金仓读写分离配置”思路完全一样只是驱动类和方言要按照国产数据库官方文档调整数据源还是那套动态路由的原理。5. 排查实录Spring Boot Mybatis 常见问题速查5.1 Mapper 接口扫描不到、Invalid bound statement这是我在社区回复里遇到最多的一类问题。分几种情况如果是Invalid bound statement (not found)多半是 namespace 或 id 对不上。检查路径XML 的 namespace 是否等于 Mapper 接口全限定名select或insert标签的 id 是否等于接口方法名XML 文件是否在mapper-locations配置的路径下。我自己有一次把 XML 放在src/main/java包路径里结果打包后没有复制到 classes启动不报错一调用就说不存在后来统一放进src/main/resources/mapper才解决。如果是启动时提示某个 Mapper 没有注入那可能是MapperScan的包路径没覆盖到或者接口既加Mapper又用MapperScan且扫描路径交叉冲突。建议是项目里只保留MapperScan不用每个接口都加Mapper减少心智负担。还有一个小技巧启动时打印一下 Spring 容器里注册的 Mapper Bean 数量如果数量不对马上就能判断是扫描问题还是配置问题比报错后瞎猜强得多。5.2 查询结果全是 null 还是大小写问题遇到查出来的对象字段全是 null先看两处。第一map-underscore-to-camel-case是否开启没有开启的话数据库的user_name不会映射到userName。第二实体属性到底叫userName还是username如果数据库列名是user_nameJava 属性叫username那就不是驼峰问题而是命名不一致SQL 里要写别名。MySQL 的列名在 Linux 下区分大小写Windows 下不敏感这也是一个隐蔽问题。查询时写了select UserNameWindows 上正常部署到 Linux 直接报 Unknown Column或者查出来是 null。规范做法是数据库字段、SQL 列名、Java 属性名都统一小写风格。聚合查询返回 Map 时如果 key 对不上比如count(*)作为 Map 的 key看起来就是 null 或者拿不到值。给列加别名就能解决select count(*) as total。5.3 打印 SQL 配置与生产环境日志风险开发阶段想看 Mybatis 真正执行的 SQL 和参数有两种方式。第一种是在application.yml里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这个方案最简单启动后 SQL、参数、查询总数直接打到控制台。第二种是走日志框架设置 Mapper 包日志级别为 debuglogging: level: com.example.demo.mapper: debug好处是能统一走 logback生产上可以按级别动态调优。但注意生产环境千万别打印完整 SQL 参数尤其是身份证号、手机号、密码这类敏感信息一旦日志泄露就是事故。慢 SQL 分析应该交给数据库本身的慢查询日志而不是全量打印 Mybatis SQL。5.4 数据库驱动、时区与 Spring Boot 版本兼容热词里有人搜“springboot 版本太高”我猜大概率是创建了一个 Spring Boot 3.x 项目然后照着 Spring Boot 2.x 的教程配置遇到各种包名对不上。Spring Boot 3 之后javax.*迁移到了jakarta.*所以一些老的javax.annotation.Resource注入方式会失效。Mybatis 官方也发布了适配 Spring Boot 3 的 starter 3.x 版本别再用 2.x。MySQL 8 的驱动是com.mysql.cj.jdbc.Driver连接串里最好加上serverTimezoneAsia/Shanghai否则可能抛 CST 或 UTC 相关时区异常。如果用了比较老的连接池配置还可能遇到连接断掉之后不自动重连的问题这属于连接池参数和数据库 wait_timeout 之间的配合范畴不在 Mybatis 本身。5.5 和 MyBatis-Plus 混用时的取舍很多项目用 MyBatis-Plus它是 Mybatis 的增强提供BaseMapper和条件构造器但底层的动态 SQL、resultMap、TypeHandler 机制和 Mybatis 完全一致。两者的冲突点在于如果同时引入mybatis-spring-boot-starter和mybatis-plus-boot-starter并且两个自动配置都对同一批 Mapper 做扫描注册可能出现 Bean 冲突或者 Mapper 被重复代理。我的建议是新项目直接二选一。如果团队要手写 SQL就老老实实用原生 Mybatis如果希望单表 CRUD 不写 SQLMyBatis-Plus 会舒服很多。复杂 SQL 在 Plus 里写 XML 的语法和原生 Mybatis 没有任何区别不用担心迁移成本。千万别在一个项目里两套 starter 同时扫同一批 Mapper纯属给自己找麻烦。6. 从使用到面试顺便搞懂几个原理6.1 自动装配原理Mybatis 是怎么被 Spring Boot“安排”的SpringBootApplication里面包含EnableAutoConfiguration它通过AutoConfigurationImportSelector扫描 classpath 下所有 jar 包中的META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件。在 Spring Boot 2.7 之前这个文件叫spring.factories。mybatis-spring-boot-autoconfigure里就声明了MybatisAutoConfiguration。当容器里存在DataSource时MybatisAutoConfiguration通过条件注解生效自动创建SqlSessionFactory和SqlSessionTemplate。你配置的mapper-locations、type-aliases-package、configuration这些属性最终都会注入到 Mybatis 的Configuration对象里。所以你在application.yml里写的那些mybatis.*配置本质上是 Spring Boot 帮我省掉了传统mybatis-config.xml的解析过程。如果有些特殊调整在 yml 里表达不了可以实现ConfigurationCustomizer在 Mybatis 全局配置完成前做自定义修改比绕开自动装配自己造 Bean 优雅得多。6.2 Mybatis 初始化流程与 XMLConfigBuilder热词里有人搜“mybatis 中 xmlconfigbuilser 的工作流程图”。在没有 Spring 的场景下入口是new SqlSessionFactoryBuilder().build(Resources.getResourceAsStream(mybatis-config.xml))内部由XMLConfigBuilder解析 mybatis-config.xml把数据源、映射器、类型别名、TypeHandler 等信息装配成Configuration对象。Spring Boot 整合后虽然不写mybatis-config.xml但整个流程仍然是同一个套路MybatisAutoConfiguration构造Configuration然后通过XMLMapperBuilder解析你配置的 mapper XML。阅读 Mybatis 源码时建议按这一条主线走XMLConfigBuilder-XMLMapperBuilder-Configuration-SqlSessionFactory-DefaultSqlSession-Executor-StatementHandler-ParameterHandler-ResultSetHandler。这也是“为什么 Mapper 接口没有实现类却能调用”的答案核心。Mybatis 利用 JDK 动态代理给每个 Mapper 接口生成MapperProxy每次调用接口方法实际上由MapperProxy.invoke转发到对应的MapperMethod最终调的是SqlSession的selectOne、selectList、insert等方法。理解了这条链路面试时很多问题都能串起来讲。6.3 高频面试题速览与加分回答思路面试题基础回答加分点#{}和${}的区别#{}预编译占位符防注入${}字符串拼接能说出order by、表名只能${}但必须白名单校验Mapper 没有实现类为什么能调用JDK 动态代理生成MapperProxy能画出调用链接口 - MapperProxy - SqlSession - Executor一级缓存和二级缓存区别SqlSession 级和 namespace 级能结合 Spring 事务说 SqlSession 的生命周期能说出二级缓存脏读风险PageHelper 分页原理拦截 Executor 改写 SQL能说出 ThreadLocal 存储分页参数以及startPage紧跟查询的原因TypeHandler 使用场景枚举、JSON 字段转换能给出自定义 TypeHandler 的注册方式和处理枚举 code 的实战经验查询字段全是 null没开启驼峰映射或列名不一致能顺带说出 Linux/Windows 列名大小写的差异面试时不要只背结论可以把平时踩过的坑当成案例讲比如“我之前因为二级缓存脏读改成 Redis”这种真实经历比标准答案更有说服力。面试官想听的往往不是标准答案而是你有没有真正踩过坑、能不能讲清为什么。写到这里我想起自己第一次在 Spring Boot 项目里配 Mybatis排查“Invalid bound statement”查了一下午最后发现是 XML 没被打包进 target 目录。后来我在项目启动时加了一段环境信息打印把mapper-locations、数据源地址、Mapper Bean 数量都输出出来这类问题基本一眼就能定位。如果你问我最推荐的项目落地组合我自己的习惯是MyBatis-Plus 负责单表 CRUDXML 只放复杂动态 SQLresultMap认真设计TypeHandler 管住枚举缓存交给 Redis分页用物理分页。这个组合在中小项目里一直很稳。之后再遇到新问题记得先看 SQL 打印再看映射配置最后才怀疑框架本身大多数问题都出在这三层的前两层。