写MyBatis的mapper.xml文件,几乎每个Java后端开发都会被大于号、小于号、不等于号卡过那么一两次。我记得第一次在XML里写WHERE age > 18,启动项目后接口直接报错,控制台提示XML解析失败,当时还以为是SQL写错了,查了半天才发现是尖括号惹的祸。后来在这个问题上踩的坑多了,慢慢摸清了背后的机制,也整理出了一套自己的写法习惯。这篇文章就从根上讲清楚mapper.xml里比较运算符到底该怎么写,转义、CDATA、动态SQL三种场景怎么选,以及我这些年遇到过的几个古怪问题。
这个问题虽然看起来简单,但它同时牵扯到XML语法、SQL语法和MyBatis的动态SQL机制三件事,很多人只记住了"要转义"这个结论,却不知道为什么,换一个场景就不会了。下面我会把原理、写法、排错一条龙讲透,新人和写过几年的老手应该都能从里面找到点有用的东西。
1. 为什么mapper.xml里不能直接写大于小于号
1.1 XML语法和SQL语法在这个地方撞车了
先说结论:不是MyBatis限制了你,而是XML解析器限制了你。
XML文件本质上是一个有严格语法的文本文件,它自己定义了一套规则。在这套规则里,尖括号<和>并不是普通的数学符号,<表示"一个标签从这里开始",&表示"一个实体引用从这里开始"。XML解析器读到<时,会立刻进入标签解析模式,如果后面跟的是字母,比如<if、<where,它会认为这是一个标签;如果后面跟的是空格、数字、等号这类内容,它不知道该怎么处理,直接抛异常。
SQL语句里的age > 18,其中的>在XML规范里其实并不强制转义,真正致命的是<。比如你要写age < 18,XML解析器读到<后,后面跟着空格和18,它不认为这是一个合法的标签结构,所以报错。而!=这个写法里,!和=都不是XML特殊字符,从解析层面来说可以直接写。但是SQL标准里不等于的标准写法是<>,如果按照标准写,那个<就又踩中了XML的雷区。
这就好像一个人同时说两种语言,在数据库客户端里写SQL,SQL的语法规则说了算;把同样的SQL放到XML文件里,XML的语法规则也要插一脚。两个规则在这个地方互相冲突,必须用某种方式告诉XML解析器:"这段内容别按XML规则解析,给我原样放过去。"
1.2 直接写符号常见的两个典型报错
我见过不少新手(包括当年的自己)把SQL从Navicat里粘到mapper.xml,然后启动项目报错,第一反应是"我的SQL在数据库里明明能跑啊"。确实能跑,因为数据库客户端不解析XML,你的SQL没有问题,问题出在你把SQL放进了XML这个"容器"里。
最常见的报错之一是这样的:
The content of elements must consist of well-formed character data or markup.翻译过来就是:元素内容必须是格式良好的字符数据或标记。这句话基本可以翻译成"XML解析器在某个地方读到了一个它不认识的<符号"。只要看到这个错误,优先排查SQL里有没有没转义的比较符号。
还有另一种报错看起来更迷惑:
元素类型 "if" 必须由匹配的结束标记 "</if>" 终止。这种情况往往是你并没有写<if>标签,但XML解析器把某个<后面跟着的字母组合当成了标签名,于是它认为你开了一个标签却没闭合,整个文档结构全部乱套。比如你写status <> 0,解析器读到<后,后面的内容被当作标签处理,导致文档结构被破坏。这种报错和第一种报错本质上是同一个原因,只是表现形式不同。
明白了这一层,后面所有写法就都好理解了:不管用转义还是CDATA,目的都是让XML解析器不要误解你的比较符号。
2. 三种主流写法:转义字符、CDATA、函数变通
2.1 最基础的转义字符方案
转义字符是解决问题最直接的方式。XML规范里预定义了几个实体引用,把它们写到XML文本里,解析器会用对应的字符替换回去。和SQL比较符号相关的对照表大概是这样的:
| 你想要的符号 | XML转义写法 | 说明 |
|---|---|---|
< | < | less than,必须转义 |
> | > | greater than,推荐转义,保持风格统一 |
<> | <> | SQL标准不等于写法 |
!= | != | 可以直接写,没有XML语法冲突 |
<=组合 | <= | 小于等于,小于号必须转义 |
>=组合 | >= | 大于等于,推荐转义 |
& | & | 如果你在SQL里写逻辑与 |
&& | && | OGNL表达式里的逻辑与 |
举个例子,查询年龄大于18且状态不等于0的用户:
SELECT * FROM user WHERE age > 18 AND status <> 0这段XML解析后,MyBatis拿到的SQL是:
SELECT * FROM user WHERE age > 18 AND status <> 0这里要注意,#{}参数占位符放在>后面完全没问题,因为MyBatis是在XML解析完成之后再去处理#{}的,两者不在同一个阶段,互不干扰。
转义方案的最大缺点是可读性差。SQL一长,满屏都是>和<,眼睛看过去第一反应是乱码,排查问题的时候非常痛苦。所以我的习惯是:比较符号只有一两个的时候才用转义,一旦条件多起来,果断换CDATA。
2.2 CDATA包裹方案,SQL保持原样
CDATA的全称是 Character Data,翻译过来是字符数据。在XML里,<![CDATA[ ... ]]>是一个特殊的标记区域,在这个区域内部,所有字符都会被视为纯文本,XML解析器不会去解析<、>、&这些特殊字符。这是XML规范专门为"需要放一段任意文本"这种需求设计的机制。
用法很简单:
<![CDATA[ SELECT * FROM user WHERE age > 18 AND status <> 0 ]]>CDATA区域里的SQL和数据库客户端里写的一模一样,不需要任何转义,读起来非常直观。一个区域里放多少内容都行,整个SQL包进去也没问题,只要注意区域里不能出现字符串]]>,因为解析器看到]]>会认为CDATA区域结束了,这个限制在实际开发中基本不会遇到。
我个人的建议是:一段SQL中如果大于、小于、不等于这类符号超过两个,优先考虑CDATA。比如查询最近30天内有登录记录、分数大于60、状态不等于关闭的用户,这种多条件SQL用CDATA包住,一眼就能看清业务逻辑。
2.3 两种方式的边界与选型
转义和CDATA并不是二选一的关系,它们各有适用场景。我在实际开发中总结了一套选型标准,可以参考一下:
| 比较维度 | 转义字符 | CDATA |
|---|---|---|
| 可读性 | 符号多时很差 | 保持SQL原样,可读性好 |
| 适用范围 | 单个符号、短SQL | 长SQL、多个比较符号 |
| 与动态SQL标签配合 | 可以随意组合 | CDATA内部不能放动态标签 |
| 出错概率 | 容易漏写、写错 | 嵌套错误后不好排查 |
这里有一个非常关键的坑必须单独拿出来说:CDATA区域内部不能再嵌套MyBatis动态SQL标签,比如<if>、<where>、<foreach>。原因很简单,CDATA的语义是"这里面的内容全是文本",一旦MyBatis的XML解析阶段遇到CDATA起始标记,整段内容都会原样提取出来,动态标签根本不会被识别成标签,而是被当作普通字符串拼进SQL里。
我当年写过这样的代码:
<![CDATA[ <if test="minAge != null"> AND age > #{minAge} </if> ]]>运行后不报错,但SQL执行结果完全不对,日志里能看到SQL语句里明晃晃地出现了<if test=...>字符串。这个问题排查了很久才发现,就是因为对CDATA的特性理解不够。
正确的做法是:动态标签放在CDATA外面,CDATA只包裹比较符号那一小段。比如:
<if test="minAge != null"> AND age <![CDATA[ > ]]> #{minAge} </if>这样MyBatis能正常识别<if>标签,同时CDATA区域里的>符号又不会被XML解析器干扰。后面的实操部分我会展示更完整的写法。
2.4 函数变通:换个思路绕开符号
有时候也可以从SQL函数层面绕开比较符号,虽然这不是主流的解法,但某些情况下挺好用。比如要判断一个日期是否大于当前时间,除了写create_time > NOW(),也可以写成DATE_ADD(NOW(), INTERVAL -1 DAY) < create_time,把符号方向换一下,用小于号避开大于号。再比如要查询年龄小于某个值的用户,可以用BETWEEN 0 AND #{maxAge}替代age <= #{maxAge}。
这种变通方式的优点是能绕开XML语法冲突,缺点是可读性会变差,而且不是所有场景都能用函数替代。我的看法是:没必要为了不用转义符号强行改SQL逻辑,比较符号该写还是写,转义或者CDATA才是正规解法。
3. 实操:在一个真实mapper.xml里写各种比较条件
3.1 mapper.xml基础结构回顾
先从一个标准的mapper骨架开始,后面所有示例都基于这个结构。
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> <mapper namespace="com.example.demo.mapper.UserMapper"> <resultMap id="UserResultMap" type="com.example.demo.entity.User"> <id property="id" column="id"/> <result property="name" column="name"/> <result property="age" column="age"/> <result property="status" column="status"/> <result property="createTime" column="create_time"/> </resultMap> </mapper>namespace对应Mapper接口的全限定名,resultMap把数据库列名和Java实体字段映射起来,这是最常用的基础配置。如果实体字段名和列名一致,也可以用resultType="com.example.demo.entity.User"简化,不需要resultMap。
3.2 大于、小于、等于、不等于的完整示例
以一个用户表为例,字段包括 id、name、age、status、create_time。先看几个最基础的查询写法。
查询年龄大于18岁的用户:
<select id="selectByAgeGreaterThan" resultMap="UserResultMap"> SELECT id, name, age, status, create_time FROM user WHERE age > 18 </select>也可以写成CDATA的形式:
<select id="selectByAgeGreaterThan" resultMap="UserResultMap"> <![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE age > 18 ]]> </select>这里有一个容易忽略的小知识点:如果CDATA把整条SQL包裹起来,SQL语句末尾的分号;可写可不写,但不要写在CDATA外面,否则某些数据库驱动可能会把分号当成SQL的一部分传过去,导致执行异常。我一般习惯不在mapper里写分号,和MyBatis原生风格保持一致。
查询年龄在18到30之间,同时状态不等于0的用户:
<select id="selectByCondition" resultMap="UserResultMap"> <![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE age >= 18 AND age <= 30 AND status <> 0 ]]> </select>这个SQL里既有>=又有<=还有<>,如果用转义写法,那行SQL眼睛都要看花,所以这里CDATA是最合适的。
查询创建时间晚于某个日期的用户:
<select id="selectByCreateTime" resultMap="UserResultMap"> <![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE create_time >= '2024-01-01 00:00:00' ]]> </select>关于日期字符串,要特别注意和数据库字段格式保持一致。MySQL里如果字段是datetime类型,字符串比较时MySQL会做隐式转换,但格式不一致很容易出问题,比如'2024-1-1'和'2024-01-01'虽然表示同一个时间,转换时可能产生意外结果。更稳妥的做法是用STR_TO_DATE函数明确指定格式,或者让Java层把参数格式化成标准格式再传进来。
3.3 动态SQL里怎么处理比较条件
实际项目里很少会把条件写死,通常都是前端传入查询参数,后端根据参数动态拼SQL。这是MyBatis动态SQL的主场,但也是比较符号最容易翻车的地方。
先看完整的动态SQL示例:
<select id="selectUsersByCondition" resultMap="UserResultMap"> SELECT id, name, age, status, create_time FROM user <where> <if test="minAge != null"> AND age <![CDATA[ >= ]]> #{minAge} </if> <if test="maxAge != null"> AND age <![CDATA[ <= ]]> #{maxAge} </if> <if test="status != null"> AND status <> #{status} </if> <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> </where> ORDER BY create_time DESC </select>这里有三个关键点值得展开。
第一,<where>标签会自动处理掉第一个条件前面多余的AND。比如只传入了maxAge,拼出来的SQL是WHERE age <= #{maxAge},AND会被自动去掉。如果没有<where>而直接写WHERE加<if>,当所有条件都为null时,SQL会变成WHERE后面什么都没有,直接语法错误。
第二,<if>标签的test属性里写的是OGNL表达式,走的是MyBatis自己的解析器,不是XML标签的内容区。所以test="minAge != null and minAge > 0"里面的>和!=不需要转义,直接写没问题。这里有一个细节:OGNL表达式里的逻辑与如果写&&,在XML里就必须转义成&&,因为&是XML特殊字符。为了避免这个麻烦,我通常建议在test属性里直接用and,比如minAge != null and minAge > 0,简洁又安全。
第三,SQL片段里的比较符号要放在CDATA里,但CDATA只包住符号本身,不要包住<if>标签。第一次写的人很容易把整个条件用CDATA包起来,结果动态标签就失效了,这一点我在前面已经强调过。
再看一个查询"所有状态不为关闭的用户,且年龄在指定区间"的完整案例。
<select id="selectActiveUsersByAgeRange" resultMap="UserResultMap"> <![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE status <> 0 AND age >= #{minAge} AND age <= #{maxAge} ]]> </select>这个SQL里的比较符号多,但都是静态条件,直接整段CDATA最简单。#{minAge}放CDATA里面完全没问题,因为CDATA只是让XML解析器跳过这段文本,#{}参数替换是在SQL解析阶段进行的,阶段不同,互不冲突。
3.4 参数空值和类型转换陷阱
写完动态SQL,还有一个非常隐蔽的坑:参数为null时SQL不报错,但结果不对。比如你写了:
<select id="selectByAge" resultMap="UserResultMap"> <![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE age > #{minAge} ]]> </select>如果minAge传了null进去,拼接后的SQL是WHERE age > null。在SQL中,任何值和NULL比较,结果都是UNKNOWN,最终查询结果为空。而且这个错误非常隐蔽,不报错、不警告,就是查不到数据,你要是不知道原因,排查起来相当费劲。
解决办法就是加<if>判空:
<select id="selectByAge" resultMap="UserResultMap"> SELECT id, name, age, status, create_time FROM user <where> <if test="minAge != null"> AND age <![CDATA[ > ]]> #{minAge} </if> </where> </select>还有一种类型陷阱:数据库字段类型和传入参数类型不匹配。比如字段是varchar类型,你传入了一个数字,MySQL会做隐式类型转换。表面上看没毛病,但索引可能失效,性能变得很慢。更严重的是字符串比较的问题:如果年龄字段设计成了varchar,'12' > '9'会返回true,因为字符串比较按字符顺序走,先比较第一位'1'和'9','1'小于'9',所以结果是false,和数字比较的结果完全相反。这种问题只能从表结构设计上解决,数字字段就用数字类型,别把数值存成字符串。
4. 常见问题与排查技巧
4.1 XML解析报错的定位思路
排序问题的时候,先看日志里有没有XML解析异常。MyBatis启动阶段加载mapper.xml时如果解析失败,应用会直接启动不了,或者运行到某条SQL时报错。定位思路按优先级来:
第一,看报错信息里有没有 "well-formed" 字样,有的话基本就是XML结构不合法,优先排查比较符号。第二,看报错信息里提到的行号,直接跳到那一行检查,注意XML报错的行号对应的是XML文件的行号,不是SQL的行号。第三,用IDE的XML校验功能。IDEA里打开XML文件,如果文件里有语法错误,右侧会显示红色波浪线,鼠标放上去就能看到具体的错误原因。养成写完mapper看一眼的习惯,很多低级错误当场就能发现。
我整理过一张速查表,这些年遇到的异常情况基本都能对上号:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| XML解析失败,报well-formed | SQL里直接写了<,未转义 | 用<或CDATA包裹 |
| 报错提到某个元素未闭合 | <后的内容被误认为标签,文档结构混乱 | 检查所有比较符号是否处理 |
| SQL执行结果为空,但无异常 | 参数为null,SQL变成字段> null | 加<if>判空 |
| CDATA里的动态SQL不生效 | <if>被包进了CDATA区域 | <if>移到CDATA外面 |
| 字符串比较结果不对 | 字段类型为varchar,隐式类型转换导致按字符比较 | 修改表结构或使用CAST转换 |
test表达式里用&&报错 | XML不识别裸&字符 | 用and替代或写&& |
4.2 CDATA吞掉动态标签的深层坑
前面已经讲了CDATA不能包动态标签,这里再深入说一个更隐蔽的变体。有时候你会出于习惯,把写好的SQL直接整个包进CDATA,然后里面又想加动态条件,比如:
<select id="selectBadExample" resultMap="UserResultMap"> <![CDATA[ SELECT * FROM user WHERE age > 18 <if test="status != null"> AND status = #{status} </if> ]]> </select>这段代码不会报错,但<if>会被当成字符串拼进SQL。真正执行的时候SQL长这样:
SELECT * FROM user WHERE age > 18 <if test="status != null"> AND status = ? </if>数据库看到<if这种语法,直接抛SQL语法错误,报错的位置还特别靠后,很难第一时间想到是CDATA的问题。
这种问题怎么破?我的习惯是写SQL之前先想清楚:这段SQL最终是静态的还是动态的?静态就整段CDATA;动态就把动态标签放在CDATA外面,CDATA只管符号。动态SQL和不等于符号结合时,我通常这样写:
<if test="excludeStatus != null"> AND status <> #{excludeStatus} </if>只一个不等于号,用转义就够,没必要再上CDATA。如果比较符号多,单个条件里可以混合:
<if test="minAge != null and maxAge != null"> AND age <![CDATA[ >= ]]> #{minAge} AND age <![CDATA[ <= ]]> #{maxAge} </if>这种写法动态标签在外面,CDATA只包住符号,两个阶段的解析各管各的,互不干扰,是最稳妥的组合方式。
4.3 不等于符号在不同数据库下的行为差异
<>和!=在MySQL里都表示"不等于",但是有个null的坑很多人第一次踩到都会懵。
举个例子:
SELECT * FROM user WHERE status <> 0这条SQL不会返回status IS NULL的记录,因为在SQL的Three-Valued Logic(三值逻辑)里,NULL <> 0的结果是UNKNOWN,UNKNOWN在WHERE条件里等价于false。如果你的业务语义是"状态不等于0的都查出来",而状态字段又允许为null,那这个null的数据就会悄无声息地被漏掉。
解决办法是显式处理null:
<![CDATA[ SELECT id, name, age, status, create_time FROM user WHERE (status <> 0 OR status IS NULL) ]]>不同数据库对不等于的支持也有细微差别,MySQL两种写法都支持,Oracle也支持<>和!=,但SQL Server里两种也都支持。为了统一规范,我一般推荐统一写<>,这一方面是SQL标准写法,另一方面和XML转义的<>对应性强,团队协作时风格也统一。
4.4 动态比较符号的进阶处理
有些场景里,比较符号本身也是前端传过来的。比如查询条件里有个"大于/小于/等于"的下拉框,这种时候你可能会想把符号直接拼进SQL:
<select id="selectByDynamicSymbol" resultMap="UserResultMap"> SELECT id, name, age, status, create_time FROM user WHERE age ${symbol} #{value} </select>这个写法能跑,但\${}是字符串拼接,不做任何预编译处理,如果symbol从前端直接传进来,等于给SQL注入留了个大口子。我见过有人传symbol=1 OR 1=1把整个表的数据都查出来的,这种问题属于高危漏洞,日常开发中一定要避免。
我的建议是用<choose>分支白名单处理,把符号控制在固定几个选项里:
<select id="selectByDynamicSymbol" resultMap="UserResultMap"> SELECT id, name, age, status, create_time FROM user <where> <choose> <when test="symbol == 'gt'"> AND age <![CDATA[ > ]]> #{value} </when> <when test="symbol == 'lt'"> AND age <![CDATA[ < ]]> #{value} </when> <when test="symbol == 'eq'"> AND age = #{value} </when> </choose> </where> </select>这样前端只能传gt、lt、eq这几个固定值,映射到固定的SQL片段,既满足需求又没有注入风险。如果前端想直接传符号,后端也要做严格校验,把符号限定在白名单里再拼接,绝对不要裸拼参数。
4.5 关于SQL日志排查比较符号问题的技巧
最后分享一个排查技能。当mapper.xml里的SQL执行结果不对时,先别急着改XML,把MyBatis生成的SQL打印出来看。常见的做法是在application.yml里配置:
mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl这样MyBatis会在控制台打印完整SQL和参数列表。你会发现,XML里写的>到了SQL日志里已经变成了>,<![CDATA[ > ]]>也已经还原成了>,因为MyBatis拿到的是XML解析后的真实内容。通过对比打印出的SQL和预期SQL,基本能快速定位问题出在XML解析阶段还是SQL执行阶段。这一步非常关键,很多人排查方向从一开始就错了,用日志定位可以少走很多弯路。
这几年代码写下来,我对mapper.xml里比较符号的处理形成了一套自己的习惯:单个符号、SQL不长,用转义;一段逻辑比较符号多,用CDATA;涉及动态标签,动态标签放外面,符号用转义或者CDATA包一小段。其实不管哪种写法,本质都是让XML解析器别把比较符号当标签处理,理解了这个原理,遇到再奇怪的报错也能顺着根因排查下去。希望这篇从原理到实操再到排错的总结,能帮你把这个问题一次解决干净。