ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

MyBatis进阶:动态SQL等于判断、缓存机制、SQL日志与Mapper代理核心原理

MyBatis进阶:动态SQL等于判断、缓存机制、SQL日志与Mapper代理核心原理 很多人学 MyBatis 是这么过来的找一份带 Spring Boot 的多商户商城开源项目源码跑起来看着控制台里一条条 SQL 打印出来觉得这框架真简单无非是写接口、写 XML、配个驼峰映射。但真等到自己动手改需求、排查线上问题的时候才会发现那些能跑通的 Demo 和能抗住业务的用法之间隔着一大堆看不见的细节。这篇「续」不打算再讲一遍怎么搭建环境而是把最容易出问题的几个点掰开揉碎动态 SQL 里一个“等于”判断为什么能让你排查一下午一级缓存和二级缓存到底该怎么看待SQL 日志配好了才算真正会看以及 Mapper 代理机制在源码层面到底做了什么。文章里的例子大多来自多商户电商项目常见的订单、商品、支付场景整理成这篇进阶经验帖。1. 动态 SQL 里的“等于”判断才是新手和老手的分水岭把“等于”放在第一站是有原因的。很多人入门时写selectById、insertUser这类固定 SQL 毫无压力一到了多条件筛选、动态拼 SQL 的场景就频繁翻车翻车点有八成出在if标签的判断条件上。MyBatis 的if、choose、where这套标签底层走的是 OGNL 表达式它的类型判断规则和纯 Java 有一些差异你这个“等于”写的对不对直接决定条件拼不拼得进去。1.1 字符串判等最经典的翻车现场先看一个最典型的错误写法很多人第一次写动态条件时都这么写过if testuserType seller AND user_type seller /if注意if的test属性外层用的是双引号内层字符串又写了单引号。这里的问题在于OGNL 对单引号包裹的常量在某些解析场景下会把它当成字符类型而不是字符串类型来处理导致和 String 类型的userType比较时结果永远是 false。我在实际项目里见过有人因为这个排查了整个下午最后把日志打出来才发现userType明明传了seller条件就是没拼进 SQL。安全的写法有几种任选其一!-- 外层改用单引号内层字符串用双引号 -- if testuserType seller AND user_type seller /if !-- 显式调用 toString() -- if testuserType seller.toString() AND user_type seller /if !-- 或者直接用 equals可读性最好 -- if testseller.equals(userType) AND user_type seller /if如果你去翻一些老项目的源码会看到大量的xxx.toString()这种写法看着别扭但确实是验证过能用的。我个人的习惯是统一用第三种xxx.equals(field)因为它的意图最直白而且不容易再踩 OGNL 类型解析的暗坑。这里还要再补一句OGNL 里也可以直接调用 Java 方法所以equals这种写法是完全合法的这算是经验里比较隐蔽的一条。1.2 数字比较和空值判断别让类型不一致毁了条件数字类型的“等于”判断看起来简单if teststatus 1这行写出来大多数情况下确实能用但它有一个隐藏前提传入的status必须是 Integer 或能自动转换成 Integer 的类型。如果前端把status传成了字符串1OGNL 在比较 String 和 Integer 时大概率会得出 false条件又不生效了。在我接触过的多商户商城的订单筛选场景里这种问题非常常见。卖家后台传订单状态有的端传数字有的端传字符串接口层如果没有把类型收敛统一好到 Mapper 这一层就会时好时坏。这里有两个建议第一在 Controller 或 Service 层用统一的数据类型接收参数别把类型转换的压力留给 SQL第二判断时养成先判空再判值的习惯if teststatus ! null and status 1 AND status 1 /if注意这里的顺序status ! null必须放在前面。虽然 OGNL 本身有一定的空值容忍度但在不同版本下直接拿 null 和数字比较的行为并不完全一致有的环境会抛异常有的环境会静默返回 false。为了保证行为可预期先判空再判值是硬性规范。还有一个很多人会忽略的点多个数字条件的组合要重视括号。OGNL 里and、or的优先级和 Java 不完全一样写过复杂判断的人应该都被坑过。比如你要表达“状态是 1 或 2并且支付方式非空”应该写if test(status 1 or status 2) and payType ! null AND (status IN (1, 2)) /if外层的括号是必须的别偷懒。在动态 SQL 的test表达式里一切以 OGNL 的优先级为准不确定就加括号加括号永远比赌优先级安全。1.3 集合判空和 foreach 的隐藏细节批量查询是 MyBatis 里另一个高频操作比如按订单 ID 集合批量查订单if testids ! null and ids.size() 0 AND id IN foreach collectionids itemid open( separator, close) #{id} /foreach /if这里比较隐蔽的两个点第一ids.size()括号不能省略。在 Java 里list.size和list.size()是两回事前者是取属性后者是调方法但 OGNL 对属性访问的解析方式容易让人误以为能省略括号。实际测试下来list.size这种写法在某些环境下能工作某些环境下不能为了稳定永远写ids.size()。第二foreach的collection属性取值规则。如果你的 Mapper 方法参数带了Param(ids)那collection就填ids如果不带注解且只有一个 List 参数可以填list如果是数组则填array。这个规则看似简单但换一个项目就出错的人不在少数。我的建议是在 Mapper 接口里统一加Param注解不要把规则留给 MyBatis 自己去猜。另外补充一个实务经验如果ids列表很大比如几千上万个 ID一次性拼一个IN语句数据库的性能会变得很差甚至可能直接超过数据库的IN表达式长度限制。电商后台的批量操作尤其要小心我见过有人一次性传了两万个商品 ID 把数据库打挂的。稳妥的做法是在 Service 层分批每批 500 到 1000 个 ID分多次查询再在应用层聚合结果。1.4 一个真实的排错过程订单筛选条件为什么没生效把前面的问题串起来看一个真实的排查过程。场景是多商户商城的买家订单列表有多个筛选条件关键词、支付方式、订单状态、时间范围。其中有一项需求是“支付方式等于微信支付时附带一个子条件”XML 里写的是if testpayType WECHAT AND pay_type WECHAT AND is_offline 0 /if现象日志里能看到payType参数确实传入了WECHAT但最终拼接出来的 SQL 里AND is_offline 0这个子条件没有出现于是查询结果里混入了不该出现的数据。我的排查过程是这样的先确认参数确实传进来了。观察日志的Parameters行看到WECHAT(String)说明值本身没问题。再确认 XML 条件是否走到。发现pay_type WECHAT这条条件都没拼进去基本可以断定是if判断返回了 false。怀疑 OGNL 字符串比较的问题。把payType WECHAT改写成WECHAT.equals(payType)条件恢复正常。这个案例里的关键点在于问题不在数据而在表达式求值。如果你每次排查都只盯着数据和 SQL 结果不往表达式本身想很容易陷入“参数明明有值为什么条件不生效”的死循环。把日志打开后面会专门讲然后大胆怀疑 OGNL是快速定位这类问题的钥匙。2. 一级缓存和二级缓存你以为的缓存和实际工作方式不是一回事MyBatis 的缓存是面试高频也是实际工作中最容易误解的一块。很多人提起缓存就是“一级缓存是 SqlSession 级别的二级缓存是 namespace 级别的”背得滚瓜烂熟但一问到“同一个事务里查两次第二次还查数据库吗”这种实际问题就卡壳。这里面的弯弯绕比面试题看起来要多得多。2.1 一级缓存的生命周期被 Spring 整合掩盖了一级缓存默认开启挂在Executor内部的localCache上作用范围是每一个SqlSession。同一个SqlSession内如果执行了完全相同的 SQLstatementId 相同、参数相同第二次查询不会真正访问数据库而是直接返回缓存里的结果。关键是在平时用 Spring Boot MyBatis Starter 的开发模式下SqlSession的创建和销毁基本由框架接管了你根本感知不到它的边界。这里有两套行为模式没有开启事务的方法每次调用 Mapper 方法Spring 都会开启一个新的SqlSession方法执行完就关闭。也就是说两次独立调用哪怕 SQL 一模一样一级缓存也是各查各的“缓存命中”基本不存在。一个事务内整个事务共享同一个SqlSession所以同一个事务里重复查询相同数据第二次会命中一级缓存。这也是为什么你会在日志里看到加了Transactional的方法里明明执行了两次查询却只打出了一条 SQL。但一级缓存有个非常隐蔽的坑它缓存的是对象引用不是对象的副本。也就是说在同一个事务里第一次查到订单对象你把这个对象的内存属性改了比如把订单状态从“待支付”改成了“已支付”但没有调用 update第二次再查同一个订单拿到的还是那个被改过的对象。这个行为本身是 MyBatis 的设计如此但很多人第一遇到的时候都会觉得“数据脏了”。应对方式有两种一是事务里不要直接修改查询出来的实体对象如果要改数据就另建 DTO二是如果确实需要立刻查询最新数据可以在查询前调用SqlSession.clearCache()清掉当前会话的缓存。但实话说最好的办法还是在业务设计上尽量避免这种对缓存行为的依赖。2.2 一级缓存为什么会失效这几个条件要背牢面试考一级缓存问得最多的就是“什么情况下缓存失效”。这里按我的经验整理成清单执行的 SQL 不同缓存 key 不同自然不命中。查询参数不同缓存 key 不同也就没有缓存可言。执行了任何 update/insert/delete 操作MyBatis 会主动清空当前 SqlSession 的一级缓存。这个设计逻辑很简单写操作之后旧的查询结果可能已经过期继续留着会读出脏数据。手动调用sqlSession.clearCache()缓存直接清空。如果配置了localCacheScopeSTATEMENT那每执行完一条语句缓存就会被清掉这等价于“一级缓存名存实亡”。从源码层面看这些行为大多落在BaseExecutor里update方法会调用clearLocalCache()查询时会用CacheKey来标记缓存项。理解了这一点你就不难记住规则的边界了。2.3 二级缓存实现细节namespace 级别的缓存到处都是坑二级缓存的作用范围是 Mapper 的 namespace它能在多个 SqlSession 之间共享。默认情况下二级缓存是开启的cacheEnabled默认为 true但如果你不在 XML 里加cache/标签它不会生效。配置很简单在 Mapper XML 顶部加一行cache evictionLRU flushInterval60000 size512 readOnlyfalse/加完以后这个 Mapper 下所有查询结果都会进入二级缓存。但问题接踵而至第一个坑对象必须可序列化。readOnlyfalse默认值时MyBatis 会在缓存写入时把对象序列化一份在读取时反序列化成新对象避免多个线程拿到同一个对象实例互相污染。这意味着你的实体类必须实现Serializable接口否则运行时会直接抛NotSerializableException。而readOnlytrue虽然不用序列化但所有线程拿到的都是同一个对象引用并发修改的风险极高基本没人敢用。第二个坑多表关联查询会脏读。这是二级缓存最致命的缺陷。假设OrderMapper里有条查询语句 join 了OrderItem表的数据查询结果被缓存进了OrderMapper的 namespace。此时如果另一个 service 调了OrderItemMapper更新了订单项数据OrderItemMapper自己的缓存会被刷新但OrderMapper的缓存根本感知不到这次更新。下次再执行那条 join 查询拿到的还是缓存里的旧订单项信息。这种脏读问题在多商户商城这种表关联复杂的场景里尤其容易爆发。第三个坑高频更新表不要开二级缓存。像订单、库存、支付流水这类写频繁、实时性要求高的表开了二级缓存就是给自己埋雷。缓存里的数据可能还是几分钟前的用户刷新页面看到旧数据投诉就来了。所以我的结论很明确实际项目中二级缓存的使用场景非常有限通常只适合那种几乎不变的基础表数据比如分类字典、国家地区表。对于业务核心表要么不开二级缓存要么在 Service 层用 Redis 做可控的缓存后者明显更灵活、可管理。2.4 怎么看缓存到底有没有生效Cache Hit Ratio如果你还是想尝试二级缓存有一个非常有效的验证手段——看Cache Hit Ratio。把日志级别调到 DEBUG 或 TRACE控制台会输出类似这样的内容Cache Hit Ratio [com.shop.mapper.OrderMapper]: 0.4这个数字的意思是OrderMapper里所有查询语句的二级缓存命中率。0.4 表示有 40% 的查询没走数据库这个数字不再变化时可以反过来检验缓存配置是否真的在起作用。如果加了cache/但 ratio 一直是 0.0说明缓存根本没生效这时候就回头检查cacheEnabled开关、实体类的序列化实现、以及 SQL 是否都在同一个 namespace 下。不过话说回来我见过的大部分成熟项目最后都选择关闭 MyBatis 二级缓存统一用 Redis 在更上层做缓存。这也是值得参考的实践方向。3. 把 SQL 打印出来是排查 MyBatis 问题的基本功很多人排查 MyBatis 问题第一步是“人肉猜”。猜参数有没有传错猜测 SQL 拼接结果猜缓存是不是脏了。但真正高效的做法永远是把实际执行的 SQL 和参数直接打出来看。这篇专门讲清楚 SQL 打印怎么配置、日志每一行是什么意思以及为什么打印出来的 SQL 往往不能直接复制到数据库里执行。3.1 两种配置方式开发和生产该怎么选最常见的方式是在application.yml里配置 MyBatis 的日志实现mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这种方式会把 SQL 日志直接输出到控制台特点是配置简单、直观适合开发阶段调试。但它有一个问题所有日志都往控制台刷时间长了以后你想回看某次请求的 SQL 基本靠翻屏而且生产环境也不适合这种“无差别输出”。更好的方式是配合日志框架按 Mapper 包路径单独打开 DEBUG 级别logging: level: com.shop.mapper: debug这种方式的好处是你可以把com.shop.mapper这个包的日志单独输出到 logback 的某个 appender写进独立的文件里。这样既不影响整体日志的噪声也能保留完整的 SQL 轨迹生产环境排查问题时特别有用。如果项目里已经接入了 logback 或 log4j2MyBatis Starter 会自动感知并桥接不需要额外依赖。3.2 看懂日志里的每一行Preparing、Parameters、Total配置好之后控制台会输出类似这样的内容Preparing: SELECT * FROM orders WHERE order_id ? AND status ? Parameters: SO20240901001(String), 1(Integer) Total: 3 ms这三个字段的含义是Preparing行是经过 MyBatis 解析后的预编译 SQL里面的?是占位符。Parameters行是按顺序绑定的参数列表每个参数后面括号里标注了类型。这里要注意有时候类型会出乎你的意料比如明明是 Integer 却显示成 String这就是前面说到的类型不统一问题看一眼日志就能发现。Total是这条 SQL 从执行到返回结果集的耗时单位毫秒。除了这三行DEBUG 级别下还会输出结果集相关的内容 Columns: order_id, status, pay_type Row: SO20240901001, 1, WECHAT Total: 1Columns是查询返回的列名Row是每一行的数据。这里有个实用技巧当你发现自动映射出来的 Java 属性值不对时先看Columns的命名再看实体类属性名基本就能定位是下划线转驼峰没有生效还是 SQL 里别名写错了。3.3 日志里的 SQL 为什么复制到数据库跑不了很多人第一次看到Preparing日志会直接把那条 SQL 复制到 Navicat 里执行结果发现报错。原因是 SQL 里的?并不是真的值参数是单独绑定到PreparedStatement上的日志里根本不会拼接成一条可以直接执行的完整 SQL。这是 JDBC 预编译机制决定的也是#{}参数能避免 SQL 注入的根本原因。那想要一条“可以直接执行”的完整 SQL 怎么办我平时有两种做法手动替换把?按顺序替换成Parameters行里的参数值注意字符串和日期类型要手工加引号数字可以直接填入。数量少的时候这么做没问题但参数一多就很容易出错。拦截器打印用 MyBatis 的插件机制写一个拦截器在 SQL 执行前拿到BoundSql里的原始 SQL 和参数自行组装成可读的完整 SQL 输出。这种方法一劳永逸下面详细说。3.4 自己写一个拦截器完整 SQL 和耗时一起打出来说到拦截器其实很多增强工具都内置了慢 SQL 日志功能但如果你想自己掌控打印格式和阈值自己写一个是最可靠的。这里给一个我常用的简化版本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 SqlLogInterceptor implements Interceptor { private static final Logger log LoggerFactory.getLogger(SqlLogInterceptor.class); Override public Object intercept(Invocation invocation) throws Throwable { long start System.currentTimeMillis(); Object result invocation.proceed(); long cost System.currentTimeMillis() - start; MappedStatement ms (MappedStatement) invocation.getArgs()[0]; Object parameter invocation.getArgs()[1]; BoundSql boundSql ms.getBoundSql(parameter); String sql boundSql.getSql().replaceAll(\\s, ); if (cost 500) { log.warn(slow sql cost{}ms, sql{}, params{}, cost, sql, JSON.toJSONString(parameter)); } else { log.info(cost{}ms, sql{}, cost, sql); } return result; } Override public Object plugin(Object target) { return Plugin.wrap(target, this); } }把这个类注册成 Spring Bean就能对所有 Mapper 生效。需要注意几件事拦截器里不要做太重的事情比如打印结果集的完整内容否则会影响性能JSON.toJSONString(parameter)在参数对象非常大时也会增加开销可以简单打印参数的toString()或者抽几个关键字段慢 SQL 阈值 500ms 只是一个参考实际阈值要根据业务调整。另外BoundSql.getSql()里有非常关键的隐藏信息如果被分页插件等第三方拦截器修改过拿到的 SQL 可能已经是改造后的版本。想了解最终执行的 SQL 长什么样在这个拦截器里输出是最准确的。4. 从 Mapper 接口到 SQL 执行源码层的关键链路面试就问这些“MyBatis 源码”这个关键词经常出现在热搜里。很多人一看源码就头大其实我觉得不必把整本源码读一遍只需要把最核心的一条链路走通从一个普通的 Mapper 接口到真正执行 SQL中间到底发生了哪几步。这条链路既是源码的骨架也是面试题最喜欢的切入点。4.1 Mapper 接口没有实现类它是怎么被调用的学过 JDBC 的人都知道以前写数据访问层必须写一个实现类里面创建Connection、PreparedStatement、处理ResultSet。但 MyBatis 里你只需要定义一个接口public interface OrderMapper { Order selectByOrderId(String orderId); }不需要写任何实现类它却能直接被Autowired注入并调用。这里面的魔法是 JDK 动态代理。链路大致是这样的项目启动时MapperScan扫描到所有 Mapper 接口交给MapperRegistry注册注册时会为每个接口创建一个MapperProxyFactory当 Spring 需要注入OrderMapper时这个工厂就基于 JDK 动态代理生成一个代理对象。你在 Service 里调用orderMapper.selectByOrderId(...)实际是调到了代理类的invoke方法。invoke方法里做的事可以简化为根据方法名和方法签名找到对应的MappedStatement它封装了 SQL 语句、参数映射、返回值映射等信息把这个调用交给SqlSession去执行。执行完再根据配置把结果集映射成 Java 对象返回。所以面试时可以这样回答Mapper 接口之所以不需要实现类是因为 MyBatis 在运行时用 JDK 动态代理生成代理对象把接口方法调用转成 SqlSession 的数据库操作。这里还能延伸问一句代理对象能不能单例注入Spring 默认对接口代理是单例的由于 MyBatis 的 Mapper 代理本质上是无状态的它每次操作都从 Spring 管理的 SqlSession 模板中获取会话所以可以安全注入。这就是你为什么可以在一个单例 Service 里同时使用多个 Mapper 的原因。4.2 #{} 与 ${}从预编译原理看 SQL 注入这是 MyBatis 的基础题中的基础题也是面试里几乎必问的一题。两者的核心区别从源码执行的角度看非常清楚#{}会被解析成PreparedStatement的占位符?参数在后续阶段通过setObject等方法绑定进去。由于参数值不直接参与 SQL 字符串拼接而是在数据库驱动层做类型转换和转义所以能有效防止 SQL 注入。${}则是在 SQL 解析阶段直接做字符串替换把值直接嵌入到 SQL 字符串中。这种方式可以用于动态拼接表名、排序字段这类无法用占位符表达的场景但也正因为值是直接拼进去的如果值包含恶意片段如1; DROP TABLE ...就会被原样执行产生注入漏洞。实际项目中最常用的危险场景是排序字段select idselectOrderList resultTypeOrder SELECT * FROM orders ORDER BY ${sortColumn} ${sortOrder} /selectsortColumn和sortOrder一旦能被前端直接传入就必须做白名单校验否则用户传入status DESC; DROP TABLE orders; --这类内容后果不堪设想。我这里分享一个安全做法在 Service 层写一个排序字段的白名单字典只允许映射后的固定值进入 SQL凡是字典里没有的一律用默认排序。另外关于模糊查询LIKE %${keyword}%也属于高频使用的场景但更安全的写法是if testkeyword ! null and keyword ! AND title LIKE CONCAT(%, #{keyword}, %) /if用#{}CONCAT的效果和${}完全一样但不会引入注入风险。这是我在代码 review 时最常指出的一种写法改进。4.3 自动映射和 ResultMap 的设计取舍MyBatis 默认情况下有“自动映射”能力查询结果的列名如果能对应上实体的属性名就会被自动赋值。如果数据库列名用了下划线风格order_id而 Java 属性是驼峰风格orderId就需要开启配置mybatis: configuration: map-underscore-to-camel-case: true这个配置帮绝大多数项目省去了手写映射的麻烦。但仅限于“单表简单查询”的场景一旦涉及多表关联自动映射就不够用了。比如电商后台查询“订单列表同时带出下单用户昵称和订单项明细”就会用到resultMapresultMap idorderDetailMap typeOrder id propertyid columnid/ result propertyorderNo columnorder_no/ association propertybuyer javaTypeUser result propertyuserName columnuser_name/ /association collection propertyitems ofTypeOrderItem id propertyid columnitem_id/ result propertygoodsName columngoods_name/ /collection /resultMap这里面association表达“一对一”的关系比如订单对买家collection表达“一对多”的关系比如一个订单对应多个订单项。这还带出另一个高频问题N1 查询。如果用嵌套 selectassociation或collection里配置select属性来做关联查询MyBatis 会先查主表再根据每一行结果逐条查关联表数据量大时会产生大量 SQL 请求。解决思路有两种一种是一次性 join 查出所有需要的字段再用resultMap做嵌套映射另一种是先在 Service 层批量查出关联数据再在内存中组装。两种方式在电商场景里都常见具体选哪种要看数据量和查询频率。4.4 面试高频机制清单为什么你总能碰上这些题从热搜里的“mybatis面试题”来看大家最关心的还是那几个反复被问的点。这里集中列一个自查清单每个问题可以对照前面的章节组织答案一级缓存和二级缓存的区别、一级缓存的失效条件见第 2 章回答时先讲层级和作用范围再讲失效条件。#{}和${}的区别以及 SQL 注入原因见第 4.2 节从 PreparedStatement 预编译角度作答。Mapper 接口为什么能直接注入、代理机制是什么见第 4.1 节核心词是 JDK 动态代理、MapperProxy、MappedStatement。分页插件原理分页插件本质上是一个 MyBatis 拦截器拦截Executor的 query 方法拿到原始 SQL 之后改写成分页 SQL比如 MySQL 场景就是在前文补充LIMIT语句再额外执行一条 count 查询。懒加载原理MyBatis 的懒加载也是通过代理实现的返回的关联对象是一个代理对象真正访问属性时才发 SQL 查询。但要注意懒加载要求 SqlSession 仍然打开Spring 托管下配置不当会出现LazyInitializationException。resultType 和 resultMap 的区别resultType 是自动映射resultMap 是自定义映射规则。涉及关联查询、嵌套结果时用 resultMap简单查询用 resultType 就够了。面试时回答这些问题的关键不是背概念而是能结合自己的实际场景说明白“为什么这样做”。比如被问二级缓存时能讲出“多表关联脏读”的例子远比单纯背一个 namespace 级别的定义更能打动面试官。最后说点个人体会。我见过很多人学 MyBatis一上来就刷源码、背面试题结果写真实需求时还是在动态 SQL 里一天天地踩坑。其实框架的学习顺序应该是先掌握真实项目里最常见的操作和最容易出问题的点再带着问题去源码里找答案。这篇写的每一个“等于”判断、每一段缓存坑、每一条 SQL 日志经验都是我从真实项目里 Debug 出来的教训希望能帮你在 MyBatis 这条路上少走一段弯路。如果非要说一个最想让大家带走的小技巧那就是把 SQL 日志的配置和慢日志拦截器尽早落到项目骨架里相比任何手册都能更快地帮你定位问题。
返回列表