Rate_Limit限流
限流算法固定窗口计数器算法固定窗口其实就是时间窗口其原理是将时间划分为固定大小的窗口在每个窗口内限制请求的数量或速率即固定窗口计数器算法规定了系统单位时间处理的请求数量。假如我们规定系统中某个接口 1 分钟只能被访问 33 次的话使用固定窗口计数器算法的实现思路如下将时间划分固定大小窗口这里是 1 分钟一个窗口。给定一个变量counter来记录当前接口处理的请求数量初始值为 0代表接口当前 1 分钟内还未处理请求。1 分钟之内每处理一个请求之后就将counter1当counter33之后也就是说在这 1 分钟内接口已经被访问 33 次的话后续的请求就会被全部拒绝。等到 1 分钟结束后将counter重置 0重新开始计数。优点实现简单易于理解。缺点限流不够平滑。例如我们限制某个接口每分钟只能访问 30 次假设前 30 秒就有 30 个请求到达的话那后续 30 秒将无法处理请求这是不可取的用户体验极差无法保证限流速率因而无法应对突然激增的流量。例如我们限制某个接口 1 分钟只能访问 1000 次该接口的 QPS 为 500前 55s 这个接口 1 个请求没有接收后 1s 突然接收了 1000 个请求。然后在当前场景下这 1000 个请求在 1s 内是没办法被处理的系统直接就被瞬时的大量请求给击垮了。滑动窗口计数器算法滑动窗口计数器算法算的上是固定窗口计数器算法的升级版限流的颗粒度更小。滑动窗口计数器算法相比于固定窗口计数器算法的优化在于它把时间以一定比例分片。例如我们的接口限流每分钟处理 60 个请求我们可以把 1 分钟分为 60 个窗口。每隔 1 秒移动一次每个窗口一秒只能处理不大于60(请求数)/60窗口数的请求 如果当前窗口的请求计数总和超过了限制的数量的话就不再处理其他请求。很显然当滑动窗口的格子划分的越多滑动窗口的滚动就越平滑限流的统计就会越精确。优点相比于固定窗口算法滑动窗口计数器算法可以应对突然激增的流量。相比于固定窗口算法滑动窗口计数器算法的颗粒度更小可以提供更精确的限流控制。缺点与固定窗口计数器算法类似滑动窗口计数器算法依然存在限流不够平滑的问题。相比较于固定窗口计数器算法滑动窗口计数器算法实现和理解起来更复杂一些。漏桶算法我们可以把发请求的动作比作成注水到桶中我们处理请求的过程可以比喻为漏桶漏水。我们往桶中以任意速率流入水以一定速率流出水。当水超过桶流量则丢弃因为桶容量是不变的保证了整体的速率。如果想要实现这个算法的话也很简单准备一个队列用来保存请求然后我们定期从队列中拿请求来执行就好了和消息队列削峰/限流的思想是一样的。优点实现简单易于理解。可以控制限流速率避免网络拥塞和系统过载。缺点无法应对突然激增的流量因为只能以固定的速率处理请求对系统资源利用不够友好。桶流入水发请求的速率如果一直大于桶流出水处理请求的速率的话那么桶会一直是满的一部分新的请求会被丢弃导致服务质量下降。实际业务场景中基本不会使用漏桶算法。令牌桶算法令牌桶算法也比较简单。和漏桶算法算法一样我们的主角还是桶这限流算法和桶过不去啊。不过现在桶里装的是令牌了请求在被处理之前需要拿到一个令牌请求处理完毕之后将这个令牌丢弃删除。我们根据限流大小按照一定的速率往桶里添加令牌。如果桶装满了就不能继续往里面继续添加令牌了。优点可以限制平均速率和应对突然激增的流量。可以动态调整生成令牌的速率。缺点如果令牌产生速率和桶的容量设置不合理可能会出现问题比如大量的请求被丢弃、系统过载。相比于其他限流算法实现和理解起来更复杂一些。针对什么来进行限流实际项目中还需要确定限流对象也就是针对什么来进行限流。常见的限流对象如下IP 针对 IP 进行限流适用面较广简单粗暴。业务 ID挑选唯一的业务 ID 以实现更针对性地限流。例如基于用户 ID 进行限流。个性化根据用户的属性或行为进行不同的限流策略。例如 VIP 用户不限流而普通用户限流。根据系统的运行指标如 QPS、并发调用数、系统负载等动态调整限流策略。例如当系统负载较高的时候控制每秒通过的请求减少。针对 IP 进行限流是目前比较常用的一个方案。不过实际应用中需要注意用户真实 IP 地址的正确获取。常用的真实 IP 获取方法有 X-Forwarded-For 和 TCP Options 字段承载真实源 IP 信息。虽然 X-Forwarded-For 字段可能会被伪造但因为其实现简单方便很多项目还是直接用的这种方法。除了我上面介绍到的限流对象之外还有一些其他较为复杂的限流对象策略比如阿里的 Sentinel 还支持 基于调用关系的限流包括基于调用方限流、基于调用链入口限流、关联流量限流等以及更细维度的 热点参数限流实时的统计热点参数并针对热点参数的资源调用进行流量控制。另外一个项目可以根据具体的业务需求选择多种不同的限流对象搭配使用。单机限流怎么做单机限流针对的是单体架构应用。单机限流可以直接使用 Google Guava 自带的限流工具类RateLimiter。RateLimiter基于令牌桶算法可以应对突发流量。Guava 地址https://github.com/google/guava除了最基本的令牌桶算法(平滑突发限流)实现之外Guava 的RateLimiter还提供了平滑预热限流的算法实现。平滑突发限流就是按照指定的速率放令牌到桶里而平滑预热限流会有一段预热时间预热时间之内速率会逐渐提升到配置的速率。我们下面通过两个简单的小例子来详细了解吧我们直接在项目中引入 Guava 相关的依赖即可使用。import com.google.common.util.concurrent.RateLimiter; public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 RateLimiter rateLimiter RateLimiter.create(5); for (int i 0; i 10; i) { double sleepingTime rateLimiter.acquire(1); System.out.printf(get 1 tokens: %ss%n, sleepingTime); } } }输出get 1 tokens: 0.0s get 1 tokens: 0.188413s get 1 tokens: 0.197811s get 1 tokens: 0.198316s get 1 tokens: 0.19864s get 1 tokens: 0.199363s get 1 tokens: 0.193997s get 1 tokens: 0.199623s get 1 tokens: 0.199357s get 1 tokens: 0.195676s下面是一个简单的 Guava 平滑预热限流的 Demo。import com.google.common.util.concurrent.RateLimiter; import java.util.concurrent.TimeUnit; public class RateLimiterDemo { public static void main(String[] args) { // 1s 放 5 个令牌到桶里也就是 0.2s 放 1个令牌到桶里 // 预热时间为3s,也就说刚开始的 3s 内发牌速率会逐渐提升到 0.2s 放 1 个令牌到桶里 RateLimiter rateLimiter RateLimiter.create(5, 3, TimeUnit.SECONDS); for (int i 0; i 20; i) { double sleepingTime rateLimiter.acquire(1); System.out.printf(get 1 tokens: %sds%n, sleepingTime); } } }输出get 1 tokens: 0.0s get 1 tokens: 0.561919s get 1 tokens: 0.516931s get 1 tokens: 0.463798s get 1 tokens: 0.41286s get 1 tokens: 0.356172s get 1 tokens: 0.300489s get 1 tokens: 0.252545s get 1 tokens: 0.203996s get 1 tokens: 0.198359s为什么要有预热限流因为很多服务刚启动时不能直接扛满流量例如数据库刚启动连接池还没初始化缓存冷启动没有数据JIT 还没编译优化服务刚启动直接满流量进来会直接卡死所以需要流量慢慢增加 → 系统慢慢预热 → 最后达到最大 QPS分布式限流怎么做分布式限流针对的分布式/微服务应用架构应用在这种架构下单机限流就不适用了因为会存在多种服务并且一种服务也可能会被部署多份。分布式限流常见的方案借助中间件限流可以借助 Sentinel 或者使用 Redis 来自己实现对应的限流逻辑。网关层限流比较常用的一种方案直接在网关层把限流给安排上了。不过通常网关层限流通常也需要借助到中间件/框架。就比如 Spring Cloud Gateway 的分布式限流实现RedisRateLimiter就是基于 RedisLua 来实现的再比如 Spring Cloud Gateway 还可以整合 Sentinel 来做限流。如果你要基于 Redis 来手动实现限流逻辑的话建议配合 Lua 脚本来做。为什么建议 RedisLua 的方式主要有两点原因减少了网络开销我们可以利用 Lua 脚本来批量执行多条 Redis 命令这些 Redis 命令会被提交到 Redis 服务器一次性执行完成大幅减小了网络开销。原子性一段 Lua 脚本可以视作一条命令执行一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行保证了操作不会被其他指令插入或打扰。我这里就不放具体的限流脚本代码了网上也有很多现成的优秀的限流脚本供你参考就比如 Apache 网关项目 ShenYu 的 RateLimiter 限流插件就基于 Redis Lua 实现了令牌桶算法/并发令牌桶算法、漏桶算法、滑动窗口算法。Redisson 中的RRateLimiter另外如果不想自己写 Lua 脚本的话也可以直接利用 Redisson 中的RRateLimiter来实现分布式限流其底层实现就是基于 Lua 代码令牌桶算法。Redisson 是一个开源的 Java 语言 Redis 客户端提供了很多开箱即用的功能比如 Java 中常用的数据结构实现、分布式锁、延迟队列等等。并且Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。RRateLimiter的使用方式非常简单。我们首先需要获取一个RRateLimiter对象直接通过 Redisson 客户端获取即可。然后设置限流规则就好。// 创建一个 Redisson 客户端实例 RedissonClient redissonClient Redisson.create(); // 获取一个名为 javaguide.limiter 的限流器对象 RRateLimiter rateLimiter redissonClient.getRateLimiter(javaguide.limiter); // 尝试设置限流器的速率为每小时 100 次 // RateType 有两种OVERALL是全局限流,ER_CLIENT是单Client限流可以认为就是单机限流 rateLimiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.HOURS);接下来我们调用acquire()方法或tryAcquire()方法即可获取许可。// 获取一个许可如果超过限流器的速率则会等待 // acquire()是同步方法对应的异步方法acquireAsync() rateLimiter.acquire(1); // 尝试在 5 秒内获取一个许可如果成功则返回 true否则返回 false // tryAcquire()是同步方法对应的异步方法tryAcquireAsync() boolean res rateLimiter.tryAcquire(1, 5, TimeUnit.SECONDS);“实际项目里限流怎么做”我按这条线来总结注解入口 → AOP 执行 → Lua 原子限流 → 异常/降级 → 实际落点与优缺点。第一步是实现Rate_Limit接口的定义package interview.guide.common.annotation; import interview.guide.common.aspect.RateLimitAspect; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; /** * 限流注解 * 用于方法级别的限流控制支持多维度组合限流 * * see RateLimitAspect */ Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { /** * 限流维度枚举 */ enum Dimension { /** * 全局限流对所有请求统一限流 */ GLOBAL, /** * IP限流按客户端IP地址限流 */ IP, /** * 用户限流按用户ID限流 */ USER } /** * 限流维度配置 * 支持多维度组合只有所有维度都满足条件时才允许请求通过 * 例如{Dimension.GLOBAL, Dimension.USER} 表示同时进行全局限流和用户级限流 * * return 限流维度数组 */ Dimension[] dimensions() default {Dimension.GLOBAL}; /** * 在指定时间窗口内允许的最大请求数 * 例如count 10, interval 1, timeUnit MINUTES 表示每分钟最多 10 次 * * return 令牌总数 */ double count(); /** * 时间窗口大小 * 默认 1 * * return 时间窗口 */ long interval() default 1; /** * 时间单位 * 默认为秒即默认“每秒 count 次” * * return 时间单位 */ TimeUnit timeUnit() default TimeUnit.SECONDS; /** * 等待令牌的超时时间 * 如果设置为0表示不等待直接获取令牌失败则拒绝 * 如果大于0会尝试等待指定时间获取令牌 * * return 超时时间 */ long timeout() default 0; /** * 降级方法名 * 当限流触发时调用指定方法进行降级处理 * 降级方法支持 * 1. 无参方法 * 2. 与原方法参数列表完全一致的方法 * 降级方法必须在同一个类中返回值类型与原方法兼容 * 如果为空字符串则抛出 RateLimitExceededException 异常 * * return 降级方法名 */ String fallback() default ; /** * 时间单位枚举 */ enum TimeUnit { MILLISECONDS, SECONDS, MINUTES, HOURS, DAYS } }维度GLOBAL/IP/USER配额count interval timeUnit降级fallback执行在 AOP 切面 Around(annotation(rateLimit)) 拦截计算时间窗口毫秒根据维度生成多个 key这里我们生成的Redis Key带 HashTag支持集群ratelimit:{AIController:chat}:global ratelimit:{AIController:chat}:ip:127.0.0.1 ratelimit:{AIController:chat}:user:1001HashTag {类名方法名} 保证所有 key 在同一个 slot→ Redis Cluster 下 Lua 脚本仍能原子执行。调用 Redis LuaevalSha做原子判定失败则执行 fallback 或抛 RateLimitExceededException✔fallback 服务降级 限流后不抛异常返回友好结果什么是降级接口被限流了不报错不崩溃不返回 500而是执行一个你预先写好的 “备胎方法”例子RateLimit(count 10, fallback fallbackChat) public Result chat() { ... } public Result fallbackChat() { return Result.fail(请求过于频繁请稍后再试); }降级方法 备胎方法限流 → 走备胎 → 给用户友好提示Lua 脚本核心是“两阶段”预检查阶段遍历所有维度GLOBAL/IP/USER清理过期请求恢复可用令牌扣减阶段只有全部维度通过才统一扣减并写入本次记录记录当前请求扣减令牌设置过期时间返回 1成功异常走业务异常体系RateLimitExceededException 被 GlobalExceptionHandler 的 BusinessException 统一封装返回。多维度组合限流同一请求可同时受 GLOBAL IP 约束例如控制器上普遍这样用。Redis Cluster 兼容key 生成时用了 HashTag{ClassName:methodName}见 generateKeys。这样同一接口的各维度 key 落同一 slotLua 可在单节点原子执行。滑动窗口实现方式:permitsZSet记录每次请求scoretimestamp:valueString记录当前可用令牌每次先清理窗口外记录再回收令牌再判断/扣减声明式降级能力fallback 支持同参方法或无参方法反射调用。底层数据结构zremrangebyscore删除过期请求zcard或计数逻辑 统计当前请求数判断是否超过上限zadd记录本次请求本质这是“Redis Lua AOP”的分布式限流方案重点是原子性和易接入。为什么注解AOP业务代码无侵入统一治理后续加监控/降级更容易。面试问答“关于你的限流设计思路”1. 为什么要分布式限流因为你的项目是AI 接口调用 → 每次都花钱Token 计费多实例部署集群必须全局统一控制请求量防止恶意刷接口、爆 Token、压垮模型所以必须用Redis 全局统一计数。2. 为什么用 Lua 脚本因为限流必须原子性先判断 → 再扣减 → 必须一步完成否则高并发下会超发、超卖Redis 执行 Lua 是单线程原子性所以绝对安全。3. 为什么用滑动窗口固定窗口会出现边界突刺滑动窗口更平滑、更精准适合 AI 接口这种耗时、高代价接口4. 为什么支持多维度GLOBAL接口层面最大 QPS 控制IP同一个 IP 防刷USER同一个用户防刷可组合使用必须全部通过才放行5. 为什么加 HashTag因为 Redis Cluster 会把 key 分到不同槽不加 HashTag → 多 KEY 无法原子执行加{class:method}→ 所有 key 落在同一个 slot →Lua 原子性保证