用 Node 的定时器做延迟队列,我遇到了内存泄漏

我在一个订单自动取消的服务里用 setTimeout 做延迟队列,Node 16 环境,单机峰值 30 万延迟任务。现象是运行两天后 RSS 从 300MB 涨到 1.2GB,heap snapshot 里全是 Timeout 对象和闭包。

我的做法是把每个订单的取消任务存进一个 Map,key 是订单号,value 是 { timeout, expireAt }。创建时 setTimeout 回调里从 Map 删除并执行取消逻辑。

const pending = new Map();
function schedule(orderId, delayMs) {
  const timeout = setTimeout(() => {
    pending.delete(orderId);
    cancelOrder(orderId);
  }, delayMs);
  pending.set(orderId, { timeout, expireAt: Date.now() + delayMs });
}
function unschedule(orderId) {
  const item = pending.get(orderId);
  if (item) {
    clearTimeout(item.timeout);
    pending.delete(orderId);
  }
}

问题出在订单支付成功后调用 unschedule,但回调里 pending.delete 和 unschedule 的 clearTimeout 有竞争:如果支付回调先执行(事件循环里比定时器早),定时器已经被 clearTimeout 了,但 pending 里还留着 item,因为 unschedule 里 pending.delete 是在 clearTimeout 之后执行的,而 clearTimeout 本身不会触发回调,所以 pending.delete 执行了才对,我检查后发现是另一个地方:批量取消时直接 pending.clear(),但没有逐个 clearTimeout,导致定时器还在,回调执行时 pending 里已经没有这个 key,但回调闭包引用了 orderId 和 cancelOrder,这些对象被定时器持有直到超时。

修正方案是封装一个 removeAll 方法,遍历 Map 逐个 clearTimeout 再 clear。另外把回调改成不直接引用大对象,只传 orderId,在回调里重新查库。

function removeAll() {
  for (const [id, item] of pending) {
    clearTimeout(item.timeout);
  }
  pending.clear();
}

为什么这样做:setTimeout 返回的 Timeout 对象在 Node 里是 C++ 层和 JS 层都有引用的,clearTimeout 只是标记取消并释放 JS 引用,但如果不调用,定时器会一直活到超时。Map 里存 Timeout 对象是为了能取消,但批量操作时容易漏掉。代价是每个延迟任务多一个对象引用,30 万任务大约多占 50MB,但比泄漏好。

哪里会失效:如果延迟时间超过 2^31-1 毫秒(约 24.8 天),setTimeout 会立即执行,需要拆成轮询或换用其他方案。单进程内存有限,30 万任务接近上限,再大要考虑分片或持久化队列。另外 clearTimeout 后 Timeout 对象不会被 GC 立即回收,需要等事件循环下一轮,所以监控内存要隔一段时间看。

你们在生产里做延迟任务时,是继续用 setTimeout 加 Map,还是换成了 Redis 或消息队列的延迟消息?

0
赞同度 100% · 0 条评论 · 7 阅读

0 条评论

支持 Markdown · 回复会实时推送给对方

还没有评论,写下第一条。