1. 网关异常处理为什么总在深夜炸锅
做后端的人大概都有过这种经历:白天压测一切正常,流量曲线平滑得像教科书,结果凌晨两点告警群突然炸了,一堆502、504、503铺满屏幕,值班同学手忙脚乱重启网关,重启完好了十分钟又挂。第二天复盘发现,根因可能只是某个下游服务响应变慢,导致网关线程池被拖垮,进而引发雪崩。这类问题不是代码写错了,而是异常处理的设计没做到位。
网关作为所有流量的入口,它面对的不是"正常请求"这个理想状态,而是各种千奇百怪的异常:下游超时、连接被拒、返回体格式错误、限流触发、熔断打开、DNS解析失败、TLS握手失败、请求体过大、Header超长……每一种异常如果处理不当,轻则用户看到一堆看不懂的错误页,重则整个网关被拖死,所有业务一起陪葬。
这篇内容我想聊的是网关异常处理的完整思路和落地细节,包括异常分类、超时与重试的边界、熔断降级的触发逻辑、错误响应的统一封装、日志与链路追踪的配合,以及那些只有真正在生产环境被毒打过才会知道的坑。适合正在做网关选型、正在排查网关稳定性问题、或者准备给现有网关补异常处理能力的同学。全文基于常见的网关实践展开,不绑定某个具体框架,思路可以迁移到大多数网关实现上。
先说一个反直觉的结论:网关异常处理的核心不是"处理异常",而是"控制异常的影响范围"。很多人一上来就想着怎么把每个异常都catch住、都返回一个漂亮的错误码,结果代码里全是try-catch,性能掉了一大截,真正的雪崩还是没挡住。正确的做法是先想清楚哪些异常必须快速失败、哪些可以重试、哪些必须熔断、哪些要降级返回兜底数据,然后再去写代码。
2. 把异常分清楚,比急着写catch重要得多
2.1 按来源划分:客户端、网关自身、下游服务
网关收到的异常,按来源可以分成三大类,处理策略完全不同。
第一类是客户端异常,比如请求参数不合法、Header缺失、请求体超过大小限制、认证Token过期、请求路径不存在。这类异常的特点是:重试没有意义,因为客户端不改请求,重试多少次结果都一样。处理策略是快速失败,返回明确的4xx状态码和可读的错误信息,同时记录足够的上下文方便排查。
第二类是网关自身异常,比如线程池满了、连接池耗尽、内存不足、配置加载失败、路由规则解析出错。这类异常最危险,因为它意味着网关这个"入口"本身出了问题,所有经过它的流量都会受影响。处理策略是尽量隔离,比如用独立的线程池处理不同路由,避免一个路由的异常拖垮整个网关;同时要有自保护机制,比如快速失败而不是无限排队。
第三类是下游服务异常,比如连接超时、读超时、返回5xx、返回体解析失败、连接被重置。这类异常最复杂,因为下游可能是暂时抖动,也可能是真的挂了。处理策略要区分:超时可以有限重试,5xx要看情况,连接被拒通常意味着下游不可用应该熔断。
我见过不少团队把所有异常都塞进一个统一的ExceptionHandler里,结果客户端参数错误和下游超时走同一套逻辑,该快速失败的走了重试,该熔断的走了兜底,问题越处理越乱。分类这件事看起来简单,但它是后面所有策略的基础。
2.2 按可恢复性划分:瞬时异常与持久异常
除了按来源分,还要按"能不能恢复"来分。瞬时异常比如网络抖动导致的超时、下游GC导致的短暂无响应,这类异常重试一次大概率就好了。持久异常比如下游服务已经下线、数据库连接串配错了,这类异常重试只会浪费资源。
判断瞬时还是持久,不能靠猜,要靠指标。常见做法是维护一个滑动窗口的失败率统计,如果某个下游在最近10秒内失败率超过阈值,就认为它是持久异常,直接熔断;如果只是偶发的一两次失败,就认为是瞬时异常,允许有限重试。
这里有个容易踩的坑:重试必须带退避,而且要有上限。我见过有人配置重试3次、间隔固定100ms,结果下游只是慢,不是挂,三次重试叠加起来把下游彻底压垮了。正确的做法是指数退避,比如第一次等100ms,第二次等200ms,第三次等400ms,并且总重试时间不能超过网关自身的超时时间。
2.3 一张表看清各类异常的处置策略
| 异常类型 | 典型表现 | 是否重试 | 是否熔断 | 返回状态码 | 备注 |
|---|---|---|---|---|---|
| 客户端参数错误 | 400 | 否 | 否 | 400 | 快速失败 |
| 认证失败 | 401/403 | 否 | 否 | 401/403 | 不泄露内部信息 |
| 请求体过大 | 413 | 否 | 否 | 413 | 提前拦截 |
| 下游连接超时 | ConnectTimeout | 有限重试 | 失败率高时熔断 | 504 | 退避重试 |
| 下游读超时 | ReadTimeout | 有限重试 | 失败率高时熔断 | 504 | 注意总耗时 |
| 下游返回5xx | 500/502/503 | 谨慎重试 | 失败率高时熔断 | 502/503 | 幂等接口才重试 |
| 下游连接被拒 | ConnectionRefused | 否 | 是 | 503 | 通常下游已挂 |
| 网关线程池满 | RejectedExecution | 否 | 自保护 | 503 | 快速失败 |
| 路由不存在 | 404 | 否 | 否 | 404 | 配置问题 |
这张表不是让你照抄,而是给你一个思考框架。每个团队的业务特性不同,比如支付类接口绝对不能随便重试,查询类接口重试就相对安全。关键是先把分类做出来,再针对每一类定策略。
3. 超时、重试、熔断三者的联动关系
3.1 超时时间怎么算才合理
超时是网关异常处理里最基础也最容易配错的东西。配太短,正常请求被误杀;配太长,慢请求把线程池占满。我见过一个团队把网关超时配成30秒,结果下游有个接口偶尔要25秒才返回,网关线程池在高峰期直接被这些慢请求占满,其他正常请求全部排队超时。
合理的超时时间应该这么算:先统计下游接口的P99响应时间,然后在这个基础上留出一定余量。比如P99是200ms,那超时可以配500ms到1秒。如果下游接口本身波动很大,P99和P999差距悬殊,那就要考虑是不是下游本身有问题,而不是一味调大网关超时。
还有一点:网关超时应该大于下游超时之和。如果一次请求要经过网关→服务A→服务B,那网关的超时必须大于A的超时加上B的超时,否则A还在等B,网关已经把A掐了,A的资源就白白浪费了。这个链路超时的计算经常被忽略,导致上游超时了但下游还在跑,资源双重浪费。
3.2 重试不是越多越好
重试的初衷是提高成功率,但重试本身会增加下游压力。如果下游已经因为压力大而变慢,你再重试,等于火上浇油。所以重试必须满足几个条件:接口是幂等的、失败是瞬时的、重试次数有上限、重试有退避、重试总耗时不超过网关超时。
幂等性这点特别重要。GET、HEAD、PUT、DELETE通常是幂等的,POST不一定是。如果对非幂等的POST接口做重试,可能造成重复下单、重复扣款,这种事故比超时严重得多。所以重试策略一定要按接口维度配置,不能全局一刀切。
退避策略我一般用指数退避加随机抖动。随机抖动是为了避免多个请求同时重试造成"重试风暴"。比如基础退避100ms,第一次重试等100ms加0到50ms的随机值,第二次等200ms加随机值,这样能把重试请求打散。
3.3 熔断的触发与恢复
熔断的本质是"当失败率超过阈值时,暂时切断对该下游的调用,给它恢复的时间"。触发条件通常是滑动窗口内的失败率,比如最近10秒内请求数超过20个且失败率超过50%,就打开熔断。
熔断打开后,所有请求直接走降级逻辑,不再调用下游。经过一段冷却时间后,进入半开状态,放少量请求过去试探,如果成功就关闭熔断,如果失败就继续保持打开。这个半开状态的试探很关键,它决定了熔断能不能及时恢复。
我踩过的一个坑是:熔断的滑动窗口统计用了全局计数器,结果某个低频接口的失败把高频接口的熔断也触发了。后来改成按下游实例或按接口维度统计,问题才解决。熔断的粒度一定要想清楚,太粗会误伤,太细会失去意义。
3.4 三者的联动配置示例
假设一个查询接口,下游P99是150ms,我们这样配:
- 网关超时:800ms
- 下游连接超时:200ms
- 下游读超时:500ms
- 重试次数:2次
- 重试退避:100ms、200ms,各加0到50ms随机
- 熔断窗口:10秒
- 熔断触发:请求数≥20且失败率≥50%
- 熔断冷却:30秒
- 半开试探请求数:5
这样配的逻辑是:单次调用最多500ms读超时,重试两次加上退避最多约1.1秒,但网关超时800ms会先触发,所以实际最多800ms就返回。熔断在失败率超过一半时打开,避免持续打挂下游。这套参数不是标准答案,但思路是:所有参数要能互相兜住,不能出现某个参数大到把其他保护机制架空的情况。
4. 统一错误响应与降级兜底怎么做
4.1 错误响应体的设计原则
网关返回给客户端的错误响应,要满足几个原则:结构统一、信息够用但不泄露内部细节、便于客户端程序处理。
结构统一意味着不管什么异常,返回的JSON结构是一样的,比如都有code、message、requestId三个字段。客户端拿到响应后,先看HTTP状态码,再看code,逻辑清晰。
信息够用但不泄露内部细节,意思是不要把下游的堆栈、内部IP、数据库错误直接返回给客户端。这些信息对攻击者很有价值。正确的做法是返回一个笼统的错误信息,把详细信息记到日志里,通过requestId关联。
requestId这点特别重要。网关生成的requestId要透传到下游,下游的日志里也带上这个ID,这样排查问题时可以通过一个ID串起整条链路。我见过不少团队网关和下游的日志各记各的,排查一个问题要在几个系统之间来回翻,效率极低。
4.2 降级返回的几种形态
降级不是简单地返回一个错误,而是尽量返回"有用的东西"。常见的降级形态有几种:
第一种是返回缓存数据。比如商品详情接口挂了,但网关本地或Redis里有上次的缓存,可以返回缓存数据并标记为"可能不是最新"。这种降级对用户体验最友好。
第二种是返回兜底默认值。比如推荐接口挂了,返回一个默认的热门列表。这种适合对实时性要求不高的场景。
第三种是返回友好错误页。比如返回一个"服务暂时不可用,请稍后重试"的页面,而不是一堆乱码。这种适合面向普通用户的Web场景。
第四种是静默失败。比如埋点上报接口挂了,直接返回成功,不阻塞主流程。这种适合非核心链路。
选择哪种降级形态,取决于业务对"数据准确性"和"可用性"的权衡。支付类接口绝对不能降级返回假数据,但推荐类接口降级返回默认值完全可以接受。
4.3 降级逻辑放在网关还是下游
这是个经常争论的问题。我的经验是:通用的降级放网关,业务相关的降级放下游。
通用的降级比如限流后的兜底响应、熔断后的默认返回,这些和具体业务无关,放网关统一处理最合适。业务相关的降级比如"商品服务挂了返回哪些默认商品",这需要业务知识,放下游或者专门的降级服务里更合适。
网关做降级有个好处是快,不用经过下游就能返回。但网关做降级也有风险,就是网关可能不了解业务语义,降级返回的数据可能不合适。所以网关降级最好只做"兜底",不做"业务决策"。
4.4 一个容易忽略的点:降级也要有监控
降级逻辑一旦触发,说明系统已经处于异常状态。如果降级没有监控,你可能根本不知道系统正在降级运行。我见过一个团队降级逻辑跑了三个月,直到用户投诉才发现,因为降级返回的数据一直是错的,但没人注意到。
所以降级触发时必须打点,要有告警。降级的次数、降级的接口、降级的原因,都要能查到。降级不是"处理完就完了",它是一个需要被持续关注的信号。
5. 日志、链路追踪与异常定位的配合
5.1 网关日志该记什么
网关日志最容易犯的错是记太多或记太少。记太多,磁盘爆了,排查时被淹没;记太少,出问题时什么都查不到。
我的经验是分两层:访问日志和异常日志。访问日志记每个请求的基本信息,包括requestId、时间、路径、方法、状态码、耗时、客户端IP、上游实例。异常日志只在异常发生时记,包括异常类型、异常信息、堆栈、当时的上下文。
访问日志建议用异步写,避免阻塞请求。异常日志可以同步写,因为异常本身就不多。但要注意,如果异常量突然暴涨,同步写日志可能成为瓶颈,所以异常日志也要有采样或限流机制。
5.2 requestId的生成与透传
requestId是链路追踪的基石。网关收到请求后,如果Header里没有requestId就生成一个,如果有就沿用。生成规则要保证全局唯一,常见做法是时间戳加随机数加机器标识。
生成后要透传到下游,通常放在Header里,比如X-Request-Id。下游的日志框架要配置成自动把这个ID打进每条日志。这样排查问题时,拿一个requestId就能在网关日志和所有下游日志里搜到相关记录。
有个细节:如果客户端自己带了requestId,网关要不要沿用?我的建议是沿用,但要做格式校验,防止客户端传一个超长的或者带特殊字符的ID把日志系统搞乱。如果格式不合法,就重新生成。
5.3 异常定位的完整链路
一个典型的异常定位过程是这样的:用户反馈某个请求失败,拿到requestId,先在网关日志里搜这个ID,看到网关返回了504,说明是下游超时。然后看网关日志里记录的上游实例地址,去那个实例的日志里搜同一个requestId,看到下游在调用另一个服务时卡住了。再往下追,最终定位到某个数据库慢查询。
这个过程能走通的前提是:每一层都记了requestId,每一层的日志都能被检索。如果中间有一层没记,链路就断了。所以网关作为入口,一定要把requestId这件事做扎实。
5.4 日志脱敏不能忘
网关日志里很容易混入敏感信息,比如用户手机号、身份证号、Token、密码。这些信息一旦写进日志,就是合规风险。所以日志脱敏必须在网关层做,不能指望下游。
常见的脱敏规则:手机号中间四位打码、身份证号只留前六后四、Token只留前几位、密码字段直接替换成星号。脱敏规则要可配置,因为不同业务对敏感字段的定义不同。
脱敏有个坑:如果脱敏规则写得太死,可能把有用的调试信息也脱掉了。比如某个接口的入参里有个字段叫"token",但其实是业务上的一个普通标识,被脱敏后就查不到问题了。所以脱敏规则要支持白名单,允许某些字段不脱敏。
6. 那些只有被毒打过才知道的坑
6.1 线程池隔离没做,一个慢接口拖垮全部
这是最经典的网关事故。网关用一个大线程池处理所有请求,某个下游接口变慢,处理它的线程全部阻塞,线程池被占满,其他正常接口的请求全部排队,最终整个网关不可用。
解决办法是线程池隔离,不同路由用不同的线程池。这样即使某个路由的线程池满了,也不影响其他路由。线程池隔离的代价是线程数变多,内存占用增加,但相比整个网关挂掉,这个代价是值得的。
线程池隔离的粒度也要想清楚。按路由隔离太细,线程池太多;按下游服务隔离比较合适,同一个下游的接口共用一个线程池。线程池大小要根据下游的QPS和响应时间算,公式大概是:线程数 = QPS × 平均响应时间(秒)× 冗余系数。
6.2 连接池配置不当,请求全部卡在获取连接
网关调用下游通常用HTTP连接池。连接池的最大连接数如果配得太小,高并发时请求全部卡在"获取连接"这一步,表现为超时,但下游其实很健康。
连接池大小要结合下游能承受的并发来配。配太大,可能把下游打挂;配太小,网关自己成为瓶颈。一般建议从下游能承受的最大并发反推,比如下游单实例能扛200并发,网关有10个实例,那每个网关实例的连接池可以配20到30。
还有个坑是连接池的"空闲连接回收"配置。如果回收太激进,连接频繁创建销毁,性能反而下降。如果回收太保守,空闲连接占着资源。这个要根据实际流量曲线调,通常保留一定数量的常驻连接,其余按需创建。
6.3 重试放大了故障,而不是缓解了故障
前面提过重试的风险,这里再强调一次。重试在正常情况下能提高成功率,但在故障情况下会放大故障。下游已经因为压力大而变慢,重试让请求量翻倍,下游彻底崩溃。
所以重试必须和熔断配合。熔断打开后,重试自然就停了,因为请求根本不发往下游了。另外,重试要有全局预算,比如整个网关每秒最多重试1000次,超过就放弃重试,避免重试本身成为压垮系统的最后一根稻草。
6.4 异常处理代码本身抛异常
这个坑很隐蔽。异常处理逻辑里如果又抛了异常,比如降级时去查缓存,缓存也挂了,那这个异常会往上冒,最终可能变成一个500。更糟的是,如果异常处理逻辑里有死循环或者阻塞操作,可能把处理异常的线程也卡死。
所以异常处理代码要格外小心,尽量简单,不要有复杂的依赖。降级逻辑要能容忍自身失败,比如查缓存失败就直接返回默认值,不要再抛异常。异常处理代码的测试覆盖率要更高,因为它在正常情况下不执行,一旦执行就是关键时刻。
6.5 配置热更新导致路由短暂不可用
网关的路由配置通常是热更新的。如果更新逻辑没做好,更新的一瞬间可能出现路由表为空或者部分路由缺失,导致请求404。这种问题很难复现,因为只在更新的瞬间出现。
解决办法是路由表用不可变对象,更新时构建一个新的路由表,然后原子替换引用。这样更新过程中,请求要么用旧表要么用新表,不会看到中间状态。另外,更新要有校验,新配置不合法就拒绝更新,保留旧配置。
6.6 健康检查太激进,把正常实例摘掉了
网关通常会做下游实例的健康检查,不健康的实例不转发流量。如果健康检查太激进,比如超时设得很短、失败一次就摘除,可能把只是偶尔抖动的正常实例摘掉,导致剩余实例压力增大,引发连锁反应。
健康检查要温和一些:超时可以比正常请求超时稍长,失败要连续几次才摘除,摘除后要定期试探恢复。摘除和恢复都要有日志和监控,方便观察。
7. 从一次真实故障复盘看异常处理的完整闭环
7.1 故障现象与初步排查
某次线上故障,现象是部分用户反馈下单失败,错误码是503。查网关监控,发现503的数量在几分钟内从0涨到几百,然后回落。查网关日志,503的原因是"熔断打开"。
继续查熔断的原因,发现是订单服务的一个下游接口失败率飙升。再查那个下游接口,发现它的数据库连接池被打满了。数据库连接池为什么满?因为有个慢查询,平时偶尔出现,那天因为数据量增长,慢查询变多,连接被长时间占用。
7.2 暴露出的问题
这次故障暴露了几个问题。第一,熔断的粒度太粗,订单服务的一个下游接口失败,导致整个订单服务的熔断都打开了,其实订单服务的其他接口是正常的。第二,数据库慢查询没有监控,直到引发故障才发现。第三,熔断打开后没有告警,是靠用户反馈才知道的。
7.3 改进措施
针对第一个问题,把熔断粒度细化到接口级别,不同接口独立统计失败率。针对第二个问题,给数据库加慢查询监控,超过阈值的查询要告警。针对第三个问题,熔断状态变化要打点告警,熔断打开时通知相关同学。
另外还加了一条:熔断打开后,降级逻辑要返回一个明确的错误码和提示,而不是笼统的503,方便用户和客服判断。
7.4 复盘带来的思考
这次故障让我意识到,异常处理不是写完代码就完了,它是一个需要持续运营的能力。熔断阈值合不合适、降级逻辑对不对、告警及不及时,这些都要在真实故障中检验和调整。
还有一点:异常处理的每个环节都要有可观测性。熔断为什么打开、降级为什么触发、重试为什么失败,这些都要能从监控和日志里看到。没有可观测性,异常处理就是黑盒,出了问题只能猜。
8. 给正在做网关异常处理的同学几条实在建议
第一,先把异常分类做清楚,再写代码。分类是策略的基础,分类不清,策略必乱。
第二,超时、重试、熔断的参数要一起调,不能单独调。它们是一个整体,任何一个参数不合理都会让其他保护机制失效。
第三,降级逻辑要有监控和告警。降级不是"处理完就完了",它是一个需要被持续关注的信号。
第四,requestId和日志脱敏要在网关层做扎实。这是排查问题和合规的基础,后面补很麻烦。
第五,异常处理代码要简单、要测试、要能容忍自身失败。它在关键时刻执行,不能成为新的故障点。
第六,定期做故障演练,主动触发熔断、降级、超时,看看系统表现是否符合预期。很多异常处理逻辑平时不执行,只有演练才能发现问题。
第七,不要追求一次做完美。异常处理是迭代出来的,先覆盖主要场景,再根据真实故障逐步完善。我见过太多团队想一次性设计一个完美的异常处理框架,结果拖了几个月没上线,线上问题一直裸奔。
最后分享一个我个人一直用的检查清单,每次网关改动后过一遍:所有下游是否都有超时配置、所有重试是否都幂等、所有熔断是否都有降级、所有降级是否都有监控、所有异常是否都有requestId、所有日志是否都脱敏。这六条看起来简单,但每次都能查出点问题。