ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot+MyBatis全链路实战:从配置到原理与踩坑指南

2026/9/30 9:23:51 拓冰建站 浏览量
Spring Boot+MyBatis全链路实战:从配置到原理与踩坑指南 做过几年 Spring Boot 项目的人大概都经历过从 JDBC 手写PreparedStatement到被 MyBatis 解放的过程。起初接触 MyBatis 是在 SSH 时代那时候 xml 映射文件一大堆配上 Spring 的SqlSessionTemplate光配置就能写半天。后来 Spring Boot 横空出世MyBatis 官方也顺势推出了mybatis-spring-boot-starter配置量直接砍到只剩一个application.yml段和几个注解上手难度骤降。但正是因为太方便很多人在实际项目中只停留在“会调接口”的程度——SQL 写在注解里、Mapper 接口和 XML 映射经常对不上、缓存配置一开就踩坑、TypeHandler 和插件更是用得稀里糊涂。这篇文章我会从一个 Spring Boot MyBatis 项目里最典型的操作链路讲起覆盖从依赖引入、数据源配置、Mapper 扫描、XML 映射文件规范到动态 SQL、缓存机制、插件拦截器、TypeHandler 的自定义实现最后再聊几个我在生产环境里真实踩过的坑。内容偏实操适合刚接触 MyBatis 的初学者也适合用了一段时间但没深入过原理的开发者。1. MyBatis 与 Spring Boot 的整合思路拆解1.1 为什么 Spring Boot 让 MyBatis 焕发第二春MyBatis 本身是一个半自动 ORM 框架它的定位很明确SQL 由你掌控映射关系由框架处理。与 Hibernate 这种全自动框架不同MyBatis 不帮你生成 SQL而是让你把 SQL 写在 XML 或注解里然后由框架完成参数绑定、结果集到对象的映射、缓存管理以及延迟加载等繁琐工作。在 Spring Boot 出现之前Spring 整合 MyBatis 需要手动配置DataSource、SqlSessionFactory、MapperScannerConfigurer还要把SqlSessionTemplate注入到 DAO 层配置链长且容易出错。Spring Boot 将这一系列工作全部自动化通过mybatis-spring-boot-starter依赖在应用启动时自动完成以下事DataSource 数据源 -- SqlSessionFactory(基于配置和 XML 映射文件构建) -- Mapper 接口代理注册 -- MapperScan 扫描并注入容器这套链路的自动配置核心在MybatisAutoConfiguration类中实现它会在 Spring 容器初始化阶段自动创建SqlSessionFactory和SqlSessionTemplate并且读取application.yml中以mybatis.开头的配置项。1.2 自动配置背后的核心原理很多人不知道MyBatis-Spring-Boot-Starter 其实是一个“包壳子”真正干活的是mybatis-spring项目。启动时自动配置类会完成三件事第一通过SqlSessionFactoryBean创建SqlSessionFactory它会加载 MyBatis 全局配置文件、Mapper XML 文件并设置数据源、类型别名包、插件、TypeHandler 等。第二创建SqlSessionTemplate这是 MyBatis 与 Spring 事务整合的关键它实现了 Spring 的SqlSession接口代理会参与 Spring 事务管理。第三通过MapperScan或自动扫描机制将每个 Mapper 接口扫描进容器注入代理对象。你可能会好奇为什么 Mapper 接口没有任何实现类却能直接被注入到 Service 层这里使用了 JDK 动态代理MyBatis 为每个 Mapper 接口生成一个代理对象代理对象内部会绑定一个MapperProxy当调用接口方法时代理会根据方法签名找到对应的 SQL 语句从配置文件中解析出的MappedStatement执行并返回结果。从源码角度看MapperProxy的invoke方法是一个关键入口它会判断方法是否为Object类通用方法如果不是就通过MapperMethod执行 SQL。MapperMethod内部又分为两个阶段SqlCommand负责从MappedStatement中解析 SQL 类型SELECT、INSERT、UPDATE、DELETEMethodSignature负责解析方法参数与 SQL 参数的绑定关系。1.3 和 MyBatis-Plus 的选型之争说到 Spring Boot 项目里的 MyBatis绕不开 MyBatis-Plus。我在多个项目里分别用过原生 MyBatis 和 MyBatis-Plus简单说说我的感受。原生 MyBatis 的优势在于绝对的控制力所有 SQL 都或在 XML 或注解里显式写出性能调优空间最大SQL 审查也最直白。缺点是单表 CRUD 需要手写大量重复 SQL开发效率相对低。MyBatis-Plus 在 MyBatis 基础上封装了通用 Mapper内置了insert、updateById、selectPage等现成方法单表操作基本不用写 SQL。但它也引入了一些新概念比如LambdaQueryWrapper、Wrapper条件构造器以及乐观锁插件、分页插件等。如果项目以单表 CRUD 为主MyBatis-Plus 效率优势非常明显如果涉及复杂关联查询、存储过程、SQL 优化原生 MyBatis 更可控。很多团队在选型上摇摆我的建议是中小规模项目、以业务 CRUD 为主的直接用 MyBatis-Plus数据访问层要求严格、SQL 需要频繁人工调优的大厂核心系统原生 MyBatis 更稳妥。两者不是对立关系MyBatis-Plus 底层还是 MyBatis多学原生原理永远不会浪费。2. 项目初始化与基础配置全解析2.1 引入依赖的正确姿势用 Maven 构建 Spring Boot 项目并引入 MyBatis 依赖最核心的就一个 starterdependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.2/version /dependency注意这个 starter 的版本号与 Spring Boot 主版本不是对应的。比如 Spring Boot 2.7.x 可以搭配 MyBatis Starter 2.3.xSpring Boot 3.x 需要搭配 3.0.x 以上版本。原因在于 Spring Boot 3 基于 Jakarta EEjavax 命名空间迁移到了 jakarta旧版 mybatis-spring 不兼容。同时需要引入数据库驱动比如 MySQLdependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency如果你的项目还用到了连接池推荐引入 HikariCPSpring Boot 2.x 以后默认就是它不需要额外引入依赖。如果要手动指定其他连接池Druid 等需要另配第三方 starter。2.2 application.yml 配置详解一个典型的基础配置长这样spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/demo_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 username: root password: 123456 hikari: maximum-pool-size: 10 minimum-idle: 5 idle-timeout: 30000 connection-timeout: 30000 mybatis: mapper-locations: classpath*:mapper/**/*.xml type-aliases-package: com.example.demo.entity configuration: map-underscore-to-camel-case: true cache-enabled: true lazy-loading-enabled: true aggressive-lazy-loading: false type-handlers-package: com.example.demo.handler逐项解释mapper-locations指定 Mapper XML 文件的路径支持通配符。classpath*:前缀会到所有 jar 包中搜索classpath:只在当前项目 classpath 中搜索。建议用classpath*:防止多模块项目里因路径不一致导致 XML 加载不到。type-aliases-package指定实体类别名扫描包配置后可以在 XML 中用简单类名代替全限定名比如用User代替com.example.demo.entity.User。map-underscore-to-camel-case非常关键它会自动将数据库字段的下划线命名映射为 Java 属性的驼峰命名。比如数据库字段create_time会自动映射到createTime属性省去大量resultMap手写工作。cache-enabled开启二级缓存lazy-loading-enabled开启延迟加载aggressive-lazy-loading控制聚合加载层级。这几个后面详细讲。2.3 三种常见数据源配置场景实际项目中数据源配置还有很多变化。我经历过的典型场景有单数据源最简单如上文所示。多数据源则需手动配置两个或多个DataSourceBean并在每个 Mapper 包上分别注解MapperScan(basePackages ..., sqlSessionFactoryRef ...)。另一个常见场景是读写分离通常在主从数据库之间通过AbstractRoutingDataSource实现路由。还有一种场景是第三方或国产数据库比如达梦、人大金仓、GaussDB它们的驱动和方言略有不同数据源驱动类和 URL 也会变化但只要驱动提供了 JDBC 实现MyBatis 基本都能适配。多数据源有个坑就是你一旦手动创建了SqlSessionFactory就绕过了 MyBatis 自动配置。此时需要自己在配置类中把MybatisAutoConfiguration排除掉否则会冲突SpringBootApplication(exclude {MybatisAutoConfiguration.class})3. Mapper 编写与 XML 映射细节3.1 一个标准 Mapper 接口该怎么写Mapper 接口是 MyBatis 最核心的组件。我先给一个标准示例public interface UserMapper { User selectById(Param(id) Long id); ListUser selectList(Param(keyword) String keyword, Param(status) Integer status); int insert(User user); int updateById(User user); int deleteById(Param(id) Long id); }与对应 XMLmapper namespacecom.example.demo.mapper.UserMapper select idselectById resultTypecom.example.demo.entity.User SELECT * FROM user WHERE id #{id} /select select idselectList resultTypeUser SELECT * FROM user where if testkeyword ! null and keyword ! AND user_name LIKE CONCAT(%, #{keyword}, %) /if if teststatus ! null AND status #{status} /if /where /select insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO user(user_name, password, email, status) VALUES(#{userName}, #{password}, #{email}, #{status}) /insert update idupdateById parameterTypeUser UPDATE user SET user_name #{userName}, password #{password} WHERE id #{id} /update delete iddeleteById DELETE FROM user WHERE id #{id} /delete /mapper有两个细节很容易出错。第一namespace必须与 Mapper 接口的全限定名一致否则启动时就会报“BindingException: Invalid bound statement (not found)”。第二接口中方法名必须与 XML 中id一致参数类型如果是对象parameterType可以省略MyBatis 可以根据 ParamNameResolver 推断。3.2 参数传递的几种方式和坑点MyBatis 参数传递是新手踩坑重灾区。我总结了几种场景单参数如果直接传一个基本类型或 StringXML 中的#{任意名}都能取到。因为 MyBatis 会把单参数直接映射为参数的唯一值。多参数必须使用Param注解为每个参数命名否则 MyBatis 会通过反射获取参数名为arg0、arg1低版本是param1、param2写 SQL 时用错名称非常容易报错。User selectByNameAndStatus(Param(name) String name, Param(status) Integer status);SELECT * FROM user WHERE user_name #{name} AND status #{status}对象参数直接传入实体对象XML 中用属性名取值。集合参数传入List时在 XML 中可以用foreach遍历。需要注意的是list是默认参数名也可以用Param指定。foreach的collection属性应填参数名。一个比较隐蔽的问题是#{ }和${ }的区别。简单说#{ }是预编译占位符会生成?并作为参数传给 JDBC 的PreparedStatement能有效防止 SQL 注入${ }是字符串拼接直接把值拼进 SQL。所以能用#{}的地方绝对不要用${}。但有几种场景必须用${}比如动态表名、字段名、排序字段等这种场景一定要做白名单校验避免注入风险。3.3 动态 SQL 四兄弟的实战用法MyBatis 的动态 SQL 是它最大的优势之一可以用 XML 中的几个标签组合出非常复杂的查询条件。我用得最频繁的四个if、where、choose、foreach。if标签做条件判断但单纯用if容易出现 SQL 拼接错误。比如SELECT * FROM user WHERE 1 1 if testname ! null and name ! AND user_name #{name} /if低版本 MyBatis 常用WHERE 11这种写法绕过WHERE与AND冲突的问题现在推荐用where标签它会自动处理首个条件的前缀SELECT * FROM user where if testname ! null and name ! user_name #{name} /if if teststatus ! null AND status #{status} /if /wherechoose类似 switch-case适合“多选一”逻辑choose when testqueryType name AND user_name #{keyword} /when when testqueryType phone AND phone #{keyword} /when otherwise AND email #{keyword} /otherwise /chooseforeach常用于 IN 查询和批量插入SELECT * FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach INSERT INTO user(user_name, email, status) VALUES foreach collectionlist itemuser separator, (#{user.userName}, #{user.email}, #{user.status}) /foreach3.4 resultMap 的映射技巧与性能考量resultType和resultMap是两种结果映射方式。resultType适合简单场景只要map-underscore-to-camel-case开启就能自动完成字段映射。resultMap适合复杂场景比如关联查询、构造函数参数映射、字段类型转换等。最常用的resultMap写法resultMap iduserDetailMap typeUser id propertyid columnid / result propertyuserName columnuser_name / result propertyemail columnemail / result propertycreateTime columncreate_time / association propertydept javaTypeDept id propertyid columndept_id / result propertydeptName columndept_name / /association /resultMapassociation用于一对一或 N1 查询中的单对象关联collection用于一对多关联。需要注意resultMap中id映射对性能非常关键它用于 MyBatis 的UniqueConstraint去重不配置会对关联集合的去重产生副作用。延迟加载开启后association和collection支持按需加载。比如查询用户列表时不查部门信息等真正使用user.getDept()时才发出第二条 SQL。这在列表数据量大的场景中能显著降低数据传输量但也需要警惕循环嵌套导致的 N1 查询。4. 缓存机制详解与试用心得4.1 一级缓存的生命周期与坑MyBatis 的一级缓存默认开启作用范围是SqlSession级别的本地缓存。同一个 SqlSession 内执行相同 SQL 且参数相同第二次会直接返回缓存结果不再查询数据库。Spring Boot 整合后要理解一级缓存在业务代码中的真实生命周期。SqlSessionTemplate在 Spring 管理下每个会话对应一个代理对象但底层 SqlSession 的生命周期与 Spring 事务绑定。如果你在 Service 层开了Transactional那么整个事务过程中多个 Mapper 调用共享同一个 SqlSession一级缓存生效如果没有事务每次 Mapper 调用都会新建 SqlSession一级缓存基本只对单次查询有效。一级缓存有个知名坑在同一事务中先查后改UPDATE 或 DELETE之前缓存的数据不会自动失效实际上 MyBatis 在任何增删改操作后都会清空一级缓存所以“缓存与数据库不一致”在一级缓存层面问题不大。真正要担心的是跨 SqlSession 的一致性这就要依靠二级缓存。4.2 二级缓存开启与序列化问题二级缓存作用范围为namespace也就是一个 Mapper默认关闭需要手动开启。开启步骤包括配置cache-enabled: true在 XML 中添加cache/标签实体类实现Serializable接口。更细粒度控制时可以在方法上的select标签加useCachefalse在insert、update、delete上加flushCachetrue默认就是 true。二级缓存有几个注意点第一开启缓存后查询结果会先存入二级缓存后续相同查询直接走缓存因此实体类必须可序列化否则会报NotSerializableException。第二多个 Mapper 关联操作同表数据时会出现缓存脏读问题。比如UserMapper缓存了用户列表UserLogMapper的更新操作只刷新自己的 namespace 缓存用户表的缓存不会清理。核心表的操作建议关闭缓存或者引入 Redis 做分布式二级缓存而不是用内置缓存。第三cache标签的eviction属性可选 LRU、FIFO、SOFT、WEAK默认 LRU。flushInterval是刷新间隔size是缓存数量上限readOnly是否只读。生产环境我建议用 LRU 定时刷新避免数据长期不一致。4.3 自定义 Redis 二级缓存的扩展思路内置二级缓存是本地内存不支持分布式多实例部署时各实例缓存互相独立不一致风险很大。改进方向有两种一是直接使用专门的 Redis 缓存框架让 MyBatis 的缓存不再处理业务数据二是实现 MyBatis 的Cache接口把缓存存取逻辑改成操作 Redis。第二种方案需要实现Cache接口的putObject、getObject、removeObject、clear等方法并在 XML 的cache typecom.example.demo.cache.RedisCache/中指定。由于 MyBatis 的缓存序列化基于 Java Serializable存入 Redis 时需要自定义序列化方式比如用 Jackson 转 JSON。实际项目中我遇到的多数团队更倾向于在 Service 层直接使用 Redis 做缓存把 MyBatis 内置缓存关掉这样缓存边界更清晰也更容易做缓存预热、缓存穿透防护。5. 插件机制与 TypeHandler 实战5.1 四大拦截器对象与工作流程MyBatis 插件机制本质是拦截器通过 JDK 动态代理或 CGLIB 对四大核心对象进行增强Executor执行器、StatementHandlerSQL 语句处理器、ParameterHandler参数处理器、ResultSetHandler结果集处理器。工作流程非常简单可以理解为一条调用链Executor.execute -- StatementHandler.prepare -- ParameterHandler.setParameters -- StatementHandler.execute -- ResultSetHandler.handleResultSets插件通过Intercepts注解定义要拦截的方法签名在配置中注册后MyBatis 会对目标对象进行包装。最常见的分页插件原理就在Executor层拦截改写 SQL拼接 limit。比如 PageHelper 的原理就是通过拦截器拦截Executor的query方法获取当前线程上下文中设置的分页参数改写 SQL 并执行 count 查询。5.2 手写一个性能监控插件我在项目中写过耗时统计插件完整代码如下Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}), Signature(type Executor.class, method update, args {MappedStatement.class, Object.class}) }) public class SqlCostInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { long start System.currentTimeMillis(); try { return invocation.proceed(); } finally { long cost System.currentTimeMillis() - start; MappedStatement ms (MappedStatement) invocation.getArgs()[0]; String sqlId ms.getId(); if (cost 200) { log.warn(slow sql: {}, cost: {}ms, sqlId, cost); } } } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } Override public void setProperties(Properties properties) { // 可读取配置参数 } }注册方式有两种一种是通过 XML 的plugins标签另一种是通过Bean配置ConfigurationCustomizer添加或直接注入SqlSessionFactory时设置。这个插件要注意性能System.currentTimeMillis()本身开销极小但在高并发下要避免在拦截器里做耗时操作比如打日志过多导致磁盘压力。5.3 TypeHandler 的工作原理与自定义TypeHandler 解决了 Java 类型与 JDBC 类型之间的转换问题。MyBatis 内置了大量 TypeHandler比如StringTypeHandler、LocalDateTimeTypeHandler、EnumTypeHandler。但业务场景中总有一些特殊类型需要自定义处理。举一个实际例子项目使用 JSON 字符串存储用户标签Java 属性是ListString数据库字段是VARCHAR。如果使用默认 TypeHandler存入时会把 List 对象直接 toString与 JSON 格式不符读取时也只会拿到一个 String而不是反序列化后的 List。自定义 TypeHandler 的步骤如下MappedTypes(List.class) MappedJdbcTypes(JdbcType.VARCHAR) public class JsonListTypeHandler extends BaseTypeHandlerListString { private static final ObjectMapper MAPPER new ObjectMapper(); Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { try { ps.setString(i, MAPPER.writeValueAsString(parameter)); } catch (JsonProcessingException e) { throw new SQLException(Failed to convert List to JSON, e); } } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { return parse(rs.getString(columnName)); } Override public ListString getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return parse(rs.getString(columnIndex)); } Override public ListString getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return parse(cs.getString(columnIndex)); } private ListString parse(String json) { if (json null || json.isEmpty()) { return Collections.emptyList(); } try { return MAPPER.readValue(json, new TypeReferenceListString() {}); } catch (IOException e) { throw new RuntimeException(Failed to parse JSON to List, e); } } }注册方式同样有两种全局注册在application.yml的type-handlers-package中配置包路径局部注册在用到的字段resultMap中指定typeHandler。全局注册更省事但要注意MappedTypes的泛型要与实际使用类型匹配。TypeHandler 的底层原理其实不复杂MyBatis 在解析 SQL 参数时根据参数类型找到对应 TypeHandler调用setParameter在解析结果集时根据ResultMapping中的 JavaType 找到 TypeHandler调用getNullableResult。配置好后业务代码完全无感知。6. 常见问题与排查技巧实录6.1 Mapper 接口与 XML 绑定失败这个报错信息通常是“Invalid bound statement (not found): com.example.mapper.UserMapper.selectById”。排查步骤很固定先确认 XML 文件位置是否正确。接口全限定名与 namespace 是否一致方法名与 SQL 的 id 是否一致。再确认mapper-locations路径是否写对尤其多模块项目里容易漏掉模块前缀。还有一个常见情况application.yml中mybatis.mapper-locations配的是classpath:mapper/*.xml但 XML 实际在另一个模块的src/main/resources里导致启动时扫描不到。这时改用classpath*:mapper/**/*.xml就可以解决。如果是打包成 jar 后这个问题才出现本地 ide 启动正常大多是 maven 打包时 XML 文件没有打进 jar。需要检查pom.xml中 build 资源配置确保src/main/resources下文件被包含。6.2 SQL 语法报错Cause: java.sql.SQLSyntaxErrorException这类问题多半是动态 SQL 拼接错误排查技巧很实用就是把执行的 SQL 打印出来。配置日志打印方式logging: level: com.example.demo.mapper: debug此时 MyBatis 会输出Preparing和Parameters日志可以看到实际执行的 SQL 和参数。如果还不够直观可以在 JDBC URL 上加trace日志或者用 druid 连接池的 SQL 拦截功能。动态 SQL 拼接的错误很常见比如if条件判断导致多个 AND 连在一起或者foreach的 open、separator、close 写错产生空括号。此时把 SQL 打印出来问题一眼就能看出来。6.3 避免在循环里单条执行 SQL这是性能问题中我见过最多的。查出 1000 条数据然后循环里调用 1000 次 insert这种写法在业务代码里太常见了。MyBatis 有foreach实现批量插入只需要一条 SQL 往返。批量更新也有更好的方式比如通过case when语法实现一次更新多条记录或者使用多条 SQL 组合视数据库对话支持情况而定。MySQL 的 JDBC 驱动还支持rewriteBatchedStatementstrue参数开启后ExecutorType.BATCH的批量提交性能显著提升。6.4 大 JSON 字段与 N1 查询另一个容易忽略的问题是 N1 查询。在association或collection配置了延迟加载的情况下查询用户列表时MyBatis 只执行了用户表 SQL但遍历列表获取部门信息时每一条数据都会触发部门查询共 N1 条 SQL。这个问题在数据量小时感知不明显数据量上百条时非常明显。解决方式不需要延迟加载时直接在查询 SQL 里使用join显式关联然后在resultMap中配置columnPrefix区分同名字段。或者使用collection的select属性实现一次查询但使用时要格外小心避免 N1。6.5 常见问题速查表问题现象排查方向启动报 Invalid bound statementMapper 接口命名空间、mapper-locations 路径、XML 是否打入 jar 包#{}参数取不到值参数未加Param、参数名拼写不一致SQL 注入风险告警误用${}拼接用户输入改用#{}并做白名单校验查询字段映射为 null未开启map-underscore-to-camel-case或 resultMap 字段不匹配INSERT 后主键为 null未配置useGeneratedKeystrue和keyProperty二级缓存数据不一致多个 Mapper 操作同一张表时缓存 namespace 之间不共享查询结果重复resultMap 中未配置id导致关联去重失效分页查询数据不准插件拦截顺序、PageHelper 线程上下文未清理7. 我有话要对你说写过几年 Spring Boot MyBatis 项目后我越来越觉得这个组合的“舒服点”在于自由掌控与开发效率之间的平衡。它不像 Hibernate 那样控制力强到让你忽视 SQL也不像纯 JDBC 那样繁琐而重复。该手动写复杂 SQL 时你完全可以写该用动态 SQL 简化拼接时它又不会绑架你。如果说有什么经验值得单独拿出来说那就是一定别把 MyBatis 用得过于“黑盒”。你可以不读源码但至少要知道它内部有 SqlSessionFactory、Executor、MappedStatement 这些核心角色知道一次 Mapper 方法调用背后发生了哪些层面的解析、生成、执行和映射。你把这个执行链路摸透了遇到问题排查起来效率会高很多。再分享一个小技巧如果你接手的项目里 MyBatis 配置写得混乱先别急着重构。把mapper-locations路径捋清楚把日志打印打开先跑通一条完整链路确认了“配置 → SQL → 结果映射”三环节都正常再动手拆分和重构风险会小得多。希望这篇文章能帮你在 Spring Boot 的 MyBatis 之路上少踩几个坑。有问题随时在评论区交流每一个踩过的坑都值得被分享那些坑才是这个技术栈最宝贵的教材。