PAYATHON 2026

Concurrent Payment Control:支付处理中状态如何避免永久锁定

支付小周

结论

不要用永久性的 is_payment_processing 布尔字段锁定产品,也不要依赖用户打开 success/failure callback page 来释放锁。

更稳妥的方案是:

  • 每次结账都创建独立的 payment_attempt 或 order 记录。
  • 使用明确的支付状态机,例如 PENDING、SUCCEEDED、FAILED、EXPIRED。
  • 如果需要独占商品或库存,应创建带 expires_at 的库存预留,不能无限期锁定。
  • 支付结果以服务端 webhook 和主动查询为准,浏览器回调只负责页面展示。
  • 使用数据库条件更新、唯一约束和幂等键处理并发。
  • 定时任务可以清理过期预留,但只能作为兜底,不能把“超过 3 分钟”直接视为“支付失败”。

为什么布尔锁容易出问题

is_payment_processing = True 只能说明“似乎有一笔支付正在进行”,却回答不了下面这些问题:

  • 是哪个订单占用了商品?
  • 由哪个用户发起?
  • 使用了哪个支付渠道?
  • 支付请求何时创建,何时过期?
  • 支付渠道是否已经扣款?
  • 重复 webhook 是否已经处理?
  • 旧请求是否有权清除新请求设置的锁?

浏览器跳转本身也不可靠。用户可能关闭窗口或断网,支付平台也可能延迟跳转。即使用户已经付款,浏览器也未必会返回商户页面。因此,success callback 和 failure callback 都不能作为最终的支付依据。

如果简单地在 3 分钟后重置布尔字段,还可能引发更严重的问题。第一位用户仍在支付,锁却已经释放;第二位用户成功购买后,第一笔支付也成功,最终造成重复销售或超卖。

推荐的数据模型

支付记录和库存预留应分开管理:

CREATE TABLE orders (
    id              BIGINT PRIMARY KEY,
    user_id         BIGINT NOT NULL,
    product_id      BIGINT NOT NULL,
    status          VARCHAR(32) NOT NULL,
    amount          DECIMAL(18, 2) NOT NULL,
    created_at      TIMESTAMP NOT NULL,
    updated_at      TIMESTAMP NOT NULL
);

CREATE TABLE payment_attempts (
    id                      BIGINT PRIMARY KEY,
    order_id                BIGINT NOT NULL,
    provider                VARCHAR(32) NOT NULL,
    provider_payment_id     VARCHAR(128),
    idempotency_key         VARCHAR(128) NOT NULL,
    status                  VARCHAR(32) NOT NULL,
    expires_at              TIMESTAMP,
    created_at              TIMESTAMP NOT NULL,
    updated_at              TIMESTAMP NOT NULL,
    UNIQUE (provider, provider_payment_id),
    UNIQUE (idempotency_key)
);

CREATE TABLE inventory_reservations (
    id              BIGINT PRIMARY KEY,
    order_id        BIGINT NOT NULL,
    product_id      BIGINT NOT NULL,
    status          VARCHAR(32) NOT NULL,
    expires_at      TIMESTAMP NOT NULL,
    created_at      TIMESTAMP NOT NULL
);

常见状态可以这样设计:

Order:
CREATED -> PAYMENT_PENDING -> PAID
                           -> PAYMENT_FAILED
                           -> EXPIRED
                           -> CANCELLED

Reservation:
ACTIVE -> CONSUMED
       -> RELEASED
       -> EXPIRED

状态之间不能随意转换。例如,订单一旦进入 PAID,超时任务就不能再将它改成 EXPIRED。

正确的处理流程

1. 创建订单并预留库存

用户点击 Place Order 后,在一个短数据库事务中完成以下操作:

  1. 锁定库存,或对库存执行原子更新。
  2. 创建订单。
  3. 创建带明确过期时间的库存预留。
  4. 提交事务。
  5. 在事务外调用支付平台。

用户停留在第三方支付页面期间,不要一直持有数据库事务或行锁。

对于有数量的普通商品,可以用条件更新防止超卖:

UPDATE products
SET available_stock = available_stock - 1
WHERE id = :product_id
  AND available_stock >= 1;

只有受影响行数为 1 时,预留才算成功。受影响行数为 0 表示库存不足。

如果商品是只能出售一次的唯一物品,可以通过条件更新取得预留权:

UPDATE products
SET reservation_order_id = :order_id,
    reservation_expires_at = :expires_at
WHERE id = :product_id
  AND (
      reservation_order_id IS NULL
      OR reservation_expires_at < CURRENT_TIMESTAMP
  );

这里同样要检查受影响行数。多个请求同时执行时,最多只有一个请求能成功预留。

2. 创建支付请求

向支付平台发起请求时,应使用稳定的幂等键,例如订单 ID 或支付尝试 ID:

Idempotency-Key: payment-attempt-839127

具体的请求头和幂等规则由支付服务商决定,不能假定所有平台都使用相同的名称。应用重试创建支付请求时,不应因此产生多笔独立扣款。

3. 浏览器回调只负责用户体验

success callback page 可以显示“正在确认支付”,再向自己的服务端查询订单状态。

async function waitForPayment(orderId) {
  const response = await fetch(`/api/orders/${orderId}`);
  const order = await response.json();

  if (order.status === "PAID") {
    showSuccess();
  } else if (order.status === "PAYMENT_FAILED") {
    showFailure();
  } else {
    showPending();
  }
}

不能仅凭浏览器 URL 中的 success=true 就把订单标记为已支付。用户可以伪造前端请求,真实的付款流程也可能尚未完成。

failure callback 同样不一定代表最终失败。用户可能在跳转过程中遇到网络问题,但支付平台已经受理交易。能否立即判定失败,要以支付平台返回的服务端状态为准。

4. 使用 webhook 确认最终结果

收到支付平台发送的 webhook 后,应先完成以下检查:

  • 验证签名和时间戳。
  • 根据 provider_payment_id 查找支付记录。
  • 校验订单号、币种和金额。
  • 对重复事件进行幂等处理。
  • 只执行允许的状态转换。
  • 在同一个数据库事务中确认订单,并消费库存预留。

示例伪代码:

def handle_payment_webhook(event):
    verify_signature(event)

    with db.transaction():
        attempt = db.query_one_for_update(
            """
            SELECT *
            FROM payment_attempts
            WHERE provider = %s
              AND provider_payment_id = %s
            """,
            [event.provider, event.payment_id],
        )

        if attempt.status == "SUCCEEDED":
            return  # 重复 webhook,幂等返回

        order = db.query_one_for_update(
            "SELECT * FROM orders WHERE id = %s",
            [attempt.order_id],
        )

        validate_amount_and_currency(order, event)

        if event.status == "SUCCEEDED":
            transition_payment(attempt, "SUCCEEDED")
            transition_order(order, "PAID")

            db.execute(
                """
                UPDATE inventory_reservations
                SET status = 'CONSUMED'
                WHERE order_id = %s
                  AND status = 'ACTIVE'
                """,
                [order.id],
            )

        elif event.status in ("FAILED", "CANCELLED"):
            transition_payment(attempt, "FAILED")
            transition_order(order, "PAYMENT_FAILED")
            release_reservation(order.id)

生产环境还应保存支付事件 ID,并为其建立唯一约束,防止同一事件被重复消费。

用户关闭窗口时怎么处理

用户关闭窗口后,不需要立即执行特殊操作:

  • 支付平台仍可能通过 webhook 通知最终结果。
  • 在支付尝试的有效期内,订单保持 PAYMENT_PENDING。
  • 到期后,后台任务向支付平台查询一次真实状态。
  • 确认仍未支付且支付请求已经失效后,再将订单标记为 EXPIRED 并释放库存。
  • 如果之后收到支付成功通知,则按照预先定义的迟到支付策略处理。

迟到支付不能直接忽略。通常需要明确选择以下一种策略:

  • 如果库存仍能履约,恢复订单并完成交易。
  • 如果已经无法履约,记录异常并自动退款。
  • 将订单转入人工审核队列。

具体采用哪一种,取决于商品是否唯一、支付渠道是否支持撤销或退款,以及业务的履约规则。

定时任务可以保留,但要这样写

每分钟运行一次清理任务是合理的,但判断条件应基于 expires_at 和当前支付状态,不能只看“字段是否超过 3 分钟未修改”。

先抢占一批待处理记录,避免多个任务实例重复执行:

SELECT id
FROM payment_attempts
WHERE status = 'PENDING'
  AND expires_at <= CURRENT_TIMESTAMP
ORDER BY expires_at
FOR UPDATE SKIP LOCKED
LIMIT 100;

然后对每条记录执行以下操作:

  1. 调用支付平台的服务端查询接口。
  2. 如果确认成功,按支付成功流程处理。
  3. 如果确认失败或已经关闭,释放预留。
  4. 如果平台仍返回处理中,推迟到下一次检查。
  5. 如果查询超时,不要直接判定失败,应按照退避策略重试。

更新最终状态时,要带上旧状态条件:

UPDATE payment_attempts
SET status = 'EXPIRED',
    updated_at = CURRENT_TIMESTAMP
WHERE id = :attempt_id
  AND status = 'PENDING';

这样可以防止清理任务覆盖 webhook 刚刚写入的 SUCCEEDED 状态。

释放库存时,也要限定预留的所有者和当前状态:

UPDATE inventory_reservations
SET status = 'EXPIRED'
WHERE order_id = :order_id
  AND status = 'ACTIVE'
  AND expires_at <= CURRENT_TIMESTAMP;

不要只根据 product_id 清除锁,否则旧订单的超时任务可能误删新订单创建的预留。

超时时间不应随意设为 3 分钟

过期时间应与支付渠道的支付会话有效期、用户完成支付所需的时间,以及商品的稀缺程度相匹配。

如果支付平台明确提供 expires_at,应优先使用平台给出的时间。如果没有,再根据业务规则设置,并在界面上向用户显示剩余时间。银行验证、3-D Secure 或移动端切换都可能超过 3 分钟,因此固定 3 分钟通常太短。

还要保证应用服务器和数据库的时钟一致。判断锁是否过期时,优先使用数据库时间,例如 CURRENT_TIMESTAMP,以免不同应用实例之间的时间偏差影响结果。

不同商品应采用不同并发策略

如果商品库存充足,通常没有必要在支付期间锁定整个产品。只需预留相应数量,其他用户仍可继续购买。

如果业务能够接受缺货退款,也可以等支付成功后再扣减库存,但必须准备好处理“支付成功却无货”的补偿流程。对普通电商来说,这种体验通常不如在支付前进行短期预留。

演出票、座位、域名等只能出售给一个人的资源,则必须采用独占预留,并设置明确的到期时间。最终的唯一性还要由数据库约束保证,不能只依赖应用层判断。

还需要注意的边界情况

  • webhook 可能重复、乱序或延迟到达。
  • 创建支付请求时,客户端可能超时,但支付平台其实已经创建成功。
  • 支付成功事件和过期清理任务可能同时执行。
  • 用户可能重复点击 Place Order。
  • 同一订单可能存在多次支付尝试,但只能接受一次有效付款。
  • 金额必须从服务端订单中读取,不能信任浏览器提交的金额。
  • 支付状态和订单状态不应共用一个含义模糊的字段。
  • 数据库事务提交后再调用外部支付 API,避免长事务。跨系统一致性要通过幂等、重试和状态对账实现。
  • 应定期运行对账任务,检查长时间处于 PENDING、平台已成功但本地未入账,以及本地已支付但金额不一致的记录。

并发控制应交给原子数据库操作和唯一约束,临时占用应使用带期限的 reservation,支付结果以服务端通知和查询为准,定时任务负责修复异常状态。这样即使用户关闭窗口、网络中断或回调丢失,也不会留下永久锁。

备注:内容仅供参考。