在 NestJS 项目里,定时任务本身并不难实现。
真正麻烦的地方往往不是“如何跑起来”,而是上线之后如何让它跑得更稳。
例如:
- 上一次任务还没结束,下一次 Cron 又触发了
- 服务扩容成多实例后,同一个任务被重复执行
- 长任务执行时间超过锁 TTL,导致锁提前失效
- 任务失败了,但日志里看不清到底是任务异常还是锁出了问题
这些问题一旦落到实际业务里,影响往往不是一条日志那么简单,可能直接变成:
- 重复同步
- 重复发消息
- 聚合数据被多次计算
基于这些实际问题,我们封装了 @raytonx/nest-scheduler,希望在保留 @nestjs/schedule 使用习惯的前提下,把定时任务里最容易踩坑的部分统一处理掉。
这个模块解决什么问题
@raytonx/nest-scheduler 的定位并不是取代 @nestjs/schedule,而是在它的基础上补齐更适合生产环境的能力:
- 基于
@nestjs/schedule的任务装饰器封装 - 单进程下的任务重入跳过
- 可选的 Redis 分布式锁执行保护
- 标准化任务与锁生命周期日志
换句话说,它更关注的是:
如何让定时任务在单实例和多实例环境里都尽量可控、可观测、可排查
为什么我们要自己封装
在多个项目里,我们反复遇到同一类问题:
- 开发阶段只考虑了“任务能执行”,没有统一处理“任务是否可以重复进入”
- 部署阶段从单实例扩到多实例后,同一个 Cron 被多个实例同时跑
- 接入 Redis 锁后,日志里仍然很难快速判断任务到底是成功、跳过、失败,还是执行过程中失去了锁
这些问题如果每个项目都各自处理,通常会出现两种情况:
- 每个项目都写一套相似但不完全一致的锁逻辑
- 日志字段和异常行为不统一,后续排查成本越来越高
因此我们更倾向于把这类约定沉淀成一个统一模块,让业务代码回到“定义任务本身”这件事上。
快速开始
如果你已经在 NestJS 项目里使用 @nestjs/schedule,接入方式非常直接:
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { SchedulerModule } from "@raytonx/nest-scheduler";
@Module({
imports: [
ScheduleModule.forRoot(),
SchedulerModule.forRoot({
isGlobal: true,
}),
],
})
export class AppModule {}
如果项目存在多实例部署,需要分布式互斥,再额外安装 Redis 相关依赖:
pnpm add @raytonx/nest-scheduler @nestjs/schedule
pnpm add @raytonx/nest-redis ioredis
Cron / Interval / Timeout 的使用方式
这个模块提供了三个与定时任务对应的装饰器:
DistributedCronDistributedIntervalDistributedTimeout
示例:
import { Injectable } from "@nestjs/common";
import {
DistributedCron,
DistributedInterval,
DistributedTimeout,
} from "@raytonx/nest-scheduler";
@Injectable()
export class JobsService {
@DistributedCron("0 * * * *")
async syncReport(): Promise<void> {
// do work
}
@DistributedInterval(10_000)
async syncMetrics(): Promise<void> {
// do work
}
@DistributedTimeout(5_000)
async warmup(): Promise<void> {
// do work
}
}
默认行为包括:
- 同一任务上次未结束时,本次触发直接跳过
- 未安装或未接入 Redis 时,自动使用进程内锁
- 接入 Redis 时,默认优先使用 Redis 分布式锁
- 长任务默认会自动续期 Redis 锁
- 默认记录任务开始、结束、成功、失败、跳过日志
- Redis 锁续期失败或任务执行期间失去锁所有权时,会单独输出错误日志
这类默认值的意义在于,很多项目并不需要每个任务都手动写锁逻辑和日志逻辑,而是更希望“先有一个安全的默认行为,再按需覆盖”。
单进程锁和 Redis 分布式锁
在只有单实例的场景下,进程内锁通常已经足够,可以解决最常见的“任务重入”问题。
但只要进入多实例部署,情况就会不一样。
例如两个副本同时触发同一个 Cron,如果没有分布式锁,就可能出现重复执行。
这时可以接入 Redis:
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { RedisModule } from "@raytonx/nest-redis";
import { SchedulerModule } from "@raytonx/nest-scheduler";
@Module({
imports: [
ScheduleModule.forRoot(),
RedisModule.forRoot({
isGlobal: true,
connections: [
{
host: "127.0.0.1",
port: 6379,
},
],
}),
SchedulerModule.forRoot({
isGlobal: true,
driver: "auto",
}),
],
})
export class AppModule {}
driver 的规则很明确:
auto:优先 Redis,不可用时回退到memoryredis:强制要求 Redis 锁服务存在memory:始终只使用单进程内锁
这种设计比较适合不同阶段的项目:
- 本地开发和单实例环境可以先用
memory - 进入多实例部署后切到
auto或redis - 对执行一致性要求非常高的任务,可以直接强制使用
redis
长任务为什么要处理锁续期
定时任务的一个常见误区是:
“只要加了 Redis 锁,就不会重复执行。”
实际上,如果任务执行时间超过锁 TTL,锁可能会在任务尚未结束时提前过期。
这意味着新的实例可能重新拿到同一把锁,从而再次执行同一个任务。
@raytonx/nest-scheduler 默认支持自动续期:
SchedulerModule.forRoot({
lock: {
keyPrefix: "scheduler:",
ttl: 30_000,
retryAttempts: 0,
retryDelay: 200,
retryJitter: 50,
autoExtend: true,
extendInterval: 10_000,
},
logging: "default",
});
这并不是为了“绝对防错”,而是为了把长任务里最容易被忽略的风险尽量前置处理。
日志为什么要标准化
在定时任务排查里,最麻烦的往往不是“有没有日志”,而是“日志能不能快速说明发生了什么”。
默认情况下,这个模块会输出结构化 JSON 日志,事件分为两类:
- 任务事件:
task_started、task_succeeded、task_failed、task_skipped、task_finished - 锁事件:
lock_acquired、lock_extended、lock_extend_failed、lock_expired_before_finish、lock_released
其中 logging 支持:
"default":只输出默认开启的任务日志和关键锁异常日志"verbose":额外输出锁获取、续期、释放日志false:关闭日志
默认开启的事件包括:
task_startedtask_succeededtask_failedtask_skippedtask_finishedlock_extend_failedlock_expired_before_finish
如果需要完整锁日志,可以把 logging 设为 "verbose",额外输出:
lock_acquiredlock_extendedlock_released
这类标准化日志的价值在于,当你面对线上问题时,可以更快区分:
- 是任务本身执行失败
- 还是任务被主动跳过
- 还是锁续期失败
- 还是任务执行中途已经失去了锁
例如当 Redis 锁 TTL 到期且任务尚未结束时,通常会看到:
lock_extend_failedlock_expired_before_finish- 随后的
task_failed - 最终的
task_finished
这对排查定时任务一致性问题会很有帮助。
装饰器级别的覆盖配置
除了模块级默认配置,也可以在单个任务上做更细粒度控制:
@DistributedCron("0 * * * *", {
name: "report-job",
lockKey: "jobs:report",
driver: "redis",
ttl: 60_000,
skipIfLocked: true,
logging: "verbose",
})
总结
@raytonx/nest-scheduler 本质上是在 @nestjs/schedule 之上,补齐了一层更适合生产环境的执行保护:
- 单进程下避免任务重入
- 多实例下支持 Redis 分布式锁
- 长任务支持自动续期
- 任务与锁事件具备统一日志结构
如果你的 NestJS 项目里已经开始出现以下信号:
- 定时任务偶尔重复执行
- 扩容后 Cron 行为不稳定
- 排查任务问题时很难判断是业务错误还是锁问题
那么把这些能力前移到统一模块里,通常比让每个业务任务各自处理会更稳妥。
安装:
pnpm add @raytonx/nest-scheduler @nestjs/schedule
如需多实例分布式互斥,再额外安装:
pnpm add @raytonx/nest-redis ioredis
对很多团队来说,定时任务真正难的从来不是“怎么写一个 Cron”,而是“怎么让它在真实生产环境里稳定运行”。
这正是我们封装这个模块的出发点。