支付处理后系统立即崩溃,如何恢复数据一致性?
结论
调整两行代码的执行顺序,无法彻底解决这个问题。支付服务和 MongoDB 是两个独立系统,普通的 MongoDB transaction 不能把外部支付 API 纳入同一个原子事务。
更可靠的处理方式是:
- 支付前在 MongoDB 中创建状态为
PENDING的订单或支付记录。 - 为每次业务支付生成稳定且唯一的
idempotencyKey。 - 调用支付服务时带上订单号和
idempotencyKey。 - 结合支付结果、支付回调和主动查询,持续更新本地状态。
- 定期执行对账任务,修复“支付成功但本地尚未更新”的记录。
- 确保所有状态更新和回调处理都支持幂等执行。
这套方案实现的是“最终一致性”,并非支付服务与 MongoDB 之间严格的跨系统 ACID 一致性。
为什么 MongoDB transaction 解决不了
MongoDB transaction 只能保证 MongoDB 内部多个读写操作的原子性,无法回滚支付服务已经完成的扣款。
例如,即使这样写:
await session.withTransaction(async () => {
await call_payment_vendor_api();
await write_to_collection();
});
如果支付成功后 MongoDB transaction 回滚,用户仍可能已经被扣款。MongoDB 不知道如何撤销外部支付,支付服务也不会参与 MongoDB 的事务提交。
反过来,先写数据库再支付,只会产生另一种异常:数据库显示记录已经创建,但支付没有完成。因此,重点不是消除中间状态,而是保存中间状态,并让系统能够从中安全恢复。
推荐的状态模型
至少应保存以下状态:
PENDING 已创建本地记录,尚未确认支付结果
PROCESSING 正在请求支付服务
SUCCEEDED 已确认支付成功
FAILED 已确认支付失败
UNKNOWN 请求结果不明确,需要主动查询
REFUNDED 已退款
其中需要明确区分两种情况:
- 明确失败:支付服务明确返回失败。
- 结果未知:请求超时、连接断开或进程崩溃,无法判断支付是否成功。
超时并不代表支付失败。此时不能立即发起一笔新的支付,否则可能造成重复扣款。
正确的处理流程
1. 先创建本地支付记录
调用支付服务前,先保存业务订单号、金额、币种和幂等键:
import crypto from "node:crypto";
async function createPaymentRecord({ orderId, userId, amount, currency }) {
const idempotencyKey = crypto.randomUUID();
return payments.insertOne({
orderId,
userId,
amount,
currency,
idempotencyKey,
status: "PENDING",
vendorPaymentId: null,
createdAt: new Date(),
updatedAt: new Date()
});
}
idempotencyKey 应当与本次业务支付绑定并持久化。重试同一笔支付时必须复用原来的值,不能在每次重试时重新生成。
建议为业务订单号和幂等键建立唯一索引:
await payments.createIndex({ orderId: 1 }, { unique: true });
await payments.createIndex({ idempotencyKey: 1 }, { unique: true });
如果一个订单允许进行多次独立的支付尝试,就不能简单地将 orderId 设为唯一值。此时应设计 paymentAttemptId,并明确哪一次尝试当前有效。
2. 使用幂等键调用支付服务
这种做法的前提是支付服务支持 idempotency key 或同类机制。具体字段名由供应商 API 决定:
async function processPayment(orderId) {
const payment = await payments.findOne({ orderId });
if (!payment) {
throw new Error("Payment record not found");
}
if (payment.status === "SUCCEEDED") {
return payment;
}
await payments.updateOne(
{
_id: payment._id,
status: { $in: ["PENDING", "UNKNOWN"] }
},
{
$set: {
status: "PROCESSING",
updatedAt: new Date()
}
}
);
try {
const vendorResult = await call_payment_vendor_api({
amount: payment.amount,
currency: payment.currency,
idempotencyKey: payment.idempotencyKey,
metadata: {
orderId: payment.orderId
}
});
await markPaymentSucceeded(payment._id, vendorResult);
} catch (error) {
if (isDefinitivePaymentFailure(error)) {
await payments.updateOne(
{ _id: payment._id, status: { $ne: "SUCCEEDED" } },
{
$set: {
status: "FAILED",
failureCode: error.code,
updatedAt: new Date()
}
}
);
} else {
await payments.updateOne(
{ _id: payment._id, status: { $ne: "SUCCEEDED" } },
{
$set: {
status: "UNKNOWN",
updatedAt: new Date()
}
}
);
}
throw error;
}
}
如果进程恰好在供应商确认支付后崩溃,本地记录会停留在 PROCESSING。由于订单号和幂等键已经保存,恢复任务可以向供应商查询实际结果,无须直接再次发起扣款。
3. 幂等地更新成功状态
支付成功后的处理逻辑也要允许重复执行:
async function markPaymentSucceeded(paymentId, vendorPayment) {
await payments.updateOne(
{
_id: paymentId,
status: { $ne: "SUCCEEDED" }
},
{
$set: {
status: "SUCCEEDED",
vendorPaymentId: vendorPayment.id,
paidAt: new Date(vendorPayment.paidAt),
updatedAt: new Date()
}
}
);
}
还应为 vendorPaymentId 建立唯一索引,防止同一笔供应商支付被错误地关联到多条本地记录:
await payments.createIndex(
{ vendorPaymentId: 1 },
{
unique: true,
partialFilterExpression: {
vendorPaymentId: { $type: "string" }
}
}
);
如果确认支付成功后还要修改订单、增加账本记录或生成业务事件,可以使用 MongoDB transaction,让这些本地操作一起提交:
async function finalizePayment(paymentId, vendorPayment) {
const session = mongoClient.startSession();
try {
await session.withTransaction(async () => {
const payment = await payments.findOne(
{ _id: paymentId },
{ session }
);
if (!payment) {
throw new Error("Payment record not found");
}
if (payment.status === "SUCCEEDED") {
return;
}
await payments.updateOne(
{ _id: paymentId, status: { $ne: "SUCCEEDED" } },
{
$set: {
status: "SUCCEEDED",
vendorPaymentId: vendorPayment.id,
paidAt: new Date(vendorPayment.paidAt),
updatedAt: new Date()
}
},
{ session }
);
await orders.updateOne(
{ _id: payment.orderId },
{
$set: {
paymentStatus: "PAID",
updatedAt: new Date()
}
},
{ session }
);
await outbox.insertOne(
{
type: "PAYMENT_SUCCEEDED",
aggregateId: payment.orderId,
payload: {
paymentId: payment._id,
vendorPaymentId: vendorPayment.id
},
published: false,
createdAt: new Date()
},
{ session }
);
});
} finally {
await session.endSession();
}
}
这里的 transaction 只保证本地支付记录、订单和 outbox 事件保持一致,不负责包裹外部支付调用。
使用支付回调补偿崩溃窗口
大多数支付服务都提供 webhook。支付成功后,即使处理原始请求的进程已经崩溃,回调仍能继续推动本地状态更新。
app.post("/webhooks/payment", async (req, res) => {
if (!verifyWebhookSignature(req)) {
return res.status(401).end();
}
const event = parseWebhookEvent(req);
if (event.type !== "payment.succeeded") {
return res.status(200).end();
}
const orderId = event.data.metadata?.orderId;
const vendorPaymentId = event.data.id;
const payment = await payments.findOne({
$or: [
{ orderId },
{ vendorPaymentId }
]
});
if (!payment) {
await unmatchedPaymentEvents.updateOne(
{ eventId: event.id },
{
$setOnInsert: {
event,
createdAt: new Date()
}
},
{ upsert: true }
);
return res.status(200).end();
}
await finalizePayment(payment._id, event.data);
return res.status(200).end();
});
Webhook 可能重复发送,也可能乱序或延迟到达,因此不能假设每个事件只会处理一次。可以为供应商的 event.id 建立唯一索引,并将事件处理记录与业务更新放进同一个 MongoDB transaction。
签名校验必须使用支付供应商要求的原始请求体和验证算法,不能仅根据请求中的订单号或支付 ID 判断请求是否可信。
增加主动查询和定期对账
Webhook 不能作为唯一的恢复手段,因为回调本身也可能延迟或投递失败。后台任务应定期扫描长时间停留在中间状态的记录:
async function reconcilePendingPayments() {
const cutoff = new Date(Date.now() - 5 * 60 * 1000);
const cursor = payments.find({
status: { $in: ["PROCESSING", "UNKNOWN"] },
updatedAt: { $lt: cutoff }
});
for await (const payment of cursor) {
const vendorPayment = await query_payment_vendor_api({
idempotencyKey: payment.idempotencyKey,
orderId: payment.orderId
});
if (vendorPayment.status === "SUCCEEDED") {
await finalizePayment(payment._id, vendorPayment);
} else if (vendorPayment.status === "FAILED") {
await payments.updateOne(
{
_id: payment._id,
status: { $in: ["PROCESSING", "UNKNOWN"] }
},
{
$set: {
status: "FAILED",
updatedAt: new Date()
}
}
);
}
}
}
轮询间隔、超时时间和查询参数应根据供应商接口、业务时效和限流规则确定。对于长期无法自动确认的记录,应将其放入人工对账队列,不能让记录永久停留在 PROCESSING。
还应定期从供应商侧拉取结算单或交易列表,并与本地支付记录核对。实时查询用于处理单笔异常,批量对账则用于发现 webhook、程序逻辑或运维故障造成的遗漏。
已经发生不一致时如何恢复
如果当前已经出现“供应商显示支付成功,但 MongoDB 没有记录”的情况,可以按以下顺序处理:
- 从支付供应商后台、交易查询 API 或结算文件中确认交易状态。
- 根据原始订单号、幂等键或供应商支付 ID 定位对应的业务订单。
- 校验金额、币种、收款账户和用户信息,不能只比较订单号。
- 在 MongoDB 中幂等地补建支付记录,并将订单状态更新为已支付。
- 记录恢复来源、供应商交易 ID 和操作时间,保留审计痕迹。
- 如果无法安全地关联业务订单,将交易放入人工处理队列,并在必要时按业务规则退款。
如果调用支付服务时没有传递任何可供回查的业务标识,也没有保存幂等键,自动恢复就可能无法做到完全可靠。此时只能结合金额、时间、用户资料和供应商流水进行人工匹配,不能因为金额相同就认定是同一笔交易。
需要特别注意的事项
- 不要把网络超时视为支付失败。
- 重试扣款请求时必须复用原来的幂等键。
- 不要只依赖同步 API 响应,还要结合 webhook 和主动查询。
- 不要由客户端直接决定订单是否已支付。最终状态应以服务端验证过的供应商结果为准。
- 金额应使用最小货币单位的整数或合适的定点数,避免浮点误差。
- 状态只能按照允许的方向转换。迟到的失败事件不能覆盖已经确认的成功状态。
- 数据库唯一索引是最后一道防线,但不能代替业务幂等逻辑。
- 如果支付成功后业务无法履约,应执行明确的补偿流程,例如退款。补偿操作也必须支持幂等执行。
- 对账任务需要配套监控和告警,包括记录长期处于
UNKNOWN、回调无法匹配,以及供应商金额与本地金额不一致等情况。
核心原则是:发起不可逆的外部操作前,先保存可用于恢复的本地记录;为每次操作设置稳定的唯一身份;允许处理过程重复执行;再通过回调、查询和对账,让系统最终收敛到正确状态。
备注:内容仅供参考。
