分布式限流:当你的“景区”开了连锁店,如何统一管理客流量?

5 阅读11分钟

分布式限流:当你的“景区”开了连锁店,如何统一管理客流量?🏰➡️🏰🏰

一个曾因单机限流导致流量“偏科”,让1号服务器累死、2号服务器闲死的架构师。现在,我是分布式限流的“区域总指挥”。🎖️

朋友们,还记得我们上次聊的单机限流吗?那就像每个景区分店各自管理自己的游客。但今天,你的景区开成了连锁品牌,在10个城市都有分店!问题来了:

用户通过官网预约,可以自由选择去任意分店。结果就是:

  • 北京分店:人山人海,被挤爆了 🏃💨
  • 上海分店:门可罗雀,员工在打瞌睡 😴
  • 广州分店:勉强维持,但系统预警不断 ⚠️

这就是分布式限流要解决的“资源分配不均”问题:如何在多实例、多节点的集群环境中,实现全局统一的流量控制,而不是各自为政?

一、分布式限流是啥?连锁景区的“中央调度中心” 🎯

简单说,分布式限流就是在分布式系统中,协调多个服务实例,共同遵守一个全局的流量限制规则

用“连锁景区”来比喻:

  • 你的微服务集群​ = 10个城市的分店
  • 用户请求​ = 来自全国各地的游客
  • 单机限流​ = 每个分店自己数人数
  • 分布式限流​ = 总部统一调度,控制全国总游客数,并合理分配到各分店

核心目标:让10个分店作为一个整体来接待游客,而不是各自为战,最后有的撑死有的饿死!

二、为什么单机限流不够用?因为“信息孤岛”会坏事!🏝️

你可能会想:我给每个服务实例都配置相同的单机限流不就行了?

大错特错! ​ 看看这三大“翻车”现场:

翻车现场1:负载不均,旱的旱死,涝的涝死 🌊🏜️

# 实例A配置:每秒100请求
instance-a.rate-limit: 100

# 实例B配置:每秒100请求  
instance-b.rate-limit: 100

# 实际运行结果:
# 负载均衡轮询,但实例A处理慢,实例B处理快
# 结果:实例A队列积压,实例B闲得发慌

翻车现场2:重启丢数据,限流失效 🎲

// 单机令牌桶,数据在内存
public class LocalTokenBucket {
    private int tokens; // 重启就没了!
    
    public boolean tryAcquire() {
        if (tokens > 0) {
            tokens--;
            return true;
        }
        return false;
    }
}
// 实例重启后,令牌桶重置,之前累积的令牌全没了!

翻车现场3:客户端绕过,直捣黄龙 🎯

用户请求 → 负载均衡器 → 随机转发到实例A/B/C
         ↓
聪明的黑客:发现实例C比较空闲
         ↓
直接攻击实例C的IP:PORT,绕过负载均衡!
         ↓
实例C单独被击垮 💥

三、分布式限流的四种“武器库” 🗡️🛡️

1. Redis集中计数:简单的“总部计数器” 🧮

原理:所有实例共享同一个Redis计数器

每个请求来,都去Redis检查:
- 全局计数+1
- 如果超过阈值,拒绝
- 定期(如每秒)重置计数器

代码实现

public class RedisCounterLimiter {
    private Jedis jedis;
    private String key;
    private int limit; // 总限制
    private int period; // 时间窗口(秒)
    
    public boolean tryAcquire() {
        // 使用Lua脚本保证原子性
        String luaScript = 
            "local current = redis.call('incr', KEYS[1])\n" +
            "if current == 1 then\n" +
            "    redis.call('expire', KEYS[1], ARGV[2])\n" +
            "end\n" +
            "if current > tonumber(ARGV[1]) then\n" +
            "    return 0\n" +
            "else\n" +
            "    return 1\n" +
            "end";
        
        Object result = jedis.eval(luaScript, 1, 
            key, String.valueOf(limit), String.valueOf(period));
        return "1".equals(result.toString());
    }
}

优点:简单,实现快

缺点性能瓶颈!所有请求都要访问Redis,Redis可能成为单点瓶颈

适合:中小规模集群,QPS不高

2. 令牌桶+Redis:共享的“中央票仓” 🎫

原理:在Redis中实现一个全局令牌桶

总部有一个大票仓(Redis令牌桶)
每个分店(实例)需要票时,去总部领
总部以固定速率发票
没票了就拒绝

Redis Lua脚本实现

-- KEYS[1]: 令牌桶key
-- ARGV[1]: 桶容量
-- ARGV[2]: 每秒添加的令牌数
-- ARGV[3]: 当前时间戳
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local last_tokens = tonumber(redis.call("get", key..":tokens"))
local last_time = tonumber(redis.call("get", key..":time"))

if last_tokens == nil then
    last_tokens = capacity
    last_time = now
end

-- 计算这段时间应该添加多少令牌
local delta = math.max(0, now - last_time)
local new_tokens = math.min(capacity, last_tokens + delta * rate)

local allowed = 0
if new_tokens >= 1 then
    allowed = 1
    new_tokens = new_tokens - 1
end

-- 更新Redis
redis.call("setex", key..":tokens", 2, new_tokens)
redis.call("setex", key..":time", 2, now)

return allowed

优点:真正的全局统一限流

缺点:Redis压力大,网络延迟影响性能

适合:对全局一致性要求极高的场景

3. 分层限流:网关+实例的“双层防线” 🛡️🛡️

原理:在网关层做第一道限流,在实例层做第二道限流

用户请求 → 网关限流(全局控制) → 实例限流(自我保护)

Spring Cloud Gateway + Redis限流

# application.yml
spring:
  cloud:
    gateway:
      routes:
        - id: user-service
          uri: lb://user-service
          predicates:
            - Path=/api/users/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 10  # 每秒10个
                redis-rate-limiter.burstCapacity: 20  # 突发20个
                key-resolver: "#{@userKeyResolver}"

实例层本地限流(作为补充):

// 即使网关限流,实例自己也做保护
@RestController
public class UserController {
    private RateLimiter localLimiter = RateLimiter.create(100); // 本地限流
    
    @GetMapping("/api/users/{id}")
    public User getUser(@PathVariable Long id) {
        if (!localLimiter.tryAcquire()) {
            throw new TooManyRequestsException("实例繁忙,请稍后");
        }
        return userService.getById(id);
    }
}

优点:双层保护,更安全

缺点:架构复杂,维护成本高

适合:中大型企业级应用

4. 客户端限流+服务端协调:智能的“分店自主权” 🤖

原理:每个实例根据从协调中心获取的配额,自行限流

总部(协调中心)分配配额:A30%B40%,C店30%
各分店根据配额自行管理游客
配额动态调整,根据各店负载情况

Apache Sentinel 集群流控

// 1. 部署Sentinel Dashboard(协调中心)
// 2. 客户端集成
@SentinelResource(value = "getUser", blockHandler = "handleBlock")
@GetMapping("/user/{id}")
public User getUser(@PathVariable String id) {
    return userService.getById(id);
}

// 3. Sentinel Dashboard配置集群规则
// 设置全局QPS=1000,分配到3个实例

优点:智能分配,兼顾公平与效率

缺点:组件较重,学习成本高

适合:云原生环境,大规模集群

四、四大方案对比:选对“武器”才能打胜仗 🎯

方案比喻优点缺点适用场景
Redis计数总部人工计数简单,强一致Redis压力大,性能瓶颈小集群,低QPS
Redis令牌桶中央票仓全局统一,精确控制网络延迟影响大对一致性要求高
分层限流双层安检多层次保护,安全架构复杂中大型企业应用
客户端+协调分店自主智能分配,性能好组件重,有学习成本云原生,大规模集群

选择口诀

  • 小系统,用Redis计数
  • 要精确,用Redis令牌桶
  • 要安全,用分层限流
  • 要智能,用客户端+协调

五、实战:用Redis实现分布式令牌桶 🏆

这是最常用的方案,我们来看看完整的实现:

@Component
public class DistributedTokenBucket {
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    // Lua脚本(一次发送,原子执行)
    private static final String LUA_SCRIPT = 
        "local key = KEYS[1]\n" +
        "local capacity = tonumber(ARGV[1])\n" +
        "local rate = tonumber(ARGV[2])\n" +
        "local now = tonumber(ARGV[3])\n" +
        "local requested = tonumber(ARGV[4])\n" +  // 本次请求的令牌数
        "\n" +
        "local tokens_key = key .. ':tokens'\n" +
        "local timestamp_key = key .. ':timestamp'\n" +
        "\n" +
        "local last_tokens = tonumber(redis.call('get', tokens_key))\n" +
        "if last_tokens == nil then\n" +
        "    last_tokens = capacity\n" +
        "end\n" +
        "\n" +
        "local last_time = tonumber(redis.call('get', timestamp_key))\n" +
        "if last_time == nil then\n" +
        "    last_time = now\n" +
        "end\n" +
        "\n" +
        "-- 计算新增的令牌\n" +
        "local delta = math.max(0, now - last_time)\n" +
        "local new_tokens = math.min(capacity, last_tokens + delta * rate)\n" +
        "\n" +
        "local allowed = 0\n" +
        "if new_tokens >= requested then\n" +
        "    allowed = 1\n" +
        "    new_tokens = new_tokens - requested\n" +
        "end\n" +
        "\n" +
        "-- 更新,设置过期时间防止无限增长\n" +
        "local ttl = 2  -- 过期时间2秒\n" +
        "redis.call('setex', tokens_key, ttl, new_tokens)\n" +
        "redis.call('setex', timestamp_key, ttl, now)\n" +
        "\n" +
        "return allowed";
    
    private DefaultRedisScript<Long> redisScript;
    
    @PostConstruct
    public void init() {
        redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(LUA_SCRIPT);
        redisScript.setResultType(Long.class);
    }
    
    /**
     * 尝试获取令牌
     * @param key 限流key(如接口名)
     * @param capacity 桶容量
     * @param rate 每秒添加的令牌数
     * @param requested 请求的令牌数(默认为1)
     * @return 是否获取成功
     */
    public boolean tryAcquire(String key, int capacity, double rate, int requested) {
        List<String> keys = Collections.singletonList(key);
        // 当前时间(秒,带小数)
        double now = System.currentTimeMillis() / 1000.0;
        
        Object result = redisTemplate.execute(
            redisScript,
            keys,
            String.valueOf(capacity),
            String.valueOf(rate),
            String.valueOf(now),
            String.valueOf(requested)
        );
        
        return result != null && ((Long) result) == 1L;
    }
    
    // 简化版,默认请求1个令牌
    public boolean tryAcquire(String key, int capacity, double rate) {
        return tryAcquire(key, capacity, rate, 1);
    }
}

使用示例

@RestController
public class OrderController {
    
    @Autowired
    private DistributedTokenBucket rateLimiter;
    
    @PostMapping("/api/orders")
    public Response createOrder(@RequestBody Order order) {
        // 对用户ID限流:每个用户每秒最多下3单
        String key = "order:user:" + order.getUserId();
        if (!rateLimiter.tryAcquire(key, 3, 3.0)) {
            return Response.error("您下单太快了,请稍后再试");
        }
        
        // 全局限流:整个订单接口每秒最多1000单
        String globalKey = "order:global";
        if (!rateLimiter.tryAcquire(globalKey, 1000, 1000.0)) {
            return Response.error("系统繁忙,请稍后重试");
        }
        
        return orderService.create(order);
    }
}

六、分布式限流的“天坑”与填坑指南 🕳️

坑1:Redis单点故障

问题:Redis挂了,限流就失效了!

填坑

// 1. Redis集群部署
@Configuration
public class RedisConfig {
    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisClusterConfiguration config = new RedisClusterConfiguration();
        config.addClusterNode(new RedisNode("redis1", 6379));
        config.addClusterNode(new RedisNode("redis2", 6379));
        config.addClusterNode(new RedisNode("redis3", 6379));
        return new JedisConnectionFactory(config);
    }
}

// 2. 降级策略:Redis不可用时,降级到本地限流
public boolean tryAcquireWithFallback(String key, int capacity, double rate) {
    try {
        return tryAcquire(key, capacity, rate);
    } catch (Exception e) {
        // Redis挂了,降级到本地限流
        log.warn("Redis限流降级到本地限流", e);
        return localRateLimiter.tryAcquire();
    }
}

坑2:网络延迟导致限流不准

问题:A实例申请令牌时,B实例也申请了,由于网络延迟,Redis处理有先后

填坑

// 使用本地预取+异步刷新的模式
public class HybridRateLimiter {
    private int localTokens; // 本地令牌
    private long lastSyncTime; // 上次同步时间
    
    public synchronized boolean tryAcquire() {
        // 1. 先看本地有没有令牌
        if (localTokens > 0) {
            localTokens--;
            return true;
        }
        
        // 2. 本地没了,去Redis申请一批
        int batchSize = 10; // 一次申请10个
        boolean success = redisLimiter.tryAcquire(key, batchSize);
        if (success) {
            localTokens = batchSize - 1; // 用掉1个,剩下9个存本地
            return true;
        }
        
        return false;
    }
}

坑3:时钟不同步

问题:各服务器时间不一致,导致限流计算错误

填坑

// 不要用本地时间,用Redis的TIME命令获取统一时间
public double getRedisTime() {
    // Redis的TIME命令返回服务器时间
    List<Object> time = jedis.time();
    long seconds = (Long) time.get(0);
    long microseconds = (Long) time.get(1);
    return seconds + microseconds / 1_000_000.0;
}

坑4:热点数据集中攻击

问题:所有请求都访问同一个商品,虽然全局QPS没超,但单商品超了

填坑

// 多维度限流
public boolean shouldLimit(HttpServletRequest request) {
    // 1. IP限流
    String ipKey = "limit:ip:" + getClientIp(request);
    
    // 2. 用户限流
    String userKey = "limit:user:" + getUserId(request);
    
    // 3. 接口限流
    String apiKey = "limit:api:" + request.getRequestURI();
    
    // 4. 参数限流(如商品ID)
    String productId = request.getParameter("productId");
    String productKey = "limit:product:" + productId;
    
    // 任意一个维度触发限流,就拒绝
    return redisLimiter.tryAcquire(ipKey, 100, 100.0)
        && redisLimiter.tryAcquire(userKey, 10, 10.0)
        && redisLimiter.tryAcquire(apiKey, 1000, 1000.0)
        && redisLimiter.tryAcquire(productKey, 50, 50.0);
}

七、生产环境最佳实践:做聪明的“区域总指挥” 🧠

1. 选择合适的方案

根据你的业务规模选择:

# 小团队(<5个实例)
方案: Redis计数器
工具: 自研简单实现

# 中型团队(5-50个实例)  
方案: Redis令牌桶
工具: 自研或Redisson

# 大型团队(>50个实例)
方案: 分层限流 或 客户端+协调
工具: Spring Cloud Gateway + Sentinel

2. 监控告警必不可少

// 关键监控指标
@Bean
public MeterRegistryCustomizer<MeterRegistry> metrics() {
    return registry -> {
        // 1. 限流通过率
        Counter.builder("rate.limit.passed")
            .description("限流通过的请求数")
            .register(registry);
        
        // 2. 限流拒绝率  
        Counter.builder("rate.limit.blocked")
            .description("限流拒绝的请求数")
            .register(registry);
        
        // 3. Redis延迟
        Timer.builder("redis.latency")
            .description("Redis操作延迟")
            .register(registry);
    };
}

// 告警规则:拒绝率>5%时告警
if (blockedRate > 0.05) {
    alertService.sendAlert("限流拒绝率过高:" + blockedRate);
}

3. 动态调整阈值

// 根据系统负载动态调整限流阈值
@Scheduled(fixedRate = 10000) // 每10秒调整一次
public void adjustRateLimit() {
    double cpuUsage = getCpuUsage();
    double memoryUsage = getMemoryUsage();
    
    if (cpuUsage > 0.8 || memoryUsage > 0.8) {
        // 负载高,降低限流阈值
        rateLimiter.adjustRate(0.8); // 降到80%
    } else if (cpuUsage < 0.3 && memoryUsage < 0.3) {
        // 负载低,提高限流阈值
        rateLimiter.adjustRate(1.2); // 提升到120%
    }
}

4. A/B测试不同的限流策略

// 对不同用户使用不同的限流策略
public RateLimiter getUserRateLimiter(String userId) {
    // 根据用户分组,A组用令牌桶,B组用滑动窗口
    String group = getUserGroup(userId);
    
    if ("A".equals(group)) {
        return tokenBucketLimiter;
    } else if ("B".equals(group)) {
        return slidingWindowLimiter;
    } else {
        return defaultLimiter;
    }
}

八、灵魂拷问:你真的需要分布式限流吗?🤔

在投入分布式限流前,先问自己:

  1. 流量真的均匀吗? ​ 如果80%流量都打到同一个实例,先解决负载均衡问题
  2. 能接受最终一致吗? ​ 很多业务可以接受各实例限流略有差异
  3. 有更简单的方案吗? ​ 考虑Nginx限流、API网关限流
  4. 成本效益如何? ​ 分布式限流带来复杂度,是否值得?

记住:分布式限流是最后的手段,不是首选方案!

结语:分布式限流是“艺术”而不是“科学” 🎨

分布式限流就像指挥一个交响乐团,每个乐器(服务实例)既要独奏(处理自己的请求),又要合奏(遵守全局规则)。指挥家(分布式限流系统)要确保整个乐团的和谐,而不是让某件乐器声音过大或过小。

记住分布式限流的核心哲学

  1. 全局视角,局部执行:总部定规则,分店去执行
  2. 宁可错杀,不可错放:保护系统比服务个别用户更重要
  3. 动态调整,智能适应:没有一成不变的规则
  4. 监控为王,数据说话:没有监控的限流是盲人摸象

现在,带上这份指南,去构建你的分布式限流系统吧!让它像一位经验丰富的乐团指挥,优雅地引导流量,保护你的系统在流量洪峰中翩翩起舞,而不是被冲垮。💃🌊

(当你再次面临流量冲击时,希望你能微笑着说:“来吧,我的限流系统已经准备好了!”)😎