返回博客
NestJS定时任务Redis分布式锁Scheduler

NestJS 定时任务实践:如何避免任务重入与多实例重复执行

结合实际项目经验,介绍我们如何基于 @nestjs/schedule 封装 @raytonx/nest-scheduler,用更统一的方式解决 NestJS 定时任务中的任务重入、多实例重复执行、锁续期与日志可观测性问题。

RaytonX
3 min read

在 NestJS 项目里,定时任务本身并不难实现。
真正麻烦的地方往往不是“如何跑起来”,而是上线之后如何让它跑得更稳。

例如:

  • 上一次任务还没结束,下一次 Cron 又触发了
  • 服务扩容成多实例后,同一个任务被重复执行
  • 长任务执行时间超过锁 TTL,导致锁提前失效
  • 任务失败了,但日志里看不清到底是任务异常还是锁出了问题

这些问题一旦落到实际业务里,影响往往不是一条日志那么简单,可能直接变成:

  • 重复同步
  • 重复发消息
  • 聚合数据被多次计算

基于这些实际问题,我们封装了 @raytonx/nest-scheduler,希望在保留 @nestjs/schedule 使用习惯的前提下,把定时任务里最容易踩坑的部分统一处理掉。

这个模块解决什么问题

@raytonx/nest-scheduler 的定位并不是取代 @nestjs/schedule,而是在它的基础上补齐更适合生产环境的能力:

  • 基于 @nestjs/schedule 的任务装饰器封装
  • 单进程下的任务重入跳过
  • 可选的 Redis 分布式锁执行保护
  • 标准化任务与锁生命周期日志

换句话说,它更关注的是:

如何让定时任务在单实例和多实例环境里都尽量可控、可观测、可排查

为什么我们要自己封装

在多个项目里,我们反复遇到同一类问题:

  1. 开发阶段只考虑了“任务能执行”,没有统一处理“任务是否可以重复进入”
  2. 部署阶段从单实例扩到多实例后,同一个 Cron 被多个实例同时跑
  3. 接入 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 的使用方式

这个模块提供了三个与定时任务对应的装饰器:

  • DistributedCron
  • DistributedInterval
  • DistributedTimeout

示例:

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,不可用时回退到 memory
  • redis:强制要求 Redis 锁服务存在
  • memory:始终只使用单进程内锁

这种设计比较适合不同阶段的项目:

  • 本地开发和单实例环境可以先用 memory
  • 进入多实例部署后切到 autoredis
  • 对执行一致性要求非常高的任务,可以直接强制使用 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_startedtask_succeededtask_failedtask_skippedtask_finished
  • 锁事件:lock_acquiredlock_extendedlock_extend_failedlock_expired_before_finishlock_released

其中 logging 支持:

  • "default":只输出默认开启的任务日志和关键锁异常日志
  • "verbose":额外输出锁获取、续期、释放日志
  • false:关闭日志

默认开启的事件包括:

  • task_started
  • task_succeeded
  • task_failed
  • task_skipped
  • task_finished
  • lock_extend_failed
  • lock_expired_before_finish

如果需要完整锁日志,可以把 logging 设为 "verbose",额外输出:

  • lock_acquired
  • lock_extended
  • lock_released

这类标准化日志的价值在于,当你面对线上问题时,可以更快区分:

  • 是任务本身执行失败
  • 还是任务被主动跳过
  • 还是锁续期失败
  • 还是任务执行中途已经失去了锁

例如当 Redis 锁 TTL 到期且任务尚未结束时,通常会看到:

  • lock_extend_failed
  • lock_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”,而是“怎么让它在真实生产环境里稳定运行”。
这正是我们封装这个模块的出发点。