做MyBatis开发这几年,最常见的报错其实不在复杂SQL的业务逻辑里,而在一个不起眼的细节:XML里写了个小于号,整个Mapper就编译不通过;最拖慢系统性能的也往往不是慢查询,而是一层for循环里反复执行单条insert。这篇就专门聊MyBatis实战里的两个高频痛点:特殊符号处理和高效批量操作。前者解决XML转义、like模糊查询搜不准、动态SQL里符号解析报错的问题,后者解决批量插入、批量更新的性能瓶颈,从foreach拼接到JDBC Batch再到驱动参数调优,我会把原理、实操和踩过的坑一起说透。适合正在维护老项目、被XML报错折磨的开发者,也适合准备把单条插入改造成批量写入的同学参考。
1. 特殊符号处理:先解决XML里那些看不见的坑
1.1 XML转义:为什么一个小于号能毁掉整个Mapper
MyBatis的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里有五个需要特殊处理的字符,对应五个预定义实体:
| 原始字符 | 转义写法 | 使用场景 |
|---|---|---|
< | < | 小于号、动态条件判断 |
> | > | 大于号(文本中通常可写可不写,但推荐统一转义) |
& | & | AND、位运算、函数名里的& |
' | ' | 属性值中出现的单引号 |
" | " | 属性值中出现的双引号 |
实际写SQL时,最常见的转义需求就是小于号和小于等于号。比如按时间范围查询:
<select id="selectByTimeRange" resultType="com.example.model.Order"> SELECT * FROM t_order WHERE create_time <= #{endTime} AND create_time >= #{startTime} </select>这里我把<=转成<=,把>=转成>=。虽然>在XML文本节点里不转义也能过,但统一转义可以少记很多规则,代码评审时也不用纠结。
还有一个容易忽视的地方:XML属性里也不能出现裸的<。比如在<if test="age < 18">这种写法里,<同样会让XML解析失败。解决办法是把test属性里的<也转义成<,或者更推荐用OGNL支持的别名写法:
<if test="age < 18"> AND age < 18 </if> <!-- 等价写法:ognl内置运算符 --> <if test="age lt 18"> AND age < 18 </if>lt是OGNL里的“小于”运算符,对应还有gt、lte、gte、eq、neq。这个写法在XML属性里完全不需要转义,少踩很多坑。
1.2 CDATA的用法与边界:别把整个SQL包进去
很多人的习惯是遇到特殊符号就上CDATA,比如把整段SQL塞进<![CDATA[ ]]>里。这样做确实能绕开转义问题,但会引入一个新的、更隐蔽的坑:CDATA块内部的内容,MyBatis不会再解析其中的动态SQL标签。
什么意思?看这段代码:
<select id="searchUsers" resultType="com.example.model.User"> <![CDATA[ SELECT * FROM t_user WHERE age > #{minAge} <if test="name != null"> AND name LIKE #{name} </if> ]]> </select>XML解析器把CDATA里的一切都当作纯文本,<if>标签不会被识别为MyBatis的动态标签,而是作为普通文本拼到SQL里,执行时直接报语法错误。所以CDATA的正确用法是:只包裹有特殊字符的那一小段表达式,而不是包整条SQL。
<select id="searchUsers" resultType="com.example.model.User"> SELECT * FROM t_user WHERE age <![CDATA[ > ]]> #{minAge} <if test="name != null and name != ''"> AND name LIKE CONCAT('%', #{name}, '%') </if> </select>这才是CDATA的正确打开方式:让需要动态解析的标签留在CDATA外面,只把比较运算符或特殊函数包起来。
另一个CDATA的经典坑是内容里出现了]]>序列。CDATA以]]>结尾,如果业务数据或SQL里恰好包含这三个字符,XML解析器会提前认为CDATA结束了,导致解析失败。遇到这种情况,可以把]]>拆开写:]]]]><![CDATA[>。这个技巧平时用不到,但一旦遇到就会卡很久,我在一次生成动态报表SQL时就被坑过。
1.3 模糊搜索里的%和_:为什么全表数据都被查出来了
like模糊查询是特殊符号处理里最容易被忽视的场景。假设搜索框里用户输入了一个下划线_,你的SQL长这样:
<select id="searchUsers" resultType="com.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 id="searchUsers" resultType="com.example.model.User"> SELECT * FROM t_user <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') ESCAPE '/' </if> </where> </select>这样用户输入100%,你的条件就变成name LIKE '%100/% %' ESCAPE '/',%被当成普通字符,只有前后手动拼接的两个%具备通配语义。
还有一个细节:如果你的数据库是Oracle,CONCAT函数只支持两个参数,多字段拼接就要用||。考虑到多数据库兼容,用<bind>标签更省心:
<bind name="namePattern" 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>当前端传一个sortField=id;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拼接一条多值INSERT:
<insert id="batchInsertUsers" parameterType="list" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user(name, age, org_id) VALUES <foreach collection="list" item="user" separator=","> (#{user.name}, #{user.age}, #{user.orgId}) </foreach> </insert>对应Service层:
@Transactional public void importUsers(List<User> users) { if (users == null || users.isEmpty()) { return; } userMapper.batchInsertUsers(users); }这样5000条数据只发一条SQL,网络往返从5000次降为1次,性能提升非常明显。
注意几个细节。第一,collection="list"的写法依赖Mapper方法的参数是一个未加注解的List;如果用了@Param("users") List<User> users,foreach里要写成collection="users"。第二,parameterType="list"在低版本MyBatis里可以省,但写上更明确。第三,空集合不能直接传,否则会生成INSERT INTO t_user VALUES这种语法不完整的SQL,所以要在service层提前判空。
另一种业界常见的写法是UNION ALL:
<insert id="batchInsertUsersByUnionAll"> INSERT INTO t_user(name, age, org_id) <foreach collection="list" item="user" separator="UNION ALL"> SELECT #{user.name}, #{user.age}, #{user.orgId} </foreach> </insert>Oracle早期版本不支持多值VALUES,UNION 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、executeBatch:
try (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?rewriteBatchedStatements=true一句话对比:开启前,addBatch 1000次,executeBatch 1次,但驱动默认仍然发送了1000条单条INSERT;开启后,驱动把1000条合成一条大且长的多值INSERT,网络往返骤降。这也是为什么很多人反馈“明明用了ExecutorType.BATCH,性能还是没提升”,很可能就是漏了这个参数。
配合前面的批次设计,我建议的实践方案是:
private static final int BATCH_SIZE = 500; public void batchInsertWithJdbcBatch(List<User> 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”这种天然批量写法。很多人图省事写循环单条UPDATE:
for (User user : users) { userMapper.updateById(user); }结果就是N次网络往返,而且每条UPDATE独占锁的时间,在高并发下容易引发死锁和锁等待。
在批内几十到几百条、更新字段不超过三五个的场景,推荐用CASE WHEN一条SQL搞定:
<update id="batchUpdateUsers" parameterType="list"> UPDATE t_user SET age = CASE id <foreach collection="list" item="item"> WHEN #{item.id} THEN #{item.age} </foreach> END, name = CASE id <foreach collection="list" item="item"> WHEN #{item.id} THEN #{item.name} </foreach> END WHERE id IN <foreach collection="list" item="item" open="(" separator="," close=")"> #{item.id} </foreach> </update>这条SQL一次把整个批的多个字段更新完,网络往返一次。但要注意几个坑。
第一,CASE当中有WHEN没有ELSE时,如果某行的某个字段不需要更新,会把该字段更新成NULL。比如name字段在Java对象里是null,CASE WHEN分支里THEN null,等于把原name清空了。解决思路是按需拼接动态列,或者更新前显式给默认值。
第二,如果批量更新的行数上了千,生成的SQL会非常长,数据库解析压力大。这时候CASE WHEN就不合适了,更优方案是临时表或分多批执行。
第三,CASE WHEN批量更新和事务必须绑定。中间任何一条数据校验失败,应该整个批次回滚,而不是部分成功。
@Transactional(rollbackFor = Exception.class) public void batchUpdate(List<User> users) { // 分批处理:每批500条 for (int i = 0; i < users.size(); i += BATCH_SIZE) { List<User> subList = users.subList(i, Math.min(i + BATCH_SIZE, users.size())); userMapper.batchUpdateUsers(subList); } }几百条的批量更新,用CASE WHEN完全够用;几千条以上,就要开始考虑临时表或者批处理线程池了。
3. 批量场景的性能调优:参数、批次与监控
3.1 三种批量写入方式怎么选
把几种方式放在一张表里对比,选择逻辑就清楚了:
| 写入方式 | 网络往返次数 | 适用数据量 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| 单条insert循环 | N次 | 几十条以内 | 性能最差、线程阻塞时间长 | 低频零星写入 |
| foreach多值VALUES | 1次 | 单批100~1000条 | SQL很长、受max_allowed_packet限制 | 中小批量导入、一次性初始化 |
| ExecutorType.BATCH + rewriteBatchedStatements | 攒批后每批1次 | 单批500~1000条,可多批 | 需要SqlSession手动管理、循环里不能乱查select | 大批量导入、稳定批量写入 |
我个人选择依据很简单:1000条以下优先foreach多值,代码简单直观;5000条以上必须上BATCH模式;频繁批量写入且追求吞吐量的,就是ExecutorType.BATCH+rewriteBatchedStatements的组合。
需要特别提醒的是,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 id="batchInsertUsers" parameterType="list" useGeneratedKeys="true" keyProperty="id">useGeneratedKeys=true告诉MyBatis需要JDBC返回的自增主键,keyProperty="id"表示把主键写回每个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重写成一条多值INSERT,Com_insert的增长速度会明显下降,这可以当成参数是否生效的佐证。
再补充一个连接池维度的注意点。批量写入是CPU密集+IO密集操作,单条SQL很大时,数据库解析要占不少资源,连接会被长时间占用。如果连接池maxActive设置太小,其他正常请求就会排队,表现为接口变慢。批量导入任务尽量放在业务低峰期,或者单独配置一个专用于导出的连接池。
4. 高频问题排查与完整示例
4.1 特殊符号类报错速查表
下面这些报错是我在实际项目里见过最多、也和特殊符号处理强相关的:
| 报错特征 | 根因 | 处理办法 |
|---|---|---|
SAXParseException: The content of elements must consist of well-formed... | XML里裸写了<或& | 转义成</&,或用CDATA包住片段 |
元素类型 "if" 必须由匹配的结束标签 </if> 终止 | CDATA包裹了动态标签,或标签不配对 | 把动态标签移出CDATA,检查标签闭合 |
LIKE查询把全表数据都查出来 | %、_没转义 | Java层replace,SQL层加ESCAPE |
You have an error in your SQL syntax,提示near '%xxx%' | ${}拼接导致引号或特殊字符破坏SQL | 改成#{}+CONCAT/bind |
| test属性表达式报错 | XML属性里裸< | 用<或OGNL的lt写法 |
排查这类问题有一个固定的步骤:先用IDE打开Mapper XML做一次XML格式校验,这一步能筛掉大部分转义和标签问题;再逐段缩小范围,把可疑SQL复制到数据库客户端执行,看具体语法报错位置;最后确认CDATA有没有误伤动态SQL。
4.2 批量操作类问题速查表
批量操作的报错和异常现象比特殊符号更隐蔽,我整理成表:
| 问题现象 | 根因 | 处理办法 |
|---|---|---|
PacketTooBig或连接中断、Communications link failure | 单批次SQL超过max_allowed_packet | 减小批次、估算单行长度 |
| 批量插入没拿到主键 | UNIon ALL不支持useGeneratedKeys,或BATCH模式在主键回填前读取 | 改多值VALUES,flush后再读id |
| 用了ExecutorType.BATCH还是慢 | 没开启rewriteBatchedStatements,或循环里查了select | 开启参数,批循环里只做写操作 |
Parameter index out of range | foreach里item名写错,占位符数量对不上 | 检查item.name与#{}名字是否一致 |
| 批量更新把某些字段变成NULL | CASE WHEN没有ELSE,且Java对象该字段为null | 按需拼接列,或对空值做默认值处理 |
Batch update returned unexpected row count | MySQL的受影响行数语义和驱动预期不一致 | 调整连接参数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 id="batchInsertUsers" parameterType="list" useGeneratedKeys="true" keyProperty="id"> INSERT INTO t_user(name, age, org_id) VALUES <foreach collection="list" item="user" separator=","> (#{user.name}, #{user.age}, #{user.orgId}) </foreach> </insert> <select id="searchUsers" resultType="com.example.model.User"> SELECT id, name, age, org_id, created_at FROM t_user <where> <if test="keyword != null and keyword != ''"> AND name LIKE CONCAT('%', #{keyword}, '%') ESCAPE '/' </if> <if test="minAge != null"> AND age <![CDATA[ > ]]> #{minAge} </if> </where> ORDER BY id DESC LIMIT #{offset}, #{limit} </select>Service层做三件事:转义、分批、搜索:
@Service public class UserService { private static final int BATCH_SIZE = 500; @Transactional(rollbackFor = Exception.class) public void importUsers(List<User> users) { if (CollectionUtils.isEmpty(users)) { return; } for (int i = 0; i < users.size(); i += BATCH_SIZE) { List<User> subList = users.subList(i, Math.min(i + BATCH_SIZE, users.size())); userMapper.batchInsertUsers(subList); } } public PageResult<User> search(String keyword, Integer minAge, int page, int size) { String escapedKeyword = escapeLikeKeyword(keyword); int offset = (page - 1) * size; List<User> 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里加rewriteBatchedStatements=true。
- 批量更新前审视CASE WHEN会不会把未覆盖字段置NULL。
- 只要批量操作跨多条数据,就一定要有事务,要么全成功要么全回滚。
- 生产环境别开详细SQL日志,批量写入时日志打印会超过业务SQL本身。
我个人在实际操作中的体会是:特殊符号处理本质是XML规范和SQL解析规则的交叉问题,把规范记牢就能省掉后期很多排查;批量性能优化则始终围绕“减少网络往返”这个核心目标,foreach多值、ExecutorType.BATCH、rewriteBatchedStatements三项技术从不同层面解决了同一个问题。如果你也正在做批量导入模块,建议先把批次大小和模糊搜索转义做成公共工具类,这样后来的人不必在同样的坑里再爬一次。最后再提醒一句:优化永远先看数据,压测对比之后再改,别凭感觉调参数。