Mybatis系列笔记写到第5天我想换个思路。前面四天我们都在解决“怎么写”怎么写Mapper接口、怎么写XML、怎么配映射关系、怎么拼动态SQL。到了第五天我更想聊“为什么”。Server端的缓存为什么时灵时不灵分页插件到底替你做了什么一条update执行变慢的时候该去查哪个环节Spring环境下为什么可以放心注入Mapper——这些问题光靠背面试题是背不出来的。但只要把Mybatis的执行链路在脑子里完整串一遍大部分疑惑会自己消失面试官问什么都接得住。所以Day5这篇文章我打算用“一个请求怎么从Mapper走到数据库”这条线把SqlSession生命周期、一级缓存、二级缓存、分页插件源码逻辑、慢SQL排查、动态代理这些知识点全部串起来。内容偏原理但每个结论我都会给出对应的实战场景和排查方法确保你能拿回去直接用。1. SqlSession活多久数据就对不对先搞懂生命周期再谈优化1.1 一个查询从Mapper接口到数据库中间到底过了几道门我以前带过好几个刚转Java的同学最容易出现的一个误区是把Mybatis当成一个“直接连数据库的ORM框架”。其实Mybatis的每一次查询都要穿过至少这样几层你调用userMapper.selectById(1)这个userMapper不是你的实现类而是JDK动态代理生成的MapperProxy对象。MapperProxy把方法调用转换成MapperMethod然后通过SqlSession来执行。SqlSession这个门面背后真正干活的是Executor。默认情况下是SimpleExecutor开了二级缓存会包一层CachingExecutor。Executor在执行前要处理缓存在执行时要通过StatementHandler创建JDBC的PreparedStatement再交给TypeHandler做参数绑定。最后才通过数据库连接执行SQL。这一路下来你会发现SqlSession是整个链条里面承上启下的核心。而直接持有数据库连接、一级缓存、事务状态这些关键资源的就是SqlSession。所以“SqlSession生命周期怎么管理”直接决定了你程序里的一级缓存是不是生效、数据库连接会不会被浪费、会不会出现线程安全问题。1.2 SqlSession的三种常见错误用法很多从JDBC转过来的同学第一次用Mybatis又没人带很容易写出这三种写法第一种把SqlSession定义成成员变量甚至静态变量。从Spring容器里拿一次SqlSession然后所有请求都复用。这种写法几乎必然出问题。DefaultSqlSession不是线程安全的多个请求共用同一个实例轻则数据错乱重则连接关闭后其他线程还在用直接报Cannot get a connection或者Connection is closed。第二种手动openSession()之后忘了关。新手写原生Mybatis最常见。SqlSession实现了Closeable底层托管着连接不关就意味着连接池里的连接被一直占用。连接池耗尽之后系统表现就是“越跑越慢、然后突然大量超时”。第三种在一个循环里重复创建和销毁SqlSession。比如批量导入数据每处理一条就openSession一次。虽然写法安全但效率极低。每一条都要创建Executor、获取连接、提交事务性能和批量模式差了不止一个量级。正确的打开方式就一句话一个请求或者一个业务事务对应一个SqlSession。这也是Spring整合Mybatis之后最省心的地方——你不需要手动管理SqlSession因为SqlSessionTemplate内部通过动态代理每次调用都帮你分配一个干净的SqlSession用完了自动关闭。1.3 为什么说生命周期决定了缓存和连接的行为搞清楚了SqlSession的作用周期很多奇怪现象就能解释一级缓存为什么失效大概率是因为你的两次查询跨越了两个SqlSession。数据库连接为什么一会儿多一会儿少因为SqlSession的开闭直接影响连接的获取和释放。为什么很多人说“Mybatis每次查都发SQL是不是没缓存”其实是SqlSession关闭后一级缓存被回收了。缓存和连接都是挂在Executor身上的资源而Executor是跟着SqlSession走的。理解了这个前提后面讲缓存、讲分页插件、讲慢SQL排查你才有完整的坐标系。不然你只会看到一个个孤立的知识点遇到问题照样不知道怎么串联。2. 一级缓存为什么“突然”失效二级缓存为什么容易翻车2.1 一级缓存的命中条件以及最坑的失效时机一级缓存是Mybatis默认就有的不需要任何配置。它的实现原理其实很简单在BaseExecutor里维护了一个LocalCache本质就是一个HashMap。这个Map的key是CacheKey是由statementId、SQL、参数值、RowBounds这些信息共同计算出来的value是查询结果对象。所以一次一级缓存要命中条件非常严格必须是同一个SqlSession。必须是同一个statementId也就是Mapper里的同一个方法。SQL必须完全一致参数也要一致。两次查询之间不能执行过任意的insert、update、delete语句。不能手动调用sqlSession.clearCache()。这里最坑的是第四点——“执行了update操作就清空一级缓存”。这个设计本身是为了避免数据不一致但很多人写代码的时候没注意在一个Service方法里先查了数据做判断然后又执行了一次update接着又查同一份数据。看起来是“同一个方法里连续查询”实际上第二次查询已经因为中间的update把缓存清了等于查了两次数据库。还有个同样坑的点localCacheScope这是全局参数默认是SESSION。如果你被人误导配置成了STATEMENT那更是每次查询都失效一级缓存基本名存实亡。mybatis: configuration: local-cache-scope: SESSION # 默认值不要改成STATEMENT不过要说清楚Spring环境下这个“同一个SqlSession”是有限定的。如果用的是SqlSessionTemplate它在同一个事务里会让多条操作共享同一个SqlSession一级缓存在此期间是能生效的。一旦没有事务包裹每次Mapper调用都是独立的SqlSession一级缓存自然就用不上。这也是为什么“同一个Service方法里两次调用selectById居然发了两次SQL”完全正常不是Bug。2.2 二级缓存的配置步骤和三个翻车案例二级缓存是跨SqlSession的作用范围是同一个namespace也就是同一个Mapper。它的工作机制是查询时先查二级缓存查不到再走一级缓存最后落到数据库数据库查询结果会先放进二级缓存。开启二级缓存的步骤很简单但很多人只做了前半段第一步全局开关cacheEnabledMybatis默认就是true所以一般不用动。 第二步在Mapper的XML文件里加一个cache/标签。mapper namespacecom.example.mapper.UserMapper cache evictionLRU flushInterval60000 size512 readOnlyfalse/ /mapper第三步查询结果对应的实体类必须实现Serializable接口。这个很多人会漏掉漏掉之后启动不报错第一次查询也不报错但并发访问缓存或者缓存刷新的时候会抛NotSerializableException非常莫名其妙。配置完成后基础功能是能用了。但二级缓存翻车往往出现在下面三个场景第一个readOnlyfalse默认值表示缓存返回的是反序列化出来的对象副本安全但开销大。有人图省事改成readOnlytrue直接返回缓存对象的引用。这时候如果有人拿到对象修改了某字段等于直接改了缓存里的数据下一次查询从缓存读出来的就是被改过的脏数据。第二个多表联查的数据缓存更新策略根本覆盖不到。比如UserMapper里查了订单表的字段订单表数据变了但二级缓存只认namespace。由于OrderMapper的更新不会触发UserMapper的缓存失效UserMapper里那条旧SQL会一直返回脏数据。除非你在cache里配置cache-ref namespacecom.example.mapper.OrderMapper/把两个namespace的缓存关联起来但这种关联关系多了以后缓存管理会变成一场灾难。第三个分布式环境下多个应用实例各自维护本地缓存一个实例更新了数据库其他实例的缓存还是旧的。Mybatis自带的二级缓存是本地缓存没有跨节点失效机制。如果想用得自己接Redis实现Cache接口这已经属于二次开发了。2.3 什么场景才值得开二级缓存我的个人判断标准很简单只有那种“读多写极少、数据变化不敏感、跨请求被高频反复查询”的数据才值得开二级缓存。最典型的是字典表、配置表、省市区表这类基础数据。举个例子订单状态字典表几百条数据被所有服务高频查询一天也改不了几次这个开二级缓存就很合适。反过来订单明细表这种实时变化、按用户维度查询的表开了二级缓存不但命中率极低还得承担序列化和缓存维护的开销纯粹是负优化。另外提醒一句如果你用了Spring Boot Mybatis-PlusMybatis-Plus默认开启了二级缓存相关的支持但同样要遵循上面的判断标准。默认不主动开启不是因为它不好用而是因为默认场景下收益不稳定。3. 分页插件用法详解它做的远不止给你加一条LIMIT3.1 自己拼LIMIT为什么会输给分页插件早期写JDBC和原生Mybatis的人分页基本都是手拼SQLListUser selectPage(String name, int offset, int limit) { String sql SELECT * FROM user WHERE name ? LIMIT ?, ?; // 手动查count手动传offset和limit }问题倒不是说“不能写”而是这样写有四个实际痛点第一每一组分页代码都要额外写一条count查询而且count的SQL和查询SQL之间的条件必须保持同步改一个忘另一个页面上就会出现“总条数对不上”的数据。第二数据库方言难处理。MySQL用LIMIT offset, sizeOracle用行号包裹三层子查询SQL Server又是另一套。项目一旦要从MySQL迁移到Oracle所有手写分页SQL全得重写。第三分页逻辑侵入SQL本身。业务SQL本来只关心“查哪些条件”现在还要管截断逻辑可读性下降。第四最容易出问题的是占位符顺序和类型。老手写LIMIT翻车率也不低尤其是offset和limit顺序写反、Long类型传到MySQL执行计划异常这种低级错误。分页插件把这些问题全部封装掉了。你要做的只是在Mapper方法签名里传入一个分页参数SQL该怎么写还怎么写完全不用关心方言和count语句。3.2 分页插件拦截Executor之后做了什么我自己研究过PageHelper和Mybatis-Plus的分页拦截器源码发现它们的核心思路惊人的一致都是基于Mybatis的Interceptor机制拦截Executor的query方法。大概流程是这样的用户调用pageHelper.startPage(pageNum, pageSize)或者传入IPage参数。分页拦截器拦到Executor的query方法后先从参数中解析出分页对象。根据原SQL和参数生成一条count SQL并执行拿到total总数。再根据数据库方言把原SQL改写成带分页语句的SQL。MySQL就是在末尾拼LIMIT ?Oracle就是包一层ROWNUM子查询。执行分页SQL把结果放入一个有total属性的分页对象里返回。所以说“分页插件就是帮你加了条LIMIT”是不准确的它还帮你做了count查询。如果你在业务里自己又写了一遍count那就是白做了一次无用查询。Spring Boot下集成分页插件现在主流的做法是Mybatis-Plus的PaginationInnerInterceptor配置非常简单Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); pagination.setOverflow(false); interceptor.addInnerInterceptor(pagination); return interceptor; } }然后Mapper接口里直接传Page对象IPageUser selectUserPage(PageUser page, Param(name) String name);select idselectUserPage resultTypecom.example.entity.User SELECT * FROM user where if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if /where /select注意Page参数不一定要写在SQL里引用Mybatis-Plus的分页拦截器会自动识别第一个IPage类型的参数并进行分页处理SQL里面不需要出现LIMIT。3.3 一对多映射时分页数据变少问题出在哪这个场景我见过太多次了分页插件配好了分页查询一张订单表结果返回的总条数看起来对但List里面的订单数居然比分页条数少甚至有的页是空的。问题不在分页插件而在你的结果映射。比如一个订单包含多个订单项你用collection做一对多嵌套映射resultMap idOrderWithItemMap typecom.example.entity.Order id columnorder_id propertyid/ collection propertyitemList ofTypecom.example.entity.OrderItem id columnitem_id propertyid/ result columnitem_name propertyitemName/ /collection /resultMap这种情况下SQL层用LIMIT 10查出10条订单关联数据但如果一个订单有多条明细SQL返回的记录数可能是15条。Mybatis在内存里按order_id合并集合之后最终组装出来的Order对象数量只有8个。所以你明明LIMIT了10条最后List里只有8个元素。这个不怪Mybatis也不怪分页插件而是关系模型和对象模型天然存在差异。真要处理这种一对多分页建议的思路是先分页查出主表ID再通过这些ID查关联表最后在业务层手动组装或者用第二条SQL批量查明细并映射回去。不要指望一个嵌套resultMap解决所有问题。4. 一条update执行变慢我按这个链路排查到根因4.1 先定量SQL到底执行了几毫秒“Mybatis的update执行慢”这句话在大多数情况下是不成立的。更准确的说法是“包含update操作的那个接口响应慢”。所以接到性能问题我第一步永远是先把“慢”量化是整个接口慢还是只有特定参数慢是第一次慢还是每次都慢JDBC层执行SQL用了多少毫秒事务提交的那一步用了多少毫秒如果是Spring Boot项目先确认Mybatis的SQL日志有没有打出来。通常把包名下的日志级别调到DEBUGSQL和参数就能看到logging: level: com.example.mapper: debug也可以直接在application.yml里指定Mybatis的日志实现和SQL输出mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl通过日志里打印的时间基本能判断问题在哪一层SQL执行本身很快那瓶颈在业务代码或事务控制SQL执行很慢再往下查数据库。我之前排查过一个“update执行慢”的问题结果SQL语句本身只花了5毫秒但整个方法执行了3秒。一查发现是Service方法里在update之前调了一个远程接口同步数据和Mybatis没有半毛钱关系。4.2 排除Mybatis自身开销动态SQL、参数映射与日志如果确认SQL慢确实发生在Mybatis这一层接下来看这几个方面动态SQL解析。script里的if、foreach这些标签默认每次执行都会重新解析XML片段生成SQL。SQL越复杂、遍历次数越多解析开销越大。官方建议在初始化时预编译动态SQL也就是把那些不会变化的SQL用普通语句而不是动态标签承载。如果写的是注解式动态SQL比如Update({script, UPDATE user SET ...})这类SQL每次执行也要走脚本解析批量大循环调用时会被明显放大。参数映射。update语句如果更新大字段比如CLOB、BLOB检查字段类型是否被映射成字符串IO操作。有一次排查Oracle下update一个CLOB字段巨慢跟踪半天发现是Mybatis把整个大对象从数据库先查出来再整体更新性能当然差。正确做法是判断字段有没有变化再决定是否要更新减少大字段无谓的IO。日志输出。上线后连接的日志配置是INFO级别还好但如果在DEBUG级别下高频打印SQL和参数每一条update都要拼接参数字符串高并发下也会拖慢接口。日志本身不是执行慢的根因但它会放大前面的问题。4.3 数据库侧的头号嫌疑犯锁等待和索引失效大部分“update慢”的真相都在数据库侧而不是应用侧。原因很简单select靠MVCC机制可以读快照数据除非显式加锁否则基本不会被阻塞。而update必须拿到行锁才能改数据一旦遇到锁冲突就只能干等。所以排查update慢第一个要查的就是锁等待。MySQL下可以先看当前有哪些事务正在执行SELECT * FROM information_schema.INNODB_TRX;如果发现trx_stateRUNNING且trx_started时间很长说明有个事务长期没提交后续所有针对同一行数据的update都在等锁。这种场景最常见的原因是业务代码里开了事务然后在事务里做了耗时的外部调用数据库锁被长时间攥着不释放。锁问题排除后再看索引问题。update语句的WHERE条件如果没走索引InnoDB会锁住全表扫描到的所有行等于行锁升级成了表锁。尤其是更新时WHERE条件里对索引列做了函数运算或隐式类型转换索引直接失效UPDATE user SET status 2 WHERE DATE(create_time) 2024-06-01;这种写法看着没啥问题但DATE()函数让create_time上的索引失效。改成范围查询就能走索引UPDATE user SET status 2 WHERE create_time 2024-06-01 00:00:00 AND create_time 2024-06-02 00:00:00;4.4 批量更新怎么改才能快一个量级业务上经常遇到这种情况List里有几百上千条数据需要挨个update。新手写法就是在for循环里调update方法一条一条提交。我实测过1000条逐条updatemysql连接默认不开启rewriteBatchedStatements的情况下耗时可能要用秒级甚至分钟级计算。改用批量Executor之后往往能缩短到原来的五分之一甚至十分之一。Spring环境下用SqlSessionTemplate可以这么做Autowired private SqlSessionTemplate sqlSessionTemplate; public void batchUpdate(ListUser userList) { SqlSession session sqlSessionTemplate.getSqlSessionFactory() .openSession(ExecutorType.BATCH); try { UserMapper mapper session.getMapper(UserMapper.class); for (User user : userList) { mapper.updateById(user); } session.commit(); } finally { session.close(); } }这里要特别提醒批量模式下别在循环里面调用session.commit()或者sqlSession.clearCache()否则批处理效果会被彻底破坏。我见过有人代码写得“很严谨”每处理10条commit一次结果性能不仅没提升还比逐条更新慢因为频繁刷新批处理缓冲区反而增加了开销。JDBC连接串上如果有条件建议加上rewriteBatchedStatementstrueMySQL驱动的批量参数重写能力会在这个参数下真正发挥出来。如果是Mybatis-Plus封装的方法saveBatch和updateBatchById内部本质上也是循环分批执行批处理大小默认1000条一批底层走的就是批量Executor的逻辑。5. 源码级复盘从Mapper接口到SQL执行中间藏着多少面试考点5.1 Mapper接口“没有实现类”的秘密JDK动态代理Mybatis面试必问的一个问题Mapper接口明明没有实现类为什么Spring能直接注入一个可用的Bean答案就是JDK动态代理。Mybatis在启动时会扫描所有Mapper接口为每个接口通过MapperProxyFactory创建一个MapperProxy代理对象。当你调用userMapper.selectById(1)时实际是调到了MapperProxy的invoke方法MapperProxy根据当前调用的方法从MapperMethod缓存里找到对应的SQL指令。如果是查询方法走SELECT分支解析参数后调用sqlSession.selectList或者selectOne。如果是更新方法走UPDATE分支调用sqlSession.update。返回结果之前还要做一次结果集转换把可能的List包装成方法声明的返回类型。整条链路没有魔法就是一层一层委托。5.2 Spring环境下为什么SqlSession可以放心用面试再往上走一层就会问DefaultSqlSession不是线程安全的为什么Spring里多线程并发调用同一个Mapper没问题关键在SqlSessionTemplate。它实现了SqlSession接口内部持有的是一个动态代理对象。每次执行数据库操作时SqlSessionTemplate都会判断当前是否存在Spring事务有事务把当前线程绑定的事务范围内的SqlSession返回保证同一个事务里所有操作共用同一个SqlSession一级缓存和事务边界一致。没有事务每次都新创建一个SqlSession用完关闭。这个设计让SqlSessionTemplate变成了线程安全的门面所以我们才敢放心地把它注入到Service层。面试答出这一点比单纯背“SqlSession是非线程安全的”要更有说服力。5.3 面试里容易答错的几个Mybatis机制题最后把我这几年面试别人和被别人问过的几个高频题整理一下每个都附上我认为最稳的回答逻辑。第一个#{}和${}的区别。#{}在预编译阶段会被替换成占位符?由PreparedStatement参数绑定能防止SQL注入${}是字符串直接拼接只能用在表名、列名、ORDER BY字段等动态结构上。任何把用户输入直接用${}拼进去的写法都是危险操作。第二个Mybatis的一级缓存默认开启吗开启作用范围是SqlSession级别。Spring环境下没事务时一次Mapper调用一个SqlSession所以缓存几乎等于没开。第三个二级缓存默认开启吗全局开关默认开启但每个Mapper默认不开启需要显式加cache/。面试官如果挖坑问“二级缓存默认开启”你要明确区分这两层。第四个延迟加载的原理。Mybatis对关联对象创建代理对象真正调用关联对象的getter时才触发额外的数据库查询。aggressiveLazyLoading如果设为true则所有延迟加载都会在对象被访问时立即触发等于关闭了延迟加载的效果。第五个Oracle下用Mybatis查询时间字段映射成java.util.Date以后“时间对不上”怎么办。这个问题的本质是Oracle的DATE、TIMESTAMP类型和JDBC驱动默认类型映射不一致。常用的解法是在XML里给时间字段指定jdbcType或者对这类字段写一个自定义TypeHandler统一处理时区和精度转换。第六个老项目里MyBatis Generator生成的Example类的andXxxEqualTo这些方法底层在干什么。Example类本质是条件对象的拼装器它把所有条件抽象成Criteria对象最终在XML动态SQL里解析生成WHERE子句。理解这点你才会明白为什么Example类能链式调用而且不用手写条件SQL。把这几条链路串起来再看Mybatis你会发现它并没有那么“神秘”。Mapper是个代理SqlSession是门面Executor是执行者缓存是挂在执行链路上的可选组件分页插件是拦截了执行链路的插件。Day5学到这你至少可以把之前遇到过的所有“莫名其妙”的问题归因到具体层了。如果还想继续深挖我建议你自己断点跑一遍SimpleExecutor.doQuery亲眼看看一级缓存和JDBC查询在源码里的位置关系那种感觉比看十篇文章都管用。
