11.3 限流

响应式系统限流策略全面实践:令牌桶与漏桶算法原理对比与适用场景、Resilience4j RateLimiterOperator Reactor 适配、Redis Lua 原子脚本实现分布式令牌桶、网关层与应用层分层限流的职责分工与阈值配置指南。

为什么需要限流

突发流量可能来自:

  • 用户行为(秒杀、抢购)
  • 系统故障(缓存失效、重试风暴)
  • 恶意攻击(DDoS、爬虫)

限流保证系统在过载时优雅降级,而不是崩溃。

令牌桶 vs 漏桶

算法特点突发流量适用
令牌桶以固定速率放入令牌,请求消耗令牌允许一定突发API 限流
漏桶请求进入队列,以固定速率流出平滑输出网络流量整形

令牌桶更适合服务端限流:允许正常情况下的突发(用户手快多点几次),但长期超负荷时被限制。

Resilience4j RateLimiter

import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.reactor.ratelimiter.operator.RateLimiterOperator;

@Configuration
public class ResilienceRateLimiterConfig {

    @Bean
    public RateLimiter apiRateLimiter() {
        io.github.resilience4j.ratelimiter.RateLimiterConfig config =
            io.github.resilience4j.ratelimiter.RateLimiterConfig.custom()
            .limitForPeriod(1000)          // 每秒 1000 个请求
            .limitRefreshPeriod(Duration.ofSeconds(1))
            .timeoutDuration(Duration.ofMillis(10))  // 获取不到令牌等待 10ms
            .build();

        return RateLimiter.of("api", config);
    }
}

使用:

@GetMapping("/api/orders")
public Flux<Order> listOrders() {
    return orderRepo.findAll()
        .transformDeferred(RateLimiterOperator.of(rateLimiter));
}

令牌耗尽时抛 RequestNotPermitted。注解式 Controller 返回的是 Flux<Order>,不能在这个链上 onErrorResume 成 ServerResponse(类型不匹配),应该用异常处理器统一映射:

@ExceptionHandler(RequestNotPermitted.class)
public ResponseEntity<Map<String, String>> handleRateLimit() {
    return ResponseEntity.status(429)
        .header("Retry-After", "1")
        .body(Map.of("error", "Rate limit exceeded"));
}

函数式路由则可以在 handler 内部把它转成 ServerResponse.status(429),因为那里的返回类型本来就是 Mono<ServerResponse>。RateLimiterOperator 按订阅计费,不是按元素计费:一个返回 Flux 的请求只消耗一个令牌,不会因为流里有一万条记录就消耗一万个令牌。

Redis 分布式限流

单实例限流无法应对分布式部署。Redis 原子操作实现分布式令牌桶:

-- rate_limit.lua:令牌数和时间戳放在同一个 HASH 里
local key = KEYS[1]
local rate = tonumber(ARGV[1])      -- 每秒令牌数
local capacity = tonumber(ARGV[2])  -- 桶容量
local now = tonumber(ARGV[3])       -- 当前时间戳(毫秒)
local requested = tonumber(ARGV[4]) -- 请求令牌数

local data = redis.call('HMGET', key, 'tokens', 'ts')
local tokens = tonumber(data[1])
local ts = tonumber(data[2])
if tokens == nil then tokens = capacity end
if ts == nil then ts = now end

local delta = math.max(0, now - ts)
local filled = math.min(capacity, tokens + delta * rate / 1000)
local allowed = filled >= requested
local remaining = allowed and filled - requested or filled

redis.call('HSET', key, 'tokens', remaining, 'ts', now)
redis.call('EXPIRE', key, math.ceil(capacity / rate * 2))

return allowed and 1 or 0

用 HMGET/HSET 而不是两个字符串 key,是因为 Redis Cluster 只允许脚本访问同一个 slot 的 key;key 和 key:last_updated 会触发 CROSSSLOT 错误。返回值也必须是整数:Lua 的 false 会被转成 nil,Boolean 输出类型拿不到明确的 false。

Java 调用用 DefaultRedisScript 直接传脚本内容。它自带脚本的 SHA1,Spring 先发 EVALSHA,收到 NOSCRIPT 再回退到 EVAL(EVAL 会让 Redis 缓存脚本);并不会先发一条独立的 SCRIPT LOAD:

@Service
public class DistributedRateLimiter {
    private static final String LUA = """
        local key = KEYS[1]
        local rate = tonumber(ARGV[1])
        local capacity = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])
        local requested = tonumber(ARGV[4])

        local data = redis.call('HMGET', key, 'tokens', 'ts')
        local tokens = tonumber(data[1])
        local ts = tonumber(data[2])
        if tokens == nil then tokens = capacity end
        if ts == nil then ts = now end

        local delta = math.max(0, now - ts)
        local filled = math.min(capacity, tokens + delta * rate / 1000)
        local allowed = filled >= requested
        local remaining = allowed and filled - requested or filled

        redis.call('HSET', key, 'tokens', remaining, 'ts', now)
        redis.call('EXPIRE', key, math.ceil(capacity / rate * 2))

        return allowed and 1 or 0
        """;

    private static final DefaultRedisScript<Long> SCRIPT =
        new DefaultRedisScript<>(LUA, Long.class);

    private final ReactiveRedisTemplate<String, String> redis;

    public Mono<Boolean> isAllowed(String key, int rate, int capacity) {
        long now = System.currentTimeMillis();
        return redis.execute(SCRIPT, List.of(key), List.of(
                String.valueOf(rate), String.valueOf(capacity),
                String.valueOf(now), "1"))
            .next()
            .map(result -> result != null && result == 1L);
    }
}

不要手写 "rate_limit_sha".getBytes():那必须是 SCRIPT LOAD 返回的真实 SHA1,写死的字符串只是把一次错误的 EVALSHA 变成运行时错误。

分层限流

外部请求
    → 网关层(第 3.3 节):IP 限流,100 req/s
        → API 层:用户限流,10 req/s
            → 服务层:方法限流,100 req/s
                → 数据库层:连接池限流,20 conns

每层限流保护不同资源:

  • 网关:防 DDoS,保护网络带宽
  • API:防用户滥用,保护应用 CPU
  • 服务:防内部错误,保护下游服务
  • DB:防查询风暴,保护数据库

eventd v2 限流配置

eventd v2 的限流策略:

层级粒度限流动作
网关IP100/s429 Too Many Requests
网关用户(JWT)30/s429
API方法500/s429
数据库连接池20 conn等待或拒绝

与熔断的配合

请求
    → 限流(RateLimiter,最外层)
        → 重试(Retry)
            → 熔断(CircuitBreaker,最内层)
                → 下游服务

顺序理由:

  1. 限流最先:超量请求在进入重试和熔断统计之前就被拒绝
  2. 重试在熔断外层:每次重试都经过熔断器,熔断器统计真实尝试次数;重试要排除 CallNotPermittedException
  3. 熔断最内层:直接保护下游,打开后立即快速失败

这与 11.2 的「熔断内层、重试外层」是同一套顺序,不要反过来写成「限流 → 熔断 → 重试」。

小结

算法实现突发平滑
令牌桶Resilience4j / Redis Lua✅中
漏桶队列限速❌高
固定窗口计数器❌低
滑动窗口Redis ZSET中中
层级工具粒度
网关Redis + LuaIP / 用户
应用Resilience4j RateLimiter方法 / 服务
数据库连接池大小全局

第 11 章结束。下一节 12.1 事件驱动集成 :事件总线、事件网关、Saga 与响应式衔接。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 15.3 全书总结与学习路径回顾
  2. 15.2 响应式流处理的演进趋势
  3. 15.1 引擎选型指南