1. 先看一段重复到想吐的SQL这个标签存在的理由后端开发做到三五年面试桌上大概率会被问起 MyBatis。其他题多少能聊几句唯独这种看着很简单的标签题最容易暴露你是背过答案还是真在项目里用过。我见过不少候选人张口就来sql标签就是抽公共 SQL 的用include引用。然后就没有然后了。说实话这句话算答对了一半但离深度解析还有不小的距离。为什么会存在sql这个标签你随便打开一个业务系统翻翻里面的 mapper 文件十有八九能看到这种画面select idselectUserBasic resultTypeUser SELECT id, username, nickname, email, phone, status, create_time FROM sys_user WHERE id #{id} /select select idselectUserList resultTypeUser SELECT id, username, nickname, email, phone, status, create_time FROM sys_user WHERE status #{status} ORDER BY create_time DESC /select select idselectUserByDept resultTypeUser SELECT id, username, nickname, email, phone, status, create_time FROM sys_user WHERE dept_id #{deptId} /select三个查询语句字段列表一模一样。刚开始只有三处你还能忍等用户表增加字段比如加上avatar_url你就要跑到所有查询里去改。改漏一处线上就会出现A 接口返回了头像、B 接口没返回这种诡异现象。更麻烦的是这些字段列表还经常出现在 count 查询、导出查询、多表关联查询里散落在不同文件的不同角落。sql标签解决的就是这个核心痛点把一段 SQL 片段定义一次多处引用。字段要变更改一个地方所有引用它的语句自动生效。这其实不是什么高深思想就是我们常说的 DRY——Dont Repeat Yourself只不过它作用于的是 MyBatis 的 SQL 模板层面。顺着这个思路你会发现 MyBatis 里其实有三套消除重复的机制sql对应 SQL 片段复用resultMap的extends对应结果映射复用include则专门负责把sql片段拼进目标语句。面试时如果能把这几个概念的边界说清楚就已经比大多数候选人扎实了。2. 解析期发生了什么深入include的替换机制很多资料只讲怎么用不讲什么时候被解析。而面试官真正想听的恰恰是后面这个。sql和include不是运行时动态组装的功能它的替换动作发生在 MyBatis 启动解析 XML 映射文件的阶段。2.1 替换过程的三个关键步骤MyBatis 在启动时读取各个 mapper XML 文件XMLMapperBuilder负责解析文件的整体结构而真正处理include标签的是一个叫XMLIncludeTransformer的组件。它做的事情可以简化成三步第一步根据include refidxxx/里的 refid 找到对应的sql片段。refid 可以先找当前 mapper 命名空间里的也可以写完整的命名空间.片段id去别的 mapper 里找。第二步如果include内部带有property子标签则把 properties 里的值替换到片段文本中的${xxx}占位符上。第三步将include节点整体替换成sql片段里的内容。注意这一步是复制节点而不是移动节点同一个sql片段可以被任意多个语句引用替换完成后继续检查替换进来的内容里有没有嵌套的include有就递归处理。替换完成之后MyBatis 才开始真正构建这条语句的 SqlSource。如果替换进来的片段里含有if、where、foreach这些动态标签就会生成一个DynamicSqlSource如果片段就是纯静态文本则生成静态的 SqlSource。前者在每次执行时根据参数动态拼 SQL后者从启动之后就是固定不变的。2.2 为什么说它是静态复用而不是运行调用理解了上述过程你就明白了一个关键结论include的合并是构建期行为不是运行时行为。最终的 SQL 模板在应用启动时就确定了不会根据某次请求动态变化。这个概念可以类比 C 语言里的宏定义#define。宏是在编译阶段展开的不是函数调用sql也是在解析阶段展开的不是运行时方法。所以你在sql片段里写的${}占位符如果在 include 时通过property传了值就会在启动阶段被静态替换成真实文本如果没传它会被当作动态 SQL 变量保留到运行时处理。实操中验证这个结论很简单。把 MyBatis 的日志级别调到 debug启动应用时观察控制台logging: level: com.example.mapper: debug启动后第一次执行查询控制台打印的 Preparing: SELECT id, username ... FROM sys_user WHERE id ?就是 include 展开之后、预编译之前的完整 SQL。看到这条日志你就知道刚才那段替换动作确实发生在之前了。3. 定义、引用、传参三种基础姿势与一个翻车细节原理讲完我们来把用法拆开揉碎。这部分内容不多但每一个细节都可能是面试追问的切入点。3.1 定义与同文件引用最简单的用法定义片段然后同文件引用mapper namespacecom.example.mapper.UserMapper sql iduserColumns id, username, nickname, email, phone, status, create_time /sql select idgetUser resultTypeUser SELECT include refiduserColumns/ FROM sys_user WHERE id #{id} /select /mapper这里有个小细节sql片段里的内容可以是任意 SQL 片段不一定非要从SELECT开头。它可以是字段列表、表名、WHERE 条件、ORDER BY 子句甚至是整条 INSERT 语句的 VALUES 部分。MYSQL 的语法允许你把这些零件拆开用 include 拼装成完整的语句。3.2 跨 namespace 引用公共片段当公共片段多到一定程度单独建一个公共 SQL 定义文件是常见的做法。比如项目里专门建一个CommonSql.xml里面不写任何 statement只放sql片段mapper namespacecom.example.mapper.CommonSql sql iduserColumns u.id, u.username, u.nickname, u.avatar_url /sql /mapper其他 mapper 通过全限定名引用select idselectUserVo resultTypeUserVO SELECT include refidcom.example.mapper.CommonSql.userColumns/ FROM sys_user u WHERE u.id #{id} /select跨 namespace 引用的好处是职责清晰公共片段集中管理业务 statement 只关心自己的差异化逻辑。坏处是维护时你需要跳文件查看一旦公共片段改名所有引用它的地方都可能报错。所以实际项目中我更推荐如果片段只在同一个 mapper 内复用优先放本文件确有两个以上 mapper 都在用再抽到公共文件。3.3 用 property 给片段传参这是sql标签最有意思、也是最容易被忽略的能力。include可以给片段传属性片段内的${alias}会被替换成属性值sql idaliasColumns ${alias}.id AS id, ${alias}.username AS username, ${alias}.nickname AS nickname /sql select idselectUserWithRole resultTypeUserVO SELECT include refidaliasColumns property namealias valueu/ /include , r.role_name AS roleName FROM sys_user u LEFT JOIN sys_role r ON u.role_id r.id WHERE u.id #{id} /select展开后的效果相当于SELECT u.id AS id, u.username AS username, u.nickname AS nickname, r.role_name AS roleName FROM sys_user u LEFT JOIN sys_role r ON u.role_id r.id WHERE u.id ?这个场景在联表查询中极其常见。没有 property 机制的话你只能把带别名的字段列表完整复制到每个联表查询里有了它一份片段配不同别名就能应对单表、双表、三表查询。3.4 容易翻车的${}与#{}边界很多人在这里摔跤。include传的 property只能配合${}使用不能配合#{}。原因很简单property 替换发生在解析阶段是纯文本层面的事情而#{}是预编译参数占位符要留到 SQL 执行阶段由 JDBC 的PreparedStatement去绑定参数。这两个阶段差了十万八千里。占位符遇到 include 传入的 property执行阶段${alias}解析期被静态替换为u启动阶段完成#{alias}不参与替换保留为参数占位符运行时绑定参数若调用方法没有该参数则直接报错如果你在片段里写了#{alias}希望它变成表别名那肯定是错的。它会在运行时被当作一个普通参数去参数对象里找找不到就抛Parameter alias not found之类异常。面试被追问到这个点能够讲清楚为什么只能用${}比背用法高明得多。4. 实战片段拆解公共列、公共条件、多表别名怎么抽用法是骨架场景才是血肉。下面说四个我在项目里实际用过、也觉得最值得抽取的片段类型。4.1 公共列查询和插入共用一套字段字段列表是最先值得抽的。更进一步的玩法是把插入语句的列名和值也抽出来保证它们永远同步sql iduserInsertColumns username, nickname, email, status /sql sql iduserInsertValues #{username}, #{nickname}, #{email}, #{status} /sql insert idinsertUser parameterTypeUser INSERT INTO sys_user ( include refiduserInsertColumns/ ) VALUES ( include refiduserInsertValues/ ) /insert这样做的价值在于列名和占位符值永远一一对应。后来如果有人给表加字段只需要在两个片段里同步增加INSERT 语句的格式问题、字段错位问题都在源头被规避了。注意#{username}是运行时参数它不会被 property 静态替换这里两个片段只是文本复用参数绑定照常工作。4.2 公共查询条件where if 组合复用比字段列表更值得复用的是查询条件。一个列表页往往有三五个查询入口状态筛选、关键字搜索、部门过滤。这些条件组合出现在分页查询和 count 查询里最容易一处改了另一处忘改。sql idcommonUserWhere where if teststatus ! null AND status #{status} /if if testkeyword ! null and keyword ! AND (username LIKE CONCAT(%, #{keyword}, %) OR nickname LIKE CONCAT(%, #{keyword}, %)) /if if testdeptId ! null AND dept_id #{deptId} /if /where /sql然后分页查询和 count 查询都引用它select idpageUsers resultTypeUser SELECT include refiduserColumns/ FROM sys_user include refidcommonUserWhere/ ORDER BY create_time DESC /select select idcountUsers resultTypelong SELECT COUNT(*) FROM sys_user include refidcommonUserWhere/ /select这里的隐藏约束是片段里if test...用到的参数名在所有引用它的方法里都必须存在。假如pageUsers方法的参数里有keyword而countUsers没传那么 count 查询运行时就会因为找不到 keyword 而报错。也就是说抽取公共条件片段时你得先确认所有引用方的参数集合是兼容的否则宁可在两个语句里各写各的。4.3 多表别名property 传值的高频场景联表查询是 Java 后端绕不开的日常。一个用户列表要关联角色表一个工单列表要关联用户表和部门表字段名全是u.username、r.role_name、d.dept_name疯狂重复。这时候用 property 传别名最舒服sql idcommonUserFields ${alias}.id AS id, ${alias}.username AS username, ${alias}.nickname AS nickname, ${alias}.avatar_url AS avatarUrl /sql select idselectUserAndRole resultTypeUserVO SELECT include refidcommonUserFields property namealias valueu/ /include , r.role_name AS roleName FROM sys_user u LEFT JOIN sys_role r ON u.role_id r.id WHERE u.id #{id} /select你甚至可以抽一层用户加角色的复合片段里面再嵌套 include 用户字段然后加角色字段这样就形成了常用零件拼装的效果。不过这里要克制嵌套层数一旦超过两层SQL 的可读性会急剧下降别人维护时根本看不出最终语句长什么样得不偿失。关于这个边界我后面会专门说。4.4 报表统计里的口径统一最后一个容易被忽视的场景是统计报表。报表 SQL 对口径极其敏感比如本月新增用户的统计口径A 接口用DATE_FORMAT(create_time, %Y-%m) DATE_FORMAT(NOW(), %Y-%m)B 接口写成create_time 2024-01-01 AND create_time 2024-02-01两边结果对不上业务侧就要开始扯皮。把统计口径抽成片段能让所有报表语句引同一段逻辑sql idmonthCondition DATE_FORMAT(create_time, %Y-%m) DATE_FORMAT(NOW(), %Y-%m) /sql这样至少能保证同一时间范围内所有统计接口的筛选逻辑是一致的。等哪天口径要调整成按自然周统计你只需要改这一个片段所有报表自动切换。对需要频繁对齐指标口径的团队来说这个价值甚至比字段列表复用还大。5. 维护边界与踩坑复盘别把sql用成灾难再好的工具用过头就是灾难。sql标签我见过不少事故也踩过几个坑在这里完整复盘一遍。5.1 重复 id 与 refid 找不到先说最常见的两类报错一是同一个 mapper 里定义了两个相同 id 的sql片段MyBatis 启动时直接报错说这个 id 已经存在二是include refid.../写错了片段名或者漏写了 namespace 前缀启动时抛Could not find SQL statement to include with refid。这类问题的根源都是命名不够规范。我的建议是给公共片段统一命名规则字段列表就叫XxxColumns查询条件就叫XxxWhere插入列就叫XxxInsertColumns。项目里如果已经有多个 mapper最好开一个专门的公共 mapper 文件管理真正跨模块复用的片段再在团队规范里写明 refid 的写法。别小看这种基础命名问题它是代码 review 里最容易被放过去、又最容易埋雷的细节。5.2 半静态替换带来的线上隐患前面讲过 property 替换是解析期的静态行为。这个特性如果被误用很容易出事。举一个我见过的真实案例某同事想把表名也做成可配置的就在sql片段里写了${tableName}然后在 include 时通过property nametableName value${requestTableName}/把前端传的表名传了进去。先不说这个设计本身有多危险——把前端参数拼进表名等于把 SQL 注入的钥匙主动交了出去。就算单从机制上谈这里也有两个问题其一property 的 value 是在解析期展开的根本不会等运行时再取值所以这个写法启动时就会拿不到正确的值其二${requestTableName}这种占位符如果没被 property 覆盖会作为动态参数变量跑到运行时参数对象里没有这个属性就直接抛异常。正确的认知是sql片段里的${}只能用来做设计期静态替换比如表别名、固定表名、固定排序字段绝对不能让它承载任何来自外部请求的值。真要动态拼表名、拼排序字段应该走 MyBatis 的动态 SQL 机制并且对排序字段做严格的枚举白名单校验。5.3 过度抽象三层 include 的噩梦sql片段是允许嵌套include的解析器会递归展开。技术上没毛病但维护上就会变成噩梦。我曾接手过一个老项目一个看似简单的查询语句点开一看第一层 include 了一个用户通用字段片段这个片段里又 include 了用户基础字段和用户扩展字段用户扩展字段里还 include 了一个用户部门信息片段。整个语句被拆成了四五个文件里的七八段碎片实际 SQL 长什么样不把日志打开根本看不出来。最要命的是后来有一次改字段改了中间某一层结果有二十多个查询的行为一起变了查了半天才定位到源头。我的经验是两个红线。第一只有被两个以上地方引用、且内容相对稳定的片段才值得抽取一个片段只在一个语句里用抽了就是多余。第二嵌套不超过一层一个语句里最多一层 include 展开再多就必须停下来重新设计。抽 SQL 片段的目的是让读代码的人更快理解逻辑而不是制造一场找零件解谜游戏。5.4 修改公共片段前先问自己三句话由于sql片段是多处共享的修改一个公共片段影响面是不可控的。我自己的习惯是动手改之前强制自己回答三个问题这个片段被哪些语句引用了所有引用方的 SQL 语义在修改后是否仍然正确这次修改会不会让某个不该变的查询变了回答第一个问题可以通过 IDE 的全局搜索搜 refid也可以看 mapper 文件里的注释。我在团队里推行过一个约定每个sql片段定义处必须有一行注释写明这个片段被哪些方法引用、大概作用是什么。这样后来的人改的时候心里有数不敢瞎动。很多时候事故不是技术多复杂就是一句注释没写后一个接手的人不知道影响范围一脚踩上去。6. 面试追问清单答完主问题延伸题才是分水岭到这里主问题sql标签作用已经答得很完整了。但面试官通常不会就此打住他们会顺着往下追问。把这些延伸题提前准备好才算是真正的深度解析。6.1 include 是运行时拼接还是启动时解析答案前面已经详细讲过启动解析阶段完成属于构建期的文本替换不是运行时动态组装。面试官问这个问题其实是想判断你到底只是用过还是真读过源码或者排查过相关的问题。如果能顺带说出XMLIncludeTransformer这个类名或者说出解析完成后才生成 SqlSource这个顺序基本能直接加分。6.2 为什么 property 传参只能用${}因为include的 property 是解析期变量要被静态文本替换只有${}有文本占位逻辑#{}是预编译参数属于执行期的 JDBC 绑定两者不是同一个阶段的东西。引申一句既然${}会做静态替换那么凡是用户能影响 property value 的场景都存在注入风险。所以 included 片段的占位符应该只承载设计期就确定的值别名、固定表名不要动态传值。6.3 片段里能嵌套 include 吗能XMLIncludeTransformer是递归处理 include 节点的。但嵌套会显著拉高维护成本现实中我建议最多嵌套一层再深就要怀疑抽象是否过度了。面试时可以说支持递归嵌套但我个人会控制嵌套深度这个回答既展示了你知道机制又体现了工程判断力比单纯说能或者不能都高明。6.4 和 resultMap 的 extends、MyBatis-Plus 有什么区别sql解决的是 SQL 文本的重复resultMap的extends解决的是结果映射配置的重复。一个是语句怎么写层面的复用一个是查出来怎么映射层面的复用两者不在一个维度但可以配合使用。至于 MyBatis-Plus它解决的是单表 CRUD 的模板化问题LambdaQueryWrapper让你不用手写基础 SQL而sql更适合复杂的多表关联、报表统计这类 MP 的QueryWrapper搞不定的场景。面试时别把这两个东西对立起来更好的回答是单表简单操作用 MP 的封装复杂查询、口径统一的场景回到 XML 手写 SQL 并配合sql复用。这种什么场景选什么工具的判断力才是资深工程师和初学者的核心差距。6.5 一条回答顺序直接给面试官划重点最后给你一条我整理出来的回答顺序可以当作面试时的标准动作第一句sql是 MyBatis 里用来定义可复用 SQL 片段的标签配套include使用。第二句核心价值是消除重复比如公共字段列表、公共查询条件、联表查询的别名片段。第三句它的解析发生在应用启动阶段通过XMLIncludeTransformer做文本替换不是运行时动态组装。第四句使用时注意 property 传参只能用${}并且不能把外部请求数据塞进 property 的 value。第五句它和resultMap extends、MyBatis-Plus 的定位不同分别解决文本复用、映射复用和单表 CRUD 模板化问题。这套回答既有层次又有细节面试官想打断你都难。最后再分享一个小习惯。我每次抽完一个sql片段都会顺手在片段上方加一行注释写清楚它被哪些语句引用、为什么值得抽。别小看这几行字几个月后你自己回来改代码或者别人接手你的项目能少掉一半头发。这道题看起来简单但能把它讲出边界感——知道它解决什么问题、什么时候不该用——我觉得才算真正吃透了。