首页 / 资讯中心 / 文章详情

老项目库存超卖陷阱:一招修复并发漏洞

老项目库存超卖陷阱:一招修复并发漏洞 ★ FEATURED ARTICLE
上周帮朋友排查一个线上问题商城后台显示库存还剩 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()这类组合看看有没有类似的写法。改动量不大但能省掉后面很多对账的麻烦。最后提醒一句修完之后一定要用并发脚本压一遍别只跑单元测试。竞态这种东西只有真的撞上了才算验证过。
阅读完成 · 觉得有帮助?
咨询建站