那次事故到现在我都记得很清楚。凌晨两点,大促压测刚跑起来十分钟,下单接口的TP99就从50ms一路飙到3秒,数据库连接池被打满,紧接着商品详情页、购物车、订单查询全开始超时。群里的第一反应特别统一:赶紧限流、熔断、降级。但真到动手的时候,三个人给出三个方案——有人要在网关层限制QPS,有人要立刻熔断对库存系统的调用,还有人要把营销推荐模块直接关掉。争论了十分钟才发现,大家说的根本不是同一件事。
“限流、熔断、降级”这三板斧,几乎每个做后端的人都挂在嘴边,但真正能把它们拆开讲清楚的人不多。它们都属于高并发系统保护的范畴,却分别解决不同环节的问题:一个管入口流量,一个管故障止损,一个管资源取舍。用错了,轻则保护无效,重则误伤正常用户。这篇就基于我自己的线上经验,把三者彻底掰开揉碎讲透,包含算法原理、参数设计、框架落地和踩坑记录。
1. 一次线上故障教会我的:保护三板斧各管一段
1.1 事故现场:不是加机器就能解决的
先还原一下当时的情况。我们是一个典型的微服务架构,入口是Nginx和网关,往下是订单、库存、营销、用户等一堆服务。压测把流量打到正常峰值的5倍以后,最先扛不住的是数据库——连接数耗尽,所有请求都在等连接。数据库慢下来,调用它的服务线程就开始堆积,线程一堆积,服务的健康检查开始超时,服务发现里开始出现不健康的实例。如果没有人为干预,这个链路会沿着调用关系向上传递,最终入口也拒绝服务。
当时有个同学说了一句很有代表性的话:“扛不住就加机器呗。”但压测期间加机器根本来不及,而且数据库是单点,加应用实例救不了数据库连接池。真正的问题是:系统的承载能力是有上限的,而流量是瞬时的,必须有一种机制在系统被压垮之前主动放弃一部分请求或功能。
1.2 团队里的三种声音与认知偏差
压测现场,团队内部的讨论很有意思,基本代表了大多数人对这三板斧的误解。
第一种声音:“网关把QPS限到2万,超过的直接返回。”这是限流思维,解决的是入口流量过大的问题。但它不解决下游已经故障的情况——如果库存服务已经挂了,无论入口限多少流量,调用库存的请求依然会失败。
第二种声音:“把库存服务的调用熔断掉,快速失败。”这是熔断思维,解决的是调用方被故障下游拖死的问题。但如果库存服务只是慢,还没到熔断阈值呢?或者流量还没大到触发熔断呢?
第三种声音:“把营销推荐关掉,先保住交易主链路。”这是降级思维,解决的是系统资源不够时怎么取舍的问题。但如果入口流量不减,光靠降级也扛不住全量请求。
三种方案听起来都对,但作用的位置、触发条件、保护对象全都不一样。把它们当成“反正都是保护系统,随便上一个就行”,这正是线上事故扩大的常见原因。
1.3 复盘得出的关键认知
事后我们复盘,发现这三者其实是一条完整的防御链——限流在入口放弃流量,熔断在调用层放弃故障请求,降级在业务层放弃非核心功能。它们相互补充,但绝不能互相替代。后面几节,我逐个把它们的原理、算法、参数和落地方式讲清楚。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 限流:在入口做减法,保护的是“系统不被打爆”
2.1 限流到底在限制什么
限流的本质是:在单位时间内,只允许一定数量的请求进入系统,多出来的请求直接拒绝、排队或者走降级策略。它是一道防盗门,不管来的人是谁,只要超过了门禁容量,就先挡在外面。
关键点是,限流不需要关心下游是否健康。哪怕下游一切正常,只要流量超过系统承载力,限流就该工作。所以限流保护的是整体系统的“输入边界”,是一种主动防御。
限流限的指标常见有两种:一是QPS(每秒请求数),二是并发数。QPS限流适合接口类场景,控制每秒钟放进来多少请求;并发限流控制的是同时处理的请求数,比如数据库连接池是100个连接,那就限制同时最多100个请求在查数据库。我在项目里通常两个都配:网关按QPS限,服务内部对数据库操作按并发数限。
2.2 四种常见限流算法:没有银弹,只有取舍
限流算法网上资料一大堆,但真正到了选型时,要考虑的是业务特点。我直接用一张表对比四种主流算法:
| 算法 | 核心思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定窗口 | 把时间切成固定大小的窗口,每个窗口内计数 | 实现简单 | 窗口边界可能出现双倍流量,俗称临界突刺 | 对突发不敏感的后台任务 |
| 滑动窗口 | 在固定窗口基础上按时间滑动细分格子 | 解决临界突刺 | 内存占用稍高 | 大多数API限流 |
| 令牌桶 | 以固定速率向桶里放令牌,请求需要拿到令牌才能通过 | 允许一定突发流量,同时限制平均速率 | 桶大小和速率需要调 | 秒杀、热点活动,允许短时突发 |
| 漏桶 | 请求进入桶里,以固定速率流出 | 输出速率绝对恒定 | 无法应对突发流量,请求被削平 | 保护下游能力很弱的系统 |
我自己的经验是:日常业务接口优先用令牌桶,因为大部分业务天然有波峰波谷,完全平峰反而会把一些正常突发请求误伤。但如果你下游是一个很脆弱的第三方接口,比如对方只允许每秒10个请求,就老老实实用漏桶把流量削平。
2.3 单机限流与分布式限流,怎么选
限流还有一个绕不开的问题:单机算还是集群算。
单机限流最简单,每个实例自己维护计数器或令牌桶,部署两台机器,每台限100,整体就能扛200。它的问题是扩缩容后阈值要跟着改,而且机器多了之后,单台配额计算很别扭。
分布式限流是把全局计数器放在Redis这类中间件里,每个实例在放行前先请求Redis做一次原子扣减。常用的实现是Redis + Lua脚本,保证判断和扣减是一个原子操作。Sentinel的集群限流也是这个思路,通过Token Server统一发令牌。
这里要注意:分布式限流多了一次网络开销,所以在超高QPS场景下,我习惯的做法是“本地限流为主,集群限流为兜底”。打个比方,本地限流相当于每个门卫自己把住一个门,集群限流相当于一个总控制室随时调度,两者配合才能既挡流量又不拖慢主链路。
3. 降级:在资源紧张时做取舍,保护的是“核心链路可用”
3.1 先澄清一个概念:服务降级不是“版本回退”
只要搜“降级”这个词,最先出来的可能是一堆“JDK降级到17”“微信降级”“小米11降级包”之类的内容。那是客户端软件版本回退,和我们说的高并发系统保护里的“服务降级”完全是两回事,只是中文都叫降级。
服务降级的本质是:在系统资源不足或依赖不可用的时候,主动关闭、简化部分非核心功能,把有限的资源集中到核心链路上。它的核心词是“取舍”,不是“回退”。比如商品详情页挂了,正文加载不出来,但至少要让用户能看到价格和库存;支付短信发不出去,先把支付请求收下来异步处理,告诉用户“支付处理中”。
3.2 降级的三种典型操作
根据降级的位置和方式,我习惯把降级分成三类:
第一类,读降级。当数据源查询失败或超时,不再继续等待,而是直接返回降级结果。最简单的降级结果是本地缓存,虽然是旧数据,但总比没有强;其次是返回默认值,比如推荐列表返回空列表、用户昵称显示“用户XXX”;再激进一点,直接返回一个静态兜底页面。
第二类,写降级。写操作通常不能直接丢弃,否则会丢数据。常见做法是把写请求转成消息发到MQ,先削峰填谷,让后端慢慢消费。比如秒杀场景,先把下单请求落到队列里,再批量扣减库存,用户看到的是“排队中”,而不是“系统繁忙”。
第三类,功能降级。直接关掉某些非核心功能模块。大促期间把“猜你喜欢”“弹窗活动”“消息通知”全关掉,这是最粗暴也最有效的方式。功能降级需要提前设计好开关,不能线上临时改代码。
3.3 降级开关怎么设计才不至于出事故
既然降级的核心是主动取舍,那“怎么触发降级”就是最大的问题。我自己踩过最大的坑是:降级开关写死在配置文件里,上线时手动打开。听起来没问题,但真到故障时,你根本来不及去改配置发布。
现在比较成熟的方案是:用配置中心(比如Nacos、Apollo)动态管理降级开关,开关粒度要细到“某个接口级别”,最好还能按用户维度灰度。比如“商品推荐接口的降级开关”和“详情页缓存的降级开关”分开,避免一个开关控制所有功能,导致误关时灾难面扩大。
另一个关键点是:降级必须有兜底结果。降级不是简单粗暴地抛异常,而是要返回一个对用户“无害”的结果。用户看到“暂时无法加载”没问题,但看到系统500崩溃,那就是事故。所以每次加降级逻辑,我都会要求团队同时定义降级响应结构,并且做压测验证降级后的响应时间。
3.4 降级与超时重试:方向相反的两件事
有个很常见的错误认知:觉得超时重试也是一种变相降级,重试几次也许就成功了。实际恰恰相反,重试是给系统“加压”,而降级是“减压”。
我见过一个案例,服务A调用服务B超时后自动重试3次,B本身已经因为慢SQL被打得半死,A的重试直接把B彻底拖垮。所以降级的设计里,一定要和超时、重试策略联动:一旦进入降级逻辑,原则上就不应该再发起重试,最多做一次快速失败。重试要留给那些“偶发抖动”的情况,而且必须带上重试退避和次数上限,绝不能和降级同时叠加。
4. 熔断:对故障调用做止损,保护的是“调用方不被拖死”
4.1 故障是怎么沿着调用链传播的
熔断要解决的问题,和限流、降级都不一样。它关注的是“调用关系”:当你调用的下游服务已经不稳定了,你的系统还不断地发请求过去,结果是什么?
结果就是线程堆积。假设你的服务有200个线程,其中150个都卡在等待下游响应上,剩余50个线程处理不过来新的请求,新的请求又开始排队,排队多了,你的服务也“看起来挂了”。下游的故障就这样沿着调用链一路传染上去,最后引起雪崩。
所以熔断的本质是止损:当我判断下游出问题的概率很高时,我就不再继续调用它,而是直接快速失败,把线程释放出来处理其他请求。这就是断路器模式(Circuit Breaker),跟家里的保险丝是一个逻辑——电流过大就跳闸,避免烧坏整个线路。
4.2 断路器状态机:关闭、打开、半开
断路器有三个状态,理解了状态机就理解了熔断:
-
关闭(Closed):一切正常,请求正常发往下游。但内部在统计失败率,一旦失败率超过阈值,断路器打开。
-
打开(Open):请求不再发往下游,直接快速失败。持续一段时间(熔断时长)后,进入半开状态。
-
半开(Half-Open):放少量探测请求去试探下游是否恢复。如果探测请求成功了,说明下游恢复了,断路器关闭,恢复正常调用;如果探测请求还是失败,断路器重新打开,继续熔断。
这个状态设计最精妙的地方是半开状态。如果没有半开,熔断后要么永久拒绝,要么定时恢复后不管下游好坏拼命打流量,都会出问题。半开相当于用极小的代价去试探,既避免恢复后的流量冲击,又能在下游恢复后及时恢复链路。
4.3 熔断参数怎么给:别按感觉拍
熔断的触发条件和恢复速度,是由几个参数决定的。以Sentinel(热词里提到的那个)和Resilience4j为例,最核心的几个参数我列一下:
| 参数 | 含义 | 经验值参考 |
|---|---|---|
| 最小请求数 | 滑动窗口内至少要有多少个请求才开始判断,避免样本太少误判 | 一般10~20 |
| 失败率阈值 | 窗口内失败请求的比例,达到即触发熔断 | 一般50%~70% |
| 熔断时长 | 断路器打开后保持多久,之后进入半开 | 一般10~30秒,视下游恢复速度 |
| 半开探测请求数 | 半开状态下允许通过的探测请求数量 | 一般1~5 |
| 慢调用RT阈值 | 超过RT的请求算慢调用,单独统计比例 | 根据业务TP99设定 |
这里最容易翻车的是“最小请求数”。如果设得太小,比如1,那么第一个请求失败就直接熔断,误伤概率极大;如果设得太大,比如1000,那窗口期内的失败请求已经造成很大压力了,熔断形同虚设。
4.4 熔断与重试的冲突:熔断期内别做无畏试错
前面讲降级的时候说过,降级阶段要避免重试。熔断也一样:断路器处于打开状态时,调用方根本就不应该发起请求,更不应该做重试。否则熔断没有意义——你这边开着断路器,那边重试逻辑还在疯狂打下游,相当于消防通道锁了大门,但是窗户全开着。
我在项目中会把重试、熔断、超时三者放在一起设计,原则是:每层调用只允许最外一层做有限重试,重试次数超过上限立刻交给熔断器判断。熔断一旦打开,重试逻辑必须同步关闭,直到熔断器关闭。
5. 三板斧从来不是单选题:协同方案与落地框架
5.1 一条完整链路上,它们是怎么衔接的
写到这里,应该能看出一个清晰的层次了。限流管入口,熔断管出口(调用),降级管路由(业务功能取舍)。用一个完整请求的视角走一遍:
用户请求先到达网关,网关层的限流先做第一道过滤——超出系统容量的请求直接拒绝或排队。通过的请求进入业务服务,业务服务开始调用下游依赖时,熔断器上场——如果依赖已经故障,请求不会耗死在等待中,而是快速失败。快速失败之后,业务层再判断:这个失败能不能用降级结果兜住?能,就返回降级数据,不能,才返回错误。
网关限流、服务熔断、业务降级,正好覆盖了请求从进入到返回的完整路径。你做系统保护的时候,如果把这三者部署在同一个位置,那保护效果一定是有巨大空洞的。
5.2 主流框架选型:Sentinel、Hystrix、Resilience4j
框架选型是个老话题。Hystrix已经进入维护状态,新项目我一般不建议再引入,但它的设计思想值得学习。目前主流就两个方向:
| 对比项 | Sentinel | Resilience4j |
|---|---|---|
| 限流能力 | 内置丰富,支持QPS、并发数、热点、系统规则 | 主要通过RateLimiter实现,能力相对基础 |
| 熔断能力 | 支持慢调用比例、异常比例、异常数 | 支持基于调用次数、时间窗口的熔断 |
| 降级与兜底 | 支持降级规则、BlockHandler统一兜底 | 主要依赖调用方自己处理 |
| 分布式 | 支持集群限流(Token Server) | 无内置,需自己实现 |
| 接入成本 | 注解 + 控制台,比较低 | Spring Boot Starter,也比较顺滑 |
| 维护状态 | 社区活跃,持续迭代 | 社区活跃,滚动更新 |
我的标准是:如果你需要的是一个综合的流量治理方案,尤其在国内技术栈、有控制台可视化需求,那Sentinel非常合适;如果你只想要一个轻量、无外部依赖的熔断限流库,且团队已经熟悉Spring生态,那Resilience4j更好。选型没有标准答案,关键是别在项目里同时上两套保护框架,运维成本会翻倍。
5.3 Sentinel限流后统一响应:别让用户看到一堆报错
热词里出现了“Sentinel限流后统一响应”,这确实是个实战中特别值得讲的点。默认情况下,Sentinel直接抛出BlockException,如果每个接口都得写一遍异常处理,代码会很丑,而且前端拿到的返回结构还不统一。
我的做法是全局实现一个BlockExceptionHandler,把所有被限流、降级、熔断拦截的请求统一包装成固定的响应结构。比如:
java复制@Component
public class CustomBlockExceptionHandler implements BlockExceptionHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, BlockException e) throws Exception {
response.setStatus(200); // 业务上把流控当作正常返回处理,避免前端误判为系统故障
response.setContentType("application/json;charset=UTF-8");
Result<Object> result = Result.fail(CommonErrorCode.FLOW_LIMITED);
response.getWriter().write(JSON.toJSONString(result));
}
}
这个处理里有两个细节值得说。第一,HTTP状态码我故意返回200,但业务码是429(限流),原因是为了防止部分网关或浏览器把5xx响应当成系统故障做重试,造成二次流量涌入。第二,响应结构必须和正常接口的结构一致,只是data为空、code为业务码,这样前端只需要做一个统一判断。
5.4 一个完整场景:秒杀系统的三板斧配置
拿秒杀场景举个例子,方便直接套用:
-
入口:网关层配置令牌桶限流,每用户每秒限1次,总QPS按压测结果的80%配置。超过的直接返回“排队中,请稍后重试”。
-
服务层:订单服务对库存服务的调用配置熔断规则,窗口内最小请求数20,失败率超过50%打开熔断,持续15秒后半开探测。同时设置调用超时500ms。
-
业务层:营销推荐、优惠券试算、消息通知在配置中心里预置降级开关,一旦订单核心接口RT超过阈值或对应依赖故障,自动切换到降级模式,返回默认值。
这一套下来,任意单一环节出问题,系统都不会全线崩溃。
6. 配置与调优中的坑,以及我现在的实施顺序
6.1 阈值别拍脑袋:先压测,再留水位
很多团队配置限流和熔断参数时,是拍脑袋定的,比如“限流就限1000吧”。但1000 QPS对一个接口到底意味着什么?取决于这台机器的核数、数据库连接池大小、下游响应速度。我踩过的坑是:阈值设太高,压测还没到阈值系统就挂了;阈值设太低,正常业务高峰就被误伤。
合理的做法是:先用压测工具(比如JMeter、wrk)把系统打到真实极限,记录极限QPS和对应RT,然后取极限值的70%~80%作为限流阈值。熔断的失败率阈值要根据故障容忍度来调,核心链路可以设严一点,比如失败率超40%就熔断;非核心链路可以松一些,避免频繁熔断影响体验。
6.2 只设熔断不设超时:线程依然会被拖死
这是一个非常隐蔽的坑。熔断器统计的是“失败率”,如果下游只是变慢但最终不失败,超时时间设得很长,那么请求就会长时间占用线程。在窗口期内,大量请求都在等待慢响应,线程池依然会被占满。熔断器看到的是“还没失败,只是慢”,不会触发熔断。
所以我的顺序一定是:先设超时,再设熔断。超时是熔断的前置条件,没有一个合理的超时时间,熔断阈值就是空话。你可以在熔断规则里配置慢调用RT阈值,比如超过800ms就算一次慢调用,慢调用比例达到一定值就熔断,这样“慢”也能被熔断捕获。
6.3 降级预案不演练,等于没有预案
降级最怕的不是没设计,而是设计了但没验证。我见过一个团队,降级开关放在配置中心,平时一直没动过,真到故障时运维打开开关,结果降级接口本身有Bug,直接抛异常,连兜底结果都没返回。
降级预案一定要演练,而且要在压测环境里演练。演练的内容包括:打开开关后接口RT是否下降、兜底数据是否能正常返回、开关关闭后功能是否无缝恢复。把这些场景写进Chaos Engineering的故障演练清单里,每个季度至少跑一遍。
6.4 我现在的推荐实施顺序与监控经验
被事故教育过之后,我现在接手一个新系统,做高并发保护时会按这个顺序推进:
-
先做超时治理:所有依赖调用必须有明确的超时时间,避免线程无限期占用。
-
再做线程池隔离或信号量隔离:把核心和非核心的调用隔离开,避免一个下游拖垮所有线程。
-
然后加熔断:在隔离的基础上,对故障依赖做快速失败。
-
接着加限流:在入口和关键接口上控制流量。
-
最后做降级:把非核心功能一个开关一个开关地梳理出来,准备兜底结果。
监控方面,除了常规的QPS、RT、成功率之外,我特别关注两个指标:一是线程池活跃线程数和队列长度,二是熔断器状态变化事件。前者能在故障发生前发出预警,后者能在熔断触发时第一时间感知。Sentinel控制台和Prometheus+Grafana都能覆盖,关键是别只看平均值,要把TP99和MAX值盯住——平均值永远会骗人。
写到这里,限流、降级、熔断各自的边界和配合逻辑应该已经很清楚了。我在实际项目里见过太多因为概念不清导致的保护失效案例,希望这篇能帮你省掉那些学费。下次再有人问“这三个到底什么区别”,你可以直接告诉他:限流是在门口查票,熔断是发现景区设施坏了先停运,降级是干脆把表演节目砍了只保门票,三件事,各管一段,缺一不可。
