超时机制
超时机制的概念
超时机制说的是当一个请求超过指定的时间(比如 1s)还没有被处理的话,这个请求就会直接被取消并抛出指定的异常或者错误(比如 504 Gateway Timeout)。
我们平时接触到的超时可以简单分为下面 2 种:
- 连接超时(ConnectTimeout):客户端与服务端建立连接的最长等待时间。
- 读取超时(ReadTimeout):客户端和服务端已经建立连接,客户端等待服务端处理完请求的最长时间。实际项目中,我们关注比较多的还是读取超时。
一些连接池客户端框架中可能还会有获取连接超时和空闲连接清理超时。如果没有设置超时的话,就可能会导致服务端连接数爆炸和大量请求堆积的问题。这些堆积的连接和请求会消耗系统资源,影响新收到的请求的处理。严重的情况下,甚至会拖垮整个系统或者服务。
超时时间的设置
超时到底设置多长时间是一个难题!
如果设置时间太长没有意义。如果设置太低,可能使得系统在处理速度变慢的情况下(比如请求突然增多),大量请求重试导致服务端压力增大,进而压垮系统或服务。
通常情况下,我们建议读取超时设置为 1500ms ,这是一个比较普适的值。如果你的系统或者服务对于延迟比较敏感的话,那读取超时值可以适当在 1500ms 的基础上进行缩短。反之,读取超时值也可以在 1500ms 的基础上进行加长,不过,尽量还是不要超过 1500ms 。连接超时可以适当设置长一些,建议在 1000ms ~ 5000ms 之内。
另一种方式设置,是通过压测,选择 TP99 的处理时间作为超时时间。
更上一层,参考美团的 Java 线程池参数动态配置思想,我们也可以将超时弄成可配置化的参数而不是固定的,比较简单的一种办法就是将超时的值放在配置中心中。这样的话,我们就可以根据系统或者服务的状态动态调整超时值了。
连接超时和读取超时参数
对于 HTTP 调用,虽然应用层走的是 HTTP 协议,但网络层面始终是 TCP/IP 协议。TCP/IP 是面向连接的协议,在传输数据之前需要建立连接。几乎所有的网络框架都会提供这么两个超时参数:
- 连接超时参数 ConnectTimeout,让用户配置建连阶段的最长等待时间;
- 读取超时参数 ReadTimeout,用来控制从 Socket 上读取数据的最长等待时间。
连接超时参数和连接超时的误区有这么两个:
- 连接超时配置得特别长,比如 60 秒。一般来说,TCP 三次握手建立连接需要的时间非常短,通常在毫秒级最多到秒级,不可能需要十几秒甚至几十秒。如果很久都无法建连,很可能是网络或防火墙配置的问题。这种情况下,如果几秒连接不上,那么可能永远也连接不上。因此,设置特别长的连接超时意义不大,将其配置得短一些(比如 1~5 秒)即可。如果是纯内网调用的话,这个参数可以设置得更短,在下游服务离线无法连接的时候,可以快速失败。
- 排查连接超时问题,却没理清连的是哪里。通常情况下,我们的服务会有多个节点,如果别的客户端通过客户端负载均衡技术来连接服务端,那么客户端和服务端会直接建立连接,此时出现连接超时大概率是服务端的问题;而如果服务端通过类似 Nginx 的反向代理来负载均衡,客户端连接的其实是 Nginx,而不是服务端,此时出现连接超时应该排查 Nginx。
读取超时需要注意的内容
- 出现了读取超时,服务端的执行无法判断。可能服务端会执行完成,也可能服务端检测到连接断开从而终止执行。
- 读取超时出现原因,有可能是网络不好,大概率是服务端处理业务逻辑时间长。
- ReadTimeout 设置的不要过大,否则太长会影响用户的体验。
重试机制
重试机制的概念
重试机制一般配合超时机制一起使用,指的是多次发送相同的请求来避免瞬态故障和偶然性故障。
瞬态故障可以简单理解为某一瞬间系统偶然出现的故障,并不会持久。偶然性故障可以理解为哪些在某些情况下偶尔出现的故障,频率通常较低。比如网络波动。
重试的核心思想是通过消耗服务器的资源来尽可能获得请求更大概率被成功处理。由于瞬态故障和偶然性故障是很少发生的,因此,重试对于服务器的资源消耗几乎是可以被忽略的。
常见的重试策略
常见的重试策略有两种:
- 固定间隔时间重试:每次重试之间都使用相同的时间间隔,比如每隔 1.5 秒进行一次重试。这种重试策略的优点是实现起来比较简单,不需要考虑重试次数和时间的关系,也不需要维护额外的状态信息。但是这种重试策略的缺点是可能会导致重试过于频繁或过于稀疏,从而影响系统的性能和效率。如果重试间隔太短,可能会对目标系统造成过大的压力,导致雪崩效应;如果重试间隔太长,可能会导致用户等待时间过长,影响用户体验。
- 梯度间隔重试:根据重试次数的增加去延长下次重试时间,比如第一次重试间隔为 1 秒,第二次为 2 秒,第三次为 4 秒,以此类推。这种重试策略的优点是能够有效提高重试成功的几率(随着重试次数增加,但是重试依然不成功,说明目标系统恢复时间比较长,因此可以根据重试次数延长下次重试时间),也能通过柔性化的重试避免对下游系统造成更大压力。但是这种重试策略的缺点是实现起来比较复杂,需要考虑重试次数和时间的关系,以及设置合理的上限和下限值。另外,这种重试策略也可能会导致用户等待时间过长,影响用户体验。
- 退火算法。是一种基于蒙特卡罗迭代求解法的启发式随机搜索算法。它源于对固体退火过程的模拟,在某一给定初温下,通过缓慢下降温度参数,使算法能够在多项式时间内找到近似最优解。
这两种适合的场景各不相同。固定间隔时间重试适用于目标系统恢复时间比较稳定和可预测的场景,比如网络波动或服务重启。梯度间隔重试适用于目标系统恢复时间比较长或不可预测的场景,比如网络故障和服务故障。
重试次数的设置
重试的次数不宜过多,否则依然会对系统负载造成比较大的压力。
重试的次数通常建议设为 3 次。大部分情况下,我们还是更建议使用梯度间隔重试策略,比如说我们要重试 3 次的话,第 1 次请求失败后,等待 1 秒再进行重试,第 2 次请求失败后,等待 2 秒再进行重试,第 3 次请求失败后,等待 3 秒再进行重试。
重试设计需要考虑的方面
- 什么时候重试?超时、网络错误、可以重试的错误
- 什么时候不重试?没有权限、服务 503 错误
- 重试的次数和时间。可以指数级递增的等待时间,200ms,400ms,800ms。
- 重试还需要考虑幂等设计,事务相关机制。
- 重试还需要考虑被调用方的压力问题。
重试幂等的概念
超时和重试机制在实际项目中使用的话,需要注意保证同一个请求没有被多次执行。
什么情况下会出现一个请求被多次执行呢?客户端等待服务端完成请求完成超时但此时服务端已经执行了请求,只是由于短暂的网络波动导致响应在发送给客户端的过程中延迟了。
举个例子:用户支付购买某个课程,结果用户支付的请求由于重试的问题导致用户购买同一门课程支付了两次。对于这种情况,我们在执行用户购买课程的请求的时候需要判断一下用户是否已经购买过。这样的话,就不会因为重试的问题导致重复购买了。
重试的实现
如果要手动编写代码实现重试逻辑的话,可以通过循环(例如 while 或 for 循环)或者递归实现。不过,一般不建议自己动手实现,有很多第三方开源库提供了更完善的重试机制实现,例如 Spring Retry、Resilience4j、Guava Retrying。
参考
- <程序员练级攻略>中“容错设计篇之‘重试设计’”
- <RPC实战与核心原理>中“异常重试:在约定时间内安全可靠地重试”
- 微服务之间调用超时的设置治理:https://www.infoq.cn/article/eyrslar53l6hjm5yjgyx
- 超时、重试和抖动回退:https://aws.amazon.com/cn/builders-library/timeouts-retries-and-backoff-with-jitter/