秒杀系统分布式限流防超卖

wen java案例 2

本文目录导读:

秒杀系统分布式限流防超卖

  1. 核心思想:流量漏斗 + 层层过滤 + 最终一致性
  2. 第一部分:分布式限流(保护系统入口)
  3. 第二部分:防超卖(核心库存保护)
  4. 第三部分:流量削峰与最终一致性(保证不超卖的关键落地)
  5. 第四部分:完整流程总结(伪代码)
  6. 第五部分:关键点检查清单

这是一个非常经典且高难度的分布式系统设计问题。秒杀系统的核心挑战在于:短时间内海量并发请求(读和写)冲击单一热点资源(库存),导致数据库崩溃、库存超卖、用户响应缓慢。

针对“分布式限流”和“防超卖”这两个关键点,以下是经过生产验证的完整解决方案架构。

核心思想:流量漏斗 + 层层过滤 + 最终一致性

把请求想象成水流,从外到内逐层削减,最终只有极少量的请求能触达数据库。

graph TD
    subgraph 客户端层
        A[用户请求] --> B[CDN/浏览器静态化]
    end
    subgraph 接入层 (限流与防刷)
        B --> C[Nginx/LVS]
        C --> D{API网关/负载均衡}
        D -- 令牌桶/漏桶 --> E[Nginx Lua限流模块]
    end
    subgraph 应用与缓存层 (核心过滤与库存扣减)
        E --> F[Redis Cluster]
        F -- 库存预扣/本地标记 -->
        G[秒杀服务集群]
        G -- 抢到资格 -->
        H[消息队列 MQ]
    end
    subgraph 数据持久层 (异步落库)
        H -- 异步/串行消费 -->
        I[数据库 DB 事务]
        I --> J[订单/库存持久化]
    end
    style F fill:#f9f,stroke:#333,stroke-width:2px
    style H fill:#bbf,stroke:#333,stroke-width:2px

第一部分:分布式限流(保护系统入口)

目标是挡住大部分无效或过量的请求,防止系统被冲垮。

前端与网关限流(第一道防线)

  • 动静分离:秒杀页面静态化(HTML/CSS/JS)、CDN加速,用户点击按钮前,页面静态部分不消耗服务器资源。
  • 按钮防抖:点击后立即置灰,防止用户频繁点击。
  • Nginx/Lua级别限流(最外层,性能最高):
    • IP限流:同一IP每秒最多允许N次请求。
    • URL限流:针对“/seckill/do”接口做全局限流。
    • 连接数限制:限制Nginx到后端服务的最大连接数。

应用层分布式限流(第二道防线)

  • 令牌桶算法(推荐)

    • 原理:以固定速率向桶中放入令牌,请求必须拿到令牌才能通过,允许突发流量,但总体速率可控。

    • 实现:使用 Redis + Lua脚本(原子性)实现分布式令牌桶,定义一个key,hash结构存储last_refresh_timetokens

    • 代码示例(Lua脚本简化逻辑)

      -- 获取令牌逻辑
      local key = KEYS[1]
      local rate = tonumber(ARGV[1]) -- 每秒速率,1000
      local burst = tonumber(ARGV[2])-- 桶容量
      local now = redis.call('TIME')[1]
      local token_info = redis.call('HMGET', key, 'last_time', 'tokens')
      local last_time = tonumber(token_info[1]) or now
      local tokens = tonumber(token_info[2]) or burst
      -- 根据时间差计算应添加的令牌
      local delta = math.max(0, now - last_time)
      tokens = math.min(burst, tokens + delta * rate)
      if tokens >= 1 then
          redis.call('HMSET', key, 'last_time', now, 'tokens', tokens - 1)
          return 1  -- 限流通过
      else
          return 0  -- 限流拒绝
      end

第二部分:防超卖(核心库存保护)

核心原则:将库存扣减操作提前到内存/缓存层,且必须保证原子性。

Redis 原子扣减库存(最重要的一步)

  • 为什么不用数据库直接扣?:数据库行锁会导致性能瓶颈,无法支撑高并发。
  • 操作方式:使用 Redis 的 DECR 命令或 Lua脚本 进行库存扣减。
    • Lua脚本 保证整个操作(查库存、判断、扣减)的原子性,避免并发问题。
    • 预判库存:先读库存,如果小于等于0,直接返回“已售罄”,避免无效的DECR操作。
      -- Redis Lua 扣减库存脚本
      -- KEYS[1]: 商品库存的Key (stock:1001)
      -- ARGV[1]: 购买数量 (通常为1)
      local stock = redis.call('GET', KEYS[1])
      if not stock or tonumber(stock) <= 0 then
      return 0  -- 库存不足
      end
      if tonumber(stock) - tonumber(ARGV[1]) < 0 then
      return 0  -- 库存不足
      end
      return redis.call('DECRBY', KEYS[1], ARGV[1]) -- 返回剩余库存

本地缓存标记(为 Redis 减负)

  • 问题:高并发下,即使使用Redis,大量请求查询“商品是否售罄”也会给Redis带来压力。
  • 解决方案
    • 当从Redis中读取到某个商品的库存为0(或已被标记售罄)时,将状态写入秒杀服务本地内存(如Caffeine、Guava Cache)。
    • 后续请求直接检查本地缓存,若已售罄,不再请求Redis,直接返回失败。
    • 注意:需要设置一个较短的过期时间(如10秒),并配合一个后台任务定期检查Redis库存,防止库存恢复(如订单取消)后本地缓存未更新。

“一人一单”严格限制(结合用户维度)

  • 问题:黄牛用多个账号/IP刷单。
  • 技术实现
    • 基于Redis Set:使用商品ID+用户ID作为key,维护一个已购买成功的用户ID集合。
    • 基于布隆过滤器:在秒杀开始前,将历史购买记录加载到布隆过滤器中,快速拦截重复购买。

第三部分:流量削峰与最终一致性(保证不超卖的关键落地)

异步下单(消息队列MQ)

  • 为什么需要:扣减库存后,不能直接写入数据库,如果直接写入,高并发写入会导致数据库死锁或Connection池耗尽。
  • 流程
    1. Redis扣减库存成功。
    2. 将“用户ID、商品ID、数量、秒杀资格Token”封装成消息,发送到 MQ(如 RocketMQ/Kafka)。
    3. 立即返回给用户“正在排队中...”或“秒杀成功,请等待订单生成”。
    4. 后端服务异步消费MQ消息,单线程/串行化地处理数据库写操作。
  • 防重复消费:数据库订单表中建立唯一索引(用户ID + 商品ID + 活动ID),确保同一用户不会生成两个订单。

数据库层兜底(乐观锁)

  • 即使异步落库,最后一步写入数据库时仍需防御。
  • 更新SQLUPDATE stock SET version = version + 1, num = num - 1 WHERE id = ? AND version = ? and num > 0
  • 如果更新影响行数为0,说明库存被其他线程更新了,该订单作废(回滚Redis库存)。

第四部分:完整流程总结(伪代码)

// 1. 入口:接入层限流 (令牌桶)
if (!rateLimiter.tryAcquire()) {
    return "请求过于频繁,请稍后重试";
}
// 2. 业务层:检查用户黑名单,一人一单,防刷 (布隆过滤器)
if (bloomFilter.mightContain(userId + productId)) {
    return "您已经参与过该活动";
}
// 3. 本地缓存检查售罄 (JVM内存)
if (localCache.isSoldOut(productId)) {
    return "商品已售罄";
}
// 4. Redis Lua 脚本原子扣减库存
Long stockRemaining = redisTemplate.execute(seckillLuaScript, stockKey, 1);
if (stockRemaining == null || stockRemaining < 0) {
    // 扣减失败,标记本地缓存售罄
    localCache.markSoldOut(productId, 10, TimeUnit.SECONDS);
    return "商品已被抢光";
}
// 5. 扣减成功,发送MQ消息,异步落单 (加购标记,防重复)
orderMessage = new OrderMessage(userId, productId, token);
mqProducer.send(orderMessage);
// 6. 返回成功给用户
return "恭喜你,秒杀成功!正在处理订单...";
// --- 异步消费者 ---
@RocketMQMessageListener(topic = "seckill_order")
public void onMessage(OrderMessage msg) {
    try {
        // 7. 数据库防重复插入 (唯一索引)
        // INSERT INTO order (user_id, product_id,...) VALUES (?,?,...)
        // 如果唯一索引冲突,直接 return (幂等)
        // 8. 数据库乐观锁扣减最终库存
        int rows = stockMapper.updateStockByVersion(productId, version);
        if (rows == 0) {
            // 乐观锁失败,库存被改 (理论上不应该发生,或极少发生)
            // 补偿:修改订单状态为“失败”,回滚Redis库存
            redisTemplate.opsForValue().increment(stockKey, 1);
            return;
        }
        // 9. 创建订单成功
    } catch (Exception e) {
        // 10. 重试/死信队列处理
    }
}

第五部分:关键点检查清单

环节 技术手段 目标
前端 按钮置灰、静态化、CDN 减少无效请求
网关 Nginx Lua 限流(IP/URL) 防刷、流量整形
应用层 分布式令牌桶(Redis+Lua) 控制总并发数
库存层 Redis Lua 原子扣减 严格防止超卖
状态缓存 本地缓存(Caffeine)标记售罄 减轻Redis压力
一人一单 Redis Set / 布隆过滤器 防黄牛重复购买
削峰 消息队列(RocketMQ/Kafka) 异步落库,保护DB
最终一致 数据库乐观锁 + 唯一索引 兜底,防重复

通过以上“层层过滤、异步削峰、原子扣减”的组合拳,就能构建一个稳定、不超卖的分布式秒杀系统。

上一篇库存系统分布式并发扣减

下一篇当前分类已是最新一篇

抱歉,评论功能暂时关闭!