ARTICLE DETAIL

资讯详情

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

MyBatis实战避坑指南:从动态SQL、缓存机制到源码主线与多商户落地

MyBatis实战避坑指南:从动态SQL、缓存机制到源码主线与多商户落地 在Java后端干得时间长了你会发现一个现象不管面试题怎么变MyBatis永远不会缺席。缓存、动态SQL、#{}和${}的区别、二级缓存实现几乎每次跳槽都要被问一轮工作中更是每天都在和它打交道。这篇不是官方文档翻译而是我把这几年在真实项目里使用MyBatis的经验做一个系统梳理覆盖动态SQL写法、缓存机制、SQL日志打印、源码主线以及多商户这类业务场景里的落地细节新手能拿去做面试提纲老手也能拿来查漏补缺。很多刚入行的朋友会把MyBatis当成一个“写SQL的框架”用起来就是Mapper接口加XML文件能跑通CRUD就觉得会了。但实际上你面试被挂、线上出故障多半不是CRUD的问题而是动态SQL写崩了、缓存没关导致脏数据、日志不会看导致慢SQL排查半天。所以这篇文章我从实际项目角度出发把最容易踩坑的几个点一次讲透。1. 为什么Java后端绕不开MyBatis设计与选型思路1.1 MyBatis和JPA到底差在哪先解决一个基本问题市面上的ORM这么多为什么国内团队尤其偏爱MyBatis如果你用过Hibernate或者Spring Data JPA你会发现核心分歧在“SQL由谁生成”。JPA的理念是由框架根据实体关系自动生成SQL开发者只操作对象MyBatis的理念则完全相反——SQL你自己写框架只负责把参数传进去、把结果映射成对象。这两种理念直接决定了团队的技术风格。JPA在简单CRUD场景确实高效但一旦遇到多表关联、复杂统计、报表查询框架自动生成的SQL往往不够精准要么多查了字段要么关联方式不符合预期最后还得靠Query手写JPQL甚至原生SQL反而更绕。MyBatis从一开始就把SQL控制权交给你代价是每条SQL都要自己维护但这在国内严重依赖复杂查询的业务系统里反而是优势因为SQL的最终形态是可控、可预期、可优化的。再说说学习和排查成本。MyBatis的映射规则很直观参数用#{}占位结果用resultType或resultMap映射动态SQL用if、foreach拼条件。出现问题时把日志里的SQL复制到数据库执行一遍基本就能定位不需要额外学习框架的缓存机制、懒加载策略、实体状态管理这些抽象概念。对于大多数快速迭代的业务团队来说这种“直来直去”的方式学习曲线更低线上问题也更容易排查。1.2 什么场景适合用MyBatis什么场景该换我的经验是下面几类项目选MyBatis非常合适传统企业级管理系统、电商交易系统、多商户平台、报表统计分析后台这些场景的共同特征是查询逻辑复杂、SQL条件动态变化、对响应时间敏感。比如一个订单列表页筛选条件可能有时间范围、商品类目、商户ID、订单状态好几个维度用MyBatis的where加if拼条件非常顺手。反过来如果项目是领域模型驱动的DDD架构实体关系复杂状态流转多JPA能帮你省不少事它天生适合“以对象为中心”的开发模式。另外如果你做的项目几乎没有复杂SQL全是单表操作那用不用MyBatis差别不大Spring Data JPA甚至更省代码。这里没有谁好谁坏只有适合不适合。关于热词里提到的“Spring Boot MyBatis的Java开源多商户跨境商城源码”我接触到不少类似项目。这种商城系统的典型特点是商户端和平台端的数据模型不同、商品和订单查询条件极其多变、结算报表需要聚合统计恰好是MyBatis的舒适区。当然“跨境”两个字在技术层面没有特殊含义真正有意思的其实是多商户数据隔离、分页、缓存这些点后面我会专门展开。2. 动态SQL实战最容易翻车的几个写法2.1 XML里的特殊字符与“等于判断”转义很多人第一次在Mapper XML里写小于号时直接报错因为XML解析器会把它当成标签开始。比如查询七天前的订单select idselectExpiredOrders resultTypeOrder SELECT * FROM orders WHERE create_time #{expireTime} /select这段代码跑起来直接报“元素类型必须由匹配的结束标记终止”。解决办法有两种一是用XML实体lt;代替写成create_time lt; #{expireTime}二是用CDATA包起来![CDATA[ create_time #{expireTime} ]]。我建议查询条件里涉及比较运算符的直接用CDATA可读性更好而且还能顺便处理、等符号。再说热词里专门提到的“mybatis等于”这个坑更多出现在if test判断里。看这段写法if testtype SHOP AND shop_type 1 /if初看没问题但实际执行时可能报NumberFormatException。原因在于OGNL表达式里单引号和双引号的含义不同。test属性本身被XML的双引号包着所以内部字符串要用单引号但OGNL把单引号包的内容当成char处理当你拿char和字符串做equals比较时就会触发类型转换异常。稳妥的写法有两种if testtype SHOP AND shop_type 1 /if或者if testtype SHOP.toString() AND shop_type 1 /if我个人的习惯是第一种外层用单引号内层用双引号既保证XML属性合法又避免OGNL的字符/字符串混淆。这个细节在面试里经常被拿来考“MyBatis等于判断的写法”实际项目里一旦写错轻则报错重则条件不生效查出全表数据。2.2 if判断的类型陷阱动态SQL里if test参数 ! null and 参数 ! 这种写法很常见但有一个经典陷阱当参数是数值类型且值为0时条件会被跳过。原因是OGNL在比较数值和空字符串时会把空字符串转换成数值0来比较于是0 ! 的结果是false整个条件不成立。举个例子你要按订单状态查询订单状态0表示“待支付”if teststatus ! null and status ! AND order_status #{status} /if前端传status0时这个条件不会拼接查询结果变成全量订单线上事故就来了。正确做法是数值类型只判断! null字符串类型才同时判断! 。这是我强烈建议团队规范里写死的一条。另外还有一个小坑if testname ! null and name ! 里如果name是字符串“0”这个判断本身没问题但如果你在service层把前端参数做了类型转换比如Integer.valueOf(0)那到了XML里就是数值0又踩回上面那个坑。所以规范应该是参数类型在DTO里定义清楚SQL里按类型区分写法不要一套模板到处套。2.3 foreach批量操作与in查询细节foreach是动态SQL使用频率最高的标签之一最常见的两个场景批量插入和IN查询。批量插入的标准写法是这样insert idbatchInsert parameterTypelist INSERT INTO shop (id, name, owner_id) VALUES foreach collectionlist itemitem separator, (#{item.id}, #{item.name}, #{item.ownerId}) /foreach /insert这里要注意collection属性的取值如果Mapper接口方法参数是List用list如果是Param(shops) ListShop shops用shops如果是数组用array。很多人忘了加Param注解然后纠结为什么一直报“参数找不到”其实就是collection写错了。IN查询的坑在于空集合。当你传入一个空list时拼接出的SQL是WHERE id IN ()这在MySQL里直接语法错误。所以使用foreach做IN查询前务必在Service层判空或者用if testlist ! null and list.size() 0包一层。还有一个性能方面的经验大批量IN查询时SQL长度和解析时间会上升。我实测过单条SQL里IN后面跟几百上千个参数性能明显下滑。这种情况下可以分批查每批500个左右然后用Map合并结果比一条大SQL稳定得多。批量插入同理一次插入几千行时建议拆分成每批500行避免超过数据库的max_allowed_packet限制。3. 缓存机制深度拆解一级缓存、二级缓存与一致性3.1 一级缓存默认开启但也容易失效MyBatis的一级缓存是SqlSession级别的默认开启且无法关闭。什么含义同一个SqlSession里执行两次完全相同的查询第二次不会访问数据库直接返回第一次缓存的引用。设计初衷是减少数据库压力但在Spring集成场景下这里有个容易忽略的关键点。SqlSessionTemplate是MyBatis和Spring整合的桥梁它内部默认每次执行SQL都会创建新的SqlSession只有你在Transactional事务内才会复用同一个SqlSession。换句话说没有事务的方法里一级缓存基本等于摆设因为它作用域太短了。一级缓存真正要注意的是“脏读”问题。看这个场景你在同一个事务里先查询一条记录然后通过另一个Mapper更新了这条记录再查一次——按道理应该查到新数据但一级缓存会直接返回第一次查询的旧对象。原因很简单查询走的是第一个Mapper的缓存更新走的是另一个namespace的缓存双方互不知道对方动了数据。MyBatis的设计是执行任何insert、update、delete时会清空当前SqlSession的缓存但如果你用的是多个SqlSession那就无能为力了。所以我的建议很简单同一次事务内如果需要“查了再改再查”这种流程尽量在改完数据后手动调用sqlSession.clearCache()清缓存或者干脆用两条独立查询封闭在不同的方法里别让事务跨太长。这个坑不常见但碰到了非常难排查。3.2 二级缓存配置与实现细节二级缓存是Mapper级别的也就是namespace级别多个SqlSession可以共享。默认情况下二级缓存是关闭的需要在Mapper XML里加一行cache/才能开启。完整的配置长这样cache evictionLRU flushInterval60000 size1024 readOnlyfalse/参数含义分别是eviction缓存回收策略LRU表示最近最少使用还有FIFO先进先出、SOFT软引用、WEAK弱引用flushInterval刷新间隔单位毫秒size最多缓存的对象数readOnly只读缓存设置true时直接返回缓存对象的引用性能好但调用方可以修改缓存对象设置false时会序列化拷贝对象安全但性能差。开启二级缓存后执行流程变成查询先走二级缓存没有再到一级缓存还没有才查数据库。数据写入二级缓存的时机是事务提交时不是在SQL执行完立即写入。这意味着如果两个事务并发操作同一批数据后提交的事务可能把先提交的覆盖掉。二级缓存的实现细节有一个很多人不知道的点被缓存的对象要么实现Serializable要么设置readOnlyfalse时会进行序列化。默认的readOnlyfalse要求返回对象可序列化所以你的实体类最好都实现Serializable否则开启二级缓存后直接报NotSerializableException。3.3 缓存不一致的经典踩坑二级缓存最大的坑在“多表关联查询”。看这个场景ShopMapper里有一个查询SELECT * FROM shop s LEFT JOIN product p ON s.id p.shop_id这个查询开启了二级缓存。然后ProductMapper更新了商品价格。结果是什么ShopMapper的缓存不知道ProductMapper动了数据下次再查那个关联查询时返回的依然是旧价格。这个问题本质是二级缓存按namespace隔离但业务查询经常跨表数据变更分散在多个Mapper里缓存一致性无法保证。解决方案也不复杂一个是尽量不在多表查询的Mapper里开启二级缓存另一个是使用cache-ref namespace其他Mapper/把相关Mapper的缓存合并到一个区域。比如ShopMapper引用ProductMapper的缓存ProductMapper一更新ShopMapper的缓存跟着失效。实际项目里我对二级缓存的态度一直是“能不开就不开”。热点数据用Redis收口分布式环境下缓存一致性天然更好单个JVM内部的二级缓存还要处理序列化、失效、跨namespace引用这些问题收益不大但风险不小。真要开只对那种数据基本不变且查询量极大的字典表、配置表开并且认真设置flushInterval。4. 把SQL打出来日志配置与慢SQL定位4.1 三种配置方式对比热词里专门有“mybatis配置打印”这也是群里被问烂的问题。Spring Boot项目里配置MyBatis打印SQL主要就三种方式。我用表格列一下方便对照配置方式核心配置适用场景包路径日志级别logging.level.com.example.mapperdebug生产环境排查问题输出到日志文件指定日志实现mybatis.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl本地开发直接在控制台看SQL和参数MyBatis-Plus配置mybatis-plus.configuration.log-impl: org.apache.ibatis.logging.stdout.StdOutImpl使用MyBatis-Plus的项目第一种方式最规范logging.level后面接的是Mapper接口所在的包路径设为debug后MyBatis会把SQL预编译语句和执行参数都打出来。它的好处是走Spring Boot的日志体系可以配合logback输出到文件生产环境排查问题不打扰控制台。第二种方式是我本地开发常用的配置简单SQL和参数会以固定格式打到控制台。不过StdOutImpl本质是System.out输出不走日志框架所以你没法控制它的输出级别也无法让它进日志文件。生产环境不建议用这个。第三种就是MyBatis-Plus项目里的写法形式上和第二种一样只是前缀不同。很多人在MyBatis-Plus项目里配了mybatis.configuration.log-impl发现不生效就是这个原因命名空间不对。4.2 从日志到慢SQL的排查思路日志打出来之后怎么定位慢SQL我一般看三个东西SQL本身的写法、执行参数、数据库执行计划。假设你看到一条SQL执行了3秒先把它和参数复制到数据库客户端用EXPLAIN看执行计划重点看type列和key列。如果是ALL全表扫描或者没用上索引优先优化索引而不是改SQL。如果你用的是MySQL还可以开启慢查询日志把超过1秒的SQL全部记录下来用pt-query-digest这类工具按频率和耗时排序找出最值得优化的Top N。抓出慢SQL之后大多数情况优化方案是加索引、改写LIKE %xx这种前缀模糊查询、拆分大事务、加缓存。这部分经验和MyBatis本身关系不大但“会看SQL日志”是评估一个后端基本功的硬指标。还有一个小技巧MyBatis日志里输出的 Preparing:和 Parameters:之间Preparing显示的是带?占位符的SQL模板Parameters是参数列表。排查时不要光看SQL模板一定要把参数带进去看真实SQL。很多时候SQL没问题问题是参数传了空集合或者传了0这在日志里一眼就能看出来。5. 面试高频考点与源码阅读入门5.1 高频题背后的原理“MyBatis面试题”这个热搜词不是没道理的因为MyBatis的面试题很少是纯背答案的大多是考你对执行流程的理解。我总结下来高频考点就四块#{}和${}的区别、一级缓存和二级缓存、动态SQL的实现原理、Mapper接口为什么没有实现类也能调用。#{}和${}这个题已经快被问烂了但每年还是有人栽。#{}是预编译占位符MyBatis会给它生成?然后通过PreparedStatement的参数绑定传入值数据库端执行的是预编译SQL不存在注入风险。${}是字符串拼接直接把值替换进SQL如果参数是用户输入的那SQL注入的风险就很大。所以规则很简单值传递一律用#{}表名、字段名这种结构性的内容才用${}而且必须对参数做白名单校验。“Mapper接口没有实现类为什么可以直接注入”这个题考验的是动态代理。MyBatis启动时扫描Mapper接口为每个接口通过MapperProxyFactory生成一个代理对象代理逻辑在MapperProxy.invoke()里它把接口方法的调用转换为一次SqlSession操作。所以你在Service里注入的ShopMapper本质是一个动态代理对象不是手写的实现类。“MyBatis怎么防止SQL注入”这个题其实是#{}的延伸。理解了预编译原理就能解释为什么#{}安全而${}危险。面试官如果进一步问“那${}是不是完全不能用”你可以答分页排序字段、动态表名这种场景会用但是必须校验字段名在白名单内。5.2 源码主线SqlSession到MapperProxy读源码不是让你把所有类都背下来而是把主链路搞清楚。我建议按这条线读SqlSessionFactoryBuilder读XML构建ConfigurationConfiguration是整个MyBatis的核心配置类它里面保存了所有的Mapper映射、结果映射、缓存配置并且负责创建Executor。执行SQL时SqlSession把活交给Executor。默认的BaseExecutor负责缓存和一二级缓存处理然后调用StatementHandler。这里要注意二级缓存是通过CachingExecutor装饰器实现的——Configuration.newExecutor()里如果开启了二级缓存会用CachingExecutor包一层这就是为什么二级缓存是“可选装饰”而不是内置逻辑。StatementHandler再往下是ParameterHandler负责把#{}的参数设置到PreparedStatement上最后ResultSetHandler把查询结果映射成对象。一条SQL从Mapper接口到数据库经历了动态代理、Executor、StatementHandler、ParameterHandler、ResultSetHandler五层理解这条链路后再去看拦截器Interceptor就非常清晰——你可以在Executor、StatementHandler这些组件的调用前后插入自定义逻辑分页插件、多租户插件、自动填充字段插件全都是这么干的。5.3 Spring Boot集成时容易忽略的配置细节关于Spring Boot集成我补充几个配置上的细节。首先包扫描问题MapperScan要么加在启动类上要么为每个Mapper加Mapper注解但两者不要同时用否则会重复注册。其次驼峰映射数据库字段是下划线风格create_time实体是驼峰createTime如果不配置map-underscore-to-camel-case: true查询结果会映射不上去这是初学者最常见的问题。还有一个容易被忽略的是configuration和configuration-properties的覆盖关系。在application.yml里如果你用了mybatis.configuration.*它相当于把MyBatis的Configuration对象的相关属性set进去如果你用mybatis.config-location指定了独立的XML配置文件那configuration.*里的部分属性可能冲突逻辑上以后者为准。多个配置源叠加时我建议明确只走一种方式别混着写不然排查配置不生效的问题会非常头大。6. 多商户商城项目中的落地经验6.1 分页、多租户与动态字段热词里提到的开源多商户商城项目虽然我没法直接讲某一个具体源码但多商户这种业务模式的MyBatis落地经验是通用的。先说多租户问题多商户平台里平台方和商户方共用一套代码但每个商户只能看到自己的订单、商品、数据报表。常规做法是在每张业务表上加tenant_id字段查询时自动拼接条件。用MyBatis实现的时候最推荐的方式是写一个Interceptor拦截Executor的query方法在SQL执行前通过BoundSql拿到原始SQL然后用jsqlparser解析SQL给需要隔离的表拼上AND tenant_id ?条件。这个方案的好处是业务SQL里不需要手动写租户条件防止开发人员漏加同时也方便在拦截器里做租户字段的动态绑定。注意不要对所有表都做处理只要给配置了多租户注解的表拼条件不然连缓存表、字典表都被套上租户条件就麻烦了。分页在多商户商城是刚需。MyBatis-Plus自带分页插件传统MyBatis配PageHelper也可以原理都是拦截Executor生成一条COUNT(*)查询和一条带LIMIT的分页SQL。分页插件有一个坑当查询语句里有GROUP BY时自动生成的count语句可能统计的是分组前的数据或者语义不对导致总页数异常。我遇到过很多次解决办法是自己写count查询或者用分页插件时对分组查询单独处理。6.2 缓存与事务的取舍商城系统的热点数据比如首页推荐商品、类目树、配置信息直接用Redis缓存是更合理的选择。MyBatis的二级缓存适合数据量不大、更新频率极低、只在一个Mapper里访问的场景比如商户的结算费率配置。我从多商户项目的经验来看经常出问题的地方恰恰是“数据在多个Mapper里都有访问”比如商品信息在ProductMapper、CartMapper、OrderMapper里都会查到如果在每个Mapper都开了二级缓存一个商品的更新至少要让三个namespace的缓存失效一致性风险成倍增加。事务方面多商户商城的订单流程通常跨多个Mapper操作预扣库存、写订单、扣优惠券、记流水。这里要注意MyBatis一级缓存的表现在同一个Spring事务里如果先查询订单再更新订单再查询订单第二次查询可能拿到的是更新前的缓存数据。我在实际项目里遇到过一次预付款回调里反复查订单状态结果拿到旧状态导致重复入账。解决方式是避免在一个事务方法里“先查、再改、再查”要么拆成两个方法要么在更新后主动清缓存。如果你希望事务内部的每次查询都读最新数据可以考虑在配置里把Spring事务的传播级别改成REQUIRES_NEW来隔离缓存但这样会伤性能不建议滥用。6.3 我踩过的几个真坑写到最后分享几个压箱底的实操经验也不算多高大上但每个都让团队加过班。第一个是resultType和resultMap的选择问题。很多人图省事多表查询结果直接映射到VO类字段名对不上就加别名。短期看可以长期维护就是灾难。我的习惯是多表查询坚决用resultMap哪怕映射代码多一点但语义清晰后续加字段不迷路。单表查询用resultType加驼峰映射够用且简洁。第二个是log-impl配置的坑。我在一个生产事故排查中发现MyBatis的日志一直没输出查了半天发现项目里引入了两个日志框架的依赖MyBatis的日志适配器自动选了一个不输出的实现。解决办法是显式指定StdOutImpl或Slf4jImpl不让它自动探测。其实MyBatis的LogFactory会自动用java.util.logging、commons-logging、log4j、slf4j里的第一个可用实现依赖混乱时它会选到一个“能用但不输出”的实现。第三个是批量更新。很多新手在商城里批量更新商户结算状态时用foreach拼接多条UPDATE语句在MySQL连接串里没加allowMultiQueriestrue的时候直接报语法错误。这种需求应该改写成分批查询后循环更新但注意大事务问题——几百上千条更新最好分批提交每批50到100条不然锁等待和回滚成本都扛不住。第四个是关于where和trim的小技巧。动态SQL有条件不满足时WHERE关键字会悬空SQL变成SELECT * FROM orders WHERE直接报错。用where会自动处理这种情况它会去掉第一个多余的AND或OR。但注意where只在字符串开头匹配AND/OR如果你在第一个条件前面加了别的字符或者换行加空格它可能处理不了所以规范写法是每个条件单独一行保证AND在开头。我个人这几年做后端的体会是MyBatis本身不复杂复杂的是使用场景动态SQL的边界条件、缓存的一致性问题、日志的排查能力、Spring集成时的配置细节这些都是面试题和线上故障的重灾区。如果你正在准备面试把#{}和${}、一级缓存、二级缓存、Mapper动态代理这几个点吃透比背一百道零散题管用如果你在做项目先把SQL日志配置好再搞一套规范的Mapper写法能帮你少熬很多个排查问题的夜。最后再分享一个习惯每次写完一段动态SQL我都会在本地把日志打开实际打印SQL看一眼拼接结果这个简单的动作帮我拦下了不知道多少低级错误。
返回列表