Управління розподіленими cron-завданнями в NestJS: від базових до рішень, готових до виробництва

pic

У цій статті розглядаються стратегії управління cron завданнями в додатках NestJS, що працюють на кількох інстансах. Ми розглянемо типові проблеми в розподілених середовищах та запропонуємо різні підходи для забезпечення надійного планування із одноразовим виконанням — від базових реалізацій до рішень, готових до виробництва.

Базова реалізація cron

Почнемо з базової реалізації cron за допомогою пакету @nestjs/schedule:

 // app.module.ts  
import { Module } from '@nestjs/common';  
import { ScheduleModule } from '@nestjs/schedule';  
import { CronService } from './cron.service';  

@Module({  
 imports: [ScheduleModule.forRoot()],  
 providers: [CronService],  
})  
export class AppModule {}  


// cron.service.ts  
import { Injectable, Logger } from '@nestjs/common';  
import { Cron, CronExpression } from '@nestjs/schedule';  

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

 @Cron(CronExpression.EVERY_HOUR)  
 async handleCron() {  
 this.logger.log('Running hourly task...');  
 await this.performTask();  
 }  

 private async performTask() {  
 // Реалізація завдання  
 await new Promise(resolve => setTimeout(resolve, 1000));  
 }  
}

Проблема багатьох інстансів

Коли ви запускаєте кілька інстансів вашого додатка NestJS (що є звичним для виробничих середовищ), виникає кілька проблем:

  1. Дублювання виконання: Кожен інстанс запускає cron завдання окремо.
  2. Витрати ресурсів: Кілька інстансів виконують однакові завдання.
  3. Консистентність даних: Потенційні конфлікти, коли кілька інстансів змінюють однакові дані.
  4. Умови гонки: Одночасне виконання завдань може заважати одне одному.

Рішення для середовищ з кількома інстансами

1. Підхід з виділеним сервісом

Одне з найпростіших рішень — створити окремий сервіс, призначений для фонової обробки:

Переваги:

  • Простота в реалізації та підтримці.
  • Чітке розмежування обов'язків.
  • Гарантоване одноразове виконання.

Недоліки:

  • Додаткові витрати на інфраструктуру.
  • Одинична точка відмови.
  • Обмеження в масштабуванні.

2. Підхід через API

Перетворіть cron завдання в API-ендпоінти, які викликаються зовнішніми планувальниками:

@Controller('cron')  
export class CronController {  
 @Post('daily-task')  
 @UseGuards(ApiKeyGuard) // Забезпечення безпечного доступу  
 async triggerDailyTask() {  
 // Реалізація завдання  
 }  
}

Переваги:

  • Балансувальник навантаження обирає інстанс для виконання.
  • Легко моніторити та відлагоджувати.
  • Можна вручну запустити, якщо потрібно.

Недоліки:

  • Потрібен зовнішній планувальник.
  • Можливі проблеми з мережею.
  • Додаткові вимоги до безпеки.

3. Підхід із блокуванням бази даних

Блокування транзакцій у RDBMS:

@Injectable()  
export class CronService {  
 @Cron(CronExpression.EVERY_HOUR)  
 async handleCron() {  
 await this.dataSource.transaction(async manager => {  
 const lock = await manager.findOne(CronLock, {  
 where: { jobId: 'hourly-task' },  
 lock: { mode: 'pessimistic_write' }  
 });  

 if (lock && lock.isLocked) return;  
 // Виконати завдання  
 });  
 }  
}

Підхід із TTL у MongoDB:

@Injectable()  
export class CronService {  
 @Cron(CronExpression.EVERY_HOUR)  
 async handleCron() {  
 try {  
 await this.mongoCollection.insertOne({  
 _id: `hourly-task-${new Date().toISOString().split('T')[0]}`,  
 createdAt: new Date(),  
 });  
 // Виконати завдання  
 } catch (error) {  
 if (error.code === 11000) return; // Помилка дубліката ключа  
 throw error;  
 }  
 }  
}

4.

Redis Lock Підхід

Цей підхід використовує Redis для реалізації розподіленого блокування:

import { Injectable, Logger } from '@nestjs/common';  
import { Cron, CronExpression } from '@nestjs/schedule';  
import * as Redis from 'redis';  
import * as redisLock from 'redis-lock';  
import { promisify } from 'util';  

@Injectable()  
export class CronService {  
 private readonly redisClient: Redis.RedisClient;  
 private readonly acquireLock: (key: string, timeout?: number) => Promise<() => void>;  

 constructor() {  
 this.redisClient = Redis.createClient();  
 this.acquireLock = promisify(redisLock(this.redisClient));  
 }  

 @Cron(CronExpression.EVERY_HOUR)  
 async handleCron() {  
 let unlock: (() => void) | undefined;  

 try {  
 unlock = await this.acquireLock('cron-lock:hourly-job', 5000);  
 await this.performTask();  
 } catch (error) {  
 if (error.message?.includes('lock already held')) {  
 return;  
 }  
 throw error;  
 } finally {  
 if (unlock) unlock();  
 }  
 }  
}

Переваги:

  • Простота реалізації
  • Прямий контроль над механізмом блокування

Недоліки:

  • Ручне управління блокуванням
  • Потенційні мертві блокування
  • Відсутність вбудованого механізму повторних спроб

5. BullMQ з повторюваними завданнями

Цей підхід використовує вбудовану функцію повторюваних завдань у BullMQ:

import { Injectable, OnModuleInit } from '@nestjs/common';  
import { InjectQueue } from '@nestjs/bullmq';  
import { Queue } from 'bullmq';  

@Injectable()  
export class CronService implements OnModuleInit {  
 constructor(@InjectQueue('scheduled-jobs') private queue: Queue) {}  

 async onModuleInit() {  
 await this.queue.add(  
 'hourly-task',  
 { timestamp: new Date().toISOString() },  
 {  
 repeat: {  
 pattern: '0 * * * *'  
 },  
 removeOnComplete: true  
 }  
 );  
 }  
}

Переваги:

  • Вбудоване розподілення завдань
  • Автоматичний механізм повторних спроб
  • Відстежування прогресу завдань

Недоліки:

  • Додаткові вимоги до інфраструктури (Redis)
  • Складніша налаштування

6. BullMQ з унікальними завданнями

Цей підхід використовує унікальні ідентифікатори завдань BullMQ для запобігання дублюванню:

import { Injectable, Logger } from '@nestjs/common';  
import { InjectQueue } from '@nestjs/bullmq';  
import { Queue } from 'bullmq';  
import { Cron } from '@nestjs/schedule';  

@Injectable()  
export class CronService {  
 constructor(@InjectQueue('scheduled-jobs') private queue: Queue) {}  

 @Cron('0 * * * *')  
 async scheduleHourlyJob() {  
 const jobId = `hourly-job-${new Date().toISOString().split('T')[0]}-${new Date().getHours()}`;  

 try {  
 await this.queue.add('hourly-task',   
 { timestamp: new Date().toISOString() },  
 { jobId }  
 );  
 } catch (error) {  
 if (error.name === 'BullMQDuplicateJob') return;  
 throw error;  
 }  
 }  
}

Переваги:

  • Гарантоване унікальне виконання завдання
  • Вбудоване запобігання дублюванню
  • Простота, порівняно з повторюваними завданнями
  • Гнучкий розклад завдань

Недоліки:

  • Потребує ретельного створення jobId

Порівняння рішень

pic

Рекомендації

Вибирайте підхід залежно від ваших конкретних потреб:

  1. Малі додатки
  2. Використовуйте підхід через API або виділений сервіс
  3. Простота реалізації та підтримки
  4. Середні додатки
  5. Розгляньте підходи з блокуванням бази даних
  6. Добре збалансовані надійність та складність
    3.
    Великомасштабні додатки
  7. Реалізуйте рішення за допомогою BullMQ
  8. Найкраще підходить для вимог до високої надійності та масштабованості

Підхід BullMQ з унікальними завданнями залишається рекомендованим рішенням для більшості виробничих середовищ завдяки своїм потужним функціям та надійності.

Вибирайте цей підхід, коли:

  • Працюєте в середовищі з кількома інстансами
  • Потрібна гарантована одноразова реалізація завдання
  • Потрібен вбудований механізм повторних спроб
  • Необхідне відстеження та моніторинг завдань
  • Багато інстансів виконують одночасно cron-завдання, що призводить до дублювання виконань
  • Існує кілька рішень, від виділених сервісів до підходів на основі черг
  • Вибір варіантів реалізації варіюється від простих API-ендпоінтів до складних систем черг
  • Різні підходи підходять для різних вимог щодо масштабування та інфраструктурних потреб

Перекладено з: Managing Distributed Cron Jobs in NestJS: From Basic to Production-Ready Solutions

Leave a Reply

Your email address will not be published. Required fields are marked *