Quartz 集群 + 策略模式:逾期预警推送系统设计实录

起点是一道业务题

这个模块的起点不是什么技术规划,是一道业务题:订单履约完成、进入应收阶段后,逾期催收靠人工盯,月底坏账风险集中爆发。我们要做的,是把"靠人盯"这件事工程化。

先看原来的人工流程。销售助理每天上午导出订单报表,按"应收日期过了 + 状态还没回款"过滤一遍,给每条订单匹配对应客户经理,再通过邮件、IM、电话逐条提醒。

毛病就集中在几个地方:

  • T+1 才发现逾期。上午 11 点导出报表时,订单其实已经逾期一天了,黄金催收窗口早过。
  • 渠道分发靠人。邮件、IM、电话各发各的,策略没法沉淀,新员工来了又得重新约定一遍。
  • 多节点重复提醒。同一个订单在不同环节被不同人催,客户烦,客户经理也累。
  • 月底集中爆发。高峰期人肉翻表排查,漏单是常态,坏账风险到月底才暴露。

目标定下来:搭一套自动化预警推送,替代人工查阅;多节点部署下任务不重复执行;新渠道能灵活接入。

整体结构:三层各管一段

最后落地是三层,调度、业务、推送各管一段:

复制代码
┌─────────────────────────────────────────────────────┐
│              调度层 · Quartz Cluster                 │
│   Node A / Node B / Node C                          │
│   共享 QRTZ_* 表 + @DCE 业务防重                     │
└───────────────────────┬─────────────────────────────┘
                        │ 拉取逾期订单
┌───────────────────────▼─────────────────────────────┐
│              业务层 · Service                        │
│   逾期扫描 → 风险分级 → 推送对象解析 → PushContext    │
└───────────────────────┬─────────────────────────────┘
                        │ PushContext
┌───────────────────────▼─────────────────────────────┐
│              推送层 · 策略模式                        │
│   Email / IM / SMS / Webhook ...                    │
│   新渠道 = 实现一个 PushStrategy                      │
└─────────────────────────────────────────────────────┘

三层的边界是这样划的:调度层只回答"什么时候触发",业务规则全部下沉到 Service;推送层用策略模式,新渠道接入不动主链路;多节点部署下任意节点宕机不影响整体调度,且不重复触发推送。

调度层:为什么是 Quartz 集群,不是 ShedLock

选型时第一个考虑的是 Spring @Scheduled + ShedLock 这套轻量方案。否决的理由有三条:

@Scheduled 是单机调度,节点宕机任务直接丢失;ShedLock 只解决"同一时刻不重复触发",但没有任务上下线感知和失败转移;业务上要每日多次扫描,还要支持手动补偿重跑,需要完整的调度元信息。

Quartz 集群模式下,所有节点共享同一组 QRTZ_* 表,靠行锁抢占 trigger。任意节点宕机,trigger 的 NEXT_FIRE_TIME 到达后会被存活节点接管,天然具备 HA。

集群配置的关键几项:

yaml 复制代码
spring:
  quartz:
    job-store-type: jdbc
    properties:
      org:
        quartz:
          scheduler:
            instanceName: overdueScheduler
            instanceId: AUTO          # 集群下必须 AUTO,靠它区分节点
          jobStore:
            isClustered: true          # 开启集群
            clusterCheckinInterval: 20000   # 心跳 20s
            misfireThreshold: 60000     # 容忍 60s 内的错火
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount: 10

三个踩过的点:instanceId 必须开 AUTO,否则多节点共享 instanceId 会触发重复调度;isClustered 之外,机器时钟要同步,trigger 抢占顺序乱了一切都乱,建议配 NTP;QRTZ_* 表用官方的 tables_mysql_innodb.sql 初始化,别自己改 DDL,索引涉及行锁顺序。

@DCE 注解:业务侧的二次防重

Quartz 自带的 @DisallowConcurrentExecution 能保证"同一 JobDetail 不并发执行",但实际场景里我要防的是更细粒度的事:

同一个客户经理 × 同一天的逾期订单,无论被哪个节点拉起,都只能推送一次。

为此自定义了一个 @DCE(Disable Concurrent Execution)注解,按业务主键加分布式锁:

java 复制代码
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DCE {
    /** 防重 key 的 SpEL 表达式,基于 JobDataMap 解析 */
    String key();

    /** 锁持有时间,默认 30 分钟,覆盖单次扫描最长耗时 */
    long leaseMillis() default 30 * 60 * 1000L;
}

Job 执行时靠 AOP 拦截,按 SpEL 算出业务 key,往 Redis 写一把带过期的锁:

java 复制代码
@Aspect
@Component
public class DceAspect {

    @Autowired
    private RedissonClient redisson;

    @Around("bean(overdueJobExecutor)")
    public Object around(ProceedingJoinPoint pjp) throws Throwable {
        Method method = ((MethodSignature) pjp.getSignature()).getMethod();
        DCE dce = method.getDeclaringClass().getAnnotation(DCE.class);

        JobExecutionContext ctx = (JobExecutionContext) Arrays.stream(pjp.getArgs())
                .filter(a -> a instanceof JobExecutionContext)
                .findFirst().orElseThrow();

        String key = SpelEval.eval(dce.key(), ctx.getMergedJobDataMap());
        // 例:overdue:push:cm:{#cmId}:{#bizDate}

        RLock lock = redisson.getLock(key);
        boolean acquired = lock.tryLock(0, dce.leaseMillis(), TimeUnit.MILLISECONDS);
        if (!acquired) {
            log.info("DCE skip duplicate push, key={}", key);
            return null;
        }
        try {
            return pjp.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

为什么是双层防重,不是 Quartz 自带的就够了?@DisallowConcurrentExecution 作用在 JVM 内,保护的是同一个 JobDetail 实例不并发。但集群下的 misfire 补偿会带来跨节点的复杂场景------节点 A 拿到 trigger 后 GC STW 超过 misfireThreshold,trigger 被判定 misfire,集群重新调度给 B,这时单靠 Quartz 的并发保护兜不住跨节点的业务幂等。@DCE 锁补的就是这道缝。

推送层:策略模式统一抽象

推送渠道不能再写 if-else 链了。一旦开始写"如果是邮件走这段、如果是 IM 走那段",后面每加一个渠道就要改主链路代码。

抽象接口长这样:

java 复制代码
public interface PushStrategy {

    /** 渠道编码,对应 push_channel 字典 */
    PushChannel channel();

    /** 是否支持本次推送(流量控制 / 黑名单 / 渠道开关) */
    boolean supports(PushContext context);

    /** 执行推送,返回投递结果 */
    PushResult deliver(PushContext context);

    /** 失败重试策略 */
    default RetryPolicy retryPolicy() {
        return RetryPolicy.exponential(3, 100, 2000);
    }
}

路由不写 if-else,靠 PushStrategyRouter 按业务规则匹配:

java 复制代码
@Component
public class PushStrategyRouter {

    private final List<PushStrategy> strategies;
    private final Map<PushChannel, PushStrategy> index;

    public PushStrategyRouter(List<PushStrategy> strategies) {
        this.strategies = strategies;
        this.index = strategies.stream()
                .collect(Collectors.toMap(PushStrategy::channel, Function.identity()));
    }

    /** 按业务规则匹配所有支持的渠道,按渠道优先级排序 */
    public List<PushStrategy> match(PushContext ctx) {
        return strategies.stream()
                .filter(s -> s.supports(ctx))
                .sorted(Comparator.comparingInt(s -> s.channel().getPriority()))
                .collect(Collectors.toList());
    }

    /** 指定渠道直取 */
    public PushStrategy of(PushChannel channel) {
        return Optional.ofNullable(index.get(channel))
                .orElseThrow(() -> new IllegalStateException("未配置推送渠道: " + channel));
    }
}

以 IM 通道为例,一个具体实现:

java 复制代码
@Component
public class ImPushStrategy implements PushStrategy {

    private final ImClient imClient;
    private final PushFailoverRepository failoverRepo;

    @Override
    public PushChannel channel() {
        return PushChannel.IM;
    }

    @Override
    public boolean supports(PushContext ctx) {
        // 销售助理角色统一走 IM
        return ctx.recipient().role() == Role.SALES_ASSISTANT
                && StringUtils.isNotBlank(ctx.recipient().imAccount());
    }

    @Override
    public PushResult deliver(PushContext context) {
        try {
            imClient.send(context.recipient().imAccount(), renderTemplate(context));
            return PushResult.ok(context.orderId(), channel());
        } catch (ImException e) {
            // 失败入库,由补偿 Job 走二次降级渠道
            failoverRepo.save(FailoverRecord.of(context, channel(), e));
            return PushResult.fail(context.orderId(), channel(), e.getMessage());
        }
    }
}

接入新渠道的动作就三件:PushChannel 枚举加一项、实现一个 PushStrategy 注册成 Spring Bean、(可选)配置开关和限流规则。Router 不动,调度逻辑不动,没有 if-else 可加。接入企微、钉钉机器人这些,主链路代码基本不用改。

上线后的样子

模块上线三个月后对比,几个关键指标:

指标 上线前 上线后
逾期发现时延 T+1 上午 11 点 准实时,扫描后分钟级
重复推送占比 约 12%(多节点 + 人工) 0.1% 以下(DCE 锁兜底)
渠道接入工时 2-3 人日 0.5 人日
平均回款周期 45 天 30 天

回款周期缩短约 15 天,核心原因不是技术多牛,而是发现逾期的时间点提前了将近一天,加上推送一致性上来------客户经理在客户上班前就收到了结构化的催收清单,沟通节奏整体前置。

踩过的坑

misfireThreshold 设小了反而更危险。 最早设 5s,结果节点 FullGC 一下就触发 misfire,大量任务被标记 misfired 后又重新调度,瞬时限流冲垮下游。调到 60s 配合业务侧 @DCE 锁,整体才稳下来。

策略模式最大的隐形成本,是渠道开关与降级。 光有 supports() 不够。每个渠道要独立开关和限流,防止新渠道上线冲爆下游;失败要能自动降级到下一渠道(IM → Email → SMS),落库失败记录走补偿;新渠道先按客户经理白名单灰度。这些不在策略接口里,但属于框架基础设施,后来都下沉到 PushStrategyRouter 同层。

@DCE 的 key 一定要包含业务日期。 早期 key 只用 cmId,跨日重跑场景下前一天的推送被锁住跳过,直接漏推。把业务日期纳入 key 后,既防同日重复,又不阻塞补偿重跑。

监控比实现更重要。 上线初期漏推了几单没发现,直到客户经理反馈才知道。后来加了三道监控------调度心跳、推送成功率、失败补偿队列长度,才真正敢说"自动化"。

写在最后

这次设计可以归结成一句话:用 Quartz 集群保证调度的 HA,用 @DCE 注解保证业务的幂等,用策略模式保证推送层的开放性。三件事合起来干的就是一件事:把"靠人盯"的环节工程化。

几点体会:

选型时看的是"普通用法下的边界",不是功能列表。 @Scheduled + ShedLock 看起来够轻量,但一旦要"任务上下线感知 + 失败转移 + 手动补偿",它的能力天花板就到了。Quartz 集群重一点,但这些是它的舒适区。

双层防重的逻辑是"各管一层的幂等"。 Quartz 管"trigger 不重复触发",@DCE 管"业务主键不重复推送"。两层各管一层的幂等语义,不要指望一层包打天下------单靠 Quartz 行锁保护不了 misfire 补偿下的业务幂等,单靠业务锁又挡不住 trigger 抢占竞争。

漏推比多推可怕,但多推更难发现。 多推客户会骂,漏推没人说话,直到月底对账才暴露。监控这件事不能等业务反馈,调度心跳、成功率、补偿队列长度三件套是底线。

策略模式的开放性,省下的不是写 if-else 的时间,是后续接入渠道时的心智成本。 第一次接入新渠道,改主链路加 if-else 半天也写得完;但第三次、第四次接入时,能不动主链路就接入,对在线系统的稳定性是实打实的收益。

还没做的:调度层目前是扫描式,订单状态变更即触发的事件驱动方案还没上;推送内容还是模板化,结合客户历史回款画像生成差异化措辞的能力没有;长期未推动订单自动升级到主管/法务流程的自愈能力,也是后续的事。

相关推荐
NJCloud1 小时前
Openstack(一)——基础环境配置与依赖服务安装
linux·架构·openstack
AlanBruce1 小时前
一键设备扫描功能详解
服务器·网络·网络协议·modbus·摩尔信使·设备扫描
额额额对了1 小时前
Linux C 多线程编程基础:从内存模型到生命周期管理
linux·开发语言·算法
mengge.cloud1 小时前
Linux小白入门教程(基础命令篇)
linux·运维·服务器
嵌入式小宁1 小时前
C11 原子操作 `atomic_int` 通俗指南
linux·c语言·嵌入式硬件
JasonHuan11231 小时前
Hyper-V-三台虚拟机安装启动使用完全指南
linux·ubuntu·hyper-v·虚拟机
Hive_MOM1 小时前
制造企业技术文件与图纸管理数字化(DCC):从“图纸满天飞“到版本受控
服务器·数据库·制造
王志来137944730081 小时前
工控服务器机箱选型决策要素解析:匀天以“快全准省”构建价值坐标
运维·服务器·python·devops