做后端这几年几乎每个上点规模的项目都会碰到同一个问题流量一上来系统响应变慢紧接着超时、重试、雪崩最后整个服务被打挂。很多人第一反应是加机器但机器堆得再多也架不住突刺流量瞬间把连接池、数据库、下游接口全部打穿。限流算法就是在这种场景下最常用也最直接的一道闸门。这篇文章想把限流这件事从头到尾聊透。从最经典的固定窗口、滑动窗口、漏桶、令牌桶四种算法讲起再到实际代码里怎么写、参数怎么定、坑在哪里最后聊一聊分布式环境下怎么做全局限流。不管你是刚接触后端的新人还是已经被线上告警折磨过的老手这篇都能给你一些可以立刻落地的思路。1. 限流到底是什么为什么每个系统都需要它1.1 从一次线上事故理解限流的意义先讲一个我印象很深的例子。某个模拟项目X上线了一波运营活动预估峰值QPS每秒请求数在2000左右压测的时候也测过单机4核心8G内存的实例能扛住2500 QPS。结果活动当天用户涌入比预估快了3倍瞬间打到6000 QPS网关后面的三台实例全部CPU跑满数据库连接池被打爆然后所有的请求都堆在队列里最终整个服务进程僵死持续了大概十分钟才缓过来。复盘的时候发现问题不在于机器不够而在于完全没有限流。如果当时在入口处加一层限流把超过系统容量的请求直接返回“系统繁忙请稍后重试”后面的服务根本不会被打到崩溃。这就是限流最核心的价值它不是提升系统的处理能力而是保护系统不要被超过承受能力的流量压垮。限流在行业内通常和熔断、降级放在一起讲三者都是服务治理的手段。区别在于熔断是下游故障时主动断开降级是牺牲非核心功能保核心功能而限流是控制流量进入的速率。简单理解限流就是给系统装了一个阀门不管外面水压多大进到系统里的水流量必须可控。1.2 限流到底解决什么问题限流能解决的场景其实比很多人想的要多。最典型的是秒杀、抢购这类瞬时流量极高的业务用户点一下按钮可能产生几百个请求如果不限流后端要重复处理大量无效请求。另一个常见的场景是接口对外暴露给第三方调用比如开放平台如果某个调用方疯狂刷接口不仅占用带宽还会影响其他正常用户。再比如爬虫防护虽然不能完全靠限流挡住所有爬虫但至少能把异常的请求频率降下来。限流还有个很多人忽略的作用它是一次系统容量的体检。做限流之前你必须搞清楚自己的系统到底能扛多少流量缓存命中率多少、数据库连接池多大、下游接口的响应时间是多少。这些指标摸清楚了你才知道限流的阈值该定多少。所以限流不只是写几行代码它背后的容量评估才是关键。2. 四种经典限流算法的原理与实现限流算法不是新东西业内已经沉淀出了几种非常成熟的方案。固定窗口、滑动窗口、漏桶、令牌桶这四种是面试高频题也是实际项目中真正会用到的主流算法。每一种都有自己的脾气选错算法比不限流还要尴尬所以我会把原理、代码、优缺点一起拆开讲。2.1 固定窗口计数器最简单的入门方案固定窗口计数器的思路特别直白把时间切成固定大小的窗口比如1秒每个窗口维护一个计数器请求进来计数器加1计数器达到阈值就拒绝请求窗口切换后计数器清零。一段极简的Java风格实现是这样的public class FixedWindowRateLimiter { // 窗口大小单位毫秒 private long windowSize 1000; // 窗口内允许的最大请求数 private int maxRequests 10; // 当前窗口的起始时间 private long windowStart System.currentTimeMillis(); // 当前窗口已收到的请求数 private int requestCount 0; public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); // 时间过去了重置窗口 if (now - windowStart windowSize) { windowStart now; requestCount 0; } if (requestCount maxRequests) { requestCount; return true; } return false; } }这种实现的优点就是简单一个计数器加一个时间戳就能搞定性能开销极小在单机高并发下是够用的。但它有个非常经典的缺陷业内叫“临界突变问题”。举个例子阈值是10次/秒第1秒的前900ms一个请求都没有后100ms突然来了10个请求窗口放行紧接着第2秒的前100ms又来了10个请求窗口又放行。也就是说在两个窗口的交界处有20个请求是在200ms内进入系统的这完全绕过了限流的本意。大多数线上系统不会用裸的固定窗口原因就在这里。2.2 滑动窗口计数器解决临界突变滑动窗口把时间进一步细分比如把1秒分成4个小格子每个格子250ms每过一个格子窗口就往前滑动一格统计的范围始终覆盖最近的1秒。这样即使请求恰好落在临界点也不会出现两倍阈值的流量瞬间穿过。实现上比固定窗口复杂一点需要维护一个环形数组或者链表来记录每个小格子的计数public class SlidingWindowRateLimiter { // 窗口内的时间刻度数量 private final int slotCount 4; // 每个刻度的时间长度 private final long slotDuration 250; // 滑动窗口内允许的最大请求数 private final int maxRequests 10; // 每个刻度的计数器数组 private final AtomicInteger[] slotCounters new AtomicInteger[slotCount]; // 记录每个刻度的起始时间 private final long[] slotStartTimes new long[slotCount]; public SlidingWindowRateLimiter() { long now System.currentTimeMillis(); for (int i 0; i slotCount; i) { slotCounters[i] new AtomicInteger(0); slotStartTimes[i] now - (slotCount - i) * slotDuration; } } public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); int total 0; for (int i 0; i slotCount; i) { // 当前刻度已经过期重置 if (now - slotStartTimes[i] slotCount * slotDuration) { slotCounters[i].set(0); slotStartTimes[i] now - (slotCount - i) * slotDuration; } total slotCounters[i].get(); } int currentSlot (int)((now / slotDuration) % slotCount); if (total maxRequests) { slotCounters[currentSlot].incrementAndGet(); return true; } return false; } }细心的读者应该发现了滑动窗口本质上是用更多的内存空间换更平滑的流量控制。格子切得越细算法就越接近后面要讲的滑动日志当然也越耗费资源。工程上一般切4到10个格子就够用了切太多反而成了性能负担。这里要注意一个现实问题上面这段代码为了演示逻辑用了synchronized锁全局遍历在高并发下有明显的竞争开销。实际生产环境不推荐这么写一般会借助缓存中间件的原子操作或者本地分段计数器来降低锁粒度。这个后面在分布式章节展开。2.3 漏桶算法流量整形的高手漏桶算法的灵感来自一个水桶桶底有个小洞水以固定的速率从洞口流走桶满之后新进来的水就直接溢出。对应到限流场景请求先进桶桶底的“洞口”以恒定速率放行请求桶满则丢弃新请求。和窗口类算法最大的区别在于漏桶算法输出的流量是绝对平滑的不管入口流量怎么抖动进入系统的请求速率都是固定值。这一点对于下游系统特别友好比如数据库这种对突发流量极其敏感的资源用漏桶保护就很合适。漏桶算法的实现核心是一个队列加一个定时任务或者用一个带时间戳的计数器来模拟public class LeakyBucketRateLimiter { // 桶容量 private final int capacity 100; // 漏出速率每毫秒漏出的水量 private final double leakRate 0.01; // 当前桶中的水量 private double water 0; // 上次漏水的时间 private long lastLeakTime System.currentTimeMillis(); public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); // 先执行漏水 water Math.max(0, water - (now - lastLeakTime) * leakRate); lastLeakTime now; if (water capacity) { water; return true; } else { return false; } } }但漏桶的缺点也很明显。它强制要求消费速率恒定这就导致一个尴尬的场景如果下游系统其实有能力处理突发流量只是持续时间不长漏桶也会把多余流量全部拒之门外可用的容量被浪费了。比如系统平时能扛500 QPS某次活动能扛几十秒的800 QPS漏桶因为把速率钉死在500那300的流量就白白丢掉了。所以漏桶更适合对流量平滑度有硬性要求的场景典型的是网络流量整形、数据库写入限流而不适合大部分业务接口的限流。大部分业务场景其实希望“允许一定程度的突发”这就轮到令牌桶登场了。2.4 令牌桶算法允许突发的流量控制令牌桶是对漏桶的一个改良它允许一定程度的突发流量。思路是桶里装的是令牌系统以恒定速率往桶里放令牌令牌满了就丢弃多余的令牌。请求进来时必须从桶里取到一张令牌才能放行取不到就拒绝或者等待。令牌桶的关键特性在于只要桶里还有令牌请求就可以一次性全部通过所以它可以承受瞬间的流量脉冲。比如桶里有50张令牌即使你几十秒没放新令牌进来突然来了50个请求也能全部放行。对于大部分业务系统这种可突发的能力非常实用因为它既保护了系统不被无限打爆又不会过于死板地压制正常的高峰流量。令牌桶的实现也有多种姿态。最简单的版本用两个时间戳变量来模拟这样不需要真的开一个后台任务持续往桶里放令牌只在请求到来时按时间差补令牌就行public class TokenBucketRateLimiter { // 桶容量 private final int capacity 20; // 令牌生成速率每毫秒生成的令牌数 private final double tokenRate 0.01; // 当前令牌数 private double tokens capacity; // 上次补充令牌的时间 private long lastRefillTime System.currentTimeMillis(); public synchronized boolean tryAcquire() { long now System.currentTimeMillis(); // 按时间差补充令牌 tokens Math.min(capacity, tokens (now - lastRefillTime) * tokenRate); lastRefillTime now; if (tokens 1) { tokens--; return true; } return false; } }这段代码只需要在每次请求时按时间差补齐令牌不需要单独的线程去定时生成令牌逻辑简单且性能好。很多编程语言生态里的限流库默认用的就是令牌桶思路只是内部实现会针对高并发做更精细的并发控制。但令牌桶也不是没有陷阱。突发能力是把双刃剑如果阈值设置得太高、桶容量又大突发流量依然可能打垮下游。所以令牌桶的容量参数不能拍脑袋定必须结合实际系统的承受能力和下游依赖的余量来设置。3. 算法选型同样限流不同场景差之千里3.1 四种算法关键特性对照聊完原理直接上一张对照表方便你在做技术方案时快速决策。算法输出流量形态是否支持突发实现复杂度典型场景固定窗口不平滑临界突发支持但会超限极低简单接口限流、非核心服务滑动窗口较平滑支持平滑化中接口防刷、秒杀兜底漏桶绝对平滑不支持中数据库写入、网络流量整形令牌桶平滑可突发支持可控中绝大多数业务接口限流从表格能看出来令牌桶综合表现最均衡这也是它成为主流的原因。但注意“均衡”不代表适合所有场景比如你的目标就是保护数据库这种完全不能承受波动的资源那漏桶反而更合适。选算法之前必须先问清楚你保护的对象能不能扛突发。3.2 阈值和参数到底怎么定很多人在限流上踩的第一个坑是阈值不知道设多少。我见过有人直接把压测得到的系统最大QPS当阈值结果限流一上平时流量稍微一波动就开始误杀。这里分享一个我常用的估算思路阈值 系统安全容量的80%左右。举个例子压测显示单机最大能扛1000 QPS但持续在1000 QPS下运行CPU已经到90%了GC垃圾回收频繁响应时间也翻倍了。这时候如果把阈值定在1000等于系统一直在悬崖边走路。合理的做法是把阈值定在700到800给系统留15%到20%的缓冲防止流量抖动触发雪崩。具体参数上令牌桶的令牌生成速率就是目标QPS桶容量可以设为生成速率的1.5到2倍这样允许短时间的小脉冲但不至于完全失控。如果你限流的对象是秒杀接口桶容量可以稍微调大因为秒杀场景允许前几十毫秒的流量集中涌入配合缓存扛住第一波即可。3.3 一个完整的选择决策思路选型的思考路径我一般按三步走。第一步确认限流对象是保护自己还是保护下游保护自己对平滑度要求不高保护下游要优先考虑漏桶。第二步分析流量特征业务本身就有明显的波峰波谷比如午高峰、晚高峰需要允许突发令牌桶优先如果入口流量均匀固定窗口或滑动窗口就够了。第三步评估实现成本项目规模小、单机部署本地限流足够微服务架构、多实例部署必须上分布式限流靠本地限流在每台实例各限各的整体容量会变成实例数量乘以单机阈值线上很容易被打穿。这三步走完基本就能锁定算法和部署方式了。接下来要解决的是怎么在工程里落地尤其是分布式环境下的全局限流。4. 分布式限流单机限流解决不了微服务的问题4.1 为什么单机限流在微服务里会失效微服务架构下一个接口通常会有多台实例同时对外提供服务而且前面还有一层负载均衡把流量分散到各实例。如果你只在每台实例里做本地限流每台限500 QPS部署了10台系统的总限流阈值就是5000 QPS。听着好像没什么问题但实际流量分布不可能是均匀的负载均衡的策略、实例的启动时间、机器性能差异都会导致某台实例分到的流量比别的多。假设总流量突然到了8000哪怕负载均衡已经尽量轮询大概率还是会出现某台实例分到900 QPS的情况本地限流500直接把它打回但更麻烦的是如果流量恰好比较均匀10台每台分到800每台本地限流500实际上系统只处理了5000 QPS剩下的3000全部被拒绝。这就陷入了两难阈值设低了浪费资源阈值设高了保护不了单实例。分布式限流要解决的就是把全局限额放在一个公共的地方统一管理每台实例在放行请求前先问问公共组件“我还有额度吗”只有余额足够才放行。4.2 基于缓存中间件的分布式令牌桶实现业界最成熟的分布式限流方案是基于缓存中间件的原子操作加Lua脚本。核心思路是把令牌桶的状态放到缓存里利用缓存单线程处理命令的特性保证并发安全。Lua脚本的好处是Redis这样的缓存服务能保证一个脚本在执行期间不会被其他命令插入也就是原子执行。我们可以把“补令牌取令牌”两个动作封装进一个脚本里这样分布式环境下多个实例同时请求也不会有并发问题。下面是一个简化版的分布式令牌桶核心逻辑-- KEYS[1]: 限流器的key -- ARGV[1]: 桶容量 -- ARGV[2]: 令牌生成速率每毫秒 -- ARGV[3]: 当前时间戳毫秒 local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local tokens redis.call(get, KEYS[1]) if not tokens then tokens capacity else tokens tonumber(tokens) end local lastRefillTime redis.call(get, KEYS[1] .. :time) if not lastRefillTime then lastRefillTime now else lastRefillTime tonumber(lastRefillTime) end local newTokens math.min(capacity, tokens (now - lastRefillTime) * rate) redis.call(set, KEYS[1], newTokens) redis.call(set, KEYS[1] .. :time, now) if newTokens 1 then redis.call(set, KEYS[1], newTokens - 1) return 1 else return 0 end调用方的代码也很直接每次请求进来拿着这个key去执行一次脚本返回1就放行返回0就拒绝。要注意的是脚本里所有涉及系统当前时间的参数都在调用方生成并传入不能依赖缓存服务自己的时间函数否则多台机器的时间偏差会导致限流不准确。分布式限流最大的代价是额外一次网络往返。所以实践中通常会做一层本地缓存叫“本地令牌预取”每台实例每隔一小段时间去公共组件批量领取一批令牌然后本地消耗不够了再去领。这样可以把网络的往返次数降低一个数量级代价是限流的精度会有轻微偏差尤其在批量领取的时间窗口末尾突发流量可能多放行一些。对于大多数业务场景这点偏差完全可以接受。4.3 网关层限流与代码层限流的取舍实际落地时还有一个问题限流应该写在网关还是写在自己的业务代码里我的建议是两个地方都要有但职责不同。网关限流比如常见的API网关组件做的是全局粗粒度限流主要防外部流量突刺比如整个系统的入口限制在2万 QPS。网关的优点是统一管理不用每个服务都写一遍限流逻辑而且对业务代码完全透明。缺点是不够灵活无法针对某个具体用户、某个具体接口做差异化限流。代码层限流做的是精细化的业务限流。比如同一个用户1秒内不能调用某个接口超过5次或者某个付费接口对每个应用ID的调用频率单独计数。这种逻辑放进网关里会非常臃肿而且网关通常无法感知业务字段只能按IP或URL粗粒度限流。所以常规做法是网关做整体防洪业务代码里按用户、按接口、按调用方做精细化控制。注意不管用哪种方式限流一定要在链路的最前面执行。如果请求已经经过了认证、参数校验、业务逻辑处理最后才判断限流那限流就失去了保护意义。我见过有的项目把限流写在了事务里结果限流本身成了新的性能瓶颈这是典型的反面教材。5. 常见问题与排查技巧实录5.1 限流误杀率过高正常用户也被拦了这是上线限流后最常见的线上问题。原因通常是阈值设置得太紧或者算法选型不对。排查分两步第一步看监控把限流拒绝的QPS和总QPS画在一张图上对照业务高峰时间看拒绝量是否和高峰正相关。如果正相关说明阈值偏低按前面说的容量评估方法重新算安全容量适当上调。第二步看是不是存在频繁的瞬间突发比如前端做了请求重试用户点一次按钮发了3个请求这种情况下不适合硬生生调高阈值应该在后端做去重或者在前端做请求合并。另一个人为因素也要注意多台实例的配置不一致有的实例限流阈值没更新导致一部分流量被误杀。所以在发布限流配置时一定要有配置中心统一下发不能一台台手动改。5.2 限流本身成了新的瓶颈有的团队把限流做成一个独立的服务所有请求都先经过这个服务来判断是否放行结果限流服务的QPS成了新瓶颈一旦限流服务抖动整个系统跟着抖。这是典型的把简单问题复杂化。正确的做法是让限流逻辑尽量靠近业务请求链路。单机场景直接内嵌在业务进程里连远程调用都省了分布式场景用缓存中间件的原子操作来做而不是另起一套RPC服务。记住一个原则限流是保护手段它本身的延迟必须极低不能反过来拖垮业务。如果发现限流逻辑对响应时间的影响超过2到3毫秒就该检查是不是方案选重了。5.3 限流生效后被拒绝的请求怎么处理这个问题很多人会忽略。被限流的请求直接返回一个错误码就完事了吗在用户视角体验是很差的。业界通常有几种处理方式直接拒绝返回提示、排队等待、降级返回缓存结果。排队等待适合短时间的流量高峰比如秒杀场景把超出的请求放进队列按顺序处理。降级返回缓存结果适合读多写少的接口比如商品详情限流之后直接返回一份静态缓存数据用户无感知。具体用哪种方式要结合业务性质来定但一定要在接口文档里写清楚限流后的返回约定前端也要做好对应的提示不然用户会以为系统出故障了。还有一个容易被忽视的点限流被拒绝的请求也最好记录日志包括拒绝时间、请求特征、当时的限流阈值。这些日志是后续调优限流参数最宝贵的依据。我一个做活动系统的朋友就是靠这些日志发现某个接口被限流的请求里有将近40%是同一个用户ID在重复调用排查后发现是客户端的一个bug。5.4 限流信息速查表最后整理一份限流上线检查清单照着过一遍基本能避开大部分坑。检查项关键问题建议做法阈值设定系统安全容量是多少压测最大值的80%左右留缓冲算法选择允许突发还是要求平滑大部分业务选令牌桶数据库保护选漏桶部署方式单机还是分布式多实例必须上分布式限流被拒处理用户收到什么响应提前约定错误码前端提示可观测性拒绝的请求有日志吗记录全量拒绝日志配置监控告警配置管理限流参数怎么改用配置中心统一管理避免逐台改性能开销限流逻辑耗时多少单次判断应低于1毫秒超了就优化最后分享一点个人体会限流这个东西代码写起来不难难的是对系统能力的判断和对业务场景的理解。我见过很多人花一个下午把四种算法背得滚瓜烂熟上了线却因为阈值拍脑袋该拦的没拦住不该拦的误杀一大片。做限流之前先花时间去搞清楚系统的水位线在哪下游能扛多少业务的流量曲线长什么样。算法只是一把尺子量得准不准取决于你对系统的理解有多深。再分享一个小技巧上线限流之后不要急着设一个固定值就不管了。前两周定期观察限流拒绝量和系统水位的关系微调阈值。等系统流量特征稳定了再把配置固化下来。限流是动态的不是写完代码就一劳永逸的事。