实现一个商品秒杀系统时,可以通过封装类来实现与 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();
}
}
}
代码详解
-
获取分布式锁:
- 使用
set
命令带NX
参数来保证锁的唯一性,如果锁已经存在,其他线程将无法获取到锁。 - 设置锁的过期时间
EX
,防止死锁。
- 使用
-
处理库存和订单:
- 成功获取锁后,先在 Redis 中预减库存,再在 MySQL 中验证并更新库存和订单。
-
释放分布式锁:
- 通过 Redis 的
WATCH
命令监视锁的键,如果发现该键的值等于锁的唯一标识符lockValue
,则使用事务MULTI/EXEC
删除锁,保证原子性。 - 如果锁的值已经被改变,则
unwatch
放弃操作,避免误删。
- 通过 Redis 的
优点
- 无 Lua 脚本依赖:通过 Redis 的原生命令实现了分布式锁的获取与释放,避免了对 Lua 脚本的依赖。
- 线程安全:通过
WATCH
机制确保在释放锁时的原子性操作,有效防止并发问题导致的锁误删。
这个方案确保了在高并发情况下,每次操作库存时都能正确地获取和释放锁,避免了负库存和超卖的问题。