PAYATHON 2026

setTimeout 会影响 Node.js 应用性能或导致内存泄漏吗?

支付阿杰

结论

单个或少量持续 15 分钟的 setTimeout,通常不会明显影响 Node.js 的性能,也不一定会导致内存泄漏。

但在生产环境中,它不适合用来执行需要可靠完成的订单超时取消任务。问题不在于 15 分钟太长,而在于:

  • 定时器触发前会占用少量内存,并保留回调所引用的对象。
  • 进程重启、崩溃或重新发布后,尚未触发的定时器会全部丢失。
  • 在多实例部署中,很难控制定时任务由哪个实例执行,也不容易避免重复执行。
  • 并发预订量较大时,进程中会同时存在大量长期存活的定时器。
  • setTimeout(..., 900000) 只能保证回调不会早于 15 分钟执行,不能保证它恰好在第 15 分钟执行。

因此,setTimeout 适合低风险、允许丢失的短期任务。票务预订释放库存属于业务操作,应该持久化过期时间,再由后台任务或支持延迟任务的消息队列处理。

为什么它通常不会直接造成内存泄漏

Node.js 不会为每个定时器创建一个等待线程。调用 setTimeout 后,Node.js 会登记这个定时器,等时间条件满足后,再由事件循环执行回调。

只要回调最终执行,或者通过 clearTimeout() 取消,而且没有其他引用继续持有相关对象,定时器及其闭包占用的内存通常都能被垃圾回收。

所以下面的代码本身并不等于内存泄漏:

setTimeout(() => {
  cancelBooking(bookingId);
}, 900000);

不过,在定时器执行或取消之前,回调及其引用的数据都会继续存活。例如:

route.post('/makeBooking', (req, res) => {
  const largeObject = req.body;

  setTimeout(() => {
    console.log(largeObject);
  }, 900000);
});

这里的回调引用了 largeObject。即使请求已经结束,这个对象仍可能在内存中保留 15 分钟。并发量较高时,这种正常但持续时间较长的内存占用,很容易看起来像泄漏。

更稳妥的做法是让回调只保留必要的标识:

route.post('/makeBooking', async (req, res, next) => {
  try {
    const booking = await createBooking(req.body);
    const bookingId = booking.id;

    setTimeout(() => {
      cancelBookingIfUnpaid(bookingId).catch(console.error);
    }, 900000);

    res.status(201).json({ bookingId });
  } catch (error) {
    next(error);
  }
});

这样,回调只需要保存 bookingId,不用一直持有整个 req、res、用户对象或预订详情。

真正需要考虑的问题

进程重启会丢失任务

setTimeout 只存在于当前 Node.js 进程的内存里。遇到以下任何一种情况,尚未执行的定时器都会消失:

  • 应用重启或崩溃
  • 发布新版本
  • 容器被替换
  • 实例被自动扩缩容系统回收
  • 服务器断电或被强制终止

如果订单状态完全依赖这个回调更新,定时器丢失后,预订可能会一直占用库存,却始终没有被自动取消。

大量定时器会增加资源开销

单个定时器的开销通常不大,但并发预订量升高后,需要同时保留的定时器可能非常多。除了定时器自身的数据结构,还要计算闭包捕获的数据、日志上下文和业务对象占用的资源。

实际影响取决于:

  • 同时处于待付款状态的订单数量
  • 回调闭包保留的数据量
  • 回调执行时产生的数据库访问量
  • 是否存在重试、异常或重复定时器
  • Node.js 版本和具体运行环境

所以,不能只根据 15 分钟这个时长判断是否存在性能问题,还要结合并发量和内存监控数据。

回调可能延迟执行

setTimeout(callback, 900000) 的含义是,回调最早在大约 900000 毫秒后进入可执行状态。如果当时事件循环正在执行同步代码,或者需要处理大量任务,回调还会继续延迟。

因此,不能把业务判断简单写成”定时器触发时,订单一定已经超时”。是否过期应以数据库中的过期时间为准:

if (booking.status === 'pending' && booking.expiresAt <= new Date()) {
  await cancelBooking(booking.id);
}

支付和取消可能发生竞争

付款回调可能正好与取消任务同时执行:

  1. 后台任务读取订单,发现状态仍是 pending。
  2. 支付系统确认付款。
  3. 后台任务继续执行并取消订单。

因此,取消操作应该使用条件更新,不能先查询状态,再无条件修改:

const result = await bookings.updateOne(
  {
    id: bookingId,
    status: 'pending',
    expiresAt: { $lte: new Date() }
  },
  {
    $set: {
      status: 'cancelled',
      cancelledAt: new Date()
    }
  }
);

具体的查询语法取决于数据库,但原则不变:只有订单仍未付款且确实已经过期时,才能将它取消。支付确认也应该使用相应的条件更新或事务,避免支付和取消相互覆盖。

推荐实现:保存过期时间并由后台任务扫描

创建预订时,将状态和过期时间写入数据库:

route.post('/makeBooking', async (req, res, next) => {
  try {
    const now = new Date();
    const expiresAt = new Date(now.getTime() + 15 * 60 * 1000);

    const booking = await createBooking({
      ...req.body,
      status: 'pending',
      expiresAt
    });

    res.status(201).json({
      bookingId: booking.id,
      expiresAt
    });
  } catch (error) {
    next(error);
  }
});

后台任务定期查找已经过期的待付款预订:

async function cancelExpiredBookings() {
  const now = new Date();

  const expiredBookings = await findBookings({
    status: 'pending',
    expiresAt: { $lte: now }
  });

  for (const booking of expiredBookings) {
    await cancelBookingIfStillPending(booking.id, now);
  }
}

setInterval(() => {
  cancelExpiredBookings().catch((error) => {
    console.error('Failed to cancel expired bookings:', error);
  });
}, 60_000);

cancelBookingIfStillPending() 应通过事务或原子条件更新再次检查状态,不能直接相信前一次查询的结果。

即使进程重启,这种方案也不会丢失数据库中的过期时间。后台任务恢复后,可以继续处理之前遗漏的订单。扫描间隔设为 1 分钟时,实际取消时间可能比 15 分钟稍晚。如果业务要求更高的时间精度,可以缩短扫描间隔,或者使用延迟任务队列。

数据量较大时,还应该为查询条件建立合适的索引,例如组合索引:

(status, expiresAt)

具体的索引顺序要根据数据库类型和实际查询计划确定。

更大规模时使用延迟任务队列

如果系统已经接入消息队列或任务队列,可以在创建预订时提交一个延迟 15 分钟执行的任务:

await bookingQueue.add(
  'expire-booking',
  { bookingId: booking.id },
  {
    delay: 15 * 60 * 1000,
    jobId: `expire-booking:${booking.id}`
  }
);

消费者收到任务后,仍然要查询数据库并检查订单状态:

async function processExpirationJob(job) {
  const { bookingId } = job.data;
  const booking = await getBooking(bookingId);

  if (!booking) {
    return;
  }

  if (
    booking.status === 'pending' &&
    booking.expiresAt <= new Date()
  ) {
    await cancelBookingIfStillPending(bookingId, new Date());
  }
}

队列支持重试,并不代表任务只会执行一次。可靠的队列通常允许重复投递,所以取消逻辑必须具备幂等性。无论同一订单的过期任务执行一次还是多次,最终结果都应该相同。

数据库中的 expiresAt 仍然是判断订单是否过期的依据。队列只负责提醒系统开始处理,数据库负责确认订单是否真的已经过期。

如果暂时继续使用 setTimeout

如果系统流量较小,允许偶尔遗漏,而且另有补偿机制,可以暂时保留定时器,但至少要做到以下几点:

route.post('/makeBooking', async (req, res, next) => {
  try {
    const booking = await createBooking({
      ...req.body,
      status: 'pending',
      expiresAt: new Date(Date.now() + 15 * 60 * 1000)
    });

    const bookingId = booking.id;

    setTimeout(async () => {
      try {
        await cancelBookingIfStillPending(bookingId, new Date());
      } catch (error) {
        console.error(
          `Failed to check expired booking ${bookingId}:`,
          error
        );
      }
    }, 15 * 60 * 1000);

    res.status(201).json({
      bookingId,
      expiresAt: booking.expiresAt
    });
  } catch (error) {
    next(error);
  }
});

使用时要注意:

  • 不要在闭包中保存整个 req、res 或大型业务对象。
  • 必须处理回调中的异步异常,否则取消失败后可能没人发现。
  • 数据库中仍然要保存 expiresAt,方便进程重启后进行补偿处理。
  • 支付完成后可以调用 clearTimeout(),减少无效任务,但不能将它作为唯一的状态控制方式。
  • 不要只依赖进程内的 timer ID,因为它无法跨进程或跨重启使用。
  • 不要为了让进程退出而随意调用 timeout.unref()。这样做可能导致进程在回调执行前退出,因此更不适合关键业务任务。

总的来说,setTimeout 不会仅仅因为等待 15 分钟就造成内存泄漏,但它只能在当前进程内延迟执行任务,无法提供可靠的业务调度。票务库存和付款状态需要持久化、可恢复且幂等的处理方式,更适合使用数据库过期时间配合后台任务或延迟队列。

备注:内容仅供参考。