redis实践及思考
導(dǎo)語:當(dāng)面臨存儲選型時是選擇關(guān)系型還是非關(guān)系型數(shù)據(jù)庫?如果選擇了非關(guān)系型的redis,redis常用數(shù)據(jù)類型占用內(nèi)存大小如何估算的?redis的性能瓶頸又在哪里?
前段時間接手了一個業(yè)務(wù),響應(yīng)時間達(dá)到10s左右。閱讀源碼后發(fā)現(xiàn),每一次請求都是查詢多個分表數(shù)據(jù)(task1,task2….),然后再join其他表(course,teacher..),時間全部花在了大量磁盤I/O上。腦袋一拍,重構(gòu),上redis!
拍腦袋做技術(shù)方案肯定是不行的,得用數(shù)據(jù)和邏輯說服別人才可以。
時延
時延=后端發(fā)起請求db(用戶態(tài)拷貝請求到內(nèi)核態(tài))+ 網(wǎng)絡(luò)時延 + 數(shù)據(jù)庫尋址和讀取
如果想要降低時延,只能減少請求數(shù)(合并多個后端請求)和減少數(shù)據(jù)庫尋址和讀取得時間。從降低時延的角度,基于單線程和內(nèi)存的redis,每秒10萬次得讀寫性能肯定遠(yuǎn)遠(yuǎn)勝過磁盤讀寫性能。
數(shù)據(jù)規(guī)模
以redis一組K-V為例(”hello” -> “world”),一個簡單的set命令最終會產(chǎn)生4個消耗內(nèi)存的結(jié)構(gòu)。
關(guān)于Redis數(shù)據(jù)存儲的細(xì)節(jié),又要涉及到內(nèi)存分配器(如jemalloc),簡單說就是存儲170字節(jié),其實內(nèi)存分配器會分配192字節(jié)存儲。
那么總的花費就是
一個dictEntry,24字節(jié),jemalloc會分配32字節(jié)的內(nèi)存塊
一個redisObject,16字節(jié),jemalloc會分配16字節(jié)的內(nèi)存塊
一個key,5字節(jié),所以SDS(key)需要5+9=14個字節(jié),jemalloc會分配16字節(jié)的內(nèi)存塊
一個value,5字節(jié),所以SDS(value)需要5+9=14個字節(jié),jemalloc會分配16字節(jié)的內(nèi)存塊
綜上,一個dictEntry需要32+16+16+16=80個字節(jié)。
上面這個算法只是舉個例子,想要更深入計算出redis所有數(shù)據(jù)結(jié)構(gòu)的內(nèi)存大小,可以參考這篇文章。
筆者使用的是哈希結(jié)構(gòu),這個業(yè)務(wù)需求大概一年的數(shù)據(jù)量是200MB,從使用redis成本上考慮沒有問題。
需求特點
筆者這個需求背景讀多寫少,冷數(shù)據(jù)占比比較大,但數(shù)據(jù)結(jié)構(gòu)又很復(fù)雜(涉及多個維度數(shù)據(jù)總和),因此只要啟動定時任務(wù)離線增量寫入redis,請求到達(dá)時直接讀取redis中的數(shù)據(jù),無疑可以減少響應(yīng)時間。
[ 最終方案 ]
HGETALL
最終存儲到redis中的數(shù)據(jù)結(jié)構(gòu)如下圖。
采用同步的方式對三個月(90天)進行HGETALL操作,每一天花費30ms,90次就是2700ms!redis操作讀取應(yīng)該是ns級別的,怎么會這么慢?利用多核cpu計算會不會更快?
常識告訴我,redis指令執(zhí)行速度 >> 網(wǎng)絡(luò)通信(內(nèi)網(wǎng)) > read/write等系統(tǒng)調(diào)用。因此這里其實是I/O密集型場景,就算利用多核cpu,也解決不到根本的問題,最終影響redis性能,**其實是網(wǎng)卡收發(fā)數(shù)據(jù)和用戶態(tài)內(nèi)核態(tài)數(shù)據(jù)拷貝**。
pipeline
這個需求qps很小,所以網(wǎng)卡也不是瓶頸了,想要把需求優(yōu)化到1s以內(nèi),減少I/O的次數(shù)是關(guān)鍵。換句話說,充分利用帶寬,增大系統(tǒng)吞吐量。
于是我把代碼改了一版,原來是90次I/O,現(xiàn)在通過redis pipeline操作,一次請求半個月,那么3個月就是6次I/O。很開心,時間一下子少了1000ms。
pipeline攜帶的命令數(shù)
代碼寫到這里,我不經(jīng)反問自己,為什么一次pipeline攜帶15個HGETALL命令,不是30個,不是40個?換句話說,一次pipeline攜帶多少個HGETALL命令才會發(fā)起一次I/O?
我使用是golang的redisgo?的客戶端,翻閱源碼發(fā)現(xiàn),redisgo執(zhí)行pipeline邏輯是 把命令和參數(shù)寫到golang原生的bufio中,如果超過bufio默認(rèn)最大值(4096字節(jié)),就發(fā)起一次I/O,flush到內(nèi)核態(tài)。
redisgo編碼pipeline規(guī)則如下圖,*表示后面參數(shù)加命令的個數(shù),$表示后面的字符長度,一條HGEALL命令實際占45字節(jié)。
那其實90天數(shù)據(jù),一次I/O就可以搞定了(90 * 45 < 4096字節(jié))!
果然,又快了1000ms,耗費時間達(dá)到了1秒以內(nèi)
對吞吐量和qps的取舍
筆者需求任務(wù)算是完成了,可是再進一步思考,redis的pipeline一次性帶上多少HGETALL操作的key才是合理的呢?換句話說,服務(wù)器吞吐量大了,可能就會導(dǎo)致qps急劇下降(網(wǎng)卡大量收發(fā)數(shù)據(jù)和redis內(nèi)部協(xié)議解析,redis命令排隊堆積,從而導(dǎo)致的緩慢),而想要qps高,服務(wù)器吞吐量可能就要降下來,無法很好的利用帶寬。
對兩者之間的取舍,同樣是不能拍腦袋決定的,用壓測數(shù)據(jù)說話!
簡單寫了一個壓測程序,通過比較請求量和qps的關(guān)系,來看一下吞吐量和qps的變化,從而選擇一個適合業(yè)務(wù)需求的值。
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)) } //根據(jù)key的長度,設(shè)置不同的循環(huán)次數(shù),平均計算,取除網(wǎng)絡(luò)延遲帶來的影響 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結(jié)果如下:
需求最終是完成了,可是轉(zhuǎn)念一想,現(xiàn)在都是集群版的redis,pipeline批量請求的key可能分布在不同的機器上,但pipeline請求最終可能只被一臺redis server處理,那不就是會讀取數(shù)據(jù)失敗嗎?于是,筆者查找?guī)讉€通用的redis 分布式方案,看看他們是如何處理這pipeline問題的。
redis cluster
redis cluster 是官方給出的分布式方案。 Redis Cluster在設(shè)計中沒有使用一致性哈希,而是使用數(shù)據(jù)分片(Sharding)引入哈希槽(hash slot)來實現(xiàn)。一個 Redis Cluster包含16384(0~16383)個哈希槽,存儲在Redis Cluster中的所有鍵都會被映射到這些slot中,集群中的每個鍵都屬于這16384個哈希槽中的一個,集群使用公式slot=CRC16 key/16384來計算key屬于哪個槽。比如redis cluster有5個節(jié)點,每個節(jié)點就負(fù)責(zé)一部分哈希槽,如果參數(shù)的多個key在不同的slot,在不同的主機上,那么必然會出錯。
因此redis cluster分布式方案是不支持pipeline操作,如果想要做,只有客戶端緩存slot和redis節(jié)點的關(guān)系,在批量請求時,就通過key算出不同的slot以及redis節(jié)點,并行的進行pipeline。
github.com/go-redis就是這樣做的,有興趣可以閱讀下源碼。
codis
市面上還流行著一種在客戶端和服務(wù)端之間增設(shè)代理的方案,比如codis就是這樣。對于上層應(yīng)用來說,連接 Codis-Proxy 和直接連接 原生的 Redis-Server 沒有的區(qū)別,也就是說codis-proxy會幫你做上面并行分槽請求redis server,然后合并結(jié)果在一起的操作,對于使用者來說無感知。
在做需求的過程中,發(fā)現(xiàn)了很多東西不能拍腦袋決定,而是前期做技術(shù)方案的時候,想清楚,調(diào)研好,用數(shù)據(jù)和邏輯去說服自己。
相關(guān)閱讀
深入淺出百億請求高可用Redis(codis)分布式集群揭秘
總結(jié)
以上是生活随笔為你收集整理的redis实践及思考的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 从头到脚说单测——谈有效的单元测试
- 下一篇: 我是程序员,每一天都太难了!