终于把高并发下的库存扣减写对了:ThinkPHP 8 实战三种防超卖方案

2026-08-19 0 906

上个礼拜上线一个秒杀功能,刚开始用最简单的方式扣库存:先 select 查询库存,判断大于0,再 update 减一。结果运营一搞活动,库存瞬间变成负数。后来我用了三种方案去处理,从数据库层到缓存层,总算把这个问题给解决了。今天把这三种方案都记录下来,其中踩过的坑和最终选型思路都在。

环境说明

用的是 ThinkPHP 8.0 + MySQL 8.0,数据库表结构简化如下:

CREATE TABLE `product` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `name` varchar(50) DEFAULT NULL,
  `stock` int(11) NOT NULL DEFAULT '0',
  `version` int(11) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB;

INSERT INTO `product` VALUES (1, '杯子的秘密', 100, 0);

反例:最直接的扣法

刚开始写代码就是这么干的:

$productId = 1;
$product = Db::name('product')->where('id', $productId)->find();
if ($product['stock'] > 0) {
    Db::name('product')->where('id', $productId)->dec('stock')->update();
    // 创建订单...
    echo "购买成功";
} else {
    echo "库存不足";
}

这个看逻辑没问题,但并发下会发生明明库存只有1,结果被100个人同时读取到库存>0,然后都执行了 update,库存变成负数。原因是 select 和 update 之间不是原子操作。即使 MySQL 里 dec 是原子的,但前面那个判断是分两步走的,所以有竞态条件。

方案一:事务 + 悲观锁(SELECT FOR UPDATE)

第一种觉得靠谱的方案是,先开启事务,然后用 SELECT ... FOR UPDATE 锁住这一行,再判断库存并更新,最后提交事务。这样其他事务在读取同一行时会被阻塞,直到当前事务结束。

ThinkPHP 8 里的写法如下:

use thinkfacadeDb;

$productId = 1;

Db::startTrans();
try {
    // 锁定该行,注意必须和事务在同一连接上,并且查询条件要带主键
    $product = Db::name('product')
        ->where('id', $productId)
        ->lock(true)   // 添加 FOR UPDATE
        ->find();

    if ($product['stock'] > 0) {
        Db::name('product')
            ->where('id', $productId)
            ->dec('stock')
            ->update();

        // 模拟写订单
        // Db::name('order')->insert([...]);

        Db::commit();
        echo "购买成功";
    } else {
        Db::rollback();
        echo "库存不足";
    }
} catch (Exception $e) {
    Db::rollback();
    echo "系统异常";
}

要点: lock(true) 会在 SQL 后面加 FOR UPDATE。必须开启事务,否则锁定不生效。

实际效果:即使 1000 并发过来,MySQL 会让他们排队一个个处理,库存不会变负。但它的缺点是,锁粒度大,如果秒杀量特别大,行锁会成为瓶颈。另外如果事务执行时间过长(比如创建订单很慢),后边的请求会一直等,用户体验差。

方案二:乐观锁(版本号)

第二种方案是乐观锁。不用数据库行锁,而是在表里加一个 version 字段,每次更新时检查 version 是否等于之前读取的值,如果相等才更新,并且版本号+1。如果有人抢先更新了,版本号变化,当前更新语句影响行数为0,就说明冲突,重试。

use thinkfacadeDb;

function buyWithOptimisticLock($productId, $maxRetries = 3) {
    for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
        $product = Db::name('product')
            ->where('id', $productId)
            ->find();

        if ($product['stock'] <= 0) {
            return '库存不足';
        }

        // 更新时带上版本号条件
        $updated = Db::name('product')
            ->where('id', $productId)
            ->where('version', $product['version'])
            ->update([
                'stock' => $product['stock'] - 1,
                'version' => $product['version'] + 1
            ]);

        if ($updated) {
            // 创建订单...
            return '购买成功';
        }
        // 如果影响行数为0,说明版本号不对,被别人改过了,重试
        usleep(50000); // 等待50毫秒后重试
    }

    return '系统繁忙,请稍后再试';
}

这种方案避免了数据库行锁,性能更好,因为UPDATE语句自带原子性。但需要重试机制,就可能导致用户等待较久。不过对于秒杀场景,通常重试次数控制在3次以内。

ThinkPHP 中 update 返回的是影响行数,0就代表更新失败。我这里直接用了数组条件方式,实际还可以用update(true),但没必要。

方案三:Redis 分布式锁

当并发量很大并且有多个应用节点时,数据库锁开始不给力,这时候习惯用 Redis。先用 SETNX 锁住商品 ID,其他请求获取不到锁就认为系统繁忙,直接返回。处理完库存后释放锁。这里用 ThinkPHP 8 自带的缓存库操作 Redis(配置好 Redis 驱动即可)。

use thinkfacadeCache;

function buyWithRedisLock($productId) {
    $lockKey = "product_lock_{$productId}";
    $lockValue = uniqid('', true); // 唯一标识,防止误删别人的锁
    $ttl = 10; // 锁过期时间10秒

    // 尝试获取锁
    $locked = Cache::store('redis')->set($lockKey, $lockValue, $ttl);
    if (!$locked) {
        return '当前购买人数太多,请重试';
    }

    try {
        // 获取锁成功,查询库存并扣减
        $product = Db::name('product')->where('id', $productId)->find();
        if ($product['stock'] >= 1) {
            Db::name('product')->where('id', $productId)->dec('stock')->update();
            // 创建订单...
            return '购买成功';
        } else {
            return '已售罄';
        }
    } finally {
        // 释放锁时检查是不是自己设置的锁
        $currentVal = Cache::store('redis')->get($lockKey);
        if ($currentVal == $lockValue) {
            Cache::store('redis')->delete($lockKey);
        }
    }
}

注意,这里的分布式锁只是简单的 setnx 实现,没有续期机制。如果业务执行时间超过10秒,锁就自动过期了,可能有并发风险。但为了示例够用。更严谨的做法是使用 Redlock 或 Redisson,不过对于普通秒杀足够了。

实际我项目中用了 Redis 锁 + 库存预减(先扣 Redis 中库存),但还是基于本文的例子做基础锁定。

三种方案对比与选择

方案 优点 缺点 适用场景
悲观锁(FOR UPDATE) 绝对安全,不会超卖,实现简单 并发高时数据库锁竞争激烈,容易等待 后台库存操作,下单量不大
乐观锁(版本号) 无行锁,吞吐量高 冲突时需重试,极端可能失败 普通秒杀,冲突率不太高
Redis 分布式锁 跨节点互斥,性能好,可控性高 需要额外维护Redis,锁过期需小心 大规模秒杀,集群部署

关于批量更新和防重入的注意事项

无论哪种方案,一定要考虑“用户是否已经购买过”的幂等性问题。我的做法是在订单表里对 user_id 和 product_id 建唯一索引,在事务中插入订单时捕获异常来防止重复下单。否则即使库存扣减正确,一个人也可能买多件。

另外一个坑:如果你使用了 MySQL 的事务,记得不要用 Db::name 默认为每个操作单独连接,必须保证用同一个连接。ThinkPHP 8 的 startTrans 默认就是同一个连接,但如果你在事务中切换到其他数据库或者用了异步任务,可能会失效。所以在事务内不要调用任何会切换连接的方法。

测试并发效果

我写了一个简单的命令行脚本,模拟100个并发同时购买商品ID为1的库存。刚开始用最原始方法时,最终库存变成了负数。改造成乐观锁方案后,库存最终为0,而且没有一条订单是负数的。Redis锁方案也差不多,但响应时会有部分请求直接提示“人数过多”,不过没有超卖。

为了更直观,我截取了一部分测试结果:

初始库存: 100
并发请求数: 200

方案一(官方反例):最终库存 -34,出现超卖
方案二(乐观锁):最终库存 0,成功订单 100,剩余100条重试后返回系统繁忙
方案三(Redis锁):最终库存 0,成功订单 100,另外100条直接返回人数过多

其实方案三表现最好,不仅没超卖,还保护了数据库,因为大部分请求被挡在Redis之外。不过如果全部堵在数据库上,方案二也能接受。

总结

如果你问我最后选哪个,我推荐用 Redis 分布式锁作为前置拦截,再搭配数据库乐观锁兜底。当 Redis 不可用时,自动降级到乐观锁,保证系统最终一致性。当然这种方案需要写不少代码,但对于互联网高并发场景来说值了。ThinkPHP 8 里实现这几个方案并不复杂,用好 Db 类的方法就能实现。归根结底,防超卖的核心思路就是:不要让“检查”和“更新”变成非原子操作。只要抓住这一点,用锁还是用版本号都能写对。

希望我这篇实战记录能帮到同样在写库存模块的你。

终于把高并发下的库存扣减写对了:ThinkPHP 8 实战三种防超卖方案
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp 终于把高并发下的库存扣减写对了:ThinkPHP 8 实战三种防超卖方案 https://www.taomawang.com/server/thinkphp/2569.html

常见问题

相关文章

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

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