1. 为什么 MyBatis 的 XML 里一个"小于号"能让你整段 SQL 崩掉
先说一个我最早接手 MyBatis 项目时遇到的真实场景:项目里有一条订单查询,要查创建时间早于某个点的记录,开发同学直接在 mapper XML 里写了这么一句:
WHERE create_time < #{endTime}结果应用一启动,控制台立刻抛了一坨异常,最显眼的是:
The content of elements must consist of well-formed character data or markup.很多新手看到这个报错直接懵了,明明 SQL 在数据库客户端里跑得好好的,怎么一放到 MyBatis 里就崩?还有人以为是 SQL 语法错误,反复去调字段名、表名,却始终找不到问题。我之所以把这篇文章的第一节放在"解析器"上,就是想先把根因讲清楚:MyBatis 的 mapper 文件首先是一个 XML 文档,其次才是 SQL 映射配置。
1.1 XML 解析器先于 SQL 执行:特殊字符问题的优先级
MyBatis 启动时,会通过 XML 解析器去读取所有的 mapper 映射文件,把每个<select>、<insert>、<update>、<delete>节点和内部 SQL 片段解析成内部配置对象。这个阶段发生在任何 SQL 执行之前。也就是说,不管你 SQL 写得多漂亮,如果 XML 本身不符合规范,MyBatis 根本走不到执行那一步。
在 XML 规范里,<是标签的开始标记,解析器看到<就会认为后面跟着的是一个标签名。你写create_time < #{endTime},解析器会尝试把< #{endTime}当标签处理,然而#和空格都不符合标签命名规则,于是解析器直接判定该文档不是 well-formed(格式良好),启动阶段就报错。>在普通文本内容里其实是可以出现的,但如果你在属性值里直接用>或者>=,某些解析器也会因为历史兼容性问题给出警告甚至报错。最保险的做法是:只要在 XML 里用到比较操作符,一律按特殊字符处理。
1.2 XML 五个预定义实体与更多隐患
XML 规范定义了五个必须在源码中写成实体形式的字符,MyBatis 开发里最常用到的就是前三个:
| 原始字符 | 实体写法 | 常见出现场景 |
|---|---|---|
< | < | 小于比较、动态标签条件 |
> | > | 大于比较、=>之类 |
& | & | 参数里带 URL、SQL 函数拼接 |
' | ' | 字符串常量包含单引号 |
" | " | 属性值里包含双引号 |
我见过有人把<拼错成lgt,或者问"小于号在 XML 里是不是 lgt",其实lt就是less than的缩写,gt是greater than的缩写,记住这个命名规则就不容易写错。还有一个容易忽略的字符是&,比如你在 SQL 里做字符串函数拼接:
SELECT * FROM t_user WHERE instr(username, #{kw}) > 0看起来没问题,但如果你直接在 XML 里写:
WHERE flag = 'A' & flag2 = 'B'这显然不合法,只是举例。真正常见的是动态列名或某些数据库函数里用到&,比如 Oracle 的TO_CHAR、DECODE拼接,或者部分 MySQL 函数。&在 XML 里必须写成&,否则解析器会认为它是一个实体引用的开始,后面跟不到分号就报错。
我的一贯建议是:不要跟 XML 解析器玩猜谜,凡是出现需要转义的原始字符,能写成实体的就写实体,能包 CDATA 的就包 CDATA,后面我会专门讲这两条路线的选择。
2. 转义与 CDATA:两条最常用的处理路线,但别用错场合
处理 MyBatis XML 特殊字符,业界基本就两招:实体转义和 CDATA 包裹。这两招都能让解析器闭嘴,但使用场合和代价完全不同。
2.1 什么时候用实体转义,什么时候用 CDATA
实体转义就是把<写成<,>写成>,>=写成>=,<=写成<=。优点是简单直接,写起来可控,尤其在<if test="...">、<when test="...">这些动态标签的属性值里,实体转义几乎是唯一干净的选择。缺点也很明显:SQL 可读性直线下降,一长串时间比较条件写下来,满屏<>,后期维护的人要花额外精力来翻译。
CDATA 的写法是:
<![CDATA[ SQL 片段 ]]>CDATA 是 Character Data 的缩写,它的意思是告诉 XML 解析器:这里面的内容全部当成纯文本,不要尝试解析任何标签、实体、注释。这样你在里面写<、>、&&都合法。MyBatis 里最经典的用法就是用 CDATA 把一条带比较运算符的完整 SQL 片段包起来:
<select id="selectByTime" resultType="com.example.Order"> SELECT * FROM t_order <where> <if test="endTime != null"> <![CDATA[ AND create_time < #{endTime} ]]> </if> </where> </select>注意,CDATA 不是 SQL 注释,它的内容最终仍然会被 MyBatis 当作 SQL 主体来处理。它只是绕过了 XML 解析器。另外,CDATA 内部不能出现字符串]]>,因为这是 CDATA 的结束标记。如果 SQL 里真的需要这个字面值(几乎不会遇到),那就只能拆分成多个 CDATA 片段拼接,或改用实体转义。
2.2 一个常见误区:CDATA 里放#{}会不会失效
不少人刚接触 CDATA 时会担心:我把 SQL 包进 CDATA,里面写的#{param}占位符还有没有用?这里我可以明确告诉你:完全有用。CDATA 影响的是 XML 解析层的动作,MyBatis 在完成 XML 解析之后,会继续处理 SQL 文本里的#{}、${}等占位符。两者完全不在一个阶段,不会互相干扰。
还有另一个误区是有人把整个<select>节点内容全部用 CDATA 包起来,包括<where>、<if>这些动态标签,比如:
<select id="bad" resultType="..."> <![CDATA[ <where> <if test="id != null"> AND id = #{id} </if> </where> ]]> </select>这样是绝对不行的。CDATA 一旦包住<where>、<if>,这些标签就变成了纯文本,MyBatis 不会再去解析它们。最后你得到的 SQL 可能是一串包含<where>字样的垃圾文本,或者直接执行报错。正确的做法是:用<if>、<where>等动态标签做条件控制,只在含有特殊字符的 SQL 片段外面套 CDATA,两者是包含关系,不是对等关系。
我个人的习惯是:单个比较符号、写在标签属性里的判断,使用实体转义;一段连续 SQL 里有多个比较运算符,比如时间范围、金额范围,优先使用 CDATA,并且只包住必要的片段,保证动态标签仍然在外层正常工作。
3. 动态 SQL 里最容易踩的三个坑:标签内判定、<=写法、模糊查询通配符
如果说静态 SQL 里的特殊字符问题还好解决,那动态 SQL 里的坑就隐蔽多了。因为你会遇到两个解析器的碰撞:外部 XML 解析器和 MyBatis 内部动态标签解析器。很多问题出现在两层解析的交叉地带。
3.1<if>、<where>标签中的大小于判定
先看这段代码:
<select id="selectOrders" resultType="Order"> SELECT * FROM t_order <where> <if test="startDate != null and startDate <= endDate"> create_date BETWEEN #{startDate} AND #{endDate} </if> </where> </select>第一眼看上去逻辑没问题,但启动时会报错。问题出在<if test="...">属性值里直接写了<=。XML 属性值是双引号包裹的普通文本,解析器遇到<同样会认为开始一个新标签。所以哪怕你只是在一个判断条件里用了小于号,也必须写成:
<if test="startDate != null and startDate <= endDate">或者干脆反转比较逻辑,写成endDate >= startDate,这样属性值里没有<就不需要转义。这里我建议优先用反转写法,因为<=在属性值里可读性真的不算好,而且容易看漏。但反转写法也有局限,如果比较语义明显,比如"开始时间不能超过结束时间",反转成"结束时间大于等于开始时间"完全是等价的,没有任何副作用,可以放心用。
3.2<与>出现在 SQL 主体里的处理策略
如果比较条件出现在 SQL 主体而不是标签属性里,比如:
<if test="minPrice != null"> AND price >= #{minPrice} </if> <if test="maxPrice != null"> AND price <= #{maxPrice} </if>>=和<=都出现在文本节点里。虽然>在 XML 文本节点中通常允许直接出现,但<=里的<是必须处理的。我建议即使只是>也统一转义,或者用 CDATA 包裹。看看下面这种写法:
<if test="minPrice != null"> <![CDATA[ AND price >= #{minPrice} ]]> </if> <if test="maxPrice != null"> <![CDATA[ AND price <= #{maxPrice} ]]> </if>这是一个典型的正确姿势:动态标签在外面控制 SQL 拼接,CDATA 只包住包含比较运算符的 SQL 片段。不是说每个比较都要这样写,而是当某个条件里同时出现<和>混用,或者有多个比较符号时,CDATA 比逐个转义清爽得多。
3.3 模糊查询里%和_的隐藏坑
特殊字符不光指<、>、&这类 XML 字符,还有 SQL 模式匹配里的通配符。比如你要按关键字模糊查询用户,很多初学者习惯直接在 XML 里拼:
AND username LIKE '%${keyword}%'先说${}的问题:它做的是字符串替换,不是预编译,用户传入%或_会改变 LIKE 语义,更危险的是可能注入恶意 SQL。正确的做法是用#{}配合数据库拼接函数:
AND username LIKE CONCAT('%', #{keyword}, '%')这样%和_在参数里会作为普通字符处理,不会影响模式匹配。假设业务上确实希望支持%和_作为通配符使用,那就需要在 Java 层对用户输入做白名单或清洗,而不是在 SQL 层冒险。
另外,如果你用的是 MySQL,LIKE语法里可能会需要ESCAPE子句,比如:
AND username LIKE CONCAT('%', #{keyword}, '%') ESCAPE '/'这个/在 XML 里没有任何特殊含义,不需要转义,但它能帮你把参数中的/、%、_转义成字面值。这部分属于 SQL 语义层面的边界问题,不在 XML 解析层,但实际排查特殊字符问题时经常一起暴露,所以放在这一节提醒。
4. 一次真实排错:时间范围查询报错,日志只有一半,问题出在哪儿
这一节我带大家完整复盘一次线上问题的排查过程。这个案例融合了特殊字符、启动报错、SQL 日志残缺三个典型现象,可以说把 MyBatis XML 的坑踩了一个遍。
4.1 现象与第一反应:以为是数据库连接问题
某天测试环境报了一个接口 500,查看后台日志,最显眼的异常是:
org.apache.ibatis.builder.BuilderException: Error creating document instance. Caused by: org.xml.sax.SAXParseException: The content of elements must consist of well-formed character data or markup.很多人的第一反应是"SQL 写错了"或"数据库连不上了",实际上BuilderException和SAXParseException两个关键词已经把范围缩得很小:MyBatis 在构建 SQL 映射时解析 XML 文档失败。这跟数据库本身没有关系,甚至跟 SQL 语义都没有关系,纯粹是 XML 格式不合法。
4.2 完整排查链路:从报错行号到特殊字符
拿到异常后,我先把异常堆栈往前翻,找到类似:
org.apache.ibatis.builder.xml.XMLMapperBuilder.configurationElement(XMLMapperBuilder.java:...)它一般会指明是哪个 mapper 文件解析失败,甚至精确到行号。我的经验是:先看行号,再打开对应 XML 文件,把光标移到那一行。通常你会发现,要么是<直接出现在 SQL 文本里,要么是&后面跟了个空格。
当时我们看到的 mapper 是订单查询,原片段长这样:
<select id="selectByCreateTime" resultType="map"> SELECT * FROM t_order WHERE create_time <![CDATA[ < #{endTime} ]]> </select>不对,这个例子是后修的。实际出问题的代码是这样的:
<select id="selectByCreateTime" resultType="map"> SELECT * FROM t_order WHERE create_time < #{endTime} </select>启动阶段直接报错。这里我建议排查时做一个二分操作:先把可能引发问题的条件逐个注释掉,每注释一次启动一次,能很快锁定是哪个<if>分支里的哪一段 SQL 出了问题。尤其是当 XML 文件几百行、报错行号又不精确的时候,二分排除比肉眼硬看高效得多。
4.3 修复方案与验证:转义和 CDATA 都可以
定位到是create_time < #{endTime}这一行后,修复方案有两个,我把两种都列出来:
方案一,实体转义:
<select id="selectByCreateTime" resultType="map"> SELECT * FROM t_order WHERE create_time < #{endTime} </select>方案二,CDATA:
<select id="selectByCreateTime" resultType="map"> SELECT * FROM t_order WHERE create_time <![CDATA[ < #{endTime} ]]> </select>两个方案执行效果完全一样。考虑到这条 SQL 后续还会加更多时间比较条件,我最终用了 CDATA,因为里面即使再出现<、>、&&也不需要逐个转义。验证方式也很简单:重启应用,查看日志中打印出的 SQL 是否完整,然后调用接口传入时间参数,确认查询结果符合预期。如果项目里配了 MyBatis SQL 日志插件,你会看到预处理后的 SQL 和绑定参数值,此时#{}会被替换成?,说明解析已经正常完成,特殊字符没有影响预编译。
我还想提醒一个容易被忽略的点:项目里如果使用了 MyBatis 的二级缓存,修改 XML 后必须清缓存或重启应用,否则可能出现改了映射文件但实际执行还是旧 SQL 的诡异现象。这一点在很多团队里都踩过,尤其是在测试环境热部署不彻底的时候。
5. 再深一层:#{}、${}与特殊字符的纠缠,以及字符集对乱码的影响
特殊字符的处理不能只停留在 XML 解析层,还要看数据是怎么进入 SQL 的。MyBatis 的参数占位符有两种,它们和特殊字符的关系完全不同。
5.1 预编译占位符与字符串替换的天壤之别
#{}是预编译占位符。MyBatis 会在运行时把 SQL 中的#{}替换成?,然后通过 JDBC 的PreparedStatement绑定参数。这意味着即使参数里包含<、>、&、'这样的字符,它们也只是被当成普通字符串值,既不会影响 XML 解析,也不会破坏 SQL 结构。所以理论上,只要你在写 SQL 时正确转义了 XML 特殊字符,执行层面的特殊字符压力很小。
${}则完全不同,它做的是直接字符串拼接。比如:
AND status = ${status}如果status的值为'CLOSED',拼接后 SQL 就是AND status = 'CLOSED'。但假如外部传入1 OR 1=1,你得到的 SQL 就是AND status = 1 OR 1=1,直接变成一个恒真条件。更麻烦的是,有些人为了规避 XML 报错,会把<写成<后发现#{}在某些场景下不好用,就换${}硬拼,这属于用更大的漏洞去补一个小坑,绝对不建议。
还有一个使用细节:在ORDER BY、动态表名、动态列名这些位置,#{}不会生效,因为预编译占位符不能出现在表名、列名位置。此时必须用${},但一定要做白名单校验,比如用 Map 映射或枚举限制传入值范围,不能直接信任外部参数。
5.2 字符集编码:特殊字符变成乱码的另一个来源
特殊字符解析成功之后,还要保证编码一致性。很多 XML 文件顶部会写<?xml version="1.0" encoding="UTF-8"?>,但 IDE 或编辑器保存文件时用的可能是 GBK 或 ISO-8859-1,两者不一致会导致中文和全角符号变成乱码。比如 SQL 里写了一个中文全角大于号>,在 XML 解析时不一定会报错,但到了数据库执行阶段可能变成?或乱码,最终查不出数据。
我处理过一个类似问题:SQL 条件里有一个'>',实际上是全角字符,应用从 XML 读取后变成乱码,导致比对永远失败。这个问题的排查思路和特殊字符不太一样,得检查文件编码、数据库连接 URL 的characterEncoding配置、以及数据库表本身的字符集。这里提醒一句:XML 声明 encoding 要与文件实际保存编码一致,数据库连接的编码也要与数据库会话编码一致,三层贯穿,任何一层断了,特殊字符和中文都会出问题。
5.3 带有 HTML 实体风格的场景: 在 XML 里不可直接用
还有一种特殊字符容易被忽略,就是类似 、©这样的 HTML 实体。HTML 里它们很常见,但 XML 标准只预定义了五个实体, 并没有在 XML 预定义实体的名单里。如果数据里包含 ,在 XML 中直接写会被解析器视为非法实体引用,需要写成 或直接写原始字符的 UTF-8 编码。MyBatis 映射文件里不常遇到这种场景,但当你处理富文本内容,或从第三方接口拿到包含 HTML 实体的数据再拼进 SQL 时,就要格外小心。
6. 一套可以直接复用的特殊字符检查清单与自测技巧
到这里,原理和案例都讲得差不多了。最后我把多年积累的检查清单和自测方法整理出来,方便你直接抄作业。
6.1 检查清单:写完 mapper XML 后逐项过一遍
- 文本节点中出现
<时,是否用了<或 CDATA? - 标签属性值(
test、value、key等)中出现<时,是否用了<? - 连续比较条件中是否混入了
>=、<=,有没有统一处理? - SQL 中是否包含
&,比如 URL 参数、函数拼接,是否写成&? - 是否误把 HTML 实体(如
)写进了 XML? - 使用了 CDATA 时,CDATA 内部是否误放了
<if>、<where>等动态标签? - 是否在不需要
${}的地方用了${},导致外部输入直接进入 SQL? - XML 声明的 encoding 与实际保存编码是否一致?
- 应用启动后,是否确认日志中打印的 SQL 与预期一致?
可以打印成一张小卡片贴在工位旁边,每次写完 mapper 文件就对照过一遍。别看这些条目琐碎,我见过不少生产事故最后都能归到其中一条。
6.2 两个自测技巧:把报错留在开发期
第一个技巧是写一个极简的单元测试,在项目启动阶段就去解析所有 mapper 文件。比如用 Spring Boot 的话,其实项目启动时 MyBatis 就会加载所有 XML,只要测试类里启动一次 Spring 容器,任何 XML 格式问题都会在这里暴露。如果你的项目是裸 MyBatis,没有 Spring 容器,也可以直接构建SqlSessionFactory:
Reader reader = Resources.getResourceAsReader("mybatis-config.xml"); SqlSessionFactory factory = new SqlSessionFactoryBuilder().build(reader);只要这一步能成功,说明至少 XML 结构是合法的。第二个技巧是用 IDE 自带的 XML 校验功能,IDEA 里打开 mapper XML,如果在某个<下方画了红色波浪线,说明解析器已经嗅到了问题。这种情况下不要急着写 SQL,先把红线消掉再说。
我还有一个习惯:写动态 SQL 时,每写一个<if>分支,就顺手在注释里标注这个分支所对应的特殊字符场景,例如"这里条件较长,用 CDATA 包住 <="。这样半年后自己回来看代码,或者同事接手,都能一眼明白当初的使用意图,避免为了"少写两行"而随手替换掉正确写法。
最后分享一个真实体会:特殊字符处理这件事,看着不起眼,却是 MyBatis 项目里最能体现基本功的细节之一。我见过很多程序员能把复杂的多表关联写得飞起,却在一条简单的create_time < #{endTime}上报错半小时。记住一个原则——在 XML 里,凡是可能被解析器误解的字符,第一时间按纯文本处理,而不是按 SQL 直觉处理。这样你在后续调试 MyBatis 时,能省下大量无谓的排查时间。