PAYATHON 2026

Node.js 支付后生成 PDF 发票:独立线程还是 RabbitMQ 队列?

支付小周

结论

建议使用 RabbitMQ、BullMQ 等持久化队列,并由独立 worker process 生成 PDF。

支付验证接口只需确认支付结果、以幂等方式更新订单状态并投递发票任务,不必等待 PDF 生成。这样既能缩短接口响应时间,也便于实现重试、限流、故障恢复和横向扩容。

不建议直接把任务交给应用进程内的 background service。虽然实现简单,但在进程重启、容器滚动发布或服务崩溃时,未完成的任务可能丢失。

三种方案如何选择

1. 在支付验证 API 中直接生成

这种方案只适合流量很低、PDF 生成速度很快,而且业务允许接口等待的简单系统。

它有几个明显的问题:

  • PDF 渲染可能占用较多 CPU 和内存。
  • 模板资源、字体或图片加载失败会拖慢支付接口。
  • 请求超时后,很难确认发票是否已经生成。
  • 支付量短时间内增加时,多个 PDF 任务可能阻塞 Node.js 进程。
  • 支付平台的 webhook 通常需要尽快返回成功。耗时过长可能导致平台重复发送通知。

即使当前业务量不大,也最好把支付状态确认和发票生成拆成两个操作,不要同步绑定。

2. 持久化队列加独立 worker

生产环境通常更适合这种方案。

它把支付处理和发票生成分开:

支付回调
  → 验证签名和金额
  → 幂等更新订单
  → 投递 invoice.generate 任务
  → 立即响应

独立 worker
  → 获取任务
  → 生成 PDF
  → 存储文件
  → 更新发票状态

这样做有以下好处:

  • API 不必等待 PDF 生成。
  • worker 异常退出后,未完成的任务可以重试。
  • 可以限制并发,避免 PDF 渲染耗尽 CPU 或内存。
  • 支付量增长时,可以单独增加 worker 数量。
  • 队列积压、失败次数和处理耗时更容易监控。
  • PDF 生成代码与支付接口相互隔离,单张发票生成失败不会直接影响支付确认。

RabbitMQ 和 BullMQ 都能处理这类任务。BullMQ 基于 Redis,接入现有 Node.js 项目通常更方便;RabbitMQ 的消息路由、确认机制和跨服务集成更灵活。具体使用哪一个,主要取决于现有基础设施,而不是发票业务本身。

3. 应用进程内的 background service

如果 background service 只是执行下面的代码:

generateInvoice(order).catch(console.error);

它算不上可靠的后台任务系统。API 虽然可以提前返回,但任务只保存在当前进程的内存中,因此存在以下风险:

  • 进程退出后任务会丢失。
  • 发布新版本时任务可能中断。
  • 多实例部署时难以统一控制并发。
  • 没有可靠的重试和死信处理机制。
  • 无法准确查询任务是否已经完成。

如果 background service 已经具备持久化任务表、任务锁、重试策略和独立进程,那么它实际上就是一套自建队列。没有明确需求时,通常不值得重复实现这些机制。

推荐实现步骤

1. 为支付回调做幂等处理

支付平台可能重复发送通知。应根据支付流水号、订单号或事件 ID 建立唯一约束,确保同一笔支付不会重复改变订单状态。

发票任务也需要一个稳定的唯一标识,例如:

invoice:{orderId}

不能假设支付平台只会通知一次。

2. 记录发票状态

可以在数据库中维护以下状态:

pending → processing → completed
                    ↘ failed

建议至少记录:

  • order_id
  • invoice_number
  • status
  • file_key 或文件路径
  • attempts
  • last_error
  • created_at
  • completed_at

前端查询发票时,如果状态还是 pending 或 processing,可以显示”发票生成中”,不要返回一个尚不存在的文件。

3. 支付成功后投递任务

下面以 BullMQ 为例。示例省略了支付签名验证和具体的 ORM 代码,这些部分需要根据实际使用的支付平台和数据库实现。

// invoice-queue.js
import { Queue } from 'bullmq';

export const connection = {
  host: process.env.REDIS_HOST,
  port: Number(process.env.REDIS_PORT)
};

export const invoiceQueue = new Queue('invoice', {
  connection
});
// payment-route.js
import express from 'express';
import { invoiceQueue } from './invoice-queue.js';
import { verifyPayment, markOrderPaid } from './payment-service.js';

const router = express.Router();

router.post('/payments/webhook', async (req, res, next) => {
  try {
    const payment = await verifyPayment(req);

    // markOrderPaid 必须是幂等操作。
    const order = await markOrderPaid({
      orderId: payment.orderId,
      transactionId: payment.transactionId,
      amount: payment.amount
    });

    await invoiceQueue.add(
      'generate',
      { orderId: order.id },
      {
        jobId: `invoice-${order.id}`,
        attempts: 5,
        backoff: {
          type: 'exponential',
          delay: 5000
        },
        removeOnComplete: 1000,
        removeOnFail: false
      }
    );

    res.sendStatus(200);
  } catch (error) {
    next(error);
  }
});

export default router;

jobId 可以减少部分重复投递,但不能代替数据库幂等。任务历史被清理、任务并发执行或人工重放时,worker 仍可能再次收到同一订单。

4. 使用独立进程处理 PDF

// invoice-worker.js
import { Worker } from 'bullmq';
import { connection } from './invoice-queue.js';
import {
  getOrderForInvoice,
  claimInvoice,
  completeInvoice,
  failInvoice
} from './invoice-repository.js';
import { renderInvoicePdf } from './pdf-service.js';
import { saveInvoice } from './storage-service.js';

const worker = new Worker(
  'invoice',
  async job => {
    const { orderId } = job.data;

    // claimInvoice 应通过数据库事务或条件更新避免并发生成。
    const claimed = await claimInvoice(orderId);

    if (!claimed) {
      return { skipped: true };
    }

    try {
      const order = await getOrderForInvoice(orderId);
      const pdfBuffer = await renderInvoicePdf(order);

      const fileKey = `invoices/${order.userId}/${order.id}.pdf`;
      await saveInvoice(fileKey, pdfBuffer);
      await completeInvoice(orderId, fileKey);

      return { fileKey };
    } catch (error) {
      await failInvoice(orderId, error.message);
      throw error; // 必须抛出,BullMQ 才会按配置重试
    }
  },
  {
    connection,
    concurrency: 2
  }
);

worker.on('failed', (job, error) => {
  console.error('Invoice generation failed', {
    jobId: job?.id,
    error: error.message
  });
});

concurrency 没有一个适用于所有系统的最佳值。使用 Chromium、Puppeteer 等方式渲染时,单个任务可能占用较多内存,因此要根据 CPU、内存、模板复杂度和压测结果来设置。

避免订单已支付,但任务没有入队

如果程序先提交数据库事务,再调用队列,进程恰好在这两个操作之间崩溃,就会出现订单已经支付,但发票任务没有投递的情况:

数据库提交成功 → 进程崩溃 → 队列投递未执行

可靠性要求较高的系统可以使用 Transactional Outbox Pattern:

  1. 在同一个数据库事务中更新订单,并插入一条 outbox 事件。
  2. 由独立发布器读取尚未发送的 outbox 事件。
  3. 将事件投递到 RabbitMQ 或 BullMQ。
  4. 投递成功后,将 outbox 事件标记为已发送。

伪代码如下:

await db.transaction(async tx => {
  await tx.orders.markPaid(orderId, transactionId);

  await tx.outbox.insert({
    type: 'invoice.generate',
    aggregateId: orderId,
    payload: JSON.stringify({ orderId })
  });
});

规模较小的系统也可以先用定时任务扫描已支付但没有发票的订单,作为补偿机制。扫描产生的任务仍然必须能够安全地重复执行。

关键注意事项

Worker 不等于 worker_threads

这里的独立 worker 通常是独立的 Node.js 进程或容器,不一定使用 worker_threads。

如果 PDF 库包含大量同步计算,放在独立进程中可以避免阻塞 API 的事件循环。worker_threads 更适合处理进程内的 CPU 密集型计算,但它不提供任务持久化、重试或故障恢复,无法替代消息队列。

任务必须可以重复执行

队列系统通常只能保证任务至少被处理一次。任务可能因超时、连接中断或 worker 崩溃而再次执行,因此生成逻辑需要满足以下要求:

  • 同一订单只能对应一个发票编号。
  • 重试不能重复开票,也不能产生多条数据库记录。
  • 文件名或存储键应保持稳定。
  • 覆盖旧文件时需要采用安全写入方式。
  • 已完成的任务再次到达时,可以直接返回。

不要盲目使用服务器本地磁盘

如果系统只有一台固定服务器,本地磁盘可以满足需求。但在多实例、容器或自动扩缩容环境中,用户下一次请求未必会落到保存文件的那台机器上,文件也可能在容器重建后消失。

这类部署更适合使用对象存储或共享存储。数据库只保存 file_key,下载时再生成短期授权地址,或者通过有权限控制的接口返回文件。

发票内容应使用支付时的快照

生成发票时,不要直接读取可能已经被修改的商品名称、价格、税率或用户地址。支付成功时应保存必要的订单快照,worker 再根据这份快照生成发票,避免商品信息后续发生变化,导致金额或税务信息不一致。

做好访问控制和监控

发票通常包含姓名、地址、税号等敏感信息。下载接口必须验证当前用户是否有权访问对应订单,不能只依赖可猜测的文件路径。

需要监控的指标包括:

  • 队列积压数量
  • 任务等待时间
  • 生成成功率
  • 重试次数
  • 最终失败任务
  • worker CPU 和内存
  • 存储写入失败

最终失败的任务应进入失败队列或保留失败记录,同时提供人工重试入口。

支付确认需要快速、稳定地完成,属于核心处理路径。PDF 发票则可以异步生成,也允许短暂延迟。通过持久化队列把两者拆开,再补上幂等、重试和补偿机制,系统会更可靠,也更容易扩展。

备注:内容仅供参考。