定时任务从外部调度搬回本地 @Scheduled:一次连接池被打满的复盘与选型思考

上周处理了一起数据库连接池被打满的线上故障,复盘完直接把跑了快一年的号源刷新任务,从外部分布式调度中心的 HTTP 调用模式,改成了 Spring 自带的本地 @Scheduled。改造本身不算大,但选型背后的判断逻辑值得单独唠一篇。

这篇文章会把出事现场、问题根因、两种调度模式到底差在哪、什么场景该用哪个、改造时踩了哪些坑全讲透,尽量用例子说话,刚入行的同学也能跟上。

先给个一句话结论:调度方式没有高低之分,只有合不合适。任务少、频率高、怕外部干扰的,本地调度更稳;任务多、要管控、要分片的,外部调度省心。

一、先还原一下事故现场

1.1 改造前的架构

我们项目里有个号源刷新任务,之前托管在 XXL-JOB 调度中心上。调度中心每 3 秒发一次 HTTP 请求,调用患者端服务暴露的 /api/refresh/source 接口,全量刷新所有自建医院的号源缓存。

注意看这张图,问题就藏在里面:定时任务的流量和正常用户的挂号流量,进了同一个大门(Tomcat 线程池),抢同一批数据库连接。

1.2 出事那天

一开始医院少、科室少的时候完全没问题。后来医院越接越多,号源刷新的频率也往上调,某天凌晨批处理高峰期直接炸了:

时间 现象 处理
02:00 监控告警:HikariCP 活跃连接数打满 30/30 开始排查
02:05 患者端页面大面积打不开,接口疯狂抛 SQLTransientConnectionException 确认是连接池耗尽
02:08 定位到号源刷新任务每次全量刷新,单次要跑 200+ 条 SQL 紧急停掉刷新任务
02:15 连接池回落,患者访问恢复 事故止血
03:00~ 复盘 + 评估改造方案 第二天开始改造

事后拉日志看了下,那个刷新任务高峰期一次执行要独占连接池 20 分钟,把 30 个连接吃得死死的,正常挂号请求排队都排不上。

1.3 复盘出来的三个致命问题

问题一:定时任务和正常业务抢线程、抢连接

外部调度发过来的 HTTP 请求,和正常用户的挂号请求走的是同一个 Tomcat 线程池。任务流量一上来,正常业务的线程直接被挤没。

问题二:每次 HTTP 调用都白付一堆"过路费"

一次远程触发,实际要经过这些环节:

一个 3 秒跑一次的高频任务,每小时要白跑 1200 次网络 + 鉴权 + 解析,全是无谓开销。

问题三:网络一抖,任务就不可控

之前出过一次事故:运维更新防火墙策略,调度中心发的请求全被拦截,号源缓存整整 6 小时没刷新,患者看到的全是过期号源。而这 6 小时里,业务服务本身完全健康,就是"闹钟打不进来"。


二、先把两个概念讲明白,别搞混

很多刚入行的同学分不清这两种模式,我用大白话解释一下。

2.1 什么是外部调度(HTTP/RPC 远程触发)

打个比方:你雇了个闹钟助理,他不住你家,在隔壁楼办公室。到点了给你打个电话喊你干活。

放到代码里:有一个独立部署的分布式调度中心(XXL-JOB、SchedulerX、Elastic-Job 这类),到了预设时间,它通过 HTTP 或 RPC 远程调用你业务服务暴露的接口,触发任务执行。任务的配置、启停、日志查看都在调度中心后台操作,业务代码本身不关心"什么时候跑"。

典型例子: 公司有 5 个微服务,每个服务里 10 个定时任务。你不想改个执行时间就得挨个服务改代码发版,就搭一个统一的 XXL-JOB,所有任务在后台统一管理,到点调度中心分别调 5 个服务的接口。这就是标准的外部调度。

2.2 什么是本地调度

打个比方:你自己手机上定的闹钟,就搁枕头边,到点直接响铃,不需要谁从外面打电话通知你。

放到代码里:定时任务的触发逻辑完全跑在当前业务应用的进程内部,最常用的就是 Spring 的 @EnableScheduling + @Scheduled。不需要对接任何外部平台,不需要暴露任何对外接口,Spring 自带的调度线程池到点直接调你写好的业务方法。

典型例子: 你写个小项目,每天凌晨 2 点清理 30 天前的过期日志。在方法上加个 @Scheduled(cron = "0 0 2 * * ?"),启动后到点自动跑,连第三方依赖都不用加。

2.3 一张表看懂核心区别

对比维度 外部调度(XXL-JOB 等) 本地调度(@Scheduled)
触发方式 调度中心远程调用(HTTP/RPC) 进程内直接方法调用
部署形态 需要单独部署调度中心 + 执行器 无额外部署,跟着应用走
代码侵入性 要写执行器 Handler、暴露接口 一个注解搞定
修改任务时间 后台改配置,不用发版 改代码 + 发版(或读配置中心)
网络依赖 依赖调度中心 ↔ 服务的网络 不依赖任何外部网络
集群去重 原生支持(路由策略/锁) 需自己处理(ShedLock/DB 锁)
分片并行 原生支持 需自己写分片逻辑
失败重试/告警 平台自带 自己写
可视化管控 有完整后台 无(看日志)
额外运维成本 高(多一个高可用中间件) 几乎为零
额外性能开销 网络 + 鉴权 + 序列化 进程内调用,几乎无
额外故障点 调度中心、网络、防火墙、鉴权 只有应用自身

三、这次为什么改成本地调度?四个理由

理由一:高频任务走 HTTP 是纯浪费

号源刷新 3 秒一次,一天要跑 28800 次。每次都要过网络、过网关、过鉴权拦截器、反序列化参数,这些开销乘上 28800 就很可观了。

改成本地调度后,就是进程里到点调一个方法,开销可以忽略不计。

理由二:线程池隔离,任务再忙也拖不垮用户

这是最关键的一点。看下改造前后的对比:

改造后,定时任务跑在专用的调度线程池里,和 Tomcat 的工作线程池物理隔离。哪怕任务跑慢了,也只是任务自己排队,患者挂号接口一点不受影响。

理由三:核心接口不再对外暴露,更安全

改造前 /api/refresh/source 是对外暴露的,加了鉴权也担心被人刷。改成本地调度后,这个方法就是个普通的内部方法,外部根本摸不到,从源头上消除了被恶意调用的风险。

理由四:砍掉一整条外部依赖链

改造前任务能不能跑,取决于:调度中心活着 + 网络通畅 + 防火墙放行 + 鉴权正常。任何一环出问题任务就停摆,而且停摆了你可能还不知道。

改造后只取决于一件事:业务服务自己活着。

改造效果(数据说话)

指标 改造前 改造后 变化
数据库连接占用(高峰) 30/30 打满 2 以内 ↓ 90%+
单次触发额外开销 网络往返 + 鉴权 + 解析 ≈ 50~200ms 进程内调用 < 1ms 基本归零
Tomcat 线程占用 每次触发占 1 个工作线程 0 彻底隔离
外部依赖 调度中心 + 网关 + 防火墙 + 鉴权 无 减少 4 个故障点
对外暴露接口数 1 个 0 个 攻击面减小
额外运维成本 需维护调度中心集群 0 ---

四、改造前后代码对比

4.1 改造前:被 HTTP 调用的 Controller

复制代码
@RestController
@RequestMapping("/api/refresh")
public class SourceRefreshController {
​
    @Autowired
    private SourceRefreshService sourceRefreshService;
​
    /**
     * 对外暴露的接口,被 XXL-JOB 每 3 秒调用一次
     * 问题:占用 Tomcat 线程、每次都要过鉴权拦截器、接口暴露在公网
     */
    @PostMapping("/source")
    @AuthCheck(roles = "job")   // 还得专门写一套鉴权
    public Result refresh() {
        sourceRefreshService.refreshAllSource();
        return Result.ok();
    }
}

配套还得有:调度中心的任务配置、执行器注册、鉴权拦截器、白名单配置、防火墙规则......一整套东西。

4.2 改造后:本地 @Scheduled

复制代码
@Slf4j
@Service
public class SourceRefreshTask {
​
    @Autowired
    private SourceRefreshService sourceRefreshService;
​
    /**
     * 每 3 秒执行一次(fixedDelay = 上一次执行结束后再等 3 秒)
     * 注意:这里必须捕获异常,否则一次报错会影响后续执行的可观测性
     */
    @Scheduled(fixedDelay = 3_000, initialDelay = 5_000)
    public void refreshSource() {
        long start = System.currentTimeMillis();
        try {
            sourceRefreshService.refreshAllSource();
            log.info("号源刷新完成,耗时 {}ms", System.currentTimeMillis() - start);
        } catch (Exception e) {
            log.error("号源刷新任务执行失败", e);
        }
    }
}

启动类加上开关:

复制代码
@SpringBootApplication
@EnableScheduling
public class PatientApplication {
    public static void main(String[] args) {
        SpringApplication.run(PatientApplication.class, args);
    }
}

4.3 关键一步:配置专用调度线程池

这一步千万别省! Spring 的 @Scheduled 默认是单线程的,如果任务跑得慢会互相阻塞。必须显式配置线程池:

复制代码
@Configuration
public class SchedulerConfig implements SchedulingConfigurer {
​
    @Override
    public void configureTasks(ScheduledTaskRegistrar registrar) {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        // 号源刷新是独立任务,给 2 个线程足够,别开太大
        scheduler.setPoolSize(2);
        scheduler.setThreadNamePrefix("local-scheduler-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(10);
        scheduler.initialize();
        registrar.setTaskScheduler(scheduler);
    }
}

划重点:这个线程池是给定时任务专用的,和 Tomcat 处理用户请求的线程池是两回事。哪怕定时任务全卡死,用户的挂号请求照样正常。


五、什么时候老老实实用外部调度?

别看了这篇文章就把 XXL-JOB 全下线了。下面这些场景,外部调度就是比本地调度强,别硬撑。

场景 1:任务数量多且分散在多个服务

比如 10 个微服务加起来 100 多个定时任务。用本地 @Scheduled 的话,改个执行时间要挨个服务改代码、重新发版,效率极低。外部调度后台点两下就完事。

判断口诀:任务数 > 30 或涉及服务数 > 3,优先外部调度。

场景 2:需要分片并行跑大数据批处理

例子: 每天凌晨跑千万级数据的日终结算,单节点要 2 小时。用 XXL-JOB 的分片路由,把任务拆成 10 个分片分给 10 个节点并行,20 分钟跑完。

本地调度要做同样的事,得自己写分片逻辑 + 分布式协调,成本高太多。

场景 3:任务绝对不能丢,需要重试 + 告警 + 全链路日志

例子: 金融的日终对账、医疗的医保数据上报。这类任务必须有失败自动重试、失败发钉钉告警、在线查看每次执行日志的能力。这些能力调度平台是自带的,自己从零写一套不现实。

场景 4:需要动态启停、临时手动触发

例子: 运营搞活动,要临时手动触发一次全量优惠券补发。外部调度后台点"立即执行"就完事;本地调度你得重启服务或者额外写个临时后台接口,还得多考虑权限问题。

场景 5:需要集群部署下保证只执行一次 + 负载均衡

例子: 订单超时自动关闭,部署了 4 个节点。用本地调度,4 个节点会各跑一次,要么加分布式锁要么改架构。外部调度的路由策略天然解决这个问题。


六、什么时候本地调度更香?

场景 1:任务简单、数量少,不想引入额外中间件

例子: 就两三个任务,每天清理过期缓存、刷新本地字典、导出个日报。为这几个任务单独部署一套 XXL-JOB 集群(还得做高可用),多一个中间件就多一个故障点。哪天调度中心挂了,所有任务全停,得不偿失。

场景 2:高频任务,对性能敏感

例子: 就是本文的号源刷新,3 秒一次。每次跨网络 HTTP 调用的开销远大于任务本身,改成本地调度后性能提升非常明显。

判断口诀:执行间隔 < 1 分钟的高频任务,优先本地调度。

场景 3:核心业务逻辑不想对外暴露接口

例子: 涉及资金、患者数据、内部缓存重建的敏感任务。本地调度后方法不再对外暴露,攻击面直接归零,也不用维护鉴权白名单。

场景 4:网络环境复杂或不可靠

例子: 我们那次防火墙误改导致 6 小时任务中断。如果业务服务部署在专有云/医院内网,和调度中心之间隔着好几层网络设备,那本地调度的稳定性优势会被进一步放大。

场景 5:团队小、没专人维护调度平台

例子: 三五个后端的小团队。搭 XXL-JOB 不难,难的是长期维护:版本升级、执行器注册异常、任务调度不执行的排查......本地调度这些问题基本不存在。


七、一张速查表,选型直接对照

你的实际情况 推荐方案 理由
任务 ≤ 10 个,集中在 1~2 个服务 本地调度 引入调度平台不划算
任务执行间隔 < 1 分钟 本地调度 避免高频网络开销
核心接口不想对外暴露 本地调度 从源头消除攻击面
网络环境复杂/跨机房 本地调度 少一个网络依赖
小团队,无专人运维 本地调度 零运维成本
任务 30+,分布在 5 个以上服务 外部调度 统一管控效率高
需要分片并行跑批处理 外部调度 原生支持,别自己造轮子
任务要求不丢、要有告警重试 外部调度 平台自带能力
需要动态启停 / 手动触发 外部调度 后台一键操作
集群部署且不能重复执行 外部调度 路由策略天然解决
金融/医疗合规要求审计日志 外部调度 有完整执行记录

八、选型流程图

嫌看图麻烦的,记这几条就够:

  1. 任务少于 10 个、集中在单服务 → 本地调度

  2. 执行间隔小于 1 分钟的高频任务 → 本地调度

  3. 核心逻辑不想对外暴露接口 → 本地调度

  4. 任务超 30 个、分布在 5 个以上微服务 → 外部调度

  5. 要分片并行、要告警重试、要可视化管控 → 外部调度


九、本地调度的 6 个避坑点(很多人栽在这)

改成 @Scheduled 不是加个注解就完事了,这几个坑我建议提前避开。

坑 1:默认单线程,任务会互相阻塞

@Scheduled 默认只有一个调度线程。如果你有 3 个任务,其中一个跑 10 分钟,另外两个会被卡住。

解决: 按第四节的方式配置 ThreadPoolTaskScheduler,按任务量给线程数。

坑 2:多实例部署会重复执行

这是最容易踩的坑。 应用部署了 3 个节点,@Scheduled 就会跑 3 次。如果任务是"发送短信""扣库存"这类有副作用的,后果很严重。

解决方案(按推荐顺序):

方案 适用场景 说明
ShedLock 最常用 用 Redis/DB 做分布式锁,加个注解
数据库乐观锁 无额外依赖 任务开始前 UPDATE ... SET locked_by=... 抢锁
只在一台节点开启 简单粗暴 用 @ConditionalOnProperty 或配置中心开关控制
任务本身幂等 首要前提 无论用哪种方案,任务都要保证幂等
复制代码
// ShedLock 示例:加个注解,多实例下同一时刻只跑一个
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "cleanExpiredCache", lockAtMostFor = "PT30M", lockAtLeastFor = "PT5M")
public void cleanExpiredCache() {
    // ...
}

坑 3:fixedRate 和 fixedDelay 别用混

注解参数 含义 适合场景
fixedRate = 3000 每 3 秒触发一次(从上次开始算) 任务必须按固定节奏跑,如心跳上报
fixedDelay = 3000 上次结束后再等 3 秒 任务耗时不定,如刷缓存、拉数据
cron 按表达式定时 定点任务,如每天凌晨 2 点

号源刷新我们用的 fixedDelay,因为它一次执行耗时不定,从开始计时容易堆积。

坑 4:cron 表达式是 6 位不是 7 位

Spring 的 @Scheduled cron 是 6 位(秒 分 时 日 月 周),和 Linux 的 5 位不一样,也没有 Quartz 的"年"位。

复制代码
// 错误:写成 5 位会直接报错
@Scheduled(cron = "0 2 * * *")        // ×
​
// 正确:秒 分 时 日 月 周
@Scheduled(cron = "0 0 2 * * ?")      // √ 每天凌晨 2 点

坑 5:任务抛异常会影响后续执行

任务里抛了未捕获的异常,Spring 会打日志然后继续调度,但下次执行的时间点可能受影响(fixedDelay 场景),而且排查起来很痛苦。建议统一 try-catch + 告警日志。

坑 6:高频任务一定要控制单次执行时长

3 秒一次的任务,如果单次要跑 5 秒,任务就会堆积。经验值:单次执行时长应小于调度间隔的 50%。 超了就改成增量刷新、分批处理,或者调低频率。


十、还有个更优解:能不能不做全量刷新?

补充一个思路。号源刷新这类场景,其实还有第三条路------事件驱动 + 增量刷新。

方案 实时性 数据库压力 复杂度
外部 HTTP 全量刷新 一般(3 秒延迟) 高(全量查) 中
本地 @Scheduled 全量刷新 一般(3 秒延迟) 高(全量查) 低
MQ 事件驱动增量刷新 高(毫秒级) 低(只刷变更) 高
本地 @Scheduled 增量刷新 较好 低 中

我们这次改造先选了本地 + 增量刷新 :只刷新最近有变动的号源(用 update_time 做游标),数据库压力直接从 200+ 条 SQL 降到 10 条以内。

技术选型从来不是"非此即彼",调度方式确定后,任务本身的实现还有优化空间。


最后说两句真心话

我见过太多项目,明明就几个简单的定时任务,为了"技术先进"强行上分布式调度,最后调度中心出的故障比定时任务本身的问题还多,完全是本末倒置。

也见过反过来的:几十个任务散落在七八个服务里,全靠 @Scheduled 硬撑,改个时间要发七八个版本,运维同学苦不堪言。

选型的核心不是哪个技术更新,而是哪个更适合你当下的业务规模和团队配置。 这次改造我们没用什么高大上的新技术,只是把一个高频核心任务从外部迁回本地,顺手做了增量优化,就解决了困扰我们大半年的连接池问题,改造成本不到两天,收益却是实打实的。

如果这篇文章能帮你少踩一个坑,那就值了。


参考代码: 完整可运行的 Spring Boot 示例(含调度线程池配置、ShedLock 集成、增量刷新 SQL)我整理好放在文末了,需要的同学评论区留言。


我是老于,一个在医疗后端踩坑多年的普通开发。主要分享线上事故复盘、架构选型和性能优化,欢迎关注。



附录:Mermaid 渲染失败时的 ASCII 备用图

如果发布后某张图还是渲染不出来(个别 CSDN 编辑器版本对 Mermaid 支持不全),把对应位置换成下面的代码块图即可,任何 Markdown 渲染器都能正常显示。

备用图 1(对应 1.1 改造前的架构):

复制代码
        ┌──────────────┐   每 3 秒一次 HTTP 请求   ┌──────────────────────┐
        │  XXL-JOB     │ ────────────────────────▶ │  Tomcat 线程池       │
        │  调度中心    │                           │  8000 端口(共用)   │
        └──────────────┘                           └──────────┬───────────┘
                                                              │
        ┌──────────────┐   正常挂号请求             ┌─────────▼───────────┐
        │ 患者 App /   │ ────────────────────────▶ │ /api/refresh/source │
        │   小程序     │                           └─────────┬───────────┘
        └──────────────┘                                     │
                                                   ┌─────────▼───────────┐
                                                   │ MySQL · HikariCP    │
                                                   │   30 连接(打满)   │
                                                   └─────────────────────┘

备用图 2(对应 1.3 问题二的链路图):

复制代码
 调度中心 → 网络传输 → Nginx/网关 → 鉴权拦截器 → 参数解析/反序列化 → 业务方法
            └──────────── 每 3 秒白跑一遍的"过路费" ────────────┘

备用图 3(对应理由二的改造前后对比):

复制代码
【改造前】任务和用户抢线程、抢连接
   XXL-JOB ──HTTP──▶ Tomcat 工作线程池(共用)──▶ HikariCP 30
   患者请求 ────────▶        ▲
​
【改造后】调度线程独立,互不干扰
   Spring 调度线程池(专用 2 线程)──▶ HikariCP 30
   患者请求 ─▶ Tomcat 工作线程池 ────▶ HikariCP 30

备用图 4(对应第八节选型流程图):

复制代码
                    ┌─────────────────────┐
                    │  我有个定时任务      │
                    └──────────┬──────────┘
                               ▼
              任务总数>30 或 涉及服务>3 ?──是──▶ 【外部调度】
                               │否
                               ▼
              需要分片并行跑大数据批处理?──是──▶ 【外部调度】
                               │否
                               ▼
              需要集群部署且不能重复执行?──是──▶ 【外部调度】
                               │否
                               ▼
              需要统一监控告警+失败重试?──是──▶ 【外部调度】
                               │否
                               ▼
          执行间隔<1分钟 或 核心接口不想对外暴露?─是─▶ 【本地调度】
                               │否
                               ▼
              任务少且不想引入中间件?──是──▶ 【本地调度】
                               │否
                               ▼
                    两者都行,按团队运维能力选

需要的话,我可以把第九节的避坑示例代码(ShedLock 完整配置 + 增量刷新 SQL)单独整理成一篇续文,方便这篇保持精简、代码细节另开一篇。

相关推荐
不停喝水1 小时前
【前端转全栈java速通课】 项目实战④12-14节 拦截器过滤器和全局配置-cors跨域设置-token体系-利用session做验证码
java·开发语言·前端·ai·容器
代码猎人1 小时前
怎么开发一个微信小程序
前端
console.log('npc')1 小时前
第 3 章 工程化与项目初始化
前端·人工智能·大模型
掘金者阿豪1 小时前
数据集成软件不能只看“同步成功”:聊聊金仓 KFS 和订单数据同步这件事
前端·后端
风骏时光牛马1 小时前
Navicat Premium 18 自动续试用天数
前端
hunterandroid1 小时前
HarmonyOS HUKS 密钥全生命周期实战:生成、轮换与失效处理
前端·华为
东鹏特饮1 小时前
贪吃蛇小游戏html
前端·javascript·html
溪语流沙1 小时前
【每天一个CSS | Day23】边框上有一道光在跑的流光呼吸卡片
前端·css
小马不起床2 小时前
零基础玩转 FastAPI_10:前端登录、权限与布局
前端·状态模式·fastapi