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”。服务器只是无状态的处理节点,需要保持一致的是订单和交易状态。只要共享存储、签名验证、幂等控制和并发更新设计正确,回调落到任何服务器都能得到相同的处理结果。
备注:内容仅供参考。
