PHP 高负载网站的限量商品并发支付架构
结论
用户停留在第三方支付页面时,不要锁住整条团购记录。PHP 进程锁、Redis 分布式锁,以及“先查询库存、再更新库存”的做法,也无法可靠防止超卖。
建议采用以下方案:
- 下单时,通过数据库原子条件更新预占库存。
- 为库存预占设置较短的有效期。
- 对支付回调做事件幂等处理,并通过订单状态机确认成交。
- 由后台任务释放超时订单占用的库存。
- 如果付款成功时预占已经失效,则退款或转人工补偿。
- 用队列、限流和缓存降低数据库压力,但最终库存仍以数据库约束为准。
关键原则是:在短事务中预占库存,提交事务后再跳转支付。绝不能持有数据库锁等待用户付款。
为什么不能长时间锁定交易
第三方支付通常需要几十秒,甚至几分钟。用户也可能关闭页面、断网,或者一直不返回。如果数据库事务或行锁始终不释放,可能造成以下问题:
- 后续请求排队等待锁,数据库连接很快就会耗尽。
- 长事务会增加 undo log,并提高锁等待和死锁的风险。
- PHP 请求超时后,业务状态可能无法正确恢复。
- 浏览器返回页不能作为可信的支付结果来源。
- 锁住整条团购记录,会迫使所有购买请求依次执行。
只有库存数量发生变化的瞬间需要串行处理,完整的支付过程不需要。
推荐的订单状态
订单至少应有以下状态:
PENDING_PAYMENT 已预占库存,等待支付
PAID 支付成功
EXPIRED 预占超时
CANCELLED 用户取消或支付失败
REFUND_PENDING 已收款但库存预占失效,等待退款
REFUNDED 已退款
订单还应保存:
- 购买数量
- 预占过期时间
- 支付平台订单号
- 支付金额和币种
- 创建、支付及取消时间
- 幂等键
- 订单版本号或必要的状态字段
不能把用户从支付页面跳回本站视为支付成功。最终结果应以支付平台签名验证通过的服务器回调或主动查询结果为准。
使用原子 SQL 预占库存
一种简单的设计是在商品表中保存当前可预占的库存:
CREATE TABLE deals (
id BIGINT UNSIGNED PRIMARY KEY,
stock_available INT UNSIGNED NOT NULL,
sold_count INT UNSIGNED NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL
) ENGINE=InnoDB;
预占两件商品时,不要先执行 SELECT,再根据查询结果决定是否更新。直接执行带条件的原子更新:
UPDATE deals
SET stock_available = stock_available - 2,
updated_at = NOW()
WHERE id = 123
AND stock_available >= 2;
执行后检查受影响的行数:
1:预占成功。0:库存不足,或者商品不存在。
即使多个请求并发执行,InnoDB 也会在更新目标行时进行协调。条件 stock_available >= 2 可以防止库存降到零以下。
数据库约束应作为最后一道防线。如果使用的 MySQL 版本会强制执行 CHECK,可以增加:
ALTER TABLE deals
ADD CONSTRAINT chk_stock_available
CHECK (stock_available >= 0);
CHECK 是否真正生效取决于数据库版本,因此不能只依赖它。
在一个短事务中创建订单
库存预占和订单创建必须放在同一个事务中,否则可能出现库存已经扣减,但订单没有创建的情况。
下面是简化的 PHP/PDO 示例:
<?php
function createPendingOrder(
PDO $pdo,
int $userId,
int $dealId,
int $quantity,
int $unitPrice,
string $currency,
string $idempotencyKey
): array {
if ($quantity <= 0) {
throw new InvalidArgumentException('Invalid quantity');
}
$pdo->beginTransaction();
try {
$find = $pdo->prepare(
'SELECT id, status, expires_at
FROM orders
WHERE user_id = :user_id
AND idempotency_key = :idempotency_key
LIMIT 1'
);
$find->execute([
':user_id' => $userId,
':idempotency_key' => $idempotencyKey,
]);
$existing = $find->fetch(PDO::FETCH_ASSOC);
if ($existing !== false) {
$pdo->commit();
return $existing;
}
$reserve = $pdo->prepare(
'UPDATE deals
SET stock_available = stock_available - :quantity,
updated_at = NOW()
WHERE id = :deal_id
AND stock_available >= :quantity'
);
$reserve->execute([
':quantity' => $quantity,
':deal_id' => $dealId,
]);
if ($reserve->rowCount() !== 1) {
$pdo->rollBack();
throw new RuntimeException('OUT_OF_STOCK');
}
$orderId = generateOrderId();
$amount = $unitPrice * $quantity;
$insert = $pdo->prepare(
'INSERT INTO orders (
id,
user_id,
deal_id,
quantity,
amount,
currency,
status,
idempotency_key,
expires_at,
created_at,
updated_at
) VALUES (
:id,
:user_id,
:deal_id,
:quantity,
:amount,
:currency,
\'PENDING_PAYMENT\',
:idempotency_key,
DATE_ADD(NOW(), INTERVAL 10 MINUTE),
NOW(),
NOW()
)'
);
$insert->execute([
':id' => $orderId,
':user_id' => $userId,
':deal_id' => $dealId,
':quantity' => $quantity,
':amount' => $amount,
':currency' => $currency,
':idempotency_key' => $idempotencyKey,
]);
$pdo->commit();
return [
'id' => $orderId,
'status' => 'PENDING_PAYMENT',
];
} catch (Throwable $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e;
}
}
generateOrderId() 应生成不会重复的订单标识,可以使用数据库序列、可靠的雪花算法实现,或者合适的 UUID/ULID 实现。具体方案取决于现有系统。
PHP 应在事务提交后调用支付平台创建支付会话,再把用户重定向到支付页面。不要把外部网络请求放进数据库事务。
防止重复下单
客户端应为每次购买尝试生成幂等键,例如:
Idempotency-Key: 01J8Y7M4XW...
同时在数据库中增加唯一索引:
CREATE UNIQUE INDEX uq_orders_user_idempotency
ON orders (user_id, idempotency_key);
这样,用户双击按钮、浏览器重试或网关重放请求时,就不会重复预占库存。
如果业务要求每个用户在每个团购中只能购买一次,还应建立对应的唯一约束,不能只靠 PHP 查询:
CREATE UNIQUE INDEX uq_orders_user_deal
ON orders (user_id, deal_id);
如果允许同一用户分多次购买,就不能建立这个索引,而应通过单独的购买限额逻辑控制。
幂等处理支付回调
支付平台可能重复发送回调,也可能乱序发送“成功”“失败”“退款”等事件。因此,回调处理必须支持安全重试。
可以为支付事件建立唯一记录:
CREATE TABLE payment_events (
provider VARCHAR(32) NOT NULL,
event_id VARCHAR(191) NOT NULL,
order_id VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
received_at DATETIME NOT NULL,
PRIMARY KEY (provider, event_id)
) ENGINE=InnoDB;
收到支付成功回调后,在短事务中锁定对应订单:
SELECT id, deal_id, quantity, amount, currency, status, expires_at
FROM orders
WHERE id = ?
FOR UPDATE;
然后根据订单的当前状态处理:
- 状态为
PENDING_PAYMENT,且预占仍然有效:更新为PAID。 - 状态已经是
PAID:直接返回成功,避免重复记账。 - 状态已经是
EXPIRED或CANCELLED:不要改回PAID,应进入退款补偿流程。 - 回调中的金额、币种或商户订单号不匹配:拒绝处理并记录告警。
确认成交时可以使用条件更新:
UPDATE orders
SET status = 'PAID',
provider_payment_id = :provider_payment_id,
paid_at = NOW(),
updated_at = NOW()
WHERE id = :order_id
AND status = 'PENDING_PAYMENT'
AND expires_at > NOW();
如果受影响行数为 0,应重新读取订单状态,确认订单是已经处理、已经过期,还是出现了异常。不要再次盲目扣减库存。
支付回调应尽快返回。发送邮件、发券、生成发票和积分入账等后续操作,可以写入事务型 outbox,再通过消息队列异步执行。
安全释放超时库存
后台任务应定期扫描已经到期的订单。释放库存时,仍要逐单使用事务和状态条件,避免与支付回调发生竞态。
对应的事务过程如下:
START TRANSACTION;
SELECT id, deal_id, quantity, status
FROM orders
WHERE id = ?
FOR UPDATE;
UPDATE orders
SET status = 'EXPIRED',
updated_at = NOW()
WHERE id = ?
AND status = 'PENDING_PAYMENT'
AND expires_at <= NOW();
-- 只有上一条 UPDATE 确实更新了一行时才执行
UPDATE deals
SET stock_available = stock_available + ?,
updated_at = NOW()
WHERE id = ?;
COMMIT;
扫描任务不应一次锁住大量订单。可以分批处理,并在数据库支持时使用:
SELECT id
FROM orders
WHERE status = 'PENDING_PAYMENT'
AND expires_at <= NOW()
ORDER BY expires_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
SKIP LOCKED 是否可用以及具体行为,取决于数据库产品和版本。使用前应在实际环境中确认。
处理“付款成功”和“库存过期”的竞态
最难处理的是下面这种情况:
- 用户已经在支付平台完成付款。
- 支付成功回调还没有到达。
- 本地库存预占恰好过期并被释放。
- 这部分库存随即被其他用户占用。
在分布式系统中,不能假设回调一定会按时到达。常见的处理方式有两种。
授权后捕获
如果支付平台支持将授权和捕获分开,可以先授权付款,确认库存预占仍然有效后再捕获款项:
创建库存预占
↓
创建支付授权
↓
收到授权成功通知
↓
确认订单仍持有库存
↓
捕获款项并完成订单
如果库存预占已经失效,就撤销授权。与先收款再退款相比,这种方式通常对用户更友好。不过,平台是否支持该模式以及授权有效期有多长,要以具体支付平台为准。
收款后补偿
如果平台只能直接收款,收到迟到的成功回调时可以这样处理:
- 如果还能重新预占库存,就在事务中重新预占并完成订单。
- 如果库存不足,把订单状态改为
REFUND_PENDING。 - 调用支付平台的退款接口。
- 使用稳定的幂等键发起退款请求。
- 定时对账,直到本地状态与支付平台一致。
发现异常后不要直接删除订单。订单和支付事件需要保留,以便退款、审计和对账。
降低热点商品对数据库的冲击
原子 SQL 可以防止超卖,但热门商品仍可能形成热点行,因此还要在入口层控制压力。
限流与排队
可以按以下维度限流:
- 单 IP
- 单账号
- 单设备或会话
- 单商品
- 整个下单接口
在极端抢购场景中,可以引入等待室或令牌队列,只让有限数量的请求进入下单服务。排队用于削减流量峰值,不能代替库存一致性检查。
缓存只做快速拒绝
Redis 可以缓存“疑似售罄”状态:
deal:123:sold_out = 1
它适合快速拒绝明显无效的请求,但不能作为唯一的库存依据。缓存丢失、主从延迟、故障切换或过期时间配置错误,都可能使多个请求同时通过。
即使 Redis 预扣成功,最终写入数据库时仍应使用条件更新或唯一约束。如果数据库操作失败,还要补偿 Redis 中的状态。
减少同步工作
下单请求只执行必要操作:
- 参数和身份验证
- 价格快照校验
- 库存预占
- 创建订单
- 创建支付会话
优惠券发放、通知、统计和推荐计算应异步处理。所有外部 API 调用都要设置连接超时和请求超时,并配置有限次数的重试及熔断策略。
保护数据库连接池
流量突然增加时,无限制增加 PHP 工作进程会进一步压垮 MySQL。应限制应用访问数据库的最大并发连接数,并在连接池耗尽时快速失败或把请求放入队列。
需要重点监控:
- 活跃连接数
- 锁等待时间
- 事务持续时间
- 死锁次数
- 下单接口延迟
- 预占成功率
- 支付回调延迟
- 超时释放数量
- 退款补偿积压
热点极高时的进一步方案
如果单个商品的并发量已经使同一数据库行成为瓶颈,可以把库存拆成多个桶。例如,把 10,000 件总库存拆成 100 个库存桶,每桶 100 件,请求通过哈希或随机方式选择库存桶。
库存分桶也会增加复杂度:
- 一个桶售罄后,需要继续尝试其他桶。
- 释放库存时,必须归还到原来的桶。
- 查询总剩余量时需要聚合多个桶。
- 库存调整和对账会更复杂。
因此,应先采用原子更新、入口限流和短事务。只有监控结果证明热点行确实已经成为瓶颈,才需要考虑库存分桶、专用库存服务或基于日志的串行处理。
消息队列本身也不能自动解决超卖。它可以按商品对库存命令分区,让同一商品的操作串行执行,但系统仍要处理消息重复、消费者重启、支付回调乱序和状态恢复。
必须建立对账机制
支付系统不能只依赖实时回调。需要定期查询支付平台或下载账单,并与本地订单进行核对:
- 平台显示已付款,本地仍是待支付。
- 本地显示已付款,平台却没有对应交易。
- 退款已经发起,但长时间没有完成。
- 同一订单存在多笔付款。
- 金额或币种不一致。
- 回调事件缺失或重复。
网络和消息系统经常会重复投递。严格意义上的“恰好一次”,通常无法通过一次接口调用实现。实际系统应采用:
- 至少一次投递
- 幂等消费
- 状态条件更新
- 唯一约束
- 失败重试
- 定期对账与补偿
实施时容易忽略的问题
- 页面上必须明确显示库存预占期限,但不能只依赖浏览器倒计时。
- 所有过期判断都应使用数据库或统一时间源,避免多台服务器之间出现时钟偏差。
- 订单中要保存成交时的商品名称、单价、税费和币种快照,回调时不能重新读取当前价格。
- 必须验证支付回调签名,并按照支付平台的要求防止重放。
- 不要信任浏览器提交的金额、商品价格或支付结果。
- 支付创建接口超时时,不能立即判定创建失败。应使用幂等键查询原请求的处理结果。
- 数据库死锁是正常的并发现象之一。对于短事务,可以进行有限次数且带抖动的重试。
- 商品售罄后,仍要保留少量容量处理支付回调、退款和查询请求,不能让抢购流量占满全部资源。
- 运营后台修改库存时,也必须经过同一套库存约束,不能直接覆盖计数。
- 定期校验
初始库存 = 可用库存 + 有效预占 + 已售库存,发现差异后立即告警。
这套架构不需要用一把大锁挡住所有购买者。每次库存变化都应是一次短暂、原子、可重试且可审计的状态转换。这样既能严格控制限量商品,也能将支付平台的不确定性隔离在库存事务之外。
备注:内容仅供参考。
