接口限流实战:令牌桶与漏桶的落地实现(含分布式场景踩坑记录)
为什么接口一定要有限流高并发系统中任何一个接口的处理能力都是有上限的。没有限流保护时突发流量会直接打满数据库连接池、耗尽线程资源最终从单个接口超时演变成整条服务雪崩。限流、缓存、降级是服务保护的三板斧而限流是第一道闸门。限流的本质是在流量超过预设阈值时主动拒绝一部分请求用少量请求的失败换取整体服务的可用。本文结合项目中的真实落地过程讲清楚固定窗口、滑动窗口、漏桶、令牌桶四种算法的差异以及分布式场景下最容易踩的坑。一、四种常见限流算法1. 固定窗口计数器最简单的实现把时间切分成固定窗口比如每秒一个窗口每个窗口内计数超过阈值就拒绝窗口切换时计数器归零。publicclassFixedWindowRateLimiter{privatefinallongwindowMillis;privatefinalintmaxRequests;privatelongwindowStartSystem.currentTimeMillis();privateintcounter0;publicFixedWindowRateLimiter(longwindowMillis,intmaxRequests){this.windowMilliswindowMillis;this.maxRequestsmaxRequests;}publicsynchronizedbooleantryAcquire(){longnowSystem.currentTimeMillis();if(now-windowStartwindowMillis){windowStartnow;counter0;}if(countermaxRequests){counter;returntrue;}returnfalse;}}踩坑点1固定窗口存在临界突刺问题。假设限制每秒100次如果在窗口的最后10毫秒涌入100次、下一窗口的最初10毫秒又涌入100次那么在这20毫秒内实际通过了200次瞬时QPS达到阈值的两倍。对边界敏感的接口不能用这种算法。2. 滑动窗口滑动窗口把一个大窗口拆成多个小格子统计时只看最近一段时间内所有格子的计数随着时间推移平滑丢弃旧格子。它解决了固定窗口的临界问题Sentinel的滑动窗口就是类似实现LeapArray。代价是实现更复杂需要维护格子数组。3. 漏桶算法漏桶把请求想象成往桶里倒水水请求从桶底以恒定速率漏出。不管进水多快出水速度始终均匀。桶满时新请求被拒绝。漏桶的特点是严格平滑适合保护下游对流量均匀性要求高的场景比如调用第三方按固定速率计费的接口。4. 令牌桶算法系统以固定速率往桶里放令牌桶有容量上限每个请求必须拿到一个令牌才能处理拿不到就拒绝或排队。令牌桶和漏桶的关键区别在于令牌桶允许一定程度的突发流量——因为桶里可以预先积累令牌短时间内可以连续放行多个请求只要长期平均速率不超过阈值。这也是GuavaRateLimiter和大多数网关限流采用的方案。二、单机令牌桶Guava RateLimiter 的正确用法单机场景直接用Guava的RateLimiter即可// 每秒生成500个令牌支持预占acquire可以透支下一段时间的令牌RateLimiterlimiterRateLimiter.create(500.0,1,RateLimiter.AcquireTimeoutBehavior.THROW_EXCEPTION);publicResponsehandle(Requestreq){// tryAcquire拿不到立即返回false不阻塞if(!limiter.tryAcquire(1,100,TimeUnit.MILLISECONDS)){returnResponse.fail(系统繁忙请稍后再试);}returndoBusiness(req);}踩坑点2acquire()和tryAcquire()行为完全不同混用会引发线上事故。acquire()拿不到令牌时会阻塞线程等待在线上Tomcat线程池里如果大量线程同时阻塞等待令牌线程池会被迅速占满表现为整个服务假活——接口不报错但全部超时。对外提供服务的接口绝大多数情况应该用带超时的tryAcquire()快速失败而不是排队阻塞。踩坑点3RateLimiter默认是预消费模式。突发大请求比如一次acquire(100)可以提前透支未来的令牌后续请求会为此等待。对公平性要求高的场景要评估这个特性避免单个大请求把后续流量全部借空。三、分布式限流Redis Lua 的实现单机限流只保护单个实例服务多实例部署时每台机器各自限流集群总阈值等于单机阈值乘以实例数起不到全局保护作用。分布式限流需要一个所有实例共享的计数中心通常用Redis实现。关键是**读计数判断写回必须是原子操作**否则在并发下会出现计数不准。用Lua脚本保证原子性-- KEYS[1]: 限流key-- ARGV[1]: 桶容量 ARGV[2]: 令牌生成速率(个/秒) ARGV[3]: 当前时间(毫秒) ARGV[4]: 请求令牌数localkeyKEYS[1]localcapacitytonumber(ARGV[1])localratetonumber(ARGV[2])localnowtonumber(ARGV[3])localrequestedtonumber(ARGV[4])-- 从Hash里取上次状态第一次用默认值locallast_tokenstonumber(redis.call(hget,key,tokens)orcapacity)locallast_timetonumber(redis.call(hget,key,timestamp)ornow)-- 按经过时间补充令牌localdeltamath.max(0,now-last_time)/1000.0localfilledlast_tokensdelta*ratelocaltokensmath.min(capacity,filled)localallowed0iftokensrequestedthentokenstokens-requested allowed1endredis.call(hmset,key,tokens,tokens,timestamp,now)redis.call(expire,key,60)returnallowedJava侧调用publicbooleandistributedTryAcquire(StringbizKey,intpermits){Stringlua...上面的脚本...;DefaultRedisScriptLongscriptnewDefaultRedisScript(lua,Long.class);LongallowedredisTemplate.execute(script,Collections.singletonList(rate_limit:bizKey),String.valueOf(capacity),String.valueOf(rate),String.valueOf(System.currentTimeMillis()),String.valueOf(permits));returnallowed!nullallowed1L;}踩坑点4时间戳必须取同一个时钟且注意Redis主从切换的数据丢失。脚本中如果时间基准不统一会导致令牌计算错误更隐蔽的问题是Redis主从异步复制主节点宕机发生主从切换时最近的限流状态可能丢失极端情况下限流失灵一次。对于不能接受任何超限的强一致场景需要考虑使用更可靠的方案或接受这部分误差。一般业务防护场景这个误差是可以接受的。踩坑点5Lua脚本里浮点取最小值要用math.min时间差要防负数。实际环境出现过因客户端时钟回拨NTP校时导致now - last_time为负、令牌被扣成异常值的问题。脚本中用math.max(0, ...)做兜底能避免时钟回拨把令牌数算乱。四、限流落地的几个工程细节限流粒度要分层全局限流保护整个服务、接口级限流保护慢接口、用户级限流防止单用户刷接口要分开配置不能只做一层。用户级限流的key要带上用户标识避免一个恶意用户拖垮全局配额。拒绝后的响应要友好且可识别限流拒绝应返回明确的业务码客户端可以据此做退避重试而不是当成普通错误反复打。重试一定要带随机退避否则所有被拒请求同一时刻重试会制造第二波流量高峰。限流阈值不能拍脑袋阈值应该基于压测得到的单机容量再结合实例数、下游承受能力综合计算。阈值设得过低会误杀正常流量设置上线后要持续观察实际拒绝率配合监控动态调整。限流要和降级联动被限流的请求可以走降级逻辑返回缓存数据、静态兜底页比直接报错体验好。五、算法选型总结算法突发流量平滑性实现复杂度典型场景固定窗口有临界突刺差最低粗粒度统计、要求不高的场景滑动窗口平滑较好中精准计数限流漏桶不允许突发恒定速率中保护下游、对接匀速接口令牌桶允许可控突发长期平均匀速中绝大多数业务接口推荐实际项目中单机保护选Guava令牌桶集群全局限流选RedisLua令牌桶对下游有匀速要求的场景再考虑漏桶。限流不是配完就结束压测定阈值、监控看拒绝率、异常做降级这套闭环跑起来限流才算真正落地。