🚦 设计一个限流系统:交通管制的智慧!

38 阅读11分钟

📖 开场:高速公路收费站

想象高速公路收费站 🛣️:

没有限流(拥堵)

国庆假期:
100万辆车同时上高速
    ↓
收费站只有10个通道
    ↓
全部堵死!💥

结果:
- 收费站瘫痪
- 高速公路瘫痪
- 所有车都走不了 😱

有限流(顺畅)

国庆假期:
100万辆车排队
    ↓
收费站:每分钟只放行1000辆 🚦
    ↓
超过1000辆 → 排队等待 ⏰

结果:
- 收费站正常运行 ✅
- 高速公路流畅 ✅
- 虽然慢,但不瘫痪 ✅

这就是限流的作用:保护系统,防止过载!


🤔 为什么需要限流?

场景1:秒杀活动 🛒

没有限流:
10:00:00  秒杀开始
    ↓
100万用户同时点击
    ↓
服务器QPS:100万
    ↓
服务器承受:1万QPS
    ↓
服务器崩溃!💀

有限流:
10:00:00  秒杀开始
    ↓
100万用户同时点击
    ↓
限流:每秒只处理1万请求 🚦
    ↓
超过的请求:返回"系统繁忙,请稍后再试" ⏰
    ↓
服务器稳定运行 ✅

场景2:API接口保护 🔒

没有限流:
恶意用户:疯狂调用API
    ↓
每秒10万次
    ↓
服务器崩溃 💀
正常用户无法访问 ❌

有限流:
每个用户:每秒最多100次 🚦
    ↓
恶意用户被限制
    ↓
正常用户正常使用 ✅

🎯 限流算法

算法1:固定窗口计数器 🪟

原理

时间窗口:1秒
限流阈值:100次

0-1秒:计数器 = 0
    ↓
请求1 → 计数器+1 = 1 → 通过 ✅
请求2 → 计数器+1 = 2 → 通过 ✅
...
请求100 → 计数器+1 = 100 → 通过 ✅
请求101 → 计数器+1 = 101 → 拒绝 ❌

1秒后,计数器重置为0

代码实现

@Component
public class FixedWindowRateLimiter {
    
    private Map<String, AtomicInteger> counters = new ConcurrentHashMap<>();
    private Map<String, Long> windows = new ConcurrentHashMap<>();
    
    private final int limit = 100;  // 限流阈值
    private final long windowSize = 1000;  // 窗口大小(毫秒)
    
    /**
     * ⭐ 尝试获取令牌
     */
    public boolean tryAcquire(String key) {
        long now = System.currentTimeMillis();
        
        // 获取当前窗口的起始时间
        Long windowStart = windows.get(key);
        
        if (windowStart == null || now - windowStart >= windowSize) {
            // 新窗口,重置计数器
            windows.put(key, now);
            counters.put(key, new AtomicInteger(0));
        }
        
        // 计数器+1
        AtomicInteger counter = counters.get(key);
        int count = counter.incrementAndGet();
        
        if (count <= limit) {
            // 未超过限制
            return true;
        } else {
            // 超过限制
            return false;
        }
    }
}

问题:临界问题

假设限流:100次/秒

0.5秒:99次请求 ✅
1.0秒:窗口重置
1.5秒:99次请求 ✅

0.5秒-1.5秒这1秒内:
实际通过了 99 + 99 = 198次 ❌

超过了限流阈值!

缺点

  • 临界问题(窗口边界)
  • 突刺流量(瞬间大量请求)

算法2:滑动窗口计数器 🪟🪟

原理

窗口大小:1秒
划分:10个小窗口(每个100ms)

当前时间:1000ms
滑动窗口:[100ms-1000ms]

┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ 10│ 12│ 15│ 8 │ 20│ 18│ 10│ 5 │ 2 │ 0 │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
 100 200 300 400 500 600 700 800 900 1000

总请求数:10+12+15+8+20+18+10+5+2+0 = 100

下一个100ms:
- 丢弃最左边的窗口(10)
- 添加新窗口(右边)

优点

  • 解决临界问题
  • 更平滑

代码实现(Redis)

@Component
public class SlidingWindowRateLimiter {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private final int limit = 100;  // 限流阈值
    private final long windowSize = 1000;  // 窗口大小(毫秒)
    
    /**
     * ⭐ 尝试获取令牌
     */
    public boolean tryAcquire(String key) {
        long now = System.currentTimeMillis();
        String redisKey = "rate_limit:" + key;
        
        // ⭐ 1. 删除窗口外的数据
        long windowStart = now - windowSize;
        redisTemplate.opsForZSet().removeRangeByScore(redisKey, 0, windowStart);
        
        // ⭐ 2. 统计窗口内的请求数
        Long count = redisTemplate.opsForZSet().zCard(redisKey);
        
        if (count != null && count >= limit) {
            // 超过限制
            return false;
        }
        
        // ⭐ 3. 添加当前请求
        redisTemplate.opsForZSet().add(redisKey, String.valueOf(now), now);
        
        // ⭐ 4. 设置过期时间
        redisTemplate.expire(redisKey, windowSize + 1000, TimeUnit.MILLISECONDS);
        
        return true;
    }
}

算法3:漏桶算法(Leaky Bucket)💧

原理

漏桶:
- 容量:100个请求
- 流出速率:10个/秒(固定)

请求进入漏桶:
┌─────────────┐
│             │ ← 请求
│   漏桶      │
│  💧💧💧    │
│  💧💧💧    │
│  💧💧💧    │
└──────┬──────┘
       │ 10个/秒(固定速率)
       ↓
    处理请求

特点:
- 流出速率固定 ✅
- 平滑处理 ✅
- 桶满则拒绝 ❌

代码实现(Guava RateLimiter)

@Component
public class LeakyBucketRateLimiter {
    
    private final RateLimiter rateLimiter;
    
    public LeakyBucketRateLimiter() {
        // ⭐ 创建限流器:每秒10个令牌
        this.rateLimiter = RateLimiter.create(10);
    }
    
    /**
     * ⭐ 尝试获取令牌(阻塞)
     */
    public boolean tryAcquire() {
        // 阻塞等待,直到获取到令牌
        rateLimiter.acquire();
        return true;
    }
    
    /**
     * ⭐ 尝试获取令牌(非阻塞,超时返回)
     */
    public boolean tryAcquire(long timeout, TimeUnit unit) {
        // 尝试获取,最多等待timeout时间
        return rateLimiter.tryAcquire(timeout, unit);
    }
}

优缺点

优点 ✅:

  • 流出速率固定,平滑处理
  • 防止突刺流量

缺点 ❌:

  • 无法应对短时间的突发流量
  • 响应慢(需要等待)

算法4:令牌桶算法(Token Bucket)🪙

原理

令牌桶:
- 容量:100个令牌
- 生成速率:10个/秒

令牌生成:
每秒生成10个令牌,放入桶中
桶满则丢弃

请求到来:
1. 尝试从桶中取1个令牌
2. 取到 → 处理请求 ✅
3. 取不到 → 拒绝请求 ❌

特点:
- 允许突发流量(桶中有令牌就能处理)✅
- 长期平均速率受限(10个/秒)✅

图解对比

漏桶 vs 令牌桶:

漏桶(Leaky Bucket):
请求 → 漏桶 → 固定速率流出
特点:流出速率固定

令牌桶(Token Bucket):
令牌按固定速率生成 → 桶
请求 → 取令牌 → 处理
特点:允许突发流量(桶中有令牌)

例子:
突然来100个请求:
- 漏桶:只能每秒处理10个,其他排队
- 令牌桶:桶中有100个令牌,可以立即处理100个 ✅

代码实现(Redis + Lua)

Lua脚本(原子操作):

-- ⭐ 令牌桶算法(Lua脚本)
-- KEYS[1]: 桶的key
-- ARGV[1]: 令牌生成速率(tokens/秒)
-- ARGV[2]: 桶容量
-- ARGV[3]: 当前时间戳(秒)

local key = KEYS[1]
local rate = tonumber(ARGV[1])       -- 令牌生成速率
local capacity = tonumber(ARGV[2])   -- 桶容量
local now = tonumber(ARGV[3])        -- 当前时间

-- 获取桶中的令牌数和上次更新时间
local tokens = tonumber(redis.call('hget', key, 'tokens')) or capacity
local last_time = tonumber(redis.call('hget', key, 'last_time')) or now

-- 计算时间间隔
local delta = now - last_time

-- 生成新令牌
local new_tokens = math.min(capacity, tokens + delta * rate)

-- 尝试获取1个令牌
if new_tokens >= 1 then
    -- 有令牌,扣除1个
    new_tokens = new_tokens - 1
    
    -- 更新桶状态
    redis.call('hset', key, 'tokens', new_tokens)
    redis.call('hset', key, 'last_time', now)
    redis.call('expire', key, 10)
    
    return 1  -- 成功
else
    -- 没有令牌
    return 0  -- 失败
end

Java代码

@Component
public class TokenBucketRateLimiter {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    private final int rate = 10;       // 令牌生成速率:10个/秒
    private final int capacity = 100;  // 桶容量:100个
    
    private final String luaScript = 
        "local key = KEYS[1]\n" +
        "local rate = tonumber(ARGV[1])\n" +
        "local capacity = tonumber(ARGV[2])\n" +
        "local now = tonumber(ARGV[3])\n" +
        "\n" +
        "local tokens = tonumber(redis.call('hget', key, 'tokens')) or capacity\n" +
        "local last_time = tonumber(redis.call('hget', key, 'last_time')) or now\n" +
        "\n" +
        "local delta = now - last_time\n" +
        "local new_tokens = math.min(capacity, tokens + delta * rate)\n" +
        "\n" +
        "if new_tokens >= 1 then\n" +
        "    new_tokens = new_tokens - 1\n" +
        "    redis.call('hset', key, 'tokens', new_tokens)\n" +
        "    redis.call('hset', key, 'last_time', now)\n" +
        "    redis.call('expire', key, 10)\n" +
        "    return 1\n" +
        "else\n" +
        "    return 0\n" +
        "end";
    
    /**
     * ⭐ 尝试获取令牌
     */
    public boolean tryAcquire(String key) {
        String redisKey = "token_bucket:" + key;
        
        // 执行Lua脚本
        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(luaScript, Long.class),
            Collections.singletonList(redisKey),
            String.valueOf(rate),
            String.valueOf(capacity),
            String.valueOf(System.currentTimeMillis() / 1000)
        );
        
        return result != null && result == 1;
    }
}

优缺点

优点 ✅:

  • 允许突发流量
  • 灵活(可以瞬间处理多个请求)
  • 适合大部分场景

缺点 ❌:

  • 实现复杂(相比固定窗口)

推荐 ⭐⭐⭐:令牌桶是最常用的限流算法


🎯 分布式限流

方案1:Redis + Lua ⭐⭐⭐

原理

  • Redis存储限流状态
  • Lua脚本保证原子性
  • 支持多服务器

代码示例(已在上面的令牌桶算法中展示)


方案2:Nginx限流 🔧

配置

http {
    # ⭐ 定义限流规则
    limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
    #              ↑                     ↑              ↑
    #          按IP限流              共享内存10MB    每秒10个请求
    
    server {
        location /api/ {
            # ⭐ 应用限流
            limit_req zone=mylimit burst=20 nodelay;
            #                      ↑        ↑
            #                   突发20个  不延迟
            
            proxy_pass http://backend;
        }
    }
}

优点

  • 性能高(Nginx C语言)
  • 配置简单

缺点

  • 功能有限(只支持固定窗口)
  • 无法动态调整

方案3:Sentinel(阿里开源)☁️

引入依赖

<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

配置

spring:
  cloud:
    sentinel:
      transport:
        dashboard: localhost:8080  # Sentinel控制台
        port: 8719

使用注解

@RestController
@RequestMapping("/api")
public class OrderController {
    
    /**
     * ⭐ 限流注解
     */
    @GetMapping("/order")
    @SentinelResource(
        value = "getOrder",
        blockHandler = "handleBlock"  // 限流后的处理方法
    )
    public Result<Order> getOrder(@RequestParam Long orderId) {
        Order order = orderService.getById(orderId);
        return Result.success(order);
    }
    
    /**
     * 限流后的处理方法
     */
    public Result<Order> handleBlock(Long orderId, BlockException ex) {
        return Result.fail("系统繁忙,请稍后再试");
    }
}

编程式限流

@Service
public class OrderService {
    
    public Order createOrder(Order order) {
        // ⭐ 定义限流规则
        Entry entry = null;
        try {
            entry = SphU.entry("createOrder");
            
            // 业务逻辑
            orderDao.insert(order);
            
            return order;
            
        } catch (BlockException ex) {
            // 限流了
            throw new RuntimeException("系统繁忙,请稍后再试");
        } finally {
            if (entry != null) {
                entry.exit();
            }
        }
    }
}

动态配置规则

@PostConstruct
public void initFlowRules() {
    List<FlowRule> rules = new ArrayList<>();
    
    FlowRule rule = new FlowRule();
    rule.setResource("getOrder");      // 资源名
    rule.setGrade(RuleConstant.FLOW_GRADE_QPS);  // QPS模式
    rule.setCount(100);                // 限流阈值:100 QPS
    
    rules.add(rule);
    
    // ⭐ 加载规则
    FlowRuleManager.loadRules(rules);
}

优点

  • 功能强大(限流、熔断、降级)
  • 可视化控制台
  • 动态配置

📊 架构总结

        分布式限流系统架构

┌──────────────────────────────────────┐
│         客户端                        │
└─────────────┬────────────────────────┘
              │
              ↓
┌──────────────────────────────────────┐
│      Nginx(第一道防线)              │
│  - IP限流                            │
│  - 粗粒度限流                        │
└─────────────┬────────────────────────┘
              │
              ↓
┌──────────────────────────────────────┐
│    应用服务器(第二道防线)           │
│                                      │
│  - 接口级限流                        │
│  - 用户级限流                        │
│  - 细粒度限流                        │
└───────┬──────────────────────────────┘
        │
        ↓
┌──────────────┐
│   Redis      │
│              │
│ - 限流状态   │
│ - Lua脚本    │
└──────────────┘

🎓 面试题速答

Q1: 限流算法有哪些?

A: 四种算法

  1. 固定窗口计数器

    • 简单,但有临界问题
  2. 滑动窗口计数器

    • 解决临界问题,更平滑
  3. 漏桶算法

    • 流出速率固定,平滑处理
  4. 令牌桶算法(推荐)⭐:

    • 允许突发流量,最常用

Q2: 令牌桶和漏桶的区别?

A: 核心区别

漏桶

  • 流出速率固定
  • 请求匀速处理
  • 不能应对突发流量

令牌桶

  • 令牌生成速率固定
  • 允许突发流量(桶中有令牌就能处理)
  • 更灵活 ✅

例子

突然来100个请求:
- 漏桶:每秒只能处理10个,其他排队
- 令牌桶:桶中有100个令牌,立即处理100个 ✅

Q3: 如何实现分布式限流?

A: Redis + Lua

// Lua脚本(原子操作)
String luaScript = 
    "local tokens = redis.call('hget', KEYS[1], 'tokens')\n" +
    "if tokens >= 1 then\n" +
    "    redis.call('hincrby', KEYS[1], 'tokens', -1)\n" +
    "    return 1\n" +
    "else\n" +
    "    return 0\n" +
    "end";

// 执行
Long result = redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Long.class),
    Collections.singletonList("rate_limit:" + key)
);

优点

  • 原子性(Lua脚本)
  • 支持多服务器
  • 性能高

Q4: 固定窗口的临界问题是什么?

A: 临界问题

假设限流:100次/秒

0.5秒:99次 ✅
1.0秒:窗口重置
1.5秒:99次 ✅

0.5秒-1.5秒这1秒内:
实际通过了 198次 ❌

超过了限流阈值!

解决:滑动窗口计数器


Q5: Sentinel有什么优势?

A: 三大优势

  1. 功能强大

    • 限流
    • 熔断
    • 降级
  2. 可视化控制台

    • 实时监控
    • 动态配置规则
  3. 易用性

    • 注解式
    • 编程式
    • 零侵入

使用

@SentinelResource(value = "getOrder", blockHandler = "handleBlock")
public Result<Order> getOrder(Long orderId) {
    // 业务逻辑
}

Q6: 如何选择限流算法?

A: 根据场景选择

场景推荐算法理由
API接口令牌桶 ⭐允许突发流量
秒杀令牌桶 ⭐控制QPS
消息队列漏桶平滑处理
简单场景固定窗口实现简单

推荐:令牌桶算法(最常用)⭐⭐⭐


🎬 总结

       限流算法对比

┌────────────────────────────────────┐
│ 固定窗口计数器                     │
│ - 简单,但有临界问题 ⭐            │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 滑动窗口计数器                     │
│ - 解决临界问题 ⭐⭐                │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 漏桶算法                           │
│ - 流出速率固定 ⭐⭐                │
└────────────────────────────────────┘

┌────────────────────────────────────┐
│ 令牌桶算法(推荐)⭐⭐⭐            │
│ - 允许突发流量                     │
│ - 最常用                           │
└────────────────────────────────────┘

    令牌桶是最佳选择!✅

🎉 恭喜你!

你已经完全掌握了限流系统的设计!🎊

核心要点

  1. 令牌桶算法:允许突发流量,最常用
  2. Redis + Lua:分布式限流,原子性
  3. Sentinel:功能强大,可视化
  4. Nginx限流:第一道防线

下次面试,这样回答

"限流系统采用令牌桶算法。令牌按固定速率生成(如每秒10个),请求到来时尝试获取令牌,获取成功则处理,失败则拒绝。允许突发流量,如果桶中有100个令牌,可以瞬间处理100个请求。

分布式限流使用Redis + Lua脚本实现。Lua脚本计算令牌数量并扣除,保证原子性。支持多服务器,状态存储在Redis中。

实际项目中,Nginx作为第一道防线做IP级别的粗粒度限流,应用服务器做接口级别的细粒度限流。使用Sentinel框架,通过注解@SentinelResource标记资源,动态配置限流规则,并通过可视化控制台实时监控。

我们项目的秒杀接口使用令牌桶限流,QPS限制在1万,超过的请求返回'系统繁忙',保护系统稳定运行。"

面试官:👍 "很好!你对限流系统的设计理解很深刻!"


本文完 🎬

上一篇: 205-设计一个分布式延迟任务调度系统.md
下一篇: 207-设计一个分布式配置中心.md

作者注:写完这篇,我都想去高速公路当收费员了!🚦
如果这篇文章对你有帮助,请给我一个Star⭐!