新闻详情

新闻详情

首页 / 资讯中心 / 详情

Java实现四种限流算法:从固定窗口到Redis分布式令牌桶

发布时间:2026/10/2 9:02:42来源:尧图网络
Java实现四种限流算法:从固定窗口到Redis分布式令牌桶
做后端这几年我越来越觉得限流算法是分布式系统里最实在的一道防线。线上流量从来不会按你的预期走——大促秒杀、热点新闻、恶意刷接口随便一个高峰都可能把后端打挂。我印象最深的一次事故是半夜做全链路压测时一个没做限流的下游服务被流量直接冲垮紧接着整个调用链跟着雪崩一群人排查到天亮。从那以后我的项目里限流这一层再没缺过席这篇文章我用JAVA代码从零把四种经典算法讲透再拎出分布式场景下的实现方案和实战坑点。如果你正在整理java面试题或者正在设计微服务架构这篇文章应该能帮你把限流这条线彻底捋顺。1. 先搞明白限流到底在解决什么问题1.1 一次线上雪崩事故说起先讲一个我亲历的场景。有个订单查询服务平时QPS大约200稳得很。结果双十一预热那天运营在首页挂了条推送消息瞬间涌进来上千的QPS。查询服务本身撑住了但它下游的数据库连接池被占满了接着数据库慢查询拖垮了缓存缓存失效后更多请求直接打到DB最终整个链路崩掉。这个事故里问题不是出在服务没扩容而是出在没人告诉服务超过能力的流量你直接丢掉就行。限流要解决的核心问题有两个保护自己防止自身服务被超出处理能力的流量打垮导致线程池耗尽、内存溢出、连接池占满。保护下游防止上游突发流量把数据库、第三方API、消息队列等下游系统冲垮避免故障沿调用链扩散。一句话总结限流不是让服务能处理更多请求而是让服务在任何流量下都只处理它能处理的数量。1.2 限流、熔断、降级如何分工很多人把限流、熔断、降级混在一起讲面试时也容易答乱。我习惯这样区分限流管量——我最多同时处理N个请求超过的部分拒绝或排队。熔断管错——下游连续出错超过阈值我就直接断开不再调用。降级管备——核心路径挂了我用缓存数据或默认值顶上保证主流程还能跑。三者的关系是一条防御链限流挡住超出预期的流量熔断挡住不断出错的下游降级兜住最坏情况下的用户体验。限流是第一道闸门也是成本最低的一道——你只需要在入口把流量拦住下游根本感知不到异常。2. 四种经典限流算法思路、缺陷与适用场景2.1 固定窗口计数器最朴素但藏着边界陷阱固定窗口计数器的思路是最直白的把时间切成固定大小的窗口比如1分钟。每一分钟内累计请求数达到上限就拒绝窗口一过计数清零。用生活场景类比小区门口有个保安他看着手表每一分钟内最多放100个人进门到了下一分钟重新数。简单粗暴但有一个很严重的问题——窗口边界会出现双倍流量突刺。举个例子限制每分钟100次。假设在23:59:50到24:00:00这10秒内系统已经收到了100次请求全部放行00:00:00到00:00:10这10秒内又来了100次请求也全部放行。表面看每一分钟都没超限但实际上这20秒内系统承受了200次请求。如果系统真实能力是每分钟150次这个算法就已经压垮它了。这个问题的根源在于固定窗口的计数边界和真实流量边界完全对不上。解决思路自然就来了——把窗口切开看细粒度。2.2 滑动窗口把大窗口切成小细格滑动窗口的思路是把固定窗口的粒度细化。还是限定1分钟内100次但不以整分钟为单位而是把这一分钟切成10段每段6秒。每当新请求进来就往当前时间段里加一个计数同时把超过当前时间戳往前1分钟范围的旧计数全部清掉。窗口是滑的不存在整点重置的突刺问题。还是拿上面那个例子如果60秒窗口被切成10个6秒小格那么前10秒最多只能积累约1/6的配额也就是最多17个左右请求假设流量均匀的话。边界突刺被平滑掉了。滑动窗口的精度取决于切分粒度。切成10格能挡住大概90%的边界问题切成60格甚至更高几乎可以做到平滑限流。但同时每一格都要记住一个计数在内存里维护的时间戳链表会随并发量变大实现时要注意清理过期数据的性能。2.3 漏桶算法用恒定速率把流量抹平漏桶的核心思想完全不同不管流量进来的速度多快、多不平滑出去的速度必须是恒定的。想象一个底部有洞的桶水滴进来但不管桶里有多少水都只从那个恒定大小的洞流出去。桶满了多余的水就从边缘溢出——溢出的部分就是被拒绝的请求。漏桶算法有两大特性强制平滑无论上游多急躁下游看到的流量都是匀速的对下游非常友好。天然抗突发桶容量固定突发请求会把桶填满之后全部溢出拒绝。它的缺陷也随之而来如果系统本身有能力在短时间内处理更多请求比如峰值能力是每秒50个均值只需每秒10个漏桶还是会死守每秒10个的速率不让你利用空闲处理能力。对大多数互联网服务来说这有点浪费——我们通常希望平时攒着能力峰值时放一波。2.4 令牌桶算法既限速又允许突发令牌桶就是在漏桶基础上改良出来的。系统以固定速率往桶里放令牌桶最多存N个令牌。每个请求进来时先取一个令牌拿到就放行拿不到就拒绝。因为桶里可以预存令牌所以某个瞬间即使来的请求数超过平均速率只要令牌存量足够也能一次性放行。生活化类比游乐场门票发售点每小时固定放出10张票但售票窗口的抽屉里最多能存100张以前没卖完的。有人一下子来团购50张只要抽屉里还有就能全部取走。这就是允许突发。需要补充的一点是令牌桶通常配合预消费策略使用。也就是拿不到令牌时不是立刻拒绝而是计算需要等多久才能轮到让请求在线程里排队。很多中间件比如Guava的RateLimiter就是这个逻辑。四种算法的定位已经比较清晰了我把选型建议放在代码实现之后因为看完代码你才能get到它们的实现成本差异。3. 手写JAVA实现从算法到可运行代码3.1 固定窗口与滑动窗口的JAVA实现先看固定窗口最简单的一版用synchronized保证线程安全public class FixedWindowRateLimiter { private final int maxRequests; private final long windowSizeMs; private long windowStart; private int requestCount; public FixedWindowRateLimiter(int maxRequests, long windowSizeMs) { this.maxRequests maxRequests; this.windowSizeMs windowSizeMs; this.windowStart System.currentTimeMillis(); this.requestCount 0; } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); if (now - windowStart windowSizeMs) { windowStart now; requestCount 0; } if (requestCount maxRequests) { requestCount; return true; } return false; } }注意两个细节第一窗口重置的逻辑是一旦发现当前时间超出窗口起点就把窗口起点挪到当前时间而不是去计算当前应该属于哪个窗口。这会让第一个超过窗口的请求直接开启新窗口整个窗口的边界由请求时间动态决定省去了定时器。第二计数是int类型在超高并发下可能存在溢出风险实际生产里可以换成AtomicLong或LongAdder。滑动窗口的实现可以有很多种。我比较喜欢用Deque存时间戳每次请求时先清理过期时间戳再判断队列长度public class SlidingWindowRateLimiter { private final int maxRequests; private final long windowSizeMs; private final DequeLong timestamps new ArrayDeque(); public SlidingWindowRateLimiter(int maxRequests, long windowSizeMs) { this.maxRequests maxRequests; this.windowSizeMs windowSizeMs; } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); long windowStart now - windowSizeMs; while (!timestamps.isEmpty() timestamps.peekFirst() windowStart) { timestamps.pollFirst(); } if (timestamps.size() maxRequests) { timestamps.addLast(now); return true; } return false; } }这段代码的优点是逻辑极简基本就是把滑动窗口的定义直接翻译成代码。缺点是每个时间戳都存内存开销会随着QPS增长且清理是O(过期数量)的。生产上更高效的做法是分桶计数——提前把窗口切成N个格子每个格子只存一个计数过期时把整个格子的计数清零。这样内存固定清理成本为O(1)。但实现稍微绕一点我在这里给出思路具体代码你可以自己动手写一版印象会更深刻。3.2 漏桶与令牌桶的实现细节漏桶的实现核心是计算流出的水量而不是真的用一个定时器来滴水。每次请求进来时先根据当前时间与上次漏水时间的差值计算出这段时间漏掉了多少水把桶里的水位降下去public class LeakyBucketRateLimiter { private final long capacity; private final double leakRatePerSecond; private double water; private long lastLeakTime; public LeakyBucketRateLimiter(long capacity, double leakRatePerSecond) { this.capacity capacity; this.leakRatePerSecond leakRatePerSecond; this.water 0; this.lastLeakTime System.nanoTime(); } public synchronized boolean tryAcquire() { long now System.nanoTime(); double elapsedSeconds (now - lastLeakTime) / 1_000_000_000.0; water Math.max(0, water - elapsedSeconds * leakRatePerSecond); lastLeakTime now; if (water 1 capacity) { water; return true; } return false; } }这里我用了System.nanoTime()而不是System.currentTimeMillis()这点很关键。currentTimeMillis是墙钟时间如果系统时间被NTP同步或人工调整会出现时间倒退导致计算出的漏水量是负的限流器直接失效。nanoTime是单调递增的只要硬件支持只用于计算时间差不怕时间被改。这个细节在java面试八股里也常被问到。令牌桶的代码和漏桶长得很像区别是漏桶减去的是水量令牌桶加上的是令牌数还要用Math.min把令牌数封顶在capacitypublic class TokenBucketRateLimiter { private final long capacity; private final double refillRatePerSecond; private double tokens; private long lastRefillTime; public TokenBucketRateLimiter(long capacity, double refillRatePerSecond) { this.capacity capacity; this.refillRatePerSecond refillRatePerSecond; this.tokens capacity; this.lastRefillTime System.nanoTime(); } public synchronized boolean tryAcquire(int permits) { long now System.nanoTime(); double elapsedSeconds (now - lastRefillTime) / 1_000_000_000.0; tokens Math.min(capacity, tokens elapsedSeconds * refillRatePerSecond); lastRefillTime now; if (tokens permits) { tokens - permits; return true; } return false; } }这个版本的令牌桶支持一次消耗多个令牌permits参数某些场景下一个请求需要多个配额时很有用。注意初始时tokens直接给满capacity也就是系统刚启动就拥有全额突发能力——如果不想让启动初期就放开所有配额可以把初始值改为0。3.3 四种算法的选型建议我给一个实践经验上的对比表你可以直接拿去用算法核心特点典型场景主要缺点固定窗口实现最简单内部接口、压力不大的场景窗口边界双倍流量滑动窗口相对平滑可控精度API网关、对外限流内存/清理成本略高漏桶强平滑恒定速率保护下游数据库/第三方接口浪费峰值能力令牌桶支持突发性能均衡Web应用、秒杀、绝大多数后端短期突发仍可能压垮下游我的个人建议是单体应用直接选令牌桶因为它既能挡住突发洪峰又不会把系统能力锁死。对外API网关选滑动窗口因为精度可视化比较强调参直观。如果下游是数据库这种对抖动极敏感的组件漏桶反而是最优解。4. 分布式限流为什么单机方案撑不住4.1 单机限流在集群环境下的失效场景单机限流最大的问题是它只能管住自己这台机器管不住整个集群。假设你部署了10个实例每个实例的令牌桶限流100 QPS看起来加起来是1000 QPS。但问题来了流量不一定均摊到每台机器上。负载均衡策略、网络分区、某些请求被路由到固定实例——这些都会导致某台机器收到超过平均值的流量。比如一台机器收到300 QPS它的限流器只放100另外9台机器总共只收到20 QPS整个集群最终只处理了120 QPS白白浪费了能扛住1000的能力。反过来也一样危险如果每台机器按总配额/实例数去限当某台机器下线或扩容时需要重新计算所有机器的配额运维成本直接拉满。所以分布式限流的本质是用一个全局共享的计数器/令牌池替代每台机器各自为战的计数器。4.2 基于RedisLua的原子限流实现目前业界最主流的分布式限流方案是Redis加Lua脚本。为什么一定要用Lua因为纯Java代码做先查后改有竞态问题——两个请求同时读到当前计数为99上限100都认为自己可以放行然后各自1结果就变成了101超限了。虽然可以用分布式锁锁住但为了一个计数去抢锁代价太重。Redis的Lua脚本是单线程执行的脚本运行期间其他命令不会被插队天然保证原子性。下面是一个固定窗口的Lua脚本-- KEYS[1]: 限流key -- ARGV[1]: 窗口内最大请求数 -- ARGV[2]: 窗口大小秒 local current redis.call(GET, KEYS[1]) if current and tonumber(current) tonumber(ARGV[1]) then return 0 end local incr redis.call(INCR, KEYS[1]) if incr 1 then redis.call(EXPIRE, KEYS[1], tonumber(ARGV[2])) end return 1这个脚本的逻辑是先读当前计数如果超过上限直接返回0否则INCR加1如果是第一次加1就给key设置一个过期时间。这里设置EXPIRE有两个作用一是释放内存二是让计数器自然归零实现窗口重置。在Java侧用Spring的RedisTemplate调用private static final String FIXED_WINDOW_LUA local current redis.call(GET, KEYS[1])\n if current and tonumber(current) tonumber(ARGV[1]) then\n return 0\n end\n local incr redis.call(INCR, KEYS[1])\n if incr 1 then\n redis.call(EXPIRE, KEYS[1], tonumber(ARGV[2]))\n end\n return 1; public boolean fixedWindowTryAcquire(String key, int limit, int windowSeconds) { DefaultRedisScriptLong script new DefaultRedisScript(FIXED_WINDOW_LUA, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key), limit, windowSeconds); return result ! null result 1L; }需要注意一个坑这个方案里EXPIRE只在第一次INCR时才设置如果某个窗口期内一直没有新请求key会自然过期但如果第一个请求发生在窗口的中段这个key的TTL就从请求到达时刻开始算了导致窗口出现漂移。因为实际限流是从首次请求开始滑动并不是严格的整点窗口。对大多数系统来说这个漂移可以接受要求严格的话可以把窗口开始时间写进key例如key:orders:202506121530让key天然按固定窗口切换。4.3 令牌桶的Redis Lua实现固定窗口在Redis上实现很轻但它依然保留了固定窗口边界突刺的毛病。如果要用分布式令牌桶Lua脚本会复杂一点得用Hash记录令牌数和上次补充时间-- KEYS[1]: 令牌桶key -- ARGV[1]: 桶容量 -- ARGV[2]: 每秒补充速率 -- ARGV[3]: 当前时间戳毫秒 -- ARGV[4]: 本次请求消耗的令牌数 local data redis.call(HMGET, KEYS[1], tokens, lastRefillTime) local tokens tonumber(data[1]) local lastRefill tonumber(data[2]) if tokens nil then tokens tonumber(ARGV[1]) lastRefill tonumber(ARGV[3]) end local elapsedMs math.max(0, tonumber(ARGV[3]) - lastRefill) local newTokens math.min(tonumber(ARGV[1]), tokens elapsedMs * tonumber(ARGV[2]) / 1000) if newTokens tonumber(ARGV[4]) then newTokens newTokens - tonumber(ARGV[4]) redis.call(HSET, KEYS[1], tokens, newTokens, lastRefillTime, tonumber(ARGV[3])) return 1 else redis.call(HSET, KEYS[1], tokens, newTokens, lastRefillTime, tonumber(ARGV[3])) return 0 end这个脚本看起来长核心就三步读存量令牌、按时间差补充令牌封顶容量、判断够不够本次消耗最后把剩余令牌写回。注意无论成功失败都要更新lastRefillTime否则下一个请求会重复计算这段补充量导致实际通过的数量被高估。Java侧调用时把四个参数传进去即可public boolean tokenBucketTryAcquire(String key, int capacity, double refillRatePerSecond, int permits) { DefaultRedisScriptLong script new DefaultRedisScript(TOKEN_BUCKET_LUA, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(key), capacity, refillRatePerSecond, System.currentTimeMillis(), permits); return result ! null result 1L; }注意Redis的方案虽然好用但它依赖Redis本身的可用性。如果Redis挂了限流器会直接失聪——所有请求都放行或者所有请求都被拒绝取决于你的异常处理策略。我的建议是采用fail-openRedis异常时放行并在监控里报警因为限流器失效导致服务过载是渐进的、可恢复的而误杀所有正常流量会立刻引起大面积故障。不过如果限流是出于安全防刷目的那就得反过来用fail-closed宁错杀不放过。5. 实战中的经验阈值怎么定、超限怎么处理、架构怎么搭5.1 双层限流架构本地限流兜底 集群限流精确控制纯Redis限流有个成本问题每个请求都要做一次Redis调用对低延迟接口来说可能多出1-2毫秒看似不多但量大了以后Redis本身也会成为瓶颈。我的做法是双层限流第一层每台机器本地用令牌桶限一个宽松的值比如集群总目标1000 QPS、10台机器本地每台限120 QPS。作用是把明显超标的流量在本地直接丢掉不让它们打到Redis。第二层放行到第二层的请求再走Redis分布式限流精确控制集群总量是1000。这样Redis的QPS从几万降到了几千压力小很多同时全局总量仍然可控。本地宽松值不建议大于集群目标/实例数的1.2倍否则第一层相当于形同虚设。如果你用的是Kubernetes可以把实例数从注册中心或服务发现组件动态拉取避免扩容时手动改配置。5.2 阈值怎么定别靠拍脑袋靠压测数据限流阈值是限流器最核心的参数定错了比不限流还糟定太高挡不住流量定太低误杀正常用户。我的经验是压测出单机瓶颈对着单实例打压观察CPU、内存、线程池、RT开始恶化的点记为S。计算集群容量集群总容量 S × 实例数。打上安全余量线上阈值设为集群容量的70%80%。留出余量是为了应对流量波动、GC停顿、机器降速等不可控因素。按业务分级核心交易接口的阈值要更保守边缘查询接口可以激进一点。举个例子某查询服务单机压测到500 QPS时RT开始从50ms涨到200ms那就以500为单机瓶颈集群10台的总目标就是5000建议线上限流阈值先设35004000然后再根据监控逐步微调。5.3 超限之后怎么办拒绝、排队、降级限流器返回不允许之后业务侧不能什么都不做干等着处理策略决定了用户体验。直接拒绝返回HTTP 429Too Many Requests或业务错误码适合秒杀、抢购这种抢不到就下次再来的场景。排队等待像Guava那样让请求排进队列按速率逐步放行。适合异步任务、消息拉取等不要求瞬时返回的场景。降级返回从本地缓存、默认值或旧数据里返回结果适合商品详情、评论列表这种拿不到最新也能看旧的场景。三种策略可以组合使用优先尝试降级降级不行再排队队列满了直接拒绝并记录日志方便后续分析。另外还有两个容易被忽略的点限流器本身的统计口径要统一到底是按请求数限还是按并发数限前者更适合网关层后者更适合保护线程池。Redis的方案天然适合按请求数限流。限流要支持动态配置不要把阈值硬编码在代码里用配置中心下发这样大促前调阈值、活动结束后调回来都不用发版。关于热点参数限流简单提一句我们平时对某个接口整体限流但如果某一个用户、某一个商品ID是热点整体限流就保护不了它。需要扩展成per-key限流Redis方案天然适合只要把key换成userId或商品ID即可。但要注意热点key的存储量和过期清理否则Redis里会堆积大量无用key。最后再分享一个小细节任何限流器上线前都一定要做下限流验证——把阈值故意调到很小确认被拒绝的请求确实被拦住了再调回正常值。我见过不止一次限流代码写了但没生效原因是异常被吞了、或者key拼错了直到线上被打挂才发现。这个验证动作比任何算法选型都重要。
网站建设高端定制企业官网
RELATED

相关资讯

更多精彩内容,欢迎继续阅读

较早相关资讯

最新相关资讯

2026 AI伦理合规自查清单:从数据到算法七步落地 2026/10/2 9:46:31

2026 AI伦理合规自查清单:从数据到算法七步落地

上个月,一个做AI客服产品的朋友来找我,说他们的产品刚上线一周就被人投诉"AI对我有偏见"。我以为是多复杂的技术问题,结果把日志调出来一看,问题根本不是模型能力不行,而是整个产品压根没做过伦理合规层面的…

阅读更多 →
嵌入式音视频同步:三级FIFO架构设计与实战 2026/10/2 9:46:31

嵌入式音视频同步:三级FIFO架构设计与实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →
B端后台AI生成提示词模板:从任务设计到页面状态 2026/10/2 9:46:24

B端后台AI生成提示词模板:从任务设计到页面状态

1. 先把“提示词”这事儿想明白:B端后台不是聊天,是“任务交接”我做了快十年的B端产品,从早期的传统管理软件到现在各种中台、低代码平台,最常被问的一个问题是:“AI生成后台页面,提示词到底怎么写&#x…

阅读更多 →
从463个AI视频到开源Skill:视频知识结构化全流程实操 2026/10/2 9:46:24

从463个AI视频到开源Skill:视频知识结构化全流程实操

很多人把“看AI视频”当成学习,但我总觉得哪里不对劲——视频里的内容再好,它是线性的、流动的,今天看完明天就忘。信息困在一帧帧画面里,沉淀不下来。所以当我攒到463个AI相关视频的时候,我做了个决定:不看…

阅读更多 →
Python 3.9.7从下载到PyCharm配置:Windows环境变量与虚拟环境保姆级教程 2026/10/2 9:46:24

Python 3.9.7从下载到PyCharm配置:Windows环境变量与虚拟环境保姆级教程

自己刚学Python那会儿,对着“Python 3.9.7下载与Windows系统环境配置方法”这类标题折腾了一整天,下载装完打开命令行输入python却提示“不是内部或外部命令”,然后又在PyCharm里卡在解释器选择上,整个过程相当劝退。这篇文章就把…

阅读更多 →
MCP 7-28 到底解决什么?它是工具协议,不是 Agent 大脑——TaoToken 视角下的 Client/Server 拆解 2026/10/2 9:46:24

MCP 7-28 到底解决什么?它是工具协议,不是 Agent 大脑——TaoToken 视角下的 Client/Server 拆解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

阅读更多 →

今日资讯

本周资讯

本月资讯

看完文章仍有疑问?

联系尧图顾问,获取一对一建站咨询

立即免费咨询 📞 400-888-8888
📞 ✉