使用Spring JdbcTemplate batchUpdate结合SQL Server MERGE处理万级数据时性能低下的优化方案咨询
我完全理解你现在的困扰——明明用了JdbcTemplate.batchUpdate和SQL Server的MERGE做批量upsert,但数据量到万级后性能直接跳水,1.4万条要29秒、2.2万条要80秒,这个速度确实不符合预期。结合你的表结构、代码实现和SQL Server环境,我整理了几个针对性的优化方向,你可以逐一尝试:
一、重构MERGE语句,实现真正的批量源表匹配
你当前的实现本质是每条数据对应一次MERGE执行(虽然用了batchUpdate,但驱动会逐条发送带单条数据的MERGE语句),这会导致数据库反复解析执行计划、建立连接上下文,开销极大。
优化思路:把整个批次的所有数据一次性放入MERGE的USING子句中,让数据库单次执行MERGE完成整个批次的upsert,而非执行1000次独立的MERGE。
具体代码实现:
public void batchUpsert(List<FundReturn> returns) { int batchSize = 1000; for (int i = 0; i < returns.size(); i += batchSize) { List<FundReturn> batch = returns.subList(i, Math.min(i + batchSize, returns.size())); // 生成对应批次数量的VALUES占位符组 String valuesClause = String.join(", ", Collections.nCopies(batch.size(), "(?, ?, ?, ?, ?, ?, ?, ?, ?)")); String upsertSql = String.format(""" MERGE INTO fund_return AS t USING (VALUES %s) AS s (fund_code, currency, data_date, period, holiday_type, fund_return, deposit_return_ann, fund_benchmark_return, modif_time) ON t.fund_code = s.fund_code AND t.currency = s.currency AND t.data_date = s.data_date AND t.period = s.period AND t.holiday_type = s.holiday_type WHEN MATCHED THEN UPDATE SET fund_return = s.fund_return, deposit_return_ann = s.deposit_return_ann, fund_benchmark_return = s.fund_benchmark_return, modif_time = s.modif_time WHEN NOT MATCHED THEN INSERT (fund_code, currency, data_date, period, holiday_type, fund_return, deposit_return_ann, fund_benchmark_return, modif_time) VALUES (s.fund_code, s.currency, s.data_date, s.period, s.holiday_type, s.fund_return, s.deposit_return_ann, s.fund_benchmark_return, s.modif_time); """, valuesClause); // 拼接批次内所有参数到数组 List<Object> params = new ArrayList<>(); long currentTime = System.currentTimeMillis(); for (FundReturn r : batch) { params.add(r.getFundCode()); params.add(r.getCurrency()); params.add(java.sql.Date.valueOf(r.getDataDate())); params.add(r.getPeriod()); params.add(r.getHolidayType()); params.add(r.getFundReturn()); params.add(r.getDepositReturnAnn()); params.add(r.getFundBenchmarkReturn()); params.add(new Timestamp(currentTime)); } jdbcTemplate.update(upsertSql, params.toArray()); } }
效果说明:每个批次只执行1次MERGE,而非1000次,大幅减少数据库的执行计划解析和上下文切换开销,我之前处理类似场景时,这样改完性能直接提升了5-10倍。
二、优化JDBC驱动参数,开启真正的批量传输
先修正你JDBC URL的拼写错误(trustServerCerftificate→trustServerCertificate),再添加几个关键参数确保批量请求被高效传输:
修改后的DB_URL:
jdbc:sqlserver://IP_ADDRESS:PORT;databaseName=dbName;trustServerCertificate=true;sendStringParametersAsUnicode=false;batchPerformanceOptimized=true;rewriteBatchedStatements=true
batchPerformanceOptimized=true:开启SQL Server驱动的批量性能优化,驱动会把批量参数打包发送,而非逐条发送。rewriteBatchedStatements=true:驱动会尝试重写批量语句,合并成更高效的格式(对MERGE批量场景也有帮助)。
三、替换MERGE为“先批量更新+后批量插入”的组合
虽然MERGE看起来简洁,但SQL Server对MERGE的批量优化有时不如分开的UPDATE和INSERT,尤其是当匹配率很高或很低的时候。
优化思路:
- 把批次数据写入临时表并创建唯一索引。
- 批量更新目标表中与临时表匹配的数据。
- 批量插入临时表中不存在于目标表的数据。
代码示例:
public void batchUpsert(List<FundReturn> returns) { int batchSize = 1000; for (int i = 0; i < returns.size(); i += batchSize) { List<FundReturn> batch = returns.subList(i, Math.min(i + batchSize, returns.size())); // 1. 创建临时表并插入批次数据 jdbcTemplate.execute(""" CREATE TABLE #TempFundReturn ( fund_code varchar(8) NOT NULL, currency varchar(3) NOT NULL, data_date date NOT NULL, period varchar(16) NOT NULL, holiday_type varchar(32) NOT NULL, fund_return numeric(30,15) NULL, deposit_return_ann numeric(30,15) NULL, fund_benchmark_return numeric(30,15) NULL, modif_time datetime NULL, CONSTRAINT uc_temp_unique UNIQUE CLUSTERED (fund_code, currency, data_date, period, holiday_type) ) """); // 批量插入临时表 String insertTempSql = "INSERT INTO #TempFundReturn VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; long currentTime = System.currentTimeMillis(); jdbcTemplate.batchUpdate(insertTempSql, batch, batchSize, (ps, r) -> { ps.setString(1, r.getFundCode()); ps.setString(2, r.getCurrency()); ps.setDate(3, java.sql.Date.valueOf(r.getDataDate())); ps.setString(4, r.getPeriod()); ps.setString(5, r.getHolidayType()); ps.setBigDecimal(6, r.getFundReturn()); ps.setBigDecimal(7, r.getDepositReturnAnn()); ps.setBigDecimal(8, r.getFundBenchmarkReturn()); ps.setTimestamp(9, new Timestamp(currentTime)); }); // 2. 批量更新匹配数据 jdbcTemplate.execute(""" UPDATE t SET t.fund_return = s.fund_return, t.deposit_return_ann = s.deposit_return_ann, t.fund_benchmark_return = s.fund_benchmark_return, t.modif_time = s.modif_time FROM fund_return t JOIN #TempFundReturn s ON t.fund_code = s.fund_code AND t.currency = s.currency AND t.data_date = s.data_date AND t.period = s.period AND t.holiday_type = s.holiday_type """); // 3. 批量插入不匹配数据 jdbcTemplate.execute(""" INSERT INTO fund_return (fund_code, currency, data_date, period, holiday_type, fund_return, deposit_return_ann, fund_benchmark_return, modif_time) SELECT s.fund_code, s.currency, s.data_date, s.period, s.holiday_type, s.fund_return, s.deposit_return_ann, s.fund_benchmark_return, s.modif_time FROM #TempFundReturn s WHERE NOT EXISTS ( SELECT 1 FROM fund_return t WHERE t.fund_code = s.fund_code AND t.currency = s.currency AND t.data_date = s.data_date AND t.period = s.period AND t.holiday_type = s.holiday_type ) """); // 销毁临时表 jdbcTemplate.execute("DROP TABLE #TempFundReturn"); } }
效果说明:临时表的聚集索引让JOIN和EXISTS查询非常高效,SQL Server对批量UPDATE/INSERT的执行计划优化比MERGE更成熟,数据量越大优势越明显。
四、数据库层面的性能调优
- 开启快照隔离:避免批量操作时的锁阻塞和升级,执行以下SQL:
ALTER DATABASE dbName SET READ_COMMITTED_SNAPSHOT ON;
这样数据库会用行版本控制代替锁,大幅减少批量操作时的锁等待时间。
优化事务日志:如果数据库是完整恢复模式,大事务会导致日志快速增长引发磁盘IO瓶颈:
- 预先把日志文件设置到足够大的大小(比如根据22000条数据的日志量,设置初始大小为10GB),避免自动增长(自动增长会导致磁盘碎片和等待)。
- 批量操作期间,可暂时把恢复模式改成简单模式(操作完改回完整):
ALTER DATABASE dbName SET RECOVERY SIMPLE; -- 执行批量操作 ALTER DATABASE dbName SET RECOVERY FULL;
重建索引碎片:定期重建目标表的聚集唯一索引,碎片过多会导致查询和更新变慢:
ALTER INDEX uc_fund_return_unique ON fund_return REBUILD;
五、代码小优化:让数据库生成modif_time
你当前在Java代码里生成modif_time并作为参数传递,其实可以直接在SQL里用数据库的系统时间,减少参数传递开销,同时时间更准确:
比如把MERGE里的modif_time = s.modif_time改成modif_time = SYSDATETIME(),INSERT里的s.modif_time改成SYSDATETIME(),这样Java代码里可以去掉第9个参数,减少参数处理的工作量。
最后验证:检查数据库等待类型
如果以上优化后还是慢,建议在执行批量操作时,用SQL Server Profiler或Extended Events查看数据库的等待类型:
- 如果是
PAGEIOLATCH_*:磁盘IO瓶颈,需要优化存储(比如换SSD,调整RAID级别)。 - 如果是
LCK_M_*:锁等待,说明快照隔离没开或者事务太大,需要拆分更小的批次。 - 如果是
CXPACKET:并行执行的等待,可以调整MAXDOP参数(比如设置为CPU核心数的一半)。
你可以先从第一和第二个优化方向开始尝试,这两个是成本最低、见效最快的,应该能快速解决你的性能问题。




