因为@scheduled是jvm级调度,无跨进程协调能力,多实例必重复执行;须用redis分布式锁或shedlock等方案实现互斥,核心是原子加锁、标识校验解锁、合理设置超时与续期。

为什么 @Scheduled 在多实例下必然重复执行
因为 @Scheduled 是纯 JVM 内调度,每个 Spring Boot 实例启动后都会独立解析并注册自己的 CronTask 到本地 ScheduledThreadPoolExecutor。没有跨进程协调机制,部署 3 个 Pod 就会触发 3 次任务——不是“可能”重复,而是“一定”重复。
常见误判是加 synchronized 或数据库行锁,这些在单机有效,但对其他实例完全透明。真正要拦住的,是不同 JVM 进程之间的并发,必须靠外部共享存储做互斥判断。
用 Redis SETNX + Lua 实现原子加锁与释放
核心是两步不可分割:加锁时写入唯一标识(防止误删),解锁时校验标识再删除(避免 A 加锁、B 超时释放、A 误删 B 的锁)。直接调 setIfAbsent 再单独 expire 有竞态漏洞,必须用带过期时间的原子写入 + Lua 脚本保障解锁安全。
-
setIfAbsent("lock:order-sync", "node-01", 30, TimeUnit.SECONDS)—— 锁键建议带业务前缀,value 必须是当前实例唯一标识(如 hostname + pid) - 解锁 Lua 脚本必须校验 value 匹配才
del,否则直接 return 0 - 锁超时时间(
lockAtMostFor)要明显大于任务最大执行耗时,否则可能任务还没跑完锁就过期,被其他实例抢走
ShedLock 注解方式接入更轻量,但要注意配置陷阱
ShedLock 封装了锁逻辑,只需加 @SchedulerLock(name = "syncOrder", lockAtMostFor = "PT30S"),但它不是开箱即用的“魔法”,几个关键点不设对就会失效:
- 必须启用
@EnableSchedulerLock,且defaultLockAtMostFor要显式设值,否则默认为 0,锁立即过期 - Redis 存储模式下,
lockProviderBean 必须用RedisLockProvider,不能漏掉redisTemplate参数传入 - 任务方法不能是 private 或 final,ShedLock 依赖 AOP 代理,且需保证该 Bean 是 Spring 容器管理的单例
- K8s 环境下,Pod 重启或 HPA 扩容时,旧锁可能残留,建议配合
lockAtLeastFor防止任务未完成就被抢占
最容易被忽略的三个实际问题
线上翻车往往不在主流程,而在边界场景:
- Redis 主从切换期间,写入主节点的锁可能未同步到从节点,导致从节点上另一个实例误判“锁不存在”而成功加锁——这是 Redis 原生最终一致性带来的固有风险,ShedLock 或自研方案都无法绕过
- 任务执行中抛异常未 catch,导致锁未释放又没走 finally 解锁逻辑;正确做法是把业务逻辑包进 try-catch,确保 unlock() 总被执行
- 时钟漂移:多个服务器系统时间不一致,会导致锁 TTL 计算错乱;生产环境必须开启 NTP 同步,不能依赖本地时钟











