从Mybatis-day这个标题说起。这不是什么框架源码分析大会也不是某本书的名字而是我某次复盘时给自己定的一个主题——把一天时间从早到晚全砸在MyBatis上早上处理生产环境的分页慢查询下午排查一个Update方法为什么比别人慢十倍晚上再回头把缓存、拦截器、Mapper代理这一串源码重新撸一遍。整理完发现这一天遇到的所有问题基本上覆盖了社区里大家高频搜索的那些词mybatis缓存、分页插件用法、mybatis拦截器、mybatis源码、idea mybatis、xml高亮、update执行慢、example.and、mybatis plus……这些零散的点拼起来就是MyBatis从日常使用到深度调优的完整地图。这篇文章就按那一天的节奏来写。不堆概念只讲实际项目中你会遇到什么、为什么会出现、以及怎么排查和解决。分六个部分先看MyBatis在Spring Boot里的完整工作流再聊SQL可见性日志、高亮、Example然后讲分页插件和拦截器这条扩展链路接着是缓存机制的坑再单独把Update执行慢和Oracle时间映射这类问题拆开最后落到源码层面的动态代理与Executor复盘。每块都尽量把我踩过、救过、复盘过的细节写进去让大家能少走弯路。1. 从热搜词看MyBatis的一天开发者高频触点的真实场景热搜词这个东西其实挺真实的。它不是官方文档而是每个开发者脑子里绷得最紧的那根弦。我扫了一眼跟MyBatis相关的近期热搜基本可以分成几类第一类是怎么配——mybatis配置打印、mybatis log、idea mybatis、mybatis xml高亮第二类是怎么查——mybatis的分页插件的用法、example.and、mybatis update执行慢第三类是怎么根治级——mybatis缓存、mybatis拦截器、mybatis源码深度复盘、mybatis面试题。这三类正好对应一个MyBatis使用者的三个阶段刚入门的在用、干了几年的在排查、准备面试或者在啃源码的在复盘。我那天处理的问题几乎一整天都在这些类目里打转。早上同事在群里说一个列表查询偶尔会查出昨天的数据最终定位是二级缓存没刷新的问题——这是缓存类中午Review代码发现有人用PageHelper.startPage之后不是紧接着查询中间隔了好几步导致分页神秘失效——这是分页插件类下午排查一个Update执行慢的问题EXPLAIN出来索引没走原因是Java端传了个字符串Oracle隐式转换把索引废了——这是类型映射类。到了晚上我把这些场景串起来重新看了一遍MyBatis的源码才发现很多白天困惑的问题在源码层面就是一层纸。所以这一篇我不想按文档顺序讲我想按一个开发者真实的一天来组织你去到公司遇到了什么问题你是靠什么线索定位的最终在源码或者配置层面找到了什么答案。这样的路径和我当年一路踩过来的路径最像也比直接看官方文档更容易留下印象。1.1 热搜词背后的真实任务清单在开始正文之前我把这些热搜词重新映射成了一张每日开发任务清单mybatis配置打印 / mybatis log / idea mybatis / mybatis xml高亮解决SQL到底长什么样的问题。上线前必做排查时救命。mybatis的分页插件的用法springboot、java解决数据太多怎么取的问题。分页是Web应用最基础也最容易翻车的功能。mybatis缓存解决为什么查出来是旧的的问题。缓存配置不当轻则数据不一致重则线上事故。mybatis拦截器 / mybatis源码深度复盘解决MyBatis到底在哪一步做了什么的问题。理解拦截器才能真正理解分页插件、审计插件、加密插件的原理。mybatis update执行慢 / oracle用mybatis查询时间映射解决生产环境为什么慢/为什么查出来不对的问题。这两类问题最隐蔽表面看是SQL实际上是参数类型、事务、驱动行为这些细节。mybatis面试题 / mybatis源码深度复盘解决知识体系成不成闭环的问题。能把这些点串起来面试基本问不倒。这七组热点我下面会逐一展开。注意很多问题表面上是独立的但深挖下去会发现它们共享同一条底层链路——SQL的执行从Mapper接口开始、经过代理和Executor、再到StatementHandler。理解了这条链路所有问题都能归位。1.2 SQL执行主线SqlSessionFactory到MapperProxy的运行路径先看一条最基础的主线。MyBatis在Spring Boot里启动后整个查询链路大概是这样的SqlSessionFactoryBuilder读取配置文件或MapperScan构建Configuration对象注册所有Mapper接口和XML映射文件。SqlSessionFactory在Spring Boot里通常由SqlSessionTemplate代理创建SqlSession。你调用UserMapper.selectById(1)实际上调用的是MapperProxy的动态代理。代理把接口方法转成MappedStatement方法签名 XML里的SQL交给Executor执行。Executor负责一级缓存维护、延迟加载、查询改写它会委托给StatementHandler做JDBC层面的操作。StatementHandler内部再调ParameterHandler参数绑定和ResultSetHandler结果集映射最终返回Java对象。这条链路看起来简单但有几个关键细节日常排查特别有用第一个细节Mapper接口本身没有实现类。MyBatis通过JDK动态代理把接口方法名和XML里的id对应起来。所以你在IDEA里看到Mapper接口跳不到XML或者XML里的namespace写错运行时才会报Invalid bound statement。这也是为什么日志里出现找不到Mapped Statement时第一反应不是代码逻辑而是看XML有没有被扫描进来。第二个细节SqlSessionTemplate是线程安全的。普通SqlSession是线程不安全的但Spring环境里你注入的SqlSessionTemplate内部用了代理每次操作会从SqlSessionFactory获取一个新的SqlSession用完再关。这个设计让MyBatis能在Spring的单例容器里安全使用但同时也影响了一级缓存的生命周期后面讲缓存时会细说。第三个细节Executor不是只有一个实现。默认是SimpleExecutor每执行一次就创建一个Statement还有ReuseExecutor复用Statement、BatchExecutor批量执行。你在Spring Boot里看到的批处理不生效问题多半是因为用的还是默认的SimpleExecutor批处理的SQL并没有真的攒到一起发。1.3 Spring Boot 3 MyBatis 3.5.x 集成时的初始化顺序现在很多新项目直接上Spring Boot 3这里有个容易忽略的点mybatis-spring-boot-starter的版本必须跟Spring Boot 3匹配用2.x的starter在Boot 3里会直接报错建议直接用mybatis-spring-boot-starter:3.0.x以上。依赖引对了之后自动配置会做三件事注册SqlSessionFactory把DataSource、XML的mapper-locations、配置文件的configuration全部组装进Configuration对象。扫描Mapper注解的接口注册到MapperRegistry并生成对应的MapperFactoryBean。注入SqlSessionTemplateSpring容器里可以直接Autowired这个Bean。实际项目里最常见的坑有两个一个是mapper-locations路径写错形如classpath:mapper/*.xml但你的XML在resources/mapper/下却起了个子文件夹启动不报错一调用就Invalid bound statement另一个是多个数据源时每个SqlSessionFactory需要手动配置mapper-locations自动配置只认一个默认数据源第二个数据源没配置就会扫不到XML。2. SQL可见性工程日志打印、XML高亮与Example.and的真实用法很多人写MyBatis写完跑一遍不报错就算完事对SQL具体长什么样完全没概念。等到出了问题又不知道去哪里看日志。这一节先把让SQL可见这件事讲透然后说说IDEA里怎么让XML更好看、更好跳转最后聊一个高频但容易用错的Example.and。2.1 日志配置的三种姿势从StdOutImpl到mybatis-log插件配置MyBatis打印SQL有三种常见姿势我按省事程度排个序姿势一application.yml里直接配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这种最粗暴SQL会直接打到控制台标准输出。优点是配置一行就完事缺点是SQL和业务日志混在一起生产环境一般不这么干而且StdOutImpl打印的是没有参数还原的原生SQL加参数列表肉眼看不直观。姿势二指定某个mapper包单独打印logging: level: com.example.project.mapper: debug通过log-impl不设置或者设置为Slf4jImpl然后单独给mapper包开debug。这种方式最推荐生产环境也可以临时用日志量可控配合日志框架能输出到文件。姿势三IDEA插件比如MyBatis Log Plugin这类插件的作用是拦截控制台里的PreparedStatement日志自动把?替换成参数值生成一条可以直接复制到数据库工具执行的完整SQL。排查问题时非常好用不用自己拿参数去替换占位符。需要注意插件的本质是解析日志你必须先把日志打开插件才能工作。我个人的建议是开发环境直接用StdOutImpl或插件方便快测试和预发环境用日志级别的方式生产环境原则上不开接口级debug除非你已经定位到是某个SQL的问题临时开几分钟用完关掉。2.2 XML高亮与IDEA调试技巧MyBatisX和正确的验证方式IDEA里看XML很多人打开就是一个普普通通的文本文件没有高亮、没有跳转。装一个MyBatisX插件或IDEA旗舰版自带的MyBatis支持体验会好很多Mapper接口里的方法旁边会有一个小箭头点击直接跳转到对应XMLXML里也能反向跳回接口SQL还能有基本的语法高亮和表名提示。但插件不是万能的。XML里的SQL在#{}参数、动态标签if、foreach这些地方IDE的语法检查经常力不从心。真正靠谱的验证方式是单元测试里跑一次真实查询最直接跑通了说明SQL能执行。拿到带参数的真实SQL用前面说的日志插件复制到数据库客户端里验证。仔细看if和where的组合空参数、全参数、半参数三种情况各测一遍这是动态SQL最容易出错的地方。还有一个隐藏技巧IDEA的MyBatis配置文件里setting namemapUnderscoreToCamelCase valuetrue/开启后user_name才能自动映射到userName。很多查出来字段全是null的问题就是栽在这里或者没有写resultMap字段名对不上。2.3 Example.and的拼接逻辑与误用场景Example类是MyBatis Generator生成的动态查询条件类。用法本身不难但很多人对and和or的组合逻辑搞不清楚。Example example new Example(User.class); Example.Criteria criteria example.createCriteria(); criteria.andEqualTo(status, 1); criteria.andLike(name, %张%); ListUser users userMapper.selectByExample(example);这段翻译成SQL就是WHERE status 1 AND name LIKE %张%到这里都没问题。容易翻车的是下面这种情况Example.Criteria c1 example.createCriteria(); c1.andEqualTo(status, 1); Example.Criteria c2 example.createCriteria(); c2.andEqualTo(type, 2);这一段生成的SQL是WHERE (status 1) OR (type 2)注意第二次调用createCriteria()不是追加AND而是追加OR。很多人想表达status1且type2结果写成了OR查出来的数据立刻变多。这个逻辑我在面试题里见到过也在实际代码里帮人救过非常经典。如果你想要且的关系应该在一个Criteria上连续调andEqualTo如果你想要或的关系才需要创建多个Criteria。更复杂的AND (a OR b)得在同一条Criteria里用andEqualTo配合orEqualToCriteria自己的orEqualTo是在这条条件内部分组或者直接用Select注解写SQL别硬拼Example。3. 分页插件与拦截器从PageHelper到自定义Interceptor的完整链路分页是每个Web项目都逃不掉的功能。MyBatis官方没有内置靠谱的分页实现社区里最常用的就是PageHelper后来MyBatis Plus这类增强框架也内置了分页插件。但分页加个依赖就能用的表象底下藏着好几个容易翻车的地方。3.1 PageHelper分页插件的正确打开方式先说标准用法。Spring Boot项目引入依赖dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency然后在业务代码里PageHelper.startPage(1, 10); ListUser users userMapper.selectAll(); PageInfoUser pageInfo new PageInfo(users);PageHelper.startPage(pageNum, pageSize)调用之后紧接着执行的第一条查询会被拦截并改写成分页SQLMySQL后面加LIMIT ?Oracle用ROWNUMSQL Server用OFFSET查询返回的List实际上是Page对象里面有total、pageNum等分页信息。PageInfo是对外展示的封装。这里有几个细节必须注意startPage和查询之间不能有任何其他查询操作否则拦截器会作用到错误的SQL上。不要循环里调startPage分页是线程绑定的处理不好会造成线程复用时下一个请求的SQL被莫名改写。PageHelper默认做count查询如果SQL本身特别复杂count也会慢。可以通过PageHelper.startPage(pageNum, pageSize, false)禁用count或指定countColumn。3.2 分页失效或错乱的三个典型场景我排查过十几个分页问题发现翻车原因高度集中在这三类场景一startPage后不紧接查询PageHelper.startPage(1, 10); // 中间有别的逻辑比如调了一次userList.size()或者又执行了别的SQL ListUser users userMapper.selectAll();此时拦截器可能作用到了别的SQL上导致第二个SQL被莫名加上LIMIT或者分页根本不生效。解决办法是startPage紧随查询中间不掺任何多余操作。场景二用了嵌套查询或复杂ResultMap导致了额外SQL比如selectAll内部通过association或collection触发了延迟加载的额外查询PageHelper拦的是主查询但副查询也可能被误伤。这个问题比较隐蔽我那次遇到的现象是主查询分页正常但详情里的子查询也带了LIMIT排查了半天才发现是拦截器线程上下文没清干净。场景三多个数据源时只给一个数据源配置了拦截器PageHelper拦截器需要注册到SqlSessionFactory上如果项目里有多个数据源只拦截了主库另一个数据源分页就会失效。3.3 拦截器机制拦截点、执行时序与自定义SQL改写PageHelper之所以能改SQL本质是MyBatis的拦截器机制。它可以拦截四大核心对象拦截对象作用Executor负责SQL执行、一级缓存、二级缓存入口分页插件通常拦这里StatementHandler负责JDBC Statement的创建和参数设置拦截这里可以做SQL改写ParameterHandler负责参数预处理拦截这里可以改参数ResultSetHandler负责结果集映射拦截这里可以改返回结果一个自定义拦截器的模子长这样Intercepts({ Signature(type StatementHandler.class, method prepare, args {Connection.class, Integer.class}) }) public class MyInterceptor implements Interceptor { Override public Object intercept(Invocation invocation) throws Throwable { // 在这里拿到StatementHandler进而拿到BoundSql改写SQL return invocation.proceed(); } }注册方式是在MyBatis配置里mybatis: configuration: interceptors: com.example.MyInterceptor手动配置的话Configuration configuration sqlSessionFactory.getConfiguration(); configuration.addInterceptor(new MyInterceptor());理解了拦截器我在介绍PageHelper的时候就不再是一个神秘插件了它就是拦截了Executor.query把原始的select * from user改写成了select * from user limit 0,10并把count查询插进去再把总数塞进Page对象。你现在自己动手写一个自动给SQL加租户条件或自动加逻辑删除条件的拦截器思路跟它完全一样。这里提醒一句拦截器是双刃剑。写在Executor层的拦截器影响所有Mapper方法稍微一个参数处理不当就是全站SQL错误。自定义拦截器一定要做充分的兼容性测试至少保证select/update/insert/delete四类语句都跑一遍再考虑上线。4. 缓存机制复盘一级缓存、二级缓存和查不到最新数据的坑缓存是MyBatis里最容易出诡异问题的地方。很多人对它的理解停留在有一级缓存和二级缓存这个层面但实际踩坑时往往都是因为对缓存的生命周期和生效条件理解不够准确。4.1 一级缓存的生命周期与失效条件一级缓存是SqlSession级别的本地缓存默认开启不需要任何配置。它在Executor里以HashMap形式存在key是CacheKey由StatementId、SQL、参数、分页条件、环境等共同组成。看起来很简单但有个关键问题在Spring环境里一级缓存的生命周期比你想象得短得多。因为SqlSessionTemplate每次执行SQL都会新开一个SqlSession执行完就关闭所以一级缓存在一个Mapper方法内部可能是有效的跨两个Mapper方法之间缓存基本就不存在了。一级缓存的失效条件包括同一个SqlSession里执行了增删改操作缓存会被清空。手动调了sqlSession.clearCache()。两个查询的参数不同CacheKey不同自然没有命中。配置了localCacheScopeSTATEMENT每个Statement执行完就清缓存。在Spring环境下做长时间事务时比如一个Transactional方法里先查后改再查你可能会依赖一级缓存避免重复查询但如果中间走了别的Mapper虽然同一线程但换了新的SqlSession缓存其实是失效的。4.2 二级缓存跨SqlSession共享的收益和代价二级缓存是namespace级别的也就是一个Mapper对应一个缓存区域可以跨SqlSession共享。启用方式很简单在XML里加一个cache/标签mapper namespacecom.example.mapper.UserMapper cache evictionLRU flushInterval60000 size512 readOnlytrue/ /mapper参数含义eviction回收策略默认LRU还有FIFO、SOFT、WEAK。flushInterval刷新间隔单位毫秒不写就只在更新时刷新。size缓存最大对象个数。readOnlytrue返回同一个实例快但可能被改坏false序列化后返回副本安全但慢。开启二级缓存后查询顺序是先查二级缓存 - 没命中再查一级缓存 - 没命中再查数据库。如果某个Mapper开启了二级缓存查询结果的POJO必须实现Serializable接口否则序列化报错。4.3 查出来的数据是旧的三个真实场景复盘二级缓存最容易让人头疼的问题是数据一致性问题。因为我遇到的坑基本离不开下面这三种场景一关联表更新了但SqlSession不知道两个MapperUserMapper和OrderMapper。OrderMapper里查订单时关联了User表但OrderMapper的缓存区域只监听OrderMapper自己的增删改。如果用户表的数据变了订单查询缓存里的用户姓名还是旧的。解决办法有两个一是涉及多表查询的缓存最好关闭二是配置cache-ref namespaceUserMapper/让OrderMapper的缓存引用UserMapper的缓存区域同步刷新。场景二事务未提交但缓存已经写了二级缓存的写入是事务提交后才刷新。但默认情况下flushCache和useCache行为如果配置不当可能出现脏读。比如在A事务里查询了一条记录B事务更新了这条记录B提交后缓存刷新但A事务还没结束如果A用了缓存可能读到旧值。场景三readOnlyfalse时的序列化开销readOnlyfalse意味着每次缓存读取都会反序列化一个副本如果一个对象的关联关系很深序列化成本会抵消缓存收益。我之前一个项目就是缓存开着慢查询依然慢Query计划显示耗时全在反序列化上。我的建议是绝大多数业务系统单表单主键查询不开二级缓存也够用真正要上二级缓存一定把刷新策略、缓存引用关系、序列化开销这三件事想清楚并且用压测数据说话而不是开缓存就快了这种直觉。5. 慢SQL与类型映射Update执行慢和Oracle时间映射的排查实录这一节可能是最贴近生产环境的部分。热搜词里mybatis update 执行慢、mybatis update 执行慢、oracle用mybatis查询时间映射这几个几乎是每周都会在社群里看到的求助帖。5.1 Update执行慢从SQL到索引到事务的完整排查链路先说结论很多”Update执行慢“的问题第一反应会去怀疑MyBatis框架但绝大多数情况是SQL本身、索引、事务、锁这些环节出了问题。MyBatis只是个JDBC封装它不会凭空把一个本来就快的SQL变慢。我的排查顺序是第一步确认慢的到底是执行还是传输/映射在日志里看SQL执行前后耗时。如果SQL在数据库客户端里执行很快毫秒级但MyBatis日志显示几百毫秒那问题可能在参数映射、返回结果集太大、或者事务没提交导致锁等待。第二步拿到真实SQL去数据库做EXPLAIN通过日志插件或慢查询日志确认Update实际执行的SQL然后EXPLAIN看有没有走索引。我那个线上案例就是这样的UPDATE user SET status ? WHERE id ?id是主键理论上应该飞快但EXPLAIN显示TYPEALL全表扫描。一看表结构id是VARCHARJava代码传的是LongMyBatis把参数绑定后Oracle内部做了隐式转换索引直接失效。第三步检查事务边界如果你在一个大事务里做了很多操作最后才执行Update那么你感觉到的这条Update慢可能不是在等SQL执行而是在等之前事务持有的锁释放。这种情况从MyBatis日志看SQL执行本身很快但方法整体耗时很长。解决办法是缩小事务范围或者把只读查询移到事务外。第四步确认是否触发了批量更新的坑如果是循环里一条条Update那每次都要走一次网络往返。1000条就是1000次当然慢。这种情况改用批处理SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH); try { UserMapper mapper sqlSession.getMapper(UserMapper.class); for (User user : userList) { mapper.updateById(user); } sqlSession.commit(); } finally { sqlSession.close(); }需要注意ExecutorType.BATCH下select的结果和自增主键的返回会受到影响需要踩过一遍才有体感。5.2 Oracle时间映射DATE、TIMESTAMP与Java类型的对应关系Oracle里面时间类型主要有DATE包含年月日时分秒和TIMESTAMP更高精度纳秒可带时区。用MyBatis查询时最常见的问题是日期显示出来少了时分秒或者毫秒被截断。出现这种问题多半是JDBC驱动和TypeHandler的配合问题。Oracle官方推荐的JDBC驱动在处理DATE类型时既能映射到java.util.Date也能映射到java.sql.Timestamp但如果你用的是自定义TypeHandler或者POJO里的字段是LocalDateTime就可能出现精度丢失。稳妥的做法是result columncreate_time propertycreateTime jdbcTypeTIMESTAMP javaTypejava.time.LocalDateTime/或者在SQL里显式转换TO_CHAR(create_time, YYYY-MM-DD HH24:MI:SS) AS createTime另外Oracle查出来时间正常、插入时间不对的问题也常见。插入时传LocalDateTime如果驱动不认识就可能变成当天零点。解决办法是全局配置Bean public ConfigurationCustomizer configurationCustomizer() { return configuration - { configuration.getTypeHandlerRegistry().register(LocalDateTime.class, new LocalDateTimeTypeHandler()); }; }或者直接用Insert的#{}里指定jdbcTypeTIMESTAMPInsert(INSERT INTO t_user(create_time) VALUES(#{createTime, jdbcTypeTIMESTAMP})) int insert(User user);5.3 慢SQL定位与MyBatis日志的结合最后给一个生产可用的组合拳数据库慢查询日志 MyBatis日志级别 拦截器统计耗时。数据库慢查询日志能看到最真实的执行耗时MyBatis日志里能看到具体的SQL和参数而想快速统计每个Mapper方法的耗时可以写一个粗粒度拦截器Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.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; if (cost 500) { // 这里打成warn把MappedStatement的id一起打出来 } } } }这个拦截器在生产上非常实用能帮你把超过阈值的SQL自动捞出来配合日志和EXPLAIN做进一步分析。6. 从日常用法到源码级复盘MapperProxy、Executor与高频面试题的一次串讲最后这部分回到热搜词里那个有分量的词——mybatis源码深度复盘。对于一个用了多年MyBatis的Java开发者来说能讲清楚Mapper是怎么被调用的、Executor和StatementHandler是怎么分工的才算真正把这套框架吃透。这不是为了面试而背题而是为了排查问题时能猜到底层发生了什么。6.1 动态代理Mapper接口是怎么变成可执行实体的MyBatis里所有的Mapper都只是一个接口没有实现类。那为什么你能直接Autowired进来然后调用方法答案是MapperRegistry在启动时扫描所有Mapper接口为每个接口创建了一个MapperProxyFactory当Spring容器需要某个Mapper的实例时它通过JDK动态代理生成了一个代理对象。MapperProxy的核心逻辑在invoke方法里如果是Object的方法toString、hashCode等直接走原生逻辑。如果是接口方法构造MapperMethod从Configuration里根据方法全限定名找到对应的MappedStatement然后调用sqlSession上的selectOne、selectList、update等。所以你在代码里看到的userMapper.selectById(1)实际会被拆解成一个MappedStatementid为com.example.mapper.UserMapper.selectById、一个参数对象1、一个执行模式select。这也能解释为什么XML里的namespace必须和Mapper接口全限定名一致id必须和方法名一致因为这就是查找的key。6.2 Executor与StatementHandler的分工一次查询到底走了几步Executor是整个执行链路的核心。接口方法有query、update、commit、rollback、flushStatements等。MyBatis提供了三个基础实现SimpleExecutor每执行一次SQL就创建、使用、关闭一个Statement。默认方式开销略大。ReuseExecutor同一个SqlSession内重复使用同一条SQL对应的Statement减少预编译次数。BatchExecutor把多条增删改SQL批量发送到数据库减少网络往返。在这三个之上还有一个CachingExecutor它是个装饰器负责二级缓存。这也是为什么二级缓存对你来说感觉只加了一个标签——实际上它是在Executor外围套了一层每次查询先查缓存缓存没有再走真正的Executor。StatementHandler是JDBC层面的执行者负责创建Statement、绑定参数、执行SQL、处理结果集。MyBatis里的RoutingStatementHandler会根据StatementTypeSTATEMENT、PREPARED、CALLABLE路由到不同的实现默认是PREPARED。整个调用链再顺一遍MapperProxy.invoke() - MapperMethod.execute() - SqlSessionTemplate.selectList() - CachingExecutor.query() // 二级缓存 - BaseExecutor.query() // 一级缓存 - SimpleExecutor.doQuery() - StatementHandler.query() - ParameterHandler.setParameters() - ResultSetHandler.handleResultSets()6.3 面试高频题串讲把热搜词变成知识闭环结合热搜词的mybatis面试题我把容易被问到、且能串联起前面所有内容的几个问题统一回答一下#{}和${}的区别是什么#{}会生成PreparedStatement的占位符?参数通过ParameterHandler绑定能有效防止SQL注入${}是直接字符串替换把参数拼接进SQL。${}用在ORDER BY/TABLE_NAME这类SQL结构场景时必须做白名单校验。这题能答好说明对ParameterHandler有理解。一级缓存和二级缓存分别是什么有什么风险一级缓存是SqlSession级的Map默认开二级缓存是namespace级的可跨SqlSession共享。风险在于多表关联查询的缓存一致性以及Spring环境下缓存生命周期的不可控性。这题能答好说明对Executor的缓存链有理解。MyBatis拦截器可以拦截哪些对象分页插件怎么实现的拦截Executor、StatementHandler、ParameterHandler、ResultSetHandler用JDK动态代理。分页插件拦截Executor.query改写SQL加LIMIT/OFFSET并额外执行count。这题能答好说明对Configuration的插件注册机制有理解。MyBatis的懒加载是怎么实现的用ResultSetHandler创建结果对象时对association/collection配置的关联对象生成一个代理代理在首次访问时触发额外查询。注意懒加载需要aggressiveLazyLoading等配置配合。这题能答好说明对ResultSetHandler有理解。MyBatis和MyBatis Plus的关系MyBatis Plus是基于MyBatis的增强框架内置了通用Mapper、条件构造器、分页插件、代码生成器等。它利用MyBatis的拦截器和动态SQL机制在BaseMapper上实现了通用CRUD。这题能答好说明你能兼容看待原教旨MyBatis和效率工具。这些题串下来你会发现它们其实都指向同一个底层结构——ConfigurationExecutorStatementHandler 各种Handler。你把那条执行链路理清了面试题怎么换花样都绕不出这个圈。最后说点实在话。用MyBatis这么多年我最大的体会是这个框架的入门门槛很低但深入之后处处是细节。你不需要背每一个源码类名但一定要理解SQL从接口到数据库的完整路径理解缓存和拦截器这两个扩展点在什么位置、会带来什么连锁反应。上面记录的这些排查经历没有哪个是教科书式的标准答案全是实际环境里被坑过才记住的教训。如果你在项目中遇到过类似的诡异问题建议也像我一样把MyBatis的一天复盘成文——排查一次、写一次、源码再看一遍比看十篇文章都管用。