ThinkPHP 8 + Redis 秒杀实战:Lua 原子扣减到异步落库的完整链路

2026-10-01 0 144

秒杀这块东西被讲烂了,但真正落到 ThinkPHP 项目里,很多人写的还是「先查库存,再减库存」那一套。平时没事,一到大促就翻车。

这篇文章不走那种一上来就画架构图的路线,直接从一段会超卖的代码开始,一步步把它改成能扛住压力的样子。用到的技术栈是 ThinkPHP 8 + Redis + Redis 驱动的队列,代码都能直接跑。

先看那段会超卖的代码

很多人第一次写秒杀,写出来是这样:

<?php
namespace appcontroller;

use appmodelGoods;
use thinkfacadeDb;

class Seckill
{
    public function order(int $goodsId)
    {
        $goods = Goods::find($goodsId);

        if ($goods->stock <= 0) {
            return json(['code' => 1, 'msg' => '库存不足']);
        }

        $goods->stock -= 1;
        $goods->save();

        Db::name('order')->insert([
            'goods_id' => $goodsId,
            'user_id'  => 1,
            'created_at' => time(),
        ]);

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

问题在哪儿?两处。

第一,find 之后到 save 之间是个空窗期。两个请求同时读到的库存都是 1,然后各自减 1 各自保存,最终数据库里库存变成 -1,却卖出去两件。这就是经典超卖。

第二,整个链路串行执行,一次请求要读两次数据库、写两次数据库。Redis 一秒能抗十万 QPS,MySQL 撑死几千,瓶颈全在数据库上。就算是库存充足的商品,几百个人同时点也够卡几秒的。

要解决这两个问题,方向很明确:库存操作从数据库挪到 Redis,重复购买判断也挪过去,并且整个过程必须是原子的。落库的部分改成异步。

表设计:一条不能省

先把表结构准备好。库存放 Redis 但不代表数据库那边就不用了,它得留着做对账兜底。

CREATE TABLE `goods` (
  `id`          int unsigned NOT NULL AUTO_INCREMENT,
  `name`        varchar(100) NOT NULL,
  `price`       int unsigned NOT NULL COMMENT '单位分',
  `stock`       int NOT NULL COMMENT '数据库侧真实库存',
  `version`     int NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
  PRIMARY KEY (`id`)
);

CREATE TABLE `seckill_order` (
  `id`         bigint unsigned NOT NULL AUTO_INCREMENT,
  `goods_id`   int unsigned NOT NULL,
  `user_id`    int unsigned NOT NULL,
  `status`     tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
  `created_at` int unsigned NOT NULL,
  `updated_at` int unsigned NOT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uniq_goods_user` (`goods_id`, `user_id`)
);

uniq_goods_user 这个唯一索引很重要,是最后一道防线。就算 Redis 那边判重失手了,数据库这层也不会让同一个人对同一个商品下两单。数据库是唯一可信的那道门,Redis 只是前置的性能手段,这个定位不能反了。

Redis 库存预热

秒杀开始前,把库存从数据库搬到 Redis,同时清掉上一次遗留的购买记录。

<?php
namespace appservice;

use thinkfacadeCache;
use thinkfacadeDb;

class StockWarmup
{
    public static function run(int $goodsId): void
    {
        $goods = Db::name('goods')->find($goodsId);
        if (!$goods) {
            throw new RuntimeException('商品不存在');
        }

        $redis = Cache::store('redis')->handler();

        // SET 会在 Redis 上一键覆盖,用 pipeline 减少网络往返
        $pipe = $redis->pipeline();
        $pipe->set(StockService::stockKey($goodsId), (int) $goods['stock']);
        $pipe->del(StockService::buyerKey($goodsId));
        $pipe->exec();
    }
}

没有用 INCR 去叠加,而是直接 SET 覆盖。原因很简单:预热这东西可能被误跑两次,用 INCR 那两次跑完库存就翻倍了。直接覆盖更安全,反正数据源始终是数据库。

核心就是这一段 Lua

把「判断有没有买过」「判断库存够不够」「扣库存」「记录买家」这四步捏成一个原子操作。分开写就会出问题:两个请求同时通过了库存检查,才轮到扣减,一样超卖。

<?php
namespace appservice;

use thinkfacadeCache;

class StockService
{
    private const PREFIX_STOCK = 'seckill:stock:';
    private const PREFIX_BUYER = 'seckill:buyer:';

    public static function stockKey(int $goodsId): string
    {
        return self::PREFIX_STOCK . $goodsId;
    }

    public static function buyerKey(int $goodsId): string
    {
        return self::PREFIX_BUYER . $goodsId;
    }

    /**
     * @return int  1成功 0库存不足 -1重复购买
     */
    public static function tryDeduct(int $goodsId, int $userId, int $num = 1): int
    {
        $script = <<<'LUA'
            local stockKey = KEYS[1]
            local buyerKey = KEYS[2]
            local userId   = ARGV[1]
            local num      = tonumber(ARGV[2])

            if redis.call('SISMEMBER', buyerKey, userId) == 1 then
                return -1
            end

            local stock = tonumber(redis.call('GET', stockKey) or '-1')
            if stock < num then
                return 0
            end

            redis.call('DECRBY', stockKey, num)
            redis.call('SADD', buyerKey, userId)
            return 1
        LUA;

        $redis = Cache::store('redis')->handler();

        $result = $redis->eval(
            $script,
            [
                self::stockKey($goodsId),
                self::buyerKey($goodsId),
                (string) $userId,
                (string) $num,
            ],
            2   // 前两个参数是 KEYS,后面的都是 ARGV
        );

        return (int) $result;
    }

    public static function rollback(int $goodsId, int $userId, int $num = 1): void
    {
        $redis = Cache::store('redis')->handler();
        $redis->pipeline()
            ->incrBy(self::stockKey($goodsId), $num)
            ->sRem(self::buyerKey($goodsId), (string) $userId)
            ->exec();
    }
}

eval 的第三个参数 2 经常会漏。它的含义是「前面两个参数是 KEYS,后面剩下的都是 ARGV」。漏了这个参数,PHP 的 Redis 扩展会把所有参数都当成 KEYS,脚本读不到 userId,直接返回 0 库存不足,你会一脸懵逼地以为是自己库存没预热上。

另外脚本里返回 -1 表示重复购买,这个语义是自定义的,你也可以用别的数字,只要和调用方约定一致就行。

控制器里怎么用

<?php
namespace appcontroller;

use appjobCreateOrderJob;
use appserviceStockService;
use thinkfacadeQueue;
use thinkResponse;

class Seckill
{
    public function order(int $goodsId): Response
    {
        $userId = (int) request()->userId;

        $code = StockService::tryDeduct($goodsId, $userId, 1);

        if ($code === -1) {
            return json(['code' => 1, 'msg' => '你已经抢过了']);
        }

        if ($code === 0) {
            return json(['code' => 1, 'msg' => '被抢光了']);
        }

        // 扔进队列,让后台慢慢落库
        Queue::push(CreateOrderJob::class, [
            'goods_id' => $goodsId,
            'user_id'  => $userId,
        ], 'seckill');

        return json(['code' => 0, 'msg' => '抢到了,正在生成订单']);
    }
}

返回给用户的消息是「正在生成订单」而不是「下单成功」。这是刻意的。因为队列是异步的,此刻数据库里还没有这条订单记录。用户刷新订单列表可能看不到。用「正在生成」这种说法更诚实,页面上配一个轮询订单状态的小逻辑就行。

队列任务:把数据库的东西落地

队列消费者负责把 Redis 里已经确认扣减的名额,变成数据库里真实的一行。

<?php
namespace appjob;

use appserviceStockService;
use thinkfacadeDb;
use thinkqueueJob;

class CreateOrderJob
{
    public function fire(Job $job, array $data): void
    {
        $goodsId = (int) $data['goods_id'];
        $userId  = (int) $data['user_id'];

        try {
            Db::transaction(function () use ($goodsId, $userId) {
                // 数据库库存再用乐观锁扣一次,双保险
                $affected = Db::name('goods')
                    ->where('id', $goodsId)
                    ->where('stock', '>', 0)
                    ->whereRaw('version = version')
                    ->update([
                        'stock'   => Db::raw('stock - 1'),
                        'version' => Db::raw('version + 1'),
                    ]);

                if ($affected === 0) {
                    throw new RuntimeException('数据库库存已空');
                }

                Db::name('seckill_order')->insert([
                    'goods_id'   => $goodsId,
                    'user_id'    => $userId,
                    'status'     => 0,
                    'created_at' => time(),
                    'updated_at' => time(),
                ]);
            });

            $job->delete();
        } catch (Throwable $e) {
            // 数据库这边失败,把 Redis 那边预扣的还回去
            StockService::rollback($goodsId, $userId, 1);

            // 唯一索引冲突说明已经生成过了,直接确认,不用重试
            if (str_contains($e->getMessage(), 'Duplicate entry')) {
                $job->delete();
                return;
            }

            if ($job->attempts() > 3) {
                // 超过三次就放弃,记个日志人工处理
                trace('秒杀落库失败: ' . $e->getMessage(), 'error');
                $job->delete();
                return;
            }

            // 抛出去让队列自己重试
            throw $e;
        }
    }
}

数据库这层为什么还要再扣一次?因为 Redis 是内存,重启、主从切换、脚本误删都可能丢数据。数据库这一层是兜底的账本,它上面必须也是原子的。用 stock > 0 作为更新的条件,影响行数为 0 就说明真的没货了。

失败的时候一定要把 Redis 的库存还回去。这个操作叫「补偿」,是整条链路能自愈的关键。否则用户看着抢到了,最后订单没生成,钱也没付,客服电话直接打爆。

超时未支付也要还库存

用户抢到了名额但一直不付款,库存不能就这么锁死。写个定时任务扫一遍:

<?php
namespace appcommand;

use appserviceStockService;
use thinkconsoleCommand;
use thinkconsoleInput;
use thinkconsoleOutput;
use thinkfacadeDb;

class ReleaseExpired extends Command
{
    protected function configure(): void
    {
        $this->setName('seckill:release-expired')
             ->setDescription('释放超时未支付的秒杀订单');
    }

    protected function execute(Input $input, Output $output): int
    {
        $deadline = time() - 900; // 15 分钟

        $orders = Db::name('seckill_order')
            ->where('status', 0)
            ->where('created_at', '<', $deadline)
            ->limit(200)
            ->select();

        foreach ($orders as $order) {
            Db::transaction(function () use ($order) {
                $affected = Db::name('seckill_order')
                    ->where('id', $order['id'])
                    ->where('status', 0)
                    ->update(['status' => 2, 'updated_at' => time()]);

                if ($affected === 0) {
                    return; // 已经被别的地方处理了
                }

                Db::name('goods')
                    ->where('id', $order['goods_id'])
                    ->update([
                        'stock'   => Db::raw('stock + 1'),
                        'version' => Db::raw('version + 1'),
                    ]);

                StockService::rollback($order['goods_id'], $order['user_id'], 1);
            });
        }

        $output->writeln('处理了 ' . count($orders) . ' 条超时订单');
        return 0;
    }
}

在 config/console.php 里注册一下,然后交给系统的计划任务或者 Supervisor 定时触发。15 分钟这个时长要根据商品调整,虚拟商品可以短一点,实物商品可以长一点。

上线前压一把

光看代码没用,得上压力测试。推荐用 wrk 或者 ab,简单粗暴。

# 模拟 100 个用户抢 20 件库存,持续 10 秒
wrk -t4 -c100 -d10s -s post.lua http://your-host/seckill/order?goods_id=1

这里提醒一句:压测千万别直接怼线上环境。找一个跟线上机器配置差不多的测试机跑,否则 Redis 没准是共享的,你的压测会影响到其他业务。

压完之后必须做的事:

-- 数据库里卖出去的订单数
SELECT COUNT(*) FROM seckill_order WHERE goods_id = 1;

-- Redis 里还剩多少库存
GET seckill:stock:1;

-- 数据库里的商品库存
SELECT stock FROM goods WHERE id = 1;

三个数字必须对得上:卖出的 + 数据库库存 = 初始库存;Redis 剩余库存 = 数据库库存(前提是队列已经消费完)。对不上就查日志,大概率是队列积压或者补偿逻辑有遗漏。

踩过的坑,能少走一步是一步

Lua 脚本里的 GET 一定要容错。 如果库存 key 不存在,redis.call('GET', key) 返回的是 false,直接 tonumber(false) 会报错。脚本里用 or '-1' 兜一下,程序才不会崩。

队列重试次数别设太大。 三次足够了。重试太多次的意义不大,真出问题都是逻辑错,重试只会让内存越积越多。

Lua 脚本用 redis-cli --eval 单独调试过再上。 ThinkPHP 那边报的错很模糊,直接在命令行 redis-cli --eval script.lua key1 key2 , arg1 arg2 试一遍,问题一目了然。

写脚本的机器和跑脚本的机器要保持时区一致。 我们有一次是任务在午夜整点跑,结果跑了两遍,因为容器里的时区是 UTC 但日志时间戳是本地时间,监控告警绕晕了。

下单接口加防抖。 用户手指快一点真的会连点两下。前端按钮加个 disabled 是最简单的,后端就靠 Redis 的买家记录去重。两手都要有。

别把库存 key 设过期时间。 有些同学为了「清理」加个 TTL,结果活动还没结束 key 就没了,脚本读不到库存返回 -1,全部变成「库存不足」。这个 key 只能靠活动结束后手动清理,或者换成带活动 ID 的命名空间。

Redis 挂了怎么办。 你的秒杀系统此时应该整体降级,直接返回「活动过于火爆」。别硬撑着让请求打到 MySQL 上,那只会把数据库一起拖垮。降级开关提前做好,别等真挂了才加。

再往深一层能做什么

上面这套方案撑住几十万人的秒杀问题不大。如果想继续优化,还有几个方向:

把用户信息、商品信息也缓存到 Redis,减少数据库查询;用 Lua 脚本一次性完成「扣库存 + 生成预订单号 + 推入队列」,减少网络往返;库存扣减拆成多 key 做分段扣减,避免单个 key 成为热点。

但这些都是量级到了再说的事。先把这篇里的东西跑通、压测过、跑一个月平稳,再考虑下一步。很多项目死在过度设计上,不是死在性能瓶颈上。

ThinkPHP 8 + Redis 秒杀实战:Lua 原子扣减到异步落库的完整链路
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 + Redis 秒杀实战:Lua 原子扣减到异步落库的完整链路 https://www.taomawang.com/server/thinkphp/2847.html

常见问题

相关文章

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

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