ThinkPHP8解决高并发扣库存:事务+锁的正确用法,别再踩坑了

2026-08-31 0 751

前几天上线了一个秒杀活动,刚放出去没到一分钟,用户就反馈说“明明看到有库存,点购买却提示已抢光”,还有人说“抢到了两个,扣了我三次款”。后台一看数据库,库存变成负数了。这肯定是并发下单时候没处理好,超卖了。

这个问题我一开始也以为很简单:用ThinkPHP8的事务,把库存检查、扣减、订单创建全部包起来不就行了?实际测下来并不是这样,还牵扯到锁的粒度、死锁、以及事务隔离级别。这篇文章就把自己踩过的坑和最终能落地的方案写出来。

先说最直觉的做法:事务包一切

很多人包括我一开始会这样写:

Db::transaction(function () use ($goodsId, $userId) {
    // 查库存
    $goods = Goods::find($goodsId);
    if ($goods->stock <= 0) {
        throw new Exception('库存不足');
    }

    // 扣库存
    $goods->stock = $goods->stock - 1;
    $goods->save();

    // 创建订单
    Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
});

看着好像没问题,操作都在一个事务里,要么全部成功,要么全部回滚。但实际一压测,还是超卖。因为两个请求同时读到库存都是1,然后都认为自己能扣,都去执行扣减,最后库存变成0,但两个订单都创建了。

原因在于事务默认的隔离级别下,普通SELECT不会加锁(MySQL默认是可重复读的MVCC),两个事务可以同时读到库存=1,然后各自更新,造成超卖。

所以光靠事务不够,还得加锁。

方案一:悲观锁(行锁)

所谓悲观锁,就是在读取库存的时候就把这一行锁住,直到事务结束才释放。这样其他事务只能等当前事务提交后才能再读。

在ThinkPHP8里,可以用 lock(true) 方法给查询加上排他锁:

Db::transaction(function () use ($goodsId, $userId) {
    // 加行锁查出商品
    $goods = Goods::where('id', $goodsId)->lock(true)->find();

    if (!$goods || $goods->stock <= 0) {
        throw new Exception('库存不足');
    }

    // 扣库存
    $goods->stock = $goods->stock - 1;
    $goods->save();

    // 创建订单
    Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
});

这里用了 lock(true),ThinkPHP会生成 SELECT ... FOR UPDATE。事务A锁住这条商品记录后,事务B再来执行同样的查询,就会被阻塞住,必须等A提交或回滚后才能继续读。这样库存就不会被两个事务同时扣了。

但是,悲观锁有个副作用:如果事务里干了太多别的事情(比如调远程接口、写日志),锁的持有时间就会很长,并发量一大,数据库连接容易被打满,接口响应也变慢。而且如果你用的是MySQL默认的索引,锁的是主键,效率挺高。但如果你的表结构没设计好,锁的是整张表,就别提性能了。

另外还要注意事务隔离级别,FOR UPDATE 生效的前提是当前事务没有提前提交。所以必须保证整个流程都在同一个事务里。如果中间你调了 Db::commit(),那锁就没了。

方案二:乐观锁(CAS)

悲观锁太保守,很多场景下我们其实可以用乐观锁。思路是不加锁,而是在更新的时候检查期望的库存值是否没变,如果没变就更新成功,变了就说明被别人改过,重试或者失败。

具体做法是在更新库存时加上条件 stock > 0 或者 stock = 之前查到的值。我用的是 stock > 0 的方式,因为只要判断库存没扣成负数就行。

Db::transaction(function () use ($goodsId, $userId) {
    // 先查一下当前库存(不加锁)
    $goods = Goods::find($goodsId);
    if (!$goods || $goods->stock <= 0) {
        throw new Exception('库存不足');
    }

    // 尝试扣减库存,条件必须是 stock > 0
    $affected = Goods::where('id', $goodsId)
        ->where('stock', '>', 0)
        ->dec('stock', 1)
        ->update();

    if (!$affected) {
        throw new Exception('库存不足');
    }

    // 创建订单
    Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
});

dec('stock', 1) 是ThinkPHP的原子递减方法,它最终生成的SQL是 UPDATE goods SET stock = stock - 1 WHERE id = ? AND stock > 0。这一步是原子操作,数据库行锁会保证并发时只有一个请求能成功执行,即使两个请求同时执行这条SQL,也只会有一个因为影响行数为1而成功,另一个影响行数为0,从而抛出“库存不足”。

这种方法不用手动加锁,也不需要持锁等待,性能比悲观锁好了不少。但有个前提:业务上扣库存可以允许部分失败。如果你还要同时校验其他条件(比如限购数量),就得把那些条件也放在UPDATE的WHERE子句里。

实际项目中的组合用法

我后来没有单独用某一种锁,而是结合了一下。因为单纯的乐观锁在极端情况下(比如库存还有20个,同时来50个请求)会有大量失败,用户体验不好。所以我在入口处用Redis做一个简单的计数限流,先挡掉大部分流量,再用乐观锁扣库存,最后下单。Redis的具体用处就是给每个商品设置一个“令牌桶”,但这个不是本文重点,跳过。

代码流程大概这样:

// 1. Redis预减库存
$redis = new Redis();
$redis->connect('127.0.0.1');
$res = $redis->hIncrBy('seckill_stock', $goodsId, -1);
if ($res < 0) {
    $redis->hIncrBy('seckill_stock', $goodsId, 1); // 加回去
    return '已抢光';
}

// 2. 数据库事务内扣减
try {
    Db::transaction(function () use ($goodsId, $userId) {
        $goods = Goods::find($goodsId);
        if (!$goods || $goods->stock <= 0) {
            throw new Exception('库存不足');
        }

        $affected = Goods::where('id', $goodsId)
            ->where('stock', '>', 0)
            ->dec('stock', 1)
            ->update();

        if (!$affected) {
            throw new Exception('库存不足');
        }

        Order::create(['goods_id' => $goodsId, 'user_id' => $userId]);
    });
} catch (Exception $e) {
    // 事务回滚后,Redis库存要加回来
    $redis->hIncrBy('seckill_stock', $goodsId, 1);
    return $e->getMessage();
}

// 3. 发送消息提醒、异步计算秒杀成功人数之类的,可以放在事务外面做
return '秒杀成功';

这里有一个注意点:Redis预减库存只用于挡住超高并发的请求,真正的库存扣减还是以MySQL为准。如果数据库扣减失败,必须把Redis里的值加回来。

坑:事务里用了锁后,千万别在里面做耗时的外呼

我第一次用悲观锁时,在事务里顺便调了一个赠送积分的外部接口,结果那个接口超时了5秒,导致事务一直不提交,后续请求全部堆积在行锁上。最后数据库连接池爆了,整个服务都卡死了。后来我把所有外部调用全部移到了事务外面,事务里只做库存扣减和订单插入,速度一下就上来了。所以用锁的时候务必保持事务尽量短。

还有一个容易忽略的点:索引

行锁要起作用,查询条件必须用到索引。如果 where('id', $goodsId) 没走主键索引,MySQL可能会升级成表锁,那样并发量一大性能就会很差。ThinkPHP里还要注意避免对字段做表达式运算,比如 where('id', (int)$goodsId) 倒是无所谓,但是 whereRaw('id + 1 = ?') 这种就没法走索引了。

另外如果你用的是悲观锁,一定要确认你的MySQL版本和引擎是InnoDB,MyISAM不支持事务和行锁,别搞混了。

最终压测结果

用上面乐观锁+Redis限流的方案,我本地用200个并发请求去抢10个库存商品,最终数据库里只有10个订单,库存也正确归零,没有再出现负数。平均响应时间从悲观锁的1.2秒降到200毫秒左右。虽然看起来还是有点慢,但比之前好多了。如果还想再快,可以把订单表做成异步队列,事务里只扣库存,订单数据放到MQ里慢慢消费,性能会更高。但那样会增加复杂度,看业务需求吧。

以上是我这次高并发扣库存的完整解决过程,没有什么高深理论,都是实操。希望能帮到同样在坑里的兄弟。

ThinkPHP8解决高并发扣库存:事务+锁的正确用法,别再踩坑了
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP8解决高并发扣库存:事务+锁的正确用法,别再踩坑了 https://www.taomawang.com/server/thinkphp/2674.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务