TCP長連接實踐與挑戰(zhàn)

本文介紹了tcp長連接在實際工程中的實踐過程馋劈,并總結(jié)了tcp連接焙趵颍活遇到的挑戰(zhàn)以及對應(yīng)的解決方案咐鹤。

作者:字節(jié)跳動終端技術(shù) ——— 陳圣坤

概述

眾所周知瘟则,作為傳輸層通信協(xié)議绣夺,TCP是面向連接設(shè)計的尘盼,所有請求之前需要先通過三次握手建立一個連接谈飒,請求結(jié)束后通過四次揮手關(guān)閉連接猾昆。通常我們使用TCP連接或者基于TCP連接之上的應(yīng)用層協(xié)議例如HTTP 1.0等觅够,都會為每次請求建立一次連接胶背,請求結(jié)束即關(guān)閉連接。這樣的好處是實現(xiàn)簡單喘先,不用維護(hù)連接狀態(tài)钳吟。但對于大量請求的場景下,頻繁創(chuàng)建窘拯、關(guān)閉連接可能會帶來大量的開銷红且。因此這種場景通常的做法是保持長連接坝茎,一次請求后連接不關(guān)閉,下次再對該端點發(fā)起的請求直接復(fù)用該連接暇番,例如HTTP 1.1及HTTP 2.0都是這么做的景东。然而在工程實踐中會發(fā)現(xiàn),實現(xiàn)TCP長連接并不像想象的那么簡單奔誓,本文總結(jié)了實現(xiàn)TCP長連接時遇到的挑戰(zhàn)和解決方案斤吐。

事實上TCP協(xié)議本身并沒有規(guī)定請求完成時要關(guān)閉連接,也就是說TCP本身就是長連接的厨喂,直到有一方主動關(guān)閉連接為止和措。實現(xiàn)TCP連接遇到的挑戰(zhàn)主要有兩個:連接池和連接保活蜕煌。

連接池

長連接意味著連接是復(fù)用的派阱,每次請求完連接不關(guān)閉,下次請求繼續(xù)使用該連接斜纪。如果請求是串行的贫母,那完全沒有問題。但在并發(fā)場景下盒刚,所有請求都需要使用該連接腺劣,為了保證連接的狀態(tài)正確,加鎖不可避免因块,如果連接只有一個橘原,就意味著所有請求都需要排隊等待。因此長連接通常意味著連接池的存在:連接池中將保留一定數(shù)量的連接不關(guān)閉涡上,有請求時從池中取出可用的連接趾断,請求結(jié)束將連接返回池中。

用go實現(xiàn)一個簡單的連接池(參考《Go語言實戰(zhàn)》):

import (
    "errors"
    "io"
    "sync"
)

type Pool struct {
    m         sync.Mutex
    resources chan io.Closer
    closed    bool
}

func (p *Pool) Acquire() (io.Closer, error) {
    r, ok := <-p.resources
    if !ok {
        return nil, errors.New("pool has been closed")
    }
    return r, nil
}

func (p *Pool) Release(r io.Closer) {
    p.m.Lock()
    defer p.m.Unlock()

    if p.closed {
        r.Close()
        return
    }
    
    select {
    case p.resources <- r:
    default:
        // pool is full , just close
        r.Close()
    }
}

func (p *Pool) Close() error {
    p.m.Lock()
    defer p.m.Unlock()
    if p.closed {
        return nil
    }
    
    p.closed = true
    close(p.resources)
    for r := range p.resources {
        if err := r.Close(); err != nil {
            return err
        }
    }

    return nil
}

func New(fn func() (io.Closer, error), size uint) (*Pool, error) {
    if size <= 0 {
        return nil, errors.New("size too small")
    }

    res := make(chan io.Closer, size)
    for i := 0; i < int(size); i++ {
        c, err := fn()
        if err != nil {
            return nil, err
        }

        res <- c
    }

    return &Pool{
        resources: res,
    }, nil
}

池的對象只需實現(xiàn)io.Closer接口即可吩愧,利用go緩沖通道的特性可以輕松地實現(xiàn)連接池:獲取連接時從通道中接收一個對象芋酌,釋放連接時將該對象發(fā)送到連接池中。由于go的通道本身就是goroutine安全的雁佳,因此不需要額外加鎖脐帝。Pool使用的鎖是為了保證Release操作和Close操作的并發(fā)安全,防止連接池在關(guān)閉的同時再釋放連接甘穿,造成預(yù)期外的錯誤腮恩。

連接池經(jīng)常遇到的一個問題就是池大小的控制:過大的連接池會帶來資源的浪費梢杭,同時對服務(wù)端也會帶來連接壓力温兼;過小的連接池在高并發(fā)場景下會限制并發(fā)性能。通常的解決辦法是延遲創(chuàng)建和設(shè)置空閑時間武契,延遲創(chuàng)建是指連接只在請求到來時才創(chuàng)建募判,空閑時間是指連接在一定時間內(nèi)未被使用則將被主動關(guān)閉荡含。這樣日常情況下連接池控制在較小的尺度,當(dāng)并發(fā)請求量較大時會為新的請求創(chuàng)建新的連接届垫,這些連接在請求完畢后返還連接池释液,其中的大部分會在閑置一定時間后被主動關(guān)閉,這樣就做到了并發(fā)性能和IO資源之間較好的平衡装处。

連接蔽笳活

長連接的第二個問題就是連接保活的問題妄迁。雖然TCP協(xié)議并沒有限制一個連接可以保持多久寝蹈,理論上只要不關(guān)閉連接,連接就一直存在登淘。但事實上由于NAT等網(wǎng)絡(luò)設(shè)備的存在箫老,一個連接即使沒有主動關(guān)閉,它也不會一直存活黔州。

NAT

NAT(Network Address Translation)是一種被廣泛應(yīng)用的網(wǎng)絡(luò)設(shè)備耍鬓,直觀地解釋就是進(jìn)行網(wǎng)絡(luò)地址轉(zhuǎn)換,通過一定策略對tcp包的源ip流妻、源端口牲蜀、目的ip和目的端口進(jìn)行替換∩鹫猓可以說各薇,NAT有效緩解了ipv4地址緊缺的問題,雖然理論上ipv4早已耗盡君躺,但正由于NAT設(shè)備的存在峭判,ipv4的壽命超出了所預(yù)計的時間。公司內(nèi)部的網(wǎng)絡(luò)也是通過NAT構(gòu)建起來的棕叫。

雖然NAT有如此的優(yōu)點林螃,但它也帶來了一些新的問題,對TCP長連接的影響就是其中之一俺泣。我們將一個通過NAT連接的網(wǎng)絡(luò)簡化成下面的模型:


A如果想保持對B的長連接疗认,它實際并不與B直接建立連接,而是與NAT A建立長連接伏钠,而NAT A又與NAT B横漏、NAT B與B建立長連接。如果NAT設(shè)備任由下面的機(jī)器保持連接不關(guān)閉熟掂,那它很容易就耗盡所能支持的連接數(shù)缎浇,因此NAT設(shè)備會定時關(guān)閉一定時間內(nèi)沒有數(shù)據(jù)包的連接,并且它不會通知網(wǎng)絡(luò)的雙方赴肚。這就是為什么我們有時候會遇到這種錯誤:

error: read tcp4 1.1.1.1:8888->2.2.2.2:9999: i/o timeout

按照TCP的設(shè)計素跺,連接有一方要關(guān)閉連接時會有“四次揮手”的過程二蓝,通過一個關(guān)閉的連接發(fā)送數(shù)據(jù)時會拋出Broken pipe的錯誤。但NAT關(guān)閉連接時并不通知連接雙方指厌,發(fā)送方不知道連接已關(guān)閉刊愚,會繼續(xù)通過該連接發(fā)送數(shù)據(jù),并且不會拋出Broken pipe的錯誤踩验,而接收方也不知道連接已關(guān)閉鸥诽,還會持續(xù)監(jiān)聽該連接。這樣發(fā)送方請求能成功發(fā)送箕憾,但接收方無法接收到該請求衙传,因此發(fā)送方自然也等不到接收方的響應(yīng),就會阻塞至接口超時厕九。經(jīng)過實踐發(fā)現(xiàn)公司的NAT超時是一個小時蓖捶,也就是保持連接不關(guān)閉并閑置一個小時后,再通過該連接發(fā)送請求時扁远,就會出現(xiàn)上述timeout的錯誤俊鱼。

我們上面提到連接池大小的控制問題,其實看起來有點類似NAT的超時控制畅买,那既然我們允許連接池關(guān)閉超時的閑置連接并闲,為什么不能接受NAT設(shè)備關(guān)閉呢?答案就是上面提到的谷羞,NAT設(shè)備關(guān)閉連接時并未通知連接雙方帝火,因此客戶端使用連接請求時并不知道該連接實際上是否可用,而如果是由連接池主動關(guān)閉連接湃缎,那它自然知道連接是否是可用的犀填。

Keepalive

通過上面的描述我們就知道怎么解決了,既然NAT會關(guān)閉一定時間內(nèi)沒有數(shù)據(jù)包的連接嗓违,那我們只需要讓這個連接定時自動發(fā)送一個小數(shù)據(jù)包九巡,就能保證連接不會被NAT自動關(guān)閉。

實際上TCP協(xié)議中就包含了一個keepalive機(jī)制:如果keepalive開關(guān)被打開蹂季,在一段時間(泵峁悖活時間:tcp_keepalive_time) 內(nèi)此連接不活躍,開啟背ソ啵活功能的一端會向?qū)Χ税l(fā)送一個比龊海活探測報文。只要我們保證這個tcp_keepalive_time小于NAT的超時時間涕滋,這個探測報文的存在就能保證NAT設(shè)備不會關(guān)閉我們的連接睬辐。

unix系統(tǒng)為TCP開發(fā)封裝的socket接口通常都有keepalive的相關(guān)設(shè)置,以go語言為例:

conn, _ := net.DialTCP("tcp4", nil, tcpAddr)

_ = conn.SetKeepAlive(true)

_ = conn.SetKeepAlivePeriod(5 * time.Minute)

另一個常見的保活機(jī)制是HTTP協(xié)議的keep-alive溉委,不同于TCP協(xié)議,HTTP 1.0設(shè)計上默認(rèn)是不支持長連接的爱榕,服務(wù)器響應(yīng)完立即斷開連接瓣喊,通過請求頭中的設(shè)置“connection: keep-alive”保持TCP連接不斷開(HTTP 1.1以后默認(rèn)開啟)。

流水線控制

盡管使用連接池一定程度上能平衡好并發(fā)性能和io資源黔酥,但在高并發(fā)下性能還是不夠理想藻三,這是因為可能有上百個請求都在等同一個連接,每個請求都需要等待上一個請求返回后才能發(fā)出:

這樣無疑是低效的跪者,我們不妨參考HTTP協(xié)議的流水線設(shè)計棵帽,也就是請求不必等待上一個請求返回才能發(fā)出,一個TCP長連接會按順序連續(xù)發(fā)出一系列請求渣玲,等到請求發(fā)送成功后再統(tǒng)一按順序接收所有的返回結(jié)果:


這樣無疑能大大減少網(wǎng)絡(luò)的等待時間逗概,提高并發(fā)性能。隨之而來的一個顯而易見的問題是如何保證響應(yīng)和請求的正確對應(yīng)關(guān)系忘衍?通常有兩種策略:


  1. 如果服務(wù)端是單線程/進(jìn)程地處理每個連接逾苫,那服務(wù)端天然就是以請求的順序依次響應(yīng)的,客戶端接收到的響應(yīng)順序和請求順序是一致的枚钓,不需要特殊處理铅搓;
  2. 如果服務(wù)端是并發(fā)地處理每個連接上的所有請求(例如將請求入隊列,然后并發(fā)地消費隊列搀捷,經(jīng)典的如redis)星掰,那就無法保證響應(yīng)的順序與請求順序一致,這時就需要修改客戶端與服務(wù)端的通信協(xié)議嫩舟,在請求與響應(yīng)的數(shù)據(jù)結(jié)構(gòu)中帶上獨一無二的序號氢烘,通過匹配這個序號來確定響應(yīng)和請求之間的映射關(guān)系;

HTTP 2.0實現(xiàn)了一個多路復(fù)用的機(jī)制家厌,其實可以看成是這種流水線的優(yōu)化威始,它的響應(yīng)與請求的映射關(guān)系就是通過流ID來保證的。

總結(jié)

以上就是對TCP長連接實踐中遇到的挑戰(zhàn)和解決思路的總結(jié)像街,結(jié)合筆者在公司內(nèi)部的實踐經(jīng)驗分別探討了連接池黎棠、連接保活和流水線控制等問題镰绎,梳理了實現(xiàn)TCP長連接經(jīng)常遇到的問題脓斩,并提出了解決思路,在降低頻繁創(chuàng)建連接的開銷的同時盡可能地保證高并發(fā)下的性能畴栖。

參考

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市燎猛,隨后出現(xiàn)的幾起案子恋捆,更是在濱河造成了極大的恐慌,老刑警劉巖重绷,帶你破解...
    沈念sama閱讀 222,000評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件沸停,死亡現(xiàn)場離奇詭異,居然都是意外死亡昭卓,警方通過查閱死者的電腦和手機(jī)愤钾,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,745評論 3 399
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來候醒,“玉大人能颁,你說我怎么就攤上這事〉挂” “怎么了伙菊?”我有些...
    開封第一講書人閱讀 168,561評論 0 360
  • 文/不壞的土叔 我叫張陵,是天一觀的道長敌土。 經(jīng)常有香客問我占业,道長,這世上最難降的妖魔是什么纯赎? 我笑而不...
    開封第一講書人閱讀 59,782評論 1 298
  • 正文 為了忘掉前任谦疾,我火速辦了婚禮,結(jié)果婚禮上犬金,老公的妹妹穿的比我還像新娘念恍。我一直安慰自己,他們只是感情好晚顷,可當(dāng)我...
    茶點故事閱讀 68,798評論 6 397
  • 文/花漫 我一把揭開白布峰伙。 她就那樣靜靜地躺著,像睡著了一般该默。 火紅的嫁衣襯著肌膚如雪瞳氓。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 52,394評論 1 310
  • 那天栓袖,我揣著相機(jī)與錄音匣摘,去河邊找鬼。 笑死裹刮,一個胖子當(dāng)著我的面吹牛音榜,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播捧弃,決...
    沈念sama閱讀 40,952評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼赠叼,長吁一口氣:“原來是場噩夢啊……” “哼擦囊!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起嘴办,我...
    開封第一講書人閱讀 39,852評論 0 276
  • 序言:老撾萬榮一對情侶失蹤瞬场,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后涧郊,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體贯被,經(jīng)...
    沈念sama閱讀 46,409評論 1 318
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,483評論 3 341
  • 正文 我和宋清朗相戀三年底燎,在試婚紗的時候發(fā)現(xiàn)自己被綠了刃榨。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片弹砚。...
    茶點故事閱讀 40,615評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡双仍,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出桌吃,到底是詐尸還是另有隱情朱沃,我是刑警寧澤,帶...
    沈念sama閱讀 36,303評論 5 350
  • 正文 年R本政府宣布茅诱,位于F島的核電站逗物,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏瑟俭。R本人自食惡果不足惜翎卓,卻給世界環(huán)境...
    茶點故事閱讀 41,979評論 3 334
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望摆寄。 院中可真熱鬧失暴,春花似錦、人聲如沸微饥。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,470評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽欠橘。三九已至矩肩,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間肃续,已是汗流浹背黍檩。 一陣腳步聲響...
    開封第一講書人閱讀 33,571評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點兒被人妖公主榨干…… 1. 我叫王不留始锚,地道東北人建炫。 一個月前我還...
    沈念sama閱讀 49,041評論 3 377
  • 正文 我出身青樓,卻偏偏與公主長得像疼蛾,于是被迫代替她去往敵國和親肛跌。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,630評論 2 359

推薦閱讀更多精彩內(nèi)容