尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

分布式定时任务实现:从分布式锁到XXL-Job调度平台

分布式定时任务实现:从分布式锁到XXL-Job调度平台 面试官问“分布式定时任务怎么实现”时最怕的不是候选人不会写代码而是把单机定时任务、分布式锁、任务调度平台这三层概念混在一起。实际业务里定时任务从单实例部署变成多实例以后第一个暴露的问题就是重复执行两台机器同时触发同一个 Job数据库里同一批数据被刷两遍报表对不上通知重复发送。要回答好这个问题至少要讲清楚三个东西为什么单机 Cron 不够用 Redis 分布式锁或数据库锁怎么保证互斥以及 XXL-Job 这类调度平台在架构上解决了什么问题。下面按“问题拆解 - 概念基础 - 可落地方案 - 面试话术与排错 - 生产实践”的顺序展开。文章里的代码以 Spring Boot 为例重点说明思路落地时根据自己的包名、版本和任务逻辑调整。1. 单机定时任务到分布式任务问题到底出在哪里1.1 单机任务为什么不能直接支撑多实例部署先看最简单的单机实现。Spring Boot 项目里开启定时任务只需要在启动类上添加EnableScheduling然后在任意方法上添加Scheduled注解。Component public class OrderSyncTask { Scheduled(cron 0 0 2 * * ?) public void syncOrder() { // 从第三方平台同步订单 } }这段代码在本地运行没有任何问题。部署到生产环境后为了高可用通常会启动两个或多个实例。问题立刻出现每个实例都会加载同一个OrderSyncTask到凌晨两点时每个实例都会执行一次syncOrder()。如果里面对应的是订单同步、积分计算、余额结转这类业务重复执行会产生重复数据或重复扣减。单机定时任务的隐含假设是“同一个 JVM 内只有一个任务实例在运行”。多实例部署破坏了这个假设。1.2 分布式定时任务要解决的四个核心问题分布式定时任务不能只理解成“用锁拦住重复执行”。站在生产角度一个完整方案至少要考虑四个方面核心问题说明典型问法任务竞争多实例同时命中调度时间如何保证同一个任务只执行一次怎么防止重复执行任务触发手动触发、立即执行、动态修改 cron是否需要一个统一入口业务方想立刻跑一次怎么办任务分片几百万数据一次处理太慢如何拆给多个机器并行处理大数据量怎么提速任务监控任务是否超时、失败、堆积日志如何追溯出了问题怎么排查面试时如果能主动说出这四个维度比只背一个 Redis 锁方案要完整得多。1.3 常见方案全景对比现在工程里常用的方案可以按复杂度分成几档方案核心组件优点明显局限Redis 分布式锁Spring Boot Redis改造快适合已有 Redis 的项目没有管理界面无法分片和监控数据库锁 ShedLockSpring Boot JDBC锁逻辑与业务解耦配置简单依赖数据库锁时间设置不好容易误删Quartz 集群模式Quartz 数据库 JobStore老牌成熟支持集群配置重复杂场景运维成本高XXL-Job调度中心 执行器有管理界面支持分片、失败重试、日志需要额外部署调度中心Elastic-JobZookeeper 执行器分片能力强适合大数据处理运维依赖 ZK学习成本比 XXL-Job 高面试中不建议一上来就说“我们用 XXL-Job”因为有些场景根本不需要引入一套调度平台。先给出“加锁解决互斥平台解决管理和扩展”的判断再落到具体选型会更像做过架构取舍的人。2. 先掌握分布式锁它是分布式定时任务的地基2.1 分布式锁在定时任务里的作用分布式锁解决的问题是互斥同一把锁的 key在同一时刻只能被一个客户端持有。放到定时任务场景里任务的入口代码先去获取锁获取成功才执行业务逻辑获取失败直接返回这样就能保证多实例下只有一个节点真正执行。但这里有一个容易混淆的点锁不保证任务一定成功只保证任务不会并发进入临界区。业务代码自己的异常处理、幂等控制、失败重试仍然要单独设计。2.2 Redis 分布式锁的最小实现假设项目已经引入了spring-boot-starter-data-redis最简单的最小实现如下。Component public class OrderSyncTask { private static final String LOCK_KEY task:order-sync:lock; private final StringRedisTemplate redisTemplate; public OrderSyncTask(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } Scheduled(cron 0 0 2 * * ?) public void syncOrder() { String lockValue UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(LOCK_KEY, lockValue, Duration.ofMinutes(10)); if (!Boolean.TRUE.equals(locked)) { return; } try { doSync(); } finally { releaseLock(LOCK_KEY, lockValue); } } private void doSync() { // 实际业务逻辑 } private void releaseLock(String key, String value) { String current redisTemplate.opsForValue().get(key); if (value.equals(current)) { redisTemplate.delete(key); } } }关键点有三个。第一setIfAbsent必须同时设置过期时间不能先setIfAbsent再单独expire两步操作不是原子的。手动控制过期时间可以避免任务进程崩溃后锁永远不释放。第二锁的 value 要使用随机值。如果所有实例都用同一个固定值线程 A 的锁过期后线程 B 加锁A 执行完delete时会把 B 的锁删掉造成锁失效。第三释放锁前要先比较 value 再删除。上面代码中“取值 - 比较 - 删除”三步不是原子的严格生产实现应该用 Lua 脚本。Redis 官方建议的释放脚本如下if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end2.3 这个基础方案有哪些坑面试追问时最常问的是三个坑。第一个是锁过期时间问题。任务执行时间超过锁过期时间锁自动释放另一个实例拿到锁后重复执行。解决思路有两个把过期时间设置成业务耗时的几倍或者实现看门狗续期逻辑。手写续期比较复杂所以很多团队干脆用 ShedLock 或分布式锁框架。第二个是主从切换问题。Redis 主节点加锁成功后还没同步到从节点主节点宕机从节点升级为主节点此时锁丢失。严格场景下需要使用 RedLock但 RedLock 本身也有争议。面试时提到这个点即可不用过度展开。第三个是任务失败和锁释放的关系。finally里释放锁时如果业务逻辑抛了异常锁会被释放下一次调度可以正常触发。这是合理的。但如果任务要支持失败重试锁的释放逻辑和重试队列要配合不能简单理解为“锁释放了就是任务成功”。3. 方案一用 Redis 分布式锁改造成本最低3.1 什么时候选这个方案Redis 分布式锁适合已有 Redis、部署架构简单、任务数量不多、不要求任务分片和集中监控的项目。它的最大价值是“最小改造”不需要额外部署调度中心只需要一个锁 key就能解决多实例重复执行的问题。3.2 Spring Boot 项目准备先在pom.xml里引入 Redis 依赖。版本号以你当前 Spring Boot 父依赖管理的版本为准不需要写死。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency然后在application.yml里配置 Redis 连接。生产环境建议使用单独的 Redis 实例或单独 namespace避免锁 key 和其他缓存数据混在一起。spring: data: redis: host: 127.0.0.1 port: 6379 timeout: 3s3.3 加锁执行与锁续期如果任务执行时间可能超过默认过期时间不要用每次执行都重新加锁的方式那会破坏“同一时刻只有一个执行者”的语义。更实用的做法是单独封装一个DistributedLock组件内部提供带续期的锁。下面是一个“锁持有期间自动续期”的简化思路。核心是启动一个后台线程每隔锁过期时间的三分之一续期一次锁释放时停止续期线程。public class RedisDistributedLock { private final StringRedisTemplate redisTemplate; private final String lockKey; private final String lockValue; private final Duration lockDuration; private volatile boolean renewing true; public RedisDistributedLock(StringRedisTemplate redisTemplate, String lockKey, String lockValue, Duration lockDuration) { this.redisTemplate redisTemplate; this.lockKey lockKey; this.lockValue lockValue; this.lockDuration lockDuration; } public void startRenewal() { Thread renewalThread new Thread(() - { long sleepMillis lockDuration.toMillis() / 3; while (renewing) { try { Thread.sleep(sleepMillis); redisTemplate.expire(lockKey, lockDuration); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } catch (Exception e) { // 续期失败要记录日志并告警 } } }, lock-renewal- lockKey); renewalThread.setDaemon(true); renewalThread.start(); } public void stopRenewal() { renewing false; } }实际项目中续期线程需要处理 Redis 异常、线程泄漏和任务执行期间 JVM 重启等问题。这也是很多团队不自己造锁而选择现成框架的原因之一。3.4 这个方案的适用边界Redis 分布式锁方案有四件事做不到没有任务管理界面不能动态修改 cron。不支持分片数据量大时不能自动拆任务。没有失败重试和告警策略任务失败了只能在业务代码里处理。没有任务执行时长、成功率统计做不到集中观察。如果超过两条不满足就应该往 ShedLock 或 XXL-Job 方向演进而不是继续在锁方案上堆代码。4. 方案二用 ShedLock 把锁从业务代码中剥离4.1 先搞清楚 ShedLock 是什么ShedLock 不是一个任务调度框架而是一个锁提供者。它不负责触发任务只负责在任务触发时确保分布式环境下只有一个节点执行。它和Scheduled配合使用把锁逻辑从业务方法中抽离出来。这个设计有个明显好处业务代码里不需要写setIfAbsent不需要手动管理锁 key只要加一个注解指定锁名和锁持有时间即可。4.2 引入依赖和锁表ShedLock 支持多种锁存储常见的是数据库表和 Redis。使用数据库表时需要先创建一张锁表。CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP NOT NULL, locked_at TIMESTAMP NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) );Maven 依赖按需要选择。如果项目使用 Spring Boot 和 JDBC 存储需要引入dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-spring/artifactId !-- 替换为当前稳定版本 -- /dependency dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-provider-jdbc-template/artifactId !-- 替换为当前稳定版本 -- /dependency然后是核心配置类Configuration EnableScheduling EnableSchedulerLock(defaultLockAtMostFor 10m) public class SchedulerConfig { Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider(dataSource); } }EnableSchedulerLock里的defaultLockAtMostFor表示默认情况下锁最多占用时间。它用来防止任务突然死掉后锁无法释放。4.3 给任务方法加锁任务方法上同时使用Scheduled和SchedulerLock。Component public class OrderSyncTask { Scheduled(cron 0 0 2 * * ?) SchedulerLock( name task:order-sync:lock, lockAtMostFor 10m, lockAtLeastFor 1m ) public void syncOrder() { doSync(); } }这里两个参数很容易理解反。lockAtMostFor是锁最多持有时间。任务节点崩溃时锁会在这个时间后自动释放避免其他节点永远无法执行任务。这个值要设置成大于任务最大耗时。lockAtLeastFor是锁最短持有时间。它的作用是防止任务执行太快刚释放锁另一个节点就立刻拿到锁重复执行。比如任务 1 秒就跑完了没有lockAtLeastFor同一秒内其他节点可能拿到锁再跑一次。设置成 1 分钟可以避免这种“秒级重复”。4.4 数据库锁方案的优势与局限数据库锁方案的优点是代码干净锁逻辑完全交给框架团队无需维护 Redis 锁的复杂细节。它的局限也很明显锁表会成为数据库的一个写入热点任务很多时要关注锁表性能。lockAtMostFor设置过大节点崩溃后任务会空窗很久设置过小任务长尾时会提前放锁。ShedLock 本身不提供管理界面和分片能力它只是解决了“互斥”。所以 ShedLock 适合“已经有 Spring Boot 数据库任务规模中等暂不打算引入调度平台”的团队。5. 方案三XXL-Job 这类调度平台的生产级解法5.1 调度器与执行器分离的架构XXL-Job 和单机Scheduled最大的区别是调度和执行分离。调度中心是一个独立 Web 应用负责任务注册、定时触发、失败重试、执行日志查询。执行器是挂在业务服务里的一个组件负责接收调度中心的命令执行具体的 Job 方法。业务服务可以部署多个实例实例启动后自动向调度中心注册。这种架构带来的好处是任务管理和业务服务解耦。修改 cron、手动触发、查看执行日志都在调度中心操作不需要改代码重新发布。5.2 最小接入流程接入 XXL-Job 前需要先准备好调度中心再在自己的业务服务里集成执行器。执行器部分的核心配置如下。xxl: job: admin: addresses: http://127.0.0.1:8080/xxl-job-admin accessToken: executor: appname: order-executor address: ip: port: 9999 logpath: ./logs logretentiondays: 30admin.addresses指向调度中心地址。executor.appname是执行器在调度中心里的应用名。executor.port是执行器和调度中心通信的端口多实例部署时注意不要冲突。accessToken一般建议配置用来做调度中心和执行器之间的鉴权本地学习可以先留空。业务方法上使用XxlJob注解Component public class OrderSyncJob { XxlJob(orderSyncJobHandler) public void syncOrder(String param) throws Exception { // 从调度中心传入的参数 XxlJobHelper.log(开始同步订单param{}, param); doSync(); XxlJobHelper.log(订单同步完成); } }然后在调度中心页面里新增任务配置 cron、JobHandler 名称、路由策略和失败重试次数。这里相比前面两种方案多了一个 Web 操作界面运维体验完全不同。5.3 分片任务处理需要分片时执行器可以通过XxlJobHelper拿到当前实例的分片序号和总分片数量。XxlJob(orderShardingJobHandler) public void orderShardingJob(String param) { int shardIndex XxlJobHelper.getShardIndex(); int shardTotal XxlJobHelper.getShardTotal(); // 按 shardIndex 和 shardTotal 切分数据范围 ListLong orderIds queryOrderIdsByShard(shardIndex, shardTotal); for (Long orderId : orderIds) { process(orderId); } }分片逻辑的关键是“数据如何按分片序号均匀切分”。常见做法是按主键取模、按时间范围切分、按业务维度分组。分片实现得不好时会出现某个实例很忙、其他实例很闲的倾斜问题。5.4 选择平台时要考虑哪些条件引入调度平台前先问自己四个问题是否有多套业务服务都需要定时任务且希望统一管理业务方是否经常需要手动触发某个任务立即执行是否有单次上百万条数据的任务需要分片并行处理是否希望任务失败时有邮件或钉钉告警如果四个问题是“否”可以不用扩大架构。如果至少两个是“是”引入 XXL-Job 这类平台是合理的。它带来管理便利的同时也增加了一个需要运维的调度中心组件和一条网络通信链路。6. 面试回答思路、本地多实例验证与常见排错6.1 面试回答的推荐思路回答这道题不要一上来就讲代码。更好的顺序是先定性单机Scheduled多实例部署后会重复执行所以分布式定时任务的核心是解决多节点间的互斥、调度、分片和监控问题。再给演进路径最轻量的是 Redis 分布式锁或数据库锁想要锁逻辑更干净可以使用 ShedLock需要统一管理和分片时再引入 XXL-Job 这类平台。最后落到细节面试官追问 Redis 锁时说出随机 value、过期时间、释放前校验、锁续期和主从切换这几个关键词追问 XXL-Job 时说出调度器与执行器分离、JobHandler、分片参数、失败重试和日志采集。这样回答会表现出“我做过也踩过坑”而不是“我背过方案”。6.2 本地验证多实例模拟本地想验证分布式锁是否生效最简单的方法是同机启动多个 Spring Boot 实例修改端口后观察同一个任务是否只被一个实例执行。mvn spring-boot:run -Dspring-boot.run.arguments--server.port8081mvn spring-boot:run -Dspring-boot.run.arguments--server.port8082也可以直接打成 jar 包分别启动java -jar target/order-service.jar --server.port8081 java -jar target/order-service.jar --server.port8082用 Redis 方案时观察 Redis 中的锁 key 是否存在、释放是否正常。用 XXL-Job 时在调度中心“调度日志”里查看同一时刻是否有多个执行记录正常情况下只有一条。如果本地两个实例同时执行了任务优先排查锁 key 是否拼错了、两个实例是否连到同一个 Redis、任务是否绕过了锁方法直接调用业务逻辑。6.3 常见问题排查表问题现象可能原因检查方式处理建议任务重复执行锁 key 不一致或锁未生效查看 Redis key 和实例日志统一锁 key确认加锁方法确实在任务入口锁一直不释放任务线程被杀或锁过期时间太长查看任务日志和锁 key 剩余过期时间调小锁过期时间或增加看门狗续期逻辑两个实例日志显示都执行了数据库时钟不一致或 ShedLock 表未创建查看 shedlock 表数据确认锁表和锁名配置正确调度中心显示执行器离线端口不通或 appname 不一致调用执行器健康接口检查网络和 executor 配置任务执行时间超过锁时间锁时间设置过短对比任务耗时和锁配置增大 lockAtMostFor 或优化任务逻辑6.4 生产环境还必须补的配置无论选择哪种方案生产环境都要关注这些点配置外置化调度中心地址、Redis 连接、锁过期时间不能写死在代码里要用配置中心和环境变量管理。日志埋点任务开始、结束、耗时、处理条数、异常堆栈都要有结构化日志。线程池隔离不要让定时任务和请求接口共用一个线程池防止任务耗时过长拖垮请求线程。幂等保护锁只是前置防线数据库表设计里要增加唯一索引或业务状态位保证即使锁失效也不会产生脏数据。失败告警任务执行失败时要能通过邮件、企微或钉钉通知到负责人。7. 生产落地的几个关键实践7.1 任务幂等设计分布式定时任务里锁和幂等是一对组合拳。锁减少并发冲突的概率幂等保证即使冲突了系统依然正确。例如订单同步任务可以在目标订单表增加sync_batch_no字段每次同步生成一个批次号。数据库层增加唯一约束重复执行时插入已存在的批次号会报错从而拦截重复数据。ALTER TABLE order_sync_log ADD UNIQUE KEY uk_batch_no (batch_no);这种方法不依赖分布式锁属于最终防线非常值得保留。7.2 执行日志和监控用 Redis 锁实现的定时任务没有现成管理界面所以日志设计更重要。建议每个任务至少输出以下字段task_nameorderSync start_time2025-01-01 02:00:00 end_time2025-01-01 02:01:30 cost_ms90000 process_count12345 successtrue exceptionnull接入 Prometheus 时还可以暴露task_execute_total、task_execute_duration和task_execute_error_total三个指标方便在 Grafana 上配置任务失败率告警。7.3 学习路径与扩展方向结合面试需要推荐按下面的顺序实践先用Scheduled写一个单机任务观察单实例执行逻辑。本地起两个 Spring Boot 实例复现重复执行问题。用 RedissetIfAbsent实现分布式锁修复重复执行问题。引入 ShedLock对比锁逻辑从业务代码里删除后的代码结构。部署一次 XXL-Job 调度中心把任务迁移到执行器上体验管理界面、手动触发和分片。最后可以看一下 XXL-Job 的执行器注册和任务分发源码理解调度中心如何维护“在线执行器列表”。分布式定时任务不是一个独立功能它会牵涉到分布式锁、任务幂等、线程池隔离、日志监控和调度平台选型。面试时能把这条链路讲清楚比单纯记住某个工具的使用方法更有竞争力。真正做项目时优先选择“最简单且能解决问题”的方案任务规模和运维能力不够时不要为了分布式而分布式。
返回列表