在 Spring Event 接收到事件消息后,无论是 MQ 的发送,还是 HTTP 的调用,都是有可能失败的。可能是网络超时,可能是服务宕机,可能是线程阻塞,可能是流量洪峰等等原因。
      所以,我们需要对完成 MQ、HTTP 的处理后,首先更新数据库任务表状态,是成功还是失败。之后再由定时扫描任务来做补偿处理,以确保最终一致性。
请输入图片描述

  • 首先,任务补偿,它是一个定时任务扫描库表的过程。这里为了提高整体的扫描效率,设计了门牌号,可以配置多个任务,每个任务扫描自己的门牌号范围。另外扫描库表,还要根据条件获取一个最小的符合的id,之后在 > id limit x 获取数据列表。
  • 然后,还要对之前的流程做一些补充。http 调用操作、MQ 推送操作,之后要做数据库表任务记录的更新,成功或者失败。这里看,从 Spring Event 接收到消息后,执行通知操作(http、mq),之后更新数据库。这些都不是一个事务的,也就是说从事务(业务数据+任务表数据写入)往后,都是有可能失败的。所以要做任务补偿操作,那么这里也带来一个问题,补偿就有可能重复,比如 http 重复调用一次,mq 重复发送一次,所以这些业务一定要做幂等操作(比如 OrderId 做唯一索引处理)。

编码实现

  • 以 TaskMessageEventJob 为入口,基于 LocalTaskMessageAutoProperties 任务配置拆分任务扫描数据门牌号范围。之后调用新增加的 ILocalTaskMessageDataService 服务,处理数据的扫描拿到待处理的任务数据,发送 SpringEvent 事件。事件发送后,继续走之前实现的监听流程。
  • 在 HttpNotifyStrategy、RabbitmqMqNotifyStrategy,两个流程处理中,补充添加更新数据库任务表操作,成功或失败。

    数据操作(Dao)

    public interface ITaskMessageDao {
    
      /**
       * 插入任务消息
       *
       * @param taskmessagePO 任务消息PO对象
       * @return 影响行数
       * @throws SQLException
       */
      int insert(TaskMessagePO taskmessagePO) throws SQLException;
    
      /**
       * 根据任务ID修改状态
       *
       * @param taskId 任务ID
       * @param status 状态(0-待处理,1-处理中,2-已完成,3-失败)
       * @return 影响行数
       */
      int updateStatusByTaskId(String taskId, Integer status);
    
      /**
       * 根据门牌号查询任务消息列表
       *
       * @param houseNumbers 门牌号列表
       * @param id           查询ID大于此值的记录
       * @param limit        限制返回结果数量
       * @return 任务消息列表
       */
      List<TaskMessagePO> selectByHouseNumber(List<Integer> houseNumbers, Long id, Integer limit);
    
      /**
       * 根据门牌号查询符合条件的最小ID
       *
       * @param houseNumbers 门牌号列表
       * @return 最小ID,如果没有找到则返回NULL
       */
      Long selectMinIdByHouseNumber(List<Integer> houseNumbers);
    }

    任务更新

    @Slf4j
    @Component("httpNotifyStrategy")
    public class HttpNotifyStrategy implements INotifyStrategy {
    
      @Resource
      private ILocalTaskMessagePort port;
    
      @Resource
      private ILocalTaskMessageRepository repository;
    
      @Override
      public String notify(TaskMessageEntityCommand command) throws Exception {
          try {
              String result = port.notify2http(command);
              repository.updateTaskStatusToSuccess(command.getTaskId());
              return result;
          } catch (Exception e) {
              log.error("http notify error {}", JSON.toJSONString(command), e);
              // 通知失败,更新状态为失败
              repository.updateTaskStatusToFailed(command.getTaskId());
              throw e;
          }
      }
    }
    
    @Slf4j
    @Component("rabbitMqNotifyStrategy")
    public class RabbitMqNotifyStrategy implements INotifyStrategy {
    
      @Resource
      private ILocalTaskMessagePort port;
    
      @Resource
      private ILocalTaskMessageRepository repository;
    
      @Override
      public String notify(TaskMessageEntityCommand command) throws Exception {
          try {
              String result = port.notify2rabbitmq(command);
              repository.updateTaskStatusToSuccess(command.getTaskId());
              return result;
          } catch (Exception e) {
              log.error("rabbitmq notify error {}", JSON.toJSONString(command), e);
              // 通知失败,更新状态为失败
              repository.updateTaskStatusToFailed(command.getTaskId());
              throw e;
          }
      }
    }

    扫描任务

          因为不同的任务可以扫描不同的门牌号,这里要增加一个自定义属性配置,允许用户根据自己的诉求,拆分出多个任务。

    @Data
    @ConfigurationProperties(prefix = "local.task.message.config", ignoreInvalidFields = true)
    public class LocalTaskMessageAutoProperties {
      /**
       * 任务组配置列表
       */
      private List<TaskGroupConfig> groups = new ArrayList<>();
    
      @Data
      public static class TaskGroupConfig {
          /**
           * 任务组ID或名称,用于日志区分
           */
          private String groupId = "default";
    
          /**
           * 扫描的门牌号列表,例如 [1,2,3]
           */
          private List<Integer> houseNumbers = new ArrayList<>();
    
          /**
           * 调度 cron 表达式,例如:"0/10 * * * * ?" 表示每10秒
           */
          private String cron;
    
          /**
           * 固定延迟毫秒;若配置此项则使用固定延迟调度
           */
          private Long fixedDelayMs;
    
          /**
           * 每次批量处理限制条数
           */
          private Integer limit = 100;
      }
    
    }

          配置实例:

    local:
    task:
      message:
        config:
          groups:
            # 使用 cron 表达式调度(每 10 秒执行一次)
            - groupId: group01
              houseNumbers: [1, 2, 3]
              cron: "0/10 * * * * ?"
              limit: 100
            # 使用固定延迟调度(每 5 秒执行一次)
            - groupId: group02
              houseNumbers: [4, 5]
              fixedDelayMs: 5000
              limit: 50
    

          触发层新增 TaskMessageEventJob 扫描任务

    @Slf4j
    @Component
    @RequiredArgsConstructor
    public class TaskMessageEventJob {
    
      private final Map<String, AtomicLong> groupLastIdMap = new ConcurrentHashMap<>();
    
      private final LocalTaskMessageAutoProperties properties;
    
      private final ThreadPoolTaskScheduler scheduler;
    
      private final ILocalTaskMessageNotifyService notifyService;
    
      private final ILocalTaskMessageDataService dataService;
    
      @PostConstruct
      public void init() {
          List<LocalTaskMessageAutoProperties.TaskGroupConfig> groups = properties.getGroups();
          if (groups == null || groups.isEmpty()) {
              log.info("TaskMessageEventJob 未配置任务组,跳过调度初始化");
              return;
          }
    
          for (LocalTaskMessageAutoProperties.TaskGroupConfig group : groups) {
              scheduleGroup(group);
          }
      }
    
      private void scheduleGroup(LocalTaskMessageAutoProperties.TaskGroupConfig group) {
          String groupId = group.getGroupId();
          List<Integer> houseNumbers = group.getHouseNumbers();
          if (houseNumbers == null || houseNumbers.isEmpty()) {
              log.warn("任务组 [{}] 未配置 houseNumbers,跳过该组调度", groupId);
              return;
          }
    
          // 初始化lastId
          groupLastIdMap.computeIfAbsent(groupId, k -> {
              Long minId = dataService.selectMinIdByHouseNumber(houseNumbers);
              long startId = (minId == null ? 0L : minId);
              log.info("任务组 [{}] 初始化起始ID为 {},houseNumbers={}", groupId, startId, houseNumbers);
              return new AtomicLong(startId);
          });
    
          Runnable task = () -> {
              try {
                  long lastId = groupLastIdMap.get(groupId).get();
                  List<TaskMessageEntityCommand> list = dataService.selectByHouseNumber(houseNumbers, lastId, group.getLimit());
                  if (list == null || list.isEmpty()) {
                      return;
                  }
    
                  for (TaskMessageEntityCommand cmd : list) {
                      notifyService.notify(cmd);
                  }
    
                  Long maxId = list.stream().map(TaskMessageEntityCommand::getId).max(Comparator.naturalOrder()).orElse(lastId);
                  groupLastIdMap.get(groupId).set(maxId);
                  log.info("任务组 [{}] 处理完成,拉取 {} 条,lastId:{} -> {}", groupId, list.size(), lastId, maxId);
              } catch (Exception e) {
                  log.error("任务组 [{}] 执行异常:{}", groupId, e.getMessage(), e);
              }
          };
    
          if (group.getCron() != null && !group.getCron().trim().isEmpty()) {
              scheduler.schedule(task, new CronTrigger(group.getCron()));
              log.info("任务组 [{}] 已按 cron [{}] 调度", groupId, group.getCron());
          } else {
              long delay = group.getFixedDelayMs() != null ? group.getFixedDelayMs() : 5000L;
              scheduler.scheduleWithFixedDelay(task, delay);
              log.info("任务组 [{}] 已按 fixedDelayMs [{}] 调度", groupId, delay);
          }
    
      }
    }
  • 类职责:TaskMessageEventJob 是一个“定时拉取并发布任务消息”的调度器。按照配置的任务组定期查询待处理消息,发布事件,并记录每个任务组已处理到的最大 id,用于增量继续拉取,实现动态补偿。
  • 核心功能:

    • groupLastIdMap:保存“任务组 → 最近处理的最大ID”的映射,用于增量拉取,避免重复处理。类型为 AtomicLong,保证并发场景下读写的原子性。
    • properties:读取外部配置(任务组、门牌号、调度方式等)。
    • scheduler:Spring 的线程池调度器,负责按 cron 或固定延迟执行任务。
    • notifyService:对外发布事件(通知消费方处理每条消息)。
    • dataService:数据查询服务,从存储中按条件拉取消息记录。
  • 初始化流程:

    • 读取配置中的 groups;如果未配置,打印日志并跳过初始化。
    • 对每个任务组调用 scheduleGroup(group) 进行独立的调度注册。
  • 注册任务组调度:

    • 参数校验:必须配置 houseNumbers(门牌号集合),否则跳过。
    • 初始化增量起点 `lastId:

      • 使用 dataService.selectMinIdByHouseNumber(houseNumbers) 找到该组关注的门牌号里的最小 id。
      • 若查询为空则从 0 开始;将起始值保存到 groupLastIdMap,供后续增量拉取使用。
    • 构造执行体 task:每次调度触发时,按以下步骤处理:

      • 读取当前组的 lastId。
      • 通过 dataService.selectByHouseNumber(houseNumbers, lastId, group.getLimit()) 拉取“id 大于 lastId”的消息,最多 limit 条。
      • 如果本次没有新消息,直接返回。
      • 遍历消息并发布事件:notifyService.notify(cmd)。
      • 计算本批次的最大 id 并更新 groupLastIdMap,作为下次拉取的起点。
      • 打印处理日志,异常捕获与记录。
  • 调度方式选择:

    • 如果配置了 cron 表达式,则按 CronTrigger 定时执行。
    • 否则按固定延迟 fixedDelayMs 执行;未配置时默认 5000ms。
  • 增量与补偿的意义:

    • 通过记录 lastId,每次只拉取“新产生”的消息,实现增量处理,避免重复。
    • 如果某次运行异常,中断前更新的 lastId 仍在内存中;下次调度会从该值继续,尽量减少漏处理。但为更强的容灾,实际工程中常把 lastId 持久化(例如数据库/Redis),并配合消费方的幂等设计,确保“最多一次”或“至少一次”语义。
  • 配置示例说明:

    • group01 使用 cron(每10秒执行),拉取门牌号 [1,2,3],每次最多 limit=100 条。
    • group02 使用固定延迟(每5秒执行),拉取门牌号 [4,5],每次最多 limit=50 条。
最后修改:2026 年 07 月 13 日
如果觉得我的文章对你有用,请随意赞赏