PHP 商城高并发抢购:库存数量与锁控制全套方案

抢购核心痛点:超卖(库存负数)、少卖、并发争抢、DB 压力大、流量打垮 MySQL;PHP 是同步阻塞 FPM 模型,不适合大量并发直接压数据库,必须前置拦截 + 锁 + 库存校验
核心目标:1)杜绝超卖;2)尽量高性能;3)防止重复下单;4)控制数据库压力。

业务前提:库存分两种:数据库真实库存(最终权威)、Redis 缓存库存(抗压)

前置基础问题说明

  1. PHP-FPM:每个请求一个进程,无共享内存,不能用进程内变量做锁 / 计数;跨请求共享状态依赖 Redis、MySQL。
  2. 不建议直接:select stock from goods where id=xxx,再update stock=stock‑1;高并发会超卖。
  3. 超卖根源:查询库存与扣减库存分成两条 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扣减成功,再去创建订单,扣数据库真实库存

⚠️严重坑点

  1. Redis 扣库存成功,但是 DB 下单失败(网络异常、业务异常)→ Redis 库存少扣,DB 库存没扣,库存对不上,少卖
  2. 重复请求:同一用户多次提交,会多次扣 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

  1. 前端层:按钮置灰,防抖,限制频繁点击;
  2. 网关 / Nginx 层:限流 IP,频率控制,黑名单;
  3. PHP 前置:用户校验、活动时间校验;
  4. Redis 层:Lua 脚本预扣库存、防重复抢购,过滤 99% 无效请求;拿到抢购资格才往下走;
  5. 消息队列(Redis List / RabbitMQ) ✨关键

拿到抢购资格后,不直接同步创建订单,丢入异步队列,PHP 立刻返回 “排队中”;消费者进程消费队列,真正操作数据库生成订单扣库存。

为什么队列?PHP‑FPM 同步处理下单,如果大量请求同步写 DB,FPM 进程直接占满雪崩;队列削峰,把瞬时大流量变成平缓消费。

队列模式流程

  1. 用户请求 PHP;时间、用户校验;
  2. Redis‑Lua:校验库存,校验用户是否抢过;成功,把 goodsId,userId 推入队列;返回用户:正在排队;
  3. 后端消费脚本(PHP CLI 常驻进程)消费队列;
  4. 消费者执行数据库原子扣库存 update stock set stock‑1 where id=xx and stock>0
  5. 数据库扣成功,生成订单;扣失败(DB 库存不足),标记抢购失败;
  6. 用户轮询接口查询抢购结果。

风险点:队列堆积、消息丢失,要做 ack、重试、死信队列。

库存数据一致性问题(Redis <-> MySQL)

  1. 同步策略:活动开始把 DB 库存全量加载到 Redis;
  2. 定时兜底:定时任务对比 Redis 库存和 DB 库存,发现差异修复;
  3. 最终以数据库库存为权威;Redis 只是预过滤;即使 Redis 显示有库存,DB 扣减 rowCount=0 就代表售罄。
  4. 订单超时未支付:需要回补 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 特别注意)

  1. ❌PHP FPM 进程变量存库存:进程隔离,完全无效。
  2. ❌先 select 查询库存再 update 扣减:不加锁一定会超卖。
  3. ❌Redis 预扣成功就直接判定抢购成功,不校验 DB;会出现 Redis 扣成功,DB 失败导致少卖。
  4. ❌没有限制同一用户重复抢购;同一用户刷接口抢多单。(Lua SSet 集合记录已抢用户)
  5. ❌队列丢失消息;拿到资格消息丢了,用户永远不会生成订单。
  6. ❌订单超时未支付,忘记回补库存,库存不会释放。
  7. ❌Redis 宕机,缓存库存丢失;兜底:数据库永远作为唯一可信源。
  8. ❌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)订单超时回补库存逻辑代码。

posted @ 2026-08-21 16:16  长沙小程序开发  阅读(1)  评论(0)    收藏  举报