硬核干货:HTTP超时、重复请求必见坑点及解决方案

网络
HTTP调用即通过HTTP协议执行一次网络请求。既然是网络请求,就有超时的可能性(可能你的网卡,也可能服务器所处网络卡),因此在开发中需要注意

[[351757]]

 1 超时,无法避免的痛

HTTP调用即通过HTTP协议执行一次网络请求。既然是网络请求,就有超时的可能性(可能你的网卡,也可能服务器所处网络卡),因此在开发中需要注意:

  • 框架设置的默认超时时间是否合理
  • 过短,请求还未处理完成,你就急不可待了!
  • 过长,请求早已超出正常响应时间而挂了
  • 考虑网络不稳定性,超时后可以通过定时任务请求重试
  • 注意考虑服务端接口幂等性设计,即是否允许重试
  • 考虑框架是否会像浏览器那样限制并发连接数,以免在高并发下,HTTP调用的并发数成为瓶颈

1.1 HTTP调用框架技术选型

  • Spring Cloud全家桶

          使用Feign进行声明式的服务调用。

  • 只使用Spring Boot

           HTTP客户端Apache HttpClient进行服务调用。

1.2 连接超时配置 && 读取超时参数

虽然应用层是HTTP协议,但网络层始终是TCP/IP协议。TCP/IP是面向连接的协议,在传输数据之前需要建立连接。所以网络框架都会提供如下超时参数:

 

  • 连接超时参数ConnectTimeout

          可自定义配置的建立连接最长等待时间

  • 读取超时参数ReadTimeout

          控制从Socket上读取数据的最长等待时间。

1.3 常见踩坑点

连接超时配置过长

比如60s。TCP三次握手正常建立连接所需时间很短,在ms级最多到s级,不可能需要十几、几十秒,多半是网络或防火墙配置问题。这时如果几秒还连不上,那么可能永远也连不上。所以设置特别长的连接超时无意义,1~5秒即可。

如果是纯内网调用,还可以设更短,在下游服务无法连接时,快速失败

无脑排查连接超时问题

服务一般会有多个节点,若别的客户端通过负载均衡连接服务端,那么客户端和服务端会直接建立连接,此时出现连接超时大概率是服务端问题

而若服务端通过Nginx反向代理来负载均衡,客户端连接的其实是Nginx,而非服务端,此时出现连接超时应排查Nginx

读取超时参数和读取超时“坑点”

只要读取超时,服务端程序的正常执行就一定中断了?

案例

client接口内部通过HttpClient调用服务端接口server,客户端读取超时2秒,服务端接口执行耗时5秒。

 

调用client接口后,查看日志:

  • 客户端2s后出现SocketTimeoutException,即读取超时

  • 服务端却泰然地在3s后执行完成

Tomcat Web服务器是把服务端请求提交到线程池处理,只要服务端收到请求,网络层面的超时和断开便不会影响服务端的执行。因此,出现读取超时不能随意假设服务端的处理情况,需要根据业务状态考虑如何进行后续处理。

读取超时只是Socket网络层面概念,是数据传输的最长耗时,故将其配置很短

比如100ms。

发生读取超时,网络层面无法区分如下原因:

  • 服务端没有把数据返回给客户端
  • 数据在网络上耗时较久或丢包

但TCP是连接建立完成后才传输数据,对于网络情况不是特差的服务调用,可认为:

  • 连接超时

          网络问题或服务不在线

  • 读取超时

          服务处理超时。读取超时意味着向Socket写入数据后,我们等到Socket返回数据的超时时间,其中包含的时间或者说绝大部分时间,是服务端处理业务逻辑的时间

超时时间越长,任务接口成功率越高,便将读取超时参数配置过长

HTTP请求一般需要获得结果,属同步调用。

若超时时间很长,在等待 Server 返回数据同时,Client 线程(通常为 Tomcat 线程)也在等待,当下游服务出现大量超时,程序可能也会受到拖累创建大量线程,最终崩溃。

  • 对定时任务或异步任务,读取超时配置较长问题不大
  • 但面向用户响应的请求或是微服务平台的同步接口调用,并发量一般较大,应该设置一个较短的读取超时时间,以防止被下游服务拖慢,通常不会设置读取超时超过30s。

评论可能会有人问了,若把读取超时设为2s,而服务端接口需3s,不就永远拿不到执行结果?

的确,因此设置读取超时要结合实际情况:

过长可能会让下游抖动影响到自己

过短又可能影响成功率。甚至,有些时候我们还要根据下游服务的SLA,为不同的服务端接口设置不同的客户端读取超时。

1.4 最佳实践

连接超时代表建立TCP连接的时间,读取超时代表了等待远端返回数据的时间,也包括远端程序处理的时间。在解决连接超时问题时,我们要搞清楚连的是谁;在遇到读取超时问题的时候,我们要综合考虑下游服务的服务标准和自己的服务标准,设置合适的读取超时时间。此外,在使用诸如Spring Cloud Feign等框架时务必确认,连接和读取超时参数的配置是否正确生效。

2 Feign&&Ribbon

2.1 如何配置超时

为Feign配置超时参数的难点在于,Feign自身有两个超时参数,它使用的负载均衡组件Ribbon本身还有相关配置。这些配置的优先级是啥呢?

2.2 案例

  • 测试服务端超时,假设服务端接口,只休眠10min

  • Feign调用该接口:

 

  • 通过Feign Client进行接口调用

在配置文件仅指定服务端地址的情况下:

  1. clientsdk.ribbon.listOfServers=localhost:45678 

得到如下输出:

  1. [21:46:24.222] [http-nio-45678-exec-4] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController:26  ] -  
  2.     执行耗时:222ms 错误:Connect to localhost:45679 [localhost/127.0.0.1, localhost/0:0:0:0:0:0:0:1]  
  3.         failed: Connection refused (Connection refused) executing  
  4.             POST http://clientsdk/feignandribbon/server 

Feign默认读取超时是1秒,如此短的读取超时算是“坑”。

分析源码

自定义配置Feign客户端的两个全局超时时间

可以设置如下参数:

  1. feign.client.config.default.readTimeout=3000 
  2. feign.client.config.default.connectTimeout=3000 

修改配置后重试,得到如下日志:

  1. [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController    :26  ] - 执行耗时:3006ms 错误:Read timed out executing POST http://clientsdk/feignandribbon/server 

3秒读取超时生效。

注意:这里有一个大坑,如果希望只修改读取超时,可能会只配置这么一行:

  1. feign.client.config.default.readTimeout=3000 

测试会发现,这样配置无法生效。

要配置Feign读取超时,必须同时配置连接超时

查看FeignClientFactoryBean源码

  • 只有同时设置ConnectTimeout、ReadTimeout,Request.Options才会被覆盖

想针对单独的Feign Client设置超时时间,可以把default替换为Client的name:

  1. feign.client.config.default.readTimeout=3000 
  2. feign.client.config.default.connectTimeout=3000 
  3. feign.client.config.clientsdk.readTimeout=2000 
  4. feign.client.config.clientsdk.connectTimeout=2000 

单独的超时可覆盖全局超时

  1. [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController    :26  ] -  
  2. 执行耗时:2006ms 错误:Read timed out executing  
  3. POST http://clientsdk/feignandribbon/server 

除了可以配置Feign,也可配置Ribbon组件的参数以修改两个超时时间

参数首字母要大写,和Feign的配置不同。

  1. ribbon.ReadTimeout=4000 
  2. ribbon.ConnectTimeout=4000 

可以通过日志证明参数生效:

  1. [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController    :26  ] -  
  2. 执行耗时:4003ms 错误:Read timed out executing  
  3. POST http://clientsdk/feignandribbon/server 

同时配置Feign和Ribbon的参数

谁会生效?

  1. clientsdk.ribbon.listOfServers=localhost:45678 
  2. feign.client.config.default.readTimeout=3000 
  3. feign.client.config.default.connectTimeout=3000 
  4. ribbon.ReadTimeout=4000 
  5. ribbon.ConnectTimeout=4000 

最终生效的是Feign的超时:

  1. [http-nio-45678-exec-3] [WARN ] [o.g.t.c.h.f.FeignAndRibbonController    :26  ] -  
  2. 执行耗时:3006ms 错误:Read timed out executing  
  3. POST http://clientsdk/feignandribbon/server 

同时配置Feign和Ribbon的超时,以Feign为准

在LoadBalancerFeignClient源码

如果Request.Options不是默认值,就会创建一个FeignOptionsClientConfig代替原来Ribbon的DefaultClientConfigImpl,导致Ribbon的配置被Feign覆盖:

但若这么配置,最终生效的还是Ribbon的超时(4秒),难点Ribbon又反覆盖了Feign?不,这还是因为坑点二,单独配置Feign的读取超时无法生效:

  1. clientsdk.ribbon.listOfServers=localhost:45678 
  2. feign.client.config.default.readTimeout=3000 
  3. feign.client.config.clientsdk.readTimeout=2000 
  4. ribbon.ReadTimeout=4000 

3 Ribbon自动重试请求

一些HTTP客户端往往会内置一些重试策略,其初衷是好的,毕竟因为网络问题导致丢包虽然频繁但持续时间短,往往重试就能成功,

但要留心这是否符合我们期望。

3.1 案例

短信重复发送的问题,但短信服务的调用方用户服务,反复确认代码里没有重试逻辑。

那问题究竟出在哪里?

Get请求的发送短信接口,休眠2s以模拟耗时:

 配置一个Feign供客户端调用:

Feign内部有一个Ribbon组件负责客户端负载均衡,通过配置文件设置其调用的服务端为两个节点:

  1. SmsClient.ribbon.listOfServers=localhost:45679,localhost:45678 

客户端接口,通过Feign调用服务端

在45678和45679两个端口上分别启动服务端,然后访问45678的客户端接口进行测试。因为客户端和服务端控制器在一个应用中,所以45678同时扮演了客户端和服务端的角色。

在45678日志中可以看到,29秒时客户端收到请求开始调用服务端接口发短信,同时服务端收到了请求,2秒后(注意对比第一条日志和第三条日志)客户端输出了读取超时的错误信息:

  1. [http-nio-45678-exec-4] [INFO ] [c.d.RibbonRetryIssueClientController:23  ] - client is called 
  2. [http-nio-45678-exec-5] [INFO ] [c.d.RibbonRetryIssueServerController:16  ] - http://localhost:45678/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418 
  3. [http-nio-45678-exec-4] [ERROR] [c.d.RibbonRetryIssueClientController:27  ] - send sms failed : Read timed out executing GET http://SmsClient/ribbonretryissueserver/sms?mobile=13600000000&message=a2aa1b32-a044-40e9-8950-7f0189582418 

而在另一个服务端45679的日志中还可以看到一条请求,客户端接口调用后的1秒:

  1. [http-nio-45679-exec-2] [INFO ] [c.d.RibbonRetryIssueServerController:16  ] - http://localhost:45679/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418 

客户端接口被调用的日志只输出了一次,而服务端的日志输出了两次。虽然Feign的默认读取超时时间是1秒,但客户端2秒后才出现超时错误。

说明客户端自作主张进行了一次重试,导致短信重复发送。

3.2 源码揭秘

查看Ribbon源码,MaxAutoRetriesNextServer参数默认为1,也就是Get请求在某个服务端节点出现问题(比如读取超时)时,Ribbon会自动重试一次:

解决方案

1.把发短信接口从Get改为Post

API设计规范:有状态的API接口不应定义为Get。根据HTTP协议规范,Get请求适用于数据查询,Post才是把数据提交到服务端用于修改或新增。选择Get还是Post的依据,应该是API行为,而非参数大小。

  • 常见误区:Get请求的参数包含在Url QueryString中,会受浏览器长度限制,所以一些开发会选择使用JSON以Post提交大参数,使用Get提交小参数。

2.将MaxAutoRetriesNextServer参数配为0,禁用服务调用失败后在下一个服务端节点的自动重试。在配置文件中添加一行即可:

  1. ribbon.MaxAutoRetriesNextServer=0 

问责

至此,问题出在用户服务还是短信服务?

也许双方都有问题吧。

  • Get请求应该是无状态或者幂等的,短信接口可以设计为支持幂等调用
  • 用户服务的开发同学,如果对Ribbon的重试机制有所了解的话,或许就能在排查问题上少走弯路

最佳实践

对于重试,因为HTTP协议认为Get请求是数据查询操作,是无状态的,又考虑到网络出现丢包是比较常见的事情,有些HTTP客户端或代理服务器会自动重试Get/Head请求。如果你的接口设计不支持幂等,需要关闭自动重试。但,更好的解决方案是,遵从HTTP协议的建议来使用合适的HTTP方法。

4 并发限制爬虫抓取

HTTP请求调用还有一个常见的问题:并发数的限制,导致程序处理性能无法提升。

4.1 案例

某爬虫项目,整体爬取数据效率很低,增加线程池数量也无谓,只能堆机器。

现在模拟该场景,探究问题本质。

假设要爬取的服务端是这样的一个简单实现,休眠1s返回数字1:

爬虫需多次调用该接口抓取数据,为确保线程池不是并发瓶颈,使用了一个无线程上限的newCachedThreadPool,然后使用HttpClient执行HTTP请求,把请求任务循环提交到线程池处理,最后等待所有任务执行完成后输出执行耗时:

使用默认的PoolingHttpClientConnectionManager构造的CloseableHttpClient,测试一下爬取10次的耗时:


虽然一个请求需要1s执行完成,但线程池可扩张使用任意数量线程。

按道理,10个请求并发处理的时间基本相当于1个请求的处理时间,即1s,但日志中显示实际耗时5秒:

4.2 源码解析

PoolingHttpClientConnectionManager源码有两个重要参数:

  • defaultMaxPerRoute=2,即同一主机/域名的最大并发请求数为2。我们的爬虫需要10个并发,显然是默认值太小限制了爬虫的效率。
  • maxTotal=20,即所有主机整体最大并发为20,这也是HttpClient整体的并发度。我们请求数是10最大并发是10,20不会成为瓶颈。举一个例子,使用同一个HttpClient访问10个域名,defaultMaxPerRoute设置为10,为确保每一个域名都能达到10并发,需要把maxTotal设置为100。

HttpClient是常用的HTTP客户端,那为什么默认值限制得这么小?

很多早期的浏览器也限制了同一个域名两个并发请求。对于同一个域名并发连接的限制,其实是HTTP 1.1协议要求的,这里有这么一段话:

  • Clients that use persistent connections SHOULD limit the number of simultaneous connections that they maintain to a given server. A single-user client SHOULD NOT maintain more than 2 connections with any server or proxy. A proxy SHOULD use up to 2*N connections to another server or proxy, where N is the number of simultaneously active users. These guidelines are intended to improve HTTP response times and avoid congestion.
  • HTTP 1.1协议是20年前制定的,现在HTTP服务器的能力强很多了,所以有些新的浏览器没有完全遵从2并发这个限制,放开并发数到了8甚至更大。
  • 如果需要通过HTTP客户端发起大量并发请求,不管使用什么客户端,请务必确认客户端的实现默认的并发度是否满足需求。

尝试声明一个新的HttpClient放开相关限制,设置maxPerRoute为50、maxTotal为100,然后修改一下刚才的wrong方法,使用新的客户端进行测试:

输出如下,10次请求在1秒左右执行完成。可以看到,因为放开了一个Host 2个并发的默认限制,爬虫效率得到了大幅提升:

4.3 最佳实践

若你的客户端有比较大的请求调用并发,比如做爬虫,或是扮演类似代理的角色,又或者是程序本身并发较高,如此小的默认值很容易成为吞吐量的瓶颈,需要及时调整。

 

责任编辑:姜华 来源: JavaEdge
相关推荐

2015-12-02 15:35:08

Redis Clust迁移解决方案

2014-04-08 09:49:27

PostgreSQL双缓冲

2020-05-06 14:14:50

Linux依赖软件

2021-10-18 07:58:33

MyBatis Plu数据库批量插入

2022-12-27 11:06:35

海量接口并发

2010-07-29 15:56:04

FlexSocket

2019-10-08 16:05:19

Redis数据库系统

2015-05-12 16:31:22

Elasticsear开源分布式搜索引擎

2018-12-12 15:50:13

2018-10-12 14:34:13

2020-05-08 15:37:20

Redis分布式优化点

2017-06-29 10:13:20

信息安全智慧安全蓝盾股份

2017-08-01 05:44:10

Dockerweave虚拟机

2009-10-12 13:22:15

unique列

2014-03-06 10:52:57

Windows Ser重复数据

2011-12-13 11:39:38

天地超云云服务器云计算

2015-03-05 13:32:02

远程医疗解决方案华为

2011-05-04 16:33:09

游戏销售Android Mar游戏

2009-12-07 15:50:27

WCF文件

2023-09-14 15:44:46

分布式事务数据存储
点赞
收藏

51CTO技术栈公众号