Java开发中使用 HTTP 的注意要点

2022/6/3 1:22:49

本文主要是介绍Java开发中使用 HTTP 的注意要点,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

若察觉文中知识要点有误,请私信及时指正
本篇内容总结于极客时间——《Java开发常见错误 100 例》

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

  虽然 HTTP 是应用层的协议,但本质上还是执行的网络层的 TCP/IP 协议,TCP/IP 协议是面向连接的协议,在传输数据前需要建立连接,而基本每个网络框架都会给予两个配置参数:

  • 连接超时参数,用户等待连接建立的耗时;
  • 读取超时参数,读取数据的最长等待时间;

连接超时参数和连接超时的误区

  • 将连接超时参数设置的特别长,比如 60s,就像上面提到的,HTTP 本质还是 TCP/IP 协议的三次握手,这套动作的耗时一般都是在毫秒级别最多几秒,所以当出现十几秒甚至几十秒的情况,一般都是网络问题或者防火墙问题,基本上可以认定连接不上。因此,设置特别长的连接超时参数是没有什么意义的,将其配置的短一些是没有问题的(一般是 1-5s ),如果是内网就可以更短。
  • 没有抓住是谁导致连接超时,有些服务端具有负载均衡的能力,所以可以跟客户端直连。但是一般的情况下,可能都是通过像 Nginx 这种通过反向代理支持负载均衡能力来连接服务端的,而如果服务端通过类似 Nginx 的反向代理来负载均衡,客户端连接的其实是 Nginx,而不是服务端,此时出现连接超时应该排查 Nginx。

读取超时参数和读取超时的误区:

  • 读取超时后,服务器的执行就中断了,这个是我初入职场时犯的错误,当时我需要执行一个耗时 5s 的统计任务,但框架设定的读取超时参数是在 3s ,也就是我这样我永远也拿不到返回值,在不了解公司读取超时参数的情况下,我反复的执行那条复杂的 sql,最终在 mysql 警报的情况下被我的组长给拦了下来。
      来还原当时的场景,定义一个 client 接口,内部通过 HttpClient 调用服务端接口 server,客户端读取超时 2s,服务端接口执行耗时 5s
@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 的服务器都是把请求提交到线程池来做处理,只要服务端收到了请求,网络层面的超时和断开便不会影响服务端的执行。因此,出现读取超时不能随意假设服务端的处理情况,需要根据业务状态考虑如何进行后续处理。

  • 认为超时时间越长任务接口成功率就越高,将读取超时参数配置得太长。HTTP 请求需要等待结果的返回,那就是同步调用,当你把超时时间设置的过于长,我们的程序运行在 Tomcat 这样的线程池中(现在问题可能就变成了线程池的问题),客户端线程也在等待的情况下,当下游服务出现大量超时的时候,程序可能也会受到拖累创建大量线程,最终崩溃。
      对于异步任务或者定时任务来说,你设置一个较长的超时时间是没什么问题的。但是对于面向用户和一些短平快的微服务时,就要调整一下策略,一般不会超过30s(一般就是在 3s-6s )

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

  对 feign 的配置感到棘手,一方面是网上的资料五花八门,另一方面则是因为 feign 和它的组件 ribbon 都有对应的配置参数。那么,这些配置的优先级是怎样的,又哪些什么坑呢?接下来,我们做一些实验吧。
  首先查看一下 feign 的默认读取超时参数是多少,假设有这么一个服务端接口,什么都不干只休眠 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());
    }
}

  得到以下输出:

[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.client.config.default.readTimeout=3000
feign.client.config.default.connectTimeout=3000

  但!这里也有一个坑点,也就是结论二 feign 配置参数是需要上面两个参数都配置,否则只配一个读取超时是会失败的,原因是这段代码:

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

  上面也提到 ribbon 也可以配置相关的参数,但它的坑点也就是结论三是配置参数需要大写

ribbon.ReadTimeout=4000
ribbon.ConnectTimeout=4000

  那么回到重点,feign 和 ribbon 都配置了参数的情况下谁会生效,答案是结论四,feign 的会生效。 在 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;
}

  但注意 feign 的配置如果只配置了一条那么还是 ribbon 的生效,原因是结论二。

Ribbon 的重试

  这个问题是当时我们开发了一个短信发送的系统,但是偶尔会出现重复发送的情况,但我们反复确定了代码内没有重复发送的逻辑,最后确定问题出现在 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 请求应该是无状态或者幂等的,短信服务商的短信接口可以设计为支持幂等调用的。

HTTP 协议的并发数限制

  这里我们可以直接看 PoolingHttpClientConnectionManager 的源码,这里有 2 个参数:

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) {
    ...
}}
  • defaultMaxPerRoute=2,也就是同一个主机 / 域名的最大并发请求数为 2。如果你使用爬虫需要 10 个并发,则默认值太小会限制爬虫的效率
  • maxTotal=20,也就是所有主机整体最大并发为 20,这也是 HttpClient 整体的并发度

  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 客户端发起大量并发请求,不管使用什么客户端,请务必确认客户端的实现默认的并发度是否满足需求。



这篇关于Java开发中使用 HTTP 的注意要点的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程