尧图精选

Spring Boot定时任务全解析:从@Scheduled到分布式调度实践

🕒 发布时间:2026/10/2 18:30:03 📁 来源:尧图网络
做后端开发这几年定时任务几乎是每个项目都绕不开的活。报表统计、缓存刷新、订单超时处理、数据归档甚至每天的巡检通知都依赖一个靠谱的调度机制。很多人一开始都用 Spring Boot 自带的 Scheduled几行注解就能跑起来确实省事。但真正上了生产环境尤其是实例一多、任务一多问题就全冒出来了任务重复执行、线程阻塞、错乱执行、甚至整个应用被拖垮。这篇文章我就把自己在 Spring Boot 里做定时任务的一套完整思路梳理一遍什么场景用什么方案、注解怎么配不踩坑、cron 表达式怎么写才稳妥、多实例部署时怎么避免重复执行最后再聊聊分布式定时任务的选型。适合正在准备给项目加定时任务、或者已经被线上定时任务折腾过几轮的 Java 开发者。1. 先想清楚你的定时任务属于哪一类1.1 从执行时长和部署形态两个维度拆任务定时任务看似统一实际上不同类型的任务需要完全不同的处理方式。我习惯先把它从两个维度拆开看执行时长和部署形态。执行时长维度可以把任务分成短任务和长任务。短任务通常是几百毫秒到几秒内就能跑完的逻辑比如每 5 分钟刷新一次配置缓存、每分钟拉取一次第三方接口数据、每小时汇总一次简单的统计指标。这种任务逻辑简单直接用 Scheduled 就够了成本低、依赖少。长任务则完全不同比如每天凌晨同步几百万条数据、批量生成月度财务报表、清理过期文件这类任务可能要跑几十分钟甚至更久。长任务往往会占用大量 CPU、IO 和数据库连接如果不做精细化控制很容易把整个应用拖垮这一点后面会单独再讲。部署形态维度则要看你的服务在线上到底跑几个实例。最常见的误解是我见过不少人觉得我们配了负载均衡有多个节点所以就是分布式了。其实多个节点如果同时执行同一个凌晨任务那不叫负载均衡那叫重复执行。这两个维度组合下来基本可以确定你的方案单实例 短任务Scheduled 直接上不纠结多实例 短任务需要分布式锁或者直接上调度平台单实例 长任务Scheduled 异步 分批处理多实例 长任务调度平台 分片、路由策略1.2 组合场景下的初步选型建议上面四种组合不是每种都需要上一整套完整的调度平台。我认为选型的基本原则是先上手成本最低的方案只有当它确实无法满足需求时再升级。这里可以用生活化的方式来理解。Scheduled 就像一个随手可用的闹钟适合简单的个人提醒Quartz 像一个功能更强大的日程管理软件能处理复杂规则而 XXL-Job、Elastic-Job 这类定时任务平台则更像一个多人的值班排班系统不光管理你的时间表还能协调多个执行人之间的关系。举个例子如果你的项目是公司内部的报表后台单机部署每天凌晨跑一次汇总统计用 Scheduled 完全足够。但如果你在做的是一个电商平台订单超时要自动关闭、会员到期要自动提醒、库存不够要自动补货、对账数据要定时推送而且服务拆成了多个微服务模块还在 Kubernetes 上起了多个副本那就必须认真考虑分布式调度方案否则某个凌晨三个副本一起发短信给用户第二天客服电话会被打爆。当你在选型时拿不准我建议先用一个最简单的任务把执行日志跑起来观察一段时间。不要一上来就引入额外平台定时任务本身不复杂复杂的是任务和业务数据之间的关系。日志会帮你建立对任务规模、执行时长的真实感知再决定要不要升级方案会理性得多。2. Scheduled 注解单机定时任务的快速实现2.1 第一个定时任务这样写Scheduled 是 Spring Framework 提供的定时任务注解Spring Boot 只是把它包装得更顺手。想要用起来第一步是在启动类或者任意配置类上加 EnableScheduling这一步是告诉 Spring 容器我要启用任务调度能力SpringBootApplication EnableScheduling public class OrderApplication { public static void main(String[] args) { SpringApplication.run(OrderApplication.class, args); } }然后在需要定时执行的方法上加 ScheduledComponent public class CacheRefreshTask { private static final Logger log LoggerFactory.getLogger(CacheRefreshTask.class); Scheduled(cron 0 */5 * * * ?) public void refreshCache() { log.info(定时刷新配置缓存开始...); // 拉取配置刷新本地缓存 } }这段代码看起来非常简单但背后触发任务的时机是由 Scheduled 属性决定的。常用的属性有 cron、fixedRate、fixedDelay、initialDelay 四个它们的含义完全不同用错了会让任务执行频率和预期差很多。cron 最灵活按表达式触发精确到秒0 */5 * * * ? 就是每 5 分钟执行一次。fixedRate 是从上一次任务开始时间算起每间隔固定毫秒执行一次。fixedDelay 是从上一次任务结束时间算起间隔固定毫秒执行一次。initialDelay 则是延迟指定毫秒后首次执行常和上面三者配合避免应用刚启动时任务立即执行。2.2 cron 表达式别只靠背关键规则要说清楚cron 在 Spring 里一般是六段或者七段结构秒 分 时 日 月 周第七段是可选的年。和 Linux 里的 cron 不一样Spring 的 cron 是自带秒段的这也是很多从 Linux 转过来的新手最容易踩的坑。比如0 0 2 * * ?表示每天凌晨 2 点执行如果手滑写成0 2 * * ?那你每分钟的第 2 秒都会触发一次这个任务就像发了疯一样频繁执行日志瞬间刷爆。日和周是互斥的两个字段通常不能同时设置具体值必须用 ? 代替其中一个。比如每月的 15 号触发可以写成 0 0 2 15 * ?周字段用 ?。再比如 0 0 2 ? * MON-FRI 表示周一到周五每天凌晨 2 点执行日字段用 ?。这是一个很容易出错、但一旦理解就非常稳定的规则。我强烈建议在写复杂 cron 表达式时用在线生成工具先转一下再结合自己的业务时间解读几遍不要凭记忆硬写。另外还有一个常见的业务需求是每天凌晨 2 点执行很多服务器时区不是东八区这时 cron 会按服务器系统时区解释执行时间和预期不一致。Spring 里可以通过 zone 属性直接指定Scheduled(cron 0 0 2 * * ?, zone Asia/Shanghai) public void dailySummary() { // 业务逻辑 }2.3 默认单线程的坑以及如何配置线程池Scheduled 默认的调度器是单线程的这个信息官方文档写得明明白白但实际开发中还是会反复踩。你可以这样理解代码里注册了 5 个 Scheduled 任务Spring 内部默认用只有一个线程的调度器挨个触发它们。假设任务 A 每天凌晨 2 点跑一个半小时任务 B 每天凌晨 2 点 10 分执行那这个 2:10 的任务就要等到 3:30 才能开始中间这一小段看起来就是任务突然不执行了。更严重的情况是如果一个任务内部出现了长时间阻塞比如调用外部接口一直等待响应整个调度线程被占住其他所有任务全部瘫痪。我见过不少项目线上出现定时任务突然不跑了的报警登到服务器上一查线程被一个 sleep 或者慢请求卡死了。解决方法是自定义一个 TaskScheduler配置一个合理的线程池Configuration public class SchedulingConfig { Bean(name myTaskScheduler) public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(scheduled-task-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(60); scheduler.setRemoveOnCancelPolicy(true); return scheduler; } }代码里两个容易被忽略的配置setWaitForTasksToCompleteOnShutdown(true) 会让应用停机时等待正在执行的任务完成setAwaitTerminationSeconds(60) 是最大等待秒数超过就强制结束。没有这两个配置应用发布时正在跑的长任务会被直接杀掉可能造成数据不一致。配置完以后你可以从日志的线程名前缀 scheduled-task- 来判断自定义线程池是否生效。2.4 任务方法内部的异常处理不能省Scheduled 任务如果方法内部抛出异常你会看到异常日志但这个任务未来还会不会继续执行不同版本和不同配置下有差异。实践中最安全的结论是别指望框架帮你恢复。我一贯的做法是在任何定时任务方法里都要做兜底异常捕获防止某个异常把整个任务打死。举个例子一个关闭超时订单的任务如果某一个订单处理失败了不应该影响后续其他订单的处理更不应该让整个任务停止调度Component public class OrderTimeoutTask { Scheduled(cron 0 0 1 * * ?) public void closeTimeoutOrders() { log.info(关闭超时订单任务开始); try { ListLong orderIds orderService.findTimeoutOrderIds(); for (Long orderId : orderIds) { try { orderService.closeOrder(orderId); } catch (Exception e) { log.error(关闭订单失败, orderId{}, orderId, e); } } } catch (Exception e) { log.error(关闭超时订单任务异常, e); } } }外层 catch 是防止整个任务退出内层 catch 是保证单个订单处理失败不影响其他订单。如果有需要还可以把失败记录写入 task_fail_log 表后续做补充处理。定时任务里最怕的就是一个异常炸掉全部任务的设计。3. 任务要可控、可观测这些细节不能省3.1 用开关控制任务的启停生产环境经常有这种需求运维要调整数据库希望相关任务先暂停但又不想整个应用重启。或者某些任务只在特定环境跑比如只在生产环境执行数据归档测试环境的定时任务干脆不执行。针对这类需求可以在配置文件里增加开关task: >Component public class DataArchiveTask { Value(${task.data-archive.enabled:false}) private boolean enabled; Scheduled(cron 0 0 3 * * ?) public void archiveData() { if (!enabled) { log.info(数据归档任务处于关闭状态跳过执行); return; } // 业务逻辑 } }这样不用改代码也不用担心环境判断写死在代码里。配置文件改一下刷新配置任务就启停可控了。如果是 Spring Cloud 项目还可以结合配置中心动态修改真正做到不重启就切换任务状态。3.2 任务执行记录排查问题的唯一线索定时任务总是半夜执行出了问题只能看日志。但如果日志没打全或者每次执行只打了开始没打结束排查时你就不知道任务到底卡在哪一步。我习惯在每个任务里记录完整的执行轨迹任务名称、执行节点 IP、开始时间、结束时间、执行结果。如果任务内部有多阶段还会记录每个阶段的耗时。实现方式很简单可以在任务方法内组装一条记录写入 log或者写一张 task_execution_log 表。这张表带来的价值非常大。有一次系统数据异常客户反馈某天的账单重复推送了。要不是 task_execution_log 表里记录了每台机器执行任务的时间、结果根本不可能判断出是哪个实例跑了两次任务。一翻表就发现两台实例都执行了同一任务分布式锁没生效定位速度比翻日志快了一天不止。建议日志里包含的字段至少包括 task_name、node_ip、start_time、end_time、status、error_msg。3.3 长任务拆分和分批策略长任务最怕的是一个事务里处理海量数据。比如一个近千万条的订单归档任务如果一次性把所有数据查出来放内存再启动事务逐条更新内存可能直接撑爆数据库连接也会被占满顺便把在线业务拖垮。我在实际项目里的做法是把长任务拆分成小批次。比如每次从数据库取 500 条待处理数据处理完之后提交事务再继续取下一批。这样每次事务都很短内存占用有界数据库连接不会长时间被占用。同时在批次之间加一点间隙比如 Thread.sleep(100)给数据库喘息的机会。Scheduled(cron 0 30 2 * * ?) public void archiveOldOrders() { long lastId 0; while (true) { ListOrder orders orderMapper.findOrdersByPage(lastId, 500); if (orders.isEmpty()) { break; } for (Order order : orders) { archiveOneOrder(order); } lastId orders.get(orders.size() - 1).getId(); } }注意这个 while 循环里的每个批次都是一个独立的小事务批与批之间不共享事务。如果其中一个批次失败只影响该批次下一批继续跑。这种设计虽然看起来不如大事务要么全成功要么全失败但对于海量数据的运维类任务可靠性反而更高已经处理完的数据不会因为后续失败而回滚只需要对失败批次做补偿即可。3.4 进阶用 SchedulingConfigurer 动态注册任务如果任务需要从数据库读取 cron 配置并且支持运行期动态修改就不能写死 Scheduled(cron ...) 了。这种情况可以实现 SchedulingConfigurer 接口动态注册一个触发器Component public class DynamicScheduleTask implements SchedulingConfigurer { Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.addTriggerTask( () - processDynamicTask(), triggerContext - { String cron dynamicTaskService.getCronFromDb(); return new CronTrigger(cron).nextExecutionTime(triggerContext); } ); } private void processDynamicTask() { // 业务逻辑 } }这样修改数据库里的 cron 值后不需要重启应用下一个调度周期就会生效。适合运营需要自助配置报表任务、接口同步任务这类场景。需要注意这种方式每次触发都会从数据库读取一次 cron所以要把读取方法写得轻量不要在里面做复杂查询否则每次触发都会引入不必要的数据库压力。4. 多实例部署时怎么避免任务重复执行4.1 为什么多实例会让同一个任务跑多遍如果你的应用以多副本的方式部署在 Kubernetes 里比如 3 个 Pod 共同对外提供服务那么每个 Pod 都会启动一份 Spring 容器每个容器中的 Scheduled 都会自己触发。结果是同一个任务3 个节点各跑一次。有些任务重复执行无伤大雅。比如刷新缓存任务3 个节点各刷新一次顶多浪费一点资源。但有些任务绝对不能重复给用户发短信、扣减库存、关闭订单、生成账单、推送消息。这类任务重复执行一次就是一次线上事故。我在项目里见过的真实案例是系统多实例部署后凌晨的会员到期提醒任务在三个实例上同时执行结果同一个用户收到了三条相同的到期提醒短信用户体验极差。另外一个需要注意的隐蔽场景是哪怕你用的是 Scheduled 数据库的某些记录去判断当前节点是否执行也可能出现判断和数据操作不是原子的情况导致重复执行。比如先查询任务状态表状态是未执行再执行但在并发环境下两个实例可能同时查询到未执行然后同时执行产生重复。4.2 轻量方案基于 Redis 的分布式锁如果你的项目已经引入了 Redis一个相对轻量的方法是给定时任务加分布式锁让同一时间只有一个实例能执行任务。核心思路是使用 Redis 的 SET NX EX 命令如果某个实例抢到了锁其他实例直接返回跳过。用 Spring 的 StringRedisTemplate 实现Component public class ReportSendTask { Scheduled(cron 0 0 9 * * ?) public void sendDailyReport() { String lockKey job:sendDailyReport; String requestId UUID.randomUUID().toString(); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(locked)) { log.info(其他实例正在执行日报发送任务本实例跳过); return; } try { // 真正的业务逻辑 } finally { String currentValue stringRedisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentValue)) { stringRedisTemplate.delete(lockKey); } } } }这段代码里有三个关键细节锁的过期时间必须大于任务最长执行耗时。如果任务要跑 10 分钟锁过期时间却只有 5 分钟那么任务还没结束锁就过期了另一个实例会抢到锁重复执行。delete 之前要判断 value 是否是自己的 requestId防止误删别的实例持有的锁。比如当前实例拿的锁已经过期另一个实例已经重新抢到锁这时如果当前实例直接 delete就把别人的锁删掉了。加锁和设置过期时间要保证原子性setIfAbsent(key, value, Duration) 在 Redis 里就是一条 SET NX EX 命令天然满足要求。这个方案适合任务数量不多、场景不太复杂的多实例应用比如定期刷新缓存、生成日报。但它的缺点也很明显没有失败重试、没有动态调整 cron、没有调度的可视化记录任务失败只能看日志。如果你的团队运维能力偏弱这个方案其实够用很长一段时间。4.3 成熟方案对比Quartz、XXL-Job、Elastic-Job 选哪个如果任务数量多、需要动态管理或者团队人多、需要运维同学也能操作那就要认真评估一个调度平台。我把常见方案整理如下方案部署依赖动态任务管理失败重试分片能力运维成本适用场景Scheduled 分布式锁Redis不支持不支持不支持低单机/多实例简单任务Quartz 集群数据库支持不支持不支持中老项目改造Spring 生态内XXL-JobMySQL支持支持支持中大多数互联网业务场景Elastic-JobZooKeeper/etcd支持支持支持高分片复杂、数据量大先说 Quartz 集群。Quartz 本身是非常成熟的调度框架支持内存和 JDBC 两种存储方式。集群模式基于数据库行锁实现任务互斥但配置相对繁琐需要建十几张表而且每次调度都要访问数据库任务量大的时候会给数据库带来不小的压力。如果是老项目里已经用了 Quartz可以继续维护新项目我更推荐先看看更现代的方案。再说 XXL-Job。国产开源调度平台社区活跃在国内使用率很高。核心组件是调度中心和执行器调度中心负责管理任务执行器负责真正执行任务两者可以独立部署。支持动态创建任务、cron 修改、失败重试、告警通知也有一定的分片能力。对我来说它最大的吸引力是有一个还算不错的管理后台非开发同事也能操作这对小团队特别友好。最后是 Elastic-Job。现在是 Apache ShardingSphere 生态的一部分它的强项是分片任务。比如有 10w 条数据处理需求、3 台执行器它能将数据按照某种规则平均分给 3 台机器并行执行这是 Scheduled 和 XXL-Job 在默认情况下都不容易做到的。但它的运维依赖 ZooKeeper 或者 etcd环境复杂度明显高一些需要团队有足够精力去维护这些中间件。4.4 从 Scheduled 平滑迁移到 XXL-Job如果你正在评估从 Scheduled 迁移到 XXL-Job这里给你一个平滑过渡的路线。第一步引入依赖我这里以 2.4.1 版本为例它在 Spring Boot 2.3.x 和 2.6.x 上都表现稳定。dependency groupIdcom.xuxueli/groupId artifactIdxxl-job-core/artifactId version2.4.1/version /dependency第二步在配置文件中配置调度中心地址和执行器xxl: job: admin: addresses: http://127.0.0.1:8080/xxl-job-admin accessToken: default_token executor: appname: order-service address: ip: port: 9999 logpath: /data/applogs/xxl-job/jobhandler logretentiondays: 30第三步创建执行器配置类Configuration public class XxlJobConfig { Value(${xxl.job.admin.addresses}) private String adminAddresses; Value(${xxl.job.accessToken}) private String accessToken; Value(${xxl.job.executor.appname}) private String appname; Value(${xxl.job.executor.port}) private int port; Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor executor new XxlJobSpringExecutor(); executor.setAdminAddresses(adminAddresses); executor.setAppname(appname); executor.setPort(port); executor.setAccessToken(accessToken); return executor; } }第四步把原来的 Scheduled 方法改成 XxlJob 注解方法Component public class DataArchiveJob { XxlJob(dataArchiveJob) public void dataArchiveJob() throws Exception { // 原来的业务逻辑原封不动搬过来 } }迁移的时候建议保留原有的 Scheduled 代码但先在配置里加开关把它关掉再打开 XXL-Job 的任务入口两边做一个短暂的交接期。千万不要在同一个时间段内两个调度方式同时跑同一个业务逻辑否则就是标准的多实例重复执行事故现场。5. 定时任务线上问题排查五个高频场景实录5.1 任务为什么一直不执行我遇到的任务不执行原因大概有这几类EnableScheduling 没加任务注册不到调度器上。这个最简单先查启动类的注解。cron 表达式写错特别是秒、分、时位置搞乱。到定时时间点前打个日志看到底有没有触发。任务方法被定义在非 Spring Bean 类里比如 new 出来的对象Spring 无法代理和执行。调度线程池被长任务占满新任务等待执行但没有线程能够运行。这一点可以从日志线程名看到所有任务都在一个线程上排队。任务方法内部抛异常导致任务自杀之后不再触发。这也是我一直强调要在任务方法里全包 catch 的原因。排查的第一步是看任务日志里有没有进入方法的入口日志。如果没有再看看线程池当前有多少活跃线程。通常一个线程名带 scheduled-task- 前缀的线程处于 WAITING 状态就能确认是线程不足。5.2 任务为什么执行了多次最常见的原因是服务部署了多个实例每个实例各自触发。可以通过日志上的 IP 和进程 ID 来确认。另外还有一种情况是同一个服务以多个进程方式在运行比如旧的发布进程没有完全退出新进程又起来了两个进程同时跑。还有一类隐蔽问题同一个 Scheduled 方法如果被多次注册比如配置类被多次扫描或者手动又 new 了一个 Bean也会导致多次触发。检查的时候重点看 Spring Bean 的定义方式别在一个类里既写 Component又手写一个 Bean 返回同类型的实例。如果是在 XXL-Job 里则要检查调度中心是否重复创建了同一条任务并且绑在了同一个执行器上。5.3 时区导致的任务时间错乱cron 表达式本身不携带时区信息Spring 会使用服务器默认时区来解析。如果你的服务器系统时区是 UTC而业务希望北京时间凌晨 2 点执行任务会跑到上午 10 点。排查这个问题很简单登录服务器执行 date 命令确认时区然后给 Scheduled 加上 zone Asia/Shanghai。如果用的是 XXL-Job也要看一下调度中心所在服务器的时区以及执行器日志时间两边时区不一致同样会造成任务时间不符合预期。5.4 定时任务把数据库连接池耗尽长任务在高峰期执行很容易把数据库连接池占光。比如一个任务深夜遍历全部用户发消息每个操作都要从连接池拿一个连接业务连接池就那么几个全被定时任务占用第二天早上用户正常请求就只能排队。我的做法是任务限流、分批、控制并发。连接池的配置里给定时任务单独一个小的连接池也可以比如单独定义一个 DataSource 给批量任务用。如果任务里有复杂的 SQL尽量使用只读或者分批小事务避免长时间持有一个连接。定时任务看似只在深夜跑但它的影响会在第二天早上集中爆发。5.5 分布式锁失效的几种场景有时候锁加了仍然重复执行排查下来大概有三种可能。一是锁过期时间设置太短。任务没执行完锁先过期另一个节点拿到锁又执行一遍。所以锁超时时间一定要根据任务的最长耗时来定还可以引入看门狗续期机制但那样复杂度就上去了。二是删锁时把别人的锁删了。当前节点执行时间过长锁已经过期并再次被其他节点拿到当前节点 finally 里直接 delete把别人的锁删掉了。解决方法是 value 做成唯一标识删除前比对参考上面代码里的 requestId 逻辑。三是 Redis 主从切换导致锁丢失。Redis 在极端情况下主节点故障、主从切换原锁信息就丢了。如果任务强一致要求分布式锁不能只依赖 Redis要上 Redisson 的看门狗或者 ZooKeeper 的临时顺序节点。别等到出了问题才来反思锁方案重要任务值得提前做一次设计评审。6. 最后再分享一点我的实际体会我在实际项目里踩过不少定时任务的坑最深的体会是别把定时任务当成多了几行注解的小事。启动简单但让它稳定长时间运行尤其是在多实例环境下不重不漏地执行才真正考验设计能力。如果你现在项目里还只是单机部署可以放心用 Scheduled一旦开始上集群尽早研究调度平台或者锁方案别等出了问题再补。最后再分享一个小技巧无论用哪种方案一定要把任务执行的开始时间、结束时间、状态、执行节点 IP 落到日志或数据库表里。排查问题的时候这些记录比什么都管用。定时任务看着不起眼却是最容易在深夜给系统埋炸弹的地方。根据我自己的经验一个相对稳妥的成长路径是先用 Scheduled 线程池 日志表把单机任务做实出现多实例重复执行时引入 Redis 分布式锁当任务种类变多、需要重试和告警时再迁移到 XXL-Job 这类平台。每个阶段做该阶段该做的事系统就会一直处于可控状态。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →