批量任务怎么做得更稳?我在实际开发里踩过的几个坑

0 阅读11分钟

摘要

批量任务这东西,平时看着不起眼,真出问题的时候特别烦。最恶心的不是跑不起来,而是看着跑完了,结果其实不全,或者重跑一次又重复了。这篇文章我就不讲太虚的最佳实践了,直接聊几个我自己做批量任务时踩过的坑:一把梭全量处理、只管提交不管结果、事务边界没想清楚、没有幂等、日志太少、补偿入口没留。说白了就一个目标:让批量任务别那么容易出事。

前言

批量任务这个东西,后端开发基本都绕不过去。

比如:

  • 批量更新状态
  • 批量生成数据
  • 批量同步
  • 定时跑一批任务
  • 补历史数据
  • 多线程处理一堆对象

刚开始做的时候,大家想法其实都差不多:

能跑完就行。

我一开始也是这么想的。

但后面做多了就发现,批量任务最烦的根本不是“慢一点”,而是下面这些情况:

  • 看起来执行完了,实际上漏了一部分
  • 表面成功了,其实中间有失败
  • 重跑一下,又把数据搞重复了
  • 出了问题以后,根本不知道哪一批有问题
  • 想补救的时候,发现连抓手都没有

所以我现在看批量任务,最在意的已经不是“能不能跑”,而是:

量上来以后,它到底稳不稳。

下面这些坑,基本都是我自己真踩过,或者真在线上见过的。


一、第一个坑:一把梭全量处理,开始看着爽,后面容易出事

这个坑特别常见。

需求一来,思路也很直接:

  1. 查出一批数据
  2. 一次性处理完
  3. 更新完结束

代码写起来特别顺,甚至会觉得挺优雅。

先看一个很常见的写法

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)补偿能力要预留

失败以后能不能补、怎么补,这一点很关键。

说到底,我现在越来越把批量任务当成一套完整流程来看,而不是一段“跑完就结束”的代码。

因为它真正要面对的不只是“执行”这一步,

还包括:

  • 失败
  • 重跑
  • 排查
  • 补偿
  • 核对

总结

批量任务最怕的不是写复杂了,

而是写得太想当然。

因为这类任务小数据量时通常都能跑,

真正的问题往往是量一上来才开始暴露:

  • 压力集中
  • 结果不全
  • 重跑重复
  • 日志不足
  • 补偿困难

所以我现在做批量任务时,最在意的已经不是“代码短不短”,而是:

它在真实数据量、真实异常场景、真实线上环境里,到底稳不稳。

如果你平时也在做批量更新、批量同步、补数据、定时任务这类工作,建议你有空也可以回头看看自己现在的任务设计:

  • 是不是一把梭全量处理
  • 有没有结果收集
  • 事务边界清不清楚
  • 重跑会不会出问题
  • 日志够不够追
  • 有没有补偿抓手

很多坑,不一定要等出事故以后才知道,

很多时候平时就能提前避开。

大家加油:)