资讯动态

Spring整合MyBatis:DAO层动态代理与SqlSessionTemplate原理深度解析

发布时间:2026/9/10 6:57:44 来源:尧图企业网站定制
1. 项目概述为什么 DAO 层让人既熟悉又头疼先说个真实场景。很多人第一次把 Spring 和 MyBatis 整合起来写完一个 mapper 接口在 Service 里用Autowired注入进去项目启动居然报错了或者注入成功但一调用就抛Invalid bound statement (not found)。更奇怪的是明明 Mapper 是个接口MyBatis 和 Spring 却总能像变魔术一样塞给你一个“实现”让你能直接userMapper.selectById(1)。如果你也卡在这一步或者想彻底搞明白这层“魔法”背后到底发生了什么这篇就是为你准备的。我不打算讲那种“跟着步骤抄一遍能跑”的教程那些你随便搜都有。我想把 Spring 整合 MyBatis 时DAO 层这条链路里最容易踩坑、也最值得搞清楚的部分拆开讲接口为什么能被注入、MyBatis 的动态代理怎么工作、SqlSessionTemplate 和 SqlSession 有什么区别、缓存是怎么回事、还有那些你迟早会碰到的异常是怎么来的。这套内容适合谁刚开始接触 SSM 整合的初学者写了好几年业务代码但对底层一问三不知的中级开发以及准备面试想把这个知识点串清楚的人。看完不敢说你能手写一个 MyBatis但至少以后遇到 DAO 层相关的问题不会一脸懵地靠百度苟活。这是一个纯后端的基础问题不需要前后端配合也没有分布式环境依赖理解起来成本很低但收益很高因为这类知识点属于“一次搞懂、终身受用”的类型。2. 从一段最小代码反推设计思路2.1 先看一段再熟悉不过的代码来一个 Spring Boot MyBatis 最典型的 DAO 层程序Mapper public interface UserMapper { User selectById(Param(id) Long id); }你的 Service 里用它Service public class UserService { Autowired private UserMapper userMapper; public User getUser(Long id) { return userMapper.selectById(id); } }就这么简单。Mapper 是接口没有impl但 Spring 容器里确确实实存在一个类型为UserMapper的 Bean。你不得不问一句这个 Bean 是哪来的这就是整个 DAO 层整合的核心谜题——接口没有实现类却说可以注入。答案涉及三个角色JDK 动态代理、MyBatis 的 MapperProxy、Spring 的扫描注册机制。下面一层层拆开看。2.2 为什么必须用接口不用接口不行吗很多新手问我直接在 Service 里注入 SqlSessionTemplate自己写 SQL 不行吗当然行但极其痛苦。你没有接口意味着每次查询都要手动指定 SQL 的 id、手动处理参数映射、手动把结果集转成对象代码量翻三倍不说还失去了编译期类型检查。DAO 存在的意义就是用接口把“我要什么”和“怎么做”分离。正因为我们面对的是接口才能顺理成章地使用 JDK 动态代理而 MyBatis 正好就是基于 JDK 动态代理来实现 Mapper 接口的运行时实现的。所以这块设计不是 Spring 单独搞的是 Spring MyBatis 两个框架配合出来的产物。Spring 负责把代理对象放进容器MyBatis 提供代理逻辑两者缺一不可。3. MapperProxyMyBatis 的动态代理核心3.1 JDK 动态代理回顾先说底层机制。JDK 动态代理要求目标必须是一个或多个接口运行时通过Proxy.newProxyInstance()创建一个实现了这些接口的代理对象。当你调用代理对象的方法时方法调用会被转发到InvocationHandler.invoke()方法里。MyBatis 做的就是为每个 Mapper 接口创建一个MapperProxy实例这个实例实现了InvocationHandler。看一下核心逻辑它长这样public class MapperProxyT implements InvocationHandler, Serializable { private final SqlSession sqlSession; private final ClassT mapperInterface; private final MapMethod, MapperMethodInvoker methodCache; Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 处理 Object 中的方法比如 toString、hashCode if (Object.class.equals(method.getDeclaringClass())) { return method.invoke(this, args); } // 缓存里取避免每次反射解析 return cachedInvoker(method).invoke(proxy, method, args, sqlSession); } }每次调用userMapper.selectById(1)实际会走MapperProxy.invoke()然后从缓存中拿到对应的MapperMethod最终交给SqlSession执行。3.2 MapperMethod 是怎么把接口方法变成 SQL 执行的MapperMethod是另一个关键类它干两件事解析方法对应的 SQL 语句、解析参数并执行。public Object execute(SqlSession sqlSession, Object[] args) { Object result; switch (command.getType()) { case INSERT: { Object param paramNameResolver.getNamedParams(args); result rowCountResult(sqlSession.insert(command.getName(), param)); break; } case UPDATE: { Object param paramNameResolver.getNamedParams(args); result rowCountResult(sqlSession.update(command.getName(), param)); break; } case DELETE: { Object param paramNameResolver.getNamedParams(args); result rowCountResult(sqlSession.delete(command.getName(), param)); break; } case SELECT: { // 根据返回类型判断是返回单条、多条、还是游标 if (method.returnsVoid() method.hasResultHandler()) { executeWithResultHandler(sqlSession, args); result null; } else if (method.returnsMany()) { result executeForMany(sqlSession, args); } else if (method.returnsMap()) { result executeForMap(sqlSession, args); } else if (method.returnsCursor()) { result executeForCursor(sqlSession, args); } else { Object param paramNameResolver.getNamedParams(args); result sqlSession.selectOne(command.getName(), param); } break; } default: throw new BindingException(Unknown execution method for: command.getName()); } return result; }这个类的SqlCommand内部类负责解析注解或 XML 中的 SQL 标识MethodSignature负责解析方法返回类型、参数注解。所以你可以看到MyBatis 在执行前把所有元数据都解析好、缓存好真正调用时已经不再做大量反射操作性能是有保障的。从使用者的角度来看你的 DAO 接口方法签名并不能随便写它必须和 XML 里的id对应上参数名也必须对得上否则就会出现BindingException。这也是我后面要讲的一个常见坑。3.3 为什么说 MapperProxy 是“无状态”的一个容易忽略但很重要的点MapperProxy实例本身几乎不持有任何业务状态它的核心依赖是SqlSession和methodCache。也就是说同一个 Mapper 接口的所有代理逻辑只依赖一个共享的SqlSession而SqlSession是否线程安全决定了代理对象能不能安全地交给 Spring 容器管理。实际上 MyBatis 的官方SqlSession也就是DefaultSqlSession不是线程安全的多个线程共享同一个DefaultSqlSession会出问题。但直接每个操作新建一个又太浪费连接。Spring 整合包里的SqlSessionTemplate就是为了解决这个问题而生的。它实现了线程安全的SqlSession代理本质上是把SqlSession和 Spring 的事务绑定起来。4. Spring 是怎么把 Mapper 接口注册成 Bean 的4.1 MapperScannerConfigurer 的作用有了代理逻辑还得让 Spring 知道你在MapperScan(com.example.mapper)里配置的那些接口每一个都要替你生成一个代理对象放进容器。核心工具是MapperScannerConfigurer这个BeanDefinitionRegistryPostProcessor。Spring 启动时它会扫描指定包下的所有接口然后为每个接口生成一个MapperFactoryBean的BeanDefinition。这些接口最终以mapper的 beanName 注册到容器中。简化逻辑如下public class MapperScannerConfigurer implements BeanDefinitionRegistryPostProcessor, InitializingBean { private String basePackage; private SqlSessionFactory sqlSessionFactory; Override public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) { ClassPathMapperScanner scanner new ClassPathMapperScanner(registry); scanner.setSqlSessionFactoryBeanName(this.sqlSessionFactoryBeanName); scanner.registerFilters(); scanner.scan(StringUtils.tokenizeToStringArray(this.basePackage, ConfigurableApplicationContext.CONFIG_LOCATION_DELIMITERS)); } }这种后置处理器的执行时机很早甚至早于普通ServiceRepository的注册。很多人排查循环依赖时容易忽略这一点Mapper 接口的 BeanDefinition 是在配置类阶段就注册好了而不是等到实例化阶段才扫描。所以哪怕 Service 的构造器里直接依赖 Mapper也不容易出现“Mapper 还未创建”的问题。4.2 MapperFactoryBean产生 Mapper 对象的工厂MapperFactoryBean是 Spring 的FactoryBean实现它的getObject()返回的是代理对象而不是它自身。代码大致如下public class MapperFactoryBeanT extends SqlSessionDaoSupport implements FactoryBeanT { private ClassT mapperInterface; Override protected void checkDaoConfig() { // 验证 mapperInterface 是否配置且必须接口 super.checkDaoConfig(); Configuration configuration getSqlSession().getConfiguration(); if (this.addToConfig !configuration.hasMapper(this.mapperInterface)) { configuration.addMapper(this.mapperInterface); } } Override public T getObject() throws Exception { return getSqlSession().getMapper(this.mapperInterface); } }getSqlSession()返回的是SqlSessionTemplate。所以最终的代理生成链路是Spring 扫描到UserMapper接口注册MapperFactoryBean这个 FactoryBean容器要获取UserMapper类型的 Bean 时调用getObject()getObject()内部调用SqlSessionTemplate.getMapper(UserMapper.class)Configuration.getMapper()使用 JDK 动态代理创建MapperProxy实例代理对象被包装成一个 Bean 返回这里还有个隐藏知识点MapperFactoryBean实现了SqlSessionDaoSupport这个支持类内部持有SqlSessionTemplate和SqlSessionFactory。如果你配置了多数据源每个数据源对应一个SqlSessionFactory那么每个 Mapper 要指定用哪一个 factory否则就会用默认的那个这也是多数据源时报“无效 bound statement”的常见原因。4.3 MapperScan 和 Mapper 注解的关系Mapper注解是 MyBatis 提供的它是一个标记注解MyBatis 的扫描器看到它会把这个接口加入配置。MapperScan是 MyBatis-Spring 提供的作用是在某个配置类上指定扫描包路径。Configuration MapperScan(com.example.dao) public class MyBatisConfig { Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); return factoryBean.getObject(); } }实际项目中更常见的是在 Spring Boot 的启动类上直接写MapperScan(com.xxx.mapper)然后 Mapper 接口上还可以继续加Mapper两者并不冲突。MapperScan扫描到的接口会注册为 MapperMapper注解在非扫描场景下用来告诉容器“这是一个 MyBatis Mapper”。如果你两种方式都没用只在接口上写Repository那 Spring 只会把它当成普通 Bean 的候选不会走MapperFactoryBean的流程结果就是启动报错找不到 Bean或者在注入时报NoSuchBeanDefinitionException。5. SqlSession 和 SqlSessionTemplate线程安全的取舍5.1 DefaultSqlSession 为什么不安全MyBatis 原生环境里使用方式很简单SqlSession session sqlSessionFactory.openSession(); try { UserMapper mapper session.getMapper(UserMapper.class); User user mapper.selectById(1L); } finally { session.close(); }注意这个session不是一个长时间存活的对象通常一个请求或一个事务结束就要关闭。而DefaultSqlSession内部维护着Executor这个Executor和数据库连接绑定。多个线程共享同一个 session就是共享同一个数据库连接事务隔离、连接管理都会出问题。你可以把一个DefaultSqlSession理解成“一次数据库操作的会话凭证”它不是线程安全的。5.2 SqlSessionTemplate 的设计思路MyBatis-Spring 中的SqlSessionTemplate和 Spring 事务机制深度绑定。它代理了 SqlSession 的所有方法但真正干活时会调用SqlSessionUtils.getSqlSession()获取一个和当前 Spring 事务绑定的 SqlSession。关键逻辑public class SqlSessionTemplate implements SqlSession, DisposableBean { private final SqlSession sqlSessionProxy; public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory) { this.sqlSessionFactory sqlSessionFactory; this.executorType sqlSessionFactory.getConfiguration().getDefaultExecutorType(); this.sqlSessionProxy (SqlSession) Proxy.newProxyInstance( SqlSessionFactory.class.getClassLoader(), new Class[]{SqlSession.class}, new SqlSessionInterceptor() ); } private class SqlSessionInterceptor implements InvocationHandler { Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // 从 Spring 事务上下文中拿 SqlSession SqlSession sqlSession SqlSessionUtils.getSqlSession( SqlSessionTemplate.this.sqlSessionFactory, SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator); try { Object result method.invoke(sqlSession, args); // 如果当前没有事务那这次操作结束后需要立即提交不能让它持有事务 if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) { sqlSession.commit(true); } return result; } catch (Throwable t) { // 异常时如果是非 Spring 事务则回滚 if (t instanceof PersistenceException) { Throwable cause t.getCause() ! null ? t.getCause() : t; if (cause instanceof SQLException) { // 把 SQLException 转换成 Spring 的 DataAccessException } } throw t; } finally { // 关闭 session返回连接池 SqlSessionUtils.closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory); } } } }这个拦截器最有意思的一点是它让 MyBatis 的 session 生命周期和 Spring 事务完全绑定。如果当前在事务中它会复用同一个 SqlSession这样同一个事务里对同一个查询才会命中一级缓存如果当前没有事务每次 Mapper 方法调用都会拿一个新的 session用完立刻关闭连接还回连接池。一句话总结SqlSessionTemplate就是给 SqlSession 套了一层“按需分配、用后即还”的动态代理壳子它本身可以被多个线程安全地共享。5.3 连接是何时归还的很多人误以为 Spring 整合 MyBatis 后每个 Mapper 方法都独立一个数据库连接。这话在对的也不全对。在非事务场景下每个 Mapper 方法确实独立获得连接并释放一旦方法上有Transactional整个事务内的所有 Mapper 调用共享同一个连接直到事务提交或回滚才归还。可以想象成餐厅吃饭非事务场景你点一个菜厨房单独做一份做完即走事务场景你订了一桌宴席点多少菜都从同一个灶台出宴席结束才关火。这对性能影响极大如果一个方法里循环调用了 100 次 Mapper不加事务就是 100 次连接获取和释放加上事务以后只有 1 次。这也是一个常见的性能优化切入点。6. 缓存机制一级缓存、二级缓存和那些坑6.1 一级缓存的默认行为MyBatis 的一级缓存是本地缓存作用域是SqlSession。同一个SqlSession里两次执行相同的查询第二次直接走缓存。前面说过Spring 整合后同一个事务内SqlSession是共享的所以在事务方法里两次selectById同一个 id第二次不会查数据库。来看个例子Transactional public User testCache(Long id) { User u1 userMapper.selectById(id); User u2 userMapper.selectById(id); System.out.println(u1 u2); // true同一对象或 equals 相等且同引用 return u2; }在没有事务的情况下两次独立调用userMapper.selectById(id)每次都会新建 SqlSession 并关闭一级缓存基本没有意义。官方文档里写过一句话一级缓存是 SqlSession 级的如果 SqlSession 关闭了缓存也没了。这句话是对的但临床应用上有一个严重的坑如果你在同一个事务里先查询一个对象然后手动执行了一个 update哪怕改的是另一个表一级缓存就被清空了。原因很简单MyBatis 认为任何写操作都可能改变数据不清理缓存会读到脏数据。6.2 一级缓存失效的常见场景两次查询之间执行了任何 insert/update/delete两次查询使用了不同的 SqlSession比如跨事务查询的参数对象 equals 或 hashCode 变了手动调用了sqlSession.clearCache()所以你在一个事务里有几步需要“先查再判断再更新再查”的业务逻辑千万别指望第二次查询一定走缓存很可能已经被清掉一次了。这不是 bug是设计如此。6.3 二级缓存全局共享但轻易别开二级缓存的作用域是 namespace也就是一个 Mapper 文件对应的所有操作共享缓存。默认情况下 MyBatis 的二级缓存是不开启的你得先在 XML 里加cache/标签或者在注解方式下用CacheNamespace。开了以后缓存的存放对象必须是可序列化的默认序列化方式是 Java 原生序列化。这就引出第一个坑如果你缓存的 POJO 没有实现Serializable直接报NotSerializableException。更大的坑是数据一致性问题。二级缓存是跨 SqlSession 的如果在其他地方直接改了数据库比如另一个系统、Job、存储过程或者用了多数据源缓存不会自动失效你会读到陈旧数据。另外如果两个 Mapper 共用了某些表那一个 Mapper 更新数据时另一个 Mapper 的缓存不会被清掉照样读到旧值。我的实际建议很简单绝大部分业务系统不要开二级缓存。数据库本身就有一套成熟的缓存和查询优化应用层缓存引入的一致性问题远比省下的那几次查询划不来。真需要缓存上 Redis 或者 Caffeine用代码显式控制失效别把命交给自动缓存。关于“mybatis缓存”这个热搜词面试里最常问的区分就是一级缓存默认开启作用域是 SqlSession二级缓存需要手动开启作用域是 namespace。两者都会在更新操作后失效但二级缓存因为跨 session问题更隐蔽。7. 常见异常与排查思路实录7.1 Invalid bound statement (not found)这个应该是 DAO 层遇到最多的异常了。报错信息长这样org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.mapper.UserMapper.selectById排查步骤依次确认XML 文件的 namespace 是不是和 Mapper 接口全限定名一致。这是最常见的原因namespacecom.example.mapper.UserMapper写错一个字母启动可能不报错但调用一定挂。XML 文件有没有被 Maven 打包进 classes 目录。如果你把 XML 放在src/main/java目录下而构建工具没有把 XML 资源复制到输出目录运行时就找不到 Statement。Spring Boot 项目建议把 XML 放到src/main/resources/mapper下或者配置mybatis.mapper-locationsclasspath:mapper/*.xml。Mapper 接口方法名和 XML 里的 id 是否一致。MapperScan扫描的包路径是否覆盖到了你的 Mapper 接口。当你排除了以上所有问题还是不行把配置打出来看。在 application.yml 里加mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 开发环境下可以打印 SQL logging: level: com.example.mapper: debug日志里能看到 MyBatis 初始化的 XML 解析记录。把这条日志发到搜索引擎基本上能定位问题。7.2 循环依赖导致 Mapper 代理创建异常MyBatis-Spring 版本比较老的整合方案里容易在Transactional和Lazy混用时出现代理问题。比如 ServiceA 依赖 ServiceBServiceB 又依赖 ServiceASpring 循环依赖处理时如果某个 Bean 需要 AOP 代理就可能提前暴露尚未完全创建的对象而 Mapper 的 FactoryBean 在创建代理时依赖 SqlSessionFactory如果这个工厂还没有就绪会报BeanCreationException。Spring Boot 2.6 之后默认禁止循环依赖很多老项目升级后突然报错也是这个原因。排查思路是去应用配置看看spring.main.allow-circular-references是否被改过。真正的修复是把 Service 层的循环依赖拆掉而不是开开关。7.3 多数据源时 Mapper 注入后还是查错库多数据源配置下如果两个SqlSessionFactory都注册到了容器里MapperFactoryBean拾取哪个工厂取决于MapperScan的sqlSessionFactoryRef设置。Configuration MapperScan(basePackages com.example.dao.user, sqlSessionFactoryRef userSqlSessionFactory) public class UserDataSourceConfig { Bean public SqlSessionFactory userSqlSessionFactory(Qualifier(userDataSource) DataSource dataSource) throws Exception { SqlSessionFactoryBean bean new SqlSessionFactoryBean(); bean.setDataSource(dataSource); return bean.getObject(); } }很多“查询结果对不上库”的问题根本原因就是所有的 Mapper 都走了默认的SqlSessionFactory。办法就是把每个数据源对应的 Mapper 包路径分开用不同的MapperScan绑定不同的sqlSessionFactoryRef。7.4 传参时参数名解析失败老一点的项目在编译时没有加-parameters参数MyBatis 解析Param(id)以外的参数名时会变成arg0、arg1导致 XML 里#{id}找不到值。解决方式方法签名上显式加ParamMaven 编译插件配置maven.compiler.parameterstrueSpring Boot 项目一般会自动带上但如果你是手动打包的 Java 工程注意检查我在实际开发中的铁律多参数方法一律加 Param别图省事。这不光是为了 MyBatis 能识别也是让代码可读性更好避免三个月后回来一看arg0不知道是什么参数。8. 工程上的经验建议8.1 用 XML 还是注解MyBatis 支持注解写 SQL比如Select(select * from user where id #{id})。简单语句用注解确实简洁但复杂动态 SQL多条件拼接、foreach、choose when otherwise用注解写就是一坨可读性极强的灾难。我试过用SelectProvider 拼接字符串的方式写一个复杂报表查询后来维护时自己都想骂人。一个比较主流的选择单表简单 CRUD用 MyBatis-Plus 的 BaseMapper不要自己写复杂查询、多表关联、动态 SQL放 XML注解方式只用于极少数固定 SQL比如按主键查状态、计数器更新XML 的好处不只是清晰还能在不改 Java 代码的情况下调整 SQL对生产环境排查问题非常有用。你把 SQL 模板放在 XML 里DBA 拿过去直接能看不用翻源码。8.2 DAO 层命名规范和分层我见过很多项目把 Service 层写得和 DAO 层一样每个方法直接透传 Mapper。这不叫分层这叫脱裤子放屁。一个合理的 DAO 层方法是围绕“数据访问”设计的而不是围绕“业务动作”设计的。举一组一眼能看出问题的接口方法User selectByUserName(String userName); ListUser selectByLastLoginTimeRange(Date start, Date end); int updateEmailById(Param(email) String email, Param(userId) Long userId);这是合理 DAO 的写法。反例是// 错误的示范 User getUserAndCheckStatusAndReturnProcessedResult(String userName); boolean updateLastLoginTimeAndFlagAndReturnSuccessOrNot(Long userId);DAO 层不管业务判断只管“查到了什么”“改了几行”。业务状态判断放到 Service 层。这不是风格偏好而是职责边界问题。业务规则一变如果是后者这种写法你大概率要改 DAO但你的 DAO 接口设计成通用查询就完全不用动。8.3 分页的坑新手在 DAO 层最容易踩的分页坑是用RowBounds做内存分页。这个类虽然提供了分页参数但它是把数据全部查回来后在内存里截取你的 offset 和 limit。数据量小时看不出来到了几十万行一次查询把整个表载入内存服务直接内存溢出。如果你用的 MyBatis-Plus分页直接用Page对象加分页插件底层会改写 SQL生成对应的LIMIT。如果你是原生 MyBatis用PageHelper插件但注意它在多数据源或嵌套查询时偶尔会自动拼接错误 SQL线上出过不少事故。我的建议手写分页 SQL 最稳复杂排序时灵活性最高。LIMIT #{offset}, #{size}就够了不要为了用一个框架而用一个框架。8.4 日志和 SQL 打印排查 DAO 层问题时SQL 和参数是最重要的线索。Spring Boot 项目里配置logging: level: com.yourproject.mapper: debug这样打印出来的是预编译前的 SQL参数会以?占位符形式出现。如果你想要带参数值的完整 SQL那得靠数据库端的日志或者第三方组件不要指望 MyBatis 默认打出来。有一些中间件能在日志里替换占位符但部分在特殊类型比如LocalDateTime、JSON上格式有问题看个人取舍。8.5 关于“自动建表”和基础设施热搜词里有一条“springboot mybatis 当表不存在自动建表”这个在 DAO 层是一个很容易被低估的设计选择。我的态度是数据库表结构的管理不应该由应用层代码承担。自动建表适合本地开发、单元测试和演示环境但不适合生产环境。生产环境的表结构变更要用 Flyway 或 Liquibase 这类迁移工具管理它们能把 schema 版本和代码版本绑在一起。如果只是在本地开发时需要快速建表可以用 Spring JDBC 的DataSourceInitializer或者直接写个schema.sql指定在启动时执行。但不要让业务 DAO 层的初始化代码去“检查表是否存在不存在就 CREATE TABLE”这种代码一旦进入生产就是炸雷的种子。想象一下多个应用实例同时启动同时发现表不存在同时执行 CREATE TABLE相互竞争最终报错操作还会越来越乱。9. 一个完整可跑的最小工程示例空谈太多没有手感我直接给你一个最小可运行的工程结构你照着搭起来再打断点看每一步调用比自己干看源码有用得多。目录结构spring-mybatis-demo/ ├── pom.xml └── src/main/ ├── java/com/example/demo/ │ ├── DemoApplication.java │ ├── controller/UserController.java │ ├── service/UserService.java │ ├── dao/UserMapper.java │ └── entity/User.java └── resources/ ├── application.yml ├── mapper/UserMapper.xml └── schema.sqlpom.xml 关键依赖dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependencyapplication.ymlspring: datasource: url: jdbc:mysql://localhost:3306/test?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: yourpassword driver-class-name: com.mysql.cj.jdbc.Driver sql: init: mode: always mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true logging: level: com.example.demo.dao: debugUser.javapublic class User { private Long id; private String name; private Integer age; // getter/setter 略 }UserMapper.javapackage com.example.demo.dao; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; Mapper public interface UserMapper { User selectById(Param(id) Long id); }UserMapper.xml?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.dao.UserMapper select idselectById resultTypecom.example.demo.entity.User select id, name, age from user where id #{id} /select /mapperUserService.javapackage com.example.demo.service; import com.example.demo.dao.UserMapper; import com.example.demo.entity.User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; Service public class UserService { private final UserMapper userMapper; public UserService(UserMapper userMapper) { this.userMapper userMapper; } Transactional public User getUserWithCache(Long id) { User u1 userMapper.selectById(id); User u2 userMapper.selectById(id); System.out.println(u1 u2: (u1 u2)); return u2; } }schema.sqlCREATE TABLE IF NOT EXISTS user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64), age INT ); INSERT INTO user (name, age) VALUES (张三, 25);注意schema.sql在生产环境要禁掉我是为了让你本地跑方便才加的。启动工程后访问/user/1或者直接写个启动时调用的 CommandLineRunner你就能在日志里看到第一次查询发送了 SQL第二次查询没有发送因为一级缓存生效了。我在本地还专门设了断点去检查MapperProxy的状态确认methodCache在同一个 Mapper 代理上的调用是命中缓存的。看源码 断点 日志三者交叉验证远比背面试题扎实。10. 关于 DAO 层设计的一些后续建议Spring 整合 MyBatis 的 DAO 层到这里主干内容已经讲透了。但工程实践远比框架用法复杂比如多租户的数据权限隔离、逻辑删除、乐观锁、公共字段自动填充这些都是 DAO 层设计要思考的问题。如果你准备用 MyBatis-Plus它能帮你省掉不少基础 CRUD但底层依然跑在上述机制之上碰到疑难杂症回来复习本文提到的东西就能看得懂异常栈。还有一点值得提很多初级工程师会把 DAO 层和“表”一一对应表一多Mapper 类爆炸。我的建议是必要时按领域聚合把相关表的查询聚合到一个 DAO 里避免一个微小的查询也要新建一个 Mapper 接口文件。当然这个看团队规范核心是别让 DAO 层变成“一个表一个类”的无脑堆砌然后在 Service 里拼 SQL 拼接逻辑。最后再分享一个排查 DAO 问题的万能技巧把所有日志级别调到 DEBUG把 MyBatis 的 SQL 打印出来再结合数据库的慢查询日志绝大多数“查得慢”“结果不对”“插入没生效”的问题都能定位。框架底层的坑大多有迹可循真正让你头疼的反而往往是传参错误、配置遗漏这种低级问题所以严谨细心的代码审查习惯比背一百个面试题更重要。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价