
做过线上系统的朋友多少都经历过这种夜半惊魂监控面板上某个接口的QPS从平时的几百突然飙到几万数据库连接池瞬间被打满紧接着整个业务链路开始雪崩——从网关到数据库一层接一层地垮掉。这种事故绝大多数情况下根本不是恶意攻击可能就是一次热点活动、一个爬虫脚本没限速、或者某个调用方把重试逻辑写成了死循环。这时候你会发现保住系统的核心手段不是扩容而是限流。SpringBoot作为Java后端最主流的开发框架API限流几乎是每个严肃项目都绕不开的必修课。这篇文章我从实际项目出发把限流这件事讲透从最基础的单机限流到生产环境最常用的RedisLua分布式限流再到AOP注解限流、网关层限流和Sentinel整合每一块都给出可直接落地的代码和参数计算思路。不管你是刚接触限流这个概念还是已经在项目里写过简单限流但想做得更规范这篇都值得认真看一遍。1. 限流方案整体设计先想清楚再动手1.1 限流到底在解决什么问题先说一个容易混淆的点限流、熔断、降级这三件事经常被放在一起说但解决的问题完全不同。限流管的是“进来的请求太多我处理不过来就先拒绝一部分”——它的核心是保护系统自身不被打垮。熔断管的是“下游服务已经不行了别再调它了直接走快速失败”——它保护的是对下游的依赖不被无效请求继续压榨。降级管的是“核心功能保不住的时候放弃非核心功能优先保证主流程”——它是业务层面的取舍。很多人一上来就写限流代码连自己的系统瓶颈在哪都没搞清楚。我见过一个项目数据库明明只能扛200 QPS结果在接口层把限流阈值设成了5000 QPS限流失效得毫无意义。所以动手之前一定要先明确你要保护的资源是什么它的真实承载上限是多少系统的瓶颈在数据库、第三方接口还是本地线程池。这个明确了阈值设置才有依据算法选择才不会跑偏。1.2 四种限流算法对比固定窗口、滑动窗口、漏桶、令牌桶限流算法是整篇文章的地基。我按实际使用频率从低到高讲一遍。固定窗口是最容易理解的把时间切成1秒一个窗口每个窗口内最多处理N个请求窗口结束计数器清零。实现就是一个计数器加一个时间戳但问题也很典型——临界突变。假设限制是每秒100个请求第一个窗口的最后100ms打进来100个请求第二个窗口的前100ms又打进来100个请求这200ms内系统实际承受了200个请求已经翻倍了。这个问题在秒杀、抢购这类瞬时流量场景里非常致命。滑动窗口是对固定窗口的改进把窗口细分成多个小格比如1秒分成10个100ms的小格每个小格独立计数窗口每滑过一个小格就移除最老的那个小格的计数。它能比较平滑地限制流量但本质上还是“计数”思路没法处理“匀速”的需求。而且如果分格粒度太细内存和计算开销会明显上升。漏桶算法的思路像一个底部有洞的桶——请求先进入桶里不管进来的速度多快出去的速率恒定。它能做到绝对匀速非常适合保护数据库这类对请求频率极其敏感的资源。但缺点是应对突发流量很差哪怕系统此刻完全有能力处理更多请求漏桶也不让你多用。令牌桶是目前使用最广泛的算法。桶里按固定速率生成令牌桶最多存一定数量的令牌请求来了必须拿到令牌才能通过。它的巧妙之处在于既可以通过控制令牌生成速率来限制持续流量又允许一定程度的突发——因为桶里攒下的令牌可以一次性被消耗掉。RedisLua实现限流绝大多数方案都是令牌桶Guava的RateLimiter底层也是令牌桶思想。对你来说只要不是对“绝对匀速”有硬性要求闭眼选令牌桶不会错。我用一个表格把四种算法的区别说清楚算法突发流量处理匀速控制实现复杂度典型场景固定窗口差临界突变无极低简单计数场景滑动窗口一般无低对突发有一定容忍的场景漏桶不支持强中保护数据库、第三方接口令牌桶支持中中绝大多数业务接口1.3 单机 vs 分布式你的场景需要哪一层限流搞清楚算法之后下一个问题是在哪一层限流。单机限流就是每个应用实例各管各的不依赖外部存储直接用一个本地RateLimiter或者Semaphore就能实现。它的优势是性能极高、零网络开销、部署简单适合两个场景一是单体应用或者实例数很少的系统二是做“实例级兜底”比如限制单个实例上的线程数、连接数防止某个实例被打挂。分布式限流则是把限流状态放到Redis这类外部存储里所有实例共享同一个计数或令牌状态。对于部署了多个实例、流量会负载均衡到不同节点的系统只能做分布式限流——否则你有10个实例每个实例限100 QPS整体就是1000 QPS完全失控。那什么时候用网关限流什么时候用应用层限流我个人的实践是网关层做全局粗粒度限流比如按IP、按用户维度限制总请求量把明显异常的大流量挡在系统最外层应用层做细粒度业务限流针对具体接口、具体操作设置各自合理的阈值因为只有业务代码最清楚这个接口的消耗有多重。两层各司其职互不冲突。2. 单机限流快速落地Guava RateLimiter 实战2.1 引入依赖与基础用法Guava的RateLimiter是我在单机场景下的首选它实现的就是令牌桶算法而且经过了谷歌内部大规模生产环境的验证稳定性和性能都没得挑。引入依赖非常简单dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version33.0.0-jre/version /dependency基础用法就这么几行// 创建限流器每秒生成10个令牌 RateLimiter rateLimiter RateLimiter.create(10.0); // 非阻塞获取拿不到就直接返回false if (rateLimiter.tryAcquire()) { // 放行业务逻辑 } else { // 返回系统繁忙或抛出限流异常 } // 阻塞获取拿不到就一直等慎用容易堆积请求 rateLimiter.acquire();不难吧三行代码一个接口就限流了。RateLimiter还支持预热模式RateLimiter.create(10.0, 3, TimeUnit.SECONDS)表示在3秒内平滑地达到每秒10个令牌的速率而不是一开始就满速。这个功能在系统冷启动、JIT预热阶段特别有用可以避免刚启动就被流量冲垮。2.2 结合拦截器实现接口粒度限流实际项目中不推荐把RateLimiter直接new在Service里每个接口维护一个实例太散乱了。我习惯用Spring MVC拦截器做统一处理按接口路径维护一个ConcurrentHashMap每个接口绑定一个独立的RateLimiter。Component public class RateLimitInterceptor implements HandlerInterceptor { private final ConcurrentHashMapString, RateLimiter limiterMap new ConcurrentHashMap(); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String uri request.getRequestURI(); // 每个接口一个限流器阈值可以从配置中心动态拉取 RateLimiter limiter limiterMap.computeIfAbsent(uri, key - RateLimiter.create(100.0)); if (!limiter.tryAcquire()) { response.setStatus(429); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:429,\msg\:\请求过于频繁请稍后再试\}); return false; } return true; } }然后在WebMvcConfigurer里注册拦截器指定拦截路径就可以了。这套方案的优点是代码侵入性极低不需要改任何业务接口。但我必须提醒你几个坑第一ConcurrentHashMap会一直持有每个接口的RateLimiter实例如果项目里接口是动态注册的要注意清理第二429状态码是HTTP标准里专门表示“请求太多”的别随手返回200否则调用方无法从状态码层面感知到限流第三如果配置中心没有接入阈值是写死在代码里的改阈值要发版这点在后续AOP方案里我会给出改进思路。2.3 单机限流的边界与坑单机限流最大的隐患我已经提到了——多实例部署时整体阈值等于单机阈值乘以实例数。你以为限了100 QPS实际对外是300 QPS3个实例。所以用单机限流之前一定要先确认系统部署架构是不是单实例或者在设计上明确它只是“兜底方案”。还有一个很多人忽略的性能细节RateLimiter.tryAcquire()在高并发下如果有大量请求失败会伴随大量异常日志输出。如果没有全局异常处理器默认的日志输出本身就会吃掉不少CPU和磁盘IO。我给个建议限流触发时记录一条WARN级别的聚合日志就够了千万别每拒绝一个请求就打印一次完整堆栈。另外要说的是Guava RateLimiter无法跨JVM共享状态也无法在多个实例间协调。如果系统已经上了Kubernetes、实例数量随时在变单机限流只能作为辅助手段真正的主限流必须走分布式方案。这也是下一章重点讲的内容。3. 分布式限流核心Redis Lua 实现令牌桶3.1 为什么选令牌桶为什么必须 Lua分布式限流方案本质上就两个派别一是纯Redis计数实现二是RedisLua脚本。计数实现的问题在于“检查-扣减”两步操作非原子在高并发下会出现超卖——两个线程同时读到剩余名额是1都认为可以放行实际就放行了2个。虽然可以用Redis的WATCH/MULTI事务解决但事务失败重试的成本很高性能也不好。正确的做法是把整个令牌桶的检查、扣减、补充逻辑写进一个Lua脚本通过Redis执行Lua是原子性的。Redis是单线程模型执行脚本期间不会穿插其他命令这就从根源上解决了并发问题。而且Lua脚本在Redis里可以缓存复用减少网络传输性能相比多次往返调用Redis要高得多。令牌桶算法的Lua实现思路我画个简图来描述Redis里用Hash结构存两个字段一个记录当前令牌数一个记录上次补充令牌的时间戳。每次请求来了先根据当前时间和上次补充时间算出这段时间应该补充多少个令牌把令牌数补到上限桶容量以内然后再判断令牌数是否足够本次请求消耗。这样设计的好处非常明显不需要额外的定时任务去生成令牌令牌的补充是“惰性”的只有在请求到达时才计算完全避免了对Redis的空轮询。3.2 完整 Lua 脚本逐行拆解先上一个我最初写的版本-- KEYS[1] 限流key -- ARGV[1] 桶容量 -- ARGV[2] 每秒补充令牌数 -- ARGV[3] 本次请求令牌数 local bucket redis.call(HMGET, KEYS[1], tokens, lastRefill) local tokens tonumber(bucket[1]) local lastRefill tonumber(bucket[2]) if tokens nil then tokens tonumber(ARGV[1]) lastRefill redis.call(TIME)[1] end local now redis.call(TIME)[1] local elapsed math.max(0, now - lastRefill) tokens math.min(tonumber(ARGV[1]), tokens elapsed * tonumber(ARGV[2])) local allowed 0 if tokens tonumber(ARGV[3]) then tokens tokens - tonumber(ARGV[3]) allowed 1 end redis.call(HSET, KEYS[1], tokens, tokens, lastRefill, now) redis.call(EXPIRE, KEYS[1], 10) return allowed这个版本能跑但很快就暴露了一个精度问题redis.call(TIME)[1]返回的是秒级时间戳。如果两个请求间隔小于1秒elapsed会算成0令牌补充就完全不生效如果QPS很高每秒钟的补充次数又会被“合并”成整秒一次导致限流速率不精确。在10万QPS的高压场景下这种误差不可接受。我后来改成了毫秒级时间戳-- KEYS[1] 限流key -- ARGV[1] 桶容量 -- ARGV[2] 每秒补充令牌数支持小数如0.5 -- ARGV[3] 本次请求所需令牌数 local bucket redis.call(HMGET, KEYS[1], tokens, lastRefillMs) local tokens tonumber(bucket[1]) local lastRefillMs tonumber(bucket[2]) if tokens nil then tokens tonumber(ARGV[1]) lastRefillMs redis.call(TIME)[1] * 1000 end local time redis.call(TIME) local nowMs tonumber(time[1]) * 1000 math.floor(tonumber(time[2]) / 1000) local elapsedMs math.max(0, nowMs - lastRefillMs) tokens math.min(tonumber(ARGV[1]), tokens elapsedMs / 1000.0 * tonumber(ARGV[2])) local allowed 0 if tokens tonumber(ARGV[3]) then tokens tokens - tonumber(ARGV[3]) allowed 1 end redis.call(HSET, KEYS[1], tokens, tokens, lastRefillMs, nowMs) redis.call(EXPIRE, KEYS[1], 10) return allowed这个版本的解释我给你逐行过一遍第一段HMGET取出这个key对应的当前令牌数和上次补充时间。如果key不存在说明是第一次请求直接把令牌数初始化成桶容量。这里有个重要的初始化逻辑一个全新的桶刚创建时是满的只有这样才能支持突发流量。第二段通过redis.call(TIME)拿到Redis服务器的当前时间。注意这里不能直接用Java端传时间进来因为分布式环境下各台机器的时间戳可能有偏差必须用Redis服务器的统一时间保证一致性。TIME命令返回的是[秒, 微秒]数组我把它统一换算成毫秒。第三段是算法的核心计算从上次补充到现在经过了多长时间这段时间内应该补充多少令牌elapsedMs / 1000.0 * refillRate然后加上原来的令牌数但不超过桶容量。为什么用math.min限制因为令牌桶的容量决定了最大突发量多余的令牌不能无限累积。第四段判断令牌是否够用够则扣减并返回1不够返回0。最后HSET把最新的令牌数和时间戳写回EXPIRE设置10秒过期键防止长期不用的限流key堆积在Redis里占内存。10秒这个值不是拍脑袋定的它保证即使在并发极高的情况下同一个key也不可能因为过期导致多个请求同时重建桶——因为10秒远远大于一次请求的处理时间。3.3 SpringBoot 整合RedisTemplate 调用与封装Lua脚本写好了接下来就是SpringBoot侧的整合。我直接给一个生产可用的封装Component public class RedisTokenBucketLimiter { private static final DefaultRedisScriptLong TOKEN_BUCKET_SCRIPT new DefaultRedisScript(); private static final String SCRIPT local bucket redis.call(HMGET, KEYS[1], tokens, lastRefillMs) local tokens tonumber(bucket[1]) local lastRefillMs tonumber(bucket[2]) if tokens nil then tokens tonumber(ARGV[1]) lastRefillMs redis.call(TIME)[1] * 1000 end local time redis.call(TIME) local nowMs tonumber(time[1]) * 1000 math.floor(tonumber(time[2]) / 1000) local elapsedMs math.max(0, nowMs - lastRefillMs) tokens math.min(tonumber(ARGV[1]), tokens elapsedMs / 1000.0 * tonumber(ARGV[2])) local allowed 0 if tokens tonumber(ARGV[3]) then tokens tokens - tonumber(ARGV[3]) allowed 1 end redis.call(HSET, KEYS[1], tokens, tokens, lastRefillMs, nowMs) redis.call(EXPIRE, KEYS[1], 10) return allowed; static { TOKEN_BUCKET_SCRIPT.setScriptText(SCRIPT); TOKEN_BUCKET_SCRIPT.setResultType(Long.class); } private final StringRedisTemplate redisTemplate; public RedisTokenBucketLimiter(StringRedisTemplate redisTemplate) { this.redisTemplate redisTemplate; } public boolean tryAcquire(String key, long capacity, double refillRate, int requested) { Long result redisTemplate.execute( TOKEN_BUCKET_SCRIPT, List.of(key), String.valueOf(capacity), String.valueOf(refillRate), String.valueOf(requested) ); return result ! null result 1L; } }有几个细节值得注意第一必须用StringRedisTemplate避免序列化器把参数类型搞乱第二DefaultRedisScript设置脚本文本和返回类型后要复用同一个实例不要每次请求都new一个否则脚本无法走Redis的脚本缓存机制性能会打折第三返回值用Long类型接收避免类型转换异常。调用方式boolean allowed limiter.tryAcquire(rate:limit:order:create, 200, 50, 1); if (!allowed) { throw new RateLimitException(当前请求量过大请稍后重试); }这里的语义是order:create这个操作桶容量200每秒补充50个令牌每个请求消耗1个令牌。如果按照每秒补充50个来计算这个接口在正常情况下最多支持50 QPS但短时间突发最多可以撑到200个并发请求。3.4 容量与速率怎么定一个可复用的估算方法这是我在面试和实际咨询里被问得最多的问题容量设多少合适速率设多少合适我的方法是倒推从业务容忍度和系统承载能力两个角度算。假设order:create接口平均RT是50ms单实例线程池核心线程数是200那么单实例理论吞吐是200 * (1000 / 50) 4000 QPS。如果部署了3个实例理论上限是12000 QPS。但数据库的写能力通常是瓶颈假设数据库能承受的写入是2000 TPS那么限流阈值至少应该低于2000我一般会留30%的冗余设到1400左右。这是速率的依据。容量突发量的设置要看你的业务能不能容忍瞬时尖峰。如果调用方偶尔有批量请求的诉求比如一个Job在1秒内会并发打进来100个请求那容量就不能低于100否则正常业务都会被误伤。如果完全没有突发需求容量直接等于速率就可以——相当于把令牌桶退化成漏桶用。实际经验是容量先设成速率的2倍观察线上曲线再逐步调整。不要指望一次设对限流的本质是反馈控制不是一次配置一劳永逸。4. 优雅升级AOP 自定义注解实现零侵入限流4.1 注解切面的设计思路拦截器方案把限流逻辑集中到了URI级别遇到Restful风格的接口就不好办了。/api/order/1和/api/order/2是同一个接口的两个不同URI拦截器按URI去匹配会为每一个具体URI创建独立的限流器——这显然不对。更合理的做法是把限流维度从“请求路径”上升到“业务操作”通过自定义注解标记在Controller或Service方法上用Spring AOP统一拦截。这样同一个接口无论URI怎么变只要方法相同限流就是同一个维度。而且注解上可以直接声明容量、速率、key的前缀配置就跟着方法走可读性和维护性都很好。设计思路是定义一个RateLimit注解包含key前缀、容量、补充速率、消耗令牌数等属性定义一个切面通过环绕通知拦截所有标注了RateLimit的方法在方法执行前调用分布式限流器拿不到令牌就抛异常或返回降级结果。4.2 完整代码实践先看注解定义Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { // 限流的key前缀最终key prefix : 业务维度值 String prefix() default rate:limit; // 桶容量 long capacity() default 100; // 每秒补充令牌数 double refillRate() default 10; // 每个请求消耗的令牌数 int requested() default 1; // 动态key的SpEL表达式比如从请求参数里取userId String keySpel() default ; }切面的核心逻辑Aspect Component public class RateLimitAspect { private static final SpelExpressionParser PARSER new SpelExpressionParser(); private static final DefaultParameterNameDiscoverer NAME_DISCOVERER new DefaultParameterNameDiscoverer(); private final RedisTokenBucketLimiter limiter; public RateLimitAspect(RedisTokenBucketLimiter limiter) { this.limiter limiter; } Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint joinPoint, RateLimit rateLimit) throws Throwable { String key buildKey(joinPoint, rateLimit); if (!limiter.tryAcquire(key, rateLimit.capacity(), rateLimit.refillRate(), rateLimit.requested())) { // 生产环境改成自定义异常由全局异常处理器统一处理 throw new RateLimitException(429, 请求过于频繁请稍后重试); } return joinPoint.proceed(); } private String buildKey(ProceedingJoinPoint joinPoint, RateLimit rateLimit) { String prefix rateLimit.prefix(); String keySpel rateLimit.keySpel(); if (StringUtils.hasText(keySpel)) { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); Object[] args joinPoint.getArgs(); String[] paramNames NAME_DISCOVERER.getParameterNames(method); EvaluationContext context new StandardEvaluationContext(); for (int i 0; i args.length; i) { context.setVariable(paramNames[i], args[i]); } Object bizValue PARSER.parseExpression(keySpel).getValue(context); return prefix : bizValue; } MethodSignature signature (MethodSignature) joinPoint.getSignature(); return prefix : signature.getDeclaringTypeName() : method.getName(); } }使用方式RestController RequestMapping(/api/order) public class OrderController { PostMapping(/create) RateLimit(prefix rate:order:create, capacity 200, refillRate 50) public ResultOrderVO create(RequestBody CreateOrderRequest request) { // 业务逻辑 } GetMapping(/query) RateLimit(prefix rate:order:query, capacity 500, refillRate 100, keySpel #userId) public ResultOrderVO query(RequestParam Long userId) { // 业务逻辑 } }第一接口按全局维度限流整个接口每秒只放行50个请求。第二个接口按userId维度限流每个用户每秒100个请求——这就是非常经典的用户维度的防刷限流。SpEL表达式这块代码看起来有点复杂但其实核心就三步取出方法的参数名和参数值绑定到EvaluationContext里然后解析RateLimit注解里的SpEL表达式。这样配置在注解上的“#userId”就能动态替换成实际请求传入的userId值。4.3 从接口限流到维度限流实际业务中的扩展玩法注解方案的想象力比拦截器大得多。我在这套基础上做过几种扩展实际效果都很好。第一种是黑白名单维度。如果调用方是一个开放平台每个第三方应用都有独立的appId那就在切面里根据请求头里的appId拼进key每个应用单独限流。某个应用流量异常不会影响其他应用比全局一刀切优雅得多。第二种是动态阈值。限流阈值不写死从配置中心读取。比如双11大促运营提前把这个接口的限流阈值从1000调到5000不用发版配置中心一推就生效。切面里通过配置服务查一遍当前阈值再组装成key和参数传给限流器。第三种是分级降级。我在架构里把限流触发后的处理方式也做成了可配置A级接口限流后直接返回固定文案B级接口限流后走缓存数据C级接口限流后丢进MQ排队异步处理。一套注解三种策略开发只需要在注解上多配一个字段。这套方案虽然好但有个前提你要清楚切面逻辑跑在业务应用进程里每次请求都多一次Redis调用。如果业务接口本身RT只有5msRedis调用就要1ms这个开销占比不小。所以我在高QPS场景还会加一道本地缓存做“二级限流”——先用本地RateLimiter粗筛一遍再走Redis精筛这样可以大幅减少对Redis的压力。这个技巧我在最后一章还会展开讲。5. 网关层限流与集群方案5.1 Spring Cloud Gateway Redis RateLimiter如果你的项目架构里已经有了Spring Cloud Gateway那网关限流就是最外层的第一道防线。Gateway官方内置了RequestRateLimiter过滤器底层也是基于Redis Lua实现的令牌桶可以直接复用。引入spring-boot-starter-data-redis-reactive之后配置一个KeyResolver和对应的Filter参数spring: application: name: gateway-service cloud: gateway: routes: - id: order-service uri: lb://order-service predicates: - Path/api/order/** filters: - name: RequestRateLimiter args: redis-rate-limiter.replenishRate: 100 redis-rate-limiter.burstCapacity: 200 redis-rate-limiter.requestedTokens: 1 key-resolver: #{userKeyResolver}这里redis-rate-limiter.replenishRate对应令牌桶的补充速率burstCapacity对应桶容量requestedTokens是每次请求消耗的令牌数。KeyResolver决定按什么维度限流Configuration public class RateLimiterConfig { Bean public KeyResolver userKeyResolver() { return exchange - { // 优先取用户ID没有则取IP String userId exchange.getRequest().getHeaders().getFirst(X-User-Id); if (userId ! null) { return Mono.just(userId); } String clientIp exchange.getRequest().getRemoteAddress().getAddress().getHostAddress(); return Mono.just(clientIp); }; } }网关限流最核心的价值是“前置”请求还没到业务服务就已经被拦截了。这意味着即使业务服务所有实例全部宕机限流器依然在工作不会放进来海量请求把下游压死。我在一次线上大促中实测网关层限流扛掉了大约80%的异常流量业务服务的压力瞬间降了一个数量级。5.2 Sentinel 整合 SpringBoot 快速上手如果不想自己维护Redis Lua这套方案可以考虑阿里开源的Sentinel。Sentinel除了限流还整合了熔断、降级、系统保护是整套高可用方案。引入依赖dependency groupIdcom.alibaba.cloud/groupId artifactIdspring-cloud-starter-alibaba-sentinel/artifactId version2021.0.5.0/version /dependency配置控制台地址spring: cloud: sentinel: transport: dashboard: localhost:8080 eager: true在代码里用注解标记资源RestController public class OrderController { SentinelResource(value order:create, blockHandler createBlockHandler, fallback createFallback) PostMapping(/api/order/create) public ResultOrderVO create(RequestBody CreateOrderRequest request) { // 业务逻辑 } // 限流/熔断触发时调用方法的入参和返回值要与原方法一致最后加BlockException参数 public ResultOrderVO createBlockHandler(CreateOrderRequest request, BlockException e) { return Result.error(429, 请求过于频繁请稍后再试); } // 业务异常时调用 public ResultOrderVO createFallback(CreateOrderRequest request, Throwable t) { return Result.error(500, 服务开小差了); } }从控制台可视化配置限流规则修改规则后实时生效不需要重启应用。Sentinel支持集群限流引入cluster-client依赖、配置Token Server后可以做到集群维度共享限流配额这是自研方案实现成本比较高的一个能力。不过Sentinel也有它的学习成本和运维成本控制台、Nacos规则源、集群TokenServer三件套要维护好团队规模不大谨慎引入。我用Sentinel的经验是适合中大型团队、有完善微服务体系的场景如果只是两三个服务、三五个人维护自己写RedisLua反而更可控。5.3 限流与降级、熔断的配合限流不是万能的它只能控制流量入口一旦流量进来后依赖的下游已经故障单纯的限流救不了你。真正的防线是限流、熔断、降级三者联动。我自己的实践顺序是这样的网关层用RequestRateLimiter做粗粒度限流挡掉明显的恶意流量和爬虫应用层用自己封装的Redis令牌桶做业务维度的精细限流同时用Sentinel或Resilience4j做下游依赖的熔断和降级。举个例子订单服务调用库存服务库存服务响应变慢此时不应该让订单请求无休止地等下去而是触发熔断快速返回“当前库存查询繁忙”同时开启降级策略把订单链路切换到本地缓存数据。限流管住入口熔断管住出口降级管住兜底三层各司其职系统才能在高并发下真实地存活下来。这个设计里还有一个容易被忽略的点限流触发时要同步记录监控指标。每拦截一个请求都要把拒绝原因、拒绝数量、当时的QPS打到Metrics和日志里。否则限流倒是生效了但运营和DB看到业务量下降会误以为系统故障反而引发更大的慌乱。我习惯给限流相关的指标单独建一个Grafana面板实时展示每个接口的通过量、拦截量、触发限流的Top Key列表。6. 踩坑实录与性能调优6.1 Redis 故障时的容灾策略分布式限流把Redis变成了强依赖Redis一旦不可用限流器将无法做出判断。这里有个经典的容灾选择Redis挂了是放行还是拒绝我踩过这个坑。早期线上压测我把Redis一停所有限流接口全部拒绝请求结果是业务直接被“保护性”瘫痪——比不限流还惨因为用户看到的是所有接口全部报错。后来我改成了“故障放行”策略Redis调用异常时tryAcquire方法返回true同时打出一条告警。核心思路是Redis都挂了说明基础设施已经出了问题此时更重要的是保证业务可用性而不是限流。当然这个决策要看你的业务场景——如果限流保护的是一条支付链路那么放行导致的雪崩代价远大于拒绝如果是普通查询接口放行显然更合理。我最终做成了可配置Value(${rate.limit.failopen:true}) private boolean failOpen; public boolean tryAcquire(String key, long capacity, double refillRate, int requested) { try { Long result redisTemplate.execute(TOKEN_BUCKET_SCRIPT, List.of(key), String.valueOf(capacity), String.valueOf(refillRate), String.valueOf(requested)); return result ! null result 1L; } catch (Exception e) { if (failOpen) { log.error(Redis限流异常自动放行: {}, e.getMessage()); return true; } throw e; } }为了让catch到的异常传递更多上下文我会把key、capacity这些参数打进去。另外每次Redis限流失败用一个单独的计数器累加超过阈值就触发钉钉/企业微信告警。这样既保证了业务可用性又不至于Redis故障一段时间后你完全没感知。6.2 限流阈值设置的经验公式阈值设置没有标准答案但有一个可复用的推导路径。核心思路是从下游承载能力倒推。假设数据库最大连接数是100每个连接执行一次SQL需要20ms那么数据库层面单实例能承受的最大TPS是100 * (1000 / 20) 5000。但数据库通常是多实例或主从复制架构还要考虑网络开销、连接池获取等待时间实际能力至少打个五折。再考虑CPU、内存、磁盘IO等资源预留最终我一般在理论值基础上乘以30%-50%的安全系数。对于调用第三方接口的场景限流阈值直接按第三方给定的配额来。第三方的QPS限制是1000你的限流阈值就设800留20%的buffer。这种场景千万别设置太高不然第三方先反手把你的调用方封了整个链路直接被BLOCK。对于接口内部逻辑复杂、耗时长的场景用并发信号量限流往往比QPS限流更实用。令牌桶限的是“每秒多少个”信号量限的是“同时多少个”。如果接口RT是2秒QPS为10的时候并发已经到20了但如果你限制的是并发信号量不超过5那QPS实际只有2.5。长耗时接口用信号量更贴合资源占用情况这是我踩过几次坑后的体会。6.3 容易被忽略的细节问题最后一个章节我整理几个平时容易忽略、但线上一定会遇到的细节。第一个是Linux下TIME命令的微秒精度问题。我最初版本里用秒做时间单位压测发现限流速率偏差很大排查半天才发现是单位问题。改成毫秒后精准很多但如果对精度有极致要求可以用微秒做单位无非是把elapsedMs全部换成elapsedUs乘以1000而已。第二个是stringRedisTemplate里脚本参数的类型问题。Lua脚本里的和*运算要求两边都是number类型。如果从ARGV传进来的值是形如“10.0”的字符串tonumber转换没问题但如果你用了RedisSerializer的Jackson序列化传进去的可能带了引号或者类型标识符脚本就会报错。我的建议是统一用StringRedisTemplate所有参数在Java端转成字符串传进去由Lua里的tonumber统一处理。第三个是key的过期时间与重建竞态问题。我给每个限流key设置了10秒过期好处是防止Redis内存被无用的key撑爆。但要注意过期时间不能太短否则一个低QPS接口的限流key频繁过期重建桶的初始状态一直被打断限流效果会失真。比如一个接口只有2 QPS速率为10每秒如果key过期为1秒那几乎所有请求到来时桶都是刚重建的满桶状态限流形同虚设。10秒是我的经验值如果你的接口QPS极低建议把过期时间适当加大到30秒甚至60秒。第四个是我在前文提到的两级限流优化。纯Redis限流每请求至少一次网络往返对RT要求极严的接口不友好。我的做法是在应用内先放一个Guava RateLimiter做预筛本地允许通过的请求才去查Redis本地拒绝的直接返回。比如目标速率是每秒100本地RateLimiter设成每秒120那么大约只有六分之一的请求会真正打到Redis。这样Redis的压力降了一个数量级分布式限流的精度几乎不受影响。最后再说说监控。限流器一定要有独立的监控指标包括每个接口的拒绝次数、拒绝速率、限流key数量。开发同学心里要清楚限流不只是保护系统还是业务决策的数据来源。某个接口频繁触发限流说明调用方有异常行为或者业务确实需要扩容了先别急着调大阈值先去看调用方是谁、在什么时间点、用什么参数打进来的请求。我个人在实际项目里最有感触的一个点是限流阈值不是设置完就不用管的。上线第一周每天都要看限流拦截曲线根据业务的真实流量特征去微调参数。调整两三次之后限流器的行为才会稳定下来。它不是一个防火墙配好就忘的东西更像是一个需要持续养护的韧性系统——你对流量的理解越深限流器的效果就越好。希望这篇实战经验能帮你把限流这件事一次做对。