Quartz 集群 + 策略模式:逾期预警推送系统设计实录
2026/9/17 0:18:23 网站建设 项目流程

起点是一道业务题

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

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

集群配置的关键几项:

spring:quartz:job-store-type:jdbcproperties:org:quartz:scheduler:instanceName:overdueSchedulerinstanceId:AUTO# 集群下必须 AUTO,靠它区分节点jobStore:isClustered:true# 开启集群clusterCheckinInterval:20000# 心跳 20smisfireThreshold:60000# 容忍 60s 内的错火threadPool:class:org.quartz.simpl.SimpleThreadPoolthreadCount:10

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

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

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

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

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

@Target(ElementType.TYPE)@Retention(RetentionPolicy.RUNTIME)public@interfaceDCE{/** 防重 key 的 SpEL 表达式,基于 JobDataMap 解析 */Stringkey();/** 锁持有时间,默认 30 分钟,覆盖单次扫描最长耗时 */longleaseMillis()default30*60*1000L;}

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

@Aspect@ComponentpublicclassDceAspect{@AutowiredprivateRedissonClientredisson;@Around("bean(overdueJobExecutor)")publicObjectaround(ProceedingJoinPointpjp)throwsThrowable{Methodmethod=((MethodSignature)pjp.getSignature()).getMethod();DCEdce=method.getDeclaringClass().getAnnotation(DCE.class);JobExecutionContextctx=(JobExecutionContext)Arrays.stream(pjp.getArgs()).filter(a->ainstanceofJobExecutionContext).findFirst().orElseThrow();Stringkey=SpelEval.eval(dce.key(),ctx.getMergedJobDataMap());// 例:overdue:push:cm:{#cmId}:{#bizDate}RLocklock=redisson.getLock(key);booleanacquired=lock.tryLock(0,dce.leaseMillis(),TimeUnit.MILLISECONDS);if(!acquired){log.info("DCE skip duplicate push, key={}",key);returnnull;}try{returnpjp.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 走那段",后面每加一个渠道就要改主链路代码。

抽象接口长这样:

publicinterfacePushStrategy{/** 渠道编码,对应 push_channel 字典 */PushChannelchannel();/** 是否支持本次推送(流量控制 / 黑名单 / 渠道开关) */booleansupports(PushContextcontext);/** 执行推送,返回投递结果 */PushResultdeliver(PushContextcontext);/** 失败重试策略 */defaultRetryPolicyretryPolicy(){returnRetryPolicy.exponential(3,100,2000);}}

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

@ComponentpublicclassPushStrategyRouter{privatefinalList<PushStrategy>strategies;privatefinalMap<PushChannel,PushStrategy>index;publicPushStrategyRouter(List<PushStrategy>strategies){this.strategies=strategies;this.index=strategies.stream().collect(Collectors.toMap(PushStrategy::channel,Function.identity()));}/** 按业务规则匹配所有支持的渠道,按渠道优先级排序 */publicList<PushStrategy>match(PushContextctx){returnstrategies.stream().filter(s->s.supports(ctx)).sorted(Comparator.comparingInt(s->s.channel().getPriority())).collect(Collectors.toList());}/** 指定渠道直取 */publicPushStrategyof(PushChannelchannel){returnOptional.ofNullable(index.get(channel)).orElseThrow(()->newIllegalStateException("未配置推送渠道: "+channel));}}

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

@ComponentpublicclassImPushStrategyimplementsPushStrategy{privatefinalImClientimClient;privatefinalPushFailoverRepositoryfailoverRepo;@OverridepublicPushChannelchannel(){returnPushChannel.IM;}@Overridepublicbooleansupports(PushContextctx){// 销售助理角色统一走 IMreturnctx.recipient().role()==Role.SALES_ASSISTANT&&StringUtils.isNotBlank(ctx.recipient().imAccount());}@OverridepublicPushResultdeliver(PushContextcontext){try{imClient.send(context.recipient().imAccount(),renderTemplate(context));returnPushResult.ok(context.orderId(),channel());}catch(ImExceptione){// 失败入库,由补偿 Job 走二次降级渠道failoverRepo.save(FailoverRecord.of(context,channel(),e));returnPushResult.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 半天也写得完;但第三次、第四次接入时,能不动主链路就接入,对在线系统的稳定性是实打实的收益。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询