PAYATHON 2026

可以使用 Node 构建支付网关软件吗?

支付老李

结论

可以用 Node.js 构建支付网关。对于主要处理网络请求、数据库访问、消息队列和第三方支付接口调用的系统,Node.js 通常是合适的选择。

是否改用 Go 或 Scala,不能只比较语言性能。支付系统的难点在于安全、幂等、账务一致性、故障恢复、审计和合规。如果团队熟悉 Node.js,也有能力处理好这些问题,贸然更换语言反而会增加交付和维护风险。

如果系统需要执行大量纯 JavaScript CPU 计算,比如复杂风控模型、大规模报表或自研密码学运算,应把这些任务交给 Worker Threads、独立服务或专用计算平台,以免阻塞 Node.js 的事件循环。

为什么 Node.js 可以胜任

典型的支付请求通常包含这些操作:

  1. 校验请求和权限。
  2. 查询订单及商户配置。
  3. 调用银行或支付服务商 API。
  4. 写入交易记录。
  5. 发布消息或处理回调。
  6. 返回支付状态。

其中大部分是 I/O 密集型任务,而不是 CPU 密集型任务。Node.js 的异步 I/O 模型适合处理大量并发连接。

“Node.js 不适合重计算”不等于”Node.js 不适合支付系统”。支付系统确实会用到签名、哈希和加密,但常见的 crypto 操作通常由底层原生实现完成。需要避免的是在主线程中运行耗时较长的 JavaScript 计算。

内存泄漏也并非 Node.js 独有。对象被长期持有、缓存无限增长、定时器或监听器没有清理,都可能导致内存持续增加。Node.js 提供了堆快照、性能分析和进程指标等排查工具,但这些工具需要配合监控和明确的工程约束使用。

架构上需要优先解决的问题

幂等性

网络超时可能导致客户端重复提交付款请求,但服务端不能因此重复扣款。

可以要求客户端传入唯一的 Idempotency-Key,并在数据库中为”商户 ID + 幂等键”建立唯一约束。支付结果应持久化。收到重复请求时,直接返回第一次的处理结果。

下面是简化后的 TypeScript 示例。db.transaction 和数据访问方法代表项目自己的数据库实现:

type CreatePaymentInput = {
  merchantId: string;
  idempotencyKey: string;
  amount: number;
  currency: string;
};

async function createPayment(input: CreatePaymentInput) {
  if (!Number.isInteger(input.amount) || input.amount <= 0) {
    throw new Error("Invalid amount");
  }

  return db.transaction(async (tx) => {
    const existing = await tx.payments.findByIdempotencyKey(
      input.merchantId,
      input.idempotencyKey
    );

    if (existing) {
      return existing;
    }

    return tx.payments.insert({
      merchantId: input.merchantId,
      idempotencyKey: input.idempotencyKey,
      amount: input.amount,
      currency: input.currency,
      status: "pending"
    });
  });
}

仅在代码中”先查询,再插入”并不够,因为两个请求可能同时通过查询。数据库唯一约束才是最后一道防线。应用还要捕获唯一键冲突,并读取已经存在的记录。

金额必须使用整数或定点数

不要使用 JavaScript number 直接计算带小数的金额:

0.1 + 0.2 === 0.3 // false

常见做法是以最小货币单位保存金额。例如,10.25 元可以保存为 1025 分:

const amountInMinorUnits = 1025;

不同货币的最小单位并不完全相同,不能假设所有币种都有两位小数。币种规则应交给明确的数据表或可靠的金额库管理。如果数值可能超过 Number.MAX_SAFE_INTEGER,可以使用 bigint、数据库定点类型或经过验证的金额库。

使用不可变账本

不要只在订单表中保存一个可以直接覆盖的余额字段。更稳妥的做法是记录每一次资金变动,并通过复式记账或同等能力的账务模型保证借贷平衡。

交易状态可以变化,但已经入账的账务记录通常不应直接修改。退款、撤销和冲正应生成新的反向记录,方便后续审计和对账。

正确处理超时与未知状态

支付接口超时不代表支付失败。上游可能已经成功处理请求,只是响应没有送达。

出现超时后,不要立即发起一笔新的扣款。应使用相同的幂等标识重试,或者调用上游查询接口确认最终状态。系统还要支持 pending 或 unknown 状态,再通过定时任务、回调和对账逐步确认结果。

验证 Webhook

处理支付回调时,不能只相信请求体中的订单号和状态。系统必须验证签名、时间戳和允许的时间偏差,并防止重放攻击。签名算法和待签名内容必须严格按照支付服务商的协议处理。

下面是一个通用的 HMAC 验证示例。实际字段格式应以上游协议为准:

import { createHmac, timingSafeEqual } from "node:crypto";

function verifySignature(
  rawBody: Buffer,
  receivedSignature: string,
  secret: string
): boolean {
  if (!/^[0-9a-fA-F]+$/.test(receivedSignature)) {
    return false;
  }

  const expected = createHmac("sha256", secret)
    .update(rawBody)
    .digest();

  const received = Buffer.from(receivedSignature, "hex");

  return (
    received.length === expected.length &&
    timingSafeEqual(received, expected)
  );
}

签名验证必须使用原始请求体。如果先解析 JSON,再重新序列化,空格、键顺序或字符编码的变化都可能导致验证失败。

签名合法也不代表回调只会到达一次。同一个 Webhook 可能被重复发送,因此回调处理仍需保证幂等。

如何处理 CPU 密集型任务

如果风控规则、文件解析或批量计算会长时间占用 CPU,不要直接在 HTTP 请求线程中执行。此类任务可以交给 Worker Threads:

import { Worker } from "node:worker_threads";

function runRiskAnalysis(payload: unknown): Promise<unknown> {
  return new Promise((resolve, reject) => {
    const worker = new Worker(
      new URL("./risk-worker.js", import.meta.url),
      { workerData: payload }
    );

    worker.once("message", resolve);
    worker.once("error", reject);
    worker.once("exit", (code) => {
      if (code !== 0) {
        reject(new Error(`Worker stopped with exit code ${code}`));
      }
    });
  });
}

如果任务量较大,更适合通过消息队列交给独立的风控或计算服务。这样即使计算任务过载,也不会拖慢支付入口。

性能方面的问题应先通过压测和生产指标确认,再决定是否拆分服务。没有必要仅凭担忧提前拆出大量微服务。

内存泄漏的防范措施

Node.js 服务至少应监控以下指标:

  • RSS、堆内存使用量和堆增长趋势。
  • Event Loop Lag。
  • 请求延迟、错误率和超时率。
  • 数据库连接池及消息队列积压。
  • 进程重启次数和未捕获异常。
  • 外部支付渠道的成功率与响应时间。

下面是一种常见问题:

const cache = new Map();

// 如果没有容量限制和过期策略,缓存会持续增长
cache.set(transactionId, transaction);

缓存应设置容量上限和 TTL,也可以使用外部缓存系统。事件监听器、定时器、未结束的流和未释放的连接,也要在生命周期结束时清理。

多进程部署和容器内存限制可以减轻单个进程故障的影响,但定期重启无法代替修复内存泄漏。

安全与合规要求

编程语言本身不能保证支付网关的安全。无论使用 Node.js、Go 还是 Scala,都要处理以下事项:

  • 全链路 TLS 和敏感数据加密。
  • 密钥轮换及专业的密钥管理服务。
  • 最小权限、网络隔离和访问审计。
  • 敏感字段脱敏,禁止在日志中记录完整卡号、CVV、密钥或访问令牌。
  • 依赖漏洞扫描和安全更新。
  • 请求限流、身份认证及授权。
  • 交易审计、日终对账和异常告警。
  • 数据备份、恢复演练和灾难恢复。
  • 适用范围内的 PCI DSS 及当地法律要求。

如果业务不需要直接处理银行卡数据,应优先采用支付服务商提供的托管支付页或 Tokenization 方案。这通常可以减少敏感数据暴露,缩小合规范围。具体责任仍取决于实际的集成方式和适用要求。

不要自行设计密码算法,也不能只根据客户端传来的”支付成功”状态更新订单。

推荐的落地步骤

  1. 使用 Node.js 和团队熟悉的 TypeScript 技术栈完成核心服务。
  2. 明确订单、支付尝试、退款和账务记录之间的状态模型。
  3. 在数据库层为幂等键、交易号等字段建立唯一约束。
  4. 使用整数或定点数处理金额。
  5. 为外部调用设置超时、有限重试和熔断策略。
  6. 使用消息队列处理通知、对账等异步任务。
  7. 验证所有回调签名,并保证回调处理幂等。
  8. 建立指标、日志、链路追踪和审计记录。
  9. 除正常流程测试外,还要进行并发、故障注入和恢复测试。
  10. 根据压测结果拆分真正的 CPU 密集型任务。

什么时候更适合选择其他语言

遇到以下情况时,可以考虑 Go、Scala、Java 或其他技术:

  • 团队已有成熟且经过验证的支付基础设施。
  • 核心任务确实需要持续进行 CPU 密集型计算。
  • 现有平台、运维体系或内部 SDK 明确以另一种语言为主。
  • 团队使用另一种语言的生产经验明显多于 Node.js。
  • 实际压测表明 Node.js 无法满足延迟、吞吐量或资源成本目标。

语言选择应结合业务模型、团队能力、故障处理机制和实测数据。对于熟悉 Node.js 的团队,Node.js 完全可以作为支付网关的主要技术栈。更值得投入精力的是让每笔交易都能追踪、重试和对账,同时确保重复请求不会造成重复扣款。

备注:内容仅供参考。