1. 先看一段重复到想吐的SQL:这个标签存在的理由
后端开发做到三五年,面试桌上大概率会被问起 MyBatis。其他题多少能聊几句,唯独这种"看着很简单"的标签题,最容易暴露你是背过答案还是真在项目里用过。我见过不少候选人张口就来:"<sql>标签就是抽公共 SQL 的,用<include>引用。"然后就没有然后了。说实话,这句话算答对了一半,但离"深度解析"还有不小的距离。
为什么会存在<sql>这个标签?你随便打开一个业务系统,翻翻里面的 mapper 文件,十有八九能看到这种画面:
<select id="selectUserBasic" resultType="User"> SELECT id, username, nickname, email, phone, status, create_time FROM sys_user WHERE id = #{id} </select> <select id="selectUserList" resultType="User"> SELECT id, username, nickname, email, phone, status, create_time FROM sys_user WHERE status = #{status} ORDER BY create_time DESC </select> <select id="selectUserByDept" resultType="User"> 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——Don't 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 refid="xxx"/>里的 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 namespace="com.example.mapper.UserMapper"> <sql id="userColumns"> id, username, nickname, email, phone, status, create_time </sql> <select id="getUser" resultType="User"> SELECT <include refid="userColumns"/> 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 namespace="com.example.mapper.CommonSql"> <sql id="userColumns"> u.id, u.username, u.nickname, u.avatar_url </sql> </mapper>其他 mapper 通过全限定名引用:
<select id="selectUserVo" resultType="UserVO"> SELECT <include refid="com.example.mapper.CommonSql.userColumns"/> FROM sys_user u WHERE u.id = #{id} </select>跨 namespace 引用的好处是职责清晰:公共片段集中管理,业务 statement 只关心自己的差异化逻辑。坏处是维护时你需要跳文件查看,一旦公共片段改名,所有引用它的地方都可能报错。所以实际项目中我更推荐:如果片段只在同一个 mapper 内复用,优先放本文件;确有两个以上 mapper 都在用,再抽到公共文件。
3.3 用 property 给片段传参
这是<sql>标签最有意思、也是最容易被忽略的能力。<include>可以给片段传属性,片段内的${alias}会被替换成属性值:
<sql id="aliasColumns"> ${alias}.id AS id, ${alias}.username AS username, ${alias}.nickname AS nickname </sql> <select id="selectUserWithRole" resultType="UserVO"> SELECT <include refid="aliasColumns"> <property name="alias" value="u"/> </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 id="userInsertColumns"> username, nickname, email, status </sql> <sql id="userInsertValues"> #{username}, #{nickname}, #{email}, #{status} </sql> <insert id="insertUser" parameterType="User"> INSERT INTO sys_user ( <include refid="userInsertColumns"/> ) VALUES ( <include refid="userInsertValues"/> ) </insert>这样做的价值在于"列名"和"占位符值"永远一一对应。后来如果有人给表加字段,只需要在两个片段里同步增加,INSERT 语句的格式问题、字段错位问题都在源头被规避了。注意,#{username}是运行时参数,它不会被 property 静态替换,这里两个片段只是文本复用,参数绑定照常工作。
4.2 公共查询条件:where + if 组合复用
比字段列表更值得复用的是查询条件。一个列表页往往有三五个查询入口:状态筛选、关键字搜索、部门过滤。这些条件组合出现在分页查询和 count 查询里,最容易"一处改了另一处忘改"。
<sql id="commonUserWhere"> <where> <if test="status != null"> AND status = #{status} </if> <if test="keyword != null and keyword != ''"> AND (username LIKE CONCAT('%', #{keyword}, '%') OR nickname LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="deptId != null"> AND dept_id = #{deptId} </if> </where> </sql>然后分页查询和 count 查询都引用它:
<select id="pageUsers" resultType="User"> SELECT <include refid="userColumns"/> FROM sys_user <include refid="commonUserWhere"/> ORDER BY create_time DESC </select> <select id="countUsers" resultType="long"> SELECT COUNT(*) FROM sys_user <include refid="commonUserWhere"/> </select>这里的隐藏约束是:片段里<if test="...">用到的参数名,在所有引用它的方法里都必须存在。假如pageUsers方法的参数里有keyword,而countUsers没传,那么 count 查询运行时就会因为找不到 keyword 而报错。也就是说,抽取公共条件片段时,你得先确认所有引用方的参数集合是"兼容"的,否则宁可在两个语句里各写各的。
4.3 多表别名:property 传值的高频场景
联表查询是 Java 后端绕不开的日常。一个用户列表要关联角色表,一个工单列表要关联用户表和部门表,字段名全是u.username、r.role_name、d.dept_name,疯狂重复。这时候用 property 传别名最舒服:
<sql id="commonUserFields"> ${alias}.id AS id, ${alias}.username AS username, ${alias}.nickname AS nickname, ${alias}.avatar_url AS avatarUrl </sql> <select id="selectUserAndRole" resultType="UserVO"> SELECT <include refid="commonUserFields"> <property name="alias" value="u"/> </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 id="monthCondition"> 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 name="tableName" 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>片段,都会顺手在片段上方加一行注释,写清楚它被哪些语句引用、为什么值得抽。别小看这几行字,几个月后你自己回来改代码,或者别人接手你的项目,能少掉一半头发。这道题看起来简单,但能把它讲出"边界感"——知道它解决什么问题、什么时候不该用——我觉得才算真正吃透了。