☰
限流算法全解析:从Java本地实现到Redis分布式限流实战
2026/10/6 5:05:37 网站建设 项目流程

限流这个话题,说难不难,说简单吧,真扔到生产环境里,踩过的坑一个比一个深。最近好几个读者问我“限流算法用Redis好还是Java写好”,我觉得这个问题本身就有问题——它不是二选一的关系,而是两种不同层面、不同适用场景的互补方案。正好我也在整理这块的实践笔记,干脆把限流算法从原理、Java实现到Redis实现,再到实际选型和排坑的完整链路都捋一遍,希望对正在做接口防护、秒杀系统、网关限流的朋友有帮助。

1. 限流算法全景:先从数学模型上把方案看透

限流本质上是一个数学问题:在一个时间窗口内控制请求数量不超过某个阈值。市面上所有限流算法,万变不离其宗,都是在这个数学模型上做文章。

1.1 固定窗口计数(Fixed Window Counter)

固定窗口的思想最简单,我按1秒切一个窗口,每个窗口内最多放行N个请求。实现方式就是两个变量:窗口起始时间戳和计数器。

这个方案的优点是实现成本极低,Java里一个AtomicLong加一个volatile时间戳就够了,Redis里一条INCR加EXPIRE就能搞定。

但它的缺陷是致命的:临界窗口问题。假设1秒内限100个请求,0.9秒到1.0秒之间来了100个请求(旧窗口末尾),紧接着1.0秒到1.1秒又来了100个(新窗口开头),那在0.2秒内实际通过了200个请求。这在秒杀场景里可以直接把下游打挂。

1.2 滑动窗口计数(Sliding Window)

滑动窗口就是为了解决固定窗口的临界突刺问题。它把窗口细化成多个小分片,比如1秒的窗口切成10个100ms的slot,每次判断时把当前时间往前推1秒,统计这个区间内所有slot的请求总数,超过阈值就拒绝。

滑动窗口的统计精度取决于分片粒度,分片越细越接近真实滑动效果,但代价是内存和计算开销变大。在Java本地实现里就是一个环形数组,在Redis里可以用ZSET来记录每个请求的时间戳。

1.3 漏桶算法(Leaky Bucket)

漏桶的核心思路是“匀速排水”:请求进来后先进桶里排队,桶底的出口以恒定速率放行请求。不管外部请求多猛,下游收到的速率永远是平的。

漏桶的最大问题是应对突发流量不友好:100个请求同时打过来,如果桶容量不够大,后面的一批直接溢出丢弃,哪怕下游完全有能力在接下来几秒内消化它们。所以漏桶适合对下游保护要求极高、必须绝对匀速的场景,比如数据库写入限流。

1.4 令牌桶算法(Token Bucket)

令牌桶是目前实际工程里用得最多的算法,Guava的RateLimiter、Redis Cell模块都是令牌桶思路。它的逻辑是桶里有一个令牌池,以固定速率往里面放令牌(比如每秒10个),请求进来时先从桶里取一个令牌,取到就放行,取不到就等或者拒绝。

令牌桶和漏桶的本质区别在于:漏桶限制的是"出去"的速率,令牌桶限制的是"进来"的速率,但允许一定程度的突发——比如桶容量是5,空闲了3秒,桶里积了15个令牌,突然来一波请求可以一次性消耗这些令牌。这对绝大多数业务来说比漏桶友好得多。

1.5 四种算法的横向对比

算法突发流量处理精确度实现复杂度典型应用
固定窗口差,有临界突刺粗最低简单接口限流
滑动窗口较好,依赖分片粒度较准中网关层限流
漏桶差,完全匀速准中数据库写入保护
令牌桶允许一定突发准中高API网关、秒杀系统

看透这四种算法之后,后面的Java实现和Redis实现其实都是对这几个模型的具体落地。

2. Java本地实现:单机限流的四种写法与演进

很多人一上来就推Guava,这没问题,但我不建议你直接抄Guava,因为你得先理解本地限流有哪些实现层次,不同层次对应不同场景。自己用原生Java写一遍,面试时也更有底气。

2.1 最基础原子计数:AtomicLong + 时间窗口

先看一个最朴素的固定窗口实现:

public class FixedWindowRateLimiter { private final long maxCount; private final long windowSizeMillis; private final AtomicLong counter = new AtomicLong(0); private volatile long windowStart = System.currentTimeMillis(); public FixedWindowRateLimiter(long maxCount, long windowSizeMillis) { this.maxCount = maxCount; this.windowSizeMillis = windowSizeMillis; } public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); if (now - windowStart >= windowSizeMillis) { windowStart = now; counter.set(0); } return counter.incrementAndGet() <= maxCount; } }

这个写法是“能跑但我强烈建议别直接上生产”的水平。原因有三个:第一,支持度有限,只有单实例有效;第二,我这个版本用了synchronized,高并发下锁竞争严重,如果你的限流阈值本身很低比如每秒100,性能还行,但如果要扛每秒几万次调用,锁就成了瓶颈;第三,它有固定窗口的临界问题,窗口切换瞬间请求量可能双倍爆发。

如果非要用计数器方案,可以改成一个更细粒度的版本,不用锁,靠CAS轮询刷新窗口:

public class AtomicSlidingWindowLimiter { private final int slotCount; private final long windowSizeMillis; private final long maxCount; private final AtomicLong[] slots; private final long[] slotStartTimes; public AtomicSlidingWindowLimiter(int slotCount, long windowSizeMillis, long maxCount) { this.slotCount = slotCount; this.windowSizeMillis = windowSizeMillis; this.maxCount = maxCount; this.slots = new AtomicLong[slotCount]; this.slotStartTimes = new long[slotCount]; for (int i = 0; i < slotCount; i++) { slots[i] = new AtomicLong(0); } } public boolean tryAcquire() { long now = System.currentTimeMillis(); int index = (int) ((now % (windowSizeMillis * slotCount)) / (windowSizeMillis / slotCount)); // 如果时间跨入新slot,重置该slot计数,这里需要线程安全处理 // ... // 汇总所有slot的计数总和 // ... } }

这段我故意留了注释,因为完整实现代码很长,核心思想是:把时间窗切成多个slot,每个slot一个AtomicLong,请求到来时更新当前slot,然后累加整个窗口内所有slot的总数。这个思路面试时只要能讲清楚并写出关键逻辑,已经能拿一个不错的分数了。

2.2 平滑限流利器:Guava RateLimiter

本地限流的生产级选择,基本绕不开Guava的RateLimiter。它是令牌桶的Java实现,核心方法就两个:

// 1秒内放行的令牌总数 RateLimiter rateLimiter = RateLimiter.create(10.0); // 阻塞获取令牌,拿到后返回等待了多少毫秒 double waitMillis = rateLimiter.acquire(); // 非阻塞尝试,100毫秒内拿不到就返回false boolean success = rateLimiter.tryAcquire(100, TimeUnit.MILLISECONDS);

有一个细节容易被忽略:RateLimiter.create默认创建的是SmoothBursty,即允许突发的平滑突发限流器。它还提供了SmoothWarmingUp模式,冷启动时速率从低到高逐渐爬升:

// 冷启动:在10秒内从1/3速率逐步爬升到满速率 RateLimiter warmupLimiter = RateLimiter.create(10.0, 10, TimeUnit.SECONDS);

你可能会想,为什么需要冷启动?原因是很多系统在刚重启后,JIT还没预热、缓存还没建好、数据库连接池还没填满,一上来就放满流量很容易被打垮。SmoothWarmingUp就是给这类场景准备的。我之前在做活动系统的时候,重启后都特意配一个30秒的预热窗口,实测比直接满速启动稳定得多。

Guava RateLimiter内部其实是把下一次可用令牌的时间戳以微秒精度存下来,请求来的时候做数学计算,而不是真正的“放令牌进桶”。这也是它能做到低延迟的原因,但正因为它是纯单机内存计算,所以多实例部署时完全无法共享限流状态——这是它最大的局限。

2.3 信号量与漏桶:两种被低估的单机方案

信号量Semaphore常被误用来限流,其实它是并发控制工具,不是严格的时间窗口限流器。它限的是“同时处理中的请求数”,而不是“每秒能通过的请求数”。比如你用Semaphore(50)的含义是最多50个请求同时在处理,其他排队,但一分钟内可能流水一样过了几万个请求。如果你要的是控制并发数而不是QPS,Semaphore反而更合适。

漏桶在Java里可以用ScheduledThreadPoolExecutor实现:启动一个定时任务,以固定速率从队列里取请求放行,队列满了直接拒绝。

public class LeakyBucketLimiter { private final BlockingQueue<Runnable> queue; private final ScheduledExecutorService scheduler; public LeakyBucketLimiter(int capacity, int ratePerSecond) { this.queue = new LinkedBlockingQueue<>(capacity); this.scheduler = Executors.newScheduledThreadPool(1); long period = 1000000000L / ratePerSecond; scheduler.scheduleAtFixedRate(() -> { Runnable task = queue.poll(); if (task != null) { task.run(); } }, 0, period, TimeUnit.NANOSECONDS); } public boolean tryAcquire(Runnable task) { return queue.offer(task); } }

这个方案的优点是写起来非常直白,漏桶模型完全符合直觉;缺点是定时任务本身有调度成本,且在高并发下BlockingQueue的锁竞争会成为瓶颈。所以我的经验是,漏桶适合低并发但必须匀速的场景,比如批量写数据库,不适合高频热路径。想要高频热路径还能支持突发流量的,令牌桶是更优解。

3. Redis实现:分布式限流的完整武器库

Java本地限流写得再好,一旦服务水平扩容到两三个实例,它就彻底失效了。因为每个实例是独立的计数器,用户先打实例A,再打实例B,两边各放行100个,实际放行200个。要解决这个问题,必须把限流状态放到一个所有实例共享的存储里——Redis几乎成了这个场景下的标准答案。

我下面按从简单到复杂的顺序,把Redis限流的几种主流方案挨个过一遍,每种方案都有完整可复制的代码。

3.1 第一步:搞清楚为什么必须用Lua脚本

网上很多Redis限流教程直接把INCR和EXPIRE分开写:

Long current = redisTemplate.opsForValue().increment(key); if (current == 1) { redisTemplate.expire(key, 1, TimeUnit.SECONDS); }

这个写法有性能问题也有原子性问题,最典型的坑是业务高峰期Redis指令乱序导致key变成永久key。你想想这个场景:线程A执行INCR刚返回,线程B也执行EXPIRE,但B执行的时候key已经重置,B的EXPIRE等于是给新窗口续了命,后面的过期时间不断被重置,旧key永远不删,内存越堆越高。

正确做法是把这两步并到一个Lua脚本里,让Redis原子执行。Redis本身是单线程执行命令,Lua脚本则能保证脚本里的多条命令原子执行。

-- fixed_window.lua local key = KEYS[1] local limit = tonumber(ARGV[1]) local expire = tonumber(ARGV[2]) local current = redis.call('INCR', key) if current == 1 then redis.call('EXPIRE', key, expire) end if current > limit then return 0 end return 1

Java侧调用:

private static final String FIXED_WINDOW_LUA = "local current = redis.call('INCR', KEYS[1]) " + "if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end " + "if current > tonumber(ARGV[1]) then return 0 end " + "return 1"; // 使用Spring Data Redis DefaultRedisScript<Long> script = new DefaultRedisScript<>(FIXED_WINDOW_LUA, Long.class); Long result = redisTemplate.execute(script, List.of("rate:limit:" + userId + ":" + System.currentTimeMillis() / 1000), 100, 1);

注意Redis Lua脚本里不能用System.currentTimeMillis()/1000这种方式拼key,每次调用的key必须提前算好。我这里示范的参数传递是正常的,生产代码里要把key的生成逻辑放到Java侧算好再传进去。

这种方式仍然有固定窗口的临界突刺问题,但胜在轻量,适合那些对精度要求不高的粗粒度限流。

3.2 用ZSET实现滑动窗口:精度更高但别乱用

要精确限流,我用ZSET滑动窗口。思路是每个请求时间戳作为score存进ZSET,每次先清理掉窗口外的时间戳,再数一下剩下的成员数。

-- sliding_window.lua local key = KEYS[1] local window = tonumber(ARGV[1]) -- 窗口毫秒数 local limit = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local member = ARGV[4] -- 唯一请求标识 redis.call('ZREMRANGEBYSCORE', key, 0, now - window) local count = redis.call('ZCARD', key) if count < limit then redis.call('ZADD', key, now, member) return 1 end return 0

Java侧调用:

DefaultRedisScript<Long> slidingScript = new DefaultRedisScript<>(SLIDING_WINDOW_LUA, Long.class); String member = UUID.randomUUID().toString().replace("-", ""); Long result = redisTemplate.execute(slidingScript, List.of("rate:sliding:user:" + userId), 1000, 100, System.currentTimeMillis(), member);

这里有几个关键细节必须提醒:

第一,member要唯一。ZSET是set语义,如果member重复会覆盖score而不是新增。高并发下同一毫秒如果用了时间戳当member,两个请求会互相覆盖,导致统计偏少。我踩过这个坑,后来统一改成UUID当member,彻底解决。

第二,这个方案每通过一个请求就要往Redis里写一个成员,窗口内流量非常大的时候,ZSET的内存膨胀和清理开销都不小。比如每秒1000次请求,窗口10秒,每个ZSET要存1万个成员。所以它适合用户维度的个性化限流(每个用户的key独立,窗口内请求数不会太夸张),不适合接口维度的全局限流。

第三,如果要求极致精确,可以把窗口切得更细来提高精度,但本质上ZSET方案是滑到哪算到哪,不存在分片粒度误差,是所有方案里最精确的。

3.3 用Redis Cell模块:最推荐的分布式令牌桶

Redis 4.0开始内置了一个限流模块,叫Redis Cell,基于令牌桶实现。不需要写任何Lua脚本,一个命令搞定:

# 限制 `/api/query` 接口:容量15,30次/60秒 CL.THROTTLE /api/query 15 30 60

参数拆解:

  • 第一个参数是key
  • 15是令牌桶容量(最大突发量)
  • 30是窗口时长内的操作数
  • 60是窗口时长(秒)
  • 算下来就是每2秒补充1个令牌,桶容量15个

命令返回值是一个包含5个元素的数组,其中第一个元素是是否通过——0表示通过,1表示被拒绝,第二个是可等待多久。

Java侧封装一下:

public boolean tryAcquire(String key, int capacity, int rate, int windowSeconds) { List<Object> args = List.of(key, capacity, rate, windowSeconds); List<Object> result = redisTemplate.execute( new DefaultRedisScript<>(List.class, "return redis.call('CL.THROTTLE', ...)"), args); // 返回数组第一个元素: 0=通过, 1=拒绝 Long code = (Long) result.get(0); return code == 0L; }

Redis Cell是我在实际项目中最推荐的Redis限流方案,原因是它把最大突发量和长期速率完全解耦。比如你配置capacity=15、rate=30、window=60,意思是平时每秒顶多0.5个令牌补充,但某个瞬间可以一次性跑15个突发请求。这个特性对很多业务的贴切程度远超固定窗口。

不过要注意,Redis Cell本质上是一个Redis模块,不是所有云Redis都默认开启。我在公司用的云Redis实例是开通的,但如果你是自己拿docker起的Redis,需要先加载模块。docker安装的时候可以直接用带模块的镜像,或者在Redis配置里loadmodule指定redis-cell.so。

如果你们环境实在装不了模块,就用Redisson内置的RRateLimiter,它是纯Lua实现的令牌桶,不需要额外装模块。

3.4 Redisson的RRateLimiter:不开模块也能用令牌桶

Redisson是Java生态里非常成熟的Redis客户端,它内置了RRateLimiter,底层就是Lua脚本实现的令牌桶。用法非常简单:

RedissonClient client = Redisson.create(config); RRateLimiter limiter = client.getRateLimiter("rate:limit:order"); limiter.trySetRate(RateType.OVERALL, 10, 1, RateIntervalUnit.SECONDS); // 阻塞获取 boolean ok = limiter.acquire(1); // 非阻塞尝试 boolean got = limiter.tryAcquire(1);

这段话里有两个细节需要额外解释。RateType有两个值:OVERALL表示所有的请求共用一个限流器,PER_CLIENT表示对每个客户端单独限流。在多实例部署时,PER_CLIENT的意思是以发往Redis连接的客户端为维度限流,一般不是我们想要的,我们默认用OVERALL。

另外RRateLimiter的底层实现把令牌桶的状态存成一个hash结构,通过Lua脚本原子更新。如果你用的Redisson版本够新,还可以支持自动过期清理,防止key长期占用内存。

4. 两种实现怎么选:别只看技术,还要看业务拓扑

写到这里,我把Java实现和Redis实现的代表方案都过了一遍,下面直接给结论。

4.1 影响选型的关键因素

维度Java本地限流Redis分布式限流
适用实例数仅单机单实例多实例、微服务集群
延迟纳秒级,几乎无感知毫秒级,取决于网络和Redis负载
共享状态不共享全局共享
精确度高(内存计算)中(取决于实现方案)
故障影响服务挂了限流随之失效Redis挂了限流不可用
依赖无额外依赖依赖Redis高可用
运维复杂度低中

核心判断标准就一条:你的限流状态需不需要跨实例共享。如果服务只有单实例,或者限流粒度是本机可接受的,就用Java本地,性能优势非常明显。如果服务有两个及以上实例,或者你有网关层限流需求,必须用Redis。

4.2 我见过的几种成熟的组合架构

最常见的是双层限流:网关层用Redis限流做全局限流,控制整体入口流量;服务内部再针对热点方法、热点用户用Java本地限流做兜底。

为什么要有这一层兜底?因为Redis限流虽然共享了状态,但它有一个天生弱势:网络抖动。一旦Redis的访问延迟从1毫秒涨到50毫秒,限流本身反而成了业务瓶颈。我在生产环境真的遇到过这种情况:限流器在高峰期严重拖慢接口响应,下游没被打挂,业务自己先被限流器拖跨了。

所以正确的做法是:网关层用分布式限流管全局,业务层里针对已知的缓存热点、数据库热点再到本地限流,主要目标是防止某条数据被高频读取击穿缓存。

另外,还有一个容易被忽略的点:限流的维度。你要限的是单用户维度的频控,还是接口维度的总量?我做过一个活动系统,两者都要,Redis的key设计就得分层:

rate:limit:api:getUserInfo -> 接口总量窗口 rate:limit:user:12345 -> 单用户频控窗口

这里的key设计原则是:维度粒度越细,key数量越多,Redis内存占用越大。以一个千万级用户量来算,如果每个用户一个key还不加过期,Redis内存会炸。所以Redis限流的key必须设置合理过期时间,或者用上面的滑动窗口方案让它自然清理。

5. 实战高频雷区与排查技巧

理论和代码都过完了,最后这部分是我最想写的,因为这些坑都是我真金白银踩出来的。

5.1 雷区一:错误地使用固定窗口做秒杀限流

固定窗口的临界问题在秒杀场景会被放大到非常恐怖。比如你配置每秒100个请求,用户在整点的时候原来就会刷新页面,零点整的请求量瞬间飚到几千,固定窗口根本挡不住这一波。

如果你的业务对突发流量敏感,千万不要用固定窗口,直接上Redis Cell或者ZSET滑动窗口。这句话希望大家刻在脑子里,我在面试别人时问“固定窗口有什么问题”,十个有七个答不上来临界突刺,但这个才是固定窗口的命门。

5.2 雷区二:Redis超时导致限流器拖垮业务

默认的Redis操作如果超时时间设得太长,在高并发场景下,每个请求都卡住等待Redis返回,服务线程会被全部占满,业务就废了。

我的处理方式是加超时控制,并且配上快速失败策略:

try { return redisTemplate.execute(script, keys, args); } catch (RedisConnectionFailureException | RedisSystemException e) { // Redis不可用时,降级策略:优先保证业务可用 // 可以放行,也可以拒绝,取决于接口类型 return fallbackPolicy.tryAcquire(); }

降级策略要分场景:写接口、抢购接口建议宁可少放不可多放(拒绝更安全);读接口、查询接口建议放行(保证用户体验,后面有数据库环节兜底)。这里没有标准答案,需要业务调优。

5.3 雷区三:Lua脚本里的key别用动态参数拼接

这是很多人踩坑的点。Redis集群模式下,Lua脚本里的所有key必须提前用KEYS数组传进去,不能运行时用动态拼接的字符串来操作多个key。原因是Redis Cluster要求同一个Lua脚本里操作的key必须分布在同一个哈希槽,如果key是动态拼接的,不同请求可能命中的槽不一样,直接报CROSSSLOT错误。

反正规范做法是:脚本用KEYS[1]、KEYS[2]占位,Java侧用List.of()把真实key传进去,永远不要在脚本里自己拼key名。

5.4 雷区四:只限流不管控结果

限流和熔断、降级是三个经常一起出现但很多人混为一谈的概念。限流管的是流量入口,熔断管的是服务状态,降级管的是失败应对。你在做限流方案的时候必须想清楚:限流拒绝的请求怎么返回?排队等待还是直接返回错误?HTTP状态码用什么?

我强烈建议被限流的接口返回429 Too Many Requests,并在响应体里带一个Retry-After头,告诉客户端多久后来再试。如果业务上允许,可以做成排队等待的模式,让请求等一小段时间而不是直接失败,这个体验会好很多。

5.5 雷区五:压测和监控缺一不可

很多限流方案上线前没有做压测验证,结果真实流量一进来,阈值设得不对,大量正常用户被误伤,或者阈值设得过高,限流形同虚设。

我的经验是:上线前先做一轮压测,确认你配置的限流阈值是下游真实承载能力的80%左右(留出安全余量),然后上线后用监控盯三个指标:限流拒绝率、Redis平均耗时、下游服务错误率。拒绝率突然飙升的时候先去检查key的维度设计是否合理,是不是有攻击流量在打同一个热点key。

我的最后一个建议

根据我的经验,如果你是个新项目,还没有特别极端的性能要求,就不要过早追求本地限流那点微秒级性能,直接上Redis限流,把限流状态放到全局,后面扩容、跨机房部署都轻松。等到你的热点接口真的到了单机几万QPS、连一次Redis RTT都觉得心疼的阶段,再去做本地+Redis双层限流的优化。限流这件事,宁可保守一点,让流量进不来,也不要让流量冲垮下游,数据不可恢复的代价远大于损失一点请求。

愿你少踩几个限流配置的坑,多学到几分开源组件的细节。有问题随时交流。

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

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

立即咨询