ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

接口超时引发雪崩?高并发系统限流熔断降级隔离实战

接口超时引发雪崩?高并发系统限流熔断降级隔离实战 做了几年后端最怕的不是半夜被报警短信吵醒而是醒来发现整个集群的接口平均耗时从200ms涨到了5秒接着一台台机器开始频繁GC监控大盘一片飘红。我经历过不只一次这种场面其中有一次就是典型的接口超时引发雪崩差点让整条交易链路挂在凌晨三点。这篇文章想跟你聊聊在高并发架构里接口超时和雪崩到底是怎么一步步发生的以及我们后来用了哪些实打实的招数把它按住。无论你是刚接触高并发的新手还是已经在一线扛过流量压力的开发这篇文章里的思路和代码配置都能直接拿去用。限流、熔断、降级、隔离这些词你可能听过很多遍但真正关键的是把它们落到具体场景里知道什么时候该用哪一个参数怎么调。1. 先搞清楚对手接口超时与雪崩是怎么一步步发生的1.1 从一次“轻微超时”到“全线雪崩”的演进路径很多人以为雪崩是瞬间发生的其实它在爆发前通常有一段“慢性死亡”的窗口期。我遇到的那次事故起因特别不起眼上游一个商品服务的Redis连接池满了导致部分查询接口响应从10ms慢慢涨到800ms。我们的订单服务调用商品服务拿商品信息用的是同步HTTP调用。当时设置的读超时是3秒看起来挺充裕但问题在于这个接口是订单列表页的基础依赖一次页面请求要循环调用它十几次。单个请求耗时800ms之后十几个串行调用叠加响应时间直接翻到10秒以上。Tomcat默认核心线程数是10最大200任务队列是Integer.MAX_VALUE。随着请求不断堆积线程池里的200个线程全被慢调用占住新的请求全部进队列等待。用户端等不到结果就开始手动刷新刷新又产生新的请求网关层看到大量超时又自动重试一次。这套动作叠加下来流量放大了接近三倍。最终Tomcat的队列里积压了几万个任务内存被待处理的任务对象撑爆FGCFull GC频繁触发CPU飙升到100%然后整台机器宕机负载均衡把流量转到下一台机器下一台机器同样被击穿。这一步步走下来表面上像是“突然挂掉”实际上是慢调用占线程 - 线程池耗尽 - 请求排队 - 内存压力 - GC停顿 - 整体不可用的连锁反应。所以治理接口超时第一步不是改超时时间而是先理解线程模型和调用链路上的资源约束。1.2 雪崩的三个阶段线程池耗尽、队列积压、依赖连锁失败我把雪崩拆成三个阶段方便对症下药。阶段一是“线程池耗尽”。系统里每个下游调用都会占用一个工作线程如果下游响应变慢线程就被“钉”在那边等结果。等的时间长了线程数量就成了系统的硬上限。你可以做一个简单的实验对一个只有10个线程的线程池放20个耗时2秒的任务第11个任务就会开始排队后面的请求肉眼可见地变慢。阶段二是“队列积压”。线程池的队列不是无底洞等待的任务会消耗内存。尤其是Tomcat默认的BlockingQueue是无界队列一旦积压几万个请求对象内存占用直接飙升。同时大量排队任务会携带脏数据或重复的上下文处理时又产生额外的垃圾对象进一步加速FGC。阶段三是“依赖连锁失败”。订单服务挂了上层调用它的购物车服务也开始超时购物车服务再去查用户服务、营销服务错误会像多米诺骨牌一样沿着调用链传导。这时候如果某个服务没有熔断保护它会继续尝试调用已经不可用的下游从而把自己也拖死。你现在可以对照检查一下自己的系统哪些服务之间的调用是同步的有没有设置合理的超时线程池的队列是有限的还是无限的如果没有限流和熔断雪崩几乎是必然的只是早晚问题。1.3 超时时间不是拍脑袋定的很多团队把超时时间设成“下游超时 1秒”这种拍脑袋做法这在实际运行中很危险。超时时间过长线程池被慢调用占用的时间就越长系统能承受的并发量成倍下降超时时间过短本来只是偶发抖动稍微慢了一点就被误判为失败紧接着重试放大流量反而造成更大的压力。合理的超时设置应该基于三个数据网络往返时间RTT的TP99业务要求的响应时间上限以及下游服务的实际耗时分布。比如订单服务调用商品服务实测正常情况TP99是80msTP999是150ms很少超过200ms。那我们可以把读超时设为500ms留出两倍以上余量同时又能挡住超过1秒的异常慢调用。这里还有个容易忽略的点超时是分层的。传统HTTP客户端里connectTimeout和readTimeout要区别对待连接超时一般设300ms就够读超时则依赖业务耗时分布。RPC框架比如Dubbo或gRPC也有自己的超时配置还要加上重试次数限制。不要只调接口层面网关层的超时也要比下游略短这样可以快速切断请求避免请求打到已经积压的业务线程池里。2. 架构层的“四板斧”限流、熔断、降级、隔离2.1 限流把流量挡在系统能承受的范围之外限流不是反人类的设计它是给自己的系统留出喘息空间。核心思路很简单不管进来多少请求我只处理我能扛住的数量其余的直接返回“系统繁忙”。常见的限流算法有四种计数器、滑动窗口、漏桶和令牌桶。计数器最简单但容易产生突刺比如每秒限制1000个请求你在900ms时放行了1000个最后100ms又放行了一批瞬时流量可能冲到1500。滑动窗口把时间切细能平滑突刺。漏桶算法把请求排成队列匀速处理适合保护下游接口但突发流量会被积压。令牌桶算法允许一定程度的突发流量更贴合大多数业务场景。实际操作中大部分公司不会自己写限流器直接用现成的组件网关层可以用Sentinel的网关插件服务层可以用Sentinel的FlowRule或者用Resilience4j的RateLimiter。我个人的建议是优先用Sentinel因为它有控制台可以实时调整规则不用改代码发版。限流阈值怎么定不是拍脑袋的应该结合压测数据先压出单机能够稳定处理的QPS比如单机800 QPS然后乘以机器数再乘以冗余系数一般0.7-0.8。比如5台机器压测单机上限800整体阈值就是80050.83200 QPS。这个数字要动态调整不能上线后一成不变。2.2 熔断像电路开关一样保护下游熔断的本质是“快速失败”。当某个下游接口的错误率达到一定阈值或者平均响应时间超过阈值熔断器打开后续请求不再真正打到下游而是直接返回降级结果或抛异常让下游有机会恢复。熔断器有三个状态CLOSED关闭、OPEN打开、HALF_OPEN半开。CLOSED状态下请求正常放行但熔断器会统计错误率和耗时。当错误率超过阈值状态变为OPEN接下来的请求直接短路。过一段时间后进入HALF_OPEN会放一个试探请求进去如果成功就关掉熔断器恢复流量如果失败继续OPEN等待下一个周期。Sentinel的熔断降级规则里我常用的是慢调用比例模式设定一个最大RT比如200ms如果在统计时长内请求数达到阈值且慢调用比例超过某个百分比比如50%就触发熔断。这里的参数不是随便填的我建议统计时长设在30秒左右最小请求数设为10以上避免单次抖动就触发熔断。熔断降级的时候一定要有降级逻辑不能直接抛500。降级可以返回一个空列表、一份缓存数据、或者一个默认对象。比如下单页查优惠券信息如果优惠券服务熔断了可以直接返回“暂无可用优惠券”用户完全感知不到后端出了问题。2.3 降级丢车保帅保证核心链路降级和熔断是搭配使用的熔断是手段降级是兜底。降级一般分两个维度一个是“主动降级”提前计划好在流量高峰时关闭非核心服务另一个是“被动降级”依赖熔断触发时的fallback逻辑。主动降级的经典场景是“大促时关闭商品详情页的个性化推荐”。这个功能挂了也不影响用户下单但它的调用量可能占到整个详情页调用量的40%。大促前通过配置中心把推荐开关关掉能瞬间释放大量线程和带宽给核心链路。被动降级的关键是写好fallback方法。在OpenFeign或Sentinel里fallback方法必须和原方法保持相同签名并额外接收一个Throwable参数。我之前见过一个团队把fallback写成了另一个远程调用结果下游挂了之后fallback也调用下游雪崩反而更严重了。降级逻辑必须是“最坏情况下的最保守处理”宁可返回假数据也不能再做任何远程调用。2.4 隔离不让一个节点拖垮整个集群隔离是四板斧里容易被忽略但又最有效的一个。隔离的核心思想是“物理屏蔽故障影响范围”。最常见的隔离手段是线程池隔离每个依赖的服务分配独立的线程池一个服务的线程池满了只影响它自己不会挤占其他服务的线程。Hystrix的线程池隔离模式很经典虽然现在Hystrix进入维护模式了但它的思想还是值得借鉴。比如订单服务调用商品服务单独分配一个10个线程的线程池调用库存服务单独分配一个5个线程的线程池。商品服务慢到打满它的线程池库存服务的调用依然畅通。线程池隔离也有代价线程切换有开销而且每个线程池都要配置大小。做隔离设计时线程池大小可以用“QPS * 单请求耗时(秒)”来估算再乘一个冗余系数。比如商品服务QPS预计200平均耗时100ms那么需要的线程数是200*0.120再乘1.5冗余大约30个。除了线程池隔离还有信号量隔离不创建线程池而是通过信号量限制并发数超过并发数的请求直接拒绝。它的开销小适合调用量巨大但耗时很短的场景但无法隔离线程阻塞需要根据场景选择。3. 实战落地一个订单系统的接口超时治理全流程3.1 现状梳理先建监控和压测基线动手改代码前一定要先摸清现状。我们的订单服务当时面临三个问题第一所有下游调用都是同步HTTP用的是RestTemplate默认配置第二QA环境压测时发现只要模拟下游延迟订单服务很快就不可用第三监控只有CPU和内存根本没有接口RT和错误率的细粒度统计。所以第一步是补监控。我们接入了Prometheus重点采集三个指标QPS、平均RT和错误率这就是常说的RED监控法。对每一个下游接口都埋点记录请求耗时和状态码用Grafana做可视化。同时用SkyWalking挂了全链路追踪当用户说“下单很慢”的时候能快速定位是哪个调用节点慢。补好监控之后做了第一轮基线压测用JMeter模拟500并发持续压5分钟。结果是订单列表接口的成功率只有62%平均RT从200ms飙升到4秒。这个就是改造前的“不正常基线”后面每一步优化都要跟它对比。压测的时候注意一点一定要在独立环境压测不要在生产环境直接导流否则压测本身就会把生产拖垮。压测过程中要盯着监控一旦发现线程池排队或Full GC马上终止压测记录数据。3.2 第一刀设置合理的超时与重试策略现状确认后我们做的第一件事就是统一超时和重试策略。HTTP客户端方面我们弃用了RestTemplate默认配置改用了OkHttp 3并给每个下游调用配置独立的连接池和超时。具体配置如下OkHttpClient client new OkHttpClient.Builder() .connectTimeout(300, TimeUnit.MILLISECONDS) .readTimeout(500, TimeUnit.MILLISECONDS) .writeTimeout(500, TimeUnit.MILLISECONDS) .retryOnConnectionFailure(false) .build();这里的关键是把retryOnConnectionFailure设为false。之前系统会自动重试一次但那一次重试是在连接失败时做的。在连接已经失败的基础上再次发起请求只会加重下游负担。重试策略我们单独写在了服务层用Spring Retry的Retryable注解要求两点重试次数最多1次且重试只针对“下游连接失败”这种瞬时错误业务异常比如库存不足不重试。同步调用重试的风险我之前提过升级到异步化之后重试可以更激进一点但那已经是后话了。同时我们设置了动态超时根据监控里的TP99每个下游接口的超时时间都会定期调整。比如库存查询正常TP99为100ms我们设置超时300ms商品批量查询TP99为250ms我们设置超时800ms。不在代码里写死而是通过Apollo配置中心动态下发改超时不需要发版。3.3 第二刀引入缓存与异步化解热点压力减少超时的最硬核办法是不发起远程调用或者不发起同步远程调用。订单列表页当时的高热点是商品基本信息查询。同一个商品在短时间内会被大量用户访问每次都去数据库查一次再返回浪费太大了。我们引入了了两层缓存第一层是本地Caffeine缓存定时2秒刷新适合应对热点商品第二层是Redis缓存过期时间5分钟适合存储商品标准化信息。Caffeine配置非常简单但要注意expireAfterWrite和refreshAfterWrite的配合。我们用的是refreshAfterWrite在缓存过期后不是直接删除而是异步触发一次刷新防止缓存雪崩。示意配置CacheString, ProductInfo productCache Caffeine.newBuilder() .maximumSize(10_000) .refreshAfterWrite(Duration.ofSeconds(2)) .build(key - productService.loadFromRedisOrDb(key));缓存命中率达到90%以上后这部分请求不再走远程调用超时覆盖率明显下降。然后是把扣减库存从同步改成异步。原来的流程是下单 - 远程调用库存预扣 - 生成订单 - 支付。预扣库存如果慢整个下单流程就慢。我们改成了下单 - 本地事务创建订单 - 发送MQ消息 - 库存消费端异步扣减。用户支付后再通过订单状态反查库存结果如果扣减失败则进行异步补救。这一步改造后下单接口不再被库存服务拖住接口耗时直接降了40%。你会发现很多“高并发”问题其实都是同步等待的问题只要把链路里非必须同步的部分砍掉系统的吞吐量就会明显提升。3.4 第三刀用Sentinel完成熔断限流配置缓存和异步解决了大头但光靠它们还不够系统还是需要一个“自动刹车”机制。我们在服务里集成了Sentinel主要做了三件事给核心接口配置限流规则给慢调用配置熔断规则给下游依赖配置降级逻辑。限流规则示例用JSON配置方便控制台动态更新[ { resource: POST:/order/create, grade: 1, count: 2000, limitApp: default, strategy: 0, controlBehavior: 1 } ]这里的grade:1表示QPS限流count:2000表示每秒允许2000个请求controlBehavior:1表示超出阈值时直接拒绝。这个2000不是拍脑袋来的而是根据压测结果推算的单机QPS上限800集群5台按0.7冗余就是2800但我们考虑到支付回调的流量也会走这个接口所以降到2000更保险。熔断规则我们用的是慢调用比例模式[ { resource: GET:/product/list, grade: 1, count: 300, timeWindow: 30, minRequestAmount: 20, slowRatioThreshold: 0.5, statIntervalMs: 30000 } ]含义是在30秒统计周期内如果请求数超过20且慢请求RT大于300ms比例超过50%就打开熔断器之后30秒内快速失败。这里把“慢调用”的阈值定义得很关键300ms是指商品列表接口的TP99再加50%余量。降级逻辑我们写在Feign的fallback里。Feign调用商品服务失败时返回一个包含默认商品名称的空壳对象保证订单列表页能渲染出来。3.5 压测验证与效果对比所有配置完成后我们又做了一轮压测和改造前的基线对比。同样用JMeter 500并发压5分钟结果如下指标改造前改造后订单列表接口成功率62%99.6%平均RT4.2s230msTP99 RT8.1s650ms机器CPU峰值100%45%Full GC次数7次0次这个结果说明超时治理不是单一手段能解决的必须组合拳。超时设置解决的是“根因”问题缓存和异步解决的是“消除无效调用”问题限流和熔断解决的是“防止故障扩散”问题。每一刀砍下去系统离雪崩就更远一点。压测过程中我们还专门模拟了商品服务挂掉的情况手动把商品服务停掉观察订单服务的表现。因为有熔断和降级订单服务依然能返回商品名称为“未知商品”的列表只是响应时间略有增加接口成功率保持在95%以上。这就是我们要的效果不是什么性能指标好看而是故障来临时系统还能用。4. 常见问题与排查技巧实录4.1 问题速查表架构改造过程中团队踩了不少坑。我把最典型的几个问题整理成表格方便你对照排查。现象常见原因排查方向接口偶尔超时但监控看不出来日志线程卡在下游IO等待全链路追踪里看下游耗时分布集群机器一台台轮流宕机慢调用占满线程池FGC频繁检查线程池活跃线程数看FGC日志熔断频繁触发但下游其实正常熔断阈值设置过严调整慢调用RT阈值结合TP999看降级后返回的数据和缓存不一致降级逻辑里查询了数据库降级方法里禁止任何远程调用重试流量放大导致雪崩框架自动重试次数过多统一切断框架重试自行控制重试场景网关层RT正常但业务层超时网关超时时间比业务层更长网关超时应该比下游略短排查接口超时最关键的一步是区分“是网络问题还是业务问题”。先用curl或grpcurl直接调接口给一个长超时看响应是否正常。如果直接调用也慢大概率是代码或下游问题如果直接调用很快但通过网关慢那就是网关或网络链路问题重点看网关日志。4.2 排查工具与思路统一用好这几类工具能节省大量时间全链路追踪SkyWalking或Zipkin重点看“Span耗时”和“异常堆栈”能快速定位是哪一次远程调用导致的超时。线程快照线上出现线程池耗尽时立刻执行jstack pid threaddump.txt重点看前缀为http-nio-和HystrixTimer-的线程栈如果大量线程都停在SocketInputStream.read基本可以断定是被下游慢调用拖住了。GC日志在JVM参数里增加-Xlog:gc*:file/logs/gc.log:time,uptime,level排查是否频繁FGC。高并发场景下FGC带来的暂停时间最容易造成接口超时假象。压测工具JMeter或wrk一定要有独立的压测环境和压测监控页面不要边压测边在IDE里看。我还建议在压测环境里做一次“故障演练”故意把下游服务的接口改成sleep 5秒然后观察上游的线程池和熔断状态。这种演练比一顿操作猛如虎的“优化”更能发现问题因为它能验证你的保护机制在真正的故障面前好不好使。4.3 避坑指南结合我个人多次搭过高并发项目的经历唠叨几个容易踩的坑。第一不要在代码里直接Thread.sleep(2000)来模拟慢调用做测试这只会让问题看起来“好像解决了”但没有客服网络延迟、丢包、连接池耗尽等因素。用真实网络环境下的故障注入工具ChaosBlade或toxiproxy更可靠。第二超时时间、线程池大小、熔断阈值这些参数不能一劳永逸。业务流量增长、依赖服务升级、机器配置变化都会影响基线每个月至少要复盘一次监控数据动态调整规则。我们团队就吃过亏一次大促前流量涨了50%但熔断阈值还是三个月前的结果刚刚触发熔断就把正常流量全断了用户大量报错。第三降级逻辑一定要单独测试。很多团队把降级方法就当成“随便返回一个东西”结果缓存key写错、默认值设置成null导致NPE反而加剧了故障。我会把降级逻辑当成一条独立的短链来测专门模拟下游挂掉的情况验证返回结果是否符合预期。第四连接池的管理比超时更隐蔽。HTTP客户端的连接池如果过小在高并发下大量请求会排队等连接这比读超时还难排查。用OkHttp时注意maxRequests和maxRequestsPerHost要跟下游接口的QPS匹配。比如单机QPS 500每个连接能处理20个并发请求至少需要25个连接建议设置maxRequestsPerHost50。结尾做高并发架构这么几年最大的体会是不要把目光只盯住“把接口调快”更要多想“接口变慢之后会发生什么”。只要有一次真实事故你就会明白限流熔断降级的重要性。别追求零超时那是不可能的只要保证超时发生时系统能够优雅降级并且不会把故障扩散出去你就能在流量洪峰里站稳脚跟。最后再分享一个小技巧每周花点时间看一下群里的报警记录尤其是那种“偶发超时”的报警别觉得无所谓很多雪崩就是从不经意的一次超时埋下伏笔的。把每一次超时都当成一次演练你的系统会越来越皮实。
返回列表