這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
一、關於串連池
一個資料庫伺服器只擁有有限的資源,並且如果你沒有充分使用這些資源,你可以通過使用更多的串連來提高輸送量。一旦所有的資源都在使用,那麼你就不能通過增加更多的串連來提高輸送量。事實上,輸送量在串連負載較大時就開始下降了。通常可以通過限制與可用的資源相匹配的資料庫連接的數量來提高延遲和輸送量。
如果不使用串連池,那麼,每次傳輸資料,我們都需要進行建立串連,收發資料,關閉串連。在並發量不高的情境,基本上不會有什麼問題,一旦並發量上去了,那麼,一般就會遇到下面幾個常見問題:
- 效能普遍上不去
- CPU 大量資源被系統消耗
- 網路一旦抖動,會有大量 TIME_WAIT 產生,不得不定期重啟服務或定期重啟機器
- 伺服器工作不穩定,QPS 忽高忽低
要想解決這些問題,我們就要用到串連池了。串連池的思路很簡單,在初始化時,建立一定數量的串連,先把所有長串連存起來,然後,誰需要使用,從這裡取走,幹完活立馬放回來。 如果請求數超出串連池容量,那麼就排隊等待、退化成短串連或者直接丟棄掉。
二、使用串連池遇到的坑
最近在一個項目中,需要實現一個簡單的 Web Server 提供 Redis 的 HTTP interface,提供 JSON 形式的返回結果。考慮用 Go 來實現。
首先,去看一下 Redis 官方推薦的 Go Redis driver。官方 Star 的項目有兩個:Radix.v2 和 Redigo。經過簡單的比較後,選擇了更加輕量級和實現更加優雅的 Radix.v2。
Radix.v2 包是根據功能劃分成一個個的 sub package,每一個 sub package 在一個獨立的子目錄中,結構非常清晰。我的項目中會用到的 sub package 有 redis 和 pool。
由於我想讓這種被 fork 的進程最好簡單點,做的事情單一一些,所以,在沒有深入去看 Radix.v2 的 pool 的實現之前,我選擇了自己實現一個 Redis pool。(這裡,就不貼代碼了。後來發現自己實現的 Redis pool 與 Radix.v2 實現的 Redis pool 的原理是一樣的,都是基於 channel 實現的, 遇到的問題也是一樣的。)
不過在測試過程中,發現了一個詭異的問題。在請求過程中經常會報 EOF 錯誤。而且是機率性出現,一會有問題,一會又好了。通過反覆的測試,發現 bug 是有規律的,當程式空閑一會後,再進行連續請求,會發生3次失敗,然後之後的請求都能成功,而我的串連池大小設定的是3。再進一步分析,程式空閑300秒後,再請求就會失敗,發現我的 Redis server 配置了 timeout 300,至此,問題就清楚了。是連線逾時 Redis server 主動斷開了串連。用戶端這邊從一個逾時的串連請求就會得到 EOF 錯誤。
然後我看了一下 Radix.v2 的 pool 包的源碼,發現這個庫本身並沒有檢測壞的串連,並替換為新的串連的機制。也就是說我每次從串連池裡面 Get 的串連有可能是壞的串連。所以,我當時臨時的解決方案是通過增加失敗後自動重試來解決了。不過,這樣的處理方案,串連池的作用好像就沒有了。技術債能早點還的還是早點還上。
三、使用串連池的正確姿勢
想到我們的 ngx_lua 項目裡面也大量使用 redis 串連池,他們怎麼沒有遇到這個問題呢。只能去看看源碼了。
經過抽象分離, ngx_lua 裡面使用 redis 串連池部分的代碼大致是這樣的:
server { location /pool { content_by_lua_block { local redis = require "resty.redis" local red = redis:new() local ok, err = red:connect("127.0.0.1", 6379) if not ok then ngx.say("failed to connect: ", err) return end ok, err = red:set("hello", "world") if not ok then return end red:set_keepalive(10000, 100) } }}
發現有個 set_keepalive 的方法,查了一下官方文檔,方法的原型是 syntax: ok, err = red:set_keepalive(max_idle_timeout, pool_size) 貌似 max_idle_timeout 這個參數,就是我們所缺少的東西,然後進一步跟蹤源碼,看看裡面是怎麼保證串連有效。
function _M.set_keepalive(self, ...) local sock = self.sock if not sock then return nil, "not initialized" end if self.subscribed then return nil, "subscribed state" end return sock:setkeepalive(...)end
至此,已經清楚了,使用了 tcp 的 keepalive 心跳機制。
於是,通過與 Radix.v2 的作者一些討論,選擇自己在 redis 這層使用心跳機制,來解決這個問題。
四、最後的解決方案
在建立串連池之後,起一個 goroutine,每隔一段 idleTime 發送一個 PING 到 Redis server。其中,idleTime 略小於 Redis server 的 timeout 配置。
串連池初始化部分代碼如下:
p, err := pool.New("tcp", u.Host, concurrency)errHndlr(err)go func() { for { p.Cmd("PING") time.Sleep(idelTime * time.Second) }}()
使用 redis 傳輸資料部分代碼如下:
func redisDo(p *pool.Pool, cmd string, args ...interface{}) (reply *redis.Resp, err error) { reply = p.Cmd(cmd, args...) if err = reply.Err; err != nil { if err != io.EOF { Fatal.Println("redis", cmd, args, "err is", err) } } return}
其中,Radix.v2 串連池內部進行了串連池內串連的擷取和放回,代碼如下:
// Cmd automatically gets one client from the pool, executes the given command// (returning its result), and puts the client back in the poolfunc (p *Pool) Cmd(cmd string, args ...interface{}) *redis.Resp { c, err := p.Get() if err != nil { return redis.NewResp(err) } defer p.Put(c) return c.Cmd(cmd, args...)}
這樣,我們就有了 keepalive 的機制,不會出現 timeout 的串連了,從 redis 串連池裡面取出的串連都是可用的串連了。看似簡單的代碼,卻完美的解決了串連池裡面逾時串連的問題。同時,就算 Redis server 重啟等情況,也能保證串連自動重連。
本文是來自又拍雲的技術投稿。(責編/錢曙光)