场景五:分布式定时任务调度
凌晨 1 点,千万个"闹钟"同时响起:还款提醒、订单超时关闭、对账批处理、报表生成……调度系统要保证每个闹钟不重复响、不漏响,还要把活儿均匀摊给一群打工机器。
场景描述与业务特征
定时任务分两大类:固定周期任务(每天凌晨跑对账、每 5 分钟同步库存)和海量单次延迟任务(每笔订单下单后 30 分钟未支付自动关闭——1000 万笔订单就是 1000 万个独立闹钟)。前者数量少但任务重,后者数量恐怖但单个轻。
类比:公司行政订了一个"每天 9 点全员打卡提醒"(周期任务),同时每个员工还各自设了"会前 10 分钟提醒"(延迟任务)。行政的难点是大扫除那天活儿太重要分工;个人闹钟的难点是数量太多,不能给每个闹钟雇一个人盯着。
🕐 整点脉冲
Cron 表达式天然喜欢整点(0 0 * * *),导致凌晨0点/1点出现人为制造的调度洪峰——自己给自己造了个"秒杀"。
👥 集群去重
任务服务部署 10 个实例,"每天发一次账单"绝不能发 10 次——分布式环境下"谁来执行"必须有共识。
📮 不能漏
执行到一半机器宕机、调度中心重启期间到期的任务——漏跑一次还款扣款,就是资损事故。
⚖️ 分片均衡
"给 5000 万用户发推送"这种大任务,要切成分片摊给整个集群;切得不匀,就会一台累死、九台围观。
🔀 依赖编排
报表任务要等对账任务先完成——任务间有 DAG 依赖,上游失败下游怎么办,都是调度语义的一部分。
📈 触发精度分层
订单关单差 5 秒无所谓,但优惠券整点生效差 5 秒会被投诉——不同任务对精度的要求差异巨大,可分级对待。
整体运作流程
主流架构是"调度中心 + 执行器集群"分离:调度中心只负责"看表掐点"(谁到期了、派给谁),执行器只负责"干活"。海量延迟任务则常用时间轮或延迟消息专门处理——不为每个闹钟雇人,而是雇一个"敲钟人"每秒看一格。
核心瓶颈与数据一致性挑战
👯 重复执行:最常见的翻车
调度中心派活后没收到 ACK(网络抖动/执行器GC),触发重试——但其实第一次已经执行了。还款扣两次、短信发两条。解法:调度侧"分布式锁+状态机"防重派,业务侧幂等键兜底(双保险缺一不可)。
🕳️ 漏触发:静悄悄的事故
调度中心主节点宕机、主备切换的 30 秒里到期的任务无人问津。解法:启动后补扫(捞出"应触发而未触发"的任务补跑)+ 任务表带状态字段可对账——漏跑要能被发现,更要能被补救。
⚖️ 分片倾斜:一台累死九台围观
按 uid%10 分片发推送,但大客户的数据集中在某几个分片——数据分布不均导致执行时长差 10 倍。解法:更细粒度的动态分片(任务队列模式:干完领下一批),代替静态取模。
🌊 调度洪峰自伤:DB 被自己人打挂
凌晨 0 点 5000 个 Cron 同时开跑,全都猛查业务库——调度系统自己制造了秒杀现场。解法:错峰(加随机抖动 offset)、分级(非关键任务错开整点)、对下游限流。
优化手段、获益与代价
① 调度中心选主 + 执行器分离 架构
调度中心多实例部署但只有 Leader 派活(DB乐观锁/ZooKeeper选主),执行器无状态水平扩容。
② 分片广播(静态/动态) 扩展
大任务广播给所有执行器,每台按分片参数处理自己那份(index/total);进阶版用"任务队列"动态领取。
③ 时间轮 / 延迟消息处理海量小闹钟 核心
订单关单类"一单一闹钟"任务不进调度中心,改用 RocketMQ 延迟消息 / Redis ZSet 轮询 / 内存时间轮。
④ 幂等键 + 状态机 一致性
每次执行携带唯一执行ID,业务表加"执行状态"字段:INIT→RUNNING→DONE,重复请求直接短路返回。
⑤ 错峰 + 随机抖动 防自伤
给 Cron 加随机偏移(0点0分 → 0点0~15分随机),非关键任务主动避开整点;对下游 DB 设并发上限。
多种解决方案对比与选型建议
| 方案 | 任务规模 | 不重不漏保障 | 运维成本 | 适用场景 |
|---|---|---|---|---|
| 单机 Cron / Spring @Scheduled 最朴素方案 | 几十个 | 无(多实例会重复) | 零 | 单体应用、非关键任务 |
| @Scheduled + 分布式锁 Redis/DB锁抢执行权 | 百级 | 防重但不防漏 | 低 | 小集群过渡方案 |
| XXL-Job 调度中心+执行器 · 国内主流 | 万级 | 失败重试+路由策略 | 中 · 需部署调度中心 | 中大型业务的标准答案 |
| PowerJob / DolphinScheduler 支持DAG工作流编排 | 十万级 | 强 · 支持依赖重跑 | 中高 | 任务间有复杂依赖/大数据ETL |
| 延迟消息/时间轮 RocketMQ延迟/Redis ZSet | 千万级单次任务 | MQ可靠投递+幂等 | 中 | 订单关单等海量"一次性闹钟" |
推演检验:换个条件还成立吗?
背下架构图不算学会,能判断"条件变了方案还成不成立"才算。先自己推,再展开参考。
推演 1:如果调度中心整体宕机 10 分钟后恢复,这 10 分钟里错过的定时任务怎么办?全部补跑一遍对吗?
💡 参考推演
不存在统一答案,这就是 misfire 策略要按任务语义选:对路同步这种"只要最新一次"的任务,补一次即可(中间错过的丢弃);对每小时对账这种"每次都有意义"的任务,要逐次补齐;对报表推送这种过时就没用的,直接跳过。前提是任务幂等——补跑两遍不能出两份错账。恢复策略不是框架配置项,是业务决策。
推演 2:如果凌晨跑批任务从 10 分钟涨到 3 小时(数据量涨了 20 倍),架构要跟着变吗?
💡 参考推演
两个问题会先后爆发:① 单机 3 小时可能撞上早高峰——上分片广播,把数据按 ID 取模切给 N 台机器并行跑,3 小时压回十几分钟;② 如果任务周期比执行时长还短(每小时调一次但跑 3 小时),还要选阻塞策略:串行等待、丢弃后续还是覆盖前一次?任务变重从来不只是"跑慢点",它会把调度重叠、资源争抢这些隐藏问题一并引爆。
推演 3:如果我的小网站每天只有 100 单,订单超时关单还需要延迟消息吗?扫表轮询不行吗?
💡 参考推演
完全行,而且更好:每分钟扫一次订单表,100 单的表扫描毫无压力,方案简单到不会出错、不依赖任何中间件。延迟消息是为"千万级订单 + 秒级精度"准备的,小体量上它反而多了一个故障源。方案没有高级与低级,只有与体量匹配与否——这和选型口诀里"单体别折腾"是同一个直觉。