05 | HTTP调用:你考虑到超时、重试、并发了吗?

你好,我是朱晔。今天,我们一起聊聊进行HTTP调用需要注意的超时、重试、并发等问题。

与执行本地方法不同,进行HTTP调用本质上是通过HTTP协议进行一次网络请求。网络请求必然有超时的可能性,因此我们必须考虑到这三点:

  • 首先,框架设置的默认超时是否合理;
  • 其次,考虑到网络的不稳定,超时后的请求重试是一个不错的选择,但需要考虑服务端接口的幂等性设计是否允许我们重试;
  • 最后,需要考虑框架是否会像浏览器那样限制并发连接数,以免在服务并发很大的情况下,HTTP调用的并发数限制成为瓶颈。

Spring Cloud是Java微服务架构的代表性框架。如果使用Spring Cloud进行微服务开发,就会使用Feign进行声明式的服务调用。如果不使用Spring Cloud,而直接使用Spring Boot进行微服务开发的话,可能会直接使用Java中最常用的HTTP客户端Apache HttpClient进行服务调用。

接下来,我们就看看使用Feign和Apache HttpClient进行HTTP接口调用时,可能会遇到的超时、重试和并发方面的坑。

配置连接超时和读取超时参数的学问

对于HTTP调用,虽然应用层走的是HTTP协议,但网络层面始终是TCP/IP协议。TCP/IP是面向连接的协议,在传输数据之前需要建立连接。几乎所有的网络框架都会提供这么两个超时参数:

  • 连接超时参数ConnectTimeout,让用户配置建连阶段的最长等待时间;
  • 读取超时参数ReadTimeout,用来控制从Socket上读取数据的最长等待时间。

这两个参数看似是网络层偏底层的配置参数,不足以引起开发同学的重视。但,正确理解和配置这两个参数,对业务应用特别重要,毕竟超时不是单方面的事情,需要客户端和服务端对超时有一致的估计,协同配合方能平衡吞吐量和错误率。

连接超时参数和连接超时的误区有这么两个:

  • 连接超时配置得特别长,比如60秒。一般来说,TCP三次握手建立连接需要的时间非常短,通常在毫秒级最多到秒级,不可能需要十几秒甚至几十秒。如果很久都无法建连,很可能是网络或防火墙配置的问题。这种情况下,如果几秒连接不上,那么可能永远也连接不上。因此,设置特别长的连接超时意义不大,将其配置得短一些(比如1~5秒)即可。如果是纯内网调用的话,这个参数可以设置得更短,在下游服务离线无法连接的时候,可以快速失败。
  • 排查连接超时问题,却没理清连的是哪里。通常情况下,我们的服务会有多个节点,如果别的客户端通过客户端负载均衡技术来连接服务端,那么客户端和服务端会直接建立连接,此时出现连接超时大概率是服务端的问题;而如果服务端通过类似Nginx的反向代理来负载均衡,客户端连接的其实是Nginx,而不是服务端,此时出现连接超时应该排查Nginx。

读取超时参数和读取超时则会有更多的误区,我将其归纳为如下三个。

第一个误区:认为出现了读取超时,服务端的执行就会中断。

我们来简单测试下。定义一个client接口,内部通过HttpClient调用服务端接口server,客户端读取超时2秒,服务端接口执行耗时5秒。

@RestController
@RequestMapping("clientreadtimeout")
@Slf4j
public class ClientReadTimeoutController {
    private String getResponse(String url, int connectTimeout, int readTimeout) throws IOException {
        return Request.Get("http://localhost:45678/clientreadtimeout" + url)
                .connectTimeout(connectTimeout)
                .socketTimeout(readTimeout)
                .execute()
                .returnContent()
                .asString();
    }
    
    @GetMapping("client")
    public String client() throws IOException {
        log.info("client1 called");
        //服务端5s超时,客户端读取超时2秒
        return getResponse("/server?timeout=5000", 1000, 2000);
    }

    @GetMapping("server")
    public void server(@RequestParam("timeout") int timeout) throws InterruptedException {
        log.info("server called");
        TimeUnit.MILLISECONDS.sleep(timeout);
        log.info("Done");
    }
}

调用client接口后,从日志中可以看到,客户端2秒后出现了SocketTimeoutException,原因是读取超时,服务端却丝毫没受影响在3秒后执行完成。

[11:35:11.943] [http-nio-45678-exec-1] [INFO ] [.t.c.c.d.ClientReadTimeoutController:29  ] - client1 called
[11:35:12.032] [http-nio-45678-exec-2] [INFO ] [.t.c.c.d.ClientReadTimeoutController:36  ] - server called
[11:35:14.042] [http-nio-45678-exec-1] [ERROR] [.a.c.c.C.[.[.[/].[dispatcherServlet]:175 ] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception
java.net.SocketTimeoutException: Read timed out
	at java.net.SocketInputStream.socketRead0(Native Method)
	...
[11:35:17.036] [http-nio-45678-exec-2] [INFO ] [.t.c.c.d.ClientReadTimeoutController:38  ] - Done

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

第二个误区:认为读取超时只是Socket网络层面的概念,是数据传输的最长耗时,故将其配置得非常短,比如100毫秒。

其实,发生了读取超时,网络层面无法区分是服务端没有把数据返回给客户端,还是数据在网络上耗时较久或丢包。

但,因为TCP是先建立连接后传输数据,对于网络情况不是特别糟糕的服务调用,通常可以认为出现连接超时是网络问题或服务不在线,而出现读取超时是服务处理超时。确切地说,读取超时指的是,向Socket写入数据后,我们等到Socket返回数据的超时时间,其中包含的时间或者说绝大部分的时间,是服务端处理业务逻辑的时间。

第三个误区:认为超时时间越长任务接口成功率就越高,将读取超时参数配置得太长。

进行HTTP请求一般是需要获得结果的,属于同步调用。如果超时时间很长,在等待服务端返回数据的同时,客户端线程(通常是Tomcat线程)也在等待,当下游服务出现大量超时的时候,程序可能也会受到拖累创建大量线程,最终崩溃。

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

你可能会说,如果把读取超时设置为2秒,服务端接口需要3秒,岂不是永远都拿不到执行结果了?的确是这样,因此设置读取超时一定要根据实际情况,过长可能会让下游抖动影响到自己,过短又可能影响成功率。甚至,有些时候我们还要根据下游服务的SLA,为不同的服务端接口设置不同的客户端读取超时。

Feign和Ribbon配合使用,你知道怎么配置超时吗?

刚才我强调了根据自己的需求配置连接超时和读取超时的重要性,你是否尝试过为Spring Cloud的Feign配置超时参数呢,有没有被网上的各种资料绕晕呢?

在我看来,为Feign配置超时参数的复杂之处在于,Feign自己有两个超时参数,它使用的负载均衡组件Ribbon本身还有相关配置。那么,这些配置的优先级是怎样的,又哪些什么坑呢?接下来,我们做一些实验吧。

为测试服务端的超时,假设有这么一个服务端接口,什么都不干只休眠10分钟:

@PostMapping("/server")
public void server() throws InterruptedException {
    TimeUnit.MINUTES.sleep(10);
}

首先,定义一个Feign来调用这个接口:

@FeignClient(name = "clientsdk")
public interface Client {
    @PostMapping("/feignandribbon/server")
    void server();
}

然后,通过Feign Client进行接口调用:

@GetMapping("client")
public void timeout() {
    long begin=System.currentTimeMillis();
    try{
        client.server();
    }catch (Exception ex){
        log.warn("执行耗时:{}ms 错误:{}", System.currentTimeMillis() - begin, ex.getMessage());
    }
}

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

clientsdk.ribbon.listOfServers=localhost:45678

得到如下输出:

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

从这个输出中,我们可以得到结论一,默认情况下Feign的读取超时是1秒,如此短的读取超时算是坑点一

我们来分析一下源码。打开RibbonClientConfiguration类后,会看到DefaultClientConfigImpl被创建出来之后,ReadTimeout和ConnectTimeout被设置为1s:

/**
 * Ribbon client default connect timeout.
 */
public static final int DEFAULT_CONNECT_TIMEOUT = 1000;

/**
 * Ribbon client default read timeout.
 */
public static final int DEFAULT_READ_TIMEOUT = 1000;

@Bean
@ConditionalOnMissingBean
public IClientConfig ribbonClientConfig() {
   DefaultClientConfigImpl config = new DefaultClientConfigImpl();
   config.loadProperties(this.name);
   config.set(CommonClientConfigKey.ConnectTimeout, DEFAULT_CONNECT_TIMEOUT);
   config.set(CommonClientConfigKey.ReadTimeout, DEFAULT_READ_TIMEOUT);
   config.set(CommonClientConfigKey.GZipPayload, DEFAULT_GZIP_PAYLOAD);
   return config;
}

如果要修改Feign客户端默认的两个全局超时时间,你可以设置feign.client.config.default.readTimeout和feign.client.config.default.connectTimeout参数:

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

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

[15:43:39.955] [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秒读取超时生效了。注意:这里有一个大坑,如果你希望只修改读取超时,可能会只配置这么一行:

feign.client.config.default.readTimeout=3000

测试一下你就会发现,这样的配置是无法生效的!

结论二,也是坑点二,如果要配置Feign的读取超时,就必须同时配置连接超时,才能生效

打开FeignClientFactoryBean可以看到,只有同时设置ConnectTimeout和ReadTimeout,Request.Options才会被覆盖:

if (config.getConnectTimeout() != null && config.getReadTimeout() != null) {
   builder.options(new Request.Options(config.getConnectTimeout(),
         config.getReadTimeout()));
}

更进一步,如果你希望针对单独的Feign Client设置超时时间,可以把default替换为Client的name:

feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
feign.client.config.clientsdk.readTimeout=2000
feign.client.config.clientsdk.connectTimeout=2000

可以得出结论三,单独的超时可以覆盖全局超时,这符合预期,不算坑

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

结论四,除了可以配置Feign,也可以配置Ribbon组件的参数来修改两个超时时间。这里的坑点三是,参数首字母要大写,和Feign的配置不同

ribbon.ReadTimeout=4000
ribbon.ConnectTimeout=4000

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

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

最后,我们来看看同时配置Feign和Ribbon的参数,最终谁会生效?如下代码的参数配置:

clientsdk.ribbon.listOfServers=localhost:45678
feign.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000
ribbon.ReadTimeout=4000
ribbon.ConnectTimeout=4000

日志输出证明,最终生效的是Feign的超时:

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

结论五,同时配置Feign和Ribbon的超时,以Feign为准。这有点反直觉,因为Ribbon更底层所以你会觉得后者的配置会生效,但其实不是这样的。

在LoadBalancerFeignClient源码中可以看到,如果Request.Options不是默认值,就会创建一个FeignOptionsClientConfig代替原来Ribbon的DefaultClientConfigImpl,导致Ribbon的配置被Feign覆盖:

IClientConfig getClientConfig(Request.Options options, String clientName) {
   IClientConfig requestConfig;
   if (options == DEFAULT_OPTIONS) {
      requestConfig = this.clientFactory.getClientConfig(clientName);
   }
   else {
      requestConfig = new FeignOptionsClientConfig(options);
   }
   return requestConfig;
}

但如果这么配置最终生效的还是Ribbon的超时(4秒),这容易让人产生Ribbon覆盖了Feign的错觉,其实这还是因为坑二所致,单独配置Feign的读取超时并不能生效:

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

你是否知道Ribbon会自动重试请求呢?

一些HTTP客户端往往会内置一些重试策略,其初衷是好的,毕竟因为网络问题导致丢包虽然频繁但持续时间短,往往重试下第二次就能成功,但一定要小心这种自作主张是否符合我们的预期。

之前遇到过一个短信重复发送的问题,但短信服务的调用方用户服务,反复确认代码里没有重试逻辑。那问题究竟出在哪里了?我们来重现一下这个案例。

首先,定义一个Get请求的发送短信接口,里面没有任何逻辑,休眠2秒模拟耗时:

@RestController
@RequestMapping("ribbonretryissueserver")
@Slf4j
public class RibbonRetryIssueServerController {
    @GetMapping("sms")
    public void sendSmsWrong(@RequestParam("mobile") String mobile, @RequestParam("message") String message, HttpServletRequest request) throws InterruptedException {
        //输出调用参数后休眠2秒
        log.info("{} is called, {}=>{}", request.getRequestURL().toString(), mobile, message);
        TimeUnit.SECONDS.sleep(2);
    }
}

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

@FeignClient(name = "SmsClient")
public interface SmsClient {
    @GetMapping("/ribbonretryissueserver/sms")
    void sendSmsWrong(@RequestParam("mobile") String mobile, @RequestParam("message") String message);
}

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

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

写一个客户端接口,通过Feign调用服务端:

@RestController
@RequestMapping("ribbonretryissueclient")
@Slf4j
public class RibbonRetryIssueClientController {
    @Autowired
    private SmsClient smsClient;

    @GetMapping("wrong")
    public String wrong() {
        log.info("client is called");
        try{
            //通过Feign调用发送短信接口
            smsClient.sendSmsWrong("13600000000", UUID.randomUUID().toString());
        } catch (Exception ex) {
            //捕获可能出现的网络错误
            log.error("send sms failed : {}", ex.getMessage());
        }
        return "done";
    }
}

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

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

[12:49:29.020] [http-nio-45678-exec-4] [INFO ] [c.d.RibbonRetryIssueClientController:23  ] - client is called
[12:49:29.026] [http-nio-45678-exec-5] [INFO ] [c.d.RibbonRetryIssueServerController:16  ] - http://localhost:45678/ribbonretryissueserver/sms is called, 13600000000=>a2aa1b32-a044-40e9-8950-7f0189582418
[12:49:31.029] [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的日志中还可以看到一条请求,30秒时收到请求,也就是客户端接口调用后的1秒:

[12:49:30.029] [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秒后才出现超时错误。显然,这说明客户端自作主张进行了一次重试,导致短信重复发送。

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

// DefaultClientConfigImpl
public static final int DEFAULT_MAX_AUTO_RETRIES_NEXT_SERVER = 1;
public static final int DEFAULT_MAX_AUTO_RETRIES = 0;

// RibbonLoadBalancedRetryPolicy
public boolean canRetry(LoadBalancedRetryContext context) {
   HttpMethod method = context.getRequest().getMethod();
   return HttpMethod.GET == method || lbContext.isOkToRetryOnAllOperations();
}

@Override
public boolean canRetrySameServer(LoadBalancedRetryContext context) {
   return sameServerCount < lbContext.getRetryHandler().getMaxRetriesOnSameServer()
         && canRetry(context);
}

@Override
public boolean canRetryNextServer(LoadBalancedRetryContext context) {
   // this will be called after a failure occurs and we increment the counter
   // so we check that the count is less than or equals to too make sure
   // we try the next server the right number of times
   return nextServerCount <= lbContext.getRetryHandler().getMaxRetriesOnNextServer()
         && canRetry(context);
}

解决办法有两个:

  • 一是,把发短信接口从Get改为Post。其实,这里还有一个API设计问题,有状态的API接口不应该定义为Get。根据HTTP协议的规范,Get请求用于数据查询,而Post才是把数据提交到服务端用于修改或新增。选择Get还是Post的依据,应该是API的行为,而不是参数大小。这里的一个误区是,Get请求的参数包含在Url QueryString中,会受浏览器长度限制,所以一些同学会选择使用JSON以Post提交大参数,使用Get提交小参数。
  • 二是,将MaxAutoRetriesNextServer参数配置为0,禁用服务调用失败后在下一个服务端节点的自动重试。在配置文件中添加一行即可:
ribbon.MaxAutoRetriesNextServer=0

看到这里,你觉得问题出在用户服务还是短信服务呢?

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

并发限制了爬虫的抓取能力

除了超时和重试的坑,进行HTTP请求调用还有一个常见的问题是,并发数的限制导致程序的处理能力上不去。

我之前遇到过一个爬虫项目,整体爬取数据的效率很低,增加线程池数量也无济于事,只能堆更多的机器做分布式的爬虫。现在,我们就来模拟下这个场景,看看问题出在了哪里。

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

@GetMapping("server")
public int server() throws InterruptedException {
    TimeUnit.SECONDS.sleep(1);
    return 1;
}

爬虫需要多次调用这个接口进行数据抓取,为了确保线程池不是并发的瓶颈,我们使用一个没有线程上限的newCachedThreadPool作为爬取任务的线程池(再次强调,除非你非常清楚自己的需求,否则一般不要使用没有线程数量上限的线程池),然后使用HttpClient实现HTTP请求,把请求任务循环提交到线程池处理,最后等待所有任务执行完成后输出执行耗时:

private int sendRequest(int count, Supplier<CloseableHttpClient> client) throws InterruptedException {
    //用于计数发送的请求个数
    AtomicInteger atomicInteger = new AtomicInteger();
    //使用HttpClient从server接口查询数据的任务提交到线程池并行处理
    ExecutorService threadPool = Executors.newCachedThreadPool();
    long begin = System.currentTimeMillis();
    IntStream.rangeClosed(1, count).forEach(i -> {
        threadPool.execute(() -> {
            try (CloseableHttpResponse response = client.get().execute(new HttpGet("http://127.0.0.1:45678/routelimit/server"))) {
                atomicInteger.addAndGet(Integer.parseInt(EntityUtils.toString(response.getEntity())));
            } catch (Exception ex) {
                ex.printStackTrace();
            }
        });
    });
    //等到count个任务全部执行完毕
    threadPool.shutdown();
    threadPool.awaitTermination(1, TimeUnit.HOURS);
    log.info("发送 {} 次请求,耗时 {} ms", atomicInteger.get(), System.currentTimeMillis() - begin);
    return atomicInteger.get();
}

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

static CloseableHttpClient httpClient1;

static {
    httpClient1 = HttpClients.custom().setConnectionManager(new PoolingHttpClientConnectionManager()).build();
}

@GetMapping("wrong")
public int wrong(@RequestParam(value = "count", defaultValue = "10") int count) throws InterruptedException {
    return sendRequest(count, () -> httpClient1);
}

虽然一个请求需要1秒执行完成,但我们的线程池是可以扩张使用任意数量线程的。按道理说,10个请求并发处理的时间基本相当于1个请求的处理时间,也就是1秒,但日志中显示实际耗时5秒:

[12:48:48.122] [http-nio-45678-exec-1] [INFO ] [o.g.t.c.h.r.RouteLimitController        :54  ] - 发送 10 次请求,耗时 5265 ms

查看PoolingHttpClientConnectionManager源码,可以注意到有两个重要参数:

  • defaultMaxPerRoute=2,也就是同一个主机/域名的最大并发请求数为2。我们的爬虫需要10个并发,显然是默认值太小限制了爬虫的效率。
  • maxTotal=20,也就是所有主机整体最大并发为20,这也是HttpClient整体的并发度。目前,我们请求数是10最大并发是10,20不会成为瓶颈。举一个例子,使用同一个HttpClient访问10个域名,defaultMaxPerRoute设置为10,为确保每一个域名都能达到10并发,需要把maxTotal设置为100。
public PoolingHttpClientConnectionManager(
    final HttpClientConnectionOperator httpClientConnectionOperator,
    final HttpConnectionFactory<HttpRoute, ManagedHttpClientConnection> connFactory,
    final long timeToLive, final TimeUnit timeUnit) {
    ...    
    this.pool = new CPool(new InternalConnectionFactory(
            this.configData, connFactory), 2, 20, timeToLive, timeUnit);
   ...
} 

public CPool(
        final ConnFactory<HttpRoute, ManagedHttpClientConnection> connFactory,
        final int defaultMaxPerRoute, final int maxTotal,
        final long timeToLive, final TimeUnit timeUnit) {
    ...
}}

HttpClient是Java非常常用的HTTP客户端,这个问题经常出现。你可能会问,为什么默认值限制得这么小。

其实,这不能完全怪HttpClient,很多早期的浏览器也限制了同一个域名两个并发请求。对于同一个域名并发连接的限制,其实是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方法,使用新的客户端进行测试:

httpClient2 = HttpClients.custom().setMaxConnPerRoute(10).setMaxConnTotal(20).build();

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

[12:58:11.333] [http-nio-45678-exec-3] [INFO ] [o.g.t.c.h.r.RouteLimitController        :54  ] - 发送 10 次请求,耗时 1023 ms

重点回顾

今天,我和你分享了HTTP调用最常遇到的超时、重试和并发问题。

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

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

最后我们看到,包括HttpClient在内的HTTP客户端以及浏览器,都会限制客户端调用的最大并发数。如果你的客户端有比较大的请求调用并发,比如做爬虫,或是扮演类似代理的角色,又或者是程序本身并发较高,如此小的默认值很容易成为吞吐量的瓶颈,需要及时调整。

今天用到的代码,我都放在了GitHub上,你可以点击这个链接查看。

思考与讨论

  1. 第一节中我们强调了要注意连接超时和读取超时参数的配置,大多数的HTTP客户端也都有这两个参数。有读就有写,但为什么我们很少看到“写入超时”的概念呢?
  2. 除了Ribbon的AutoRetriesNextServer重试机制,Nginx也有类似的重试功能。你了解Nginx相关的配置吗?

针对HTTP调用,你还遇到过什么坑吗?我是朱晔,欢迎在评论区与我留言分享你的想法,也欢迎你把这篇文章分享给你的朋友或同事,一起交流。

精选留言

  • Darren

    2020-03-19 10:52:21

    试着回答下问题:
    1、为什么很少见到写入超时,客户端发送数据到服务端,首先接力连接(TCP),然后写入TCP缓冲区,TCP缓冲区根据时间窗口,发送数据到服务端,因此写入操作可以任务是自己本地的操作,本地操作是不需要什么超时时间的,如果真的有什么异常,那也是连接(TCP)不上,或者超时的问题,连接超时和读取超时就能覆盖这种场景。
    2、proxy_next_upstream:语法: proxy_next_upstream
    [error|timeout|invalid_header|http_500|http_503|http_404|off]
    默认值: proxy_next_upstream error timeout
    即 error timeout会自动重试
    可以修改默认值,在去掉error和timeout,这样在发生错误和超时时,不会重试
    proxy_next_upstream_tries 这个参数决定重试的次数,0表示关闭该参数
    Limits the number of possible tries for passing a request to the next server. The 0 value turns off this limitation.
    作者回复

    👍🏻

    2020-03-19 11:30:01

  • 徐典阳✔️

    2020-04-17 09:27:02

    朱老师,请问Feign声明式HTTP接口调用可以针对某服务单个接口配置读取超时参数吗?我们这边一个微服务有n个接口,有一些接口处理耗时长有一些处理耗时短,但调用方又不期望针对同一个微服务声明多个Feign client。我简单翻了源码没有找到。
    作者回复

    可以,补充了一个例子:
    https://github.com/JosephZhu1983/java-common-mistakes/blob/master/src/main/java/org/geekbang/time/commonmistakes/httpinvoke/feignpermethodtimeout/FeignPerMethodTimeoutController.java

    Feign比较新的版本才会支持:
    https://github.com/OpenFeign/feign/pull/970

    相关源码:
    SynchronousMethodHandler

    Options findOptions(Object[] argv) {
    if (argv == null || argv.length == 0) {
    return this.options;
    }
    return (Options) Stream.of(argv)
    .filter(o -> o instanceof Options)
    .findFirst()
    .orElse(this.options);
    }

    2020-04-17 11:14:43

  • 蚂蚁内推+v

    2020-03-19 13:39:06

    老师,我这边工作过程中遇到服务端 499 这块要怎么从链接超时和读取超时设置去分析呢?
    作者回复

    499情况比较特殊,虽然表现为服务端(一般为代理,比如nginx)记录和返回499状态码,但是其实是因为处理时间太长,客户端超时主动关闭连接,排查两点:
    1、客户端读取超时时间多久
    2、服务端为什么处理这么慢超过了客户端的读取超时

    如果希望不要499的话,对于nginx可以开启
    proxy_ignore_client_abort,这样可以让请求在服务端执行完成

    2020-03-19 14:22:26

  • Monday

    2020-03-20 21:07:53

    我们来分析一下源码。打开 RibbonClientConfiguration 类后,会看到 DefaultClientConfigImpl 被创建出来之后,ReadTimeout 和 ConnectTimeout 被设置为 1s:

    /**
    * Ribbon client default connect timeout.
    */
    public static final int DEFAULT_CONNECT_TIMEOUT = 1000;

    /**
    * Ribbon client default read timeout.
    */
    public static final int DEFAULT_READ_TIMEOUT = 1000;

    @Bean
    @ConditionalOnMissingBean
    public IClientConfig ribbonClientConfig() {
    DefaultClientConfigImpl config = new DefaultClientConfigImpl(); //此行打断点
    config.loadProperties(this.name);
    config.set(CommonClientConfigKey.ConnectTimeout, DEFAULT_CONNECT_TIMEOUT);
    config.set(CommonClientConfigKey.ReadTimeout, DEFAULT_READ_TIMEOUT);
    config.set(CommonClientConfigKey.GZipPayload, DEFAULT_GZIP_PAYLOAD);
    return config;
    }

    被死扣的毛病折腾着,以上这段描述和代码中,有两个疑问,烦老师解惑,谢谢。
    1、使用默认配置,我在标注行打了断点,debug启动时未进断点。是不是表明默认值不是在此段代码设置的?
    2、找到了feign配置的原始类FeignClientProperties,但是没找到ribbon的。
    作者回复

    1、启动时不进断点不代表不是,执行后会进断点,原因是LoadBalancerFeignClient.execute(),运行时注入依赖的,这个方法一路追下去:

    IClientConfig getClientConfig(Request.Options options, String clientName) {
    IClientConfig requestConfig;
    if (options == DEFAULT_OPTIONS) {
    requestConfig = this.clientFactory.getClientConfig(clientName);
    }
    else {
    requestConfig = new FeignOptionsClientConfig(options);
    }
    return requestConfig;
    }


    2、ribbon是netflix的三方库,不是spring boot @ConfigurationProperties玩法,Key定义在:

    com.netflix.client.config.CommonClientConfigKey

    2020-03-20 22:26:25

  • 👽

    2020-03-19 09:55:36

    这已经不单单是一个坑了,而是N一个场景下,多种多样的坑。
    Spring Boot 带来了【约定大于配置】的说法,但是,本文告诉我们,越是约定大于配置,越是要对那些“默认配置”心里有数才行。
    HTTP请求,说到底,还是网络调用。某个老师曾说过,网络,就是不靠谱的。就存在拥塞,丢包等各种情况。从而使得排查的难度更大。要考虑的角度,宽度,都更广。不单是客户端,服务端,甚至还要考虑网络环境。这对程序员具备的技术深度,广度都有了更高的要求。
    今天的收货:
    首先,增长了经验。知道了有这么些坑,虽然不一定能记得住,最起码留个印象。以后碰到类似的问题了能想起来。
    然后,不能盲目相信默认配置。条件允许的情况下,还是需要了解关注那些默认配置以及默认实现。
    最后,对HTTP调用,的测试方式与模拟方式,也了解到了测试方式。如何分别设置超时时间来找问题。

    其实,还希望能听听老师讲讲HTTP调用出问题的排查思路与方案。
    作者回复

    总结的不错

    2020-03-19 11:43:12

  • Geek_d7ede4

    2020-03-31 21:05:24

    老师您好,我之前对接过一个第三方支付接口,调用支付接口a账户对b账户进行了转账操作,我业务数据库也要做一个记账操作在数据库中,如何保证调用第三方支付接口和我本地的业务是一致性的呢?就是第三方支付接口有可能已经转账成功了,但是我业务代码可能抛异常,导致回滚了。
    作者回复

    很典型的问题:
    1、先创建支付订单,再提交外部,创建订单的操作独立事务,不要回滚(否则出异常了,订单都没了,补偿的依据都没有)
    2、只有外部接口告诉你明确成功或失败了,你才能认为操作成功或失败
    3、否则由定时任务调用外部查询接口查询交易结果,然后根据查到的结果补偿本地状态

    2020-03-31 21:40:13

  • 斐波那契

    2020-03-20 23:11:39

    里面说的坑也许过了一段时间就忘了 当时有四个字是我学到的 那就是“查看源码”
  • Unravel👾

    2020-03-26 00:25:47

    老师您好
    前段时间遇到过一个连接超时的问题,在springboot中使用restTemplate(无论是不配置还是增大超时时间或是加入apache http client连接池)在业务中请求另外一个服务的接口经常会出connect timeout(经过nginx或是直接连接tomcat都会出现)
    此时ping、telnet、curl都是成功的
    但是如果另有一个任务定时一直请求接口,那么在业务中就不会出现connect timeout了。
    一直没有成功解决这个问题,想问下老师可以从哪方面入手,谢谢老师
    作者回复

    既然你是遇到偶尔出现连接失败,说明对端端口是开的,这种连接超时偶发问题一般是网络问题,丢包、防火墙、带宽打满、网卡配置问题,甚至是硬件问题(网线网口)等引起的,链路上任何一个环节的软件和硬件都可能引起问题,抓包分析吧。

    另外ping一次是成功的,长ping一下看看。curl一次是成功的,做一个监控10s一次curl一次试试。

    2020-03-26 08:44:28

  • Monday

    2020-03-20 23:08:25

    花了两个晚上终于还是把这节啃了下来,准备运行环境,重现所有问题,翻看相关源码。
    终于等到你,还好我没放弃。
    个人感悟,这些坑对以后快速排查问题,肯定有帮助。就算以后淡忘了这节的内容,但至少还会有些许记忆的,哪个专栏,哪个老师,哪篇文章。感谢老师!
    作者回复

    如果觉得有用可以多转发分享

    2020-03-21 08:11:53

  • 陈天柱

    2020-03-20 17:32:23

    之前用Spring Cloud就遇到过feign调用超时的坑,始终配置readTimeout值都不生效。虽然后面网上查阅了资料暂时性解决了,但是看了老师的解决问题思路才发现,这个时候就需要带着问题去阅读源码找寻答案,提高自己阅读源码的能力。
  • 终结者999号

    2020-03-19 22:38:04

    老师,对于Http Client和Ok Http相比,是不是OkHttp支持得更好,而且HTTP2相比于HTTP1.1的新特性是不是也使得我们不用过去的一些配置了啊
    作者回复

    我个人觉得okhttp易用性更高一点,不过okhttp应该在安卓领域更火一点,后端使用okhttp的应该不多。万变不离其宗,使用任何httpclient都要考虑连接池、超时配置、自动重试和并发问题

    2020-03-20 11:43:03

  • Monday

    2020-03-19 23:19:24

    public class ClientReadTimeoutController {
    private String getResponse(String url, int connectTimeout, int readTimeout) throws IOException {
    return Request.Get("http://localhost:45678/clientreadtimeout" + url)
    .connectTimeout(connectTimeout)
    .socketTimeout(readTimeout)
    .execute()
    .returnContent()
    .asString();
    }
    ....
    }


    这第一段代码中Request这个类,是引用哪个包下的?找得好辛苦,老师第5节的代码也没上传到git
    作者回复

    源码里面有,在clientreadtimeout里
    Request是在这里:
    <dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>fluent-hc</artifactId>
    <version>4.5.9</version>
    </dependency>

    2020-03-20 08:44:56

  • Monday

    2020-03-19 07:18:16

    好文章,好“坑”。
    作者回复

    如果觉得好,可以多分享转发

    2020-03-19 14:38:09

  • Whale

    2020-07-07 15:26:38

    学到了 feign和httpclient都用过,只停留在其他人搭建好我使用的状态,但这次对feign和httpclient的一些配置都有了新的认识,接下来打算自己去试着搭建。很有收获,继续前进!
  • 看不到de颜色

    2020-03-29 13:45:59

    处理超时一定要搞清楚超时的阶段。到底是建连超时还是等待响应超时(读取超时)。针对不同的问题针对解决。对于写超时这个相当于写本地TCP缓冲区,速度应该很快,很少会出现socket无法写入导致的写超时问题。
    很久没用用过HttpClient了。回忆了一下,以前确实没有搞清楚总并发(maxTotal)和单域名并发(defaultMaxPerRoute)的区别。通过这篇文章总算搞明白了,收货颇丰。
    作者回复

    是的 除非send buffer满了

    2020-03-29 15:40:17

  • 一个想偷懒的程序坑

    2020-03-20 22:46:20

    虽然没处理过这块儿的东西,但看完了解了许多知识,赞!
    作者回复

    如果觉得有用可以多转发分享

    2020-03-21 08:11:58

  • Alpha

    2020-03-20 00:49:25

    非常同意选择Get还是Post应该依据API的行为。
    但是有时数据查询的API参数确实不得已很长,会导致浏览器的长度限制,老师有好的办法吗?
    作者回复

    这么长的参数看看是否合理,对于有一些数据它可能并不是查询参数可以放头里传

    2020-03-20 08:39:43

  • Monday

    2020-03-19 20:35:01

    ribbon.ReadTimeout=4000
    ribbon.ConnectTimeout=4000

    这个参数的key命名不规范,是有故事,还是开发人员不够专业?
    作者回复

    这就不清楚了

    2020-03-19 21:38:54

  • 学无涯

    2021-06-02 09:23:53

    关于Feign读取超时必须同时配置连接超时才能生效的问题,貌似在spring-cloud-openfeign:2.2.4.RELEASE版本修复了,对应的springcloud版本Hoxton.SR7
    作者回复

    哦?

    2021-06-03 18:49:45

  • James

    2020-07-12 14:39:16

    已解决,是pom.xml文件没有将配置文件包含到class目录下.