PHP + Redis秒杀场景

在电商秒杀等高并发场景中,Redis 凭借其内存操作和原子性特性,成为库存扣减的核心组件。

下面以 PHP + Redis 为例,给出一个企业级秒杀库存设计方案示例,重点解决超卖、热点 Key、重复下单和流量冲击等问题。

一、核心思路

  • 预加载库存:秒杀活动开始前,将商品库存从 MySQL 同步到 Redis(使用 SETHMSET)。
  • 原子扣减:使用 Redis Lua 脚本(或 DECR + 校验)保证扣减与校验的原子性,杜绝超卖。
  • 异步落库:扣减成功后,将用户 ID、商品 ID 推入消息队列(如 Redis Stream / List),后台进程消费并创建订单、扣减数据库库存。
  • 限流 + 防重:通过令牌桶或滑动窗口限制用户访问频率,通过 Redis 记录用户抢购状态防止同一用户重复下单。

二、详细步骤与代码实现

1. 活动开始前:初始化库存

// 假设商品ID=1001,秒杀库存=100
$redis->set('seckill:stock:1001', 100);
$redis->set('seckill:closed:1001', 0); // 0-未结束,1-已结束

2. 抢购接口核心:Lua 脚本原子扣减

$lua = <<<LUA
local stock_key = KEYS[1]
local user_key = KEYS[2]    -- 用于记录已抢购用户
local user_id = ARGV[1]
local closed_key = KEYS[3]

-- 检查活动是否已结束
if redis.call('get', closed_key) == '1' then
    return -2   -- 活动结束
end

-- 检查用户是否已抢购过(防重复)
if redis.call('sismember', user_key, user_id) == 1 then
    return -1   -- 重复抢购
end

-- 扣减库存
local stock = redis.call('decr', stock_key)
if stock < 0 then
    -- 库存不足,回滚
    redis.call('incr', stock_key)
    return 0    -- 无库存
end

-- 记录用户
redis.call('sadd', user_key, user_id)
-- 返回当前剩余库存(正数代表成功,0代表刚好扣完)
return stock
LUA;

$result = $redis->eval($lua, [
    'seckill:stock:1001',
    'seckill:users:1001',
    'seckill:closed:1001',
    $userId
], 3); // 3个KEYS

if ($result === -2) {
    // 活动已结束
} elseif ($result === -1) {
    // 用户已抢过
} elseif ($result === 0) {
    // 库存不足
} else {
    // 扣减成功,剩余库存 $result
    // 将成功信息推入消息队列(异步创建订单)
    $redis->lpush('seckill:orders', json_encode([
        'user_id' => $userId,
        'goods_id' => 1001,
        'stock' => $result
    ]));
}

3. 异步消费队列(如使用 Redis List + PHP 常驻进程)

// consumer.php 常驻进程(可用 Swoole / Supervisor 管理)
while (true) {
    $data = $redis->brpop('seckill:orders', 2); // 超时2秒
    if ($data) {
        $order = json_decode($data[1], true);
        // 开启MySQL事务
        $db->begin();
        try {
            // 再次检查数据库库存(防止Redis数据不一致)
            $stock = $db->query("SELECT stock FROM goods WHERE id={$order['goods_id']} FOR UPDATE");
            if ($stock > 0) {
                $db->query("UPDATE goods SET stock = stock - 1 WHERE id={$order['goods_id']} AND stock>0");
                // 创建订单...
                $db->commit();
            } else {
                $db->rollback();
                // 库存不足,需回滚Redis中的用户记录(补偿)
                $redis->srem('seckill:users:1001', $order['user_id']);
                $redis->incr('seckill:stock:1001');
            }
        } catch (Exception $e) {
            $db->rollback();
            // 记录异常,重试或人工介入
        }
    }
}

4. 限流(防止刷单)

使用 Redis 的滑动窗口或令牌桶。示例:基于用户 IP 的 QPS 限制(每秒最多5次请求)

$rateLimitKey = "rate:limit:user:{$userId}";
$current = $redis->incr($rateLimitKey);
if ($current == 1) {
    $redis->expire($rateLimitKey, 1); // 1秒窗口
}
if ($current > 5) {
    die('请求过快');
}

5. 活动结束:关闭库存扣减

// 后台脚本,到达活动结束时间后执行
$redis->set('seckill:closed:1001', 1);
// 同时将Redis剩余库存同步回MySQL(避免数据不一致)
$left = $redis->get('seckill:stock:1001');
$db->query("UPDATE goods SET stock = {$left} WHERE id=1001");

三、 增强策略

  • 热点 Key 分片
    如果单个商品库存过大(如 10 万),单个 Redis key 会成为热点。可以拆分库存:
    seckill:stock:1001:1seckill:stock:1001:10,每个分片 1 万。
    请求随机落到某个分片,最后汇总统计。Lua 脚本需依次尝试扣减,若全部分片无库存则失败。
  • 本地缓存 + 快速失败
    在 PHP 应用层(如 APCu)缓存一个标志位 seckill:over:1001,一旦库存为 0 就立即拒绝请求,避免打到 Redis。
  • 使用 Redis Cluster
    将不同商品分散到不同节点,避免单节点压力过大。
  • 防机器人刷单
    结合图形验证码(如极验)、请求签名、设备指纹。
  • 降级方案
    当 Redis 或消息队列压力过大时,快速返回“排队中”或“活动火爆”,引导用户重试。

四、注意点

  • Lua 脚本是保证原子性的不二选择,避免使用 GET + DECR 这种非原子操作。
  • 异步落库能防止数据库被瞬时流量打垮,但会带来短暂不一致(用户看到扣减成功但订单未生成)。可以通过前端轮询或 WebSocket 通知最终结果。
  • 补偿机制:如果后台消费失败(如数据库库存不足),需要回滚 Redis 中的用户标记和已扣库存。
  • 监控与告警:监控 Redis 命中率、队列堆积长度、数据库死锁情况。

五、完整流程图

用户请求 → 限流过滤 → 图形验证码 → Lua脚本原子扣减Redis库存
        ↓成功                                  ↓失败
   记录用户抢购标记 → 推入消息队列           返回失败(已抢完/重复)
   异步Worker消费 → MySQL事务(减库存+创建订单)
   更新订单状态,通知用户