Node.js 独立产品定时任务设计:从 Cron 到分布式调度

📅 2026/7/9 12:59:50
Node.js 独立产品定时任务设计:从 Cron 到分布式调度
Node.js 独立产品定时任务设计从 Cron 到分布式调度一、独立产品定时任务的技术演进路径独立产品在不同发展阶段对定时任务的需求差异显著。创业初期只需简单的数据备份成长期需要复杂的业务调度规模化后面临分布式挑战。技术选型需要匹配当前阶段避免过度设计。从单机 Cron 到分布式调度系统存在清晰的技术演进路径。// 阶段 1简单 Cron适合 MVP 阶段 // 使用 node-cron 或 system Cron import cron from node-cron; // 每天凌晨 2 点执行数据备份 cron.schedule(0 2 * * *, async () { console.log(Starting daily backup...); try { await backupDatabase(); console.log(Backup completed); } catch (error) { console.error(Backup failed:, error); } }); // 存在问题 // 1. 单机故障导致任务丢失 // 2. 无法水平扩展 // 3. 缺乏任务监控和重试机制 // 4. 时间漂移服务器时间不准确各阶段的技术特征对比阶段用户规模任务类型推荐方案复杂度MVP 1000数据备份、简单通知node-cron / system Cron低成长期1000-10000报表生成、定时推送Bull Queue / Agenda中规模化 10000分布式任务、复杂调度Redis Lock / 自研调度器高graph LR A[MVP 阶段br/单机 Cron] -- B[成长期br/任务队列] B -- C[规模化br/分布式调度] A --|问题单点故障| B B --|问题任务冲突| C B -- D[技术选型] D -- E[Bull Queuebr/Redis 持久化] D -- F[Agendabr/MongoDB 持久化] C -- G[分布式锁] C -- H[任务分片] C -- I[故障转移] style A fill:#e1f5fe style B fill:#fff3e0 style C fill:#f3e5f5二、基于 Redis 的任务队列方案对于成长期的独立产品推荐使用基于 Redis 的任务队列如 Bull Queue实现定时任务。优势包括持久化、重试机制、任务优先级、分布式支持。Bull Queue 的核心概念Queue任务队列负责存储和分发任务Job具体的任务实例包含任务数据和执行状态Worker任务执行器从队列中获取任务并执行Scheduler调度器负责定时任务的触发场景描述Bull Queue 的 Repeatable Job 有一个容易被忽视的特性当同一个 Cron 任务被多次add时Bull 不会去重而是创建多个独立的重复任务。我们发现线上数据备份任务在执行两次排查后发现是 PM2 集群模式下每个进程的启动脚本都调用了addCronJob导致 4 个进程 4 个重复任务数据库每晚被备份 4 次。// 单实例环境下用 jobId 去重 await queue.add(taskType, data, { repeat: { cron: 0 2 * * * }, jobId: daily-backup-${taskType} // 固定 jobId 阻止重复创建 });但在多实例部署时上述方法只能保证同一队列内不去重不能防止不同实例的队列都创建了相同的 Repeatable Job。最终方案是把 Cron 任务的注册放在独立的管理进程或初始化脚本中而不是放在每个业务进程的启动代码里。// 使用 Bull Queue 实现定时任务 // services/taskQueue.ts import Queue, { Job } from bull; import Redis from ioredis; export interface TaskData { type: string; payload: Recordstring, any; metadata?: { userId?: string; organizationId?: string; }; } export class TaskQueueService { private queues: Mapstring, Queue new Map(); private redis: Redis; constructor(redisUrl: string) { this.redis new Redis(redisUrl, { retryStrategy: (times) Math.min(times * 50, 2000) }); } /** * 创建或获取队列 */ getQueue(name: string): Queue { if (!this.queues.has(name)) { const queue new Queue(name, { redis: this.redis, defaultJobOptions: { removeOnComplete: 100, // 保留最近 100 个完成的任务 removeOnFail: 500, // 保留最近 500 个失败的任务 attempts: 3, // 失败重试次数 backoff: { type: exponential, delay: 5000 // 重试延迟毫秒 } } }); // 全局错误处理 queue.on(error, (error) { console.error(Queue ${name} error:, error); }); queue.on(failed, (job, error) { console.error(Job ${job.id} failed:, error); }); this.queues.set(name, queue); } return this.queues.get(name)!; } /** * 添加定时任务使用 Cron 表达式 */ async addCronJob( queueName: string, taskType: string, payload: Recordstring, any, cronExpression: string, options?: { tz?: string; // 时区 startDate?: Date; // 开始时间 endDate?: Date; // 结束时间 } ): PromiseJob { const queue this.getQueue(queueName); return queue.add( taskType, { type: taskType, payload }, { repeat: { cron: cronExpression, tz: options?.tz || Asia/Shanghai, startDate: options?.startDate, endDate: options?.endDate } } ); } /** * 添加延迟任务一次性执行 */ async addDelayedJob( queueName: string, taskType: string, payload: Recordstring, any, delayMs: number ): PromiseJob { const queue this.getQueue(queueName); return queue.add( taskType, { type: taskType, payload }, { delay: delayMs } ); } /** * 注册任务处理器 */ registerProcessor( queueName: string, taskType: string, processor: (job: JobTaskData) Promisevoid ): void { const queue this.getQueue(queueName); queue.process(taskType, async (job) { const startTime Date.now(); console.log(Processing job ${job.id} (${taskType})...); try { await processor(job); const duration Date.now() - startTime; console.log(Job ${job.id} completed in ${duration}ms); } catch (error) { console.error(Job ${job.id} failed after ${Date.now() - startTime}ms:, error); throw error; // 重新抛出错误触发重试机制 } }); } /** * 获取队列统计信息 */ async getQueueStats(queueName: string): Promise{ waiting: number; active: number; completed: number; failed: number; delayed: number; } { const queue this.getQueue(queueName); const [waiting, active, completed, failed, delayed] await Promise.all([ queue.getWaitingCount(), queue.getActiveCount(), queue.getCompletedCount(), queue.getFailedCount(), queue.getDelayedCount() ]); return { waiting, active, completed, failed, delayed }; } /** * 清理已完成的任务 */ async cleanQueue(queueName: string, gracePeriodMs: number 24 * 60 * 60 * 1000): Promisevoid { const queue this.getQueue(queueName); await Promise.all([ queue.clean(gracePeriodMs, completed), queue.clean(gracePeriodMs, failed) ]); console.log(Cleaned old jobs from queue: ${queueName}); } }实际业务场景示例// tasks/userTasks.ts - 用户相关的定时任务 import { TaskQueueService } from ../services/taskQueueService; export function registerUserTasks(taskService: TaskQueueService): void { // 任务 1每日用户活跃度统计凌晨 1 点执行 taskService.registerProcessor(user-tasks, daily-activity-stats, async (job) { const { organizationId } job.data.payload; // 统计昨天的用户活跃数据 const stats await calculateDailyActivity(organizationId, yesterday); // 保存统计结果 await saveActivityStats(stats); // 如果活跃度下降超过 20%发送告警 if (stats.activityDropRate 0.2) { await sendAlert({ type: activity_drop, organizationId, data: stats }); } }); // 添加 Cron 任务 taskService.addCronJob( user-tasks, daily-activity-stats, { organizationId: all }, 0 1 * * *, // 每天凌晨 1 点 { tz: Asia/Shanghai } ); // 任务 2定期清理过期邀请链接每小时执行 taskService.registerProcessor(user-tasks, cleanup-invites, async (job) { const expiredCount await cleanupExpiredInvites(); console.log(Cleaned up ${expiredCount} expired invites); }); taskService.addCronJob( user-tasks, cleanup-invites, {}, 0 * * * * // 每小时的第 0 分钟 ); // 任务 3用户流失预警每天上午 9 点执行 taskService.registerProcessor(user-tasks, churn-prediction, async (job) { const highRiskUsers await predictChurnRisk(); for (const user of highRiskUsers) { // 发送个性化挽留邮件 await sendRetentionEmail(user); // 记录预警日志 await logChurnWarning(user); } }); taskService.addCronJob( user-tasks, churn-prediction, {}, 0 9 * * *, { tz: Asia/Shanghai } ); } // 辅助函数简化实现 async function calculateDailyActivity(orgId: string, date: string): Promiseany { // 实现活跃度统计逻辑 return { organizationId: orgId, date, activeUsers: 0, newUsers: 0, activityDropRate: 0 }; } async function saveActivityStats(stats: any): Promisevoid { // 保存统计结果到数据库 console.log(Saving activity stats:, stats); } async function sendAlert(alert: any): Promisevoid { // 发送告警通知 console.log(Sending alert:, alert); } async function cleanupExpiredInvites(): Promisenumber { // 清理过期邀请链接 return 0; } async function predictChurnRisk(): Promiseany[] { // 预测流失风险 return []; } async function sendRetentionEmail(user: any): Promisevoid { // 发送挽留邮件 console.log(Sending retention email to user:, user); } async function logChurnWarning(user: any): Promisevoid { // 记录预警日志 console.log(Logging churn warning for user:, user); }三、分布式调度系统的设计当独立产品发展到多实例部署阶段需要解决定时任务的分布式执行问题。核心挑战包括任务重复执行、调度一致性、故障转移。技术方案分布式锁使用 Redis Redlock 确保同一任务在同一时间只在一个实例上执行主节点选举使用 Redis Sentinel 或 etcd 选举调度主节点任务分片将大任务拆分为多个子任务分发到不同实例执行状态同步使用 Redis 或数据库同步任务执行状态场景描述分布式锁是解决重复执行的核心手段但锁的 TTL过期时间设置需要与任务的预期执行时间匹配。我们曾经设置了一个数据导出任务的锁 TTL 为 60 秒而实际导出操作耗时约 90 秒——锁 TTL 到期自动释放后另一个节点抢到锁又启动了一份导出导致两个节点同时导出数据用户收到两份通知邮件。教训是锁的 TTL 至少要设置成任务预期最大耗时的 2 倍并且在任务执行期间定期续期。// 分布式任务调度器 // services/distributedScheduler.ts import Redis from ioredis; import Redlock from redlock; export class DistributedScheduler { private redis: Redis; private redlock: Redlock; private nodeId: string; private isLeader: boolean false; private tasks: Mapstring, ScheduledTask new Map(); constructor(redisUrl: string, nodeId?: string) { this.redis new Redis(redisUrl); this.redlock new Redlock([this.redis], { driftFactor: 0.01, retryCount: 10, retryDelay: 200 }); this.nodeId nodeId || ${process.pid}-${Date.now()}; console.log(Distributed scheduler initialized (Node ID: ${this.nodeId})); } /** * 启动调度器 */ async start(): Promisevoid { // 1. 尝试获取领导权 await this.electLeader(); // 2. 监听领导权变化 this.startLeaderElection(); // 3. 注册内置任务 this.registerBuiltInTasks(); console.log(Scheduler started on node ${this.nodeId}); } /** * 注册定时任务 */ registerTask(task: ScheduledTask): void { if (this.tasks.has(task.name)) { console.warn(Task ${task.name} already registered, overwriting...); } this.tasks.set(task.name, task); console.log(Registered task: ${task.name} (${task.cron})); } /** * 执行任务带分布式锁 */ private async executeTaskWithLock(task: ScheduledTask): Promisevoid { const lockKey scheduler:lock:${task.name}; const lockTtl task.timeout || 60000; // 锁的过期时间毫秒 try { // 获取分布式锁 const lock await this.redlock.acquire([lockKey], lockTtl); try { console.log(Executing task: ${task.name} (Node: ${this.nodeId})); // 执行任务 await Promise.race([ task.execute(), this.timeoutPromise(task.timeout || 30000) ]); console.log(Task ${task.name} completed successfully); } finally { // 释放锁 await lock.unlock(); } } catch (error) { if (error instanceof Error error.message.includes(LockError)) { console.log(Task ${task.name} is already running on another node); } else { console.error(Task ${task.name} failed:, error); // 执行失败回调 if (task.onFailure) { await task.onFailure(error); } } } } /** * 领导节点选举 */ private async electLeader(): Promisevoid { const leaderKey scheduler:leader; const leaderTtl 10000; // 领导权有效期毫秒 try { const result await this.redis.set( leaderKey, this.nodeId, NX, PX, leaderTtl ); if (result OK) { this.isLeader true; console.log(Node ${this.nodeId} elected as leader); // 续期领导权 this.renewLeadership(leaderKey, leaderTtl); } else { this.isLeader false; const currentLeader await this.redis.get(leaderKey); console.log(Node ${this.nodeId} is follower (Leader: ${currentLeader})); } } catch (error) { console.error(Leader election failed:, error); this.isLeader false; } } /** * 续期领导权 */ private async renewLeadership(leaderKey: string, ttl: number): Promisevoid { if (!this.isLeader) return; setTimeout(async () { try { const result await this.redis.set( leaderKey, this.nodeId, XX, PX, ttl ); if (result OK) { this.renewLeadership(leaderKey, ttl); } else { console.log(Leadership expired, re-electing...); await this.electLeader(); } } catch (error) { console.error(Failed to renew leadership:, error); this.isLeader false; } }, ttl * 0.6); // 在 TTL 过期前续期 } /** * 启动领导权变化监听 */ private startLeaderElection(): void { // 使用 Redis Pub/Sub 监听领导权变化 const subscriber this.redis.duplicate(); subscriber.subscribe(scheduler:leader-change); subscriber.on(message, (channel, message) { if (channel scheduler:leader-change) { console.log(Leader change detected, re-electing...); this.electLeader(); } }); // 定期检查领导权 setInterval(() { if (!this.isLeader) { this.electLeader(); } }, 5000); } /** * 运行调度循环仅领导节点执行 */ private startScheduling(): void { setInterval(() { if (!this.isLeader) return; const now new Date(); for (const [name, task] of this.tasks) { if (this.shouldRunTask(task, now)) { this.executeTaskWithLock(task); } } }, 1000); // 每秒检查一次 } /** * 判断任务是否应该执行 */ private shouldRunTask(task: ScheduledTask, now: Date): boolean { // 简化版 Cron 判断实际应使用专门的 Cron 解析库 const [minute, hour, dayOfMonth, month, dayOfWeek] task.cron.split( ); const matches ( this.matchesCronField(minute, now.getMinutes()) this.matchesCronField(hour, now.getHours()) this.matchesCronField(dayOfMonth, now.getDate()) this.matchesCronField(month, now.getMonth() 1) this.matchesCronField(dayOfWeek, now.getDay()) ); return matches; } private matchesCronField(field: string, value: number): boolean { if (field *) return true; if (field.includes(,)) { return field.split(,).map(Number).includes(value); } return Number(field) value; } private timeoutPromise(ms: number): Promisenever { return new Promise((_, reject) { setTimeout(() reject(new Error(Task timeout)), ms); }); } private registerBuiltInTasks(): void { // 注册内置的健康检查任务 this.registerTask({ name: health-check, cron: */5 * * * *, // 每 5 分钟 execute: async () { const health await this.checkSystemHealth(); console.log(System health:, health); }, timeout: 10000 }); // 注册内置的任务清理任务 this.registerTask({ name: cleanup, cron: 0 2 * * *, // 每天凌晨 2 点 execute: async () { await this.cleanupOldTasks(); }, timeout: 60000 }); } private async checkSystemHealth(): Promiseany { return { nodeId: this.nodeId, isLeader: this.isLeader, tasksRegistered: this.tasks.size, timestamp: new Date().toISOString() }; } private async cleanupOldTasks(): Promisevoid { console.log(Cleaning up old task records...); // 实现清理逻辑 } } // 定时任务接口 export interface ScheduledTask { name: string; cron: string; execute: () Promisevoid; timeout?: number; onFailure?: (error: any) Promisevoid; }四、定时任务系统的工程化实践1. 任务监控与告警实时追踪任务执行状态异常时及时通知。// 任务监控服务 export class TaskMonitor { private metrics: Mapstring, TaskMetrics new Map(); /** * 记录任务执行 */ recordExecution(taskName: string, success: boolean, durationMs: number): void { let metrics this.metrics.get(taskName); if (!metrics) { metrics { taskName, totalExecutions: 0, successfulExecutions: 0, failedExecutions: 0, totalDurationMs: 0, lastExecutionAt: new Date(), lastFailureAt: null }; this.metrics.set(taskName, metrics); } metrics.totalExecutions; metrics.totalDurationMs durationMs; metrics.lastExecutionAt new Date(); if (success) { metrics.successfulExecutions; } else { metrics.failedExecutions; metrics.lastFailureAt new Date(); } // 检查是否需要告警 this.checkAlerts(taskName, metrics); } /** * 检查告警条件 */ private checkAlerts(taskName: string, metrics: TaskMetrics): void { // 告警规则 1连续失败超过 3 次 if (metrics.failedExecutions 3) { this.sendAlert({ level: error, taskName, message: Task ${taskName} failed ${metrics.failedExecutions} times consecutively }); } // 告警规则 2执行时间超过阈值 const avgDuration metrics.totalDurationMs / metrics.totalExecutions; if (avgDuration 30000) { // 平均超过 30 秒 this.sendAlert({ level: warn, taskName, message: Task ${taskName} average execution time is ${avgDuration}ms }); } } private sendAlert(alert: { level: string; taskName: string; message: string }): void { console.log([${alert.level.toUpperCase()}] ${alert.message}); // 发送到监控服务如 Sentry、Datadog // ... } } interface TaskMetrics { taskName: string; totalExecutions: number; successfulExecutions: number; failedExecutions: number; totalDurationMs: number; lastExecutionAt: Date; lastFailureAt: Date | null; }2. 任务依赖管理支持任务间的依赖关系确保执行顺序。// 任务依赖管理器 export class TaskDependencyManager { private dependencies: Mapstring, string[] new Map(); /** * 添加任务依赖 */ addDependency(taskName: string, dependsOn: string[]): void { this.dependencies.set(taskName, dependsOn); } /** * 获取任务的执行顺序拓扑排序 */ getExecutionOrder(taskNames: string[]): string[] { const result: string[] []; const visited new Setstring(); const visiting new Setstring(); const visit (taskName: string) { if (visiting.has(taskName)) { throw new Error(Circular dependency detected at task: ${taskName}); } if (visited.has(taskName)) { return; } visiting.add(taskName); const deps this.dependencies.get(taskName) || []; for (const dep of deps) { if (taskNames.includes(dep)) { visit(dep); } } visiting.delete(taskName); visited.add(taskName); result.push(taskName); }; for (const taskName of taskNames) { visit(taskName); } return result; } }3. 灰度发布支持新任务先在小范围执行验证通过后全量上线。场景描述灰度发布听起来像是锦上添花的功能直到你上线了一个新的用户流失预警邮件任务结果配置错误——灰度 10% 变成了 100%全量用户在一小时内收到了内容错误的预警邮件客服被打爆。灰度发布的代码实现不难难的是灰度百分比配置的校验和回滚机制。我们在setGrayConfig中加入了硬上限检查setGrayConfig(taskName: string, config: GrayConfig): void { if (config.percentage ! undefined config.percentage 20) { console.warn(Task ${taskName} gray percentage 20%, requires manual approval); // 实际项目中应发送审批通知而不是静默降级 config.percentage Math.min(config.percentage, 20); } this.grayConfigs.set(taskName, config); }这个硬限制多次救了我们——团队成员提交灰度配置时不小心把 10% 写成了 100%上限检查直接拦截了下来。// 任务灰度管理器 export class TaskGrayReleaseManager { private grayConfigs: Mapstring, GrayConfig new Map(); /** * 配置灰度规则 */ setGrayConfig(taskName: string, config: GrayConfig): void { this.grayConfigs.set(taskName, config); } /** * 判断是否应该执行任务 */ shouldExecute(taskName: string, context: { userId?: string; organizationId?: string }): boolean { const config this.grayConfigs.get(taskName); if (!config) { return true; // 没有灰度配置默认执行 } if (config.enabled false) { return false; // 任务被禁用 } // 按百分比灰度 if (config.percentage ! undefined) { const hash this.hashString(context.organizationId || context.userId || ); const bucket hash % 100; return bucket config.percentage; } // 按白名单灰度 if (config.whitelist) { const id context.organizationId || context.userId; return config.whitelist.includes(id || ); } return false; } private hashString(str: string): number { let hash 0; for (let i 0; i str.length; i) { const char str.charCodeAt(i); hash ((hash 5) - hash) char; hash hash hash; } return Math.abs(hash); } } interface GrayConfig { enabled: boolean; percentage?: number; // 灰度百分比0-100 whitelist?: string[]; // 白名单 startDate?: Date; // 开始时间 endDate?: Date; // 结束时间 }五、总结Node.js 独立产品的定时任务设计需要随业务规模演进。从单机 Cron 到分布式调度系统每个阶段都有合适的技术选型。关键实践包括使用 Redis 持久化任务队列、分布式锁避免重复执行、领导选举确保调度一致性、任务监控与告警、依赖管理和灰度发布。对于资源有限的独立产品建议从 Bull Queue 等成熟方案起步避免重复造轮子。当业务规模增长后再逐步引入分布式调度能力。