超时时间
概念
超时时间指系统在等待某个操作响应时设定的最大容忍时间阈值。当操作未在指定时间内完成,系统将主动终止等待并触发预设处理逻辑
超时可以看做是一种降级手段。因为假设服务永远能正常运行,我们并不需要超时时间,来保证服务的可用性和稳定性
常见需要依赖超时时间的场景
- 网络层:TCP 协议的 connect timeout(Linux 默认 120 秒)、HTTP 请求的 socket timeout(如 Java HttpClient 默认 30 秒)
- 数据库:MySQL 的 wait\_timeout(默认 8 小时)
- 业务层:外卖订单支付超时时间为 15 分钟
本文主要讨论网络层的超时时间
作用
减少资源浪费
当客户端发起一个请求后,如果服务器端因为某些原因无法及时响应(例如服务器过载、网络故障等),那么不设超时机制的客户端可能会无限期等待。这种情况下,不仅消耗了客户端的资源(如内存、CPU 等),也占用了服务器端的资源(如连接池中的连接,导致吞吐量降低)
设定合理的超时时间可以确保在一定时间内未得到响应时及时终止请求,释放占用的资源
保证服务质量
如果某个操作响应时间过长,会影响用户体验。合理配置超时时间可以帮助系统更快地做出反应
- 当客户端超时时,可以提示用户自己重试,比如刷新网页后点击按钮跳转页面
- 当服务端 RPC 调用超时时,自动重试,并通过负载均衡找到其它节点重试
当进行重试的时候,或许可以用更短的时间响应结果
比如某个服务绝大部分情况下 RT 都是小于 10ms 的。但假设此时某个节点网络波动,导致我们需要 1000ms 才能返回结果,那么
- 没有设置超时时间的话,最终花费 1000ms 才得到结果
- 设置超时时间为 10ms,当发现 10ms 还没得到结果,认为请求失败,进行超时重试,通过负载均衡换个好一点的节点,10ms 内返回了结果,最终花费时间为 10 + 10 = 20ms
1000ms < 20ms,及时地超时重试可以降低 RT
优化用户体验与服务监控
- 对于客户端而言,返回给用户信息“系统繁忙,稍后再试”,总是要比看着白屏转圈要好的
- 对于服务端而言,让请求及时超时并日志记录上报,可以更好地了解整个系统的运行状态。比如服务出现了大量超时,我们就知道某个服务出现了问题。同理,CPU 利用率达到 100%、线程池大量任务阻塞,也能作为服务运行状态的评判标准
设置合理的超时时间
超时时间的长度不能随便设
- 时间太长,可能导致系统资源被长时间占用,失去了设置超时时间的意义
- 时间太短,可能导致请求未完成就被中断
核心原则:服务总超时 > 下游服务总耗时。避免下游服务还没执行完,就超时了
- RPC 的超时时间一般设置为 P99
- 客户端的 HTTP 请求超时时间控制在 10s 内。一般每延长 1s 都将导致大量的用户流失,10s 实际已经算久的了
具体设置多长超时时间还是取决于业务。比如像点赞这种高频访问且实时性要求高的接口,1s 已经算慢了;而像做数据报表的请求,可能 10s 能返回结果也算快了;对于数据库连接池,超时时间往往都要设置 8 小时以上,虽然每次请求都会续上时间,但是有些服务在半夜是不使用的,导致早上访问数据库时连接会断开,服务超时
业务接口永远不要设置太长的超时时间
如果某个业务服务的接口,就是要花费很长的时间才能返回结果,也不推荐设置太长的超时时间。换句话说,一个接口 RT 不应该太久。因为请求将长时间占用客户端和服务端连接池,导致资源浪费和吞吐量下降
正确做法是改造接口,将实际的业务操作使用 MQ 或线程池异步化,这样请求接口可以立即返回结果,可以通过「服务端主动推送」或「客户端轮询」获取结果(有没有想到微信支付的异步支付呢?)
术语解释:P99
P99 就是指 99%的请求都能在这个时间内或更短时间内完成处理,只有 1% 的请求会比这个时间更长
比如,某个接口的 P99 响应时间为 300ms,这意味着 99% 的请求都在 300ms 以内得到了响应,只有 1% 的请求需要花费大于 300ms 的时间返回
获取 P99 的方式主要就是压测或线上监控
长尾请求
概念
一般大部分请求的响应时间集中在较低延迟区间,但存在一小部分请求的响应时间显著高于平均值,形成一个“长尾”分布的现象,并称为“长尾请求”
一般业界常用的是 P99 标准,也就是认为 1% 大于某个耗时的请求属于长尾请求
造成危害
长尾请求虽然可能不会在短时间内造成明显的问题,但如果没有及时处理,它们会带来一系列危害:
- 资源消耗:相比其他正常的请求,长尾请求将占用更长的连接时间。同时,长尾请求往往意味着超时,那么需要额外的重试,进一步加剧服务负载
- 降低用户体验:同一个页面,别人 1s 就打开了,而自己却要等待 5s 才能进
长尾请求在高并发系统中是不可忽视的
上面也提到,一般超时时间会设置为 P99,但即使每秒只有 1% 的请求超时,随着请求量增大,超时请求的数量会急剧增加。我们可以从两个角度来分析高并发系统下的长尾请求:影响的范围 和 必然性
假设,某个服务的 P99 为 1s,QPS 为 1000
- 影响的范围
1000 个用户请求有 1% 大于超时时间,即 1000 * 0.01 = 10
。那么每秒将有 10 个用户受到了长尾请求的影响,每分钟 600 个,每小时 36000 个,这 36000 个人已经足以在网上造成不小的舆论了
- 必然性
当请求数量增加时,长尾请求的发生变得 “必然”
假设每个查询之间是独立的,我们来做个简单的概率计算:每次查询响应小于 1 秒的概率为 99%,对于 100w 次独立的查询(假设每个查询之间是独立的),至少有一次查询响应时间大于 1 秒的概率约为 1 - 0.99^1000 ≈ 100%
正如如下图所示:
实际请求到 600 的时候,至少有一次查询响应时间大于 1 秒的概率已经是 100% 了
产生原因
原因包括但不限于:
- CPU 调度走了
- 竞争非公平锁,恰好有个倒霉线程出现锁饥饿
- 出现 GC
- 网络抖动、丢包、延迟、弱网
- 负载均衡不合理,某个节点承受了非常多的流量,请求处理速度慢,而其他节点却空闲
解决方案
可以针对上面每一点进行优化,一定程度上缓解长尾请求造成的影响。但实际无论再怎么优化,长尾请求都是存在的。所以可以采取两种方案:
- 弹性超时:允许一定的请求超出原设定的时间,减少超时重试次数
- 备份请求:在快超时之前,提前重试,从而降低 RT
弹性超时
允许一段时间一些量的请求在一定的时间内返回,即允许一定程度上的“超时”。既提高了服务质量,又不太影响用户体验
比如,每 60 秒允许 10 个请求的超时时间延长至 1000 毫秒
更多适用于一些偶发性超时场景,比如网络抖动、GC、CPU 抖动、冷启动等,如果是大面积的超时还是需要深入分析治理
备份请求
一次性发送两个请求,即双发请求,期间可能会收到多个响应值,哪个先来就用哪个,可以立即结束这次请求。用访问量来换低延时。不过这将会使服务额外承受一倍的流量。所以我们可以加个条件:当达到某个时间还没收到请求,我们再发送下一个请求,或者称其为“备份请求”
设定一个比超时时间更小的阈值 T1。当 Req1 发送请求后,在 T1 的时间内没收到响应,直接发出重试
相比于等待超时后再发出请求,这种机制能大大减少整体延时,同时又不会加大太多对服务提供方的负担
重试
当出现网络抖动、调用失败、超时等原因,我们往往可以通过重试机制,提高请求的最终成功率
不过重试可不是一个 for 循环就搞定的事情,每次重试时,我们都要考虑重试的风险和重试风暴问题
重试的风险
重复提交
重试请求可能会导致重复的数据处理或事务。例如,在线支付系统重试交易请求可能导致重复扣款。
不过这种比较好解决,核心在于保证写请求的幂等,对于读请求是不需要考虑幂等的。可以使用 Redis 唯一 key、悲观锁/乐观锁解决
加大下游的负担
假设 A 服务调用 B 服务,重试次数设置为 r(包括首次请求)。最坏情况下,下游的访问量可能放大到 r 倍,不仅不能请求成功,还可能导致 B 的负载继续升高,甚至直接打挂
同时,重试还会存在链路放大的效应,如下图所示:
随着请求节点的增多,重试的次数将指数级扩大。假设正常访问量是 n,链路一共有 m 层,每层重试次数为 r,则最后一层受到的访问量最大,为 n * r ^ (m - 1)
。这将进一步导致链路上多层都被打挂,使整个系统雪崩。也就是我们所说的重试风暴问题
防止重试风暴
核心在于避免在同一时间点重试,并通过舍弃非必要重试,减少重试的次数
随机退避
在常见的退避策略(如指数退避)中,引入随机性,使每个客户端的重试时间有所不同。通过在重试间隔中加入随机抖动,可以避免多个客户端在同一时刻重试,减少重试请求的冲突,可以有效减少系统的瞬时负载压力。这在网络波动的情况下,会有比较好的效果
假设重试的间隔是 2s、4s、8s,那么可以对这些时间加入随机范围(例如 ±20%),使得实际重试时间为 1.6s ~ 2.4s、3.2s ~ 4.8s、6.4s ~ 9.6s
(是否想起了 Redis 缓存雪崩的解决方案?设置离散的过期时间,避免缓存在同一时间点失效)
避免无意义的重试
不是所有错误都适合重试,某些错误(如请求参数错误、权限问题等)不应该重试。通过根据错误类型判断是否需要重试,可以避免无效重试
可以在系统中对错误进行分类,针对网络故障或服务器暂时不可用的错误可以重试,而对于客户端错误(如 403、404 等)则直接返回错误信息,不进行重试
重试熔断
对于单点而言,除了限制重试次数外,还要限制重试请求的成功率。对于失败率高的请求及时熔断
基于断路器的思想,限制「请求失败/请求成功」的比率,给重试增加熔断功能。可以采用滑动窗口的方法来实现,如下图,内存中为每一类 RPC 调用维护一个滑动窗口,比如窗口分 10 个 bucket ,每个 bucket 里面记录了 1s 内 RPC 的请求结果数据(成功、失败)。新的一秒到来时,生成新的 bucket ,并淘汰最早的一个 bucket ,只维持 10s 的数据。在新请求这个 RPC 失败时,根据前 10s 内的 失败/成功 是否超过阈值来判断是否可以重试。默认阈值是 0.1 ,即下游最多承受 1.1 倍的 QPS ,可以根据需要自行调整熔断开关和阈值
链路上传错误标志
虽然单点的重试被限制在了 1.1 倍,但链路级的重试依旧指数级增长
链路层面的防重试风暴的核心是限制每层都发生重试,理想情况下只有最下一层发生重试。达到“只有最靠近错误发生的那一层才重试”的效果。Google SRE 中指出了 Google 内部使用特殊错误码的方式来实现:
- 统一约定一个特殊的 status code ,它表示:调用失败,但别重试
- 任何一级重试失败后,生成该 status code 并返回给上层
- 上层收到该 status code 后停止对这个下游的重试,并将错误码再传给自己的上层
在字节跳动内部用的 RPC 协议中,通过 Response 扩展字段中传递错误码标识 nomore\_retry,告诉上游不要再重试
链路下传重试标志
在错误码上传的方案中,超时的情况可能导致传递错误码的方案失效,因为客户端没有接收来自下游的错误码标志,毕竟请求超时了。客户端不知道下游什么情况,选择继续重试,链路的重试次数又将指数级地增大
所以不仅要上传错误标志,还要下传重试标识,从而达到“对重试请求不重试”的效果
在 Request 中打上一个特殊的 retry flag ,在上面 A -> B -> C 的链路,当 B 收到 A 的请求时会先读取这个 flag 判断这个请求是不是重试请求,如果是,那它调用 C 即使失败也不会重试;否则调用 C 失败后会重试 C 。同时 B 也会把这个 retry flag 下传,它发出的请求也会有这个标志,它的下游也不会再对这个请求重试。
这样即使 A 因为超时而拿不到 B 的返回,对 B 发出重试请求后,B 能感知到并且不会对 C 重试,这样 A 最多请求 r 次,B 最多请求 r + r - 1,如果后面还有更下层次的话,C 最多请求 r + r + r - 2 次, 第 i 层最多请求 i * r - (i-1) 次,最坏情况下是倍数增长,不是指数增长了
DDL
DDL 是“ Deadline Request 调用链超时”的简称,是一种全链路式的调用超时,可以用来判断当前的 RPC 请求是否还需要继续下去
如下图,在 RPC 请求调用链中会带上超时时间,并且每经过一层就减去该层处理的时间,如果剩下的时间已经小于等于 0 ,则可以不需要再请求下游,直接返回失败即可
结语
以上讨论的超时时间、长尾请求、重试等问题,都是在网络背景下对远程服务进行调用所产生的。那这正是我们的 RPC 的工作。所以,如果我们需要搭建一个高可用的 RPC 框架,最好需要具备以下功能:
减少长尾请求的影响
- 弹性超时:允许一定的请求超出原设定的时间,减少超时重试次数
- 备份请求:在即将超时之前,提前重试,通过提高一定的访问量来降低延迟
避免重试风暴
- 随机退避:避免多个客户端在同一时刻重试
- 避免无意义的重试:像请求参数错误、权限问题,没必要去重试
- 重试熔断:当重试成功率低时,及时熔断,减少单点重试次数
- 链路上传错误标志:保证只有最靠近错误发生的那一层才重试,减少全链路重试
- 链路下传重试标志:对重试请求不重试,减少全链路重试
- DDL:全链路式的调用超时,判断请求是否继续执行下去
- 幂等:可以由业务自己实现幂等,比如通过 AOP 实现
- 限流:需要对服务调用方限流,保护自己;自己对其他服务发送请求也需要限流,保护服务提供方,并使请求流量分配更公平(避免某个节点占据了服务提供方的绝大部分资源,导致其他节点出现“饥饿”问题)
- 降级:当服务不可用时,可以返回默认信息或 mock 数据,及时响应数据
如果文章对你有帮助,欢迎点赞+收藏+关注,有问题欢迎在评论区评论哦!
公众号【牛肉烧烤屋】
B 站【爱烤猪蹄的乔治】
参考资料
https://mp.weixin.qq.com/s/5YDkKwpJmN-WHxzSpxP-4A
https://www.infoq.cn/article/5fBoevKaL0GVGvgeac4Z
https://www.nowcoder.com/discuss/353146786191712256
封面:意念艾特感叹号
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。