
1. 单机定时任务的隐忧为什么跑一次会变成跑N次1.1 多实例部署后 Scheduled 的真实表现先还原一个我经历过很多次的场景。你有一个 Spring Boot 服务里面用Scheduled(cron 0 0 2 * * ?)写了一个凌晨两点对账的定时任务。单机部署的时候一切正常第二天早上看数据账对得齐齐整整。后来项目上了容器化K8s 里 Service 后端挂了两个 Pod或者在你云服务器后面多起了几台 ECS 做负载均衡你以为什么都没变结果第二天一查数据库对账记录多了一倍——两个实例各跑了一次账单或者流水被重复处理了。这不是偶发现象是必然现象。Scheduled的语义是每个 JVM 进程内独立生效Spring 的ScheduledAnnotationBeanPostProcessor会在每个实例里分别注册调度器到 cron 表达式指定的时间点每个实例都会执行同一个方法。实例数等于执行次数一个实例也不会少。我有一次在压测环境起了四个副本四点钟的报表任务直接生成了四份重名文件把下游数据平台的导入任务顶挂了那个早晨过得相当酸爽。1.2 重复执行会造成哪些真实后果很多人觉得多跑一次顶多浪费点资源没什么大碍这是低估了定时任务重复执行的破坏力。按后果严重程度我排个序不可幂等的写操作向某个接口推送数据、给用户发短信/邮件、调用支付或账单接口。这种任务重复执行一次客户就能收到两条营销短信或者对账流水里多一笔重复扣款。竞态条件下的数据错乱两个实例同时读到待处理状态的数据同时更新又都以为自己抢到了。轻则覆盖更新重则产生脏数据而且这类问题排错极其费劲因为它只在特定毫秒内发生。文件与附件类处理生成报表、导出文件、清理临时目录多个实例各做各的最后要么文件互相覆盖要么留一堆残留。下游资源压力每个实例都去调外部 API数据库在凌晨高峰期被瞬时打满性能监控一片飘红。如果你做过一点基础设施就会发现这个问题的本质Scheduled提供的是本地定时能力不包含跨进程协调机制。多实例环境下你需要的其实不是再写一个定时任务而是一个能让多个进程之间互斥访问同一个任务执行权的协调工具——ShedLock 就是专门干这个的。2. ShedLock 的锁机制拆解不是定时框架而是锁的看门人2.1 ShedLock 与 Quartz / XXL-Job 的本质区别ShedLock 官方对自己的定位非常明确它不是定时调度框架而是一个分布式锁的轻量实现专门用来保证「被 Scheduled 标记的任务在集群环境下同一时刻只由一个实例执行」。这句话听起来绕拆解一下就清楚了。Quartz 和 XXL-Job 这类框架自身就带着调度中心 执行器的架构由调度中心统一触发任务再分配给某个执行器去跑任务触发权是集中管理的。而 ShedLock 不说谁来触发任务——触发依然靠你原来的Scheduled它只负责给触发后的任务加一道锁某个实例要开始执行前先尝试拿锁拿不到的实例就直接跳过本次触发。我倾向于把它理解成厕所门上的插销。定时器响了各个实例都走到厕所门口想用里面的马桶但门上的插销保证只有一个人能进去。其他实例看见门锁着就识趣地走了等下一个触发时间点再来。这个比喻也能解释很多新手困惑ShedLock 并不会帮你在多个实例间分配你今天执行、它明天执行它只做到同一时刻只有一个人能干活。2.2 锁的获取、持有与释放过程ShedLock 的锁信息默认存在一张数据库表里名字通常是shedlock每次任务执行前当前实例向这张表发起一次更新或插入操作。核心逻辑大致是这样的执行任务前实例 A 查询表中对应任务名的记录。如果不存在就插入一条新记录记录里包含任务名、锁直到时间、实例标识等。如果记录存在判断当前时间是否已经超过了lock_until锁的最晚结束时间。如果已经超过说明上一个持有锁的实例可能已经跑完或已经放弃A 可以用UPDATE把锁抢过来如果没超过说明锁还在别人手里A 就放弃本次执行。任务执行完成后实例 A 会更新这条记录把锁的结束时间改成一个较早的时间比如当前时刻释放锁。如果任务中途崩溃锁的真实释放就靠lock_until字段中的时间自动兜底其他实例最多等到这个时间点就能再抢锁。这里面最关键的字段就是锁的起止时间它替代了 Redis 里SETNX expire的角色只不过存储位置变成了你熟悉的数据库表。所以 ShedLock 对数据库的依赖并不复杂它靠一条 SQL 语句完成一次条件更新因此只要数据库可用锁机制就可用。3. 从零接入 ShedLock配置、建表与首次运行3.1 引入依赖与基础配置我用的是经典组合Spring Boot 2.x/3.x ShedLock 4.x/5.x JDBC 存储。项目的pom.xml里加两个依赖dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-spring/artifactId version5.13.0/version /dependency dependency groupIdnet.javacrumbs.shedlock/groupId artifactIdshedlock-provider-jdbc-template/artifactId version5.13.0/version /dependency第一个是核心模块负责和 Spring 集成第二个是 JDBC 存储实现基于 JdbcTemplate 把锁写到关系型数据库。如果你用的是 WebFlux可以换shedlock-provider-r2dbc-template如果团队已经有 Redis 基础设施也可以直接上shedlock-provider-redis-spring或者 Jedis/Lettuce 对应的实现。为了统一配置我在配置文件里增加了一个开关型配置项shedlock: enabled: true default-lock-at-most-for: PT15M然后在启动类上开两个注解SpringBootApplication EnableScheduling EnableSchedulerLock(defaultLockAtMostFor PT15M) public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }这里我解释下为什么要同时开EnableScheduling和EnableSchedulerLock前者负责把Scheduled注解的方法扫描成定时任务后者负责在任务执行前包一层 ShedLock 的锁拦截逻辑。这两个注解缺一不可但很多人刚开始只加了后者导致定时任务本身都不触发了这一点特别容易踩。3.2 创建持久化表ShedLock 要求你预先建好表它不会自动建表。MySQL 下我用的建表语句如下CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL, locked_at TIMESTAMP(3) NOT NULL, locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;需要注意几点。name是主键也就是锁的唯一标识对应任务锁的名称TIMESTAMP(3)是为了支持毫秒精度因为 ShedLock 比较锁时间时精确到毫秒如果表结构被建成了TIMESTAMP只有秒级精度在高频率触发或者PT0.5S这种短锁场景下可能出现锁判断异常。locked_by存的是当前持有锁的实例的唯一标识排查问题时很有用。如果你的项目里已经有 Flyway 或 Liquibase 管理数据库脚本我建议把这个建表语句作为一次正常的 migration 提交而不是让同事手工执行。我见过不止一次生产环境忘了建表ShedLock 抛Table shedlock doesnt exist结果定时任务全程空转你以为加上了锁实际完全没生效。3.3 核心代码改造改造前你可能有一个这样的任务方法Component public class ReconciliationTask { Scheduled(cron 0 0 2 * * ?) public void runReconciliation() { // 对账业务逻辑 } }改造后是这样Component public class ReconciliationTask { Scheduled(cron 0 0 2 * * ?) SchedulerLock(name reconciliationTask, lockAtMostFor PT30M, lockAtLeastFor PT5S) public void runReconciliation() { // 对账业务逻辑 } }SchedulerLock里最核心的两个参数是lockAtMostFor和lockAtLeastFor。lockAtMostFor表示锁的最长持有时间。如果任务没在这个时间内执行完锁也会被强制释放其他实例就能立即抢锁。lockAtLeastFor表示锁的最短持有时间。它保证即使任务很快执行完锁也不会立刻被释放至少保留这段时间防止其他实例在极短时间内抢到锁。这两个参数的单位是 ISO 8601 持续时间格式PT5S是 5 秒PT30M是 30 分钟PT2H是 2 小时。别写成5s或者5000ShedLock 解析不了启动阶段就直接报错。我第一次写的时候想当然写了60000结果启动运行时 Duration 解析异常排查了好久才意识到格式问题。3.4 验证锁是否生效接完之后不要急着部署上生产先用最简单的方式验证一把。我常用两个实例本地验证把服务复制一份改端口8080 和 8081连同一个数据库。在任务方法里加一行日志比如log.info(task executed by {}, instanceId);。等待触发时间观察两个实例的日志。如果锁生效只会有一个实例打印执行日志另一个实例虽然没有打印任务日志但如果开启了 ShedLock 的 debug 日志会看到类似Lock not acquired的记录。我在验证时还会顺手查一下shedlock表你会看到一行记录locked_by是实际执行的那个实例标识lock_until是任务开始时间加上lockAtMostFor的时间。这一行记录在全生命周期里始终存在它记录的是锁的元信息而不是说任务在持续执行。注意ShedLock 的锁状态是写过即留痕的即使任务执行完释放了锁表里的记录依然存在只是lock_until被更新成过去的时间。所以不要因为表里总有记录就以为有任务一直锁着没释放。4. 你一定会遇到的坑锁过期、时钟漂移与事务边界4.1 lockAtLeastFor 和 lockAtMostFor 的最优取值逻辑这是 ShedLock 最容易出错的两个参数而且出错方式很隐蔽——不是一上来就报错而是运行一段时间后偶发地重复执行或长期不执行。lockAtMostFor的核心原则是必须大于你任务的最大可能执行时间留足余量。假设任务正常 5 分钟跑完但某天大表数据量翻倍导致跑了 20 分钟你如果把lockAtMostFor设成PT10M第 10 分钟锁就过期了另一个实例会在 2 点 10 分抢到锁开始跑同一批数据任务就重复执行了。更麻烦的是此时第一个实例其实还没结束两个实例同时在处理同一批数据无论如何都会出现冲突。所以我的做法是先看这个任务的历史执行耗时 P99然后乘上 3 到 5 倍作为初始值再留半小时以上兜底。日志监控里长期观察实际耗时如果发现任务耗时接近lockAtMostFor的 80%就要考虑调大参数了。宁可锁挂得久一点也绝对不要让它提前失效。lockAtLeastFor则是为了处理任务执行太快导致锁失效的反向问题。假设任务只需要 100 毫秒如果不用最短持锁时间任务刚释放锁恰好另一个实例在同一毫秒内来读锁它看到的lock_until已经过期就会立刻抢到锁再执行一遍。这种竞争窗口其实非常窄但高并发实例数量和触发时间高度同步时确实会出现。我见过一个团队的数据补录定时任务任务本身 200 毫秒跑完不设置lockAtLeastFor在 8 个实例的集群下偶尔一个月出现一两次重复执行。设成PT5S之后这类现象彻底消失。4.2 为什么要求各实例的时钟一致ShedLock 判断锁是否过期依赖的是数据库服务器返回的时间以及各个应用实例的系统时间。具体来说它在更新锁时会调用NOW()或者取当前系统时间如果某个实例的系统时间比实际快了几分钟它可能认为锁已经过期而强行抢锁哪怕另一个实例还在正常执行反过来如果某个实例系统时间慢了几分钟它可能迟迟不去抢本该由它执行的任务。这是分布式锁的经典问题——没有 Ture Time只有大家约定一个时间基准。在容器环境里尤其要小心K8s Node 上如果没配置好 chrony 或 systemd-timesyncd长时间运行的 Pod 可能积累很大的时钟漂移。我在云上排查过一次业务偶发重复执行最后的根因就是某个节点时钟快了将近 40 秒那个节点上的实例总是比其他实例更早抢到锁而任务实际还没结束导致两边重复处理。如果你对时间精度要求比较高可以在运维层面把时钟同步列为容器镜像的基线配置也可以在 ShedLock 源码层面看它取的是Clock.systemUTC()确保所有实例用同一套时间源。注意这里只能尽力保证时钟一致ShedLock 本身没有自动校时能力。4.3 事务与锁的释放顺序问题这是我最想强调的一个隐蔽坑ShedLock 的锁释放并不感知事务提交。在 Spring 的常规写法里Transaction和SchedulerLock可以同时标在同一个方法上。但要注意事务拦截器和锁拦截器的执行顺序是固定的——默认情况下方法执行完毕后ShedLock 的锁释放动作先发生事务的提交动作后发生。也就是说锁被释放的瞬间事务可能还没有真正提交到数据库。这会带来什么问题任务 A 刚提交完处理结果释放了锁实例 B 立刻抢到同一把锁开始执行同样的任务。但如果 A 的事务还没提交B 去读数据时可能读到的是旧数据或半成品数据于是 B 又把同样的操作做了一遍。等到 A 的事务提交了B 也提交了数据库里就有了两份重复数据。一个小例子你在任务方法里调用一个订单处理的 Service该 Service 内部开启了事务。任务方法执行完毕ShedLock 先释放锁然后 Spring 才提交事务。如果服务里任何一步异常ShedLock 仍然会释放锁但事务回滚了其他实例看到锁已释放马上开始下一轮。看起来不会有问题但如果你在事务里执行了对账后更新状态这类操作两个实例交替抢锁就可能出现同一批数据被处理两次的窗口。我建议的做法是把加锁和事务绑定在结构上解耦。一般我会把SchedulerLock放在外层调度方法上把Transactional放在内层实际执行业务逻辑的方法上同时在内层事务方法执行完毕后再做一些轻量的状态校验。如果实在避免不了同一个方法上既有锁又有事务至少要在设计上明白这个风险结合业务判断是否需要引入乐观锁或业务幂等键来做二次兜底。5. 进阶扩展多任务、多环境与后续演进思路5.1 多任务下如何设计锁的名称和参数当项目里定时任务越来越多锁名称的管理就变得很重要。SchedulerLock(name xxx)里的name是锁的唯一键在数据库主键中直接体现。命名上我通常采用业务域 动作 粒度的规范比如member:coupon:expireCheck而不是简单地叫task1、task2。因为 ShedLock 锁没有命名空间隔离如果两个毫不相关的任务用了同一个 name它们就会互相排队互相抢锁导致本应同时执行的两个任务被串行化了这是一个不容易察觉的故障点。参数设计上建议每个任务单独评估不要全部套一个默认值。我见过一种比较务实的做法给EnableSchedulerLock的defaultLockAtMostFor设一个兜底值比如 15 分钟然后具体每个任务按实际情况显式覆盖。高频率短任务用小的lockAtLeastFor和短的lockAtMostFor低频率长任务用长的lockAtMostFor。建议配套一个任务清单表格把每个任务的历史执行时间、预期最大耗时、锁参数都记录下来作为长期维护的依据。5.2 多环境部署时的隔离策略如果你的 Spring Boot 服务要部署到 dev、test、prod 多个环境而且共用一套数据库比如开发库和测试库都在同一个 MySQL 实例上ShedLock 表也会是同一张。因为锁的name是主键两个环境的同名任务会互相干扰测试环境抢到的锁生产环境就等着这显然不合理。解决思路有两种。第一种是每个环境单独建一张锁表通过配置动态指定表名。第二种是更推荐的——每个环境用独立的数据库 schema天然隔离连建表脚本和环境绑定都清晰了。我在很多项目里都遇到过多人共用测试库的场景最终都会因为 ShedLock 锁互相抢而出现诡异的定时任务时好时坏的反馈查到最后基本都是环境隔离问题。如果你确实需要同一张表里跑多个环境比如共用一个大库可以把锁名带上环境前缀比如prod:order:stats和dev:order:stats。这种方式可用但可维护性一般不到万不得已我不推荐。5.3 ShedLock 和其他分布式调度方案的取舍很多人看完 ShedLock 觉得功能太简单也担心它能不能扛住生产。我的经验是它解决的核心问题非常单一这恰恰是它的优势。我按实际项目的决策逻辑帮你梳理一下如果场景只是让 Spring Boot 的Scheduled任务在多实例下不重复执行没有复杂调度需求ShedLock JDBC 是性价比最高的方案。依赖少、代码侵入小、不需要额外搭建调度中心。如果业务需要动态调整执行频率、手工触发任务、任务失败重试、分片执行、任务执行情况可视化ShedLock 就不合适了这时候建议考虑 XXL-Job 这类任务调度平台。如果团队已经重度使用 Quartz并且希望保留它成熟的集群特性JobStore、触发器、持久化调度可以考虑 Quartz 自身的集群模式但注意它的锁机制是数据库锁表锁竞争比 ShedLock 更重。这里不用于劝大家ShedLock 万能它就是个锁不是调度中心。项目早期可以先用 ShedLock 快速把多实例重复执行问题按住等后面调度需求真的复杂了再平滑迁移到专职调度框架这个演进路径我在实际项目里走过比较稳妥。有一点需要留神ShedLock 支持多种存储后端——JDBC、Redis、MongoDB、ZooKeeper。如果你在云上已经有现成的 Redis用它做锁存储可以减少数据库连接压力但如果你的 Redis 是单节点且没有高可用锁存储本身会成为单点风险。反过来用 JDBC 存储在事务性数据库里锁和业务数据天然一致排障也简单因此在大多数关系型项目里我还是首选 JDBC 存储。6. 我的实际维护心得锁是底线幂等是兜底最后说几句我在生产维护中体会最深的事情。ShedLock 解决的是多实例下任务不应被重复触发的问题但它不能解决所有重复问题。比如你用一个老旧的接口调用它对账时不保证幂等你起一个新的手动补偿任务也许它和定时任务会同时处理同一批数据。在这种情况下无论如何都应该在业务层面设计幂等键每个任务生成一个业务批次号要么用唯一索引挡要么在更新数据时加status条件判断确保即使有漏网的一次重复执行也不会脏数据。我见过太多人把 ShedLock 当成万能灵药装完就以为万事大吉结果某天手动执行任务时没走锁又或者任务执行环节里调了一个不幂等的支付接口照样出事故。正确的姿势是ShedLock 是第一道防线业务幂等是最后一道防线两道都在才能安心。另外运维侧建议把shedlock表纳入监控范围。我会定期查看锁记录关注lock_until是否出现异常的超长值——如果某个任务的lock_until总是不更新说明任务可能已经卡死一直持有锁而其他实例永远没有机会执行它。这种故障通常在锁表里一眼就能发现比等在业务告警上更可控。根据我的经验多实例环境下定时任务的管理最重要的不是选择一个多么复杂的框架而是清楚地理解你拥有哪些治理手段锁负责互斥幂等负责数据安全监控负责提前发现问题。ShedLock 在其中扮演的角色虽然轻却可以帮你把最常见的重复执行问题压得死死的。如果你正在为多个实例跑同一个定时任务而头疼先按上面步骤把 ShedLock 接起来跑一段时间再看监控你大概率会发现这个问题原来这么简单就解决了。