NestJS 定时任务实现

ChatGPT

2 条消息 · 2026/10/9 17:32:47

我

nestjs 的定时任务 背后是如何实现的?大公司实现类似业务又是咋做的?

AI 助手

这个问题可以分成三个层次来理解:

  1. NestJS 的定时任务是怎么实现的? 比如每天凌晨执行一次清理任务。

  2. 如果程序重启、任务执行失败、服务器有多台,怎么办?

  3. 大公司是怎么实现定时业务的? 比如每天给 100 万用户发通知、定时扣款、自动关闭超时订单。

先说结论:NestJS 的定时任务,本质上是在 Node.js 进程里注册定时器;而大规模业务通常会把“什么时候执行”和“具体执行什么”分开,使用独立的调度系统、消息队列和 Worker。

一、NestJS 的定时任务底层是怎么实现的?

NestJS 常用的定时任务模块是 @nestjs/schedule。

例如:

TypeScript

import { Injectable } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';

@Injectable()
export class TaskService {
  @Cron('0 * * * * *')
  handleTask() {
    console.log('每分钟的第 0 秒执行');
  }
}

这里的 Cron 表达式表示每分钟执行一次。

NestJS 应用启动

初始化 ScheduleModule

扫描并注册定时任务

读取 @Cron、@Interval、@Timeout 等装饰器的元数据

Node.js 进程内的定时调度器

等待时间到达,触发回调函数

执行你的业务代码

查询数据库、清理数据、发送通知等

1. 它底层用的是什么?

@nestjs/schedule 主要依赖两个东西:

  • cron:处理 Cron 表达式和定时调度。

  • Node.js 的定时器机制:底层由事件循环及相应的定时器设施支持。

NestJS 自己主要负责把这些能力集成进依赖注入系统,并自动发现、注册任务。它并没有自带一套分布式任务调度集群。

你可以把它理解成:

TypeScript

// 概念性伪代码,不是 NestJS 的实际源码
const task = new CronJob('0 * * * * *', async () => {
  await doSomething();
});

task.start();

2. @Cron、@Interval、@Timeout 有什么区别?

API作用例子
@Cron()按日历时间执行每天凌晨 2 点
@Interval()按时间间隔重复执行每隔 10 秒
@Timeout()延迟一段时间执行一次启动 5 秒后执行

需要注意:这些任务都是跟随当前应用进程运行的。

例如:

  • 应用重启时,原来的内存定时器会消失,应用重新启动后再注册。

  • 应用停机期间错过的任务,默认不会自动补跑。

  • 任务执行时间过长时,可能出现执行重叠,具体取决于任务实现和调度方式。

  • Node.js 主线程被同步计算阻塞时,定时回调也可能延迟。

这就是为什么它很适合简单的后台定时任务,却不能直接等同于一套可靠的企业级调度平台。

官方文档也明确说明,ScheduleModule.forRoot() 会在应用启动生命周期中注册声明式任务;@Timeout() 则使用 setTimeout()。因此,它本质上是应用内部的调度机制,而不是独立的任务服务器。

NestJS - A progressive Node.js framework

+1

二、为什么多台服务器会出问题?

假设你部署了 3 个 NestJS 实例:

实例 A

触发任务

实例 B

触发任务

实例 C

触发任务

三个实例都注册了相同的 @Cron,每分钟执行一次,就可能同时处理同一份业务。

例如你写了一个每天凌晨执行的任务:

TypeScript

@Cron('0 0 2 * * *')
async closeExpiredOrders() {
  await this.orderService.closeExpiredOrders();
}

如果部署了 3 个实例,这个方法可能同时被调用 3 次。

如果只是统计数据,重复计算可能还只是浪费资源;但如果是发邮件、扣款、发放优惠券,就可能产生严重问题。

注意,不是所有业务都会必然重复产生结果:如果数据库更新本身有正确的条件限制、唯一约束或幂等设计,重复调用也可以是安全的。但 NestJS 本身不会自动帮你协调多个实例。

还有几个容易被忽略的问题:

  • 进程崩溃: 任务运行到一半,服务器突然重启。

  • 执行失败: 某个外部 API 暂时不可用。

  • 错过执行: 服务器停机期间,到了计划执行时间。

  • 任务积压: 每分钟产生 1 万个任务,但 Worker 每分钟只能处理 5 千个。

大公司解决这些问题的核心,不是单纯换一个更强的定时器,而是把任务调度、执行、状态记录和故障恢复分别处理。

三、大公司一般怎么做?

没有唯一的架构,不同业务会选择不同方案。但常见的有以下几类。

方案 A:独立调度器 + 消息队列 + Worker

这是理解企业级定时任务最值得掌握的架构。

调度器 Scheduler

判断哪些任务到了执行时间

任务队列 Queue

Redis / RabbitMQ / Kafka 等,按需求选择

Worker 1

Worker 2

Worker 3

Worker 从队列领取任务,执行成功后确认;失败时按策略重试。

这三个角色分别负责:

  • Scheduler: 负责判断“现在该做什么”。

  • Queue: 负责暂存待执行的任务,缓冲突发流量。

  • Worker: 负责真正执行任务,可以独立扩容。

比如每天凌晨要给 100 万用户发送通知,调度器不应该自己同步发送 100 万封邮件,而是生成待处理任务,放进队列,再由一组 Worker 持续消费。

假设有 100 个 Worker,每个平均每秒处理 20 个通知,理论处理能力就是每秒 2,000 个。忽略启动开销、失败重试及外部服务限制,100 万个通知大约需要 500 秒。

这就是队列的重要价值:调度器只负责安排工作,真正的工作量由可扩缩的消费者承担。

不过,队列也不是万能的。消息可能被重复投递,消费者也可能在执行完成、确认消息之前崩溃,所以重要业务还需要幂等、重试和持久化状态。

方案 B:分布式任务调度平台

对于更复杂的业务,企业会使用专门的调度平台。

技术主要用途
Kubernetes CronJob在 Kubernetes 集群中定时启动 Job
Quartz / XXL-JOB 等管理周期任务、任务分片、执行记录等,具体能力依实现而定
Temporal编排需要持久化状态、定时等待、失败恢复和重试的长流程
云厂商的定时任务服务减少自行维护调度基础设施的工作

例如 Kubernetes CronJob 会创建 Job 来执行任务,并提供并发策略等控制能力,但它不等于严格的“每次必定执行且只执行一次”的业务保证。

NestJS - A progressive Node.js framework

+1

Temporal 则更进一步:它不仅管理“什么时候开始”,还能持久化工作流的执行历史,在进程故障后恢复流程。

Temporal Documentation

+2

这里有个重要区别:

  • 每天凌晨统计一次报表,可能只需要 CronJob。

  • 定时提醒用户,可能使用任务队列。

  • 一个订单需要等待 30 分钟、调用支付服务、处理失败、等待回调,可能更适合工作流引擎。

不是任务越多就越应该使用 Temporal,而是业务流程越长、状态越复杂、对故障恢复要求越高,工作流引擎的价值越大。

四、如果你用 NestJS,应该怎么选?

结合你平时使用 NestJS、Prisma、PostgreSQL 的开发场景,我建议从下面这条路线入手,不必一开始就引入复杂的微服务架构。

第一阶段:直接使用 @nestjs/schedule

简单定时任务

适合清理临时文件、定期统计、刷新缓存、执行轻量维护任务。

如果任务重复执行不会造成问题,而且漏跑一次也可以接受,直接用它就好。

第二阶段:引入 BullMQ + Redis

异步任务处理

适合邮件发送、音视频转码、批量通知、延迟任务、失败重试。

NestJS 官方提供了队列集成方案。BullMQ 可以将任务存储在 Redis 中,由独立 Worker 消费,还支持延迟任务、重试和并发控制。

BullMQ

+2

第三阶段:需要时引入工作流引擎

适合跨服务、持续很长时间、需要等待回调或恢复中断进度的业务。

例如订单支付、退款、审批、复杂的自动化工作流,可以评估 Temporal。不要仅仅为了执行几个定时任务就引入它。

五、真正需要掌握的三个设计原则

这三个原则比记住某个定时任务库的 API 更重要。

  1. 幂等性:同一个任务执行两次,最终结果也不会出错。

例如关闭订单时,不要简单地无条件更新订单状态:

TypeScript

await prisma.order.updateMany({
  where: {
    id: orderId,
    status: 'PENDING',
    expiresAt: { lte: new Date() },
  },
  data: {
    status: 'CLOSED',
  },
});

通过条件更新,只有仍然处于待支付状态且已经过期的订单才会被关闭。对于扣款等复杂操作,还需要支付方幂等键、唯一约束或其他业务保护,不能只靠这一条 SQL。

  1. 任务状态持久化:不能只依赖内存。

如果一个任务必须执行,就应该能够追踪它是否创建、是否开始、是否成功、失败原因是什么,以及什么时候重试。

  1. 失败重试不等于恰好执行一次。

即使使用了消息队列,Worker 也可能在业务处理完成后、确认任务之前崩溃,导致同一个任务再次执行。BullMQ 官方同样建议把任务设计成幂等操作。

BullMQ

+1

六、用一个实际业务串起来

假设你要实现一个电商系统:订单 30 分钟未支付就自动关闭。

一个比较实用的方案是:

用户创建订单

PostgreSQL 保存订单及过期时间

BullMQ 延迟任务

设置 30 分钟延迟,任务保存在 Redis

Worker 执行关闭逻辑

再次检查订单状态和过期时间

仍未支付

条件更新为已关闭

已经支付

不做任何处理

但这里还要考虑一个细节:创建订单成功后,如果 Redis 暂时不可用,延迟任务可能根本没有成功入队。严肃的业务可以采用数据库任务表、事务发件箱(Transactional Outbox)或定期扫描过期订单进行补偿,确保任务最终得到处理。

最后总结:

  • @nestjs/schedule 解决的是进程内的定时触发。

  • BullMQ 解决的是更可靠、可扩展的后台任务处理。

  • 分布式调度平台解决的是大规模任务的集中管理与分发。

  • Temporal 解决的是长流程的持久化执行和故障恢复。

  • 数据库事务、幂等性和补偿机制,负责保护真正的业务正确性。

如果是你自己维护的中小型 NestJS 项目,先掌握 @nestjs/schedule、BullMQ、PostgreSQL 条件更新和幂等性,就已经能够覆盖相当多的实际业务场景。