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_idinvoice_numberstatusfile_key或文件路径attemptslast_errorcreated_atcompleted_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:
- 在同一个数据库事务中更新订单,并插入一条 outbox 事件。
- 由独立发布器读取尚未发送的 outbox 事件。
- 将事件投递到 RabbitMQ 或 BullMQ。
- 投递成功后,将 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 发票则可以异步生成,也允许短暂延迟。通过持久化队列把两者拆开,再补上幂等、重试和补偿机制,系统会更可靠,也更容易扩展。
备注:内容仅供参考。
