Laravel高并发防超卖必须用DB::update()原子SQL或Redis LPOP预减库存,禁用Model->save()因“查+改”分离必然导致超卖;DB::update()需带stock>=?条件并判影响行数,Redis须用LPOP而非GET+SET避免毫秒级竞态。

高并发秒杀场景下,库存超卖不是“会不会发生”的问题,而是“在哪一步崩掉”的问题——DB::update() 原子扣减或 Redis::lpop() 队列令牌,二者选其一,别混用,也别绕开。
为什么 Model->save() 一定会超卖
因为 Eloquent 的 save() 是典型的“查+改”两步分离:先 Product::find($id) 拿到 stock = 1,再 $product->decrement('stock'),最后 save()。两个请求几乎同时走完这套流程,数据库里 stock 就变成 -1。
- 哪怕加了事务,只要没显式锁行(如
SELECT ... FOR UPDATE),照样超卖 -
lockForUpdate()必须在事务开启后、第一次查询时就执行,且 WHERE 条件必须命中主键或唯一索引,否则升级成表锁 - Eloquent 事件(如
saving)无法替代原子 SQL,它发生在save()阶段,已经晚了
DB::update() 带条件扣减怎么写才安全
核心是把“够不够”和“扣不扣”压进一条语句,由 MySQL 引擎保证原子性。不要用 whereRaw('stock > 0') 这种模糊判断,必须精确到业务数量。
- 正确写法:
DB::update('UPDATE products SET stock = stock - ? WHERE id = ? AND stock >= ?', [$quantity, $id, $quantity]) - 失败判断只看影响行数:
if (DB::affectingStatement() === 0) { // 库存不足 } - 别在事务里先
select再update,除非你真需要FOR UPDATE后做复杂校验(比如风控回调) - 这条 SQL 不触发 Eloquent 访问器、类型转换、事件,副作用最小,性能也最稳
Redis 预减库存为什么必须用 LPOP 而不是 GET+SET
因为 GET 和 SET 是两次独立命令,中间有毫秒级间隙,100 个并发请求全读到 stock = 9,全通过判断,最后写入 100 条——超卖翻倍。
- 预热时一次性填充:
Redis::lpush('stock:1001', array_fill(0, 100, 1)) - 抢购只用一行:
$token = Redis::lpop('stock:1001');,返回false即售罄 -
LLEN只能用于监控或兜底,绝不能用作业务分支判断依据 - 如果要支持“扣减 N 个”,得用 Lua 脚本封装
DECRBY+ 判断,不能靠客户端 if
事务重试不是防超卖的解药,只是缓震器
DB::transaction() 默认死锁重试 5 次,但它解决不了逻辑层竞争——比如两个请求都查到库存充足,然后都去执行 DB::update(),其中一条必然失败并回滚,但失败前已消耗资源、触发日志、可能调用外部服务。
- 重试掩盖的是死锁,不是超卖;真正防超卖靠的是原子操作本身
- 如果你的扣减逻辑涉及外部 API(如风控)、用户等级判断、生成单号等,就必须用
SELECT ... FOR UPDATE锁住行,并确保锁持有时间最短 - 锁等待超时由 MySQL 的
innodb_lock_wait_timeout控制(默认 50 秒),不是 Laravel 或 PDO 的 timeout 设置
真正难的不是写对某一行代码,而是判断当前业务是否允许“预扣即成功”——比如用户下单后需支付才最终落库,那 Redis LPOP 就是第一道防线;但如果要求“下单即锁库存”,就得用 DB::update() 或 FOR UPDATE,且必须接受失败率。选错路径,压测时第一个崩的就是库存数字。


















