MyBatis两大痛点:XML特殊符号转义与批量写入性能优化

发布时间:2026/10/15 23:50:15
MyBatis两大痛点:XML特殊符号转义与批量写入性能优化
做MyBatis开发这几年最常见的报错其实不在复杂SQL的业务逻辑里而在一个不起眼的细节XML里写了个小于号整个Mapper就编译不通过最拖慢系统性能的也往往不是慢查询而是一层for循环里反复执行单条insert。这篇就专门聊MyBatis实战里的两个高频痛点特殊符号处理和高效批量操作。前者解决XML转义、like模糊查询搜不准、动态SQL里符号解析报错的问题后者解决批量插入、批量更新的性能瓶颈从foreach拼接到JDBC Batch再到驱动参数调优我会把原理、实操和踩过的坑一起说透。适合正在维护老项目、被XML报错折磨的开发者也适合准备把单条插入改造成批量写入的同学参考。1. 特殊符号处理先解决XML里那些看不见的坑1.1 XML转义为什么一个小于号能毁掉整个MapperMyBatis的Mapper文件本质是XML文档。XML解析器看到会认为是一个标签的开始如果你在SQL文本里直接写age 18解析器会试图把 18当作标签名去解析结果自然是报错。常见的报错长这样org.xml.sax.SAXParseException: The content of elements must consist of well-formed character data or markup这句报错翻译过来是元素内容必须由格式良好的字符数据或标记组成。很多人第一次见到它直接懵了以为SQL写错了其实只是XML转义的问题。XML里有五个需要特殊处理的字符对应五个预定义实体原始字符转义写法使用场景lt;小于号、动态条件判断gt;大于号文本中通常可写可不写但推荐统一转义amp;AND、位运算、函数名里的apos;属性值中出现的单引号quot;属性值中出现的双引号实际写SQL时最常见的转义需求就是小于号和小于等于号。比如按时间范围查询select idselectByTimeRange resultTypecom.example.model.Order SELECT * FROM t_order WHERE create_time lt; #{endTime} AND create_time gt; #{startTime} /select这里我把转成lt;把转成gt;。虽然在XML文本节点里不转义也能过但统一转义可以少记很多规则代码评审时也不用纠结。还有一个容易忽视的地方XML属性里也不能出现裸的。比如在if testage 18这种写法里同样会让XML解析失败。解决办法是把test属性里的也转义成lt;或者更推荐用OGNL支持的别名写法if testage lt; 18 AND age lt; 18 /if !-- 等价写法ognl内置运算符 -- if testage lt 18 AND age lt; 18 /iflt是OGNL里的“小于”运算符对应还有gt、lte、gte、eq、neq。这个写法在XML属性里完全不需要转义少踩很多坑。1.2 CDATA的用法与边界别把整个SQL包进去很多人的习惯是遇到特殊符号就上CDATA比如把整段SQL塞进![CDATA[ ]]里。这样做确实能绕开转义问题但会引入一个新的、更隐蔽的坑CDATA块内部的内容MyBatis不会再解析其中的动态SQL标签。什么意思看这段代码select idsearchUsers resultTypecom.example.model.User ![CDATA[ SELECT * FROM t_user WHERE age #{minAge} if testname ! null AND name LIKE #{name} /if ]] /selectXML解析器把CDATA里的一切都当作纯文本if标签不会被识别为MyBatis的动态标签而是作为普通文本拼到SQL里执行时直接报语法错误。所以CDATA的正确用法是只包裹有特殊字符的那一小段表达式而不是包整条SQL。select idsearchUsers resultTypecom.example.model.User SELECT * FROM t_user WHERE age ![CDATA[ ]] #{minAge} if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if /select这才是CDATA的正确打开方式让需要动态解析的标签留在CDATA外面只把比较运算符或特殊函数包起来。另一个CDATA的经典坑是内容里出现了]]序列。CDATA以]]结尾如果业务数据或SQL里恰好包含这三个字符XML解析器会提前认为CDATA结束了导致解析失败。遇到这种情况可以把]]拆开写]]]]![CDATA[。这个技巧平时用不到但一旦遇到就会卡很久我在一次生成动态报表SQL时就被坑过。1.3 模糊搜索里的%和_为什么全表数据都被查出来了like模糊查询是特殊符号处理里最容易被忽视的场景。假设搜索框里用户输入了一个下划线_你的SQL长这样select idsearchUsers resultTypecom.example.model.User SELECT * FROM t_user WHERE name LIKE %${keyword}% /select用户输入_时解析出来的SQL是SELECT * FROM t_user WHERE name LIKE %_%在SQL的LIKE语句里%匹配任意多个字符_匹配任意一个字符。%_%的意思就是只要name字段非空全部匹配。于是你把全表数据都查出来了还带了SQL注入风险。这里的误区有两层。第一层完全不建议用${keyword}拼接模糊查询关键字既容易注入又有通配符解析问题应该用#{keyword}配合拼接函数。第二层如果业务要求用户输入的%和_必须当作普通字符处理光靠#{keyword}还不够需要用ESCAPE指定转义字符。一个稳妥的写法是两层处理。先在Java层做转义public static String escapeLike(String raw) { if (raw null) { return null; } return raw.trim() .replace(/, //) .replace(%, /%) .replace(_, /_); }这里我用/做ESCAPE转义符而不是常见的\主要原因是Java、XML、SQL三层转义下\的写法非常容易出错/干净利落。再在Mapper XML里配套select idsearchUsers resultTypecom.example.model.User SELECT * FROM t_user where if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) ESCAPE / /if /where /select这样用户输入100%你的条件就变成name LIKE %100/% % ESCAPE /%被当成普通字符只有前后手动拼接的两个%具备通配语义。还有一个细节如果你的数据库是OracleCONCAT函数只支持两个参数多字段拼接就要用||。考虑到多数据库兼容用bind标签更省心bind namenamePattern value% keyword % / SELECT * FROM t_user WHERE name LIKE #{namePattern} ESCAPE /bind把拼接逻辑放在MyBatis的OGNL表达式里由MyBatis根据方言生成跨库时少很多麻烦。1.4 排序字段和动态SQL里隐藏的“符号安全”特殊符号处理还有一个延伸问题就是${}的使用边界。${}会直接把字符串拼进SQL不是预编译绑定所以凡是用户可控的内容都尽量不要出现在${}里。但排序场景比较特殊因为ORDER BY后面不能放占位符列名必须拼进SQL。我见过很多项目图省事直接写select ... SELECT * FROM t_user ORDER BY ${sortField} ${sortOrder} /select当前端传一个sortFieldid;DROP TABLE t_user或者name%2C(select...之类的值时后果就很严重了。解决思路不是放弃动态排序而是加白名单校验String sortField request.getSortField(); if (!sortField.matches(^[A-Za-z0-9_]$)) { throw new IllegalArgumentException(非法排序字段); } String sortOrder desc.equalsIgnoreCase(request.getSortOrder()) ? DESC : ASC;排序方向的枚举映射做成强制白名单排序字段用正则限制只能由字母、数字、下划线组成。再做一层列名映射表把对外参数映射到真实列名双保险。这里的核心原则是能走#{}预编译的内容绝不走${}必须走${}的内容必须先白名单校验。同样一个符号问题性质完全不一样。2. 高效批量操作foreach只是第一步2.1 改写INSER从单条循环到多值插入单条insert是最容易写、也最容易埋下性能问题的写法。看这段代码for (User user : userList) { userMapper.insert(user); }如果userList有5000条就意味着5000次网络往返。每次往返还要经历一次SQL解析、事务提交、日志落盘耗时呈线性增长。数据量到一万条十几秒都打不住这还是在本地开发环境。最基本的优化是用foreach拼接一条多值INSERTinsert idbatchInsertUsers parameterTypelist useGeneratedKeystrue keyPropertyid INSERT INTO t_user(name, age, org_id) VALUES foreach collectionlist itemuser separator, (#{user.name}, #{user.age}, #{user.orgId}) /foreach /insert对应Service层Transactional public void importUsers(ListUser users) { if (users null || users.isEmpty()) { return; } userMapper.batchInsertUsers(users); }这样5000条数据只发一条SQL网络往返从5000次降为1次性能提升非常明显。注意几个细节。第一collectionlist的写法依赖Mapper方法的参数是一个未加注解的List如果用了Param(users) ListUser usersforeach里要写成collectionusers。第二parameterTypelist在低版本MyBatis里可以省但写上更明确。第三空集合不能直接传否则会生成INSERT INTO t_user VALUES这种语法不完整的SQL所以要在service层提前判空。另一种业界常见的写法是UNION ALLinsert idbatchInsertUsersByUnionAll INSERT INTO t_user(name, age, org_id) foreach collectionlist itemuser separatorUNION ALL SELECT #{user.name}, #{user.age}, #{user.orgId} /foreach /insertOracle早期版本不支持多值VALUESUNION ALL是兼容性更好的方案。但MySQL场景下多值VALUES写法更直观而且对自增主键回填的支持更友好所以没有特殊兼容需求时我优先推荐多值写法。2.2 ExecutorType.BATCH让JDBC的Batch机制跑起来foreach多值写法的瓶颈在于SQL长度。一条SQL插5000行字段多的情况下SQL文本可能超过几十KB超出数据库max_allowed_packet直接报错。所以大厂规范里一般都会强调“foreach分批插入”每批500到1000条。但很多人在分批之后发现性能离预期还是差一截。这时候就该考虑JDBC原生Batch机制了。JDBC本身就有批量能力核心三件套是prepareStatement、addBatch、executeBatchtry (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement( INSERT INTO t_user(name, age, org_id) VALUES (?, ?, ?) )) { for (User user : users) { ps.setString(1, user.getName()); ps.setInt(2, user.getAge()); ps.setLong(3, user.getOrgId()); ps.addBatch(); } ps.executeBatch(); }这段代码把多条INSERT累积在PreparedStatement内部最后通过executeBatch()一次性发给数据库执行。每次addBatch只是往内存里的批次缓冲区塞数据不触发网络IO只有executeBatch才真正跑一次网络往返。MyBatis把这种机制封装成了ExecutorType.BATCH。打开SqlSession时指定BATCH模式后续Mapper的insert、update、delete方法会进入批处理try (SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserMapper mapper session.getMapper(UserMapper.class); for (User user : users) { mapper.insert(user); } session.commit(); }这段代码和单条循环看起来很像但底层已经在攒批。commit时MyBatis会执行flushStatements()把积攒的批一次性发给数据库。这里有个非常隐蔽的坑在批处理循环里执行select查询MyBatis会先触发一次flushStatements()把当前积压的批先执行掉再执行查询。因为如果批里的INSERT还没真的发出去SELECT是查不到这些数据的这个逻辑上合理但会破坏你预期的攒批效果。我之前在某报表导出模块里批循环里顺手查了一条关联配置结果批处理被拆得七零八落性能还不如foreach多值。2.3 rewriteBatchedStatements一个参数拉满性能用ExecutorType.BATCH已经能攒批了但在MySQL场景下缺一个关键参数会让性能还是上不去这个参数就是rewriteBatchedStatements。MySQL的JDBC驱动默认情况下即使你用executeBatch()驱动内部可能是把每一条SQL单独发给服务器的并没有真正利用MySQL的批量写入能力。开启下面这个参数后驱动会把连续同构的INSERT语句重写成多值INSERT把连续同构的UPDATE/DELETE重写成批量模式jdbc:mysql://localhost:3306/app?rewriteBatchedStatementstrue一句话对比开启前addBatch 1000次executeBatch 1次但驱动默认仍然发送了1000条单条INSERT开启后驱动把1000条合成一条大且长的多值INSERT网络往返骤降。这也是为什么很多人反馈“明明用了ExecutorType.BATCH性能还是没提升”很可能就是漏了这个参数。配合前面的批次设计我建议的实践方案是private static final int BATCH_SIZE 500; public void batchInsertWithJdbcBatch(ListUser users) { try (SqlSession session sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserMapper mapper session.getMapper(UserMapper.class); for (int i 0; i users.size(); i) { mapper.insert(users.get(i)); if ((i 1) % BATCH_SIZE 0 || i users.size() - 1) { session.flushStatements(); } } session.commit(); } }flushStatements()手动触发批发送避免单个批次过大。到了事务末尾再commit()统一提交保证一批数据要么全成功要么全回滚。2.4 批量更新从逐条UPDATE到CASE WHEN批量更新比批量插入更麻烦因为MySQL的UPDATE没有“多值VALUES”这种天然批量写法。很多人图省事写循环单条UPDATEfor (User user : users) { userMapper.updateById(user); }结果就是N次网络往返而且每条UPDATE独占锁的时间在高并发下容易引发死锁和锁等待。在批内几十到几百条、更新字段不超过三五个的场景推荐用CASE WHEN一条SQL搞定update idbatchUpdateUsers parameterTypelist UPDATE t_user SET age CASE id foreach collectionlist itemitem WHEN #{item.id} THEN #{item.age} /foreach END, name CASE id foreach collectionlist itemitem WHEN #{item.id} THEN #{item.name} /foreach END WHERE id IN foreach collectionlist itemitem open( separator, close) #{item.id} /foreach /update这条SQL一次把整个批的多个字段更新完网络往返一次。但要注意几个坑。第一CASE当中有WHEN没有ELSE时如果某行的某个字段不需要更新会把该字段更新成NULL。比如name字段在Java对象里是nullCASE WHEN分支里THEN null等于把原name清空了。解决思路是按需拼接动态列或者更新前显式给默认值。第二如果批量更新的行数上了千生成的SQL会非常长数据库解析压力大。这时候CASE WHEN就不合适了更优方案是临时表或分多批执行。第三CASE WHEN批量更新和事务必须绑定。中间任何一条数据校验失败应该整个批次回滚而不是部分成功。Transactional(rollbackFor Exception.class) public void batchUpdate(ListUser users) { // 分批处理每批500条 for (int i 0; i users.size(); i BATCH_SIZE) { ListUser subList users.subList(i, Math.min(i BATCH_SIZE, users.size())); userMapper.batchUpdateUsers(subList); } }几百条的批量更新用CASE WHEN完全够用几千条以上就要开始考虑临时表或者批处理线程池了。3. 批量场景的性能调优参数、批次与监控3.1 三种批量写入方式怎么选把几种方式放在一张表里对比选择逻辑就清楚了写入方式网络往返次数适用数据量缺点推荐场景单条insert循环N次几十条以内性能最差、线程阻塞时间长低频零星写入foreach多值VALUES1次单批100~1000条SQL很长、受max_allowed_packet限制中小批量导入、一次性初始化ExecutorType.BATCH rewriteBatchedStatements攒批后每批1次单批500~1000条可多批需要SqlSession手动管理、循环里不能乱查select大批量导入、稳定批量写入我个人选择依据很简单1000条以下优先foreach多值代码简单直观5000条以上必须上BATCH模式频繁批量写入且追求吞吐量的就是ExecutorType.BATCHrewriteBatchedStatements的组合。需要特别提醒的是rewriteBatchedStatements只是MySQL Connector/J的一个选项不适用于其他数据库。如果是PostgreSQL、Oracle走ExecutorType.BATCH本身就有较好的批量支持不需要这个参数。3.2 批次大小怎么定max_allowed_packet与行长度估算很多人喜欢问foreach多值一次插多少条最合适这个问题不能一刀切要看你单行的平均长度和数据库max_allowed_packet配置。一条多值INSERT的SQL长度大致等于单行SQL文本长度 ≈ 列名与语法开销 单行数据内容长度 整体包大小 ≈ 单行长度 × 行数我之前遇到过一个大字段表单行包含两个VARCHAR(2000)字段加上其他字段500行的多值INSERT拼出来接近2MB而数据库max_allowed_packet默认配置比较小于是频繁出现连接中断。后来用下面这行SQL查询实例配置SHOW VARIABLES LIKE max_allowed_packet;再根据估算的单行字节数反推批次大小。如果单行平均100字节500行大约50KB非常安全如果单行包含JSON、Text字段单行可能有几KB批次就要降到100到200行。经验值普通表500到1000行一批大字段表100到200行一批宁可多分几批也别挑战包上限。还有一种情况是单批次太大导致数据库解析SQL本身就慢这个问题用explain看不出来只能通过实际压测对比。我在某个模拟项目X里测过5000条数据分别用100、500、1000、2000拆分最优批次通常在500附近超过1000后耗时反而上升因为一条超长SQL的解析、内存分配、网络传输成本都会显著上涨。3.3 批处理下的自增主键怎么回填批量插入后需要拿到自增主键这是一个高频需求。foreach多值写法只要加两个属性就行insert idbatchInsertUsers parameterTypelist useGeneratedKeystrue keyPropertyiduseGeneratedKeystrue告诉MyBatis需要JDBC返回的自增主键keyPropertyid表示把主键写回每个Java对象的id属性。执行完batchInsertUsers后循环打印user.getId()就能拿到每条记录的主键。ExecutorType.BATCH模式下如果调用的是单条insert方法每条insert入参对象在批次真正flush之后也会被回填主键。但注意回填的时机是在flushStatements之后而不是addBatch时。如果你在addBatch之后立刻读user.getId()拿到的还是null。这个顺序问题让不少同事困惑过。UNION ALL批量插入就不建议和useGeneratedKeys一起用了不同数据库对它的支持差异很大容易出现主键列不明、回填错误的问题。如果业务强依赖自增ID优先用多值VALUES写法。3.4 别让日志和监控成为批量的隐形杀手MyBatis常见的日志配置是StdOutImpl每执行一条SQL都会打印。如果你在批量写入几万条数据时开着这个日志性能会被拖垮。日志打印本身就是同步IO而且会把每条批次语句完整打印到控制台磁盘和GC都受不了。开发环境用用可以生产环境常规模式下要关掉或者用慢SQL日志来兜底。MySQL侧可以打开慢查询日志帮助定位批量瓶颈SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;批量导入出现性能异常时在MySQL里看一眼Com_insert的计数变化也能帮助判断。开启rewriteBatchedStatements后驱动会把一批INSERT重写成一条多值INSERTCom_insert的增长速度会明显下降这可以当成参数是否生效的佐证。再补充一个连接池维度的注意点。批量写入是CPU密集IO密集操作单条SQL很大时数据库解析要占不少资源连接会被长时间占用。如果连接池maxActive设置太小其他正常请求就会排队表现为接口变慢。批量导入任务尽量放在业务低峰期或者单独配置一个专用于导出的连接池。4. 高频问题排查与完整示例4.1 特殊符号类报错速查表下面这些报错是我在实际项目里见过最多、也和特殊符号处理强相关的报错特征根因处理办法SAXParseException: The content of elements must consist of well-formed...XML里裸写了或转义成lt;/amp;或用CDATA包住片段元素类型 if 必须由匹配的结束标签 /if 终止CDATA包裹了动态标签或标签不配对把动态标签移出CDATA检查标签闭合LIKE查询把全表数据都查出来%、_没转义Java层replaceSQL层加ESCAPEYou have an error in your SQL syntax提示near %xxx%${}拼接导致引号或特殊字符破坏SQL改成#{}CONCAT/bindtest属性表达式报错XML属性里裸用lt;或OGNL的lt写法排查这类问题有一个固定的步骤先用IDE打开Mapper XML做一次XML格式校验这一步能筛掉大部分转义和标签问题再逐段缩小范围把可疑SQL复制到数据库客户端执行看具体语法报错位置最后确认CDATA有没有误伤动态SQL。4.2 批量操作类问题速查表批量操作的报错和异常现象比特殊符号更隐蔽我整理成表问题现象根因处理办法PacketTooBig或连接中断、Communications link failure单批次SQL超过max_allowed_packet减小批次、估算单行长度批量插入没拿到主键UNIon ALL不支持useGeneratedKeys或BATCH模式在主键回填前读取改多值VALUESflush后再读id用了ExecutorType.BATCH还是慢没开启rewriteBatchedStatements或循环里查了select开启参数批循环里只做写操作Parameter index out of rangeforeach里item名写错占位符数量对不上检查item.name与#{}名字是否一致批量更新把某些字段变成NULLCASE WHEN没有ELSE且Java对象该字段为null按需拼接列或对空值做默认值处理Batch update returned unexpected row countMySQL的受影响行数语义和驱动预期不一致调整连接参数useAffectedRows统一确认返回行数4.3 一个串联两个主题的完整例子用户导入加模糊搜索我用一个最常见的业务场景串起这两大主题用户批量导入加上导入后的关键词模糊搜索。先建表CREATE TABLE t_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, age INT, org_id BIGINT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP );Mapper XML完整如下insert idbatchInsertUsers parameterTypelist useGeneratedKeystrue keyPropertyid INSERT INTO t_user(name, age, org_id) VALUES foreach collectionlist itemuser separator, (#{user.name}, #{user.age}, #{user.orgId}) /foreach /insert select idsearchUsers resultTypecom.example.model.User SELECT id, name, age, org_id, created_at FROM t_user where if testkeyword ! null and keyword ! AND name LIKE CONCAT(%, #{keyword}, %) ESCAPE / /if if testminAge ! null AND age ![CDATA[ ]] #{minAge} /if /where ORDER BY id DESC LIMIT #{offset}, #{limit} /selectService层做三件事转义、分批、搜索Service public class UserService { private static final int BATCH_SIZE 500; Transactional(rollbackFor Exception.class) public void importUsers(ListUser users) { if (CollectionUtils.isEmpty(users)) { return; } for (int i 0; i users.size(); i BATCH_SIZE) { ListUser subList users.subList(i, Math.min(i BATCH_SIZE, users.size())); userMapper.batchInsertUsers(subList); } } public PageResultUser search(String keyword, Integer minAge, int page, int size) { String escapedKeyword escapeLikeKeyword(keyword); int offset (page - 1) * size; ListUser list userMapper.searchUsers(escapedKeyword, minAge, offset, size); long total userMapper.countUsers(escapedKeyword, minAge); return PageResult.of(list, total); } private String escapeLikeKeyword(String raw) { if (raw null) { return null; } return raw.trim() .replace(/, //) .replace(%, /%) .replace(_, /_); } }这个例子把XML转义、CDATA、ESCAPE、foreach多值、分批插入、事务全部串在一起。实际项目里你可以直接把这段当模板改字段名复用。4.4 我总结的避坑清单把散落在各个章节里的经验汇总成清单方便你直接对照检查写Mapper XML时先过一遍XML格式校验不要等运行期报错再来找。所有比较运算符统一转义或CDATA片段包裹不要让裸出现在XML文本里。模糊搜索的关键字必须做转义否则%、_就是定时炸弹。批量插入的SQL长度一定要估算大字段表小批次普通表也不能盲目的5000条一批。ExecutorType.BATCH模式下循环里不要查select否则攒批失效。MySQL场景优先在JDBC URL里加rewriteBatchedStatementstrue。批量更新前审视CASE WHEN会不会把未覆盖字段置NULL。只要批量操作跨多条数据就一定要有事务要么全成功要么全回滚。生产环境别开详细SQL日志批量写入时日志打印会超过业务SQL本身。我个人在实际操作中的体会是特殊符号处理本质是XML规范和SQL解析规则的交叉问题把规范记牢就能省掉后期很多排查批量性能优化则始终围绕“减少网络往返”这个核心目标foreach多值、ExecutorType.BATCH、rewriteBatchedStatements三项技术从不同层面解决了同一个问题。如果你也正在做批量导入模块建议先把批次大小和模糊搜索转义做成公共工具类这样后来的人不必在同样的坑里再爬一次。最后再提醒一句优化永远先看数据压测对比之后再改别凭感觉调参数。