NestJS中定时Cron任务在指定时间重复执行两次问题求助
问题排查与解决方案
可能原因分析
- 多进程/多实例启动:应用被启动了多个进程(比如开发模式热重载重复加载、PM2多实例配置、部署时意外启动两个进程),导致定时任务被多个实例同时执行。
- 模块重复导入:包含该Cron任务的模块(如
CashOutModule)被多次导入,导致任务所在的Service被实例化多次,每个实例都会触发定时任务。 - 时区差异:服务器时区与预期时区不匹配,可能导致任务在跨天边界被触发两次(较少见,但需排查)。
分步排查与解决
1. 检查是否存在多进程
- 开发环境:关闭NestJS的热重载模式(启动命令去掉
--watch参数),测试任务是否仅执行一次。热重载时可能会重新创建应用实例,导致任务重复触发。 - 生产环境:
- Linux/macOS执行
ps aux | grep node,Windows打开任务管理器,查看是否有多个NestJS进程在运行。 - 如果使用PM2,检查配置文件中的
instances字段,确保设为1;若必须用集群模式,需通过分布式锁避免重复执行。
- Linux/macOS执行
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的实现:
- 安装依赖:
npm install ioredis @nestjs/redis
- 在
AppModule中配置Redis:
import { RedisModule } from '@nestjs/redis'; @Module({ imports: [ ScheduleModule.forRoot(), RedisModule.forRoot({ url: process.env.REDIS_URL, // 替换为你的Redis地址 }), // 其他模块... ], }) export class AppModule {}
- 在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
相关产品推荐
相关产品推荐

