这篇文章想聊的,是后端工程里一个特别常见、但总被想当然的问题:那种"不能丢、要保鲜、多实例还不能重复跑"的刷新类任务,到底该怎么调度、放哪里执行?
本文用一个真实项目(ihmm 互联网医院)里的号源刷新任务作为样本:它是怎么实现的(长轮询线程 + 两级 Redis 分布式锁、默认 30 分钟刷新一次、周期按医院可配),这套实现暴露了哪些问题,以及它和外部的分布式调度中心(XXL-JOB / SchedulerX 这类)相比,各自在什么场景更合适。
一句话结论:调度方式没有高低之分,只有合不合适。任务少、频率可控、怕外部依赖的,本地化更稳;任务多、要管控、要分片重试告警的,外部调度更香。而"本地化"本身也有多种实现路径——@Scheduled只是其中一种,自研线程 + 分布式锁是另一种。
一、先看 ihm 真实样本:号源刷新任务是怎么跑的
1.1 任务本身的画像
号源刷新要做什么:把每家医院、每个科室的挂号号源缓存刷新到最新,保证患者端看到的"有没有号"相对准确。它有三个天然特点:
不能丢:号源过期患者就约不上,属于"闹钟必须响"的任务。
重:一次全量刷新要拉起一家医院下所有科室,SQL 多、依赖 HIS 接口慢。
多实例下不能重复刷:部署了 N 个实例,同一家医院同一时刻只能有一个实例去刷,否则 HIS 会被打爆。
这三个特点,正好把"本地化 + 分布式锁"这套打法的取舍都暴露出来了。
1.2 它现在是怎么被调度的
核心类:RefreshHospitalScheduleTask(@Component+@RefreshScope,启动即初始化)。
graph LR A["应用启动 @PostConstruct"] --> B["按接入类型 SETTLE_IN / HANDBAG<br/>各起一个长轮询线程"] B --> C["每 10 秒抢 Redis 锁<br/>SCHEDULE_TASK_<类型>(TTL 5 分钟)"] C -->|抢到锁的实例| D["提交所有医院到<br/>专用线程池(3 线程)"] D --> E["每个医院 Redisson 锁<br/>refreshHospitalSchedule:<id> tryLock 5s"] E --> F["判科室号源缓存是否到期<br/>到期才刷 + 记录下次刷新时间"] F --> G["@Scheduled cron=5 0 0 * * ?<br/>每天 00:00:05 清前一天号源/排班左移"]几个关键点:
长轮询驱动,不是倒数计时:线程死循环
sleep(10s)后抢一个Redis 锁(setIfAbsent,TTL 5 分钟),只有抢到锁的那个实例才执行本轮刷新,其他实例空跑等下一轮。这就是最朴素的多实例去重。医院级二次去重:抢到类型锁后,还以"医院"为单位再抢Redisson 锁
refreshHospitalSchedule:{hospitalId}(tryLock(5s)),确保同一家医院同一时刻只有一个实例去刷,避免把 HIS 打爆。科室层面按 TTL 过期才刷:每个科室刷完,会写一个
DEPT_RREFRESH_CACHE_FLAG:{deptId}到 Redis 记录"下次刷新时间",到期才真正刷新。不是每次都全量。周期按医院可配置:下次刷新时间 =
now + intervalHour 小时 + intervalMin 分钟,代码默认intervalHour=0、intervalMin=30,也就是默认每 30 分钟刷一次号源缓存;未开启号源缓存的医院,默认每 2 小时至少大刷一次(scheduling.bigRefreshHours=2)。配置项里还有一个
@Scheduled(cron = "5 0 0 * * ?"),每天凌晨 0:00:05 删前一天号源数据、做排班表左移——这是整个类里唯一直接用 Spring@Scheduled的定点任务。
1.3 这套实现暴露的问题
结合代码逐条拆,问题其实都在明面上:
没有用 Spring 调度,自研长轮询 + 手写锁。
sleep(10s) + setIfAbsent + Redisson tryLock + latch.await(10min)这一套是自己写的,意味着没有失败重试、没有告警、没有可视化后台、不能一键手动触发——全得看日志。多个"魔法数字"要人工调:抢占周期 10s、类型锁 TTL 5 分钟、医院锁 tryLock 5s、等待上限 10 分钟。锁时间设置不合适,可能出现"锁提前过期另一个实例进来重复刷"或"一个实例卡住把其他实例也挡住"的边界问题。
重启/时钟漂移的脆弱性:长轮询线程跟着应用进程走,实例重启窗口任务可能空窗;Redis 锁依赖时钟,多机房场景要小心。
好处也得说:它完全不占 Tomcat 工作线程,和患者挂号流量物理隔离;多实例靠分布式锁天然只跑一次;刷新周期按医院可配。这些正是它敢长期这么跑的原因。
二、把两个概念讲明白,别搞混
2.1 什么是外部调度(HTTP / RPC 远程触发)
独立部署一个分布式调度中心(XXL-JOB、SchedulerX、Elastic-Job),到了预设时间,它通过 HTTP / RPC 远程调用你业务服务暴露的接口来触发任务。任务配置、启停、日志都在调度中心后台,业务代码不关心"什么时候跑"。
打个比方:你雇了个闹钟助理,他不住你家,在隔壁楼办公室,到点了给你打电话喊你干活。活儿是外面的人通知你干的。
2.2 什么是本地实现(进程内触发)
任务的触发和执行都在当前业务应用的进程内部。最常见的写法是 Spring 的@EnableScheduling+@Scheduled;但不代表只有这一种——本文的 ihm 样本就是用自研长轮询线程 + Redis 分布式锁做的本地实现。共同点是:不依赖任何外部调度平台,不暴露对外接口。
打个比方:你自己手机上定的闹钟,就在枕头边,到点直接响。闹钟是自己响的,不靠外面打电话。
2.3 一张表看懂核心区别
| 对比维度 | 外部调度(XXL-JOB 等) | 本地 @Scheduled | 自研线程 + 分布式锁(ihm 式) |
|---|---|---|---|
| 触发方式 | 调度中心远程调用 | 进程内方法调用 | 进程内长轮询线程 |
| 部署形态 | 需单独部署调度中心 + 执行器 | 无额外部署 | 无额外部署 |
| 代码侵入性 | 写执行器 Handler、暴露接口 | 一个注解 | 自研线程 + 锁,最原始 |
| 多实例去重 | 原生支持(路由策略/锁) | 需 ShedLock / DB 锁 | 自己写(Redis 锁) |
| 失败重试 / 告警 | 平台自带 | 自己写 | 自己写(本文样本就没有) |
| 可视化管控 | 有完整后台 | 无 | 无 |
| 动态手动触发 | 后台一键 | 自己写 | 自己写 |
| 额外故障点 | 调度中心、网络、防火墙、鉴权 | 应用自身 | 应用自身 + Redis |
| 刷新周期 | 平台配 | 注解配 | 业务配置(可每医院独立) |
三行都试过了就会明白:调度平台是"省心"换"重依赖",自研是"零依赖"换"什么都自己写"。ihmm 这种自研方案,本质是牺牲可观测性去换"零外部依赖 + 线程物理隔离 + 周期可控"。
三、这类"刷新任务"为什么可以本地化?四条理由
理由一:不占 Tomcat 线程,任务再重也拖不垮用户
看调度线程和 Tomcat 工作线程的关系:
graph TB subgraph S["本地化之后:调度线程和用户线程物理隔离"] R["自研长轮询线程 + 3 线程刷新池"] -->|"进程内调用"| C[("Redis 分布式锁<br/>多实例去重")] C --> D[("医院号源刷新<br/>SQL + HIS 接口")] U["患者挂号请求"] --> T["Tomcat 工作线程池"] T --> D end哪怕刷新任务再慢,也是在独立的刷新线程池里排队,患者挂号接口丝毫不受影响。
理由二:Redis 分布式锁天然解决多实例只跑一次
集体部署 N 个节点,外部调度靠"路由策略"去重,本地实现就要自己处理。ihmm 用的是两级锁:类型级锁(setIfAbsent抢谁执行本轮)+ 医院级锁(避免同一家医院被并行刷)。这是把"多实例重复执行"这个问题,用最朴素的分布式互斥解决了。
理由三:不暴露对外接口,核心刷新闭环更安全
本地实现后,刷新逻辑就是个进程内方法,外部摸不到,从源头消除被恶意调用/被刷的风险,也不用维护一套针对调度中心的鉴权。
理由四:砍掉一整条外部依赖链
任务能不能跑,取决于:调度中心活着 + 网络通畅 + 防火墙放行 + 鉴权正常。本地实现只取决于一件事:业务服务自己活着(以及 Redis 活着)。
四、但本地化也要付出代价:ihm 样本正好踩中
这是本地化绕不开的四条"欠账",ihmm 都有,也是你挖本地方案前必须先想清楚的:
没有重试。一次刷新抛异常,日志一记,下一轮 30 分钟后再说。金融对账、医保上报这种"绝对不能丢"的任务,本地方案会很痛。
没有告警。任务停了不会有人主动知道,得靠监控曲线发现"号源没更新了"。
没有后台/一键手动触发。运营想"立刻全量刷一次",要么改配置重启,要么自己加权限接口。
去重逻辑要自己写对。ShedLock / Redis 锁的 TTL、等待时长都是工程细节,写错就有重复执行或互相死等。
所以选型口诀是:要"不丢 + 告警 + 可视化 + 分片"的,老老实实用外部调度;就一两个刷新/清理任务、怕外部依赖的,本地化更香,但要提前补好重试和告警。
五、什么时候老老实实用外部调度?
任务多且分散在多个服务(口诀:任务 > 30 或涉及服务 > 3)——改时间不用挨个服务发版。
需要分片并行跑大数据批处理——平台原生分片,自己造轮子成本高。
任务绝对不能丢、要重试 + 告警 + 全链路日志——平台自带,从零写不现实。
需要动态启停、临时手动触发——后台点一下就行。
集群部署下要保证只执行一次 + 负载均衡——路由策略天生解决。
六、什么时候本地化更香?
任务简单、数量少,不想引入额外中间件——为两三个任务维护一套高可用调度集群,多一个中间件多一个故障点。
对无谓开销/线程隔离敏感——不想任务和用户抢 Tomcat 线程。
核心业务逻辑不想对外暴露接口——攻击面直接归零。
网络环境复杂或不可靠(专有云 / 医院内网,跟调度中心之间隔好几层网络设备)——本地化少一个网络依赖。
团队小、没专人维护调度平台。
七、回到 ihm 样本:3 秒高频和 30 分钟低频,怎么影响选型?
很多人会纠结"@Scheduled 还是 XXL-JOB",但其实还有一层更值得先想清楚的问题:你的任务频率到底是多高?频率不同,结论完全不同:
| 任务频率 | 典型场景 | 更合适的方案 | 原因 |
|---|---|---|---|
| 毫秒/秒级高频(< 1 分钟) | 缓存心跳、秒级状态轮询 | 本地实现 | 高频任务走外部 HTTP,网络+鉴权+序列化开销是纯浪费 |
| 分钟级 / 30 分钟级(本文样本) | 号源保鲜、频次可控的刷新 | 本地实现或外部均可 | 频率不高,主要看是否要"重试/告警/后台/分片" |
| 小时级 / 定点 | 日终对账、凌晨批处理 | 外部调度 | 低频但重要,常用平台的重试告警分片能力 |
ihmm 号源刷新属于"30 分钟级、重、不能丢、多实例不能重复"的任务:它选择了本地化 + 自研锁(换来零外部依赖、线程隔离、周期可配),代价是没有重试/告警/后台。如果哪天真要求"号源刷新必须有告警、可一键触发、可分片",就该考虑迁到外部调度——这就是选型的取舍,没有绝对对错。
八、一张速查表,选型直接对照
| 你的实际情况 | 推荐方案 | 理由 |
|---|---|---|
| 任务 ≤ 10 个,集中在 1~2 个服务 | 本地实现 | 引入调度平台不划算 |
| 执行间隔 < 1 分钟的高频任务 | 本地实现 | 避免高频网络开销 |
| 核心接口不想对外暴露 | 本地实现 | 从源头消除攻击面 |
| 网络环境复杂 / 跨机房 | 本地实现 | 少一个网络依赖 |
| 小团队,无专人运维 | 本地实现 | 零运维成本 |
| 任务 30+,分布在 5 个以上服务 | 外部调度 | 统一管控效率高 |
| 需要分片并行跑批处理 | 外部调度 | 原生支持 |
| 任务要求不丢、要有告警重试 | 外部调度 | 平台自带能力 |
| 需要动态启停 / 手动触发 | 外部调度 | 后台一键操作 |
| 集群部署且不能重复执行 | 两者皆可 | 外部天生解决,本地需自己加锁 |
最后说两句真心话
太多项目,明明就几个刷新/清理任务,为了"技术先进"硬上分布式调度,最后调度中心出的故障比任务本身还多,本末倒置;也见过反过来的,几十个任务散在七八个服务全靠手搓,改个时间要发七八个版本。
本文这个 ihmm 号源刷新样本,其实很能说明问题:它没有用任何新潮技术——自研长轮询线程 + 两级 Redis 分布式锁——就把"刷新不丢、多实例不重、不占用户线程、周期可配"做到了,代价是重试/告警/后台缺位。这个取舍本身,就是最好的选型教材:先想清楚任务频率和要求,再决定放哪里、用什么调度,而不是反过来被技术绑架。
如果这篇文章能帮你少踩一个坑,那就值了。