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

资讯详情

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

Spring 定时任务与分布式锁:避免重复执行的实践

Spring 定时任务与分布式锁:避免重复执行的实践 Spring 定时任务与分布式锁避免重复执行的实践1. 问题场景为什么定时任务会重复执行假设你负责一个订单系统每天凌晨 2 点需要给未支付订单发提醒邮件。最初部署单机时Scheduled注解简单可靠。后来为了高可用你把应用部署成 3 个实例前面挂了负载均衡。你以为万事大吉结果第二天看到发邮件服务日志里同一批订单收到了 3 封提醒邮件——因为 3 台机器都执行了同一个定时任务。这就是典型的定时任务重复执行问题。集群环境下每个节点都会运行自己的调度器如果没有协调机制任务会像被复制了一样同时执行。而很多任务如发送通知、生成报表、过期数据清理并不是天然幂等的重复执行会带来严重后果重复扣款、重复消息、数据不一致。你可能会想能否通过“让哪台机器跑”来避免但调度器并不知道彼此的存在。所以我们需要一个分布式锁在任务开始前所有节点竞争同一把锁只有抢到的节点才执行执行完释放锁其他节点跳过或等待下次调度。2. 核心模型锁与任务的关系先记住一个最小模型每个需要全局唯一的任务配一把分布式锁。任务执行前先尝试加锁加锁成功才继续执行完或超时释放锁。锁的持有者唯一从而确保同一时刻只有一个实例执行。从全局看系统中有三个角色调度器Spring 的 task scheduler在每个节点上独立触发任务。任务你要执行业务逻辑的代码块。分布式锁服务负责锁的获取、释放、过期它通常是独立组件如 Redis、数据库。一次完整的流程是这样的每个节点的调度器在指定时间如每天凌晨 2 点触发executeTask()。任务方法首先向锁服务请求锁带任务名和节点 IP。锁服务检查锁是否存在若不存在当前节点获得锁并开始执行业务逻辑若存在且未过期则说明其他节点已在执行当前节点放弃执行。执行完毕后任务主动释放锁若执行过程中崩溃锁会在超时后自动过期。下面用一个 ASCII 图展示 3 个节点竞争的过程节点A (10.0.0.1) 节点B (10.0.0.2) 节点C (10.0.0.3) | | | |--加锁请求--- | | | |--加锁请求--- | | | |--加锁请求--- | 锁服务 | |--成功:执行--- |--失败:跳过-- |--失败:跳过--3. 方案一SchedulerLock 库3.1 它是什么SchedulerLocknet.javacrumbs.shedlock是一个专为 Spring 定时任务设计的分布式锁实现。它声明式地提供服务你只需要在Scheduled方法上加SchedulerLock注解并指定锁名称和锁持续时间任务执行前会自动尝试获取锁。3.2 核心机制它通过一种“锁租约”机制工作任务开始时SchedulerLock 自动在持久化存储如数据库表记录一条锁记录包含任务名、持有节点、到期时间。任务执行中其他节点看到锁已存在且lock_until时间未到就放弃执行。任务正常结束会删除锁记录或更新到期时间使锁立即释放。若任务崩溃锁记录会残留到lock_until到期然后被其他节点接管。为什么不像普通锁那样“用后即删”因为如果没有租约崩溃的节点永远不会释放锁任务就永远无法再次执行。租约相当于自动过期时间。3.3 完整示例用数据库表实现 SchedulerLock我们先用最小示例验证核心机制一个简单的定时任务每天打印一行字。目标在本地运行两个SpringBootApplication实例观察只有一个能执行任务。前置环境JDK 8MavenMySQL或用 H2 内存库。步骤创建 Spring Boot 项目添加依赖dependencygroupIdnet.javacrumbs.shedlock/groupIdartifactIdshedlock-spring/artifactIdversion4.42.0/version/dependencydependencygroupIdnet.javacrumbs.shedlock/groupIdartifactIdshedlock-provider-jdbc-template/artifactIdversion4.42.0/version/dependency配置数据源并创建锁表如果数据库是 MySQLCREATETABLEshedlock(nameVARCHAR(64)NOTNULL,lock_untilTIMESTAMP(3)NOTNULL,locked_atTIMESTAMP(3)NOTNULLDEFAULTCURRENT_TIMESTAMP(3),locked_byVARCHAR(255)NOTNULL,PRIMARYKEY(name));编写配置类启用 SchedulerLockimportnet.javacrumbs.shedlock.core.LockProvider;importnet.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;importnet.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.jdbc.core.JdbcTemplate;importjavax.sql.DataSource;ConfigurationEnableSchedulerLock(defaultLockAtMostForPT1M)// 默认锁最大持有时间publicclassSchedulerConfig{BeanpublicLockProviderlockProvider(DataSourcedataSource){returnnewJdbcTemplateLockProvider(JdbcTemplateLockProvider.Configuration.builder().withJdbcTemplate(newJdbcTemplate(dataSource)).usingDbTime()// 使用数据库时间避免多机时钟不一致.build());}}编写任务类importnet.javacrumbs.shedlock.spring.annotation.SchedulerLock;importorg.springframework.scheduling.annotation.Scheduled;importorg.springframework.stereotype.Component;importjava.time.LocalDateTime;ComponentpublicclassMyTask{Scheduled(cron*/30 * * * * *)// 每30秒执行一次SchedulerLock(namemyTask,lockAtMostForPT1M,lockAtLeastForPT5S)publicvoidrun(){System.out.println(执行任务 LocalDateTime.now() - Thread.currentThread().getName());}}启动两个实例端口不同观察控制台输出。预期输出任何时刻只有一个实例每 30 秒打印一行另一个实例在调度到时刻时会跳过执行不会打印任何内容。易错点必须设置EnableSchedulerLock否则注解不生效。lockAtMostFor要大于任务最大执行时间但不宜过长否则锁泄漏时阻塞太久。建议设为最大执行时间的两倍。lockAtLeastFor用来防止任务执行太快导致锁刚释放立刻被其他节点抢到形成“换人执行”但设太长会阻塞延后调度。3.4 适用场景与边界SchedulerLock 适用于确保同一任务名仅有一个实例执行且不关心“哪个实例”。它不需要额外引入 Redis只要有一个共享数据库即可。但要注意它依赖数据库的读写如果数据库不可用所有节点都无法获得锁任务暂停。如果任务本身执行时间超过lock_until锁会被其他节点接管造成重入。所以lockAtMostFor必须足够大。它不能解决“多个不同任务但业务上互斥”的情况因为锁名是任务级。4. 方案二Redisson 分布式锁4.1 为什么选 Redisson如果你的集群已经使用 RedisRedisson 是一个功能丰富的 Java 客户端提供了封装好的分布式锁支持可重入、自动续期等高级特性比手写 SETNX 更可靠。4.2 核心机制Redisson 的锁基于 Redis 的 Lua 脚本实现原子操作。lock()方法会向 Redis 发送一段脚本尝试设置一个带过期时间的 key如果成功则获得锁失败则阻塞等待。关键在于看门狗机制当锁未设置过期时间时Redisson 默认给锁 30 秒过期并启动一个后台线程每 10 秒检查一次若锁仍被持有则续期到 30 秒。这避免了任务未执行完锁就过期的问题。4.3 完整示例用 Redisson 实现非注解式锁使用 Redisson 通常不会直接集成到Scheduled而是手工在方法开头加锁。这个示例展示更精细的控制比如业务需要获取锁后执行一系列操作。目标模拟库存扣减确保多实例下只有一实例能执行。前置环境Redis 服务运行在 localhost:6379Spring Boot 项目引入 Redisson 依赖dependencygroupIdorg.redisson/groupIdartifactIdredisson-spring-boot-starter/artifactIdversion3.16.8/version/dependency代码importorg.redisson.api.RLock;importorg.redisson.api.RedissonClient;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.scheduling.annotation.Scheduled;importorg.springframework.stereotype.Component;importjava.util.concurrent.TimeUnit;ComponentpublicclassInventoryTask{AutowiredprivateRedissonClientredissonClient;Scheduled(fixedDelay10000)// 每10秒执行一次publicvoiddeductInventory(){RLocklockredissonClient.getLock(inventoryTaskLock);try{// 尝试获取锁最多等待3秒自动释放时间由看门狗管理不传leaseTimeif(lock.tryLock(3,TimeUnit.SECONDS)){System.out.println(获得锁开始扣减库存 Thread.currentThread().getName());// 模拟业务操作假设耗时5秒TimeUnit.SECONDS.sleep(5);System.out.println(库存扣减完成);}else{System.out.println(未获得锁跳过执行);}}catch(InterruptedExceptione){Thread.currentThread().interrupt();System.out.println(任务被中断);}finally{// 判断当前线程是否持有锁防止重复释放if(lock.isHeldByCurrentThread()){lock.unlock();}}}}预期输出两个实例同时运行时只有一个实例打印“获得锁”另一个实例尝试等待 3 秒后打印“未获得锁”。注意tryLock不会一直阻塞适合不想让调度被阻塞的场景。易错点必须配置 Redis 连接否则启动失败。使用tryLock时未抢到锁不要抛异常跳过本次即可。释放锁前要检查当前线程是否持有否则可能释放别的线程的锁。如果不提供leaseTime看门狗会自动续期如果提供了看门狗不会启动锁会在指定时间后自动释放可能导致业务未完成锁已失效。因此生产上通常不主动设置leaseTime。4.4 适用场景与边界Redisson 锁适合你需要在业务代码中灵活控制锁粒度的情况比如一个任务既要加锁又要处理其他资源。它比 SchedulerLock 更底层但要求引入 Redis 依赖。5. 方案三数据库锁悲观/乐观5.1 为什么不直接用数据库如果你连数据库锁表都不想引入也可以直接在数据库中实现锁。常用两种悲观锁SELECT ... FOR UPDATE和乐观锁版本号。在定时任务场景悲观锁更直白但会长时间占用数据库连接。5.2 核心机制与示例以 MySQL 为例用GET_LOCK()函数实现锁它在同一会话内有效。但集群中不同节点之间的会话不可能共享所以需要一个共享的表或记录。完整示例使用唯一键实现互斥思路利用数据库的唯一约束往task_lock表插入一行包含任务名的记录。如果插入成功说明拿到锁插入失败DuplicateKey则说明其他节点持有锁。代码importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.jdbc.core.JdbcTemplate;importorg.springframework.scheduling.annotation.Scheduled;importorg.springframework.stereotype.Component;importorg.springframework.transaction.annotation.Transactional;ComponentpublicclassDbLockTask{AutowiredprivateJdbcTemplatejdbcTemplate;Scheduled(cron0 */1 * * * ?)// 每分钟执行publicvoidrun(){StringlockNamedbTask;Stringnodejava.net.InetAddress.getLocalHost().getHostName()-Thread.currentThread().getId();// 尝试插入锁记录try{jdbcTemplate.update(INSERT INTO task_lock(task_name, lock_until, locked_by) VALUES (?, ?, ?),lockName,newjava.util.Date(System.currentTimeMillis()60000),node);// 插入成功获得锁执行业务System.out.println(获得锁执行业务node);// ...业务逻辑...Thread.sleep(2000);// 业务完成后删除锁jdbcTemplate.update(DELETE FROM task_lock WHERE task_name ?,lockName);}catch(org.springframework.dao.DuplicateKeyExceptione){// 主键冲突说明已有锁跳过System.out.println(已有锁跳过执行);}catch(Exceptione){// 处理其他异常并确保锁能释放if(einstanceofInterruptedException){Thread.currentThread().interrupt();}// 实际应记录日志并考虑释放锁逻辑}}}配合建表语句CREATETABLEtask_lock(task_nameVARCHAR(64)PRIMARYKEY,lock_untilDATETIMENOTNULL,locked_byVARCHAR(255)NOTNULL);关键点插入时带lock_until字段用于别处检查是否过期。但上面代码并没有检查过期也就是锁在任务崩溃后到lock_until之前都不会被释放之后的调度依然插入失败。因此需要额外的清理任务。更完善的实现应支持更新过期时间这里只展示基本互斥。预期输出多个实例运行时只有插入成功的实例打印“获得锁”其他实例捕获异常并打印“已有锁跳过执行”。5.3 边界与问题数据库锁方式最简单但存在几个问题锁记录需要人工清理或使用已存在的唯一键配合过期时间。如果业务异常导致最后没有删除锁锁会一直存在到锁的“天然过期”更新。MySQL 默认的 REPEATABLE READ 模式下插入唯一键冲突是原子可靠的。这个方案适合已经使用相同数据库且对锁的可靠性要求不高的场景但用起来最容易出错。6. 任务幂等性设计6.1 为什么需要幂等分布式锁能防止同一时刻重复执行但有些异常情况下锁可能提前失效或者两个节点几乎同时获得锁如锁服务故障这时靠幂等可以兜底。幂等是指同一个操作执行多次与执行一次效果相同。例如发送邮件提醒如果你在业务表中记录“已提醒”那么重复执行时检查状态就不会重复发送。6.2 设计模式唯一键/唯一索引处理的数据行包含业务唯一键如订单号。插入记录前先插入去重表重复插入报错则跳过。状态机更新前检查当前状态只有符合预定义状态才会更新并转移。例如订单状态为“待支付”时才能发提醒并更新为“已提醒”。去重表用一张表记录已执行的批次号或业务ID。比如每天的任务批次可记录task_date重复执行时发现存在则跳过。6.3 与分布式锁配合使用锁用于控制执行机会幂等用于控制业务影响。两者互补即使锁没能达到预期幂等也能兜底。7. 工程化实践建议7.1 选型对比方案依赖是否需额外组件易用性可靠性适用场景SchedulerLockJDBC需要数据库已有高注解中依赖数据库锁表统一管理定时任务不用引入 RedisRedissonRedis需要 Redis中手动高看门狗可重入对锁灵活控制任务耗时长已有 Redis数据库锁自写JDBC无额外组件低易出错低需自己处理过期尝试验证或轻量级互斥7.2 锁粒度与任务拆分不要把所有任务放在一个方法里并加一个大锁。粒度应尽量小一个锁控制一个业务单元。例如“对账摘要生成”和“发送对账单”应分开避免锁时间过长。7.3 监控和告警分布式锁解决了重复问题但也可能引入“任务漏掉”的问题。你需要监控两点一是任务执行的频率二是锁的争用情况。常见做法记录每次任务开始和结束的时间与节点存入日志或数据库。利用 Spring Actuator 暴露调度器的状态。设置告警规则如果任务超过计划时间尚未执行或执行超时发送告警如通过邮件、钉钉。8. 常见误区8.1 “给任务加锁就可以高枕无忧”锁只能保证执行机会唯一但如果业务代码有副作用例如向外部系统发消息还是需要幂等设计。因为锁可能过期、服务重启前未释放对下游的影响是客观存在的。8.2 “锁的持有时间设长一点”太长可能导致另一节点迟迟无法接管任务延迟。太短则可能重入。需要根据任务平均耗时和最大耗时综合分析。一般设为最大耗时的 2 倍。8.3 “在所有节点启动同样的定时任务”你可能会想用profile或参数禁止非主节点运行但这样当主节点故障时没有备能接。分布式锁更优雅它不需要人工指定主节点。9. 排障清单当定时任务应该执行却没执行时从下面顺序检查锁是否存在且未过期查SchedulerLock的锁表或 Redis 中的锁 key。锁是否被上一节点残留检查lock_until或 Redis key 的 TTL。调度器是否启用了EnableScheduling。方法是否有SchedulerLock且锁名是否拼错。Redisson 的 watch dog 是否在续期检查日志是否有 “Renewing lock” 等。多节点时钟是否正确如果相差大SchedulerLock 的 DB 时间和锁的判断会出错。业务代码是否抛异常导致提前返回且未释放锁Redisson 中 finally 释放。10. 面试/复盘问题在集群下用Scheduled会发生什么如何解决SchedulerLock 与 Redisson 的锁机制有何异同分布式锁的过期时间怎么设置什么是看门狗你认为分布式锁能完全避免重复执行吗还需要什么如果 Redis 不可用Redisson 线程会怎样11. 总结定时任务的重复执行是集群环境下的经典问题。本文从问题场景出发介绍了三种分布式锁方案SchedulerLock基于数据库、Redisson基于 Redis和自己写数据库锁。它们各有权衡选择时考虑你的技术栈和运维复杂度。同时任务是“幂等”的设计不可忽视它是最后的兜底。最后监控和告警是我们在工程中不能忽略的一环。希望你能根据本文的框架在自己的项目中做出明智的选择。12. 参考资料SchedulerLock GitHub: https://github.com/lukas-krecan/ShedLockRedisson 官方文档: https://redisson.org/docs/Spring Framework 官方文档: https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#scheduling
返回列表