PAYATHON 2026

load balancing 环境下的 Payment Gateway Integration

支付小周

结论

支付网关返回交易信息时,请求通常先到负载均衡器,再由负载均衡器按当前策略分配给 Server X 或 Server Y。没有配置会话保持时,请求不一定会回到最初发起付款的服务器。

更稳妥的设计是让所有服务器都能安全处理同一笔支付回调。交易状态保存在共享数据库中,服务器通过订单号或支付网关交易号识别交易,同时完成签名验证、幂等处理和状态核验,无须强制回到某台服务器。

为什么回调不一定到原服务器

支付流程一般有两类返回请求:

  • 浏览器跳转:支付完成后,用户浏览器被重定向到 return_url。
  • 服务端通知:支付网关向 webhook_url 或 notify_url 发送异步通知。

这两类请求到达网站域名后,都要经过负载均衡器。负载均衡器可能采用轮询、最少连接数或源 IP 哈希等策略,因此请求可能被转发到任意健康节点。

即使启用了 sticky session,也不能依靠它保证支付流程的正确性。服务端通知未必携带用户的会话 Cookie,支付网关的出口 IP 也可能变化。节点重启或扩缩容时,会话保持同样可能失效。

推荐的处理流程

1. 将交易记录写入共享存储

Server X 或 Server Y 创建支付请求前,应先在共享数据库中创建订单记录。交易信息不能只保存在进程内存、本地文件或某台服务器的 Session 中。

建议至少保存:

  • 内部订单号 order_id
  • 应付金额和币种
  • 当前支付状态
  • 支付网关名称
  • 网关交易号 gateway_transaction_id
  • 创建时间和更新时间
  • 已处理事件的唯一标识
  • 必要的业务关联信息,例如用户 ID 或商品订单 ID

订单号应由服务端生成,并随支付请求一起提交给支付网关。回调到达任何服务器后,都可以通过订单号在共享数据库中找到对应的交易。

2. 使用统一的回调地址

例如:

https://example.com/payments/webhook
https://example.com/payments/return

这两个地址可以继续通过负载均衡器访问,不需要分别指向 Server X 或 Server Y。所有应用节点都应部署相同的回调处理代码,并使用相同的配置和密钥管理机制。

3. 验证回调真实性

回调参数中出现 status=success,不能直接作为订单已支付的依据。具体校验方式取决于支付网关 API,通常需要:

  • 验证 webhook 签名。
  • 使用支付网关要求的原始请求体计算签名。
  • 检查时间戳,降低重放攻击风险。
  • 核对商户号、订单号、金额和币种。
  • 必要时调用支付网关的查询 API,再次确认交易状态。

签名算法、请求头名称和字段格式必须以所接入支付网关的官方文档为准。

4. 实现幂等处理

支付网关可能因为响应超时或网络故障,重复发送同一条通知。因此,回调处理必须是幂等的。无论同一个事件由 Server X、Server Y,还是由两个节点先后收到,都只能产生一次业务效果。

可以在数据库中为以下字段设置唯一约束:

CREATE UNIQUE INDEX uq_payment_event_id
ON payment_events(provider, event_id);

如果支付网关没有提供稳定的 event_id,可以根据网关协议选择网关交易号,或者用规范化后的必要字段生成幂等键。字段组合必须能够唯一表示一次网关事件,不能只依赖请求到达时间。

5. 使用数据库事务更新状态

下面是一个简化的 Node.js/Express 示例。签名验证部分使用了抽象接口,实际实现必须遵循具体支付网关的规范:

app.post(
  "/payments/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const signature = req.get("X-Payment-Signature");

    let event;

    try {
      event = paymentGateway.verifyWebhook(
        req.body,
        signature,
        process.env.PAYMENT_WEBHOOK_SECRET
      );
    } catch {
      return res.status(400).send("Invalid signature");
    }

    const {
      eventId,
      orderId,
      transactionId,
      status,
      amount,
      currency
    } = event;

    try {
      await db.transaction(async (tx) => {
        const inserted = await tx.query(
          `INSERT INTO payment_events
             (provider, event_id, received_at)
           VALUES ($1, $2, NOW())
           ON CONFLICT (provider, event_id) DO NOTHING
           RETURNING event_id`,
          ["example-gateway", eventId]
        );

        if (inserted.rowCount === 0) {
          return;
        }

        const result = await tx.query(
          `SELECT order_id, amount, currency, payment_status
             FROM orders
            WHERE order_id = $1
            FOR UPDATE`,
          [orderId]
        );

        if (result.rowCount !== 1) {
          throw new Error("Unknown order");
        }

        const order = result.rows[0];

        if (
          String(order.amount) !== String(amount) ||
          order.currency !== currency
        ) {
          throw new Error("Payment data mismatch");
        }

        if (status === "paid" && order.payment_status !== "paid") {
          await tx.query(
            `UPDATE orders
                SET payment_status = 'paid',
                    gateway_transaction_id = $1,
                    paid_at = NOW()
              WHERE order_id = $2`,
            [transactionId, orderId]
          );

          // 在同一事务中写入 outbox 记录,
          // 后续再异步执行发货、发送邮件等操作。
          await tx.query(
            `INSERT INTO outbox_events
               (event_type, aggregate_id, created_at)
             VALUES ($1, $2, NOW())`,
            ["ORDER_PAID", orderId]
          );
        }
      });

      return res.status(200).send("OK");
    } catch (error) {
      console.error("Payment webhook failed", error);
      return res.status(500).send("Temporary failure");
    }
  }
);

这段代码包含以下处理:

  • Server X 和 Server Y 使用同一个数据库。
  • 回调不依赖本地 Session。
  • 使用 event_id 防止重复处理同一条通知。
  • 使用 SELECT ... FOR UPDATE 防止多个节点同时修改同一订单。
  • 服务端根据原订单记录核对金额和币种。
  • 通过 outbox 或消息队列异步执行外部操作,避免数据库已经回滚,邮件却已发送或货物已经发出的情况。

浏览器返回页不要作为支付依据

用户返回 return_url 时,可能关闭页面、重复刷新,也可能修改查询参数。因此,浏览器跳转只能用来展示结果,不能单独证明付款成功。

返回页应根据订单号查询共享数据库中的最新状态:

app.get("/payments/return", async (req, res) => {
  const orderId = req.query.order_id;

  const result = await db.query(
    `SELECT payment_status
       FROM orders
      WHERE order_id = $1`,
    [orderId]
  );

  if (result.rowCount !== 1) {
    return res.status(404).send("Order not found");
  }

  return res.render("payment-result", {
    status: result.rows[0].payment_status
  });
});

如果浏览器已经返回,但 webhook 还没有到达,可以暂时显示”支付结果确认中”,并轮询服务端的订单状态。对于长时间没有收到通知的交易,可以通过定时任务调用支付网关的查询 API 进行对账。

是否可以使用会话保持

会话保持可以减少传统应用中的部分 Session 问题,但不能用来保证支付一致性。如果现有系统依赖 Session,可以把 Session 放入 Redis 等共享存储,或者逐步将应用改造成无状态服务。

支付回调仍需满足以下条件:

  • 任意健康节点都能处理。
  • 单个节点离线时不会丢失交易状态。
  • 重复通知不会导致重复扣库存、发货或记账。
  • 多个节点并发处理时不会发生状态冲突。

其他注意事项

  • 回调地址必须通过 HTTPS 暴露。
  • Webhook 接口不能依赖用户登录状态或浏览器 Cookie。
  • 密钥应存放在密钥管理系统或安全配置中,不能写入代码仓库。
  • 如果使用 IP 白名单,只能将其作为附加防护,不能用它替代签名验证。
  • 不要记录银行卡号、CVV、完整密钥等敏感数据。
  • 应保存必要的审计记录,同时对日志内容进行脱敏。
  • 为 webhook 设置合理的超时时间,并尽快返回成功响应。耗时业务应交给消息队列或后台任务处理。
  • 定期核对订单与支付网关账单,处理通知丢失、状态不一致和退款等情况。
  • 支付状态转换应受到约束。例如,已经退款的订单不能因为迟到的旧通知而重新变成已支付。

因此,无须判断交易”属于 Server X 还是 Server Y”。服务器只是无状态的处理节点,需要保持一致的是订单和交易状态。只要共享存储、签名验证、幂等控制和并发更新设计正确,回调落到任何服务器都能得到相同的处理结果。

备注:内容仅供参考。