PHP 商城高并发抢购:库存数量与锁控制全套方案
抢购核心痛点:超卖(库存负数)、少卖、并发争抢、DB 压力大、流量打垮 MySQL;PHP 是同步阻塞 FPM 模型,不适合大量并发直接压数据库,必须前置拦截 + 锁 + 库存校验。
核心目标:1)杜绝超卖;2)尽量高性能;3)防止重复下单;4)控制数据库压力。
业务前提:库存分两种:数据库真实库存(最终权威)、Redis 缓存库存(抗压)。
前置基础问题说明
- PHP-FPM:每个请求一个进程,无共享内存,不能用进程内变量做锁 / 计数;跨请求共享状态依赖 Redis、MySQL。
- 不建议直接:
select stock from goods where id=xxx,再update stock=stock‑1;高并发会超卖。 - 超卖根源:查询库存与扣减库存分成两条 SQL,并发下时间窗口产生竞态条件。
策略一:数据库层方案(无中间件,中小并发)
适合:并发不高,无 Redis,兜底方案。
1.1 数据库乐观锁(版本号机制)
原理:不加排他锁,通过版本号判断是否被别人修改过,冲突则返回失败。
-- 商品表增加 version 版本字段
-- 查询
SELECT stock,version FROM goods WHERE id=100;
-- 扣减,版本匹配才扣,同时库存>0
UPDATE goods
SET stock=stock‑1, version=version+1
WHERE id=100 AND version=? AND stock > 0;
php 代码判断影响行数 rowCount(),等于 0 代表扣减失败(库存不足 / 版本冲突)。
优点:无阻塞,死锁概率低;
缺点:高并发大量请求更新返回 0,大量失败请求打 DB;适合并发几百以内;大量抢购会大量冲突,CPU 高。
1.2 数据库悲观锁(行锁 for update)
InnoDB 行锁,
for update会锁住该行,其他请求阻塞等待锁释放。
begin;
-- 排他行锁,锁住这一行
SELECT stock FROM goods WHERE id=100 FOR UPDATE;
if(stock <=0 ){
rollback; return 库存不足;
}
update goods set stock=stock‑1 where id=100;
commit;
⚠️注意:必须在事务内;id 必须走索引,否则退化成表锁,整个表卡死!
优点:严格防止超卖;
缺点:高并发大量请求排队阻塞,数据库连接占满,PHP FPM 进程耗尽,出现超时;并发上千基本扛不住;容易死锁。
❗不建议直接用在大流量抢购,只适合低并发场景。
1.3 数据库原生原子扣减(最推荐 DB 兜底写法)
单条 update 原子操作,where 自带 stock>0,不需要 select 先查库存!
UPDATE goods SET stock = stock‑1 WHERE id=100 AND stock > 0;
InnoDB 这条 SQL 是原子行锁;多个并发过来,数据库内部串行执行;
rowCount()>0代表扣库存成功;0 代表库存不足。
✅最佳实践:不要先 select 再 update;直接原子 update 扣减。
缺点:大流量下 MySQL 压力巨大,QPS 上限有限,大量请求打到 DB 会雪崩;所以需要 Redis 做流量削峰。
数据库层面共同短板:MySQL 扛不住瞬时万级并发,大抢购必须引入 Redis。
策略二:Redis 方案(主流 PHP 抢购方案,高并发)
Redis 单线程模型,命令原子,非常适合计数、限流、分布式锁;Redis 库存预扣,DB 做最终落地。
风险点:Redis 和 DB 数据一致性问题;Redis 宕机丢失库存;要做好兜底校验。
2.1 Redis DECR/DECRBY 原子计数(简单抢购,有坑)
// 初始化:抢购开始前,把商品库存写入redis
$redis->set("goods:stock:100", $dbRealStock);
// 每个用户请求
$stock = $redis->decr("goods:stock:100");
if($stock < 0){
// 库存不足,恢复,返回抢购失败
$redis->incr("goods:stock:100");
return "抢购失败,已售罄";
}
// redis扣减成功,再去创建订单,扣数据库真实库存
⚠️严重坑点
- Redis 扣库存成功,但是 DB 下单失败(网络异常、业务异常)→ Redis 库存少扣,DB 库存没扣,库存对不上,少卖。
- 重复请求:同一用户多次提交,会多次扣 Redis。
只适合简单场景;不能直接拿 decr 结果作为最终库存,DB 必须再次校验扣减。
2.2 Redis Lua 脚本(推荐!原子复杂逻辑)
Lua 脚本在 Redis 中原子执行,多条逻辑不会被其他命令打断;可以同时做库存判断 + 扣库存 + 判断用户是否已抢购过(防重复下单)。
示例 Lua 脚本:
-- KEYS[1] = goods:stock:100
-- KEYS[2] = goods:user:buy:100 已购买用户集合
-- ARGV[1] = userId
local stock = tonumber(redis.call('get',KEYS[1]))
if not stock or stock <= 0 then
return 0 -- 库存不足
end
-- 判断用户是否已经抢购过
if redis.call('sismember',KEYS[2],ARGV[1]) ==1 then
return -1 -- 重复抢购
end
redis.call('decr',KEYS[1])
redis.call('sadd',KEYS[2],ARGV[1])
return 1 -- 抢购资格获取成功
PHP 调用 eval 执行 lua;返回 1 代表拿到抢购资格,此时再走 PHP 业务逻辑,生成订单,扣数据库库存。
✔优点:整个逻辑原子,防超卖、防用户重复抢;网络只一次往返。
⚠️注意:拿到资格≠下单成功;拿到资格后依然有可能 DB 失败,需要补偿回滚 Redis 库存。
补偿方案:拿到资格下单失败,归还 Redis 库存
下单 DB 失败时执行:
// 订单创建失败,归还库存,移除已购买标记
$redis->incr("goods:stock:100");
$redis->srem("goods:user:buy:100",$userId);
2.3 Redis 分布式锁方案(Redlock/SETNX 锁)
适用场景:不想 lua 脚本,PHP 业务层控制,对商品资源加锁,同一商品同一时间只有一个请求处理。
// 获取锁
$lockKey = "lock:goods:100";
$ok = $redis->set($lockKey, 1, ['nx','ex'=>3]); // nx不存在才设置,过期时间3秒防死锁
if(!$ok){
return "抢购繁忙,请重试";
}
try{
// 查询redis库存,业务判断,调用数据库扣库存
}finally{
// 释放锁
$redis->del($lockKey);
}
注意:简单 setnx 不是 Redlock;单机 Redis 够用;集群要 Redlock;
缺点:锁会带来排队,吞吐量下降;锁过期要小心业务还没执行完锁就释放。
对比:抢购业务优先用 Lua 脚本做资格预扣,而不是分布式锁;锁适合资源竞争,不适合计数扣库存。
策略三:完整分层架构(生产 PHP 商城高并发抢购架构)
分层拦截,流量逐层过滤,不要让所有请求到达 MySQL
- 前端层:按钮置灰,防抖,限制频繁点击;
- 网关 / Nginx 层:限流 IP,频率控制,黑名单;
- PHP 前置:用户校验、活动时间校验;
- Redis 层:Lua 脚本预扣库存、防重复抢购,过滤 99% 无效请求;拿到抢购资格才往下走;
- 消息队列(Redis List / RabbitMQ) ✨关键
拿到抢购资格后,不直接同步创建订单,丢入异步队列,PHP 立刻返回 “排队中”;消费者进程消费队列,真正操作数据库生成订单扣库存。
为什么队列?PHP‑FPM 同步处理下单,如果大量请求同步写 DB,FPM 进程直接占满雪崩;队列削峰,把瞬时大流量变成平缓消费。
队列模式流程
- 用户请求 PHP;时间、用户校验;
- Redis‑Lua:校验库存,校验用户是否抢过;成功,把
goodsId,userId推入队列;返回用户:正在排队; - 后端消费脚本(PHP CLI 常驻进程)消费队列;
- 消费者执行数据库原子扣库存
update stock set stock‑1 where id=xx and stock>0; - 数据库扣成功,生成订单;扣失败(DB 库存不足),标记抢购失败;
- 用户轮询接口查询抢购结果。
风险点:队列堆积、消息丢失,要做 ack、重试、死信队列。
库存数据一致性问题(Redis <-> MySQL)
- 同步策略:活动开始把 DB 库存全量加载到 Redis;
- 定时兜底:定时任务对比 Redis 库存和 DB 库存,发现差异修复;
- 最终以数据库库存为权威;Redis 只是预过滤;即使 Redis 显示有库存,DB 扣减 rowCount=0 就代表售罄。
- 订单超时未支付:需要回补 Redis 库存 + 数据库库存。
订单超时回补逻辑:用户抢到资格,下单未付款,到期关闭订单,DB 库存 + 1,Redis 库存 + 1。
各方案对比选型表
表格
| 方案 | 适用并发 | 防超卖 | 性能 | 缺点 |
|---|---|---|---|---|
| DB 乐观锁 version | 几百 | 是 | 中 | 高并发大量冲突,DB 压力大 |
| DB 悲观锁 for update | 几百 | 是 | 差 | 行锁阻塞,容易耗尽连接 |
| DB 原子 update stock‑1 where stock>0 | 千级 | 是 | 中 | 所有请求压 MySQL,大抢购扛不住 |
| Redis DECR 简单扣减 | 万级 | 需 DB 兜底 | 高 | 容易出现库存不一致,少卖 |
| Redis Lua 脚本预扣资格 | 万级 | 强(配合 DB 校验) | 很高 | 需要处理资格拿到但下单失败补偿 |
| Redis Lua + 消息队列 | 十万级 | 强 | 最高 | 架构复杂,要处理消息、轮询结果 |
✅PHP 商城抢购生产推荐组合:
Nginx 限流 + Redis‑Lua 脚本预校验资格 + Redis 消息队列异步下单 + MySQL 原子 update 扣真实库存 + 定时库存兜底 + 订单超时回补库存
高频踩坑清单(PHP 特别注意)
- ❌PHP FPM 进程变量存库存:进程隔离,完全无效。
- ❌先 select 查询库存再 update 扣减:不加锁一定会超卖。
- ❌Redis 预扣成功就直接判定抢购成功,不校验 DB;会出现 Redis 扣成功,DB 失败导致少卖。
- ❌没有限制同一用户重复抢购;同一用户刷接口抢多单。(Lua SSet 集合记录已抢用户)
- ❌队列丢失消息;拿到资格消息丢了,用户永远不会生成订单。
- ❌订单超时未支付,忘记回补库存,库存不会释放。
- ❌Redis 宕机,缓存库存丢失;兜底:数据库永远作为唯一可信源。
- ❌for update 没走索引,变成表锁,整个商品表全部锁住,整个商城卡死。
伪代码简化示例(PHP 核心逻辑)
//1.基础校验:活动是否开始,用户登录
//2.执行lua脚本获取抢购资格
$lua = <<<LUA
--上面lua脚本
LUA;
$res = $redis->eval($lua, [$stockKey,$userBuyKey],[$userId]);
if($res ==0) return json(['code'=>-1,'msg'=>'库存不足']);
if($res ==-1) return json(['code'=>-2,'msg'=>'请勿重复抢购']);
//拿到资格,投递到消息队列,直接返回排队
$redis->lPush('queue:seckill_order', json_encode(['goods_id'=>$goodsId,'user_id'=>$userId]));
return json(['code'=>0,'msg'=>'抢购排队中,请稍后查询结果']);
//CLI消费脚本:循环读取队列,操作数据库
while(true){
$data = $redis->bPop('queue:seckill_order',0);
$info = json_decode($data,true);
//数据库原子扣库存
$stmt = $pdo->exec("update goods set stock=stock‑1 where id={$info['goods_id']} and stock>0");
if($stmt>0){
//生成订单
}else{
//DB库存不足,补偿redis库存
$redis->incr($stockKey);
$redis->srem($userBuyKey,$info['user_id']);
}
}
如果你需要,湖南分云网络科技有限公司可以给你小程序开发,app开发,网站开发,系统定制开发:
1)完整可运行 PHP demo(lua + 队列);
2)Nginx 限流配置;
3)订单超时回补库存逻辑代码。

浙公网安备 33010602011771号