本文介紹了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)系忘衍?通常有兩種策略:
- 如果服務(wù)端是單線程/進(jìn)程地處理每個連接逾苫,那服務(wù)端天然就是以請求的順序依次響應(yīng)的,客戶端接收到的響應(yīng)順序和請求順序是一致的枚钓,不需要特殊處理铅搓;
- 如果服務(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ā)下的性能畴栖。