Redis实现秒杀

实现一个商品秒杀系统时,可以通过封装类来实现与 MySQL、Redis 的交互,并使用令牌桶算法来控制请求的速率,确保高并发下的系统稳定性。下面是一个示例,展示如何封装这三部分功能来实现商品秒杀。

1. 创建 MySQL 表结构

首先,创建一个商品表 products 和一个订单表 orders

CREATE TABLE products (
    id INT PRIMARY KEY AUTO_INCREMENT,
    name VARCHAR(100) NOT NULL,
    stock INT NOT NULL DEFAULT 0
);

CREATE TABLE orders (
    id INT PRIMARY KEY AUTO_INCREMENT,
    product_id INT NOT NULL,
    user_id INT NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

2.【 令牌桶算法、漏桶算法、Redis Cell】能够适配,这里以令牌桶类实现,点击查看其他算法

创建一个 TokenBucket 类,用于控制每秒允许多少个请求通过:

class TokenBucket
{
    private $capacity;
    private $tokens;
    private $rate;
    private $lastRequestTime;

    public function __construct($capacity, $rate)
    {
        $this->capacity = $capacity;
        $this->tokens = $capacity;
        $this->rate = $rate;
        $this->lastRequestTime = microtime(true);
    }

    /**
     * 调整令牌桶的容量
     */
    public function setCapacity($capacity)
    {
        $this->capacity = $capacity;
        if ($this->tokens > $capacity) {
            $this->tokens = $capacity;
        }
    }

    /**
     * 调整令牌生成速率
     */
    public function setRate($rate)
    {
        $this->rate = $rate;
    }

    /**
     * 获取当前可用令牌数
     */
    public function getTokens()
    {
        $this->replenishTokens();
        return $this->tokens;
    }

    /**
     * 尝试获取令牌
     */
    public function tryAcquire($numTokens = 1)
    {
        $this->replenishTokens();

        if ($this->tokens >= $numTokens) {
            $this->tokens -= $numTokens;
            return true;
        }
        return false;
    }

    /**
     * 根据时间补充令牌
     */
    private function replenishTokens()
    {
        $currentTime = microtime(true);
        $elapsedTime = $currentTime - $this->lastRequestTime;

        $this->lastRequestTime = $currentTime;
        $newTokens = $elapsedTime * $this->rate;

        $this->tokens = min($this->capacity, $this->tokens + $newTokens);
    }
}

3.秒杀系统类

方式1:在秒杀系统中,我们利用上述 TokenBucket 类来控制请求的速率,并结合 MySQL 和 Redis 实现商品秒杀功能【通过redis的decr的原子性实现库存的减少】

  • 预减库存:首先在 Redis 中减少库存。如果 Redis 中的库存不足(减为负数),立即回滚 Redis 的库存并返回“库存不足”的消息。

  • 订单确认和库存校验:在 MySQL 中检查库存是否足够,并根据 MySQL 库存来最终决定是否创建订单。如果 MySQL 中库存不足,则回滚 Redis 的预减库存操作。

class Seckill
{
    private $redis;
    private $db;
    private $tokenBucket;

    public function __construct($redis, $db, TokenBucket $tokenBucket)
    {
        $this->redis = $redis;
        $this->db = $db;
        $this->tokenBucket = $tokenBucket;
    }

    /**
     * 初始化商品库存
     */
    public function initProduct($productId, $stock)
    {
        $this->redis->set("product_stock_$productId", $stock);
    }

    /**
     * 尝试下单
     */
    public function attemptPurchase($productId, $userId)
    {
        if (!$this->tokenBucket->tryAcquire()) {
            return "Rate limit exceeded. Please try again later.";
        }

        $stock = $this->redis->get("product_stock_$productId");

        if ($stock <= 0) {
            return "Out of stock!";
        }

        if ($this->redis->decr("product_stock_$productId") < 0) {
            $this->redis->incr("product_stock_$productId");
            return "Out of stock!";
        }

        $this->db->beginTransaction();

        try {
            $stmt = $this->db->prepare("UPDATE products SET stock = stock - 1 WHERE id = :productId AND stock > 0");
            $stmt->execute(['productId' => $productId]);

            if ($stmt->rowCount() == 0) {
                $this->redis->incr("product_stock_$productId");
                $this->db->rollBack();
                return "Out of stock!";
            }

            $stmt = $this->db->prepare("INSERT INTO orders (product_id, user_id) VALUES (:productId, :userId)");
            $stmt->execute(['productId' => $productId, 'userId' => $userId]);

            $this->db->commit();
            return "Purchase successful!";
        } catch (\Exception $e) {
            $this->db->rollBack();
            $this->redis->incr("product_stock_$productId");
            return "Purchase failed!";
        }
    }
}

方式2:基于 WATCH 的 Redis 实现

以下是一个基于 WATCH 命令的实现方案,确保在高并发情况下不会出现负库存。

class Seckill
{
    private $redis;
    private $db;
    private $tokenBucket;

    public function __construct($redis, $db, TokenBucket $tokenBucket)
    {
        $this->redis = $redis;
        $this->db = $db;
        $this->tokenBucket = $tokenBucket;
    }

    /**
     * 初始化商品库存
     */
    public function initProduct($productId, $stock)
    {
        $this->redis->set("product_stock_$productId", $stock);
        $this->db->query("UPDATE products SET stock = :stock WHERE id = :productId", [
            'stock' => $stock,
            'productId' => $productId,
        ]);
    }

    /**
     * 尝试下单
     */
    public function attemptPurchase($productId, $userId)
    {
        if (!$this->tokenBucket->tryAcquire()) {
            return "Rate limit exceeded. Please try again later.";
        }

        $stockKey = "product_stock_$productId";

        // 监视库存键
        $this->redis->watch($stockKey);

        // 检查库存
        $stock = $this->redis->get($stockKey);
        if ($stock <= 0) {
            $this->redis->unwatch();
            return "Out of stock!";
        }

        // 开启 Redis 事务
        $this->redis->multi();
        $this->redis->decr($stockKey);

        // 执行事务
        $result = $this->redis->exec();

        // 如果事务执行失败,说明有其他操作导致库存被改变
        if (!$result) {
            return "Please try again!";
        }

        // 事务处理 MySQL 库存和订单
        $this->db->beginTransaction();

        try {
            $stmt = $this->db->prepare("SELECT stock FROM products WHERE id = :productId FOR UPDATE");
            $stmt->execute(['productId' => $productId]);
            $dbStock = $stmt->fetchColumn();

            if ($dbStock <= 0) {
                $this->redis->incr($stockKey); // 回滚 Redis 库存
                $this->db->rollBack();
                return "Out of stock!";
            }

            $stmt = $this->db->prepare("UPDATE products SET stock = stock - 1 WHERE id = :productId");
            $stmt->execute(['productId' => $productId]);

            $stmt = $this->db->prepare("INSERT INTO orders (product_id, user_id) VALUES (:productId, :userId)");
            $stmt->execute(['productId' => $productId, 'userId' => $userId]);

            $this->db->commit();
            return "Purchase successful!";
        } catch (\Exception $e) {
            $this->db->rollBack();
            $this->redis->incr($stockKey); // 回滚 Redis 库存
            return "Purchase failed!";
        }
    }
}

代码详解

  • WATCH 命令:Redis 的 WATCH 命令用于监视一个或多个键。在事务执行之前,如果这些键的值被其他命令修改,事务将会中止,这样可以防止多个客户端同时修改库存导致的超卖问题。

  • Redis 事务(MULTI/EXEC):使用 MULTI 命令开始事务,EXEC 命令执行事务中的所有命令。在事务中,我们执行 decr 操作以减少库存。

  • 事务冲突检测:如果事务执行失败(即 exec 返回 false),说明有并发修改导致库存被改变,此时返回失败并让客户端重新尝试。

  • 最终库存校验:在 MySQL 中最终确认库存是否足够,如果不够,回滚 Redis 中的库存修改。这一步确保即使 Redis 中库存允许的情况下,MySQL 也不会超卖。

优点

  • 高效WATCH + MULTI/EXEC 在并发条件下能够有效减少 Redis 之间的竞态条件问题。
  • 简单性:相比分布式锁的实现,WATCH 更加简单且直接,但在非常高并发情况下可能会有一些性能损失。

通过这种方式,你可以避免在高并发情况下出现负库存的问题,并且不需要依赖 Lua 脚本来实现。

方式3:分布式锁的实现方案

以下是使用 Redis 分布式锁的商品秒杀实现方案:

class Seckill
{
    private $redis;
    private $db;
    private $tokenBucket;
    private $lockTimeout = 5; // 锁超时时间(秒)

    public function __construct($redis, $db, TokenBucket $tokenBucket)
    {
        $this->redis = $redis;
        $this->db = $db;
        $this->tokenBucket = $tokenBucket;
    }

    /**
     * 初始化商品库存
     */
    public function initProduct($productId, $stock)
    {
        $this->redis->set("product_stock_$productId", $stock);
        $this->db->query("UPDATE products SET stock = :stock WHERE id = :productId", [
            'stock' => $stock,
            'productId' => $productId,
        ]);
    }

    /**
     * 尝试下单
     */
    public function attemptPurchase($productId, $userId)
    {
        if (!$this->tokenBucket->tryAcquire()) {
            return "Rate limit exceeded. Please try again later.";
        }

        $lockKey = "lock_product_$productId";
        $lockValue = uniqid(); // 每个锁的唯一标识

        // 获取分布式锁
        $isLocked = $this->acquireLock($lockKey, $lockValue, $this->lockTimeout);
        if (!$isLocked) {
            return "Unable to acquire lock. Please try again.";
        }

        try {
            // 预减库存
            $stock = $this->redis->decr("product_stock_$productId");
            if ($stock < 0) {
                $this->redis->incr("product_stock_$productId");
                return "Out of stock!";
            }

            // 事务处理 MySQL 库存和订单
            $this->db->beginTransaction();

            $stmt = $this->db->prepare("SELECT stock FROM products WHERE id = :productId FOR UPDATE");
            $stmt->execute(['productId' => $productId]);
            $dbStock = $stmt->fetchColumn();

            if ($dbStock <= 0) {
                $this->redis->incr("product_stock_$productId");
                $this->db->rollBack();
                return "Out of stock!";
            }

            $stmt = $this->db->prepare("UPDATE products SET stock = stock - 1 WHERE id = :productId");
            $stmt->execute(['productId' => $productId]);

            $stmt = $this->db->prepare("INSERT INTO orders (product_id, user_id) VALUES (:productId, :userId)");
            $stmt->execute(['productId' => $productId, 'userId' => $userId]);

            $this->db->commit();
            return "Purchase successful!";
        } catch (\Exception $e) {
            $this->db->rollBack();
            $this->redis->incr("product_stock_$productId");
            return "Purchase failed!";
        } finally {
            // 释放锁
            $this->releaseLock($lockKey, $lockValue);
        }
    }

    /**
     * 获取分布式锁
     */
    private function acquireLock($lockKey, $lockValue, $lockTimeout)
    {
        return $this->redis->set($lockKey, $lockValue, ['NX', 'EX' => $lockTimeout]);
    }

    /**
     * 释放分布式锁
     */
    private function releaseLock($lockKey, $lockValue)
    {
        // 通过事务来确保原子性
        $this->redis->watch($lockKey);
        $storedValue = $this->redis->get($lockKey);

        if ($storedValue === $lockValue) {
            $this->redis->multi();
            $this->redis->del($lockKey);
            $this->redis->exec();
        } else {
            $this->redis->unwatch();
        }
    }
}

代码详解

  1. 获取分布式锁

    • 使用 set 命令带 NX 参数来保证锁的唯一性,如果锁已经存在,其他线程将无法获取到锁。
    • 设置锁的过期时间 EX,防止死锁。
  2. 处理库存和订单

    • 成功获取锁后,先在 Redis 中预减库存,再在 MySQL 中验证并更新库存和订单。
  3. 释放分布式锁

    • 通过 Redis 的 WATCH 命令监视锁的键,如果发现该键的值等于锁的唯一标识符 lockValue,则使用事务 MULTI/EXEC 删除锁,保证原子性。
    • 如果锁的值已经被改变,则 unwatch 放弃操作,避免误删。

优点

  • 无 Lua 脚本依赖:通过 Redis 的原生命令实现了分布式锁的获取与释放,避免了对 Lua 脚本的依赖。
  • 线程安全:通过 WATCH 机制确保在释放锁时的原子性操作,有效防止并发问题导致的锁误删。

这个方案确保了在高并发情况下,每次操作库存时都能正确地获取和释放锁,避免了负库存和超卖的问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值