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 后,在一个短数据库事务中完成以下操作:
- 锁定库存,或对库存执行原子更新。
- 创建订单。
- 创建带明确过期时间的库存预留。
- 提交事务。
- 在事务外调用支付平台。
用户停留在第三方支付页面期间,不要一直持有数据库事务或行锁。
对于有数量的普通商品,可以用条件更新防止超卖:
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;
然后对每条记录执行以下操作:
- 调用支付平台的服务端查询接口。
- 如果确认成功,按支付成功流程处理。
- 如果确认失败或已经关闭,释放预留。
- 如果平台仍返回处理中,推迟到下一次检查。
- 如果查询超时,不要直接判定失败,应按照退避策略重试。
更新最终状态时,要带上旧状态条件:
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,支付结果以服务端通知和查询为准,定时任务负责修复异常状态。这样即使用户关闭窗口、网络中断或回调丢失,也不会留下永久锁。
备注:内容仅供参考。
