You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

NestJS中定时Cron任务在指定时间重复执行两次问题求助

问题排查与解决方案

可能原因分析

  1. 多进程/多实例启动:应用被启动了多个进程(比如开发模式热重载重复加载、PM2多实例配置、部署时意外启动两个进程),导致定时任务被多个实例同时执行。
  2. 模块重复导入:包含该Cron任务的模块(如CashOutModule)被多次导入,导致任务所在的Service被实例化多次,每个实例都会触发定时任务。
  3. 时区差异:服务器时区与预期时区不匹配,可能导致任务在跨天边界被触发两次(较少见,但需排查)。

分步排查与解决

1. 检查是否存在多进程

  • 开发环境:关闭NestJS的热重载模式(启动命令去掉--watch参数),测试任务是否仅执行一次。热重载时可能会重新创建应用实例,导致任务重复触发。
  • 生产环境:
    • Linux/macOS执行ps aux | grep node,Windows打开任务管理器,查看是否有多个NestJS进程在运行。
    • 如果使用PM2,检查配置文件中的instances字段,确保设为1;若必须用集群模式,需通过分布式锁避免重复执行。

2. 检查模块导入是否重复

  • 确认包含Cron任务的模块(如CashOutModule)在AppModule及其他模块中仅被导入一次。重复导入会导致模块内的Service被多次实例化,每个实例都会触发定时任务。
  • 确保任务所在的Service在模块的providers中仅注册一次,未被其他模块重复声明。

3. 添加日志定位问题

给定时任务添加日志,记录执行时间、进程ID,确认是否为不同实例触发:

import { Logger } from '@nestjs/common';

@Injectable()
export class CashOutService {
  private readonly logger = new Logger(CashOutService.name);

  @Cron('59 23 * * *', { name: 'cash-pending-cron' })
  async CashPendingCRON(){
    this.logger.log(`CashPendingCRON启动,时间:${new Date().toISOString()},进程ID:${process.pid}`);
    
    let stores = await this.storeRepository.find();
    for (let store of stores){
        await this.connection
        .createQueryBuilder()
        .insert()
        .into(CashPending)
        .values([{ cashPending: store.cashPending, store: store }])
        .execute();
        this.logger.log(`已为店铺${store.id}生成待结算记录`);
    }
    
    this.logger.log(`CashPendingCRON执行完成,时间:${new Date().toISOString()}`);
 }
}

4. 处理多实例场景(分布式锁)

如果是多实例部署,必须通过分布式锁保证同一时间只有一个实例执行任务,以下是基于Redis的实现:

  1. 安装依赖:
npm install ioredis @nestjs/redis
  1. 在AppModule中配置Redis:
import { RedisModule } from '@nestjs/redis';

@Module({
  imports: [
    ScheduleModule.forRoot(),
    RedisModule.forRoot({
      url: process.env.REDIS_URL, // 替换为你的Redis地址
    }),
    // 其他模块...
  ],
})
export class AppModule {}
  1. 在Service中实现锁逻辑:
import { RedisService } from '@nestjs/redis';
import { Logger } from '@nestjs/common';

@Injectable()
export class CashOutService {
  private readonly logger = new Logger(CashOutService.name);
  private readonly redisClient;

  constructor(
    private readonly redisService: RedisService,
    // 其他依赖...
  ) {
    this.redisClient = this.redisService.getClient();
  }

  @Cron('59 23 * * *', { name: 'cash-pending-cron' })
  async CashPendingCRON(){
    const lockKey = 'cash-pending-cron-lock';
    const lockTTL = 3600; // 锁有效期1小时,确保任务执行完后自动释放

    // 尝试获取锁
    const lockAcquired = await this.redisClient.set(lockKey, process.pid, 'EX', lockTTL, 'NX');
    if (!lockAcquired) {
      const holderPID = await this.redisClient.get(lockKey);
      this.logger.log(`CashPendingCRON跳过:已有实例(PID: ${holderPID})在执行`);
      return;
    }

    try {
      this.logger.log(`CashPendingCRON启动,时间:${new Date().toISOString()},进程ID:${process.pid}`);
      let stores = await this.storeRepository.find();
      for (let store of stores){
          await this.connection
          .createQueryBuilder()
          .insert()
          .into(CashPending)
          .values([{ cashPending: store.cashPending, store: store }])
          .execute();
          this.logger.log(`已为店铺${store.id}生成待结算记录`);
      }
      this.logger.log(`CashPendingCRON执行完成,时间:${new Date().toISOString()}`);
    } finally {
      // 执行完成后释放锁
      await this.redisClient.del(lockKey);
      this.logger.log(`CashPendingCRON锁已释放`);
    }
 }
}

5. 确认时区配置(可选)

如果服务器时区与预期不一致,可能导致任务触发时间偏差,可在Cron装饰器中指定时区:

@Cron('59 23 * * *', { name: 'cash-pending-cron', timeZone: 'Asia/Shanghai' }) // 替换为你的目标时区

内容的提问来源于stack exchange,提问作者Rajarshi Ghoshal

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.13 21:31:01