news 2026/10/12 5:35:02

MyBatis <sql>标签深度解析:从原理到面试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis <sql>标签深度解析:从原理到面试

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>片段,都会顺手在片段上方加一行注释,写清楚它被哪些语句引用、为什么值得抽。别小看这几行字,几个月后你自己回来改代码,或者别人接手你的项目,能少掉一半头发。这道题看起来简单,但能把它讲出"边界感"——知道它解决什么问题、什么时候不该用——我觉得才算真正吃透了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/12 5:31:37

OAM超表面设计与FDTD仿真实战:从自旋轨道耦合到涡旋光束

最近在复现OAM超表面相关工作时&#xff0c;我把不少时间花在了自旋-轨道角动量耦合结构的设计和FDTD仿真调试上。这类结构利用几何相位&#xff0c;把圆偏振光携带的自旋角动量转化为轨道角动量&#xff0c;在一块亚波长厚度的平面上就能输出可控拓扑荷的涡旋光束。很多人会把…

作者头像 李华
网站建设 2026/10/12 5:30:41

读懂IEEE出版会议与EI检索:以ISBDAS 2026为例的实操指南

1. 先别急着交钱&#xff1a;怎么看懂“IEEE出版会议”和“EI检索”的成色做学术的人都有一个共识&#xff1a;会议论文的水有多深&#xff0c;很多时候比期刊论文还要难摸清。特别是一打开邮箱&#xff0c;满屏都是“快速EI检索”“见刊稳定”“往届均检索”的会议邀请&#x…

作者头像 李华