写点什么

百度网盘防雪崩架构实践

作者:百度Geek说
  • 2025-02-18
    上海
  • 本文字数:6119 字

    阅读完需:约 20 分钟

导读

大模型在研发效能领域代码生成方面发挥了越来越大的作用


而大模型的预训练依赖大量的精标代码,这些精标数据必须是比较好的工程实践代码


这些比较好的工程实践代码,需要大量的技术沉淀,包括工程架构,代码架构等多纬度,涉及性能、可用性、扩展性、安全等方向


百度网盘有不少比较好的工程实践,本文主要是介绍百度网盘工程架构中的防雪崩架构


抛砖引玉,与大家一起探讨什么才是优秀的工程实践,为大模型的落地提供坚实的数据基础

01 背景

1.1 百度网盘业务背景介绍

简要介绍一下百度网盘的业务背景


  • 外部视角:用户规模超过 10 亿,单纯外部用户请求日 pv 过千亿;

  • 内部视角:实例数超过 60w+个,模块数规模过千,单个功能最多涉及 100+个模块节点。


在复杂的链路+高并发情况下,短暂的异常就会使得整个系统出现雪崩,即使异常消失也无法自愈,对用户产生不好的用户体验。


于是百度网盘服务端工程方向设计了一套防雪崩的机制,本文先介绍雪崩的技术背景,再介绍相关的解决方案。

1.2 雪崩发生的背景介绍

雪崩是如何发生的?简单的说,某种场景下,触发一个服务的处理能力(容量)不足而拒绝导致请求失败,而它的上游因为请求失败而重试,加剧服务持续处理无效请求,无法处理有效请求,从而形成雪崩死循环,服务无法恢复。


如下图所示,某个变动(根因) –> 服务异常(诱因) –> 链路异常(局部雪崩) –> 全局异常(全局雪崩)


简单来说,雪崩分成两个阶段:


  • 初始阶段:各种根因 + 诱因 –> 服务过载;

  • 循环阶段:上游重试 —> 服务持续处理无效请求 —> 上游重试 ……



下面将按系统视角介绍:雪崩是咋发生的?


先从服务接收 &处理请求来说,根据网络 TCP 三次握手准则,客户端的请求会被放入半连接/全连接队列中,而服务端从队列中取出请求进行处理。由于队列是先进先出的,处理的请求都是之前的请求(即使 client 已经断开了连接,但是 accept 队列也不会剔除这些断开连接的请求)。如下图,client 向 serve 发了 5 个请求,r0/r1/r2/r3/r4,这些请求会被放入 server 的 accept 队列里,但是由于 server 处理逻辑比较慢,导致 client 请求超时,断开了连接 r0/r1/r2 的连接,但是 server 这边还会从 accept 队列里取出 r0/r1/r2 的连接,进行请求 request 数据的读取(即使 client 已经断开连接,server 还是能够读到之前 client 发送的请求数据),进行无效的处理,于是 client 不停的重试,而 server 不停的在处理失效的请求。



△server 读到已经断开连接的 socket 连接



△ server 读到已经断开连接的 socket 请求数据


从上下游处理视角来说,雪崩并不是局部行为,它会随着链路漫延到整个系统链路上。如下图,client 以 300ms 的超时时间访问 server,server 在访问 A 和 B 之后,已经用掉了 300ms,这个时候 client 已经断开了和 server 的连接,但是 server 却继续访问 C 和 D,进行无效的请求。当这种无效请求在整个链路蔓延开,client 又在大量的重试的时候,就是整个系统崩溃的时候。


02 传统解决方案

从预防/阻止/止损雪崩三个子方向介绍相关实战经验,以及分析它们的优缺点。

2.1 预防

通过各种手段,规避雪崩初始阶段的出现,例如热点治理、长尾治理、分级操作、容量保障等。

2.2 阻止

处于雪崩初始阶段,有发生雪崩的可能性,需要阻止雪崩进入循环阶段。


【重试率控制】


  • 基本思路:当重试请求数占当前请求数的 X%, 就不会再进行重试;

  • 优点:可以避免大量重试导致雪崩死循环;

  • 不足:设置重试率多少才是合理的?另外,这个还需要持续更新,因为下游的容量是在变化的,可能连正常的流量都无法处理(比如实例被缩容了)。


【队列控制】


  • 基本思路:不再基于系统层面的内核队列,应用层自己维护队列(比如用一个线程从系统读取连接数据到队列中),记录请求在队列中等待的时间。如下图,队列中有 8 个请求,代表他们在队列中等待的时间(单位 ms),假设上游的执行是 200ms,处理一个请求需要 100ms,那么第 1 个到第 4 个请求明显是失效请求(最大等待时间( = 上游执行超时 - 服务处理时间) < 当前等待时间),等处理完上游都已经断开连接了,就不需要处理了,可以直接从第 5 个请求进行处理;



  • 优点:通过维护应用层的队列,加上等待时间,快速抛弃无效请求,避免雪崩的发生;

  • 不足:依赖假设上游的访问超时时间以及自己的执行时间,才能准确判断出请求是否已经失效。但是有可能不同的上游的超时时间不一样,自身的执行时间也不稳定性。同在服务在极端负载下,包括读取系统队列的线程也会受限资源,无法及时从系统队列中读取请求,导致读到了无效请求。


【限流】


  • 基本思路:在服务接入层设置一个静态阈值,表示后端服务最大的请求 qps,超过这个 qps 的流量就丢弃请求;

  • 优点:可以避免瞬间突增的流量打垮服务,导致服务不可恢复,可以解决一部分场景问题;

  • 不足:提前设置限流一个静态阈值,和重试率控制一样,不是所有场景都有效果。另外也有运维维护成本,具体如下:

  • 阈值合理性:需要频繁压测才能知道当前系统的瓶颈 qps 阈值是多少,设置大了,达不到阻止过多流量的效果,设置小了,又有误伤;

  • 故障期间服务处理能力退化:当花费了很多时间压测出来一个系统的 qps 阈值是 100,但是故障期间服务承载的 qps 可能会退化成 80。会有很多原因会导致,比如一部分实例 oom 导致不可用,上线导致实例启动失败、长尾流量导致部分实例处理能力不足(比如某些视频转码需要更长的时间,同时还是热门视频,负载均衡重试到不同实例上),最常见的是下游请求执行时间变长了。这样导致每秒都需要承载多余的 qps 请求,累计下来,这个系统迟早被打垮。本质问题还是静态阈值导致的。

2.3 止损

通过各种手段,加速雪崩止损恢复,例如:


  • 限流:通过多次人工调整限流阈值,使后端请求减少,逐渐恢复;

  • 重启服务:通过重启服务,快速丢弃系统队列中的无效请求。


属于兜底的机制,缺点是整个恢复周期会比较长。

03 解决方案

传统解决方案里,包括预防、阻止、止损三个子方向。


一旦处于雪崩状态,是不可逆的,需要比较长的时间去恢复。


所以重点在于预防和阻止,因为预防会有遗漏,更多的精力在于阻止,即处于雪崩初始阶段,有发生雪崩的可能性,需要阻止雪崩进入循环阶段。


业界的做法,其实可以分成两种做法:


  • 减少过载流量:比如限流和重试率控制,剔除服务不能够承载的流量;

  • 减少无效请求:比如队列控制,使得服务处理有效的流量。


这两种做法需要结合,单个做法是会有问题的。


比如减少过载流量,由于下游的处理能力是动态变化的,实际上还是会出现过载,下游会长期处于处理无效请求,无法处理有效的请求。


减少无效请求,大部分场景下是能搞定的,但是如果瞬间的请求量特别大,请求都是属于有效的,内存可能会先达到瓶颈,导致 oom 了。


下面介绍一下百度网盘的防雪崩架构实践。

3.1 减少过载流量-基于动态熔断

【基本思路】


业界对减少过载流量,除了上面说的静态限流之外,还有一种熔断做法,具体流程如下:


流程说明1 开始请求: 系统接收到一个外部请求。2 熔断器状态判断:- 闭合状态(Closed): 熔断器允许请求通过,继续执行。- 打开状态(Open): 熔断器阻止请求,直接失败或返回预定义的响应。- 半开状态(Half-Open): 熔断器允许部分请求通过,以测试服务是否恢复。3 执行请求:- 请求成功:    - 如果处于闭合状态,则重置失败计数。    - 如果处于半开状态,则关闭熔断器,恢复正常。- 请求失败:    - 增加失败计数。    - 如果失败计数超过预设阈值,则打开熔断器,跳闸。4 冷却时间: 熔断器在打开状态后,会等待一段冷却时间,然后进入半开状态。5 半开状态测试:- 请求成功: 关闭熔断器,恢复正常。- 请求失败: 重新打开熔断器,继续等待冷却时间。
复制代码


这个熔断机制和限流一样的,都是存在静态的缺陷问题。


举个例子,当前流量的 qps 为 100,后端只能承载 10 的 qps。


当后端失败率超过阈值之后,触发打开状态,然后等待一段时间之后,进行半开状态,用一部分流量进行测试。


本质上是不能动态根据后端的处理能力进行流量限制转发,所以需要实现动态熔断限流。


【具体实践】


其核心思路是根据下游的请求成功率动态限制转发到下游的请求数。具体如下图所示,先随机丢弃 X%比例的请求,然后进行检测,判断服务是否恢复,如果还未恢复,说明需要继续降低转发到下游的请求数,设置 X = X + Step,增大丢弃请求的比例,继续熔断限流。如果已经恢复了,则判断丢弃请求的比例是否已经降低到 0(即 X 是否为 0),如果 X 不为 0,则还需要继续减少丢弃请求的比例,设置 X = X – Step,继续熔断限流,如果 X 为 0 则说明整体已经恢复,则结束动态熔断限流。


动态熔断的思想是借鉴了网络,当雪崩过载的时候,相当于发生了请求的拥塞,和网络拥塞是一样的特征行为,网络链路都带宽相当于服务的容量。




这里面其实还有一种问题没法解决,即 ddos 攻击。


这种一般需要有一个统一的接入层来解决,设置一个相对大的限流阈值,然后通过动态熔断来转给后端的业务。

3.2 减少过载流量-基于流量隔离

动态熔断策略相对复杂,还有一种简单粗暴的方式,即流量隔离。


适用于流量来源存在不同级别的,而且高优流量常态下比较稳定,低优流量有突增的情况。


【基本思路】


将流量进行分级,通过部署隔离解决高低优流量相互影响的问题,即使低优流量再怎么增加也不影响高优流量。


  • 高优流量:比如用户感知明显流量等;

  • 低优流量:比如后台流量、离线计算流量等。


【具体实践】


首先是 client 需要打上流量标签,其次是 gateway or service mesh 基于这些流量标签进行相关的流量转发。

3.3 减少无效请求-基于请求有效性

【基本思路】


上游服务 A 访问下游服务 B 的请求时间可以由以下部分组成:


请求耗时 = 上游发送请求 + 网络传输 + 下游 tcp 建连队列等待时间 + 下游处理时间


  • 上游服务 A 发送请求:基本可以忽略,机器负载问题不再这里考虑;

  • 请求在网络中的耗时:基本可以忽略,网络故障问题不再这里考虑;

  • 请求在下游服务 B 系统队列中的等待时间:堆积太多请求会导致请求失效;

  • 下游服务 B 的处理耗时:处理的慢导致请求长期阻塞在系统队列中。


不再基于单独维护一个队列的思想去解决问题。单独队列一方面是需要支持不同语言,以及准确性不足,在虚拟化的环境里,资源受限之后,队列的等待时间就不准确了,还有上游不同的超时时间,没法简单判断是否已经超时了。


这里面的关键点是上游传递一个截止时间给下游,但是如何表示该请求截止时间呢,具体有绝对时间和相关时间两种方式,相关缺点如下:


  • 绝对时间:用绝对时间戳表示(比如 1577681783),但是不同设备上的时钟不一致,如下图 A 和 B 两台机器的时钟差了 2 分钟,A 访问 B 的超时时间是 5s,于是请求发到机器 B 上就直接超时失败了,不符合预期;

  • 相对时间:用相对时间表示(比如 5s),但是这个时间是从业务接收到请求开始计时的,没有包括在系统队列中的时间,有可能已经等待了很久的时间了,上游已经断开了连接,不符合预期;



△绝对时间的问题



△相对时间的问题


【具体实践】


从上文可知有这么两个问题,绝对时间和相对时间解决其中之一:


  • 机器之间的系统时钟不一致

  • 需要关注在系统队列中的等待时间


于是可以将绝对时间和相对时间结合在一起,借助 UFC(百度网盘的 Service Mesh,详见之前的一些分享Service Mesh在百度网盘数万后端的实践落地)来解决问题,以下图流程为例子说明:


  • Host1 机器上的服务 A 访问 Host2 机器上的服务 B;

  • 服务 A 先访问本地的 UFC-agent,传递相对超时时间为 5S;

  • Host1 机器上 UFC-agent 访问 Host2 机器上的 UFC-agent,传递相对超时时间为 5S;

  • Host2 机器上的 UFC-agent 把相对超时时间转成绝对超时时间;

  • Host2 机器上的 UFC-agent 访问本地的服务 B。



通过双端的 Agent 实现了相对时间到绝对时间的转换,对业务解决了上面的那 2 个问题,对业务也是透明的。



目前网盘的核心链路都接入了这套请求执行时间过期丢失的机制。


不过这个也是存在一些缺陷场景的,比如网络故障/ufc agent 资源打满等,使用其它的解决方案来解决这些缺陷。

3.4 减少无效请求-基于 socket 有效性

上面是基于 deadline 来判断请求是否有效的,是百度网盘 19 年的技术方案。


当时百度网盘的技术栈还是比较多样的,编程语言包括 c/c++/php/golang/ngx_lua 等多种,需要从 Service Mesh 这种中间件来解决问题。


另外,这个依赖 Service Mesh 这种中间件的落地覆盖,并不是所有的业务都具备这种的前提条件。


这里提供另外一种基于 socket 有效性的方案来判断请求是否有效。


【基本思路】


需要有一个认知:


比如以下场景:


  • tcp 三次握手之后,server 未 accept 请求,client 直接调用 close 关闭连接;

  • tcp 三次握手之后,server 未 accept 请求,client 先写 request 请求,然后调用 close 关闭连接;

  • server accept 后进行请求处理时,client 调用 close 关闭连接。


从 c 语言编程视角来看,非阻塞情况下 read 函数返回-1 表示读数据失败,返回 0 表示读到 fin 包,即 client 关闭了 socket。


ssize_t read(int fd, void *buf, size_t count);


所以可以基于 socket 读到 fin 包事件来判断 client 是否已经断开连接了,如果已经断开,server 则不需要处理接下来的逻辑了(异常场景收尾逻辑可能还需要)。


【具体实践】


底层 socket 编程这块,一般都是被编程语言/编程框架屏蔽了,这里主要是介绍一下一些常见语言/编程框架怎么判断 client 已经断开连接了。


先说一下 brpc 框架,如下图所示,主要是调用 IsCanceled 函数来判断 client 是否已经断开了(不适用 http 2.0 等这样的场景)。


验证效果的话,可以通过在 brpc 框架里面的 accept 逻辑前面加 sleep,构造 server backlog 堆积的场景进去验证。




再说一下 golang 语言,主要是通过 http server 的回调函数的参数 r *http.Request 进行判断的,具体是判断 r.Context().Done()。


验证效果的话,可以通过 netutil.LimitListener 来设置最大处理的连接数。




不过 golang 和 brpc 的内部实现还是有点不一样的,下面将举例说明:


当一个 client 与 server 通过 tcp 三次握手建立连接后,进入了 server 的全连接队列中,client 调用 close 关闭连接,1 秒之后,server 调用 accept 取出该连接。


brpc 通过 IsCanceled 是可以判断出 client 已经断开连接了,而 golang 通过 r.Context().Done()却判定 client 还未断开连接,需要 sleep 若干时间之后才能判断 client 已经断开连接。


原因是 golang 在源码里是起了一个单独的协程去读 socket,所以导致这个判断会出现延迟。



解决方案是通过 ConnContext 回调函数,把连接存储在 context 里,然后在 http server 的回调函数里取出连接,先读一次,这样就可以判断是否已经断开了。



04 总结

百度网盘业务形态众多,业务的高速迭代发展需要建立在可靠的架构基础之上。


在整个架构演进过程,可用性是非常重要的事情,于是设计了一套防雪崩架构,具体包括两部分:


  • 流量限制:可以分成两部分,一个是流量接入层,解决 ddos 连接数攻击,另外一部分是流量转发层,通过动态熔断策略将后端能处理的请求数转发给后端;

  • 流量处理:业务基于流量有效性进行处理,避免处理无效请求。



最终对雪崩的治理也取得了不错的效果,单个季度可以规避若干次的雪崩故障发生,保障了网盘业务的可用性。


本文只是抛砖引玉,更多的是希望与大家一起探讨什么才是优秀的工程实践,欢迎大家留言反馈,多谢!!!


————END————


推荐阅读


如何在百度百舸部署满血版DeepSeek-V3、DeepSeek-R1模型


首日调用客户破1.5万!DeepSeek-V3/R1上线背后的超低推理成本技术揭秘


唤醒 AI 算力,专有云 ABC Stack 面向企业级智算平台的 GPU 提效实践


百度APP iOS端磁盘优化实践(上)


对话AI原生|比帮你写代码更爽的是:让Agent来打工

用户头像

百度Geek说

关注

百度官方技术账号 2021-01-22 加入

关注我们,带你了解更多百度技术干货。

评论

发布
暂无评论
百度网盘防雪崩架构实践_百度_百度Geek说_InfoQ写作社区