暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

爬虫springboot服务假死nginx报502BadGateway

开发架构二三事 2019-07-17
2144

502 Bad Gateway是指错误网关,无效网关;在互联网中表示一种网络错误。表现在WEB浏览器中给出的页面反馈。在早上上班时,接到测试提过来的一个bug,说是一个爬虫服务所有响应信息都是502 Bad Gateway,于时展开了下面的分析。其中爬虫服务是一个springboot项目,使用的是tomcat starter来作为web容器。

1. tomcat假死的一般原因:

  1. 应用本身程序的问题,程序内部有死锁。

  2. 服务load 太高,已经超出服务的极限(top查看),对堆和gc等进行分析。

  3. jvm GC 时间过长,导致应用暂停,可以输出gc log进行分析。

  4. 大量tcp 连接 CLOSEWAIT或TIMEWAIT: netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

  1. TIME_WAIT 48

  2. CLOSE_WAIT 2228

  3. ESTABLISHED 86

常用的三个状态是:ESTABLISHED 表示正在通信,TIMEWAIT 表示主动关闭,CLOSEWAIT 表示被动关闭。关于closewait和timewait,tcp中的交互图:

http交互图:

  • TIMEWAIT是主动关闭连接的一方保持的状态,客户端完成请求之后,他就会发起主动关闭连接,从而进入TIMEWAIT的状态,然后在保持这个状态2MSL(max segment lifetime)时间之后,彻底关闭回收资源。关于2MSL,见(https://www.cnblogs.com/tekkaman/p/4849522.html):

  1. MSLMaximum Segment Lifetime英文的缩写,中文可以译为“报文最大生存时间”,他是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。

  2. 因为tcp报文(segment)是ip数据报(datagram)的数据部分,具体称谓请参见《数据在网络各层中的称呼》一文,而ip头中有一个TTL域,TTLtime to live的缩写,

  3. 中文可以译为“生存时间”,这个生存时间是由源主机设置初始值但不是存的具体时间,而是存储了一个ip数据报可以经过的最大路由数,每经过一个处理他的路由器此值就减1

  4. 当此值为0则数据报将被丢弃,同时发送ICMP报文通知源主机。RFC 793中规定MSL2分钟,实际应用中常用的是30秒,1分钟和2分钟等。

  5. 2MSL即两倍的MSLTCPTIME_WAIT状态也称为2MSL等待状态,当TCP的一端发起主动关闭,在发出最后一个ACK包后,即第3次握手完成后发送了第四次握手的ACK包后就进入了TIME_WAIT状态,

  6. 必须在此状态上停留两倍的MSL时间,等待2MSL时间主要目的是怕最后一个ACK包对方没收到,那么对方在超时后将重发第三次握手的FIN包,主动关闭端接到重发的FIN包后可以再发一个ACK应答包。

  7. TIME_WAIT状态时两端的端口不能使用,要等到2MSL时间结束才可继续使用。当连接处于2MSL等待阶段时任何迟到的报文段都将被丢弃。不过在实际应用中可以通过设置SO_REUSEADDR选项达到

  8. 不必等待2MSL时间结束再使用此端口。

需要注意的是,对于基于TCP的HTTP协议,关闭TCP连接的是Server端,这样,Server端会进入TIMEWAIT状态,可想而知,对于访问量大的Web Server,会存在大量的TIMEWAIT状态,假如server一秒钟接收1000个请求,那么就会积压240*1000=240,000个 TIMEWAIT的记录,维护这些状态给Server带来负担。当然现代操作系统都会用快速的查找算法来管理这些TIMEWAIT,所以对于新的 TCP连接请求,判断是否hit中一个TIME_WAIT不会太费时间,但是有这么多状态要维护总是不好。HTTP协议1.1版规定default行为是Keep-Alive,也就是会重用TCP连接传输多个 request/response,一个主要原因就是发现了这个问题。

也就是说当服务器上出现大量TIMEWAIT时,可能是该服务器作为别的服务器的客户端rpc访问时别的服务器,在关闭连接时进入了TIMEWAIT状态,这种情况是对方的连接出现了异常。另一种可能是该服务器是一台http服务器,对于大量访问时,会出现大量的TIMEWAIT。这种情况下需要让服务器能够快速回收和重用那些TIMEWAIT的资源,可以通过修改/etc/sysctl.conf中的参数来进行,具体可以参考:https://blog.csdn.net/shootyou/article/details/6622226

  • 如果一直保持在CLOSE_WAIT状态,那么只有一种情况,就是在被动关闭的场景下,对方关闭连接之后服务器程序自己没有进一步发出ack信号。换句话说,就是在对方连接关闭之后,程序没有释放连接,于是这个连接资源就一直被程序占着。下面的摘自:https://blog.csdn.net/shootyou/article/details/6622226

  1. 服务器A是一台爬虫服务器,它使用简单的HttpClient去请求资源服务器B上面的资源,正常情况下,如果请求成功,需要关闭时服务器A会主动发出关闭连接的请求,这个时候就是主动关闭连接,服务器A的连接状态我们可以看到是TIME_WAIT

  2. 如果一旦发生异常呢?假设请求的资源服务器B上并不存在,那么这个时候就会由服务器B发出关闭连接的请求,服务器A就是被动的关闭了连接,如果服务器A被动关闭连接之后程序员忘了让HttpClient释放连接,那就会造成CLOSE_WAIT的状态了。

这里我理解的CLOSE_WAIT就是服务端被动关闭时没有及时释放连接或客户端连接池在连接被动关闭时没有及时释放连接。出现这种问题最大的可能就是代码的问题。

2. 分析

  1. 查看各种日志,之前的日志有爬取异常出现,但最新几十分钟内的日志没有异常出现,刷新页面请求时除了nginx的日志有报错信息,服务中并无新的日志输出。

  2. 这台服务分配的堆内存大小是2G,所以怀疑是内存问题,但检查cpu,内存都还正常,内存稍微高点,cpu在5%左右。

  3. 分析closewait和timewait: 执行: netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}' 结果:

  1. TIME_WAIT 56

  2. CLOSE_WAIT 2780

  3. ESTABLISHED 86

可以看到CLOSE_WAIT的数量非常多,可能是因为程序中在凌晨五点时会有一个定时任务,通过httpclient去爬取多个不同网站的资讯信息,有些响应信息被异常关闭,而客户端没有及时释放连接导到的C

windows中的命令:

  • netstat -ano | findstr “CLOSE_WAIT”

  • netstat -ano | findstr “TIMEWAIT” 唯一的一个现象就是tcp的端口有大量的CLOSEWAIT,关于CLOSE_WAIT的产生大部分都是说资源没释放导致的,有httpclient导致的,也有数据库链接导致的,但是在我们的爬虫程序中涉及到数据库的并不多,大多都是通过httpclient去爬取的操作。

需要注意几点(主要针对httpclient 4.0以上版本):

  • httpclient 4.0之后是基于http 1.1协议,连接默认是keep-alive模式的。所以需要:method.setRequestHeader("Connection", "close")。

  • 另一种方式是为了确保响应完全返回,在finally块中执行httpClient.getHttpConnectionManager().closeIdleConnections(0);这个在httpclient 4.3 以后被废弃,现在使用的是httpclient.close();

  • 请求之后未收到响应信息时(出现异常时),调用method.abort()进行处理: 

  • 多提一句,在httpclient3的版本应该是在finally块中调用method.releaseConnection()方法。于是查看了httpclient调用部分的代码:

  1. // 创建Httpclient对象

  2. CloseableHttpClient httpclient = HttpClients.createDefault();

  3. String resultString = "";

  4. CloseableHttpResponse response = null;

  5. HttpGet httpGet = null;

  6. try {

  7. // 创建uri

  8. URIBuilder builder = new URIBuilder(url);

  9. if (param != null) {

  10. for (String key : param.keySet()) {

  11. builder.addParameter(key, param.get(key));

  12. }

  13. }

  14. URI uri = builder.build();

  15. // 创建http GET请求

  16. httpGet = new HttpGet(uri);

  17. if (headers != null) {

  18. httpGet.setHeaders(headers);

  19. }

  20. // 执行请求

  21. response = httpclient.execute(httpGet);

  22. // 判断返回状态是否为200

  23. if (response.getStatusLine().getStatusCode() == 200) {

  24. resultString = EntityUtils.toString(response.getEntity(), encoding);

  25. }

  26. } catch (Exception e) {

  27. LOG.error("连接异常!",e);

  28. } finally {

  29. try {

  30. if (response != null) {

  31. response.close();

  32. }

  33. httpclient.close();

  34. } catch (IOException e) {

  35. LOG.error("IO异常!",e);

  36. }

  37. }

  38. return resultString;

可以看出,主要有几个一眼看出的缺点:(1)直接调用HttpClients.createDefault(),而没有使用连接池; (2)在出现连接异常时,并没有关闭连接,会导致很多的CLOSE_WAIT;

先将上面代码异常处理部分修改成如下:

  1. ...

  2. } catch (Exception e) {

  3. if (httpGet != null){

  4. //关闭连接

  5. httpGet.abort();

  6. }

  7. LOG.error("连接异常!",e);

  8. } finally {

  9. try {

  10. if (response != null) {

  11. response.close();

  12. }

  13. httpclient.close();

  14. } catch (IOException e) {

  15. LOG.error("IO异常!",e);

  16. }

  17. }

运行一段时间后,让定时任务执行时间提前,运行一段时间后,执行netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}',结果如下:

  1. TIME_WAIT 48

  2. CLOSE_WAIT 50

  3. ESTABLISHED 86

可见,应该是在去爬取数据时,有些服务器拒绝了请求,导致httpclient中抛出了异常,而没有及时关闭这些异常,引起了大量的CLOSE_WAIT出现。

针对上面的代码,是每个连接只使用一次的,还可以设置一些超时时间:

3. 使用连接池

直接上代码:

连接池代码:

  1. public class ApacheHttpClientManager {

  2. private static final Logger LOGGER = LoggerFactory.getLogger(ApacheHttpClientManager.class);

  3. // 连接池管理器

  4. private volatile PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = null;

  5. private static final ApacheHttpClientManager httpclientManager = new ApacheHttpClientManager();

  6. // 请求处理器

  7. private HttpRequestRetryHandler httpRequestRetryHandler = new HttpRequestRetryHandler() {

  8. @Override

  9. public boolean retryRequest(IOException exception, int executionCount, HttpContext context) {

  10. if (executionCount >= 3) {// 如果已经重试了3次,就放弃

  11. return false;

  12. }

  13. if (exception instanceof NoHttpResponseException) {// 如果服务器丢掉了连接,那么就重试

  14. return true;

  15. }

  16. if (exception instanceof SSLHandshakeException) {// 不要重试SSL握手异常

  17. return false;

  18. }

  19. if (exception instanceof InterruptedIOException) {// 超时

  20. return false;

  21. }

  22. if (exception instanceof UnknownHostException) {// 目标服务器不可达

  23. return false;

  24. }

  25. if (exception instanceof ConnectTimeoutException) {// 连接被拒绝

  26. return false;

  27. }

  28. if (exception instanceof SSLException) {// SSL握手异常

  29. return false;

  30. }

  31. HttpClientContext clientContext = HttpClientContext.adapt(context);

  32. HttpRequest request = clientContext.getRequest();

  33. // 如果请求是幂等的,就再次尝试

  34. if (!(request instanceof HttpEntityEnclosingRequest)) {

  35. return true;

  36. }

  37. return false;

  38. }

  39. };

  40. // 连接存活策略

  41. ConnectionKeepAliveStrategy myStrategy = new ConnectionKeepAliveStrategy() {

  42. @Override

  43. public long getKeepAliveDuration(HttpResponse response, HttpContext context) {

  44. // Honor 'keep-alive' header

  45. HeaderElementIterator it = new BasicHeaderElementIterator(response.headerIterator(HTTP.CONN_KEEP_ALIVE));

  46. while (it.hasNext()) {

  47. HeaderElement he = it.nextElement();

  48. String param = he.getName();

  49. String value = he.getValue();

  50. if (value != null && param.equalsIgnoreCase("timeout")) {

  51. try {

  52. return Long.parseLong(value) * 1000;

  53. } catch (NumberFormatException ignore) {

  54. }

  55. }

  56. }

  57. HttpHost target = (HttpHost) context.getAttribute(HttpClientContext.HTTP_TARGET_HOST);

  58. if ("www.naughty-server.com".equalsIgnoreCase(target.getHostName())) {

  59. // Keep alive for 5 seconds only

  60. return 5 * 1000;

  61. } else {

  62. // otherwise keep alive for 30 seconds

  63. //return 30 * 1000;

  64. return 0;

  65. }

  66. }

  67. };

  68. // 锁

  69. private static final Object LOCK = new Object();

  70. private ApacheHttpClientManager() {

  71. }

  72. public static ApacheHttpClientManager getInstance() {

  73. return httpclientManager;

  74. }

  75. /**

  76. *

  77. * @param maxTotal

  78. * 最大连接数

  79. * @param maxPerRoute

  80. * 每个路由的最大连接数

  81. * @param maxRoute

  82. * 主机的最大路由数

  83. * @param hostname

  84. * 主机名或ip地址

  85. * @param port

  86. * 端口

  87. * @return 连接池管理器

  88. */

  89. private PoolingHttpClientConnectionManager getHttpClientPoolManager(int maxTotal, int maxPerRoute, int maxRoute,

  90. String hostname, int port) {

  91. ConnectionSocketFactory plainsf = PlainConnectionSocketFactory.getSocketFactory();

  92. LayeredConnectionSocketFactory sslsf = SSLConnectionSocketFactory.getSocketFactory();

  93. Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory> create()

  94. .register("http", plainsf).register("https", sslsf).build();

  95. PoolingHttpClientConnectionManager poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(

  96. registry);

  97. // 将最大连接数增加

  98. poolingHttpClientConnectionManager.setMaxTotal(maxTotal);

  99. // 将每个路由基础的连接增加

  100. poolingHttpClientConnectionManager.setDefaultMaxPerRoute(maxPerRoute);

  101. HttpHost httpHost = new HttpHost(hostname, port);

  102. // 将目标主机的最大连接数增加

  103. poolingHttpClientConnectionManager.setMaxPerRoute(new HttpRoute(httpHost), maxRoute);

  104. return poolingHttpClientConnectionManager;

  105. }

  106. /**

  107. * 初始化连接池管理器

  108. *

  109. * 此处解释下MaxtTotal和DefaultMaxPerRoute的区别:1、MaxtTotal是整个池子的大小;

  110. * 2、DefaultMaxPerRoute是根据连接到的主机对MaxTotal的一个细分;比如:MaxtTotal=400

  111. * DefaultMaxPerRoute=200 而我只连接到http://sishuok.com时,到这个主机的并发最多只有200;而不是400;

  112. * 而我连接到http://sishuok.com 和

  113. * http://qq.com时,到每个主机的并发最多只有200;即加起来是400(但不能超过400);所以起作用的设置是DefaultMaxPerRoute。

  114. *

  115. * @see http://jinnianshilongnian.iteye.com/blog/2089792#comments

  116. * @param maxTotal

  117. * @param maxPerRoute

  118. * @return PoolingHttpClientConnectionManager

  119. */

  120. public PoolingHttpClientConnectionManager initClientPoolManager(int maxTotal, int maxPerRoute) {

  121. if (poolingHttpClientConnectionManager == null) {

  122. synchronized (LOCK) {

  123. if (poolingHttpClientConnectionManager == null) {

  124. ConnectionSocketFactory plainsf = PlainConnectionSocketFactory.getSocketFactory();

  125. LayeredConnectionSocketFactory sslsf = SSLConnectionSocketFactory.getSocketFactory();

  126. Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory> create()

  127. .register("http", plainsf).register("https", sslsf).build();

  128. poolingHttpClientConnectionManager = new PoolingHttpClientConnectionManager(registry);

  129. // 将最大连接数增加

  130. poolingHttpClientConnectionManager.setMaxTotal(maxTotal);

  131. // 将每个路由基础的连接增加

  132. poolingHttpClientConnectionManager.setDefaultMaxPerRoute(maxPerRoute);

  133. // HttpHost httpHost = new HttpHost("localhost", 10012);

  134. // 将目标主机的最大连接数增加

  135. // poolingHttpClientConnectionManager.setMaxPerRoute(new

  136. // HttpRoute(httpHost), 50);

  137. }

  138. //剔除无用连接

  139. IdleConnectionMonitorThread idleConnectionMonitorThread = new IdleConnectionMonitorThread(poolingHttpClientConnectionManager);

  140. idleConnectionMonitorThread.setDaemon(true);

  141. idleConnectionMonitorThread.start();

  142. Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {

  143. @Override

  144. public void run() {

  145. idleConnectionMonitorThread.shutdown();

  146. poolingHttpClientConnectionManager.close();

  147. //poolingHttpClientConnectionManager.closeExpiredConnections();

  148. }

  149. }));

  150. }

  151. }

  152. return poolingHttpClientConnectionManager;

  153. }

  154. /**

  155. * 获取httpclient

  156. *

  157. * @see RequestConfig#getConnectionRequestTimeout()

  158. * @see RequestConfig#getConnectTimeout()

  159. * @see RequestConfig#getSocketTimeout()

  160. * @return httpClient

  161. */

  162. public CloseableHttpClient getHttpClient() {

  163. int CONNECTION_TIMEOUT = 5 * 1000; // 设置请求超时5秒钟 根据业务调整

  164. int SO_TIMEOUT = 5 * 1000; // 设置等待数据超时时间5秒钟 根据业务调整

  165. // 定义了当从ClientConnectionManager中检索ManagedClientConnection实例时使用的毫秒级的超时时间

  166. int CONN_MANAGER_TIMEOUT = 500; // 该值就是连接不够用的时候等待超时时间,一定要设置,而且不能太大 ()

  167. // @see RequestConfig#getConnectionRequestTimeout()

  168. RequestConfig requestConfig = RequestConfig.custom().setConnectionRequestTimeout(CONN_MANAGER_TIMEOUT)

  169. .setConnectTimeout(CONNECTION_TIMEOUT).setSocketTimeout(SO_TIMEOUT).build();

  170. // 初始化连接池管理器

  171. initClientPoolManager(200, 6);

  172. CloseableHttpClient httpClient = HttpClients.custom().setConnectionManager(poolingHttpClientConnectionManager)

  173. .setRetryHandler(httpRequestRetryHandler)// 共用一个请求重试处理器

  174. .setDefaultRequestConfig(requestConfig).setKeepAliveStrategy(myStrategy) // 相同的连接存活策略

  175. .build();

  176. if (poolingHttpClientConnectionManager != null && poolingHttpClientConnectionManager.getTotalStats() != null) {

  177. LOGGER.info("now client pool " + poolingHttpClientConnectionManager.getTotalStats().toString());

  178. }

  179. return httpClient;

  180. }

  181. }

最好设置connectTimeout、socketTimeout,可以防止阻塞,在上面的getHttpClient()方法中已经对这两个参数设置了默认值,使用时可以根据实际情况进行修改。关于这两个参数,可以参考:https://blog.csdn.net/wangjun5159/article/details/78140648

4. 参考:

  • https://www.cnblogs.com/jessezeng/p/5616518.html

  • https://bbs.csdn.net/topics/392190187?list=lz

  • https://blog.csdn.net/shootyou/article/details/6622226

  • https://blog.csdn.net/chinalinuxzend/article/details/1792184


文章转载自开发架构二三事,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论