资讯动态

限流算法详解:Java与Redis分布式限流实现及踩坑实战

发布时间:2026/10/6 12:54:40 来源:尧图企业网站定制
开篇先说说我自己的经历。前几年做电商大促零点一过流量瞬间冲上来数据库连接池直接被打爆订单表写不进去紧接着就是雪崩式的超时重试把下游的库存服务也拖垮了。那次事故之后限流就成了我所有高并发系统的标配。做了这么多年的后端我自己的体会是限流算法是理论落地才是关键。Redis实现在分布式场景下绕不开Java实现则是单机版最直接的手段两者配合能把系统的稳定性兜住。这篇文章不聊虚的直接把固定窗口、滑动窗口、漏桶、令牌桶四种算法讲透然后把Java和Redis两套实现方案完整铺开最后附上我踩过的坑和排查实录。适合正在做高并发优化、准备面试或者维护线上系统的同学照着文中的代码和思路能直接落地。1. 先聊清楚限流到底在解决什么问题限流的本质很简单控制单位时间内的请求量超出的部分直接拒绝、排队或者降级。它保护的并不是某一个接口而是整个系统的处理能力上限。就像一条高速公路入口处设置收费站每秒钟放行固定数量的车后面的车要么排队等要么换路走总不能让所有的车一窝蜂涌上去把路堵死。我在实际项目中总结过限流的触发场景通常是这几类突发流量比如活动秒杀、热点事件、爬虫集中抓取流量在几秒内翻几十倍。依赖保护下游接口、数据库、消息队列的处理能力有限不限制上游就可能拖垮整个链路。成本控制云上资源弹性扩容需要时间在扩容完成前限流是成本最低的保护手段。稳定性兜底即使有熔断、降级限流仍然是最前端的第一道防线。这里要特别提醒一个常见误区限流和熔断、降级不是一回事。限流管的是“进”熔断管的是“出”降级管的是“备胎方案”。我在架构设计时通常是三层配合最外层限流中间层熔断底层降级。三者职责不同不能互相替代。限流算法的选择没有绝对的最优只有适合不适合。单机场景考虑性能和简单性分布式场景考虑一致性和集群扩展性。理解了这一点后面看到的每一种实现方案都有自己的位置。2. 四种主流限流算法各自的家底与命门2.1 固定窗口入门必会但临界问题让人头疼固定窗口算是限流的入门方案。它的思路是把时间切成固定长度的窗口比如1秒每个窗口内维护一个计数器请求来了计数器加1超过阈值直接拒绝窗口结束计数器归零。Java实现固定窗口很简单用一个AtomicInteger加一个volatile的时间戳就行。Redis实现更是顺手一条INCR加一条EXPIRE就搞定。代码量小理解成本低这是它最大的优势。但这个方案有个著名的临界问题。假设限流阈值是5次/秒在第一个窗口的最后100毫秒通过5个请求第二个窗口的前100毫秒又通过5个请求那这200毫秒内实际通过了10个请求远超阈值。这个问题的根源在于窗口边界是固定的没有平滑能力。所以固定窗口适合对流量突变不敏感、对精确度要求不高的场景比如简单限流、防刷、非核心接口的保护。2.2 滑动窗口把边界磨平精度上去了但成本也上去了滑动窗口是为了解决固定窗口的临界问题出现的。它不再让窗口边界固定在整点时刻而是每个请求进来时统计“当前时刻往前推一个窗口长度”内的请求数。Redis实现滑动窗口最典型的做法是用ZSET。成员可以存请求的唯一标识比如UUID分数存时间戳每次请求到来时用ZREMRANGEBYSCORE移除窗口外的旧记录再用ZCARD统计窗口内的请求数。这个方案的好处是精确缺点也很明显每个请求都要存储一条记录内存消耗高清理旧数据需要额外的维护逻辑。所以我一般在需要精确控流的场景才用滑动窗口平时能不用就不用。2.3 漏桶均匀放行管住突发漏桶算法的灵感来自水龙头。水请求以任意速度倒入桶中桶底有一个固定速率的出水口桶有容量上限水满了就溢出拒绝请求。这个算法的核心特点是强制平滑。不管上游怎么突发下游看到的流量都是恒定的速率。这非常适合保护下游依赖比如调用第三方接口、写数据库、调用支付网关这些场景最怕忽快忽慢。漏桶的实现通常需要一个队列加一个定时任务或者用一个变量模拟桶里的积水量。Redis实现漏桶时可以用一个计数器存桶里的积水量配合时间差计算漏水速率。但纯手写漏桶并不轻松因为要处理“上次请求后这段时间漏了多少水”的逻辑状态管理比固定窗口复杂。2.4 令牌桶允许突发兼顾速率与弹性令牌桶是我个人用得最多、也是各种框架里最常见的算法。系统以固定速率往桶里放令牌桶容量有限请求进来时先取令牌拿到就放行没拿到就拒绝或者等待。令牌桶和漏桶的区别在于漏桶的出水速率是固定的令牌桶允许一定程度的突发。比如桶容量是10生成速率是5个/秒在某一个瞬间桶里积了10个令牌这时可以一次性放行10个请求然后后续请求就要等令牌慢慢生成了。这种“允许突发、但不让突发无限放大”的特性让它非常符合大多数业务的真实诉求。Guava的RateLimiter就是令牌桶的典型实现。Redis实现令牌桶也可以用Lua脚本维护令牌数和最后补充时间每次请求查询时一次性补充积攒的令牌然后决定是否放行。算法平滑性允许突发实现复杂度适用场景固定窗口差是极低简单限流、非核心接口滑动窗口中是中精确控流、防刷漏桶强否中高保护下游、强制平滑令牌桶中有限度中绝大多数接口限流3. Java单机限流实现从AtomicLong到RateLimiter3.1 从最简单的计数器开始单机场景最朴素的限流就是固定窗口。用AtomicLong做计数器volatile记录窗口开始时间。注意这里不能用synchronized包住整个方法性能损耗太大用AtomicLong足够。public class FixedWindowLimiter { private final long windowSizeMillis; private final int maxRequests; private final AtomicLong counter new AtomicLong(0); private volatile long windowStart System.currentTimeMillis(); public FixedWindowLimiter(long windowSizeMillis, int maxRequests) { this.windowSizeMillis windowSizeMillis; this.maxRequests maxRequests; } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); if (now - windowStart windowSizeMillis) { windowStart now; counter.set(0L); } long count counter.incrementAndGet(); return count maxRequests; } }这个实现里synchronized只在窗口切换的一瞬间触发生效平时是无锁的计数器自增性能很高。用的时候只要判断返回的boolean值false就返回“请求过于频繁”的错误码。3.2 自己写一个滑动窗口Java实现滑动窗口最简单的方式是维护一个固定长度的队列队尾记录请求时间队头清理过期请求。public class SlidingWindowLimiter { private final long windowSizeMillis; private final int maxRequests; private final DequeLong requestTimes new LinkedList(); public SlidingWindowLimiter(long windowSizeMillis, int maxRequests) { this.windowSizeMillis windowSizeMillis; this.maxRequests maxRequests; } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); while (!requestTimes.isEmpty() now - requestTimes.getFirst() windowSizeMillis) { requestTimes.removeFirst(); } if (requestTimes.size() maxRequests) { return false; } requestTimes.addLast(now); return true; } }这里的核心逻辑是每次请求进来先把超出时间窗口的请求时间从队列中移除再判断队列长度。队列的长度就等于当前滑动窗口内的请求量。注意整个方法加了锁并发量高的场景要考虑锁竞争可以通过LongAdder或分段窗口来优化但单机限流的qps一般不会因为锁成为瓶颈。3.3 用Guava RateLimiter做平滑限流如果你不想自己造轮子Google的Guava RateLimiter可以直接拿来用。它是一个令牌桶实现塞入依赖就能用支持平滑突发限流和预热模式。dependency groupIdcom.google.guava/groupId artifactIdguava/artifactId version32.1.3-jre/version /dependencyimport com.google.common.util.concurrent.RateLimiter; public class GuavaLimiterDemo { public static void main(String[] args) { // 每秒生成5个令牌 RateLimiter limiter RateLimiter.create(5.0); for (int i 0; i 20; i) { if (limiter.tryAcquire()) { System.out.println(放行请求 i); } else { System.out.println(拒绝请求 i); } } } }RateLimiter还支持预热模式RateLimiter.create(10.0, 3, TimeUnit.SECONDS)意思是系统在3秒内逐渐达到每秒10个令牌的速率适合系统刚启动或者冷启动场景。但记住一个边界Guava RateLimiter是纯进程内的无法跨多个应用实例。如果你的服务部署了3个节点每个节点各放行10个请求那总量是30而不是10。分布式场景必须换方案这就是下一部分要讲的Redis限流。4. Redis分布式限流实现从INCR到Lua脚本4.1 前置Redis环境准备与基础命令回顾在动手写代码之前先确认环境是好的。我本地用的是macOSbrew install redis装完直接redis-server就能起来。Windows用户注意一下官方Redis不支持Windows但微软维护过移植版本Docker是最省事的方案docker run -p 6379:6379 redis:7一步到位。这里顺手提几个高频命令因为限流脚本里全是这些命令的基础操作INCR key对key自增返回自增后的值。EXPIRE key seconds设置key的过期时间。ZADD key score member添加有序集合成员。ZREMRANGEBYSCORE key min max移除分数在区间内的成员。ZCARD key返回有序集合的基数成员数。连不上Redis的时候先检查三件事redis-cli ping看服务是否活着、端口是否被防火墙挡了、代码里confign的host和密码对不对。我排查过好几次“连接超时”的问题最后发现都是配置里的IP写成了localhost但服务监听在别的网卡上。4.2 固定窗口的INCR实现Redis固定窗口限流是全网最简单的限流代码。每条限流规则对应一个keykey的过期时间就是窗口大小value就是计数器。public boolean fixedWindowLimit(String userId, int maxRequests, int windowSeconds) { String key rate:fixed: userId; long count redisTemplate.opsForValue().increment(key); if (count 1) { redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); } return count maxRequests; }这段代码看起来人畜无害但它有一个隐藏的原子性问题increment和expire是两条命令如果第一条命令执行后、第二条命令执行前Redis挂了key就没有过期时间计数器永远不重置限流就变成了永久封禁。解决方式是用Lua脚本把两条命令打包成一个原子操作local key KEYS[1] local maxRequests tonumber(ARGV[1]) local windowSeconds tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, windowSeconds) end return current maxRequests用Lua的好处是Redis单线程执行脚本整个脚本执行期间不会被其他命令插入原子性有保证。4.3 用Lua写滑动窗口滑动窗口的Redis实现依赖ZSET。思路是把每个请求的时间戳作为score存进ZSET请求来时先清掉窗口之外的旧数据再统计集合大小。local key KEYS[1] local windowMillis tonumber(ARGV[1]) local maxRequests tonumber(ARGV[2]) local now tonumber(ARGV[3]) local member ARGV[4] redis.call(ZREMRANGEBYSCORE, key, 0, now - windowMillis) local count redis.call(ZCARD, key) if count maxRequests then redis.call(ZADD, key, now, member) redis.call(EXPIRE, key, math.ceil(windowMillis / 1000)) return 1 end return 0member参数可以用UUID或请求ID保证唯一性。这里有一个细节我给ZSET设置了过期时间否则key会越积越多内存迟早被吃光。过期时间设成窗口长度即可因为过期后这整个key本身就是无用的。还要注意ZSET的Score精度问题。Redis的Score是双精度浮点数毫秒时间戳在数值上完全没问题但如果你需要纳秒级精度会被舍入。实际限流场景毫秒足够了。4.4 用Lua写令牌桶令牌桶的Redis实现需要维护两个状态当前令牌数和上次补充令牌的时间。核心思路是每次请求进来时先根据流逝的时间补充令牌再尝试扣减。local key KEYS[1] local maxTokens tonumber(ARGV[1]) local ratePerSecond tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requestedTokens tonumber(ARGV[4]) local tokenKey key .. :tokens local tsKey key .. :ts local tokens tonumber(redis.call(GET, tokenKey) or maxTokens) local lastTs tonumber(redis.call(GET, tsKey) or now) local elapsed (now - lastTs) / 1000 local newTokens math.min(maxTokens, tokens elapsed * ratePerSecond) if newTokens requestedTokens then newTokens newTokens - requestedTokens redis.call(SET, tokenKey, newTokens) redis.call(SET, tsKey, now) return 1 end redis.call(SET, tokenKey, newTokens) redis.call(SET, tsKey, now) return 0这个脚本有几个细节值得说明。速率换算ratePerSecond是每秒补充的令牌数elapsed秒数乘以速率就是这段时间补充的令牌。now我从Java端用System.currentTimeMillis()传入避免Redis时间和应用时间不一致。初始令牌第一次访问时桶里默认是满的也就是maxTokens。这样设计的好处是系统刚启动或规则刚创建时可以承受一次小的突发流量。令牌上限math.min(maxTokens, tokens elapsed * ratePerSecond)保证了令牌数永远不会超过桶容量这是令牌桶的关键属性。Redis调用示例public boolean tokenBucketLimit(String userId, int maxTokens, int ratePerSecond) { ListString keys Collections.singletonList(rate:token: userId); ListString args Arrays.asList( String.valueOf(maxTokens), String.valueOf(ratePerSecond), String.valueOf(System.currentTimeMillis()), 1 ); Long result redisTemplate.execute( new DefaultRedisScript(local tokenKey KEYS[1] .. :tokens ... , Long.class), keys, args.toArray() ); return result ! null result 0; }5. 六个常见坑和排查实录5.1 原子性问题不加Lua就等着出Bug我上面已经提到了incrementexpire的原子性问题这里再强调一次Redis里多条命令的“先查再写”操作必须用Lua或事务不能在Java代码里分步执行。举一个我遇到过的真实事故某业务上线了固定窗口限流线上跑了一个月没问题突然有一天接口被限得死死的流量全被拒绝。查了半天发现是因为某个时间点Redis主从切换后有一条EXPIRE命令没有执行成功计数器永远卡在一个高数字上。后来我把所有限流逻辑统一改成Lua脚本问题再没出现过。5.2 时间回拨与精度滑动窗口和令牌桶都依赖时间戳。如果Redis服务器时间收到NTP校时影响产生回拨限流可能出现短暂失效或误判。我在登录令牌桶脚本时把时间戳从Java端传入而不是在Lua里调用TIME命令有两个原因一是保证应用与Redis之间时间一致的逻辑由一端控制二是Java端可以用System.currentTimeMillis()如果业务要求更高精度还可用System.nanoTime()做差值计算。5.3 限流误差与压测别在测试环境拍脑袋配参数限流参数没有通用值需要根据压测数据来确定。我记得某个接口通过压测得出单节点QPS上限是800限流阈值设在750留了约6%的余量线上跑得很稳。有些同学图省事随便填个100结果大促流量一来大量请求被拒用户体验很差。压测方法建议用JMeter或开源的压测工具逐步增加并发数观察TPS和响应时间曲线找到拐点再乘以0.8~0.9的系数作为限流阈值。注意压测一定要覆盖GC线程数变化带来的影响别只看Redisserver的CPU。5.4 Lua脚本报错入门最常见的三类错误ERR Error running script语法错误或运行时异常可以先在redis-cli里用EVAL直接测试脚本。user_script:xx: onError脚本执行中的运行时错误通常是传入了非数字类型的参数tonumber()返回了nil。WRONGTYPE Operation against a key holding the wrong kind of value说明之前这个key是别的数据类型比如用一个字符串key去执行了ZADD。清掉这个key再试就能解决。我在调试Lua脚本时习惯先用redis-cli EVAL跑一遍确认没问题再套到Java代码里能少走很多弯路。5.5 Redis连接超时和连接池耗尽限流逻辑在请求链路上一旦Redis变慢限流本身反而会成为系统的瓶颈。线上出现过一次“限流命令执行超时”的问题排查下来是连接池太小高并发下所有线程在等连接。常规的建议是设置合理的连接池参数maxTotal通常按预估QPS和单个命令耗时来算比如预估QPS 5000单次命令2ms池大小至少10~20。开启Redis慢日志SLOWLOG GET查看是否有大key或者复杂命令拖慢Redis。给限流Redis单独部署不跟业务缓存混用避免相互影响。5.6 集群场景下的限流方案Redis集群模式下限流key的hash slot分散在不同节点上如果限流规则依赖同一个用户维度的key一般不会产生跨节点问题因为同一个key会落在同一个slot。但如果用ZSET滑动窗口并且数据量极大单key的内存和性能会成为瓶颈。这个场景业界有几个方案基于Redisson的分布式RateLimiter、自研本地Redis两级限流本地桶判快速通过Redis令牌桶判全局精细限流、或者干脆升级到Sentinel等专业限流组件。我个人建议中小团队别急着造轮子优先用Redisson自带的RateLimiter再结合一部分本地缓存比如Caffeine做两级限流比纯Redis限流稳得多。// Redisson分布式限流器示例 RLimiter limiter redissonClient.getRateLimiter(rate:order); limiter.trySetRate(RateType.OVERALL, 100, 1, RateIntervalUnit.SECONDS); if (limiter.tryAcquire()) { // 放行 }RateType.OVERALL表示全局总速率PER_CLIENT则表示按客户端维度计数。根据自己的业务语义选择。6. 说了这么多到底怎么选型把上面的内容浓缩成几条选型策略供你直接参考。单机应用、不想引入额外依赖优先Guava RateLimiter令牌桶算法已经够用代码量最少。如果只是防刷固定窗口也完全够用。分布式应用、Redis可用直接用Lua脚本实现令牌桶性能好且原子性有保证。我自己的Token Bucket Lua脚本在线上跑了两年每秒处理上千次限流判断没有问题。大流量集群、限流规则复杂本地限流 Redis限流二级配合或者接Sentinel这类专业组件。不要试图用一个Redis计数器扛全网流量也别只用本地限流然后被自己人打脸。接口被刷、要求精确滑动窗口配合ZSET加上member唯一值能做到精确控流。不管选哪种方案有几个共同的原则限流代码要薄不能有复杂的业务逻辑限流结果要可观测建议打日志或上报指标系统限流参数要可配置建议放配置中心这样才能在大促前快速调整。最后再分享一个实战小技巧。限流拒绝时的返回体不要千篇一律给客户端一个明确的错误码比如429 TOO_MANY_REQUESTS同时带上Retry-After头部告诉它多久后再试。这个细节在对接App端的时候特别重要客户端可以根据这个头做自动重试而不是一被拒就疯狂重试把限流造成二次流量放大。我自己踩过这个坑最初的版本没有这个头部客户端一看到429就立即重试结果被限的流量又按原样回来了限流形同虚设。加上了Retry-After之后重试节奏被拉齐系统才真正稳定下来。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑