为何 NestJS 的 @Cron 任务在每个副本上仅运行一次以及如何解决该问题
了解为何在 NestJS 服务扩展时,进程内的 @Cron 计划会成倍增加,以及外部触发器、幂等写操作和受保护的接口如何解决这一问题。
原本应每个实体仅创建一条记录的夜间任务现在开始产生重复数据:对于相同的实体和起始日期,会出现几行完全一致的记录,这些记录的生成时间仅相差数秒,且都没有损坏。该任务的代码并未有任何改动。变化在于该服务现在在多个副本上运行,而调度器位于每个副本中。本文将解释为何 NestJS 的 @Cron 装饰器会呈现这种行为,比较三种解决方法,并详细介绍所选方案的设计思路,包括相关的安全性和时区设置。
重复数据产生的原因
假设有一项任务,负责将每个活跃实体推进到下一个时间窗口,每天为每个实体创建一条新记录。NestJS 的标准实现方式是使用 @nestjs/schedule 装饰器:
@Injectable()
export class WindowGenerationService {
@Cron('0 8 * * *') // every day at 08:00
async generateNextWindows() {
const entities = await this.repo.findActiveEndingSoon();
for (const entity of entities) {
await this.repo.createNextWindow(entity);
}
}
}
这正是文档中所建议的做法,当服务以单个实例运行时,它能完美正常工作。
问题出现在水平扩展之后。当有三个实例时,会有三个进程各自加载该模块,而@Cron会在每个进程中注册自己的定时器。由于没有任何协调机制,08:00时这三个定时器会同时触发。因为该任务没有进行幂等性检查,也没有加锁,所以每次执行时都会尝试查找已存在的窗口,找不到后便自行创建新的副本。
由此会产生两个问题。显而易见的是数据重复。另一个不太明显的问题则是资源浪费:每个副本都在同一时间执行相同的工作,之后还需要额外精力来清理产生的结果。在可扩展服务中,进程内的调度器不仅会导致正确性错误,而且按设计会随着副本数量的增加而消耗更多计算资源。代码中没有任何相关提示,正因如此这类问题往往会在测试数据中出现,而不会在审查阶段被发现。
三种解决方法
方案1:使用分布式锁
保留 @Cron,但让各个实例通过竞争锁来决定谁有权限执行任务,比如使用数据库的咨询锁或缓存中的键,只有胜出的实例才能运行。这种方法确实有效,但却将可见的故障变成了隐形的故障。重复的记录至少还能被发现并删除,而跳过的执行则无法察觉:如果在08:00时锁的后端不可用,或者某个实例在锁的有效期结束前因崩溃而无法释放锁,那么该任务就根本不会执行,直到数日后因为缺失了某个处理环节而出现问题时才有人察觉。此外,这种方式还增加了新的依赖关系以及另一种隐性的故障模式,用以弥补将定时器放在错误层级所带来的问题。
选项2:仅依靠幂等性
让任务能够安全地多次执行,这样额外的执行就不会产生任何影响。这种方法成本低且正确,但仍然需要三个容器每晚都启动来执行重复的工作。
选项3:将调度功能从应用程序中分离出来
应用程序不应决定任务在何时执行。让外部调度器掌控时间,并向普通端点发送一个HTTP请求;应用程序仅在收到该请求时决定要执行什么操作。一个触发条件对应一次任务执行,增加副本也不会使执行次数成倍增长。常见的触发方式包括Kubernetes的CronJob、云服务提供商的调度服务或CI流水线的定时任务。
所选设计将选项3与选项2中的幂等性机制结合,作为安全保障。
新设计
@Cron装饰器已被移除,任务逻辑被置于端点之后:
@Post('jobs/run')
async runJob(@Body() body: RunJobDto) {
this.assertValidSecret(body.secret);
return this.jobs.run(body.jobKey);
}
外部调度器会在预定时间调用此函数一次。负载均衡器会将请求路由到某个实例来执行任务,其他副本则不会参与其中。通过传递jobKey,单个端点即可处理多个任务。
一个实际改进点:运行数分钟的任务可能会超过调度器的HTTP超时时间。对于长时间运行的任务,建议尽快确认收到请求并在后台执行任务,同时仍要防止任务重叠。
将幂等性视为安全保障
仍需进行幂等性检查,因为“恰好执行一次”是基础设施通常会遵守但偶尔也会违背的承诺:调度器可能在超时后重试,有人可能会手动触发任务,或者某个实例在任务执行过程中重启。
async createNextWindow(entity: Entity) {
const existing = await this.repo.findByEntityIdAndStartDate(
entity.id,
entity.nextStartDate,
);
if (existing) return; // already done, no-op
await this.repo.create(/* ... */);
}
在创建窗口之前,该方法会先查找具有相同实体ID和开始日期的窗口,如果存在则立即返回。需要注意的是,若两次操作完全重叠,这种“先检查再插入”的方式本身也存在竞态条件。可靠的解决方案是在数据库中对实体ID和开始日期设置唯一约束,这样并发产生的重复数据会在插入时被拒绝,而不会被写入。如需深入了解如何设计可重试的写操作,请参阅我们关于Node.js POST端点中幂等性键的指南。
此变更带来的成本
任务端点属于公共端点
将私有的夜间任务转换为HTTP路由后,就会形成一个任何人都能发现并反复点击的按钮。因此该路由需要一个共享密钥,而且如何比较这个密钥也很重要:
private assertValidSecret(provided: string) {
const expected = this.config.cronSecret;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
throw new UnauthorizedException();
}
}
使用 provided === expected 进行比较可能会通过时间差异泄露信息:字符串比较可能在遇到第一个不匹配的字符时就停止,因此失败所需的时间能够暗示猜测的正确程度,从而使攻击者能够逐部分恢复密钥。Node 的 crypto 模块中的 timingSafeEqual 函数可实现常数时间比较。该函数要求两个缓冲区的长度相同,因此首先会检查长度;在比较之前对两个值进行哈希处理,甚至可以避免暴露长度信息。审查人员很少会指出这一点,而寄希望于该漏洞永远不被发现并非可行的策略。
还有两个加强安全性的措施值得考虑。将密钥放在请求头而非请求体中,可以避免其被内容日志记录中间件捕获。同时在RunJobDto中验证secret为非空字符串,可防止因缺少输入而导致Buffer.from抛出错误。
选择执行时间属于产品决策范畴
第二项成本很容易被低估:选择执行时间。该任务必须对每位用户都在一天开始之后运行,而用户分布在多个美国时区,因此东海岸的上午时段在西海岸仍属于黎明之前。所以调度时间被固定为以企业业务覆盖的最西端时区为基准的UTC时间,因为这是所有地区都适用的安全时间。Crontab使用的是UTC时间,而需求指定的是本地时间,两者之间的转换正是决策的关键所在。请将这一推理写在调度安排旁边,因为仅从Cron表达式本身是无法看出的。
关键要点
@Cron会在加载该模块的每个进程中运行,因此随着副本数量的增加,其执行次数也会相应增多,且代码中不会给出任何提示。
timingSafeEqual进行比对、输入验证以及合理的日志记录。