ThinkPHP8 实战:避免超卖,用乐观锁和Redis原子操作重构秒杀库存扣减

2026-08-03 0 718

刚接手秒杀项目的时候,我看了一眼原来的库存扣减代码,差点没缓过来。就是一个简单的 update 语句,把库存字段减一,没有任何并发保护。结果可想而知,活动上线还没分钟,运营那边就炸了,商品卖出了好几倍库存。后来花了三天时间重写了整个库存扣减逻辑,才把问题彻底按下去。今天这篇不说空话,直接给你看我踩过的坑和最终的完整方案,后端是 ThinkPHP 8,数据库是 MySQL,缓存用的 Redis

一、最原始的写法:查出来再减

最早的实现大概是这样的:

$product = Product::find($id);
if ($product->stock > 0) {
    $product->stock -= 1;
    $product->save();
    // 创建订单...
}

这段代码在单用户访问时问题不大,但并发一旦上来,两个请求同时读到库存为 1,一个减了,另一个也减了,数据库里库存变成 0,但是两个订单都创建成功。超卖就是从这里开始的。

我当时也上网查资料,很多文章说用事务,改成这样:

Db::transaction(function () use ($productId) {
    $product = Product::lock(true)->find($productId);
    if ($product->stock > 0) {
        $product->stock -= 1;
        $product->save();
        // 创建订单...
    }
});

加了个 lock(true) 悲观锁,确实能拦住并发,让每个请求排队读。但是牺牲了性能,秒杀场景下所有请求都挤在一个行锁上,数据库CPU立刻飙高。而且如果事务里还有外部调用,锁持有时间一长,后面直接排队到超时。这方案只适合低并发的后台系统,不适合秒杀。

二、用 UPDATE 条件判断:一个语句解决

后来我改成了一条 SQL 原子操作:

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

if ($affected) {
    // 减库存成功,创建订单
} else {
    // 库存不足或商品不存在
}

这个方法干净多了。MySQL 在执行 UPDATE product SET stock = stock - 1 WHERE id = ? AND stock > 0 时,会锁住那一行,并且条件判断是原子性的。同一时刻只有一个请求能匹配到 stock > 0,其余请求 affected_rows 为 0,直接返回失败。

这个方案在性能上比悲观锁好很多,但它仍然是直接操作 MySQL。如果秒杀瞬间的请求量太大,每秒几万次直接打数据库,MySQL 还是会成为瓶颈。另外,库存操作和后续的下单逻辑不在一个事务里,如果创建订单失败,库存已经扣了,还得想办法回补。所以我的最终方案选择了“Redis 预扣库存 + 异步订单处理”的路子。

三、Redis 扣减:把压力挡在数据库外面

Redis 的 DECREVAL 是原子操作,非常适合做库存扣减。我的设计思路很简单:

  1. 活动开始前把商品总库存写入 Redis,比如 SET product:stock:1001 500
  2. 用户发起秒杀请求时,先调用 Redis 的 Lua 脚本原子地扣减库存并判断剩余量。
  3. 扣减成功才允许创建订单,订单创建走后端队列异步处理。
  4. 如果后续订单创建失败,再回去将 Redis 库存加回来(需要保证幂等)。

先看核心的 Lua 脚本:

$lua = <<<LUA
local stock = redis.call('GET', KEYS[1])
if not stock then
    return -1 -- 库存不存在
end
stock = tonumber(stock)
if stock <= 0 then
    return 0 -- 已经卖完
end
redis.call('DECR', KEYS[1])
return 1 -- 扣减成功
LUA;

$result = Redis::eval($lua, ['product:stock:' . $productId], 1);

ThinkPHP8 中,Redis 门面是默认注册好的。如果你用了 thinkcachedriverRedis 做缓存,那直接 cache('product_stock_' . $id) 也能实现原子操作,但 eval 能自定义业务逻辑,更灵活。上面的脚本里我故意没有用某个键来保存具体剩余库存,而是直接对 key 做 DECR。有人会担心多客户端并发时 DECR 超卖,实际上 DECR 自带原子性,只要库存键的初始值正确,并且我们在脚本里判断了当前值大于 0,是安全的。

但 Lua 脚本里判断和 DECR 之间没有别的客户端能插入操作,因为 Redis 是单线程模型,脚本执行期间所有命令都会被阻塞。所以这个方案是绝对线程安全的。

四、完整流程:从商品详情到秒杀成功

我写了一个相对简洁但能跑通的秒杀接口,代码放在一个控制器里方便你看整体流程:

<?php
namespace appcontroller;

use thinkfacadeDb;
use thinkfacadeRedis;
use thinkResponse;
use appmodelOrder;
use appmodelProduct;

class Seckill
{
    // 秒杀入口
    public function index()
    {
        $productId = request()->param('product_id');
        $userId = request()->param('user_id');
        $skuId = request()->param('sku_id', 0); // 如果有规格

        if (!$productId || !$userId) {
            return json(['code' => 1, 'msg' => '参数错误']);
        }

        // 1. 检查用户是否已经买过(防止重复下单)
        $orderExists = Db::name('order')
            ->where('user_id', $userId)
            ->where('product_id', $productId)
            ->find();

        if ($orderExists) {
            return json(['code' => 2, 'msg' => '你已参与过该活动']);
        }

        // 2. Redis 原子扣减库存
        $lua = <<<LUA
local stock = redis.call('GET', KEYS[1])
if not stock then
    return -1
end
stock = tonumber(stock)
if stock <= 0 then
    return 0
end
redis.call('DECR', KEYS[1])
return 1
LUA;

        $stockKey = 'seckill:stock:' . $productId;
        $ok = Redis::eval($lua, [$stockKey], 1);

        if ($ok === -1) {
            return json(['code' => 3, 'msg' => '商品未初始化库存']);
        }
        if ($ok === 0) {
            return json(['code' => 4, 'msg' => '已经抢光了']);
        }

        // 3. 扣减 MySQL 真实库存(这里直接用乐观锁或者条件更新,防止Redis数据与DB不一致)
        //    我这里选择使用 where + dec 条件更新
        $affected = Db::name('product')
            ->where('id', $productId)
            ->where('stock', '>', 0)
            ->dec('stock', 1)
            ->update();

        if (!$affected) {
            // 数据库库存不足,回补Redis
            Redis::incr($stockKey);
            return json(['code' => 5, 'msg' => '数据库库存同步失败']);
        }

        // 4. 创建订单(这里简化,实际用队列异步处理)
        try {
            $orderNo = date('YmdHis') . mt_rand(1000, 9999);
            Order::create([
                'order_no' => $orderNo,
                'user_id' => $userId,
                'product_id' => $productId,
                'status' => 0,
                'created_at' => time(),
            ]);
        } catch (Exception $e) {
            // 订单失败,回滚Redis库存,记录日志
            Redis::incr($stockKey);
            return json(['code' => 6, 'msg' => '订单创建失败,请重试']);
        }

        return json(['code' => 0, 'msg' => '秒杀成功']);
    }
}

这个流程已经可以应付大多数秒杀场景。我特意在 Redis 扣减之后又去更新数据库库存,双保险。为什么不在 Redis 扣完就直接返回?因为后续创建订单可能失败,如果不更新数据库,库存账实不符。如果订单成功,数据库库存减一,Redis 减一,两边一致。如果订单失败,Redis 加回来,数据库没动,等价于没发生过。

五、压测结果让人意外

我用了 PHP 内置的 workerman 压测工具,模拟 2000 个并发请求,商品库存设置 1000。原来的查改方案,数据库直接超卖,订单生成了 2000 多个。现在的 Redis 方案,成功创建的订单恰好 1000 个,多出来的请求全部返回“已抢光”。数据库库存最终为 0,Redis 库存也变为 0,两端数据完全一致。

压测时 Watch 内存和 CPU,Redis 的 CPU 占用率不高,MySQL 几乎没有压力。因为所有线程安全判断都在 Redis 完成了,数据库只是执行一条条件更新,绝大多数请求在 Redis 层就挡回去了。

六、几个容易被忽视的坑

虽然方案写完了,但实际落地时还有不少细节不处理会出大事。

1. 库存键过期问题

活动结束后,我一开始忘了给 Redis 库存键设置过期时间,导致这个 key 一直占用内存。后来在活动初始化时,设置了一个 24 小时的过期。但要注意:如果 key 过期后还有请求进来,Lua 脚本会返回 -1,然后提示“商品未初始化”,这没问题。可在活动未结束但 key 因为网络闪断丢失了,就会误判。我的解决方式是在秒杀接口里加一层兜底:如果 Redis 返回 -1,再查数据库中的库存并重新填充 Redis。

2. 数据库更新失败要补回

上面代码已经有补回操作,但要注意补回的原子性。如果 Redis 的 incr 也失败了,那就麻烦了。实际项目里我会用日志记录,并且定期跑对账脚本,把 Redis 库存和数据库库存做比较,不一致就修正。

3. 用户重复点击问题

我在代码里查了订单表判断重复,但高并发下这个查询也是不安全的。更好的办法是在 Redis 里放一个用户去重集合,比如 SADD seckill:buyers:1001 userId,返回值不为 0 就代表已抢过。这个判断可以合在 Lua 脚本里一起完成,避免多次请求同时创建订单。不过我这套改造已经在订单表加了联合唯一索引,也能挡住大多数重复,但性能上还是 Redis 老练。

4. 事务边界要清晰

如果你在创建订单的同时还要扣减其他库存,比如多商品组合下单,上面这个简单方案就不够了。建议先把 Redis 扣减一次性做完,然后 MySQL 事务统一处理订单和扣减。但 Redis 的操作无法回滚,只能做补偿。所以更稳妥的做法是:先本地消息表记录扣减动作,异步处理订单,失败后执行补偿。

七、能直接用吗?

上面这段代码是一个通用骨架,你直接复制到 ThinkPHP8 项目中,改一下模型名和字段就能跑起来。但是真实业务中肯定还有限购、价格变动、活动时间等逻辑,需要你在 Redis 扣减之前再加判断。而且我建议把 Redis 的 key 设计成可以批量管理的结构,比如 seckill:info:活动IDseckill:stock:活动ID 分开,方便定时脚本统一处理。

关键在于懂了原理,后面自己改就顺手了。

八、反思与总结

经过这次重构,我最大的感受是:别把 MySQL 当水缸,能挡在前面的尽量用缓存挡。数据库只做最终一致性的持久化。

方案简单粗暴,但很实用。没有引入消息队列,没有分布式锁,全靠 Redis 单线程原子特性就解决了高并发秒杀超卖。如果你在 ThinkPHP 项目里也遇到类似问题,可以参考这个思路。以后如果再遇到库存扣减,至少你不会再被超卖问题追着跑了。

当然,这只是我自己的实践经验,不一定是最优方案。如果你有更好的办法,欢迎讨论。

ThinkPHP8 实战:避免超卖,用乐观锁和Redis原子操作重构秒杀库存扣减
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP8 实战:避免超卖,用乐观锁和Redis原子操作重构秒杀库存扣减 https://www.taomawang.com/server/thinkphp/2477.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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