TIME_WAIT引起Cannot assign requested address報錯

1.  問題描述

     有時候用redis客戶端(php或者java客戶端)連接Redis服務器,報錯:“Cannot assign requested address。”

     原因是客戶端頻繁的連接服務器,由於每次連接都在很短時間內結束,導致很多的TIME_WAIT。所以新的連接沒辦法綁定端口,即“Cannot assign requested address”。

     我們可以通過netstat -nat | grep 127.0.0.1:6380 查看連接127.0.0.1:6380的狀態。你會發現很多TIME_WAIT。

     很多人想到要用修改內核參數來解決:

     執行命令修改如下2個內核參數  
     sysctl -w net.ipv4.tcp_timestamps=1  開啓對於TCP時間戳的支持,若該項設置爲0,則下面一項設置不起作用
     sysctl -w net.ipv4.tcp_tw_recycle=1  表示開啓TCP連接中TIME-WAIT sockets的快速回收

     其實不然,根本沒有理解出現這個問題的本質原因。首先我們瞭解Redis處理客戶端連接的機制和TCP的TIME_WAIT.

 

2.  Redis處理客戶端連接機制

(參考:http://redis.io/topics/clients)

1、建立連接(TCP連接):

      Redis 通過監聽一個 TCP 端口或者 Unix socket 的方式來接收來自客戶端的連接,當一個連接建立後,Redis 內部會進行以下一些操作:

首先,客戶端 socket 會被設置爲非阻塞模式,因爲 Redis 在網絡事件處理上採用的是非阻塞多路複用模型。
     然後爲這個socket 設置 TCP_NODELAY 屬性,禁用 Nagle 算法
     然後創建一個 readable 的文件事件用於監聽這個客戶端 socket 的數據發送

     當客戶端連接被初始化後,Redis 會查看目前的連接數,然後對比配置好的 maxclients 值,如果目前連接數已經達到最大連接數 maxclients 了,那麼說明這個連接不能再接收,Redis 會直接返回客戶端一個連接錯誤,並馬上關閉掉這個連接。

2、服務器處理順序

     如果有多個客戶端連接上 Redis,並且都向 Redis 發送命令,那麼 Redis 服務端會先處理哪個客戶端的請求呢?答案其實並不確定,主要與兩個因素有關,一是客戶端對應的 socket 對應的數字的大小,二是 kernal 報告各個客戶端事件的先後順序。

Redis 處理一個客戶端傳來數據的步驟如下:

  它對觸發事件的 socket 調用一次 read(),只讀一次(而不是把這個 socket 上的消息讀完爲止),是爲了防止由於某個別客戶端持續發送太多命令,導致其它客戶端的請求長時間得不到處理的情況。
當然,當這一次 read() 調用完成後,它裏面無論包含多少個命令,都會被一次性順序地執行。這樣就保證了對各個客戶端命令的公平對待。
3、關於最大連接數 maxclients

       在 Redis2.4 中,最大連接數是被直接硬編碼在代碼裏面的,而在2.6版本中這個值變成可配置的。maxclients 的默認值是 10000,你也可以在 redis.conf 中對這個值進行修改。

      當然,這個值只是 Redis 一廂情願的值,Redis 還會照顧到系統本身對進程使用的文件描述符數量的限制。在啓動時 Redis 會檢查系統的 soft limit,以查看打開文件描述符的個數上限。如果系統設置的數字,小於咱們希望的最大連接數加32,那麼這個 maxclients 的設置將不起作用,Redis 會按系統要求的來設置這個值。(加32是因爲 Redis 內部會使用最多32個文件描述符,所以連接能使用的相當於所有能用的描述符號減32)。

       當上面說的這種情況發生時(maxclients 設置後不起作用的情況),Redis 的啓動過程中將會有相應的日誌記錄。比如下面命令希望設置最大客戶端數量爲100000,所以 Redis 需要 100000+32 個文件描述符,而系統的最大文件描述符號設置爲10144,所以 Redis 只能將 maxclients 設置爲 10144 – 32 = 10112。

$ ./redis-server –maxclients 100000
[41422] 23 Jan 11:28:33.179 # Unable to set the max number of files limit to 100032 (Invalid argument), setting the max clients configuration to 10112.

        所以說當你想設置 maxclients 值時,最好順便修改一下你的系統設置,當然,養成看日誌的好習慣也能發現這個問題。

具體的設置方法就看你個人的需求了,你可以只修改此次會話的限制,也可以直接通過sysctl 修改系統的默認設置。如:

ulimit -Sn 100000 # This will only work if hard limit is big enough.
sysctl -w fs.file-max=100000

4、輸出緩衝區大小限制

       對於 Redis 的輸出(也就是命令的返回值)來說,其大小經常是不可控的,可能是一個簡單的命令,能夠產生體積龐大的返回數據。另外也有可能因爲執行命令太多,產生的返回數據的速率超過了往客戶端發送的速率,這時也會產生消息堆積,從而造成輸出緩衝區越來越大,佔用過多內存,甚至導致系統崩潰。

      所以 Redis 設置了一些保護機制來避免這種情況的出現,這些機制作用於不同種類的客戶端,有不同的輸出緩衝區大小限制,限制方式有兩種:

一種是大小限制,當某一個客戶端的緩衝區超過某一大小時,直接關閉掉這個客戶端連接
     另一種是當某一個客戶端的緩衝區持續一段時間佔用空間過大時,也直接關閉掉客戶端連接

對於不同客戶端的策略如下:

 對普通客戶端來說,限制爲0,也就是不限制,因爲普通客戶端通常採用阻塞式的消息應答模式,如:發送請求,等待返回,再發請求,再等待返回。這種模式通常不會導致輸出緩衝區的堆積膨脹。
       對於 Pub/Sub 客戶端來說,大小限制是32m,當輸出緩衝區超過32m時,會關閉連接。持續性限制是,當客戶端緩衝區大小持續60秒超過8m,也會導致連接關閉。
       而對於 Slave 客戶端來說,大小限制是256m,持續性限制是當客戶端緩衝區大小持續60秒超過64m時,關閉連接。

上面三種規則都是可配置的。可以通過 CONFIG SET 命令或者修改 redis.conf 文件來配置。

5、輸入緩衝區大小限制

      Redis 對輸入緩衝區大小的限制比較暴力,當客戶端傳輸的請求大小超過1G時,服務端會直接關閉連接。這種方式可以有效防止一些客戶端或服務端 bug 導致的輸入緩衝區過大的問題。

6、Client超時

      對當前的 Redis 版本來說,服務端默認是不會關閉長期空閒的客戶端的。但是你可以修改默認配置來設置你希望的超時時間。比如客戶端超過多長時間無交互,就直接關閉。同理,這也可以通過 CONFIG SET 命令或者修改 redis.conf 文件來配置。

      值得注意的是,超時時間的設置,只對普通客戶端起作用,對 Pub/Sub 客戶端來說,長期空閒狀態是正常的。

      另外,實際的超時時間可能不會像設定的那樣精確,這是因爲 Redis 並不會採用計時器或者輪訓遍歷的方法來檢測客戶端超時,而是通過一種漸近式的方式來完成,每次檢查一部分。所以導致的結果就是,可能你設置的超時時間是10s,但是真實執行的時間是超時12s後客戶端才被關閉。

 

3.  TCP的TIME_WAIT狀態

    主動關閉的Socket端會進入TIME_WAIT狀態,並且持續2MSL時間長度,MSL就是maximum segment lifetime(最大分節生命期),在windows下默認240秒,MSL是一個IP數據包能在互聯網上生存的最長時間,超過這個時間將在網絡中消失。MSL在RFC 1122上建議是2分鐘,而源自berkeley的TCP實現傳統上使用30秒,因而,TIME_WAIT狀態一般維持在1-4分鐘。

 

TIME_WAIT狀態存在的理由:

1)可靠地實現TCP全雙工連接的終止:(即在TIME_WAIT下等待2MSL,只是爲了盡最大努力保證四次握手正常關閉)。

      TCP協議規定,對於已經建立的連接,網絡雙方要進行四次握手才能成功斷開連接,如果缺少了其中某個步驟,將會使連接處於假死狀態,連接本身佔用的資源不會被釋放。

    在進行關閉連接四路握手協議時,最後的ACK是由主動關閉端發出的,如果這個最終的ACK丟失,服務器將重發最終的FIN,因此客戶端必須維護狀態信息允許它重發最終的ACK。如果不維持這個狀態信息,那麼客戶端將響應RST分節,因而,要實現TCP全雙工連接的正常終止,必須處理終止序列四個分節中任何一個分節的丟失情況,主動關閉的客戶端必須維持狀態信息進入TIME_WAIT狀態。

    我們看客戶端主動關閉服務器被動關閉四次握手的流程:

 

1、 客戶端發送FIN報文段,進入FIN_WAIT_1狀態。

2、 服務器端收到FIN報文段,發送ACK表示確認,進入CLOSE_WAIT狀態。

3、 客戶端收到FIN的確認報文段,進入FIN_WAIT_2狀態。

4、 服務器端發送FIN報文端,進入LAST_ACK狀態。

5、 客戶端收到FIN報文端,發送FIN的ACK,同時進入TIME_WAIT狀態,啓動TIME_WAIT定時器,超時時間設爲2MSL。

6、 服務器端收到FIN的ACK,進入CLOSED狀態。

7、 客戶端在2MSL時間內沒收到對端的任何響應,TIME_WAIT超時,進入CLOSED狀態。

      如果不考慮報文延遲、丟失,確認延遲、丟失等情況,TIME_WAIT的確沒有存在的必要。當網絡在不理想的情況下通常會有報文的丟失延遲發生,讓我們看下面的一個特例:

     客戶端進入發送收到四次握手關閉的最後一個ACK後,進入TIME_WAIT同時發送ACK,如果其不停留2MSL時間,而是馬上關閉連接,銷燬連接上的資源,當發送如下情況時,將不能正常的完成四次握手關閉:

客戶端發送的ACK在網路上丟失,這樣服務器端收不到最後的ACK,重傳定時器超時,將重傳FIN到客戶端,由於客戶端關於該連接的所有資源都釋放,收到重傳的FIN後,它沒有關於這個FIN的任何信息,所以向服務器端發送一個RST報文端,服務器端收到RST後,認爲搞連接出現了異常(而非正常關閉)。

所以,在TIME_WAIT狀態下等待2MSL時間端,是爲了能夠正確處理第一個ACK(最長生存時間爲MSL)丟失的情況下,能夠收到對端重傳的FIN(最長生存時間爲MSL),然後重傳ACK。

     是否只要主動關閉方在TIME_WAIT狀態下停留2MSL,四次握手關閉就一定正常完成呢?

     答案是否定的?可以考慮如下的情況, 

     TIME_WAIT狀態下發送的ACK丟失,LAST_ACK時刻設定的重傳定時器超時,發送重傳的FIN,很不幸,這個FIN也丟失,主動關閉方在TIME_WAIT狀態等待2MSL沒收到任何報文段,進入CLOSED狀態,當此時被動關閉方並沒有收到最後的ACK。所以即使要主動關閉方在TIME_WAIT狀態下停留2MSL,也不一定表示四次握手關閉就一定正常完成。

2)確保老的報文段在網絡中消失,不會影響新建立的連接 

        考慮如下的情況,主動關閉方在TIME_WAIT狀態下發送的ACK由於網絡延遲的原因沒有按時到底(但並沒有超過MSL的時間),導致被動關閉方重傳FIN,在FIN重傳後,延遲的ACK到達,被動關閉方進入CLOSED狀態,如果主動關閉方在TIME_WAIT狀態下發送ACK後馬上進入CLOSED狀態(也就是沒有等待)2MSL時間,則上述的連接已不存在:

       現在考慮下面的情況,假設客戶端(192.186.0.1:23) 到服務器192.168.1.1:6380)的TCP連接, 由於連接已關閉,我們可以馬上建立一個相同的IP地址和端口之間的TCP連接,並且這個連接也是客戶端(192.186.0.1:23) 到服務器192.168.1.1:6380),那麼當上一個連接的重傳FIN到達主動關閉方時,被新的連接所接受,這將導致新的連接被複位,很顯然,這不是我們希望看到的事情。

       新的連接要建立,必須是在主動關閉方和被動關閉方都進入到CLOSED狀態之後纔有可能。所以,最有可能導致舊的報文段影響新的連接的情況是:

      在TIME_WAIT狀態之前,主動關閉方發送的報文端在網絡中延遲,但是TIME_WAIT設定爲2MSL時,這些報文端必然會在網絡中消失(最大生存時間爲MSL)。被動關閉方最有可能影響新連接的報文段就是我們上面討論的情況,對方ACK延遲到達,在此之前重傳的FIN,這個報文端發送之後,TIME_WAIT的定時器超時時間肯定大於MSL,在1MSL時間內,這個FIN要麼在網絡中因爲生成時間到達而消失,要麼到達主動關閉方被這確的處理,不會影響新建立的連接。

    新的SCTP協議通過在消息頭部添加驗證標誌避免了TIME_WAIT狀態。

3)有關內核級別的keepalive和time_wait的優化調整

有關內核級別的keepalive和time_wait的優化調整
vi /etc/sysctl
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_tw_recycle = 1
net.ipv4.tcp_keepalive_time = 1800
net.ipv4.tcp_fin_timeout = 30
net.core.netdev_max_backlog =8096

修改完記的使用sysctl -p 讓它生效
以上參數的註解
/proc/sys/net/ipv4/tcp_tw_reuse
該文件表示是否允許重新應用處於TIME-WAIT狀態的socket用於新的TCP連接。

/proc/sys/net/ipv4/tcp_tw_recycle
recyse是加速TIME-WAIT sockets回收

對tcp_tw_reuse和tcp_tw_recycle的修改,可能會出現.warning, got duplicate tcp line warning, got BOGUS tcp line.上面這二個參數指的是存在這兩個完全一樣的TCP連接,這會發生在一個連接被迅速的斷開並且重新連接的情況,而且使用的端口和地址相同。但基本 上這樣的事情不會發生,無論如何,使能上述設置會增加重現機會。這個提示不會有人和危害,而且也不會降低系統性能,目前正在進行工作

/proc/sys/net/ipv4/tcp_keepalive_time
表示當keepalive起用的時候,TCP發送keepalive消息的頻度。缺省是2小時

/proc/sys/net/ipv4/tcp_fin_timeout 最佳值和BSD一樣爲30
fin_wait1狀態是在發起端主動要求關閉tcp連接,並且主動發送fin以後,等待接收端回覆ack時候的狀態。對於本端斷開的socket連接,TCP保持在FIN-WAIT-2狀態的時間。對方可能會斷開連接或一直不結束連接或不可預料的進程死亡。

/proc/sys/net/core/netdev_max_backlog
該文件指定了,在接口接收數據包的速率比內核處理這些包的速率快時,允許送到隊列的數據包的最大數目 

4)time_wait的優化處理

Linux系統中TCP是面向連接的,在實際應用中通常都需要檢測連接是否還可用.如果不可用,可分爲:

a. 連接的對端正常關閉.

b. 連接的對端非正常關閉,這包括對端設備掉電,程序崩潰,網絡被中斷等.這種情況是不能也無法通知對端的,所以連接會一直存在,浪費國家的資源.

TCP協議棧有個keepalive的屬性,可以主動探測socket是否可用,不過這個屬性的默認值很大.

全局設置可更改/etc/sysctl.conf,加上:

net.ipv4.tcp_keepalive_intvl = 20
net.ipv4.tcp_keepalive_probes = 3
net.ipv4.tcp_keepalive_time = 60

在程序中設置如下:

int keepAlive = 1; // 開啓keepalive屬性
int keepIdle = 60; // 如該連接在60秒內沒有任何數據往來,則進行探測
int keepInterval = 5; // 探測時發包的時間間隔爲5 秒
int keepCount = 3; // 探測嘗試的次數.如果第1次探測包就收到響應了,則後2次的不再發.

setsockopt(rs, SOL_SOCKET, SO_KEEPALIVE, (void *)&keepAlive, sizeof(keepAlive));
setsockopt(rs, SOL_TCP, TCP_KEEPIDLE, (void*)&keepIdle, sizeof(keepIdle));
setsockopt(rs, SOL_TCP, TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval));
setsockopt(rs, SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount));

 

4.  解決問題

    我們瞭解Redis處理客戶端連接的機制和TCP的TIME_WAIT.我們可以重現上述問題,我們快速建立2000個連接,

<?php  
$num = 2000;  
for($i=0; $i<$num; $i++) {  
    $redis = new Redis();  
    $redis->connect('127.0.0.1',6379);  
    //sleep(1);  
}  
sleep(10);

然後查看狀態:netstat -nat | grep 127.0.0.1:6379你會發現很多TIME_WAIT。

如果$num加大到40000或者,報錯:Cannot assign requested address。

    因此如果客戶端(php)連接redis出現這個問題,說明你程序出現bug了。你某個循環裏面實例化Redis了(即每次都new Redis),造成每一次循環都建立一個連接。

   解決這個問題不是修改內核參數,而是把連接redis封裝成單實例,確保在同一進程內,連接redis是唯一實例。

class Class_Redis {  
  
    private $_redis;  
    private static $_instance = null;  
      
    private  function __construct() {  
        $this->_redis = new Redis();  
        $this->_redis->connect('127.0.0.1',6379);  
  
    }  
      
    public static function getInstance() {  
        if(self::$_instance === null) {  
            self::$_instance = new self();  
        }  
        return self::$_instance;  
      
    }  
  
      
    public  function getRedis() {  
        return $this->_redis;  
    }  
  
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章