摘要
批量任务这东西,平时看着不起眼,真出问题的时候特别烦。最恶心的不是跑不起来,而是看着跑完了,结果其实不全,或者重跑一次又重复了。这篇文章我就不讲太虚的最佳实践了,直接聊几个我自己做批量任务时踩过的坑:一把梭全量处理、只管提交不管结果、事务边界没想清楚、没有幂等、日志太少、补偿入口没留。说白了就一个目标:让批量任务别那么容易出事。
前言
批量任务这个东西,后端开发基本都绕不过去。
比如:
- 批量更新状态
- 批量生成数据
- 批量同步
- 定时跑一批任务
- 补历史数据
- 多线程处理一堆对象
刚开始做的时候,大家想法其实都差不多:
能跑完就行。
我一开始也是这么想的。
但后面做多了就发现,批量任务最烦的根本不是“慢一点”,而是下面这些情况:
- 看起来执行完了,实际上漏了一部分
- 表面成功了,其实中间有失败
- 重跑一下,又把数据搞重复了
- 出了问题以后,根本不知道哪一批有问题
- 想补救的时候,发现连抓手都没有
所以我现在看批量任务,最在意的已经不是“能不能跑”,而是:
量上来以后,它到底稳不稳。
下面这些坑,基本都是我自己真踩过,或者真在线上见过的。
一、第一个坑:一把梭全量处理,开始看着爽,后面容易出事
这个坑特别常见。
需求一来,思路也很直接:
- 查出一批数据
- 一次性处理完
- 更新完结束
代码写起来特别顺,甚至会觉得挺优雅。
先看一个很常见的写法
public void syncAll(List<Long> ids) {
List<Order> orders = orderMapper.selectByIds(ids);
for (Order order : orders) {
process(order);
}
}
这段代码的问题不是不能跑。
问题是它默认有一个前提:
这批数据不大,处理过程也不会出什么幺蛾子。
可现实情况往往不是这样。
一旦量上来,就容易出现这些情况:
- 一次查太多
- 一次处理太多
- 数据库压力突然变大
- 执行时间很长
- 中间失败以后很难接着跑
- 想重试时只能整批再来一遍
这种代码刚写出来的时候,通常也能跑,
问题一般都是数据量上来以后才开始暴露。
我后来更倾向的写法:拆批
public void syncInBatch(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
final int batchSize = 500;
for (int i = 0; i < ids.size(); i += batchSize) {
int end = Math.min(i + batchSize, ids.size());
List<Long> batchIds = ids.subList(i, end);
List<Order> orders = orderMapper.selectByIds(batchIds);
for (Order order : orders) {
process(order);
}
log.info("当前批次处理完成,范围:[{} - {}), 数量:{}", i, end, batchIds.size());
}
}
这类代码的重点不在“多了一个 for 循环”,
而在于:
- 压力被摊开了
- 中途失败时更容易定位
- 后面做重试和补偿也更方便
这一段我现在的理解很简单
批量任务最忌讳的,就是图省事,一次全干。
二、第二个坑:任务都发出去了,但你根本不知道结果怎么样
这个坑在多线程场景里特别容易踩。
很多人做批量任务的时候,很关注:
- 线程池怎么配
- 任务怎么并发
- 怎么把任务发出去
但真正容易出问题的,反而是后面这件事:
这些任务跑完以后,你到底有没有把结果收回来。
容易出问题的写法
public void execute(List<Order> orders) {
ExecutorService executor = Executors.newFixedThreadPool(10);
for (Order order : orders) {
executor.submit(() -> process(order));
}
executor.shutdown();
log.info("批量任务执行结束");
}
这段代码看起来没什么毛病,
任务也确实提交出去了。
但它最容易制造一种特别迷惑的假象:
日志写着“执行结束”,但其实你根本不知道哪些成功了,哪些失败了。
这类问题最恶心,因为它经常不是“全挂”,而是“半死不活”。
比如:
- 任务都提交了
- 主线程也结束了
- 日志看起来像执行完成
- 但最后结果其实只有一部分成功
这种问题我后面越来越怕,
因为它特别像“已经跑完了”。
然后你一开始就会怀疑:
- 是不是查错了
- 是不是页面没刷出来
- 是不是数据延迟了
- 是不是偶发异常
但真正的问题可能只是:
- 某些子任务失败了
- 失败结果没人收
- 主线程根本没意识到这次任务其实没完整成功
更稳一点的写法:把结果收回来
public void execute(List<Order> orders) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(10);
List<Future<Boolean>> futures = new ArrayList<>();
for (Order order : orders) {
futures.add(executor.submit(() -> {
process(order);
return true;
}));
}
int success = 0;
int fail = 0;
for (Future<Boolean> future : futures) {
try {
if (future.get()) {
success++;
}
} catch (Exception e) {
fail++;
log.error("子任务执行失败", e);
}
}
executor.shutdown();
log.info("批量任务执行完成,成功:{}, 失败:{}", success, fail);
}
这段代码不是什么高级技巧,
但它至少做到了一个很关键的事:
你能说清楚这次任务到底成功了多少,失败了多少。
这一段我后来的体会很直接
多线程批量任务最怕的不是慢,
而是:
你以为它跑完了,其实只是任务发出去了。
三、第三个坑:事务边界没想清楚,最后全靠运气
这个坑也特别常见。
做批量任务时,很多人都会纠结:
- 整批一个事务?
- 每批一个事务?
- 每条一个事务?
这个问题没有标准答案。
但有一个事情一定得想清楚:
事务怎么切,不是只看技术舒服不舒服,还得看业务能不能接受。
写法 1:整批一个事务
@Transactional(rollbackFor = Exception.class)
public void batchUpdate(List<Order> orders) {
for (Order order : orders) {
updateStatus(order);
}
}
这种写法的一致性看起来很好,
但问题也很明显:
- 事务范围大
- 时间长
- 一旦中间报错,整批全回滚
- 回滚代价也大
写法 2:每批一个事务
public void batchUpdateInGroup(List<Order> orders) {
final int batchSize = 200;
for (int i = 0; i < orders.size(); i += batchSize) {
int end = Math.min(i + batchSize, orders.size());
List<Order> batch = orders.subList(i, end);
updateOneBatch(batch);
}
}
@Transactional(rollbackFor = Exception.class)
public void updateOneBatch(List<Order> batch) {
for (Order order : batch) {
updateStatus(order);
}
}
这种写法会轻很多,
但天然也意味着:
- 前几批可能成功
- 后几批可能失败
- 最终可能出现“部分成功”
如果业务能接受,那没问题。
如果业务不能接受,那就不能只图技术上写起来方便。
所以我现在更习惯先问自己一句
这个任务如果只成功一半,业务能不能接受?
如果不能接受,
那事务边界就不能随便切。
这一段大白话就是
批量任务里的事务,不是随手加个注解就完了,得先想清楚失败以后业务能不能兜住。
四、第四个坑:没做幂等,重跑一次直接翻车
这个坑太真实了。
很多批量任务第一次写的时候,只想着:
- 正常情况怎么跑
但真实线上不会永远正常。
你迟早会遇到这些情况:
- 中途失败
- 一半成功一半失败
- 要补数据
- 要重跑
- 要只处理失败那部分
这个时候如果没有幂等,事情就会很难看。
没做幂等控制的写法
public void generateBill(List<Long> orderIds) {
for (Long orderId : orderIds) {
billMapper.insert(buildBill(orderId));
}
}
这段代码第一次执行可能没啥事。
但如果中途失败了一半,你第二次再跑,很可能就会:
- 已经生成过的账单再插一次
- 造成重复数据
- 后面还得再治理
更稳一点的写法:先查状态再处理
public void generateBill(List<Long> orderIds) {
for (Long orderId : orderIds) {
boolean exists = billMapper.existsByOrderId(orderId);
if (exists) {
log.info("订单 {} 已生成账单,本次跳过", orderId);
continue;
}
billMapper.insert(buildBill(orderId));
}
}
当然,真到生产里,我一般不会只靠代码层判断。
更关键的数据,最好还得配合:
- 唯一业务键
- 唯一索引
- 异常兜底
因为只靠“先查再插”,在并发场景下未必绝对稳。
但至少这个思路要提前有
我现在做批量任务时,都会逼自己问一句:
这玩意儿如果失败以后重跑,会不会出事?
如果答案是“可能会”,
那说明这任务设计得还不够稳。
这一段大白话就是
批量任务不是只考虑第一次怎么跑,还得考虑失败以后怎么再跑。
五、第五个坑:日志写得太省,出问题时根本追不动
这个坑我自己感触特别深。
很多批量任务平时看起来没毛病,
但一旦线上出问题,你一翻日志,发现就几句:
- 批量处理失败
- 任务执行异常
- 系统繁忙,请稍后重试
这种日志不能说完全没用,
但对排查基本没啥帮助。
不够用的日志写法
public void executeBatch(List<Long> ids) {
try {
for (Long id : ids) {
process(id);
}
} catch (Exception e) {
log.error("批量任务执行失败", e);
}
}
这种写法最大的问题就是:
- 不知道总量
- 不知道哪条失败
- 不知道执行到哪一步
- 不知道最后到底成功了多少
更有用一点的日志写法
public void executeBatch(List<Long> ids) {
log.info("批量任务开始,总数量: {}", ids.size());
int success = 0;
int fail = 0;
for (Long id : ids) {
try {
process(id);
success++;
log.info("处理成功,id={}", id);
} catch (Exception e) {
fail++;
log.error("处理失败,id={}", id, e);
}
}
log.info("批量任务结束,总数量:{}, 成功:{}, 失败:{}", ids.size(), success, fail);
}
如果你觉得一条一条打成功日志太多,
那也至少要把这些东西打出来:
- 总任务量
- 当前批次
- 关键主键
- 成功失败数量
- 异常对象
以前我也总觉得日志够用就行,
后面真出了几次线上问题才知道:
批量任务的日志最好宁可多一点,也别关键时候没得看
这一段大白话就是
批量任务的日志,平时嫌多,出事儿时嫌少。
六、第六个坑:没留补偿入口,线上一出问题就特别被动
很多人写批量任务时,默认这个流程是这样的:
- 跑一次
- 成功
- 结束
但真实线上不是这样。
真实情况往往是:
- 某一批失败了
- 某几个对象失败了
- 某段后置逻辑没跑成功
- 业务说先补这一部分
- 或者要求你只重跑失败对象
如果任务完全没留补偿入口,
这时候你就会特别被动。
没有补偿入口的写法
public void syncData() {
List<Long> ids = orderMapper.selectAllNeedSyncIds();
executeBatch(ids);
}
这种写法平时没问题,
但一旦其中 20 条失败了,你很可能只能整批再跑。
留补偿入口的写法
public void syncData() {
List<Long> ids = orderMapper.selectAllNeedSyncIds();
executeBatch(ids);
}
public void retryFailed(List<Long> failedIds) {
if (failedIds == null || failedIds.isEmpty()) {
return;
}
log.info("开始重跑失败数据,数量: {}", failedIds.size());
executeBatch(failedIds);
}
再完整一点:把失败对象收集回来
public List<Long> executeBatch(List<Long> ids) {
List<Long> failedIds = new ArrayList<>();
for (Long id : ids) {
try {
process(id);
} catch (Exception e) {
failedIds.add(id);
log.error("处理失败,id={}", id, e);
}
}
return failedIds;
}
这样你至少有一个很实际的抓手:
- 哪些失败了
- 后面能不能只重跑这些
- 能不能先给业务确认一下再补
这些东西平时看起来不重要,
但真到了线上出问题的时候,价值特别大。
这一段大白话就是
批量任务不只是要能跑,还得能补。
七、我后来总结的一套相对较稳的思路
踩坑踩多了以后,我现在对批量任务的要求其实挺朴素的:
1)大任务拆小批
别一上来就全量一把梭。
2)结果必须能收回来
不是只看任务有没有发出去,而是看结果有没有真正汇总。
3)事务边界提前想清楚
别写完再想“这要不要回滚”。
4)幂等必须考虑
不是可有可无,是必须提前想。
5)日志留够
尤其是批次、主键、成功失败分布。
6)补偿能力要预留
失败以后能不能补、怎么补,这一点很关键。
说到底,我现在越来越把批量任务当成一套完整流程来看,而不是一段“跑完就结束”的代码。
因为它真正要面对的不只是“执行”这一步,
还包括:
- 失败
- 重跑
- 排查
- 补偿
- 核对
总结
批量任务最怕的不是写复杂了,
而是写得太想当然。
因为这类任务小数据量时通常都能跑,
真正的问题往往是量一上来才开始暴露:
- 压力集中
- 结果不全
- 重跑重复
- 日志不足
- 补偿困难
所以我现在做批量任务时,最在意的已经不是“代码短不短”,而是:
它在真实数据量、真实异常场景、真实线上环境里,到底稳不稳。
如果你平时也在做批量更新、批量同步、补数据、定时任务这类工作,建议你有空也可以回头看看自己现在的任务设计:
- 是不是一把梭全量处理
- 有没有结果收集
- 事务边界清不清楚
- 重跑会不会出问题
- 日志够不够追
- 有没有补偿抓手
很多坑,不一定要等出事故以后才知道,
很多时候平时就能提前避开。
大家加油:)