如何在 Go 語言中使用 Redis 連線池

極客頭條發表於2016-02-20

一、關於連線池

一個資料庫伺服器只擁有有限的資源,並且如果你沒有充分使用這些資源,你可以通過使用更多的連線來提高吞吐量。一旦所有的資源都在使用,那麼你就不能通過增加更多的連線來提高吞吐量。事實上,吞吐量在連線負載較大時就開始下降了。通常可以通過限制與可用的資源相匹配的資料庫連線的數量來提高延遲和吞吐量。

如果不使用連線池,那麼,每次傳輸資料,我們都需要進行建立連線,收發資料,關閉連線。在併發量不高的場景,基本上不會有什麼問題,一旦併發量上去了,那麼,一般就會遇到下面幾個常見問題:

  • 效能普遍上不去
  • 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 pool
func (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 重啟等情況,也能保證連線自動重連。

相關文章