起点是一道业务题
这个模块的起点不是什么技术规划,是一道业务题:订单履约完成、进入应收阶段后,逾期催收靠人工盯,月底坏账风险集中爆发。我们要做的,是把"靠人盯"这件事工程化。
先看原来的人工流程。销售助理每天上午导出订单报表,按"应收日期过了 + 状态还没回款"过滤一遍,给每条订单匹配对应客户经理,再通过邮件、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 半天也写得完;但第三次、第四次接入时,能不动主链路就接入,对在线系统的稳定性是实打实的收益。
还没做的:调度层目前是扫描式,订单状态变更即触发的事件驱动方案还没上;推送内容还是模板化,结合客户历史回款画像生成差异化措辞的能力没有;长期未推动订单自动升级到主管/法务流程的自愈能力,也是后续的事。