ARTICLE DETAIL

资讯详情

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

MyBatis sql标签源码级拆解:include展开、动态SQL与面试答法

MyBatis sql标签源码级拆解:include展开、动态SQL与面试答法 跟团队做技术面试复盘时发现MyBatis 的sql标签是个非常有意思的高频考题。问的是“请讲一下sql标签的作用”看起来是送分题但多数候选人只能答出来“定义公共 SQL 片段、用 include 引用、减少重复代码”这三句话然后就没有然后了。其实面试官真正想听的是三件事它到底解决什么问题它在框架内部是怎么被解析的以及它和动态 SQL、参数占位符之间的关系。这篇文章就围绕这三点展开适合正在准备 Java 开发面试的人也适合用了两三年 MyBatis 但没有系统看过相关源码的开发者。1. sql 标签是什么它在日常开发里解决什么问题1.1 最大价值不是省那几行代码很多人把sql标签的功能总结为“减少重复代码”这个说法没错但太宽泛了面试官听完不会有任何印象。它真正解决的是“同一段 SQL 片段被多条语句反复引用时的维护性问题”。举个例子。你有一张用户表几乎每个查询都要带deleted 0这个逻辑删除条件还要附带一段固定的公共字段。如果这些内容散落在十个select里某天业务调整比如要把逻辑删除字段从deleted改成is_deleted就得全局搜一遍手工改漏改一处就是线上事故。把这段条件抽成sql片段所有地方通过include引用修改点只有一个。这种复用在工程上的收益是“可维护性”不只是“少写几行代码”。面试中如果能答到这个层面已经比大多数背答案的人强了。1.2 三个最常用的场景第一个场景是公共查询列的复用。比如业务中经常需要查询用户的基础信息但不希望每次都把id, username, nickname, status这串列名写一遍sql iduserBaseColumns id, username, nickname, status /sql第二个场景是公共 WHERE 条件的复用。比如几乎所有查询都要过滤逻辑删除标记或者多商户系统里每条查询都要按merchant_id隔离数据sql idnotDeletedCondition deleted 0 /sql第三个场景是批量操作的公共拼接。常见的批量插入、批量更新可以通过foreach配合sql片段把列名和值的生成逻辑收敛到一处。这块在第 3 章我会给完整示例。这些场景都有一个共同特点它们不是“偶尔复用一次”的巧合代码而是业务规则层面的强制约束。用sql标签把规则固化下来后续维护时跑到一个 mapper 文件顶部就能看到全局统一口径。1.3 光会背答案和真正理解它差在哪儿我复盘时发现一个规律能答出“复用、include”的人很多但能答出“include 展开发生在 XML 解析阶段”的人极少能答出“sql 片段存储位置是 Configuration 里的 sqlFragments”的更是凤毛麟角。这其实反映了两种学习方式背出来的答案是一个静态结论它回答不了任何追问而基于源码或运行机制的理解可以推导出所有细节。举个具体的追问场景——面试官问“那include里的property标签是干什么的”如果你只知道“公共片段复用”大概率会被问住。但如果你知道 include 的处理类XMLIncludeTransformer会在展开时做属性替换就能很自然地接下去。这就是差距所在也是后面几章我拆到源码层面的原因。2. 源码级拆解sql 标签在 MyBatis 里是怎么被处理的2.1 XML 解析阶段它被存进了 configuration 的哪张“表”MyBatis 启动时会通过XMLMapperBuilder逐个解析 mapper XML 文件。这个解析器处理 XML 的能力并不是“读到一个 select 就马上生成 SQL”它会先把整个文件的结构过一遍。具体来说XMLMapperBuilder.configurationElement()这个方法会按顺序处理cache、cache-ref、parameterMap、resultMap、sql最后才处理select|insert|update|delete。遇到sql节点时MyBatis 会以它的id属性为 key把整棵 XML 节点树存进Configuration对象的一个 Map 里。这个 Map 的名字叫sqlFragments定义大致是这样的protected final MapString, XNode sqlFragments new StrictMap(XML fragments parsed from previous mappers);注意这里存的是XNode也就是 XML 节点树不是字符串。这意味着片段里的if、where、foreach这些动态 SQL 标签并没有在存储阶段被展开它们在真正构建 SQL 时才发挥作用。这里还有一个容易忽略的细节key 的生成规则。如果当前 mapper 文件配置了 namespace那么保存到sqlFragments里的完整 key 是namespace.id这种全限定格式。也就是说A 个 mapper 文件里定义的片段理论上可以被 B 个 mapper 文件通过全限定名引用。这也是跨文件复用的机制基础。面试时提到sqlFragments这个数据结构并且能说出 key 的规则基本就能让面试官确认你不是临时背的答案。2.2 include 指令是如何展开的include的展开逻辑在XMLIncludeTransformer这个类里核心方法叫applyIncludes。它做的不是简单的字符串替换而是基于 XML 节点树的操作。处理流程大致可以理解成这样遍历当前节点的子节点遇到include时取出它的refid属性去sqlFragments里找到对应的片段节点然后把这个片段的子节点内容复制到 include 所在的位置。如果片段内部又嵌套了其它include它会递归地继续展开。这里有个额外的步骤是属性替换。include允许携带property子标签这些属性值会替换掉片段文本里出现的${xxx}。注意这个替换发生在 include 展开阶段不是在 PreparedStatement 执行阶段。它是一个静态的、构建 SQL 字符串时完成的替换跟#{}这种预编译参数占位符完全是两条链路。我面试时经常追问候选人这个问题“include 展开时片段里的if标签会生效吗”答案是会的。因为 include 展开发生在动态 SQL 解析之前展开完成后原来片段里的if、where、foreach成为当前语句的一部分再由XMLScriptBuilder把它们解析成 SqlNode 树。如果你答对了这一点说明你是真的看懂了顺序。提示面试里把“include 展开发生在动态标签解析之前”这句话讲出来比你背十个概念都有用。它体现了你对执行顺序的把握。2.3 展开后会进入动态 SQL 处理链include 展开后的模板内容会继续走 MyBatis 的脚本构建链路。XMLScriptBuilder负责把 XML 节点解析成一个 SqlNode 树根节点通常是MixedSqlNode它内部挂着一系列子节点比如WhereSqlNode、IfSqlNode、ForeachSqlNode等。当真正执行 SQL 时MyBatis 会调用 SqlSource 的getBoundSql()方法传入参数对象。动态 SQL 节点逐个执行自己的apply()方法根据参数情况拼接出最终的 SQL 字符串。这里要注意sql标签的作用范围到此就结束了它只影响“模板代码的复用与展开”不会影响后续的参数绑定和结果映射。所以一个完整的链路是XML 解析阶段片段存入sqlFragments语句构建阶段include 展开片段合并到当前语句动态 SQL 处理阶段if、where等标签基于参数动态拼接执行阶段#{}占位符被预编译参数通过 PreparedStatement 绑定理解这个链路后你会发现sql标签的实质它是一个“编译期”的代码生成工具不是“运行期”的动态代理。这个区分在回答边界问题时特别关键。2.4 一个容易答串的点它和缓存标签没有关系很多候选人会把 “MyBatis 一级缓存、二级缓存” 和sql标签混在一起答可能是因为都涉及 XML 里的标签。这个是误解题。sql标签负责 SQL 片段复用它不参与数据缓存。MyBatis 的缓存机制由另一个体系管理一级缓存是 SqlSession 级别的默认开启二级缓存由cache、cache-ref标签开启和配置作用范围是 namespace 级别的。二级缓存缓存的是查询结果对象不是 SQL 语句。面试中如果被追问“sql 标签是不是参与了缓存”你可以直接说sql 标签只是模板复用不涉及任何查询结果缓存真正影响缓存命中率的是语句的id、参数类型和 SQL 文本因为缓存 key 主要基于这些信息计算。这个“区分感”很重要。面试官抛出这个问题很多时候是为了看你能否把框架的各个模块梳理清楚而不是堆砌概念。3. 进阶用法include 传参、嵌套和业务落地3.1 通过 property 给片段传参include标签的property子标签是很多开发者忽略的进阶功能但实际业务里非常有用。它的作用是在 include 展开时对片段中的${xxx}占位符做文本替换。最典型的场景是动态指定表名或排序列。sql idselectFromTable SELECT * FROM ${tableName} WHERE deleted 0 /sql select idselectFromUser resultTypemap include refidselectFromTable property nametableName valuet_user/ /include /select select idselectFromOrder resultTypemap include refidselectFromTable property nametableName valuet_order/ /include /select这个例子中执行selectFromUser时${tableName}会被替换成t_userexecute 时实际 SQL 就是SELECT * FROM t_user WHERE deleted 0。需要特别强调风险控制${}是文本拼接不是预编译占位符。所以 property 的 value 如果可能被外部输入污染就有 SQL 注入风险。正规做法是只允许后端配置好的几个固定取值绝不能直接拼接用户提交的字符串。这一点在面试里主动说出来反而会加分因为它展示了你的安全意识。还有一个规律include传参使用的是${}而不是#{}。原因在于#{}会被当作 SQL 参数占位符留给 PreparedStatement而 property 替换发生在更早的文本阶段它根本看不到#{}的参数化语义。3.2 嵌套 include 与 foreach 组合include是可以嵌套使用的也就是说一个sql片段内部可以引用另一个sql片段。MyBatis 会递归地把它们全部展开。sql iduserCommonFields username, nickname /sql sql iduserDetailFields include refiduserCommonFields/, email, phone, avatar /sql展开后userDetailFields的实际内容就是username, nickname, email, phone, avatar。这种嵌套方式适合做字段的层次化组装基础字段放一层扩展字段放另一层。嵌套和foreach组合使用的场景也很常见。比如批量插入时列名和值分别抽成片段通过foreach循环组装值列表sql iduserInsertColumns username, nickname, status, create_time /sql sql iduserInsertValues #{item.username}, #{item.nickname}, #{item.status}, #{item.createTime} /sql insert idbatchInsertUser INSERT INTO t_user (include refiduserInsertColumns/) VALUES foreach collectionlist itemitem separator, (include refiduserInsertValues/) /foreach /insert这种写法在维护上的收益非常明显加一列时只需要改两个片段批量插入和单条插入如果引用了同一个片段改动都是同步的。我实际项目里就是这种结构开发效率提升不小。注意嵌套 include 时不要形成循环引用比如片段 A 引用 B片段 B 又引用 A。MyBatis 的XMLIncludeTransformer会检测这种情况但检测机制依赖引用链的递归记录一旦触发要么日志报警要么展开结果异常。这类问题启动时不一定报错调试起来比较费神。3.3 注册模块里的完整落地示例配合标题里提到的注册功能场景我写一个贴近实际项目的组合案例。假设一个 spring boot mybatis 的注册模块用户提交注册信息后端做三个操作插入新用户、按用户名查重、更新注册状态。这三个动作都要用到用户表的公共列于是片段收敛。sql iduserProfileColumns id, username, nickname, password_hash, status, created_at /sql sql iduserActiveCondition status 1 AND deleted 0 /sql insert idinsertUser parameterTypecom.demo.entity.User INSERT INTO t_user (username, nickname, password_hash, status) VALUES (#{username}, #{nickname}, #{passwordHash}, #{status}) /insert select idselectUserByName resultTypecom.demo.entity.User SELECT include refiduserProfileColumns/ FROM t_user where username #{username} include refiduserActiveCondition/ /where /select update idupdateUserStatus UPDATE t_user SET status #{status} WHERE id #{id} include refiduserActiveCondition/ /update注意上面的userActiveCondition片段里没有写WHERE它只是一个条件片段。这样在使用时可以灵活放在where内部也可以直接拼接在 SQL 最后。如果我把WHERE写死在片段里那它就只能用在固定位置了。这就是良好的片段设计原则把“条件”和“句式结构”解耦。片段本身越中性可以复用的场景越多。这是我踩过几次坑后总结出来的经验最初我习惯把整句WHERE xxx直接写进片段后来发现换个上下文就废了。3.4 项目里维护 sql 片段的几条经验用多了之后我归纳了几条个人经验不一定适合所有团队但可以做个参考命名要有明确前缀。公共列用base_开头条件片段用cond_开头更新字段用set_开头。这样一看名字就知道这个片段的用途和位置不会误引用。片段尽量集中在 mapper 文件头部。一个文件里如果有十几个sql片段全部堆在一起反而难维护按业务域拆分成多个sql片段并加注释说明各自的用途。引用时优先使用全限定名。refidnamespace.id比单独一个id明确得多特别是在跨 mapper 引用时能避免很多 namespace 解析问题。保持片段内部不打无谓的注释。MyBatis 解析 XML 节点时注释内容可能在某些版本里引发不必要的文本拼接问题安全起见SQL 片段的维护说明写在片段外部。这些经验都是从实战里提炼出来的尤其是命名前缀公司里如果有统一规范新同学上手时踩坑会少很多。4. 面试时怎么组织答案才有区分度4.1 面试官真正想考察什么面试官问“sql 标签作用”这个看似基础的问题一般是想通过追问判断你对 MyBatis 的掌握深度。它不像“动态 SQL 有哪几种标签”那样纯粹是记忆题sql标签背后藏着 XML 解析、SQL 构建、参数处理这几个核心流程。如果候选人只能答出“复用”那结论是“用过但仅限于会调用”。如果候选人能讲到sqlFragments、XMLIncludeTransformer、include 展开时机那结论是“理解框架执行链路”。这两种印象的差距在面试评定里非常明显。另外这道题很适合作为引子后续可以自然过渡到#{}与${}的区别、动态 SQL 的执行流程、MyBatis 插件原理等话题。你在回答时如果能主动把这些关联点带出来相当于在帮面试官省时间印象分会更好。4.2 一套四层递进的回答话术我不建议背标准答案但可以给你一个组织语言的结构模板。按四层递进往下说第一层是什么sql标签是 MyBatis 提供的 SQL 片段定义机制配合include引用用于复用一段 SQL 片段。第二层怎么用可以抽公共查询列、公共 WHERE 条件也可以配合property做表名或动态列名的替换片段内部可以包含动态 SQL 标签嵌套 include 也合法。第三层底层怎么处理解析阶段XMLMapperBuilder把片段以namespace.id为 key 存进Configuration的sqlFragments构建语句时XMLIncludeTransformer执行 include 展开并在展开阶段完成${}的属性替换展开后的节点会参与动态 SqlNode 树的构建。第四层边界与风险sql标签本身不做 SQL 注入防护不参与缓存如果在片段里使用${}替换表名或列名必须保证取值来源可信否则有注入风险。照这个顺序答即使面试官没有听过源码细节也能感受到你的思路是完整且自洽的。而且每一层都可以单独停下接受追问主动权在你手里。4.3 三个追问变体及应对第一个追问“include 里的property为什么必须用${}而不是#{}”应对点${}是文本替换发生在 SQL 构建阶段#{}是预编译参数占位符发生在 SQL 构建后的参数绑定阶段。include 展开时只处理和替换文本意义上的变量所以只能用${}。第二个追问“sql 标签能解决 SQL 注入问题吗”应对点不能。它只是代码片段复用机制跟 SQL 注入防护没有关系。MyBatis 的注入防护来自#{}预编译机制。如果一个sql片段内部全部使用#{}那么注入风险由片段内语句自身控制如果片段里引入了${}就需要加额外的白名单校验。第三个追问“多个 mapper 文件里可以定义相同 id 的 sql 片段吗”应对点如果两个文件的 namespace 不同那么sqlFragments里的 key 是全限定namespace.id它们互不冲突。如果同一个 namespace 下重复定义相同 id后解析的会覆盖先解析的也就是不报错但存在隐患实际项目中应当避免。这三个变体基本覆盖了面试官对这道题的主要扩展方向。能答好这三个这道题基本就稳了。5. 实战排查与避坑记录5.1 refid 找不到或者一直报 SQL 解析错误这是我见过最多的问题。典型报错信息类似Error resolving SQL. Cause: ...或者启动时直接提示某个 sql fragment 无法解析。排查顺序通常是这样的确认refid是否写完整。如果跨 namespace 引用必须写namespace.id。确认片段确实存在于 mapper XML 文件中。很多时候是复制粘贴时漏了一段。确认 XML 文件的 namespace 和接口绑定正确。如果 mapper XML 没有被扫描到sqlFragments里自然没有对应条目。确认 mapper XML 标签闭合正确。sql片段内部如果缺少闭合标签整个 XML 解析都会失败。这个经验来自一次真实事故一个同事把sql片段放在select标签内部导致 MyBatis 把它当作语句的一部分另一个地方引用时怎么都找不到。位置放错也是常见原因sql标签必须放在 mapper 根节点下不能嵌在其它语句内部。5.2 include 嵌套递归报警十有八九是引用链出问题有一次项目启动时控制台刷出关于 include 递归的警告排查后发现是两个人同时维护一个 mapper 文件A 写的片段引用了 B 的片段B 的片段又引用了 A 的片段形成循环。这种情况在代码审查阶段不一定能发现因为两个片段分布在不同位置读起来并不直观。解决思路有两种一是把循环引用拆出来底层公共片段独立成第三个片段二是在团队规范里明确禁止跨双向引用。我更推荐前者因为单向依赖结构清晰改动时不容易误伤。如果你在日志里看到 recursive include 相关字眼第一反应就去看引用链尤其是嵌套层级超过三层的片段。5.3 片段拼接多了逗号或少了空格SQL 片段在 include 展开后是直接拼接进语句的所以片段首尾的空白和逗号非常敏感。比如sql idcols id, username, /sql如果后面直接FROM t_user结果是id, username, FROM t_user语法直接报错。解决方式有两个片段末尾不留逗号逗号放在调用方或者片段开头结尾预留一个空格。我习惯用第二种比如每个片段都写成一行结尾留一个空格这样不管拼在什么位置都不会粘连关键字。这个细节很小但很多初学到 MyBatis 的人会在这种地方卡一下。面试时如果你主动提“不要为了整洁牺牲首尾空格”说明你是真的被线上 SQL 报错教育过。5.4 一个让我印象深刻的线上坑变量替换空值有一次做报表查询优化把公共查询条件抽成了带${}的sql片段通过property传入租户 ID。当时想得很简单property 传了一个值片段里替换掉就行。结果在某个场景下上游没传租户 ID空值直接替换进去SQL 变成WHERE tenant_id AND ...这种问题最难排查的地方是不是每次都会复现只有特定请求路径才会触发。后来我们加了统一入口校验保证传入片段的所有${}变量都有默认值或上游校验才算彻底解决。结合这个教训建议在项目里用sql片段做文本替换时务必遵循三步检查变量取值是否可控、是否有默认值、替换后 SQL 是否会在极端情况下语法异常。这三步能过滤掉大部分隐患。5.5 常见问题速查表现象可能原因处理方向启动报 SQL 片段无法解析refid 写错、namespace 不匹配用全限定名引用检查 mapper 扫描include 展开后多逗号或 SQL 粘连片段首尾没有预留字符调整片段格式化统一首尾空格日志出现 recursive include 警告片段循环引用拆分公共片段改成单向依赖${}变量替换后 SQL 语法错误property 值为空或非法调用入口加校验提供默认值同一 namespace 下片段 id 冲突重复定义规范命名代码评审时留意这张表是我自己在 intern 带教时整理的说是“速查表”其实也是团队内部的知识沉淀。刚接触 MyBatis 的人遇到问题先查这张表能省不少排查时间。最后分享一个我自己的学习路径不一定适合所有人但很有效把断点打在XMLIncludeTransformer的applyIncludes方法上跑一个最简单的 select观察片段是怎么在内存里被展开的再在XMLScriptBuilder里看一眼构造出的 SqlNode 树。这一步走完你对 MyBatis 的理解会和只会用标签的人明显拉开距离。这个主题后面如果遇到更刁钻的追问变体我会继续往这个方向补充。如果你们也在面试或实际项目中碰到过sql标签相关的坑欢迎一起交流各自的排查过程。
返回列表