尧图精选

分布式定时任务原理与实践:从Redis锁到XXL-JOB的完整演进

🕒 发布时间:2026/9/2 2:39:09 📁 来源:尧图网络
“分布式定时任务怎么做”说实话很多后端程序员一听到这个问题第一反应是定时任务我项目里不就是加一个Scheduled注解吗但面试官紧接着来一句“那线上部署了多个实例你的任务会不会重复执行”的时候不少人就愣住了。这个问题的价值不在于让你背一个框架而在于考察你能不能把“单机思维”切换到“分布式思维”。今天这篇文章直接拆解单机定时任务在分布式环境下到底出了什么问题、怎么用 Redis 分布式锁兜底、什么时候该上 XXL-JOB 这类分布式任务调度平台、Quartz 集群又是干什么的最后给出可复用的代码、部署思路和排错清单。文章不是只讲概念每个方案都配有实际能跑的代码示例和验证思路Java 后端开发可以直接照着做也可以把它当成面试复盘材料。1. 方案对比速览先给一张速览表把普通选手、进阶选手和工业级选手的区别说清楚。面试官想听的就是这一层的方案演进思路。方案核心思路是否需要额外组件分布式支持适用场景优缺点单机 Scheduled进程内的定时任务调度不需要不支持单体应用、本地调试简单但多实例会重复执行Redis 分布式锁 Scheduled利用 Redis 锁保证同一时刻只有一个节点执行Redis支持但有锁过期和误删风险轻量级任务、已有 Redis 的项目实现简单锁的可靠性需自己把控Quartz 集群模式多个节点共享数据库中的 trigger靠数据库行锁避免重复调度数据库支持中小规模、不想引入额外调度平台数据库压力较大扩展性有限XXL-JOB独立的调度中心统一触发执行器注册到中心由中心分配任务数据库、调度中心服务支持很好生产环境、需要可视化和运维能力功能完善适合中大型团队消息队列延迟任务用延迟消息处理一次性定时任务MQ支持订单超时关闭等延迟场景不适合周期任务要额外管理消息面试时如果能按这个顺序讲出来就已经说明你不是只会写注解的“业务流水线工人”了。2. 分布式定时任务面试官到底在考什么一个面试题抛出之后先判断它想考察什么。分布式定时任务这个问题表面考的是“定时任务”实际上考四个点有没有真正遇到过多实例部署的问题。知不知道Scheduled在集群环境下会重复执行。能不能给出锁、调度平台、任务分片这些解决方案并说清楚各自适用边界。有没有工程经验任务失败重试、幂等处理、日志追踪、任务监控。面试官期待的答案不是一个“标准答案”而是一个“问题分析 - 方案选型 - 权衡取舍”的过程。举个真实的例子一个订单系统用户下单后 30 分钟未支付就要关闭订单。如果只是写一个Scheduled每分钟扫描一次未支付订单在单机环境没问题。但产品上线后为了保证高可用服务从 1 个实例扩容到 3 个实例这时候每分钟会有 3 个线程同时扫描“未支付订单”同一个订单可能被三个实例各自关闭一次也可能引发并发更新问题。所以问题的本质是定时任务从单机走向分布式之后如何保证任务不重复执行、不丢任务、能扩展。3. 单机定时任务在分布式环境下的问题分析先看最基础的写法很多项目里就是这么干的Component public class OrderTimeoutTask { private static final Logger log LoggerFactory.getLogger(OrderTimeoutTask.class); Scheduled(fixedDelay 60000) public void closeTimeoutOrders() { ListLong orderIds orderMapper.listTimeoutOrderIds(); log.info(开始关闭超时订单共 {} 个, orderIds.size()); for (Long orderId : orderIds) { orderService.closeOrder(orderId); } } }这段代码在本地跑没有任何问题一旦部署多个实例问题就出现了每个实例都会执行一次Scheduled方法。同一个订单会被多个实例同时读到。关闭订单的操作没有幂等性就可能出现重复关闭、重复扣减库存、重复发送通知。单点故障虽然解决了但任务质量反而变差了。为什么不能靠“部署的时候只在一个节点开启任务”来解决无法保证运维每次都记住只有一台机器开任务。如果开了任务的那个节点挂了整个定时任务就停了。任务量增加时无法横向扩容。所以分布式定时任务要解决的核心问题有三个不重复执行、故障可转移、性能可扩展。4. 方案一Redis 分布式锁 定时任务如果你的项目里已经有 Redis最直接的方案就是给Scheduled方法加一把分布式锁。逻辑很简单多个节点同时抢锁只有抢到锁的节点才执行任务其他节点直接跳过。4.1 基于 StringRedisTemplate 实现分布式锁先看最基础的实现思路用setIfAbsent加锁同时设置过期时间保证加锁的原子性Component public class OrderTimeoutTaskWithRedisLock { private static final Logger log LoggerFactory.getLogger(OrderTimeoutTaskWithRedisLock.class); private static final String LOCK_KEY task:order:timeout:lock; private static final Duration LOCK_EXPIRE Duration.ofSeconds(60); Resource private StringRedisTemplate stringRedisTemplate; Scheduled(fixedDelay 60000) public void closeTimeoutOrders() { // 尝试加锁setIfAbsent 过期时间保证原子性 Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(LOCK_KEY, 1, LOCK_EXPIRE); if (!Boolean.TRUE.equals(locked)) { log.info(未获取到锁本节点跳过本次执行); return; } log.info(获取到锁开始执行任务); try { ListLong orderIds orderMapper.listTimeoutOrderIds(); for (Long orderId : orderIds) { orderService.closeOrder(orderId); } } finally { // 释放锁 stringRedisTemplate.delete(LOCK_KEY); } } }这个版本有一个明显的坑如果业务执行时间超过了锁的过期时间锁会在任务还没结束时自动释放。这时另一个节点拿到锁两个节点就会同时执行任务。而且当前节点在 finally 里执行 delete 时可能把别的节点刚加上的锁误删掉。更稳的释放锁方式是使用 Lua 脚本先判断 value 是否是自己写入的再删除private static final String UNLOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end;使用的时候可以在加锁时设置一个随机 value释放时执行 Lua 脚本校验String requestId UUID.randomUUID().toString(); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(LOCK_KEY, requestId, LOCK_EXPIRE); if (!Boolean.TRUE.equals(locked)) { return; } try { // 执行业务逻辑 } finally { stringRedisTemplate.execute( new DefaultRedisScript(UNLOCK_SCRIPT, Long.class), List.of(LOCK_KEY), requestId ); }4.2 用 Redisson 解决锁续期问题手写分布式锁要考虑的问题很多锁过期时间设多长、业务超时怎么办、主从切换会不会丢锁。实际项目里更推荐用 Redisson它内置了看门狗机制锁快到期时如果业务还在执行会自动续期。Component public class OrderTimeoutTaskWithRedisson { private static final Logger log LoggerFactory.getLogger(OrderTimeoutTaskWithRedisson.class); Resource private RedissonClient redissonClient; Scheduled(fixedDelay 60000) public void closeTimeoutOrders() { String lockKey task:order:timeout:lock; RLock lock redissonClient.getLock(lockKey); try { // 最多等待 5 秒租约默认 30 秒看门狗会自动续期 if (lock.tryLock(5, TimeUnit.SECONDS)) { log.info(获取到锁开始执行任务); ListLong orderIds orderMapper.listTimeoutOrderIds(); for (Long orderId : orderIds) { orderService.closeOrder(orderId); } } else { log.info(未获取到锁本节点跳过本次执行); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.error(获取锁被中断, e); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } }这套方案适合什么场景适合任务量不大、定时任务数量不多、项目已经引入 Redis 的中小系统。优点是接入成本低不需要部署额外的调度平台缺点也很明显对锁的可靠性要求高时Redis 分布式锁的极端场景如主从切换仍然有锁丢失风险而且没有可视化的任务管理界面。4.3 面试高频追问问为什么 setnx 和 expire 要一起用答分开用会存在程序崩溃后锁永远不释放的问题必须保证加锁和设置过期时间是原子操作。问锁的过期时间怎么设置答要根据业务执行时间来估算同时配套看门狗或者红锁方案解决超时问题。问Redis 分布式锁在极端场景下可靠吗答主从异步复制可能导致锁丢失所以生产环境任务调度更推荐专业的调度平台。问你的幂等方案是什么答分布式锁只是“防止多节点同时执行”的手段最外层还要做业务幂等比如用订单状态机、唯一约束、数据库乐观锁。5. 方案二XXL-JOB 分布式任务调度平台如果项目已经好几套服务、十几个定时任务开发人员也需要经常调整任务执行时间Redis 锁方案就有点不够用了。这时候生产环境选择最多的是 XXL-JOB。5.1 XXL-JOB 是什么XXL-JOB 是一个开源的分布式任务调度平台核心设计分为调度中心和执行器两部分调度中心负责管理任务、配置 Cron 表达式、触发任务、记录执行日志、配置告警。执行器嵌入在业务服务中负责接收调度中心的指令真正执行业务逻辑。也就是说调度中心是一个独立的服务你可以直接拉源码编译后启动执行器则是集成到你的 Spring Boot 项目中的一个模块。任务触发由调度中心统一控制不再依赖每个业务节点自己的Scheduled从根源上避免了重复执行。5.2 快速集成步骤下面是通用的接入步骤具体版本请按你的项目实际情况调整。第一步在业务服务的 pom.xml 中加入依赖dependency groupIdcom.xuxueli/groupId artifactIdxxl-job-core/artifactId version2.4.0/version /dependency第二步在 application.yml 中配置执行器信息xxl: job: admin: # 调度中心地址多个地址用逗号分隔 addresses: http://127.0.0.1:8080/xxl-job-admin accessToken: default_token executor: # 执行器的应用名调度中心里注册的时候要跟这里一致 appname: order-task-executor # 执行器端口注意不要和业务端口冲突 port: 9999 # 日志保存路径 logpath: /data/applogs/xxl-job/jobhandler logretentiondays: 30第三步创建一个任务处理器Component public class OrderTimeoutJobHandler { private static final Logger log LoggerFactory.getLogger(OrderTimeoutJobHandler.class); Resource private OrderService orderService; XxlJob(orderTimeoutJobHandler) public void orderTimeoutJobHandler() throws Exception { XxlJobHelper.log(开始执行超时订单关闭任务); ListLong orderIds orderService.listTimeoutOrderIds(); int successCount 0; for (Long orderId : orderIds) { try { orderService.closeOrder(orderId); successCount; } catch (Exception e) { XxlJobHelper.log(关闭订单失败orderId{}, orderId, e); } } XxlJobHelper.log(任务完成成功处理 {} 条总任务 {} 条, successCount, orderIds.size()); XxlJobHelper.handleSuccess(关闭订单完成); } }第四步在 XXL-JOB 调度中心后台添加任务登录调度中心管理页面。执行器管理中新增执行器配置 AppName 为order-task-executor。任务管理中新增任务填写 Cron 表达式选择 JobHandler 为orderTimeoutJobHandler。启动任务后调度中心会在配置的时间点触发执行器。5.3 失败重试与任务日志XXL-JOB 自带失败重试能力在任务配置里可以直接设置失败重试次数。这是 Redis 锁方案需要自己写代码处理的而 XXL-JOB 把它变成了配置项。建议生产环境的每类任务都带上这些配置调度过期策略选择“忽略”或“立即执行一次”。失败重试次数根据任务幂等能力设置不能盲设。任务超时时间超过该时间主动结束调度。执行日志通过XxlJobHelper.log写入日志后续可以在调度中心直接查看。5.4 调度中心 API 调用示例XXL-JOB 调度中心本身提供 HTTP API可以在外部系统里动态触发任务。下面是一种常见的调用方式具体路径以你部署的 xxl-job-admin 版本为准# 触发一个已配置的任务 curl -X POST http://127.0.0.1:8080/xxl-job-admin/jobinfo/trigger \ -H Content-Type: application/x-www-form-urlencoded \ -d id1executorParamtest如果需要把任务的触发能力和现有系统打通例如在管理后台点击“立刻执行某任务”就可以通过这类 API 实现。5.5 XXL-JOB 适用场景XXL-JOB 比较适合中大型项目特别是有多个服务、多套任务、需要统一调度管理、任务失败需要告警的场景。它是目前 Java 后端最常见的分布式任务调度平台面试中提到的概率也很高。6. 方案三Quartz 集群模式Quartz 是 Java 生态里老牌的定时任务框架。它本身是单机框架但支持一种集群模式多个 Quartz 实例共享同一个数据库通过数据库中的表来管理任务节点之间通过数据库行锁来竞争执行权。6.1 Quartz 集群配置如果你的框架已经基于 Quartz可以在 application.yml 中配置集群模式spring: quartz: job-store-type: jdbc jdbc: initialize-schema: never properties: org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000 org.quartz.scheduler.instanceId: AUTO org.quartz.scheduler.instanceName: ClusterQuartzScheduler同时需要有一张 Quartz 的数据库表结构也就是qrtz_*系列的表。Quartz 就是利用数据库行锁来保证在多实例环境下同一时间只有一台实例会调度同一个任务。6.2 定时任务类写法Component public class QuartzTimeoutJob extends QuartzJobBean { private static final Logger log LoggerFactory.getLogger(QuartzTimeoutJob.class); Resource private OrderService orderService; Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { log.info(Quartz 集群任务开始执行); ListLong orderIds orderService.listTimeoutOrderIds(); for (Long orderId : orderIds) { orderService.closeOrder(orderId); } } }6.3 Quartz 集群优缺点优点是框架成熟不需要额外部署调度平台适合已经使用了 Quartz 的项目。缺点是调度能力依赖数据库任务量很大时数据库会成为瓶颈。没有可视化界面任务状态、执行日志都要自己开发。集群节点增加时数据库锁竞争会增加。所以 Quartz 集群更适合中小规模、任务数量可控、不想引入调度中心的场景。7. 方案四消息队列 延迟任务有一种业务场景是“30 分钟后关闭订单”这种本质上更适合用延迟消息而不是周期扫描RabbitMQ 的延迟消息插件。RocketMQ 的定时消息。Redis 过期事件 消费者监听不过这种方式可靠性和时效性都要小心。延迟消息的好处是“精准触发”不用每分钟扫一遍数据库性能更好缺点是不适合做固定周期任务比如“每天早上 8 点生成报表”就不适合用延迟队列。面试里提到这个方案主要是想表达“能区分周期任务和延迟任务是两种不同的问题”这是加分项。8. 任务分片与批量任务处理上面的方案解决了“任务不重复执行”的问题但还有一个问题没有解决如果任务量特别大比如要一次性处理一百万条数据单节点执行耗时太长怎么办答案就是任务分片。XXL-JOB 提供了分片广播的能力。调度中心可以同时通知所有执行器执行同一个任务每个执行器拿到不同的分片编号只处理属于自己的那部分数据。8.1 XXL-JOB 分片任务示例XxlJob(shardingOrderJobHandler) public void shardingOrderJobHandler() throws Exception { int shardIndex XxlJobHelper.getShardIndex(); int shardTotal XxlJobHelper.getShardTotal(); XxlJobHelper.log(当前分片{}/{}, shardIndex 1, shardTotal); ListLong orderIds orderService.listTimeoutOrderIds(); int successCount 0; for (int i 0; i orderIds.size(); i) { // 根据分片取模每个节点只处理自己负责的那部分任务 if (i % shardTotal shardIndex) { try { orderService.closeOrder(orderIds.get(i)); successCount; } catch (Exception e) { XxlJobHelper.log(关闭订单失败orderId{}, orderIds.get(i), e); } } } XxlJobHelper.log(分片任务完成成功处理 {} 条, successCount); XxlJobHelper.handleSuccess(分片任务完成); }分片的核心是让每个节点处理互不重叠的数据。具体怎么分布可以按i % shardTotal shardIndex也可以按主键区间分片、按哈希值分片根据数据分布情况选择。8.2 批量任务的工程化建议每次处理的数据量要控制可以分页扫描一次处理 500 条或 1000 条避免一次性加载全部数据到内存。单个任务失败不要影响整批任务记录失败主键并进入重试队列。批量操作加事务要谨慎大批量更新容易造成长事务和锁表建议先定位数据再逐批提交。9. 资源占用与性能观察分布式定时任务的性能观察点不太一样它不是看模型显存或 GPU而是看锁竞争、线程池、数据库压力和执行耗时。9.1 各方案的资源开销方案额外开销主要瓶颈观察方式Redis 锁 Scheduled每次执行多一次 Redis 请求任务本身耗时、业务线程池查看 Redis 慢日志、业务日志XXL-JOB调度中心服务、数据库表执行器线程池、调度中心 DB调度中心后台日志、DB 慢 SQLQuartz 集群数据库行锁数据库连接与锁竞争查看数据库锁等待最多的时间MQ 延迟任务MQ 队列开销MQ 消费能力查看消息积压情况9.2 执行器线程池观察XXL-JOB 执行器内部使用线程池执行任务。如果配置的执行器线程数过少任务会排队如果配置的线程数过多可能造成数据库连接池打满。建议第一次上线时先观察几个指标任务实际执行耗时。任务排队等待时间。同一时间运行的任务数量。9.3 锁竞争与任务阻塞Redis 锁方案里除了任务本身耗时还要观察Redis 连接池是否被打满。锁失效导致任务并发执行的概率。任务是否出现积压也就是某一个节点还在执行上一个周期的任务下一个周期的调度又开始了。Quartz 集群则要重点看数据库锁等待。qrtz_LOCKS表是 Quartz 集群实现分布式锁的关键如果频繁出现锁等待说明节点数或任务调度频率已经超出了数据库的承受能力这时候就要考虑换调度平台了。10. 分布式定时任务常见问题与排查方法问题现象可能原因排查方式解决方案集群下 Scheduled 重复执行没有加分布式锁查看多节点日志是否同一时间打印“开始执行任务”引入 Redis 分布式锁或改为 XXL-JOB拿到锁后任务还没执行完就重复执行了锁的过期时间设置过短观察任务平均执行时长调大锁过期时间使用 Redisson 看门狗Redisson 解锁报错锁已经过期自动释放当前线程不再持有锁查看异常堆栈使用isHeldByCurrentThread判断后再 unlock删锁误删了别人刚加的锁释放锁时未校验 value查看日志中是否存在 UNLOCK 失败用 Lua 脚本校验后删除XXL-JOB 执行器一直显示离线AppName 不一致、端口不通、accessToken 不一致检查执行器日志注册信息核对配置和网络确认调度中心地址可从执行器访问XXL-JOB 任务成功率高但某条数据没处理业务内异常被吞掉查看 XxlJobHelper.log 的异常信息捕获异常并记录失败数据进入重试队列Quartz 集群中部分节点不触发任务未正确开启 isClustered或数据库表缺失检查应用配置和 qrtz_* 表开启集群配置并初始化 Quartz 表任务积压同一个任务还没结束下个周期又触发上一轮执行时间超过调度周期查看日志中任务开始结束时间任务分片、异步化或加DisallowConcurrentExecutionRedis 主从切换后锁丢失Redis 异步复制导致锁还没同步就切换结合业务评估风险场景要求高就换专业调度平台不要依赖手写锁11. 最佳实践与面试答题建议11.1 工程最佳实践任何分布式定时任务都要设计幂等。分布式锁只是尽量降低重复触发的概率不能作为唯一防线。任务逻辑要考虑到“不一定只执行一次”这个前提。业务侧最好有状态机、唯一约束、数据库乐观锁来保障幂等。单个任务执行尽量小步快跑。不要在一个定时任务里处理所有数据可以通过分页、分片、队列化来控制范围。任务失败要能感知。用日志、告警、重试机制把失败暴露出来而不是静默失败。涉及用户数据、权限数据的定时任务要先确认数据合规边界在测试环境验证后再上生产。线上环境建议优先选择 XXL-JOB 这类成熟调度平台而不是自己维护一套锁方案。平台自带的重试、告警、日志和分片能力节省的运维成本远大于部署一个调度中心的成本。11.2 面试答题路径如果被问到“分布式定时任务怎么实现”可以参考下面的回答框架先抛出问题Scheduled在多个实例部署下每个实例都会执行存在重复处理风险。给出方案演进最轻量是 Redis 分布式锁适合任务少、对可靠性要求不太极端的场景如果任务多、需要运维能力用 XXL-JOB 这类调度平台老项目已经在用 Quartz可以走 Quartz 集群模式。点出核心难点锁的可靠性、任务幂等、任务分片、失败重试、日志跟踪。结合自己的项目说我负责的某某模块用了什么方案遇到过什么问题怎么解决的。这套回答比直接背“XXL-JOB 有调度中心和执行器”要强得多。面试官听完起码知道你是真的思考过方案边界而不是只背过框架文档。11.3 容易被忽视的合规与安全边界定时任务往往会处理用户数据比如批量发送通知、清理历史数据、生成报表。这些操作在上线前务必确认任务涉及的数据是否有合法的处理依据。批量推送是否可能造成打扰频率和内容是否在合理范围内。清理删除类任务要有备份和可恢复方案。通过接口或调度中心暴露的任务触发入口要防止未授权调用。12. 总结与下一步分布式定时任务这个问题从单机Scheduled到 Redis 分布式锁再到 XXL-JOB 调度平台本质上是一条“从能用到好用再到大规模可用”的演进路径。最容易踩的坑有三个只写一个Scheduled完全不考虑多实例重复执行写 Redis 锁却没有处理锁过期和误删问题上了调度平台却没有设计任务幂等和失败告警。建议第一次接触这个 topic 的同学先在自己的项目里把 Redis 分布式锁方案跑一遍然后在本地起两个端口模拟多节点观察日志里任务是否只被一个节点执行。再进一步部署一个 XXL-JOB 调度中心把执行器接入现有服务体验“后台配置任务 - 执行器收到指令 - 日志回传”的完整链路。这两步走完这个知识点基本就扎实了。后续还可以继续扩展的方向任务分片在大数据量场景的实践、调度中心的集群高可用部署、任务调度与工作流引擎的整合、利用消息队列实现延迟任务触发。分布式定时任务不只是一个面试题它背后是分布式系统里“如何安排工作、如何分配资源、如何保证一致性”的基础能力。把这些吃透再去碰更复杂的分布式事务和分布式一致性会轻松很多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →