2026/10/7 17:56:36

MyBatis进阶:缓存机制、动态SQL与源码执行链路全解析

MyBatis进阶:缓存机制、动态SQL与源码执行链路全解析 1. 从基础到进阶MyBatis二该聊什么如果你是因为标题里那个二点进来的那大概跟我一样属于那种基础会用但总觉得差点意思的阶段。mybatis相信大多数Java开发都不陌生增删改查、动态SQL、结果映射这些日常操作基本都能上手但真要往深了问——缓存怎么失效的、SQL怎么打不出来、条件明明写了却不生效、源码里那条调用链到底怎么走的——很多人就开始犯嘀咕了。这篇就是冲着这些说白了就是没搞懂的痛点去的。这一篇我打算不按官方文档的目录顺序来而是按我实际踩坑的顺序来讲先说配置层面的SQL打印和参数映射再拆缓存机制一级缓存和二级缓存各自的生命周期和坑然后是动态SQL里条件不生效的真相最后带大家走一遍源码主链路顺便把Spring Boot整合中一个最简单的注册功能背后牵扯到的组件挨个点名。适合谁看已经能用MyBatis写CRUD、但对缓存和源码有好奇心的同学以及准备面试想在mybatis这块讲出点名堂的朋友。2. 配置打印SQL这件事比想象中更重要2.1 日志配置为什么是排查问题的第一道门很多新手在MyBatis里遇到SQL写错了但不知道错在哪的困境第一反应是去翻数据库日志但数据库的慢日志和通用日志一般不会默认开而且生产环境也不建议随便开。其实MyBatis自己就带了一套完整的日志输出机制只是默认没打开。配置打印SQL这一步看似简单却是后面所有问题定位的基础。我见过太多同事在本地开发环境配了log4j2或者slf4j但MyBatis的SQL日志一直出不来原因基本都是日志门面和实现框架之间的协调问题。MyBatis的日志适配器是按顺序去查找的slf4j、commons-logging、log4j2、log4j、jdk logging找到第一个可用的就会绑定不会再换。所以如果你的项目同时引入了log4j2和slf4j但slf4j的绑定器没有配好MyBatis可能就绕到了别的实现上导致你按log4j2的配置去调怎么都调不出SQL。实际配置的时候在application.yml里加上这句Spring Boot场景mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl或者更常用的方式在mybatis-config.xml里settings setting namelogImpl valueSLF4J/ /settings如果用的是MyBatis原生没有Spring Boot自动装配的裸环境那就得在SqlSessionFactory的Configuration对象上手动设置org.apache.ibatis.session.Configuration configuration new org.apache.ibatis.session.Configuration(); configuration.setLogImpl(StdOutImpl.class);这里插一句如果你配了StdOutImpl也就是直接输出到控制台虽然开发时看起来最直观但要注意它没有日志级别控制生产环境千万别用这种方式否则SQL会全部打到标准输出日志量会非常可怕。生产环境建议用SLF4J配上对应的level把mapper包下的日志级别设为DEBUG。2.2 从打印出来的SQL反推参数绑定真正把SQL打印出来之后门的价值才体现出来。你会发现MyBatis默认打印的是Preparing语句和Parameters比如 Preparing: SELECT * FROM user WHERE id ? AND name ? Parameters: 1(Integer), 张三(String)这两个输出其实已经包含了大部分调试信息。Preparing里是MyBatis实际发送给JDBC的预编译语句它只会出现问号占位符而不会有具体值具体值在Parameters一行里。很多初学者看到?就以为SQL写错了其实这正是预编译的正常表现——预编译的好处是可以防止SQL注入同时让数据库复用执行计划。还有一个小技巧很多人不知道如果你希望看到MyBatis底层真正执行时拼接出来的完整SQL也就是参数替换后的样子光靠默认配置是看不到的。可以引入p6spy这类JDBC级监控工具它能拦截到真实执行的SQL对排查那种参数类型不对导致索引失效的问题特别有用。不过这类工具本身有性能损耗我的建议是只在测试环境用。注意生产环境排查问题时把日志级别临时调整到DEBUG是可以的但务必在看完后立刻恢复否则大量SQL输出会把磁盘IO和日志系统拖垮这个坑我踩过一次教训很深刻。3. 缓存机制一级缓存和二级缓存以及它们埋的坑3.1 一级缓存的生命周期比你想象的短缓存这块算是MyBatis面试题的常客也是实际项目里最容易出问题的点。先明确一个概念MyBatis的一级缓存默认是开启的它的作用域是SqlSession。所谓SqlSession就是一次数据库会话你在同一个SqlSession里执行两次完全相同的查询第二次会直接命中一级缓存不再查库。但问题来了Spring整合MyBatis之后SqlSession是由SqlSessionTemplate管理的每次执行一个Mapper方法都会获取一个新的SqlSession用完就关闭。这也意味着在Spring环境下一级缓存在绝大多数情况下根本活不过一次方法调用——你在Service层调用两次userMapper.selectById(1)这两次调用用的很可能是两个不同的SqlSession一级缓存自然就享受不到。这就是为什么网上很多文章说一级缓存默认开启但基本没用其实不是它没用而是Spring帮你把SqlSession的生命周期管得太短了。如果你真的想在同一个方法里利用一级缓存可以用SqlSessionTemplate的execute方法手动包一个回调让两次查询都在同一个SqlSession里执行不过这种写法在实际项目里很少见因为收益有限。一级缓存真正要注意的是脏读问题。假设你在一个SqlSession内先查了某个用户的资料然后执行了一个更新操作把这个用户的资料改了MyBatis会清空当前SqlSession的一级缓存这是合理的。但如果你在同一个SqlSession里先查A再查B再查A第二次查A如果B的查询不影响A的数据缓存就还能命中。这个机制本身没问题问题往往出在缓存没有及时失效的场景比如自己手写了一个批量更新用SqlSession直接执行了update却忘了更新操作会清空缓存这件事导致后续查询返回了旧数据。3.2 二级缓存怎么配才靠谱二级缓存的作用域是Mapper namespace也就是同一个Mapper接口下的所有查询可以共享缓存。它的开启方式是cache evictionLRU flushInterval60000 size512 readOnlytrue/或者用注解方式CacheNamespace(eviction LruCache.class, flushInterval 60000, size 512, readOnly true)开启了二级缓存的namespace在执行查询时会把结果序列化后存起来注意这个序列化意味着你的POJO对象必须实现Serializable接口否则运行时会直接报NotSerializableException。这是一个很典型的低级错误但出现的频率高到让人怀疑人生。配置二级缓存时有几个参数必须理解透eviction缓存淘汰策略。LRU最常用意思是当缓存容量到上限时淘汰最久没被使用的项。可选值还有FIFO先进先出、SOFT软引用、WEAK弱引用。SOFT和WEAK依赖GC机制虽然可以自动释放内存但可能会提前把缓存清掉导致命中率不稳日常项目还是LRU比较可控。flushInterval刷新间隔单位是毫秒。如果不配那就只有在执行了insert/update/delete操作后才会清空缓存。注意这个执行了操作就清空是指当前namespace内的操作才会触发清除如果两个表做了关联查询修改了另一个表的数据这里不会知道。size缓存最多能存多少个结果对象。配太大容易内存溢出配太小缓存形同虚设。readOnlytrue表示返回的对象的同一个实例速度快但调用方可以修改缓存中的数据false表示每次返回一个反序列化后的新对象安全但开销大。二级缓存最大的坑在于多表联查。比如你查询订单表关联了用户表的数据缓存在OrderMapper的namespace下。这时如果UserMapper更新了用户名OrderMapper的缓存根本感知不到于是用户在界面上看到的还是旧用户名。这个问题业界一般不建议在关联查询频繁的业务中用二级缓存真要加速就上Redis做应用层缓存。我的经验是二级缓存适合那种数据基本不变、按主键查询频率极高的场景比如字典表、配置表、省份城市等。3.3 mybatis二级缓存实现里容易忽略的细节顺着热搜词里mybatis二级缓存实现这个话题多说两句源码层面的东西。二级缓存实际是被装饰器模式层层包裹起来的。真正的缓存存储是PerpetualCache就是一个HashMap外层套了LruCache淘汰策略、BlockingCache阻塞缓存默认开启、SerializedCache序列化缓存readOnlyfalse时加入、LoggingCache日志记录命中率。这个装饰器链的顺序是固定的由CacheBuilder来组装。默认情况下即使你只写了cache/这一行MyBatis也会给你套上至少三层以上的装饰器。其中BlockingCache很有意思它的设计是当缓存未命中时通过加锁让同一个key的并发查询只有一个线程去查数据库其他线程等待其结果以此来避免缓存穿透。这个机制默认是被开启的源码里有个blocking属性默认就是true我看不少老版本项目里根本不知道有这个默认值存在。还有一个容易忽略的细节是事务对二级缓存写入时机的影响。二级缓存的写入不是查询完立刻写而是要等事务提交之后才真正生效。也就是说在事务里执行了两次相同查询第一次查询结束时会先把结果放到一个待提交的暂存区TransactionCache如果事务回滚了这些暂存的结果会被直接丢弃。这个机制是为了防止把事务中未提交的数据污染到缓存里。提示面试的时候如果能把这个事务提交才写入二级缓存的细节讲出来绝对能跟那些只会背二级缓存默认开启、作用域是namespace的候选人拉开差距。4. 动态SQL与条件不生效的真相4.1 条件不生效先怀疑空格和逗号动态SQL是MyBatis最灵活也最容易翻车的部分。热搜词里mybatis条件不生效和mybatis等于看起来像是两个不相关的需求其实指向的是同一个核心问题动态SQL拼接时标签书写不当会导致SQL语法错误而SQL语法错误的很多表现在日志里看起来就像条件没生效。最常见的翻车场景是where条件拼接。很多人喜欢这样写select idselectByCondition resultTypeUser SELECT * FROM user WHERE 1 1 if testname ! null and name ! AND name #{name} /if if testage ! null AND age #{age} /if /select这个写法本身没有问题WHERE 1 1是老一代开发者传下来的习惯纯粹是为了让后面的AND能直接拼上去。但从可读性角度讲MyBatis提供了where标签来做这件事select idselectByCondition resultTypeUser SELECT * FROM user where if testname ! null and name ! AND name #{name} /if if testage ! null AND age #{age} /if /where /selectwhere标签会自动去掉第一个满足条件的子句前面的AND或者OR而且如果没有任何一个if成立它连WHERE关键字都不会输出。这个省心程度比手写WHERE 1 1高不少官方文档也是推荐这样用的。但条件不生效往往不是where标签的问题而是出现在set标签配合更新语句时。比如你要做动态更新有的人会写update idupdateById parameterTypeUser UPDATE user SET name #{name}, if testage ! null age #{age} /if WHERE id #{id} /update当age为空时SQL会变成SET name ?, WHERE id ?这句SQL语法直接报错。用set标签可以自动处理末尾多余的逗号update idupdateById parameterTypeUser UPDATE user set if testname ! nullname #{name},/if if testage ! nullage #{age},/if /set WHERE id #{id} /updateset会去掉最后一个条件后面的逗号虽然它处理不了前面条件为空而后面条件非空时逗号位置的问题吗——不它能处理它会把SET关键字后面的第一个子句前面的逗号也去掉。这才是它存在的意义。4.2 等于判断为什么经常意外失效mybatis等于这个热搜词我猜有几层意思。第一层是if teststatus 1这种写法在XML里直接用判断整数看起来没问题但如果status是字符串类型还用了结果可能出乎意料因为在OGNL表达式里对字符串是比较内存地址还是equals很多环境下会踩坑。稳妥的写法是status 1.toString()或者反过来把字符串常量写在左边用equals方法判断。第二层是查询条件里的号配合动态标签时比如if testtype 1 AND type #{type} /if这个写法正确的前提是type确实是Integer类型如果你从页面传来的type是字符串1OGNL解析时type 1会因为1和1类型不同而返回false条件直接被跳过。解决办法是统一参数类型或者在test里写type 1。第三个更隐蔽的场景是MyBatis的choose标签。有些人写条件分支时没有给otherwise兜底导致当所有条件都不满足时生成了一条没有WHERE的查询SQL全表扫描还算小事在某些数据库方言下就是直接报错。choose对应Java的switchwhen对应caseotherwise对应defaultdefault一定要写。4.3 foreach、trim和bind的实用技巧动态SQL里还有一个高频场景是批量操作。批量删除用foreachdelete iddeleteByIds DELETE FROM user WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach /delete这里collection的值取决于你传入的参数。如果你传的是List写成list如果是数组写成array如果是实体类里的属性写属性名如果是Param注解指定的名字就写注解值。这个规则虽然简单但报错率极高报错信息通常是There is no getter for property named xxx in class java.lang.String这类。还有一个容易被忽略的是bind标签。当你需要做模糊查询时select idselectByName resultTypeUser bind namepattern value% name %/ SELECT * FROM user WHERE name LIKE #{pattern} /selectbind可以在SQL执行前对参数做一次表达式运算然后把结果作为新的变量传入这样比在XML里直接写LIKE %${name}%要安全得多因为后者会走字符串拼接存在SQL注入风险。${}是纯文本替换#{}是预编译占位符这个区别是MyBatis使用者的基本功但模糊查询场景下很多人仍然习惯用${}这是非常危险的写法。5. 从源码链路看MyBatis到底做了什么5.1 SqlSession、Executor、StatementHandler的分工既然热搜词里有mybatis源码我还是尽量用大家能听懂的方式把这条链路走一遍因为这不仅对面试有帮助也对排查问题有实质意义。一次标准的Mapper接口调用比如userMapper.selectById(1)底层大概经过了这么几个环节MapperProxyJDK动态代理拦截方法调用把方法名和参数转成SqlSession的对应操作。SqlSession把请求交给ExecutorExecutor是MyBatis的执行器它负责调用StatementHandler同时Executor也是缓存机制的载体——一级缓存就在这里二级缓存的逻辑也是通过CachingExecutor装饰器包在Executor外面的。StatementHandler负责和JDBC打交道它创建PreparedStatement设置参数执行SQL最后把ResultSet交给ResultSetHandler做结果映射。如果你看过源码你会发现MapperProxy里的invoke方法逻辑很清晰判断方法是不是Object的方法如果不是就找MapperMethod通过MapperMethod里的SqlCommand对象来区分这次调用是SELECT还是UPDATE。然后调用sqlSession.selectOne(statementId, args)这类方法。这里有个细节MyBatis也支持Mapper接口的默认方法处理逻辑在invoke方法里有一个if (method.isDefault())判断直接走反射调用DefaultMethodInvoker。5.2 Executor的三种实现和它们的选择依据Executor接口有三个典型实现SimpleExecutor每执行一次SQL就创建一条新的PreparedStatement用完就关。这是默认执行器。ReuseExecutor会复用PreparedStatement把statement缓存起来同一个SQL字符串用同一个statement对象。BatchExecutor批量执行更新语句把多条SQL攒到一批一次性提交适合大批量插入的场景但要注意它不会返回每个语句执行后生成的主键信息而且在未调用flushStatements之前结果集不会真正产生。这三种执行器在Spring Boot整合的场景下默认是由MybatisAutoConfiguration里面的SqlSessionTemplate来初始化的。如果你希望批量插入更快可以在构造SqlSessionFactory时用XML配置settings setting namedefaultExecutorType valueBATCH/ /settings但记得batch模式下如果中途有查询操作需要先flush一下statement否则可能出现奇怪的数据库连接状态。我实际测试过一个场景批量插入一万条数据BatchExecutor的处理速度比SimpleExecutor快大概3到4倍这是值得关注的性能优化点。Executor的调用链里还有一层拦截器机制。MyBatis的Plugin机制是通过JDK动态代理实现的会把Intercepts注解标明的类Executor、StatementHandler、ParameterHandler、ResultSetHandler替换成代理对象这也是分页插件PageHelper的实现原理。PageHelper能拦截Executor的query方法在执行前改写SQL追加limit条件。理解了这条链你就明白为什么PageHelper在某些情况下会失效——多半是拦截器顺序或SQL解析出错了。5.3 参数处理器和结果映射器的隐蔽逻辑ParameterHandler负责把Java参数绑定到PreparedStatement上。看起来就是preparedStatement.setXxx()的调用但这里有一个很容易被忽视的点如果传入参数缺失或者类型不对ParameterHandler会抛异常而异常信息往往很隐晦比如Parameter index out of range。这类问题定位时我建议先检查Mapper方法的Param名称是否和XML里的#{xxx}对上——因为MyBatis定位参数名有两种方式一种是通过Param注解另一种是通过编译时保留的-parameters参数反射获取如果你用的是JDK8以下的编译环境且没开-parameters拿到的参数名就会变成arg0/arg1这类默认名跟XML里的#{name}对不上就会报错。ResultSetHandler把结果集的列映射到Java对象时默认会把下划线风格的列名user_name转换成驼峰userName前提是开启了mapUnderscoreToCamelCasemybatis: configuration: map-underscore-to-camel-case: true很多团队在Spring Boot里没开这个配置又习惯了数据库用下划线、实体用驼峰结果每个Mapper都要写一遍resultMap代码量翻番不说还容易漏映射。这块建议无论项目大小都默认开起来只有极个别字段映射不规则的时候才单独配resultMap。6. Spring Boot整合实战从一个注册功能看组件协作6.1 从建表到跑通一个最简单的注册接口回到实际开发热搜词里出现了第2关使用springboot mybatis实现一个最简单的注册功能和springboot mybatis 结合 mvc框架设计这两条其实很适合串起来做一个小实战演示。我按自己的习惯把整个过程拆成几个阶段每个阶段都能验证对应组件是否正常工作。第一步是建表CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;第二步是引入依赖。Spring Boot 2.x系列的写法一般是dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency第三步是配置数据源和MyBatisspring: datasource: url: jdbc:mysql://localhost:3306/test?useUnicodetruecharacterEncodingutf8useSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl第四步写实体类、Mapper接口和XMLpublic class User { private Long id; private String username; private String password; private LocalDateTime createTime; // getter/setter省略 }public interface UserMapper { int insert(User user); }insert idinsert parameterTypeUser useGeneratedKeystrue keyPropertyid INSERT INTO user (username, password) VALUES (#{username}, #{password}) /insert注意这里有个关键细节useGeneratedKeystrue配合keyPropertyid执行完insert之后MyBatis会把数据库生成的主键回填到User对象的id属性上。这也是面试经常问的MyBatis如何获取自增主键的答案。如果不设置这两项你插入后拿不到主键后续要关联其他表就会很被动。第五步是Controller和ServiceRestController public class UserController { Autowired private UserService userService; PostMapping(/register) public Result register(RequestBody User user) { userService.register(user); return Result.ok(); } }Service public class UserService { Autowired private UserMapper userMapper; Transactional public void register(User user) { // 实际项目里这里要有密码加密、用户名唯一性校验等逻辑 userMapper.insert(user); } }一个最简单的注册功能到这里就通了。这个流程不到三十行核心代码但背后牵涉到的Spring Boot自动配置、Mapper代理生成、事务管理、JDBC连接池这些概念值得反复琢磨。6.2 Spring Boot整合时最常见的三个配置坑实际整合的时候比我上面演示的要曲折得多。我把这些年踩到的三个高频坑列一下每个我都赔过时间。第一个坑是mapper-locations配置失效。很多人建好了XML文件也写了接口接口里面就是找不到对应的statement报错一种Invalid bound statement (not found)。这个报错的根源八成是XML文件没有编译到target目录下或者mapper-locations的路径和实际文件路径不匹配。如果你的XML放在src/main/java目录下而不是src/main/resources一定要在pom.xml或者build.gradle里加上资源过滤配置否则Spring Boot打出来的包里根本没有这些XML。我的经验是XML统一放在src/main/resources/mapper下面用classpath:mapper/*.xml这个pattern最稳妥。第二个坑是Mapper接口没有加Mapper注解或者没有通过MapperScan扫描。Spring容器里没有Mapper的代理BeanController注入的时候就会直接报NoSuchBeanDefinitionException。很多文章推荐在启动类上写MapperScan(com.xxx.mapper)但我更倾向于在每个Mapper接口上标Mapper因为这样IDE能更准确地识别而且一个项目里多个包路径扫描时也不会出现扫多了导致代理冲突的问题。第三个坑是事务失效。Service里写了Transactional但方法内部调用了同类里的另一个方法导致事务没生效。这种问题不是MyBatis特有的但跟MyBatis结合时容易被误判成SQL问题。Spring的事务是通过AOP代理来实现的同类内部调用不会经过代理对象所以Transactional就废了。解决方法有两个一是把内部方法拆到另一个Bean里二是用AopContext.currentProxy()强行走代理。通常在Service里保持类的职责单一就不会频繁遇到这个问题。6.3 多商户商城等复杂项目中的MyBatis实践热搜词里有个spring boot mybatis 的 java 开源多商户跨境商城源码下载这个热搜本身有点标题党的味道。但顺着它想一个多商户商城项目对MyBatis的要求确实比单表CRUD高不少。比如多数据源配置商城系统经常是订单库、用户库、商品库分离的这时就要用dynamic-datasource这类库来动态切换数据源或者在SqlSessionFactory层面配置多个DataSource。多商户场景下还有一个常见的需求是数据库表前缀不一样同一个表的物理存储可能是merchant1_order、merchant2_order这样按商户拆分的。这种需求用MyBatis的Interceptor来做动态改表名会很方便但复杂度也随之上升。我的建议是除非你有很强的理由必须用这种分表方案否则优先考虑在应用层做路由用一张order表加merchant_id字段来区分归属通过索引和分区表来保证查询性能把复杂度留给数据库而不是让MyBatis层去处理动态表名。另一个实战细节是数据权限处理。多商户系统里每个用户只能查到自己商户的数据如果把merchant_id放在每个SQL里手写漏一个就是严重的越权漏洞。比较靠谱的做法是用MyBatis的拦截器在Executor层自动拼装数据权限条件或者用TenantId这类注解配合框架自动处理。这套思路在MyBatis-Plus里已经内置了租户插件如果你用的是原生MyBatis那就需要自己写拦截器了。拦截器实现时要注意只能在Executor的query或者update方法处拦截通过BoundSql对象去修改原始SQL修改时要小心处理参数映射和SQL注入的边界。7. 面试高频问题快答从缓存到分页再到设计取舍顺着热搜词里mybatis面试题这个需求我把这些年面试和被面试中反复出现的几个问题按我的理解梳理一遍。这些问题单个拎出来都不难但能一口气讲透的人确实不多。第一个必问MyBatis和JDBC有什么区别这题的亮点在于不只是说封装了JDBC而是要讲清楚MyBatis帮我们干了哪几件事连接获取和释放、SQL解析和预编译、参数映射、结果集映射、缓存管理。每一项对应JDBC原生代码里的哪一段能对应上说明你是真懂。第二个必问#{}和${}的区别。标准回答是预编译和字符串替换的区别但进阶回答要补一句${}到底能用在哪其实只适合在表名、排序字段这些无法用占位符的地方而且必须用白名单方式校验绝不能直接透传用户输入。第三个问题什么时候用resultType什么时候用resultMap简单场景用resultType配驼峰转换足够字段名不一致、嵌套对象、collection一对多映射才需要resultMap。另外resultMap支持constructor构造器映射可以在映射时直接走有参构造这个用得少但面试提出来很加分。第四个问题MyBatis的一级缓存和二级缓存分别是什么怎么避免脏读这个我在前面已经展开过这里只补充一点在Spring MyBatis的常规实践中一级缓存多数情况下是不可见的真正会踩坑的是二级缓存所以面试时你可以直接提生产环境我一般不推荐开二级缓存除非命中率极高且数据变更频率极低。第五个问题分页插件PageHelper的原理是什么它是通过MyBatis的Interceptor拦截Executor的query方法用Page对象解析出页码和每页条数改写原始SQL追加limit语句同时利用ThreadLocal传递分页参数。这里有一个经典坑PageHelper的分页参数是线程绑定的如果查询方法里面没有紧跟分页查询或者并发线程复用了ThreadLocal里的分页参数就会导致分页错乱。第六个问题Mapper接口为什么不需要写实现类因为MyBatis在运行时通过JDK动态代理生成了MapperProxy作为代理对象代理对象根据方法名找到对应的MappedStatement然后走SqlSession的方法执行。这段逻辑如果你读过源码能在面试时说出MapperProxyFactory.MapperProxyInvocationHandler这些具体类名说服力会强很多。8. MyBatis二之后你还可以往这三个方向深挖写到这里这一篇的篇幅已经不小了。按我自己的学习路径如果你把前面这些内容都吃透了接下来值得深挖的方向大概有三个。第一个方向是MyBatis-Plus。它是MyBatis的增强工具内置了条件构造器LambdaQueryWrapper、分页插件、代码生成器等很多新项目会直接用MyBatis-Plus而不碰原生MyBatis。但理解原生MyBatis的底层机制后你才能明白为什么selectPage能自动分页、为什么update方法默认不会更新null字段——这些看起来像魔法的东西底子都是MyBatis的SQL注入器和元对象处理器MetaObject在起作用。第二个方向是看MyBatis的官方文档中关于TypeHandler的部分。TypeHandler负责Java类型和JDBC类型之间的转换比如LocalDateTime和TIMESTAMP之间是如何互转的。如果你遇到查询结果集里的时间字段变成了一串数字或者枚举类型存不进数据库八成就是TypeHandler没有做对。自定义TypeHandler其实不难继承BaseTypeHandler重写四个方法然后在配置里注册或者用MappedTypes加MappedJdbcTypes注解标注就能精确控制类型转换。第三个方向是结合缓存做整体架构设计。MyBatis的本地缓存只是起点真正的缓存瓶颈在高并发场景下本地缓存不足以支撑Redis缓存又面临一致性难题。我的建议是MyBatis的二级缓存用来缓存那种查多改少、容忍短时间不一致的数据比如商品分类、地区列表涉及金额、库存这类强一致数据一律绕过MyBatis缓存走数据库或者带事务消息的Redis更新方案。我个人在实际项目里踩过最深的坑就是一开始图方便给订单查询开了二级缓存结果用户下单后偶发看到旧数据排查了很久才发现是缓存没有及时失效导致的。从那以后我的原则就变成缓存方案必须以业务一致性为上代码整洁次之性能再次之。这个权衡思路希望对读到这里的你也有参考价值。