
限流这个话题说难不难说简单吧真扔到生产环境里踩过的坑一个比一个深。最近好几个读者问我“限流算法用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个请求同时在处理其他排队但一分钟内可能流水一样过了几万个请求。如果你要的是控制并发数而不是QPSSemaphore反而更合适。漏桶在Java里可以用ScheduledThreadPoolExecutor实现启动一个定时任务以固定速率从队列里取请求放行队列满了直接拒绝。public class LeakyBucketLimiter { private final BlockingQueueRunnable 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 1Java侧调用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 DefaultRedisScriptLong 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 0Java侧调用DefaultRedisScriptLong 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 接口容量1530次/60秒 CL.THROTTLE /api/query 15 30 60参数拆解第一个参数是key15是令牌桶容量最大突发量30是窗口时长内的操作数60是窗口时长秒算下来就是每2秒补充1个令牌桶容量15个命令返回值是一个包含5个元素的数组其中第一个元素是是否通过——0表示通过1表示被拒绝第二个是可等待多久。Java侧封装一下public boolean tryAcquire(String key, int capacity, int rate, int windowSeconds) { ListObject args List.of(key, capacity, rate, windowSeconds); ListObject 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限流方案原因是它把最大突发量和长期速率完全解耦。比如你配置capacity15、rate30、window60意思是平时每秒顶多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双层限流的优化。限流这件事宁可保守一点让流量进不来也不要让流量冲垮下游数据不可恢复的代价远大于损失一点请求。愿你少踩几个限流配置的坑多学到几分开源组件的细节。有问题随时交流。