位元組一面,被問到兩個經典問題! 你知道是什麼嗎?

2022.12.16

位元組一面,被問到兩個經典問題! 你知道是什麼嗎?


當服務端出現大量 CLOSE_WAIT 狀態的連接的時候,通常都是代碼的問題,這時候我們需要針對具體的代碼一步一步的進行排查和定位,主要分析的方向就是服務端為什麼沒有調用 close。

大家好,我是小林。

之前有位讀者面位元組被問到兩個很經典的 TCP 問題:

圖片

第一個問題:服務端大量處於 TIME_WAIT 狀態連接的原因。

第二個問題:服務端大量處於 CLOSE_WAIT 狀態連接的原因。

這兩個問題在面試中很常問,主要也是因為在工作中也很常遇到這個問題。

這次,我們就來聊聊這兩個問題。

圖片

服務端出現大量 TIME_WAIT 狀態的原因有哪些?

我們先來看一下 TCP 四次揮手的流程吧,看看 TIME_WAIT 狀態發生在哪一個階段。

下面這個圖,是由「用戶端」作為「主動關閉方」的 TCP 四次揮手的流程。

圖片

TCP 四次挥手的流程

从上面我们可以知道,TIME_WAIT 状态是「主动关闭连接方」才会出现的状态。而且 TIME_WAIT 状态会持续 2MSL 时间才会进入到 close 状态。在 Linux 上 2MSL 的时长是 60 秒,也就是说停留在 TIME_WAIT 的时间为固定的 60 秒。

为什么需要 TIME_WAIT 状态?(老八股文了,帮大家复习一波)主要有两个原因:

  • 保证「被动关闭连接」的一方,能被正确的关闭。TCP 协议在关闭连接的四次挥手中,在主动关闭方发送的最后一个 ACK 报文,有可能丢失,这时被动方会重新发 FIN 报文, 如果这时主动方处于 CLOSE 状态 ,就会响应 RST 报文而不是 ACK 报文。所以主动方要处于 TIME_WAIT 状态,而不能是 CLOSE。
  • 防止历史连接中的数据,被后面相同四元组的连接错误的接收。TCP 报文可能由于路由器异常而 “迷路”,在迷途期间,TCP 发送端可能因确认超时而重发这个报文,迷途的报文在路由器修复后也会被送到最终目的地,这个原来的迷途报文就称为 lost duplicate。在关闭一个 TCP 连接后,马上又重新建立起一个相同的 IP 地址和端口之间的 TCP 连接,后一个连接被称为前一个连接的化身,那么有可能出现这种情况,前一个连接的迷途重复报文在前一个连接终止后出现,从而被误解成从属于新的化身。为了避免这个情 况, TIME_WAIT 状态需要持续 2MSL,因为这样就可以保证当成功建立一个 TCP 连接的时候,来自连接先前化身的重复报文已经在网络中消逝。

很多人误解以为只有客户端才会有 TIME_WAIT 状态,这是不对的。TCP 是全双工协议,哪一方都可以先关闭连接,所以哪一方都可能会有 TIME_WAIT 状态。

总之记住,谁先关闭连接的,它就是主动关闭方,那么 TIME_WAIT 就会出现在主动关闭方。

什么场景下服务端会主动断开连接呢?

如果服务端出现大量的 TIME_WAIT 状态的 TCP 连接,就是说明服务端主动断开了很多 TCP 连接。

问题来了,什么场景下服务端会主动断开连接呢?

  • 第一个场景:HTTP 没有使用长连接
  • 第二个场景:HTTP 长连接超时
  • 第三个场景:HTTP 长连接的请求数量达到上限

接下来,分别介绍下。

第一个场景:HTTP 没有使用长连接

我们先来看看 HTTP 长连接(Keep-Alive)机制是怎么开启的。

在 HTTP/1.0 中默认是关闭的,如果浏览器要开启 Keep-Alive,它必须在请求的 header 中添加:

Connection: Keep-Alive
  • 1.

然后当服务器收到请求,作出回应的时候,它也被添加到响应中 header 里:

Connection: Keep-Alive
  • 1.

这样做,TCP 连接就不会中断,而是保持连接。当客户端发送另一个请求时,它会使用同一个 TCP 连接。这一直继续到客户端或服务器端提出断开连接。

从 HTTP/1.1 开始, 就默认是开启了 Keep-Alive,现在大多数浏览器都默认是使用 HTTP/1.1,所以 Keep-Alive 都是默认打开的。一旦客户端和服务端达成协议,那么长连接就建立好了。

如果要关闭 HTTP Keep-Alive,需要在 HTTP 请求或者响应的 header 里添加 Connection:close 信息,也就是说,只要客户端和服务端任意一方的 HTTP header 中有 Connection:close 信息,那么就无法使用 HTTP 长连接的机制。

關閉 HTTP 長連接機制后,每次請求都要經歷這樣的過程:建立 TCP -> 請求資源 -> 回應資源 -> 釋放連接,那麼此方式就是 HTTP 短連接,如下圖:

圖片

HTTP 短連接

在前面我們知道,只要任意一方的 HTTP header 中有 Connection:close 資訊,就無法使用 HTTP 長連接機制,這樣在完成一次 HTTP 請求/處理後,就會關閉連接。

問題來了,這時候是客戶端還是服務端主動關閉連接呢?

在 RFC 文件中,並沒有明確由誰來關閉連接,請求和回應的雙方都可以主動關閉 TCP 連接。

不過,根據大多數 Web 服務的實現,不管哪一方禁用了 HTTP Keep-Alive,都是由服務端主動關閉連接,那麼此時服務端上就會出現 TIME_WAIT 狀態的連接。

用戶端禁用了 HTTP Keep-Alive,服務端開啟 HTTP Keep-Alive,誰是主動關閉方?

當用戶端禁用了 HTTP Keep-Alive,這時候 HTTP 請求的 header 就會有 Connection:close 資訊,這時服務端在發完 HTTP 回應後,就會主動關閉連接。

為什麼要這麼設計呢? HTTP 是請求-回應模型,發起方一直是用戶端,HTTP Keep-Alive 的初衷是為客戶端後續的請求重用連接,如果我們在某次 HTTP 請求-回應模型中,請求的 header 定義了 connection:close 資訊,那不再重用這個連接的時機就只有在服務端了,所以我們在 HTTP 請求-回應這個週期的「末端」關閉連接是合理的。

客戶端開啟了 HTTP Keep-Alive,服務端禁用了 HTTP Keep-Alive,誰是主動關閉方?

當客戶端開啟了 HTTP Keep-Alive,而服務端禁用了 HTTP Keep-Alive,這時服務端在發完 HTTP 回應後,服務端也會主動關閉連接。

為什麼要這麼設計呢? 在服務端主動關閉連接的情況下,只要調用一次 close() 就可以釋放連接,剩下的工作由內核 TCP 棧直接進行了處理,整個過程只有一次 syscall; 如果是要求 用戶端關閉,則服務端在寫完最後一個 response 之後需要把這個 socket 放入 readable 佇列,調用 select / epoll 去等待事件; 然後調用一次 read() 才能知道連接已經被關閉,這其中是兩次 syscall,多一次用戶態程式被啟動執行,而且 socket 保持時間也會更長。

因此,當服務端出現大量的 TIME_WAIT 狀態連接的時候,可以排查下是否用戶端和服務端都開啟了 HTTP Keep-Alive,因為任意一方沒有開啟 HTTP Keep-Alive,都會導致服務端在處理完一個 HTTP 請求後,就主動關閉連接,此時服務端上就會出現大量的 TIME_WAIT 狀態的連接。

針對這個場景下,解決的方式也很簡單,讓用戶端和服務端都開啟 HTTP Keep-Alive 機制。

第二個場景:HTTP 長連接超時

HTTP 長連接的特點是,只要任意一端沒有明確提出斷開連接,則保持 TCP 連接狀態。

HTTP 長連接可以在同一個 TCP 連接上接收和發送多個 HTTP 請求/應答,避免了連接建立和釋放的開銷。

圖片

可能有的同學會問,如果使用了 HTTP 長連接,如果用戶端完成一個 HTTP 請求後,就不再發起新的請求,此時這個 TCP 連接一直佔用著不是挺浪費資源的嗎?

對沒錯,所以為了避免資源浪費的情況,web 服務軟體一般都會提供一個參數,用來指定 HTTP 長連接的超時時間,比如 nginx 提供的 keepalive_timeout 參數。

假設設置了 HTTP 長連接的超時時間是 60 秒,nginx 就會啟動一個「定時器」,如果用戶端在完後一個 HTTP 請求後,在 60 秒內都沒有再發起新的請求,定時器的時間一到,nginx 就會觸發回調函數來關閉該連接,那麼此時服務端上就會出現 TIME_WAIT 狀態的連接。

圖片

HTTP 長連接超時

當服務端出現大量 TIME_WAIT 狀態的連接時,如果現象是有大量的用戶端建立完 TCP 連接后,很長一段時間沒有發送數據,那麼大概率就是因為 HTTP 長連接超時,導致服務端主動關閉連接,產生大量處於 TIME_WAIT 狀態的連接。

可以往網路問題的方向排查,比如是否是因為網路問題,導致用戶端發送的數據一直沒有被服務端接收到,以至於 HTTP 長連接超時。

第三個場景:HTTP 長連接的請求數量達到上限

Web 服務端通常會有個參數,來定義一條 HTTP 長連接上最大能處理的請求數量,當超過最大限制時,就會主動關閉連接。

比如 nginx 的 keepalive_requests 這個參數,這個參數是指一個 HTTP 長連接建立之後,nginx 就會為這個連接設置一個計數器,記錄這個 HTTP 長連接上已經接收並處理的用戶端請求的數量。 如果達到這個參數設置的最大值時,則 nginx 會主動關閉這個長連接,那麼此時服務端上就會出現 TIME_WAIT 狀態的連接。

keepalive_requests 參數的預設值是 100 ,意味著每個 HTTP 長連接最多只能跑 100 次請求,這個參數往往被大多數人忽略,因為當 QPS (每秒請求數) 不是很高時,預設值 100 湊合夠用。

但是,對於一些 QPS 比較高的場景,比如超過 10000 QPS,甚至達到 30000 , 50000 甚至更高,如果 keepalive_requests 參數值是 100,這時候就 nginx 就會很頻繁地關閉連接,那麼此時服務端上就會出大量的 TIME_WAIT 狀態。

針對這個場景下,解決的方式也很簡單,調大 nginx 的 keepalive_requests 參數就行。

TIME_WAIT 狀態過多有什麼危害?

過多的 TIME-WAIT 狀態主要的危害有兩種:

第一是占用系统资源,比如文件描述符、内存资源、CPU 资源等;

第二是占用端口资源,端口资源也是有限的,一般可以开启的端口为32768~61000,也可以通过 net.ipv4.ip_local_port_range参数指定范围。

客户端和服务端 TIME_WAIT 过多,造成的影响是不同的。

如果客户端(主动发起关闭连接方)的 TIME_WAIT 状态过多,占满了所有端口资源,那么就无法对「目的 IP+ 目的 PORT」都一样的服务端发起连接了,但是被使用的端口,还是可以继续对另外一个服务端发起连接的。具体可以看我这篇文章:客户端的端口可以重复使用吗?

因此,客户端(发起连接方)都是和「目的 IP+ 目的 PORT 」都一样的服务端建立连接的话,当客户端的 TIME_WAIT 状态连接过多的话,就会受端口资源限制,如果占满了所有端口资源,那么就无法再跟「目的 IP+ 目的 PORT」都一样的服务端建立连接了。

不过,即使是在这种场景下,只要连接的是不同的服务端,端口是可以重复使用的,所以客户端还是可以向其他服务端发起连接的,这是因为内核在定位一个连接的时候,是通过四元组(源IP、源端口、目的IP、目的端口)信息来定位的,并不会因为客户端的端口一样,而导致连接冲突。

如果服务端(主动发起关闭连接方)的 TIME_WAIT 状态过多,并不会导致端口资源受限,因为服务端只监听一个端口,而且由于一个四元组唯一确定一个 TCP 连接,因此理论上服务端可以建立很多连接,但是 TCP 连接过多,会占用系统资源,比如文件描述符、内存资源、CPU 资源等。

如何优化 TIME_WAIT 状态?

这里给出优化 TIME-WAIT 的几个方式,都是有利有弊:

  • 打开 net.ipv4.tcp_tw_reuse 和 net.ipv4.tcp_timestamps 选项;
  • net.ipv4.tcp_max_tw_buckets
  • 程序中使用 SO_LINGER ,应用强制使用 RST 关闭。

方式一:net.ipv4.tcp_tw_reuse 和 tcp_timestamps

开启 tcp_tw_reuse,则可以复用处于 TIME_WAIT 的 socket 为新的连接所用。

有一点需要注意的是,tcp_tw_reuse 功能只能用客户端(连接发起方),因为开启了该功能,在调用 connect() 函数时,内核会随机找一个 time_wait 状态超过 1 秒的连接给新的连接复用。

net.ipv4.tcp_tw_reuse = 1
  • 1.

使用这个选项,还有一个前提,需要打开对 TCP 时间戳的支持,即

net.ipv4.tcp_timestamps=1(默认即为 1)
  • 1.

这个时间戳的字段是在 TCP 头部的「选项」里,它由一共 8 个字节表示时间戳,其中第一个 4 字节字段用来保存发送该数据包的时间,第二个 4 字节字段用来保存最近一次接收对方发送到达数据的时间。

由于引入了时间戳,可以使得重复的数据包会因为时间戳过期被自然丢弃,因此 TIME_WAIT 状态才可以被复用。

方式二:net.ipv4.tcp_max_tw_buckets

这个值默认为 18000,当系统中处于 TIME_WAIT 的连接一旦超过这个值时,系统就会将后面的 TIME_WAIT 连接状态重置,这个方法比较暴力。

net.ipv4.tcp_max_tw_buckets = 18000
  • 1.

方式三:程序中使用 SO_LINGER

我们可以通过设置 socket 选项,来设置调用 close 关闭连接行为。

struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
  • 1.
  • 2.
  • 3.
  • 4.

如果l_onoff为非 0, 且l_linger值为 0,那么调用close后,会立该发送一个RST标志给对端,该 TCP 连接将跳过四次挥手,也就跳过了TIME_WAIT状态,直接关闭。

但这为跨越TIME_WAIT状态提供了一个可能,不过是一个非常危险的行为,不值得提倡。

前面介绍的方法都是试图越过 TIME_WAIT状态的,这样其实不太好。虽然 TIME_WAIT 状态持续的时间是有一点长,显得很不友好,但是它被设计来就是用来避免发生乱七八糟的事情。

《UNIX网络编程》一书中却说道:TIME_WAIT 是我们的朋友,它是有助于我们的,不要试图避免这个状态,而是应该弄清楚它。

如果服务端要避免过多的 TIME_WAIT 状态的连接,就永远不要主动断开连接,让客户端去断开,由分布在各处的客户端去承受 TIME_WAIT。

服务端出现大量 CLOSE_WAIT 状态的原因有哪些?

还是拿这张图:

图片

TCP 四次挥手的流程

从上面这张图我们可以得知,CLOSE_WAIT 状态是「被动关闭方」才会有的状态,而且如果「被动关闭方」没有调用 close 函数关闭连接,那么就无法发出 FIN 报文,从而无法使得 CLOSE_WAIT 状态的连接转变为 LAST_ACK 状态。

所以,当服务端出现大量 CLOSE_WAIT 状态的连接的时候,说明服务端的程序没有调用 close 函数关闭连接。

那什么情况会导致服务端的程序没有调用 close 函数关闭连接?这时候通常需要排查代码。

我们先来分析一个普通的 TCP 服务端的流程:

  • 创建服务端 socket,bind 绑定端口、listen 监听端口
  • 将服务端 socket 注册到 epoll
  • epoll_wait 等待连接到来,连接到来时,调用 accpet 获取已连接的 socket
  • 将已连接的 socket 注册到 epoll
  • epoll_wait 等待事件发生
  • 对方连接关闭时,我方调用 close

可能导致服务端没有调用 close 函数的原因,如下。

第一个原因:第 2 步没有做,没有将服务端 socket 注册到 epoll,这样有新连接到来时,服务端没办法感知这个事件,也就无法获取到已连接的 socket,那服务端自然就没机会对 socket 调用 close 函数了。

不过这种原因发生的概率比较小,这种属于明显的代码逻辑 bug,在前期 read view 阶段就能发现的了。

第二个原因:第 3 步没有做,有新连接到来时没有调用 accpet 获取该连接的 socket,导致当有大量的客户端主动断开了连接,而服务端没机会对这些 socket 调用 close 函数,从而导致服务端出现大量 CLOSE_WAIT 状态的连接。

發生這種情況可能是因為服務端在執行 accpet 函數之前,代碼卡在某一個邏輯或者提前拋出了異常。

第三個原因:第 4 步沒有做,通過 accpet 獲取已連接的 socket 後,沒有將其註冊到 epoll,導致後續收到 FIN 報文的時候,服務端沒辦法感知這個事件,那服務端就沒有機會調用 close 函數了。

發生這種情況可能是因為服務端在將已連接的socket註冊到 epoll 之前,代碼卡在某一個邏輯或者提前拋出了異常。 之前看到過別人解決 close_wait 問題的實踐文章,感興趣的可以看看:一次 Netty 代碼不健壯導致的大量 CLOSE_WAIT 連接原因分析

第四個原因:第 6 步沒有做,當發現用戶端關閉連接后,服務端沒有執行 close 函數,可能是因為代碼漏處理,或者是在執行 close 函數之前,代碼卡在某一個邏輯,比如發生死鎖等等。

可以發現,當服務端出現大量 CLOSE_WAIT 狀態的連接的時候,通常都是代碼的問題,這時候我們需要針對具體的代碼一步一步的進行排查和定位,主要分析的方向就是服務端為什麼沒有調用 close。