ThinkPHP8 用 Redis 分布式锁破解商品超卖,一个案例让你看透锁的门道

2026-08-29 0 422

前两天我们商城做了一波秒杀,活动刚开始 10 秒,订单系统就炸了,库存扣成了负数。后台一查日志,好家伙,同一个商品同时段有多个请求都通过了库存判断。这就是超卖。这次我用 ThinkPHP8Redis 分布式锁重写了库存扣减逻辑,才把问题压下去。

超卖到底是怎么发生的

传统的扣库存代码多半是这么写的:先查库存是否大于 0,然后再 update 扣减。但高并发下两个请求同时读到库存 = 1,两个都觉得自己能买,然后都去 update,最后库存变成了 -1。这就是经典的“检查再更新”竞态条件。

解决办法很多,数据库乐观锁、悲观锁都能搞。但更适合抗高并发的是 Redis 分布式锁,因为 Redis 单线程执行命令,天然原子性。今天就用 ThinkPHP8 干这个事。

环境准备

你的项目需要已经安装 ThinkPHP8,并且装好 Redis 扩展。我这里是 PHP 8.2 + ThinkPHP 8.0,Redis 扩展已启用。再用 Composer 装一个 predis 或者直接用 phpredis 都行,我用的是 phpredis 扩展,所以代码里直接用 Redis 类。

先在 config/cache.php 里配置 Redis 为默认缓存驱动:

return [
    'default' => 'redis',
    'stores' => [
        'redis' => [
            'type' => 'redis',
            'host' => '127.0.0.1',
            'port' => 6379,
            'password' => '',
            'select' => 0,
            'timeout' => 0,
            'expire' => 0,
            'persistent' => false,
            'prefix' => '',
        ],
    ],
];
    

撸一个 Redis 分布式锁服务类

别整那些花里胡哨的框架包,自己手写一个最清晰的锁类。我新建了一个 app/service/RedisLock.php,内容如下:

<?php
namespace appservice;

use thinkfacadeCache;

class RedisLock
{
    /**
     * 获取锁
     * @param string $key 锁唯一标识
     * @param int $expire 锁自动过期时间(秒)
     * @return bool|string 成功返回锁的 value,失败返回 false
     */
    public static function lock($key, $expire = 5)
    {
        // 这里的 value 要唯一,防止误删别人的锁
        $token = md5(uniqid((string)mt_rand(), true));
        // 使用 setnx 尝试加锁,同时设置过期时间,防止死锁
        $redis = Cache::store('redis')->handler();
        $result = $redis->set("lock:$key", $token, ['NX', 'EX' => $expire]);
        if ($result) {
            return $token;
        }
        return false;
    }

    /**
     * 释放锁
     * @param string $key
     * @param string $token 加锁时返回的 token
     * @return bool
     */
    public static function unlock($key, $token)
    {
        $redis = Cache::store('redis')->handler();
        // Lua 脚本原子性判断:只有 value 匹配才删除,防止删掉别人的锁
        $script = <<eval($script, ["lock:$key", $token], 1);
        return $result == 1;
    }
}
    

有几个重点:

  • 加锁的时候用 set key value NX EX。NX 表示 key 不存在才设置,EX 表示过期时间。这操作是原子的,不用怕中间出幺蛾子。
  • 锁的 value 一定要唯一。我用的是 md5(uniqid + mt_rand)。为什么?防止释放锁的时候误删了别人的锁。比如线程 A 执行太久锁过期了,线程 B 拿到锁,然后 A 执行完了去释放锁,如果不判断 value,A 就会把 B 的锁删掉。Lua 脚本判断 value 再删,就能避免这种惨案。
  • 过期时间必须设置,否则进程崩了锁就永久不释放。

在商品扣库存场景里使用

现在模拟一个商品表:id, name, stock。我用一个简单的控制器来模拟下单流程。

<?php
namespace appcontroller;

use thinkfacadeDb;
use appserviceRedisLock;

class Order
{
    public function buy($goodsId)
    {
        // 需要操作的库存数字
        $lockKey = "goods_stock_$goodsId";
        $token = RedisLock::lock($lockKey, 3);
        if (!$token) {
            return json(['code' => 429, 'msg' => '操作太频繁,请稍后再试']);
        }

        try {
            // 开启事务,保证数据库操作原子性
            Db::startTrans();
            // 读取当前库存(此时已在锁的保护下)
            $goods = Db::name('goods')->where('id', $goodsId)->lock(true)->find();
            if (!$goods || $goods['stock']  400, 'msg' => '库存不足']);
            }

            // 扣减库存
            Db::name('goods')->where('id', $goodsId)->dec('stock', 1)->update();

            // 模拟其他业务操作,比如创建订单
            Db::name('orders')->insert([
                'goods_id' => $goodsId,
                'create_time' => time(),
            ]);

            Db::commit();
            return json(['code' => 0, 'msg' => '购买成功']);
        } catch (Exception $e) {
            Db::rollback();
            // 记日志...
            return json(['code' => 500, 'msg' => '服务器繁忙']);
        } finally {
            // 释放锁(用finally确保任何情况都释放)
            RedisLock::unlock($lockKey, $token);
        }
    }
}
    

注意几个细节:

  • 在查询商品时加了 lock(true),也就是数据库的悲观锁 FOR UPDATE。锁机制是防并发的第一道门,数据库锁是第二道保险。双重保障。
  • 锁的超时时间要设置得比业务执行时间长一点。我这里设置 3 秒,如果业务超过 3 秒,锁自动失效,另一个请求就可以进来,这时数据库锁会兜底。一般商城扣库存很快,几十毫秒就完事,3 秒绝对够。
  • 释放锁放在 finally 里,保证异常时锁也能释放,不会死锁。

压测一下看效果

我自己用 Apache 的 ab 工具模拟 100 并发请求同一个商品:

ab -n 100 -c 100 "http://yourdomain/order/buy?goods_id=1"
    

库存我预先设置成了 10。跑完看日志,成功下单 10 个,剩余的 90 个请求全部返回“操作太频繁”或者“库存不足”。没有一条超卖记录,数据库库存稳稳停在 0。

没加锁之前,100 并发下库存能变成 -12。加了锁之后,虽然部分请求会提示稍后再试,但数据是准的,这才叫一致性。

锁在 ThinkPHP8 里还可以怎么玩

用锁不光能防超卖,还能防用户重复提交表单,防定时任务重复执行,防缓存击穿(只允许一个请求去重建缓存)。我把这个 RedisLock 类抽象出来,哪里需要就在哪里调用。

比如防止重复提交订单,可以在前端传入一个唯一的幂等键,然后在后端尝试加锁:

$lockKey = 'order_submit_' . $userId . '_' . $goodsId;
$token = RedisLock::lock($lockKey, 5);
if (!$token) {
    return json(['code' => 429, 'msg' => '正在处理中,请勿重复提交']);
}
// 处理订单...
RedisLock::unlock($lockKey, $token);
    

这套方案的坑与注意

首先,Redis 必须开启持久化?其实不需要。锁只是临时性的,就算 Redis 重启,锁也会消失,但数据库的悲观锁会兜住一致性。不过如果 Redis 挂了,锁全部失效,并发会直接打到数据库,那就考验数据库的能力了。最好给 Redis 搞个哨兵或集群。

其次,锁的时间不是越长越好,太长会导致大量请求失败,降低吞吐。根据业务实际耗时设置一个合理值,并尽量缩短事务内的耗时。

最后,ThinkPHP8 里如果你用了多个 Redis 库,要注意连接配置一致。我用的是 Cache 门面,它会根据 config/cache.php 里的当前默认存储来获取 redis 句柄,不会乱套。

有没有更简单的方式?

如果你不想自己写 Lua 脚本,也可以用 TP 自带的锁机制(thinkfacadeCache::lock),但那个锁的粒度比较粗,用法也不太一样。我还是喜欢直接操作 Redis 更灵活。

另外有的人会推荐用 RedLock 算法,但对于大多数业务场景,单机 Redis 锁已经够用了。RedLock 需要多个独立 Redis 节点,反而会增加复杂度,而且业内对 RedLock 一直有争议。

把上述代码集成进你的项目

把 RedisLock 类放到 app/service 下,然后在控制器里 use 它。别忘了在数据库里建好 goods 表和 orders 表,字段可以简单点,主要看逻辑。

CREATE TABLE goods (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(255) NOT NULL,
  stock INT UNSIGNED NOT NULL
);

INSERT INTO goods (name, stock) VALUES ('限量款T恤', 10);

CREATE TABLE orders (
  id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  goods_id INT UNSIGNED NOT NULL,
  create_time INT UNSIGNED NOT NULL
);
    

跑起来后,你也可以用 jmeter 模拟多线程。把并发线程数调高,观察日志中成功和失败的分布,你会看到锁的威力。

最后说点实在话

不要以为用了 Redis 锁就万事大吉。如果你的库存操作涉及多步、跨服务,甚至需要回滚,那还需要考虑分布式事务。但大多数单服务商城,Redis 锁 + 数据库悲观锁已经能抗住几十万级流量。

这周我还在继续优化我们的秒杀模块,下一步打算把库存预热到 Redis,然后使用 Lua 脚本原子扣减库存,完全避开数据库操作。到时候再写一篇分享。

希望这篇文章能让你别像我之前那样被超卖坑惨。

ThinkPHP8 用 Redis 分布式锁破解商品超卖,一个案例让你看透锁的门道
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP8 用 Redis 分布式锁破解商品超卖,一个案例让你看透锁的门道 https://www.taomawang.com/server/thinkphp/2662.html

常见问题

相关文章

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

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