У цій статті розглядаються стратегії управління 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 (що є звичним для виробничих середовищ), виникає кілька проблем:
- Дублювання виконання: Кожен інстанс запускає cron завдання окремо.
- Витрати ресурсів: Кілька інстансів виконують однакові завдання.
- Консистентність даних: Потенційні конфлікти, коли кілька інстансів змінюють однакові дані.
- Умови гонки: Одночасне виконання завдань може заважати одне одному.
Рішення для середовищ з кількома інстансами
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
Порівняння рішень
Рекомендації
Вибирайте підхід залежно від ваших конкретних потреб:
- Малі додатки
- Використовуйте підхід через API або виділений сервіс
- Простота реалізації та підтримки
- Середні додатки
- Розгляньте підходи з блокуванням бази даних
- Добре збалансовані надійність та складність
3.
Великомасштабні додатки - Реалізуйте рішення за допомогою BullMQ
- Найкраще підходить для вимог до високої надійності та масштабованості
Підхід BullMQ з унікальними завданнями залишається рекомендованим рішенням для більшості виробничих середовищ завдяки своїм потужним функціям та надійності.
Вибирайте цей підхід, коли:
- Працюєте в середовищі з кількома інстансами
- Потрібна гарантована одноразова реалізація завдання
- Потрібен вбудований механізм повторних спроб
- Необхідне відстеження та моніторинг завдань
- Багато інстансів виконують одночасно cron-завдання, що призводить до дублювання виконань
- Існує кілька рішень, від виділених сервісів до підходів на основі черг
- Вибір варіантів реалізації варіюється від простих API-ендпоінтів до складних систем черг
- Різні підходи підходять для різних вимог щодо масштабування та інфраструктурних потреб
Перекладено з: Managing Distributed Cron Jobs in NestJS: From Basic to Production-Ready Solutions