DDK Job Starter
让定时任务在多实例下同一时刻只在一个实例上执行。它开启 Spring 的
@Scheduled,并接入 ShedLock,锁放在 Redis 里。
实例 A @Scheduled 触发 ──► 抢锁 ddk:job-lock:<应用名>:<任务名> 抢到 ──► 执行任务 ──► 释放(不早于 at-least-for)
实例 B @Scheduled 触发 ──► 抢锁 ddk:job-lock:<应用名>:<任务名> 被占用 ──► 跳过这一轮能做什么
| 能力 | 说明 |
|---|---|
| 开启调度 | 不需要再写 @EnableScheduling 和 @EnableSchedulerLock |
| 任务互斥 | 标了 @SchedulerLock 的任务,多个实例里只有抢到锁的那个执行 |
| 锁存储 | 默认用应用的 Spring Data Redis 连接;应用声明自己的 ShedLock LockProvider 即可换成数据库等存储 |
| 架构规则 | 任务只能写在适配层;可选规则要求每个 @Scheduled 都加锁 |
引入方式
<dependency>
<groupId>com.domaindrivenkit</groupId>
<artifactId>ddk-job-starter</artifactId>
</dependency>package com.acme.order.adapter.job;
@Component
public class OrderJobs {
private final OrderService orders;
@Scheduled(cron = "0 */5 * * * *")
@SchedulerLock(name = "closeExpiredOrders")
public void closeExpiredOrders() {
orders.closeExpired();
}
}任务类放在 adapter.job,方法里只调用应用服务。定时任务和控制器、MCP 工具一样是用例的入口,只是触发它的是时钟:事务、业务校验和领域事件都应该走同一条路径,不能在任务里直接通过 Mapper 改表。ArchGuard 的 SCHEDULED_JOBS_MUST_RESIDE_IN_ADAPTER 规则在生成的项目里默认检查这一点。
多租户
定时任务开始执行时,OperatorContext 里没有操作者。开启 ddk-mybatis-starter 的多租户后,它的第一条查询就会失败,这是应该的:任务必须说明自己在为哪个租户工作。在任务里逐个租户、以系统操作者的身份执行:
@Scheduled(cron = "0 */5 * * * *")
@SchedulerLock(name = "closeExpiredOrders")
public void closeExpiredOrders() {
for (String tenant : tenants.activeTenantIds()) { // 租户列表由应用自己提供
OperatorContext.runAs(new Operator("job:closeExpiredOrders", null, tenant), orders::closeExpired);
}
}runAs结束后恢复调度线程原来的上下文,一个租户不会带到下一轮或下一个任务。- 操作者标识写成任务名,它会出现在任务写入数据的审计字段里。
- 不希望一个租户失败就中断其他租户时,按租户分别捕获异常。
- 不要为了让任务跑通而关掉租户拦截器或豁免 Mapper,那样会读到所有租户的数据。
锁的两个时间
| 设置 | 含义 |
|---|---|
lockAtMostFor | 持锁的最长时间。执行任务的实例宕机后,锁到期才会释放,其他实例才能接手。要比任务的正常耗时长,但不要长太多 |
lockAtLeastFor | 持锁的最短时间。任务很快结束时锁仍保留这么久,防止另一个时钟略有偏差的实例把同一轮再执行一次 |
两者的默认值在 ddk.job.lock.* 下配置,也可以在每个任务的 @SchedulerLock 上单独指定。任务的实际耗时超过 lockAtMostFor 时,下一轮可能在另一个实例上与它重叠,所以任务本身仍然要能安全地重复执行。
漏写 @SchedulerLock 不会报错。 没有这个注解的 @Scheduled 方法会在每个实例上各执行一遍。刷新本地缓存这类任务本来就该如此,但更多时候是忘了。想让漏写变成构建失败,把可选规则加进架构测试:
ArchGuard.check(classes,
CommonArchRules.LAYERED_ARCHITECTURE_RULE,
CommonArchRules.SCHEDULED_JOBS_MUST_RESIDE_IN_ADAPTER,
CommonArchRules.SCHEDULED_JOBS_MUST_BE_LOCKED);没有 Redis 的环境
本地开发环境常常没有 Redis。默认配置下没有 Redis 连接就没有 LockProvider,带 @SchedulerLock 的任务触发时会失败。0.7.0 起,可以为这样的环境选择进程内的锁:
ddk:
job:
lock:
store: local锁放在当前进程的内存里:进程内同名任务仍然不会重叠,lockAtMostFor 与 lockAtLeastFor 的含义不变。它不跨实例,只能用在单实例上,启用时启动日志里有一条警告。它必须显式选择,缺少 Redis 连接时不会自动切换过去:一把悄悄变成不共享的锁,比任务直接失败更糟。
配置项
| 配置项 | 默认值 | 说明 |
|---|---|---|
ddk.job.enabled | true | 关闭调度,例如本地只想跑接口时 |
ddk.job.lock.at-most-for | 10m | 默认的 lockAtMostFor |
ddk.job.lock.at-least-for | 0s | 默认的 lockAtLeastFor |
ddk.job.lock.store | redis | 锁放在哪里。local 放在当前进程的内存里,不需要 Redis,只能用于单实例(0.7.0 起) |
ddk.job.lock.key-prefix | ddk:job-lock | Redis key 的前缀,完整的 key 是 <前缀>:<spring.application.name>:<任务名> |
不适用的场景 / 已知问题
- 它不是分布式调度:没有分片、失败重试、控制台和手动触发。需要这些能力时用调度平台。
- 没有 XXL-Job 的 starter。XXL-Job 的许可证是 GPL-3.0,DDK 是 Apache-2.0,DDK 的模块不链接它。项目里照常按 XXL-Job 的文档自己配置
XxlJobSpringExecutor即可,SCHEDULED_JOBS_MUST_RESIDE_IN_ADAPTER规则同样覆盖@XxlJob方法。以后会考虑集成许可证兼容的调度平台。 - 锁依赖单个 Redis:主从切换时锁可能丢失,任务有小概率被执行两次。这是任务要能安全重复执行的另一个原因。