本文目录导读:

- 核心痛点:超卖的本质是“检查与扣减”的非原子性
- 数据库层基础模型:告别单字段,拥抱“三表分离”
- 高并发扣减的三大主流方案
- 库存流水表:你的救命稻草
- 库存状态机:冻结、释放与回滚
- 常见面试问答(Q&A)
- 从单体到微服务的演进
** PHP商城项目库存设计终极指南:从超卖防重到高并发扣减的架构实战
目录导读
- 库存设计的核心痛点:为什么超卖是“技术债”而不是“bug”?
- 数据库层基础模型:库存字段的“三表分离”逻辑
- 高并发下扣减库存的三种主流方案(MySQL锁 / Redis预减 / 队列异步)
- 库存流水表:审计与对账的“黑匣子”
- 库存状态机:冻结、锁定、释放与回滚设计
- 常见面试问答(Q&A):PHP工程师必问的库存设计题
- 从单体到微服务的演进路线
在PHP商城项目的开发中,库存设计往往决定了系统的生死,一个“看似简单”的库存字段,如果处理不当,在大促场景下轻则产生负库存,重则导致资金损失,本文结合搜索引擎中关于“PHP库存设计”、“超卖解决方案”的高频经验,去伪存真,为你提炼出一套既能应对日常业务,又能扛住秒杀洪峰的实战设计指南。
核心痛点:超卖的本质是“检查与扣减”的非原子性
绝大多数PHP初学者的库存代码是:
$stock = SELECT stock FROM products WHERE id = 1;
if ($stock > 0) {
UPDATE products SET stock = stock - 1 WHERE id = 1;
}
这是典型的先查后改,在PHP-FPM多进程并发下,两个请求同时读到stock=1,都进入if判断,然后都执行更新,库存变成-1,这就是超卖。方案:必须将“检查库存”和“扣减库存”合并为一条原子SQL,正确的第一步设计如下:
UPDATE products SET stock = stock - 1 WHERE id = 1 AND stock > 0; // 通过 affected_rows 判断是否扣减成功(1成功,0失败)
但这只解决了最基础的并发问题,无法应对“预扣库存后未支付释放”等复杂业务。
数据库层基础模型:告别单字段,拥抱“三表分离”
不要把库存只放在products表里,成熟的PHP商城设计,至少需要三张核心表:
- 商品表(SKU表):存储
sku_id、price,但不直接存剩余库存,只存一个total_stock(总入库量)作为参考。 - 库存流水表(stock_log):记录每一次库存变动(IN/OUT/FREEZE/UNFREEZE),这是审计与对账的“黑匣子”。
- 实时库存表(inventory):核心字段为
sku_id、available_stock(可用库存)、frozen_stock(冻结库存,如待支付锁定)、sold_stock(已售)。
关键逻辑:下单时,如果开启“支付前锁定库存”,则扣减available_stock并增加frozen_stock;支付成功后,扣减frozen_stock并增加sold_stock;超时未支付,则反向操作释放冻结库存。
高并发扣减的三大主流方案
方案A:MySQL悲观锁(FOR UPDATE) —— 适合中小流量
在事务中执行SELECT * FROM inventory WHERE sku_id = ? FOR UPDATE,行锁会阻塞其他请求,简单可靠,但吞吐量极低,秒杀场景直接压垮数据库。
方案B:Redis预减库存(Lua脚本保证原子性) —— 适合大流量PHP商城 这是目前PHP商城的主流做法,将库存预加载到Redis中,扣减操作在Redis内完成,完全绕开数据库锁。
-- 伪Lua脚本,保证原子操作
local stock = tonumber(redis.call('get', KEYS[1]))
if stock > 0 then
redis.call('decrby', KEYS[1], ARGV[1])
return 1
end
return 0
注意:Redis扣减成功不等于订单生成成功,Redis是用来“挡流量”的令牌桶,真正的数据库最终扣减需要通过异步队列(如RabbitMQ或Redis Stream)去消费,并在数据库中使用WHERE stock > 0兜底。
方案C:队列串行化 —— 适合强一致性场景 将用户请求包装成任务放入队列,由单消费者进程顺序处理数据库扣减,杜绝并发,代码简单,但响应延迟较高,用户会看到“排队中”。
库存流水表:你的救命稻草
任何一次增减操作,都必须写入stock_log表,字段包含:id、sku_id、order_sn、change_type(1下单冻结,2支付扣减,3取消释放,4人工调整)、change_amount(正负值)、before_stock、after_stock、create_time。
实战价值:当出现库存不平(Redis与数据库不一致)时,通过流水表可以回放所有操作找出问题节点,PHP后端开发者一定要在Service层封装库存操作类,强制所有代码走StockService::freeze($skuId, $qty, $orderSn)接口,禁止直接写SQL。
库存状态机:冻结、释放与回滚
设计一个严谨的状态流转图:
- 下单:
available - qty,frozen + qty。 - 支付回调:
frozen - qty,sold + qty,事务提交后删除Redis缓存key。 - 超时取消/用户取消:
frozen - qty,available + qty(需要判断是否已发货)。 - 发货后退款:
sold - qty,total_sold标记为退货状态,不可加回available_stock(防止刷单)。
PHP定时任务:建议写一个Shell脚本(crontab)每分钟扫描“已下单但未支付且超过15分钟”的订单,调用释放接口,释放接口同样要加幂等校验,防止因网络超时重复释放。
常见面试问答(Q&A)
Q1:Redis库存减成功了,但数据库更新失败怎么办?
A:这是分布式事务问题,采用最终一致性方案:Redis预减成功即放行,将订单消息发送到队列,消费者拿到消息后,先查询订单状态,若未处理,执行数据库扣减(仍然使用atomic UPDATE判断影响行数),若数据库扣减失败(说明Redis与DB数据不同步),则发送延迟队列进行补偿或人工介入。
Q2:为什么不能只靠数据库的UPDATE ... SET stock = stock - 1 WHERE stock > 0?
A:该方案只能解决“不超卖”的问题,无法解决性能瓶颈,在高并发下,每秒上万次行锁等待会让MySQL的CPU飙升,CPU被打满后,其他业务(如用户登录)也会被拖垮,必须使用Redis做前置削峰,用数据库做最终落盘。
Q3:用户下单冻结了库存,但支付时发现已售罄(因为冻结库存被释放了)是否合理? A:合理,这叫超时释放,必须提醒用户“库存有限,超时未支付订单将被自动取消并释放库存”,商品详情页应展示“实时库存”,而非SQL查出来的历史库存。
从单体到微服务的演进
对于绝大多数PHP商城项目(基于ThinkPHP或Laravel),推荐架构如下:
- 应用层:使用Redis + Lua脚本做库存预减(挡在前面)。
- 队列层:PHP异步任务(如Redis List做简单队列)消费下单请求。
- 数据层:MySQL使用
UPDATE ... WHERE stock > 0作为防超卖兜底,并写入库存流水表。 - 补偿层:定时任务扫描待支付订单,调用
StockService::release()释放冻结库存。
不要过度设计,如果日订单量少于1万,直接用MySQL REPEATABLE READ隔离级别下的SELECT ... FOR UPDATE + 原子更新就足够,如果要做秒杀,才需要引入Redis,先保证“不超卖”,再考虑“不丢单”,最后优化“让用户感觉不卡”。
最后送一句实践真言:库存设计不是纯技术问题,而是业务补偿机制和数据一致性取舍的艺术,请务必在PHP代码中为所有的库存增减操作打上monolog日志,因为生产环境中的库存问题,99%靠流水日志排查出来。