☰
Java实现四种限流算法:从固定窗口到Redis分布式令牌桶
2026/10/2 9:02:35 网站建设 项目流程

做后端这几年,我越来越觉得限流算法是分布式系统里最实在的一道防线。线上流量从来不会按你的预期走——大促秒杀、热点新闻、恶意刷接口,随便一个高峰都可能把后端打挂。我印象最深的一次事故,是半夜做全链路压测时,一个没做限流的下游服务被流量直接冲垮,紧接着整个调用链跟着雪崩,一群人排查到天亮。从那以后,我的项目里限流这一层再没缺过席,这篇文章我用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 Deque<Long> 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把令牌数封顶在capacity:

public 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 基于Redis+Lua的原子限流实现

目前业界最主流的分布式限流方案,是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) { DefaultRedisScript<Long> 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) { DefaultRedisScript<Long> 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-open"(Redis异常时放行)并在监控里报警,因为限流器失效导致服务过载是渐进的、可恢复的,而误杀所有正常流量会立刻引起大面积故障。不过如果限流是出于安全防刷目的,那就得反过来用"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 阈值怎么定:别靠拍脑袋,靠压测数据

限流阈值是限流器最核心的参数,定错了比不限流还糟:定太高挡不住流量,定太低误杀正常用户。我的经验是:

  1. 压测出单机瓶颈:对着单实例打压,观察CPU、内存、线程池、RT开始恶化的点,记为S。
  2. 计算集群容量:集群总容量 = S × 实例数。
  3. 打上安全余量:线上阈值设为集群容量的70%~80%。留出余量是为了应对流量波动、GC停顿、机器降速等不可控因素。
  4. 按业务分级:核心交易接口的阈值要更保守,边缘查询接口可以激进一点。

举个例子,某查询服务单机压测到500 QPS时RT开始从50ms涨到200ms,那就以500为单机瓶颈,集群10台的总目标就是5000,建议线上限流阈值先设3500~4000,然后再根据监控逐步微调。

5.3 超限之后怎么办:拒绝、排队、降级

限流器返回"不允许"之后,业务侧不能什么都不做干等着,处理策略决定了用户体验。

  • 直接拒绝:返回HTTP 429(Too Many Requests)或业务错误码,适合秒杀、抢购这种"抢不到就下次再来"的场景。
  • 排队等待:像Guava那样让请求排进队列,按速率逐步放行。适合异步任务、消息拉取等不要求瞬时返回的场景。
  • 降级返回:从本地缓存、默认值或旧数据里返回结果,适合商品详情、评论列表这种"拿不到最新也能看旧"的场景。

三种策略可以组合使用:优先尝试降级,降级不行再排队,队列满了直接拒绝,并记录日志方便后续分析。

另外还有两个容易被忽略的点:

  • 限流器本身的统计口径要统一:到底是按请求数限,还是按并发数限?前者更适合网关层,后者更适合保护线程池。Redis的方案天然适合按"请求数"限流。
  • 限流要支持动态配置:不要把阈值硬编码在代码里,用配置中心下发,这样大促前调阈值、活动结束后调回来,都不用发版。

关于热点参数限流,简单提一句:我们平时对某个接口整体限流,但如果某一个用户、某一个商品ID是热点,整体限流就保护不了它。需要扩展成"per-key限流",Redis方案天然适合,只要把key换成userId或商品ID即可。但要注意热点key的存储量和过期清理,否则Redis里会堆积大量无用key。

最后再分享一个小细节:任何限流器上线前,都一定要做下限流验证——把阈值故意调到很小,确认被拒绝的请求确实被拦住了,再调回正常值。我见过不止一次,限流代码写了但没生效,原因是异常被吞了、或者key拼错了,直到线上被打挂才发现。这个验证动作,比任何算法选型都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询