news 2026/10/15 23:50:01

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis两大痛点:XML特殊符号转义与批量写入性能优化

做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里有五个需要特殊处理的字符,对应五个预定义实体:

原始字符转义写法使用场景
<&lt;小于号、动态条件判断
>&gt;大于号(文本中通常可写可不写,但推荐统一转义)
&&amp;AND、位运算、函数名里的&
'&apos;属性值中出现的单引号
"&quot;属性值中出现的双引号

实际写SQL时,最常见的转义需求就是小于号和小于等于号。比如按时间范围查询:

<select id="selectByTimeRange" resultType="com.example.model.Order"> SELECT * FROM t_order WHERE create_time &lt;= #{endTime} AND create_time &gt;= #{startTime} </select>

这里我把<=转成&lt;=,把>=转成&gt;=。虽然>在XML文本节点里不转义也能过,但统一转义可以少记很多规则,代码评审时也不用纠结。

还有一个容易忽视的地方:XML属性里也不能出现裸的<。比如在<if test="age < 18">这种写法里,<同样会让XML解析失败。解决办法是把test属性里的<也转义成&lt;,或者更推荐用OGNL支持的别名写法:

<if test="age &lt; 18"> AND age &lt; 18 </if> <!-- 等价写法:ognl内置运算符 --> <if test="age lt 18"> AND age &lt; 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多值VALUES1次单批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里裸写了<或&转义成&lt;/&amp;,或用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属性里裸<用&lt;或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 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 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三项技术从不同层面解决了同一个问题。如果你也正在做批量导入模块,建议先把批次大小和模糊搜索转义做成公共工具类,这样后来的人不必在同样的坑里再爬一次。最后再提醒一句:优化永远先看数据,压测对比之后再改,别凭感觉调参数。

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

C语言OJ字符统计题三次WA复盘:输入输出边界与fgets陷阱全解析

很多练C语言的同学都绕不过某高校SSE在线练习平台这道坎&#xff0c;尤其是C语言部分的编程练习38&#xff0c;江湖人称“看起来五分钟能写完&#xff0c;提交三次不一定过”。题目本身一句话&#xff1a;输入一行字符&#xff0c;分别统计英文字母、数字、空格和其他字符的个数…

作者头像 李华
网站建设 2026/10/15 23:47:03

SpringBoot+Vue产业园区智慧公寓管理系统架构与实践解析

搞产业园区智慧公寓管理系统这套源码的人&#xff0c;十个里有八个是冲着SpringBootVue这个组合来的&#xff0c;但真正把项目跑起来之后&#xff0c;能把它当成“能用的产品”去改的&#xff0c;可能连一半都没有。这倒不是代码多难写&#xff0c;而是这类系统跟普通出租屋管理…

作者头像 李华
网站建设 2026/10/15 23:47:02

PyTorch深度学习实战:从动态图原理到生产部署的避坑指南

1. 为什么“pytorch”这个标题值得单独拎出来聊如果你在技术社区里闲逛&#xff0c;看到有人只甩出一个词——“pytorch”&#xff0c;然后底下跟了一堆讨论&#xff0c;千万别觉得这是水帖。这个词背后藏着的&#xff0c;是一整套从研究原型到生产部署的完整技术栈&#xff0c…

作者头像 李华
网站建设 2026/10/15 23:46:52

CCNA必学:交换机MAC地址静态绑定与端口安全实战指南

1. 从一个真实故障说起&#xff1a;为什么MAC地址绑定不是选修课刚入行那会儿&#xff0c;我在一个中小型企业的网络改造项目里踩过一个经典的坑。当时办公区一台接入层交换机下面挂了二十多台设备&#xff0c;某天开始间歇性断网&#xff0c;用户反馈时好时坏&#xff0c;重启…

作者头像 李华
网站建设 2026/10/15 23:46:30

安全帽检测实战:YOLOv4场景化优化与工业部署指南

1. 项目概述&#xff1a;为什么安全帽检测不是“调个YOLO就能用”的事安全帽佩戴检测&#xff0c;听起来就是个典型的工业视觉入门题——拿YOLOv4跑个检测&#xff0c;标注几百张图&#xff0c;训上几小时&#xff0c;模型输出个框加个标签&#xff0c;完事。但我在某制造园区做…

作者头像 李华
网站建设 2026/10/15 23:46:25

Wiener随机过程与Matlab在锂电池剩余寿命预测中的工程实践

做电池健康管理这些年&#xff0c;我接触过不少寿命预测模型&#xff0c;最后真正落在工程上顺手、可解释、还能给出置信区间的方案&#xff0c;反而是最早接触的Wiener随机过程模型。最近把整套流程用Matlab重新整理了一遍&#xff0c;配合锂电池容量衰减数据做剩余寿命&#…

作者头像 李华