PAYATHON 2026

支付处理后系统立即崩溃,如何恢复数据一致性?

支付阿杰

结论

调整两行代码的执行顺序,无法彻底解决这个问题。支付服务和 MongoDB 是两个独立系统,普通的 MongoDB transaction 不能把外部支付 API 纳入同一个原子事务。

更可靠的处理方式是:

  1. 支付前在 MongoDB 中创建状态为 PENDING 的订单或支付记录。
  2. 为每次业务支付生成稳定且唯一的 idempotencyKey。
  3. 调用支付服务时带上订单号和 idempotencyKey。
  4. 结合支付结果、支付回调和主动查询,持续更新本地状态。
  5. 定期执行对账任务,修复“支付成功但本地尚未更新”的记录。
  6. 确保所有状态更新和回调处理都支持幂等执行。

这套方案实现的是“最终一致性”,并非支付服务与 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 没有记录”的情况,可以按以下顺序处理:

  1. 从支付供应商后台、交易查询 API 或结算文件中确认交易状态。
  2. 根据原始订单号、幂等键或供应商支付 ID 定位对应的业务订单。
  3. 校验金额、币种、收款账户和用户信息,不能只比较订单号。
  4. 在 MongoDB 中幂等地补建支付记录,并将订单状态更新为已支付。
  5. 记录恢复来源、供应商交易 ID 和操作时间,保留审计痕迹。
  6. 如果无法安全地关联业务订单,将交易放入人工处理队列,并在必要时按业务规则退款。

如果调用支付服务时没有传递任何可供回查的业务标识,也没有保存幂等键,自动恢复就可能无法做到完全可靠。此时只能结合金额、时间、用户资料和供应商流水进行人工匹配,不能因为金额相同就认定是同一笔交易。

需要特别注意的事项

  • 不要把网络超时视为支付失败。
  • 重试扣款请求时必须复用原来的幂等键。
  • 不要只依赖同步 API 响应,还要结合 webhook 和主动查询。
  • 不要由客户端直接决定订单是否已支付。最终状态应以服务端验证过的供应商结果为准。
  • 金额应使用最小货币单位的整数或合适的定点数,避免浮点误差。
  • 状态只能按照允许的方向转换。迟到的失败事件不能覆盖已经确认的成功状态。
  • 数据库唯一索引是最后一道防线,但不能代替业务幂等逻辑。
  • 如果支付成功后业务无法履约,应执行明确的补偿流程,例如退款。补偿操作也必须支持幂等执行。
  • 对账任务需要配套监控和告警,包括记录长期处于 UNKNOWN、回调无法匹配,以及供应商金额与本地金额不一致等情况。

核心原则是:发起不可逆的外部操作前,先保存可用于恢复的本地记录;为每次操作设置稳定的唯一身份;允许处理过程重复执行;再通过回调、查询和对账,让系统最终收敛到正确状态。

备注:内容仅供参考。