redis實踐及思考

騰訊技術工程發表於2019-08-22

導語:當面臨儲存選型時是選擇關係型還是非關係型資料庫? 如果選擇了非關係型的redis,redis常用資料型別佔用記憶體大小如何估算的? redis的效能瓶頸又在哪裡?

背景
前段時間接手了一個業務,響應時間達到 10s左右 閱讀原始碼後發現,每一次請求都是查詢多個分表資料(task1,task2….),然後再join其他表(course,teacher..), 時間全部花在了大量磁碟I/O上 腦袋一拍,重構,上redis!
為什麼選擇redis
拍腦袋做技術方案肯定是不行的,得用資料和邏輯說服別人才可以。

時延

時延=後端發起請求db(使用者態複製請求到核心態)+ 網路時延 + 資料庫定址和讀取
如果想要降低時延,只能減少請求數(合併多個後端請求)和減少資料庫定址和讀取得時間。 從降低時延的角度,基於 單執行緒和記憶體的redis,每秒10萬次得讀寫效能肯定遠遠勝過磁碟讀寫效能。

資料規模

以redis一組K-V為例(”hello” -> “world”),一個簡單的set命令最終會產生4個消耗記憶體的結構。

redis實踐及思考

關於Redis資料儲存的細節,又要涉及到記憶體分配器(如jemalloc),簡單說就是儲存170位元組,其實記憶體分配器會分配192位元組儲存。

redis實踐及思考

那麼總的花費就是

  • 一個dictEntry,24位元組,jemalloc會分配32位元組的記憶體塊

  • 一個redisObject,16位元組,jemalloc會分配16位元組的記憶體塊

  • 一個key,5位元組,所以SDS(key)需要5+9=14個位元組,jemalloc會分配16位元組的記憶體塊

  • 一個value,5位元組,所以SDS(value)需要5+9=14個位元組,jemalloc會分配16位元組的記憶體塊

綜上,一個dictEntry需要32+16+16+16=80個位元組。

上面這個演算法只是舉個例子,想要更深入計算出redis所有資料結構的記憶體大小,可以參考 這篇文章
筆者使用的是雜湊結構,這個業務需求大概一年的資料量是200MB,從使用redis成本上考慮沒有問題。

需求特點

筆者這個需求背景讀多寫少,冷資料佔比比較大,但資料結構又很複雜(涉及多個維度資料總和),因此只要啟動定時任務離線增量寫入redis,請求到達時直接讀取redis中的資料,無疑可以減少響應時間。

redis實踐及思考
[ 最終方案 ]

redis瓶頸和最佳化

HGETALL

最終儲存到redis中的資料結構如下圖。

redis實踐及思考


採用同步的方式對三個月(90天)進行HGETALL操作,每一天花費30ms,90次就是2700ms! redis操作讀取應該是ns級別的,怎麼會這麼慢? 利用多核cpu計算會不會更快?

redis實踐及思考


常識告訴我,redis指令執行速度 >> 網路通訊(內網) > read/write等系統呼叫。 因此這裡其實是I/O密集型場景,就算利用多核cpu,也解決不到根本的問題,最終影響redis效能, **其實是網路卡收發資料 使用者態核心態資料複製 **

pipeline

這個需求qps很小,所以網路卡也不是瓶頸了,想要把需求最佳化到1s以內,減少I/O的次數是關鍵。 換句話說, 充分利用頻寬,增大系統吞吐量。

於是我把程式碼改了一版,原來是90次I/O,現在透過redis pipeline操作,一次請求半個月,那麼3個月就是6次I/O。 很開心,時間一下子少了1000ms。

redis實踐及思考

redis實踐及思考

pipeline攜帶的命令數

程式碼寫到這裡,我不經反問自己,為什麼一次pipeline攜帶15個HGETALL命令,不是30個,不是40個? 換句話說,一次pipeline攜帶多少個HGETALL命令才會發起一次I/O?

我使用是golang的 redisgo  的客戶端,翻閱原始碼發現,redisgo執行pipeline邏輯是 把命令和引數寫到golang原生的bufio中,如果超過bufio預設最大值(4096位元組),就發起一次I/O,flush到核心態。

redis實踐及思考

redisgo編碼pipeline規則 如下圖, *表示後面引數加命令的個數,$表示後面的字元長度 ,一條HGEALL命令實際佔45位元組。

那其實90天資料,一次I/O就可以搞定了(90 * 45 < 4096位元組)!

redis實踐及思考

果然,又快了1000ms,耗費時間達到了1秒以內

redis實踐及思考

對吞吐量和qps的取捨

筆者需求任務算是完成了,可是再進一步思考,redis的pipeline一次性帶上多少HGETALL操作的key才是合理的呢? 換句話說,伺服器吞吐量大了,可能就會導致qps急劇下降(網路卡大量收發資料和redis內部協議解析,redis命令排隊堆積,從而導致的緩慢),而想要qps高,伺服器吞吐量可能就要降下來,無法很好的利用頻寬。
對兩者之間的取捨,同樣是不能拍腦袋決定的,用壓測資料說話!

簡單寫了一個壓測程式,透過比較請求量和qps的關係,來看一下吞吐量和qps的變化,從而選擇一個適合業務需求的值。

package main
import (
    "crypto/rand"
    "fmt"
    "math/big"
    "strconv"
    "time"
    "github.com/garyburd/redigo/redis"
)
const redisKey = "redis_test_key:%s"
func main() {
    for i := 1; i < 10000; i++ {
        testRedisHGETALL(getPreKeyAndLoopTime(i))
    }
}
func testRedisHGETALL(keyList [][]string) {
    Conn, err := redis.Dial("tcp", "127.0.0.1:6379")
    if err != nil {
        fmt.Println(err)
        return
    }
    costTime := int64(0)
    start := time.Now().Unix()
    for _, keys := range keyList {
        for _, key := range keys {
            Conn.Send("HGETALL", fmt.Sprintf(redisKey, key))
        }
        Conn.Flush()
    }
    end := time.Now().Unix()
    costTime = end - start
    fmt.Printf("cost_time=[%+v]ms,qps=[%+v],keyLen=[%+v],totalBytes=[%+v]",
        1000*int64(len(keyList))/costTime, costTime/int64(len(keyList)), len(keyList), len(keyList)*len(keyList[0])*len(redisKey))
}
//根據key的長度,設定不同的迴圈次數,平均計算,取除網路延遲帶來的影響
func getPreKeyAndLoopTime(keyLen int) [][]string {
    loopTime := 1000
    if keyLen < 10 {
        loopTime *= 100
    } else if keyLen < 100 {
        loopTime *= 50
    } else if keyLen < 500 {
        loopTime *= 10
    } else if keyLen < 1000 {
        loopTime *= 5
    }
    return generateKeys(keyLen, loopTime)
}
func generateKeys(keyLen, looTime int) [][]string {
    keyList := make([][]string, 0)
    for i := 0; i < looTime; i++ {
        keys := make([]string, 0)
        for i := 0; i < keyLen; i++ {
            result, _ := rand.Int(rand.Reader, big.NewInt(100))
            keys = append(keys, strconv.FormatInt(result.Int64(), 10))
        }
        keyList = append(keyList, keys)
    }
    return keyList
}
windows上單機版redis結果如下:
redis實踐及思考

擴充套件 (分散式方案下pipeline操作)
需求最終是完成了,可是轉念一想,現在都是叢集版的redis,pipeline批次請求的key可能分佈在不同的機器上,但pipeline請求最終可能只被一臺redis server處理,那不就是會讀取資料失敗嗎? 於是,筆者查詢幾個通用的redis 分散式方案,看看他們是如何處理這pipeline問題的。

redis cluster

redis cluster 是官方給出的分散式方案。 Redis Cluster在設計中沒有使用一致性雜湊,而是使用資料分片(Sharding)引入雜湊槽(hash slot)來實現。 一個 Redis Cluster包含16384(0~16383)個雜湊槽,儲存在Redis Cluster中的所有鍵都會被對映到這些slot中,叢集中的每個鍵都屬於這16384個雜湊槽中的一個,叢集使用公式slot=CRC16 key/16384來計算key屬於哪個槽。 比如redis cluster有5個節點,每個節點就負責一部分雜湊槽, 如果引數的多個key在不同的slot,在不同的主機上,那麼必然會出錯。

因此redis cluster分散式方案是不支援pipeline操作,如果想要做,只有客戶端快取slot和redis節點的關係,在批次請求時,就透過key算出不同的slot以及redis節點,並行的進行pipeline。

github.com/go-redis就是這樣做的,有興趣可以閱讀下原始碼。

redis實踐及思考

codis

市面上還流行著一種在客戶端和服務端之間增設代理的方案,比如codis就是這樣。 對於上層應用來說,連線 Codis-Proxy 和直接連線 原生的 Redis-Server 沒有的區別,也就是說codis-proxy會幫你做上面並行分槽請求redis server,然後合併結果在一起的操作,對於使用者來說無感知。
總結
在做需求的過程中,發現了很多東西不能拍腦袋決定,而是前期做技術方案的時候,想清楚,調研好,用資料和邏輯去說服自己。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/31559354/viewspace-2654502/,如需轉載,請註明出處,否則將追究法律責任。

相關文章