上周帮朋友排查一个线上问题,商城后台显示库存还剩 3 件,但同一时间有 5 个订单都支付成功了。翻日志翻了两个小时,最后定位到一段看起来毫无问题的代码——一个老 Laravel 项目里的库存扣减逻辑。说实话,这种坑我见过不止一次了,而且踩的人远比想象中多。
这篇文章就把这个"静默陷阱"拆开讲讲,包括它为什么会出现、怎么复现、以及几种不同场景下的修法。如果你手上也有跑了两三年的 Laravel 项目,建议对照着看一眼。
一、问题现场:一段"看起来没问题"的代码
先看最常见的写法,很多老项目里都是这个套路:
public function deduct($productId, $quantity) { $product = Product::find($productId); if ($product->stock < $quantity) { throw new \Exception('库存不足'); } $product->stock = $product->stock - $quantity; $product->save(); return true; }单线程跑测试,一点毛病没有。但线上是并发环境,两个请求几乎同时进来,都执行了find(),都读到stock = 3,都判断 3 >= 1 通过,然后各自save()写回 2。结果就是卖了 2 件,库存只减了 1 件。
更麻烦的是,这段代码不会报错,不会抛异常,日志里干干净净。等你发现的时候,往往已经是财务对账对不上了。
二、为什么说它是"静默"的
我总结下来有三个原因,让这个坑特别难被发现:
- 测试环境压不出来:本地或者测试环境并发量低,两个请求撞上的概率极小,跑一百次都是对的。
- 没有异常信号:Eloquent 的
save()是"后写覆盖",它不关心你读到的值是不是最新的,所以不会报错。 - 问题滞后暴露:库存对不上通常要等到盘点或者用户投诉才被发现,中间可能已经过去好几天。
换句话说,这是一个"正确性"问题,而不是"可用性"问题。系统一直好好的,只是数据悄悄错了。
三、复现一下,眼见为实
想验证其实很简单,用 Laravel 自带的任务队列或者直接开多进程压一下就行。下面这个脚本可以快速复现:
// routes/web.php 里临时加一个测试路由 Route::get('/test-stock', function () { $product = Product::find(1); $product->stock = 100; $product->save(); $barrier = new \GuzzleHttp\Promise\Utils(); // 仅示意,实际可用 curl_multi // 用 20 个并发请求同时扣减 $urls = array_fill(0, 20, url('/deduct/1')); $mh = curl_multi_init(); $handles = []; foreach ($urls as $url) { $ch = curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_multi_add_handle($mh, $ch); $handles[] = $ch; } do { curl_multi_exec($mh, $running); curl_multi_select($mh); } while ($running > 0); return Product::find(1)->stock; // 大概率不是 80 });跑完你会发现,库存减掉的数量明显少于 20。这就是竞态在真实环境里的样子。
四、几种修法,按项目情况选
方案一:数据库原子操作(推荐)
最省事也最可靠的做法,是把"读-判断-写"合并成一条 SQL,交给数据库保证原子性:
public function deduct($productId, $quantity) { $affected = DB::table('products') ->where('id', $productId) ->where('stock', '>=', $quantity) ->decrement('stock', $quantity); if ($affected === 0) { throw new \Exception('库存不足'); } return true; }注意这里用decrement而不是先查再改,条件写在where里,数据库会在行锁的保护下完成判断和更新。返回的影响行数为 0 就说明库存不够,逻辑非常干净。
方案二:悲观锁
如果扣减逻辑比较复杂,比如还要写流水、算积分,那可以用lockForUpdate把整行锁住:
DB::transaction(function () use ($productId, $quantity) { $product = Product::where('id', $productId)->lockForUpdate()->first(); if ($product->stock < $quantity) { throw new \Exception('库存不足'); } $product->stock -= $quantity; $product->save(); // 这里可以继续写库存流水 StockLog::create([ 'product_id' => $productId, 'change' => -$quantity, ]); });要注意lockForUpdate必须放在事务里才有效,而且锁的粒度别太大,不然高并发下容易排队。
方案三:乐观锁
如果冲突概率不高,乐观锁的性能更好。给表加一个version字段:
$affected = DB::table('products') ->where('id', $productId) ->where('version', $version) ->where('stock', '>=', $quantity) ->update([ 'stock' => DB::raw('stock - ' . (int) $quantity), 'version' => $version + 1, ]); if ($affected === 0) { // 版本冲突或库存不足,重试或直接失败 }这种写法适合秒杀之外的普通商品,冲突少,重试成本低。
五、几个容易忽略的细节
修完主逻辑,还有几个地方值得顺手检查一下:
- 缓存里的库存:很多项目会在 Redis 里存一份库存做前置拦截,记得和数据库的扣减保持一致,别只改一边。
- 队列任务重复消费:如果扣减是异步的,要保证幂等,否则重试一次就多扣一次。
- 事务隔离级别:MySQL 默认是可重复读,某些场景下
select读到的可能是快照数据,配合锁使用时要留意。 - 超卖兜底:即使逻辑写对了,也建议加一个定时任务做库存校准,作为最后一道防线。
六、写在最后
这类问题的本质,是把并发场景当成了串行场景来写代码。Laravel 的 Eloquent 用起来太顺手了,find加save两行就搞定,反而容易让人忘记底下还有并发这回事。
如果你正在维护一个老项目,不妨全局搜一下->stock、->save()这类组合,看看有没有类似的写法。改动量不大,但能省掉后面很多对账的麻烦。
最后提醒一句:修完之后一定要用并发脚本压一遍,别只跑单元测试。竞态这种东西,只有真的撞上了才算验证过。