Golang你一定要懂的連線池實現
問題引入
作為一名Golang開發者,線上環境遇到過好幾次連線數暴增問題(mysql/redis/kafka等)。
糾其原因,Golang作為常駐程序,請求第三方服務或者資源完畢後,需要手動關閉連線,否則連線會一直存在。而很多時候,開發者不一定記得關閉這個連線。
這樣是不是很麻煩?於是有了連線池。顧名思義,連線池就是管理連線的;我們從連線池獲取連線,請求完畢後再將連線還給連線池;連線池幫我們做了連線的建立、複用以及回收工作。
在設計與實現連線池時,我們通常需要考慮以下幾個問題:
- 連線池的連線數目是否有限制,最大可以建立多少個連線?
- 當連線長時間沒有使用,需要回收該連線嗎?
- 業務請求需要獲取連線時,此時若連線池無空閒連線且無法新建連線,業務需要排隊等待嗎?
- 排隊的話又存在另外的問題,佇列長度有無限制,排隊時間呢?
Golang連線池實現原理
我們以Golang HTTP連線池為例,分析連線池的實現原理。
結構體Transport
Transport結構定義如下:
type Transport struct { //操作空閒連線需要獲取鎖 idleMu sync.Mutex //空閒連線池,key為協議目標地址等組合 idleConn map[connectMethodKey][]*persistConn // most recently used at end //等待空閒連線的佇列,基於切片實現,佇列大小無限制 idleConnWait map[connectMethodKey]wantConnQueue // waiting getConns //排隊等待建立連線需要獲取鎖 connsPerHostMu sync.Mutex //每個host建立的連線數 connsPerHost map[connectMethodKey]int //等待建立連線的佇列,同樣基於切片實現,佇列大小無限制 connsPerHostWait map[connectMethodKey]wantConnQueue // waiting getConns //最大空閒連線數 MaxIdleConns int //每個目標host最大空閒連線數;預設為2(注意預設值) MaxIdleConnsPerHost int //每個host可建立的最大連線數 MaxConnsPerHost int //連線多少時間沒有使用則被關閉 IdleConnTimeout time.Duration //禁用長連線,使用短連線 DisableKeepAlives bool }
可以看到,連線護著佇列,都是一個map結構,而key為協議目標地址等組合,即同一種協議與同一個目標host可建立的連線或者空閒連線是有限制的。
需要特別注意的是,MaxIdleConnsPerHost預設等於2,即與目標主機最多隻維護兩個空閒連線。這會導致什麼呢?
如果遇到突發流量,瞬間建立大量連線,但是回收連線時,由於最大空閒連線數的限制,該聯機不能進入空閒連線池,只能直接關閉。結果是,一直新建大量連線,又關閉大量連,業務機器的TIME_WAIT連線數隨之突增。
線上有些業務架構是這樣的:客戶端 ===> LVS ===> Nginx ===> 服務。LVS負載均衡方案採用DR模式,LVS與Nginx配置統一VIP。此時在客戶端看來,只有一個IP地址,只有一個Host。上述問題更為明顯。
最後,Transport也提供了配置DisableKeepAlives,禁用長連線,使用短連線訪問第三方資源或者服務。
連接獲取與回收
Transport結構提供下面兩個方法實現連線的獲取與回收操作。
func (t *Transport) getConn(treq *transportRequest,cm connectMethod) (pc *persistConn,err error) {} func (t *Transport) tryPutIdleConn(pconn *persistConn) error {}
連線的獲取主要分為兩步走:1)嘗試獲取空閒連線;2)嘗試新建連線:
//getConn方法內部實現 if delivered := t.queueForIdleConn(w); delivered { return pc,nil } t.queueForDial(w)
當然,可能獲取不到連線而需要排隊,此時怎麼辦呢?當前會阻塞當前協程了,直到獲取連線為止,或者httpclient超時取消請求:
select { case <-w.ready: return w.pc,w.err //超時被取消 case <-req.Cancel: return nil,errRequestCanceledConn …… } var errRequestCanceledConn = errors.New("net/http: request canceled while waiting for connection") // TODO: unify?
排隊等待空閒連線的邏輯如下:
func (t *Transport) queueForIdleConn(w *wantConn) (delivered bool) { //如果配置了空閒超時時間,獲取到連線需要檢測,超時則關閉連線 if t.IdleConnTimeout > 0 { oldTime = time.Now().Add(-t.IdleConnTimeout) } if list,ok := t.idleConn[w.key]; ok { for len(list) > 0 && !stop { pconn := list[len(list)-1] tooOld := !oldTime.IsZero() && pconn.idleAt.Round(0).Before(oldTime) //超時了,關閉連線 if tooOld { go pconn.closeConnIfStillIdle() } //分發連線到wantConn delivered = w.tryDeliver(pconn,nil) } } //排隊等待空閒連線 q := t.idleConnWait[w.key] q.pushBack(w) t.idleConnWait[w.key] = q }
排隊等待新建連線的邏輯如下:
func (t *Transport) queueForDial(w *wantConn) { //如果沒有限制最大連線數,直接建立連線 if t.MaxConnsPerHost <= 0 { go t.dialConnFor(w) return } //如果沒超過連線數限制,直接建立連線 if n := t.connsPerHost[w.key]; n < t.MaxConnsPerHost { go t.dialConnFor(w) return } //排隊等待連線建立 q := t.connsPerHostWait[w.key] q.pushBack(w) t.connsPerHostWait[w.key] = q }
連線建立完成後,同樣會呼叫tryDeliver分發連線到wantConn,同時關閉通道w.ready,這樣主協程糾接觸阻塞了。
func (w *wantConn) tryDeliver(pc *persistConn,err error) bool { w.pc = pc close(w.ready) }
請求處理完成後,通過tryPutIdleConn將連線放回連線池;這時候如果存在等待空閒連線的協程,則需要分發複用該連線。另外,在回收連線時,還需要校驗空閒連線數目是否超過限制:
func (t *Transport) tryPutIdleConn(pconn *persistConn) error { //禁用長連線;或者最大空閒連線數不合法 if t.DisableKeepAlives || t.MaxIdleConnsPerHost < 0 { return errKeepAlivesDisabled } if q,ok := t.idleConnWait[key]; ok { //如果等待佇列不為空,分發連線 for q.len() > 0 { w := q.popFront() if w.tryDeliver(pconn,nil) { done = true break } } } //空閒連線數目超過限制,預設為DefaultMaxIdleConnsPerHost=2 idles := t.idleConn[key] if len(idles) >= t.maxIdleConnsPerHost() { return errTooManyIdleHost } }
空閒連線超時關閉
Golang HTTP連線池如何實現空閒連線的超時關閉邏輯呢?從上述queueForIdleConn邏輯可以看到,每次在獲取到空閒連線時,都會檢測是否已經超時,超時則關閉連線。
那如果沒有業務請求到達,一直不需要獲取連線,空閒連線就不會超時關閉嗎?其實在將空閒連線新增到連線池時,Golang同時還設定了定時器,定時器到期後,自然會關閉該連線。
pconn.idleTimer = time.AfterFunc(t.IdleConnTimeout,pconn.closeConnIfStillIdle)
排隊佇列怎麼實現
怎麼實現佇列模型呢?很簡單,可以基於切片:
queue []*wantConn //入隊 queue = append(queue,w) //出隊 v := queue[0] queue[0] = nil queue = queue[1:]
這樣有什麼問題嗎?隨著頻繁的入隊與出隊操作,切片queue的底層陣列,會有大量空間無法複用而造成浪費。除非該切片執行了擴容操作。
Golang在實現佇列時,使用了兩個切片head和tail;head切片用於出隊操作,tail切片用於入隊操作;出隊時,如果head切片為空,則交換head與tail。通過這種方式,Golang實現了底層陣列空間的複用。
func (q *wantConnQueue) pushBack(w *wantConn) { q.tail = append(q.tail,w) } func (q *wantConnQueue) popFront() *wantConn { if q.headPos >= len(q.head) { if len(q.tail) == 0 { return nil } // Pick up tail as new head,clear tail. q.head,q.headPos,q.tail = q.tail,q.head[:0] } w := q.head[q.headPos] q.head[q.headPos] = nil q.headPos++ return w }
到此這篇關於Golang你一定要懂的連線池實現的文章就介紹到這了,更多相關Golang 連線池內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!