ecache輕量級(jí)本地內(nèi)存緩存
ecache 是一款極簡(jiǎn)設(shè)計(jì)、高性能、并發(fā)安全、支持分布式一致性的內(nèi)存緩存。
特性
基準(zhǔn)性能
如何使用
下載包(預(yù)計(jì)5秒)
非go modules模式:
sh>go get -u github.com/orca-zhang/ecache
go modules模式:
sh>go mod tidy && go mod download
引入包(預(yù)計(jì)5秒)
import ( "time" "github.com/orca-zhang/ecache" )
定義實(shí)例(預(yù)計(jì)5秒)
可以放置在任意位置(全局也可以),建議就近定義
var c = ecache.NewLRUCache(16, 200, 10 * time.Second)
設(shè)置緩存(預(yù)計(jì)5秒)
c.Put("uid1", o) // o可以是任意變量,一般是對(duì)象指針,存放固定的信息,比如*UserInfo
查詢緩存(預(yù)計(jì)5秒)
if v, ok := c.Get("uid1"); ok { return v.(*UserInfo) // 不用類型斷言,咱們自己控制類型 } // 如果內(nèi)存緩存沒(méi)有查詢到,下面再回源查redis/db
刪除緩存(預(yù)計(jì)5秒)
在信息發(fā)生變化的地方
c.Del("uid1")
運(yùn)行吧
?? 完美搞定 ?? 性能直接提升X倍!
sh>go run <你的main.go文件>
參數(shù)說(shuō)明
-
NewLRUCache- 第一個(gè)參數(shù)是桶的個(gè)數(shù),用來(lái)分散鎖的粒度,每個(gè)桶都會(huì)使用獨(dú)立的鎖
- 不用擔(dān)心,隨意設(shè)置一個(gè)就好,
ecache會(huì)找一個(gè)等于或者略大于輸入大小的2的冪次的數(shù)字,后面便于掩碼計(jì)算
- 不用擔(dān)心,隨意設(shè)置一個(gè)就好,
- 第二個(gè)參數(shù)是每個(gè)桶所能容納的item個(gè)數(shù)上限
- 意味著
ecache全部寫(xiě)滿的情況下,應(yīng)該有第一個(gè)參數(shù)??第二個(gè)參數(shù)個(gè)item
- 意味著
- 第三個(gè)參數(shù)是每個(gè)item的過(guò)期時(shí)間
-
ecache使用內(nèi)部定時(shí)器提升性能,默認(rèn)100ms精度,每秒校準(zhǔn)
-
- 第一個(gè)參數(shù)是桶的個(gè)數(shù),用來(lái)分散鎖的粒度,每個(gè)桶都會(huì)使用獨(dú)立的鎖
最佳實(shí)踐
- 復(fù)雜對(duì)象優(yōu)先存放指針(注意??一旦放進(jìn)去不要再修改其字段,即使再拿出來(lái)也是,item有可能被其他人同時(shí)訪問(wèn))
- 如果需要修改,解決方案:取出字段每個(gè)單獨(dú)賦值,或者用copier做一次深拷貝后在副本上修改
- 也可以存放對(duì)象(相對(duì)于上一個(gè)性能差一些,因?yàn)槟贸鋈ビ锌截悾?/li>
- 緩存的對(duì)象盡可能越往業(yè)務(wù)上層越大越好(節(jié)省內(nèi)存拼裝和組織時(shí)間)
- 如果不想因?yàn)轭愃票闅v的請(qǐng)求把熱數(shù)據(jù)刷掉,可以改用
LRU-2模式,雖然可能有很少的損耗(?? 什么是LRU-2) - 一個(gè)實(shí)例可以存儲(chǔ)多種類型的對(duì)象,試試key格式化的時(shí)候加上前綴,用冒號(hào)分割
- 并發(fā)訪問(wèn)量大的場(chǎng)景,試試
256、1024個(gè)桶,甚至更多
特別場(chǎng)景
LRU-2模式
- ?? 什么是LRU-2
直接在
NewLRUCache()后面跟.LRU2(<num>)就好,參數(shù)<num>代表LRU-2熱隊(duì)列的item上限個(gè)數(shù)(每個(gè)桶)
var c = ecache.NewLRUCache(16, 200, 10 * time.Second).LRU2(1024)
空緩存哨兵(不存在的對(duì)象不用再回源)
// 設(shè)置的時(shí)候直接給`nil`就好 c.Put("uid1", nil)
// 讀取的時(shí)候,也和正常差不多 if v, ok := c.Get("uid1"); ok { if v == nil { // 注意??這里需要判斷是不是空緩存哨兵 return nil // 是空緩存哨兵,那就返回沒(méi)有信息或者也可以讓`uid1`不出現(xiàn)在待回源列表里 } return v.(*UserInfo) } // 如果內(nèi)存緩存沒(méi)有查詢到,下面再回源查redis/db
需要修改部分?jǐn)?shù)據(jù),且用對(duì)象指針?lè)绞酱鎯?chǔ)時(shí)
比如,我們從
ecache中獲取了*UserInfo類型的用戶信息緩存v,需要修改其狀態(tài)字段
import ( "github.com/jinzhu/copier" )
o := &UserInfo{} copier.Copy(o, v) // 從v復(fù)制到o o.Status = 1 // 修改副本的數(shù)據(jù)
統(tǒng)計(jì)緩存使用情況
實(shí)現(xiàn)超級(jí)簡(jiǎn)單,注入inspector后,每個(gè)操作只多了一次原子操作,具體看代碼
引入stats包
import ( "github.com/orca-zhang/ecache/stats" )
綁定緩存實(shí)例(名稱為自定義的池子名稱,內(nèi)部會(huì)按名稱聚合)
var _ = stats.Bind("user", c) var _ = stats.Bind("user", c, c1, c2) var _ = stats.Bind("room", caches...)
打印統(tǒng)計(jì)信息
stats.Stats().Range(func(k, v interface{}) bool { fmt.Printf("stats: %s %+v\n", k, v) return true })
分布式一致性組件
- ?? 原理說(shuō)明
引入dist包
import ( "github.com/orca-zhang/ecache/dist" )
綁定緩存實(shí)例
名稱為自定義的池子名稱,內(nèi)部會(huì)按名稱聚合
注意??綁定可以放在全局,不依賴初始化
var _ = dist.Bind("user", c) var _ = dist.Bind("user", c, c1, c2) var _ = dist.Bind("token", caches...)
綁定redis client
目前支持redigo和goredis,其他庫(kù)可以自行實(shí)現(xiàn)dist.RedisCli接口,或者提issue給我
go-redis v7及以下版本
import ( "github.com/orca-zhang/ecache/dist/goredis/v7" ) dist.Init(goredis.Take(redisCli)) // redisCli是*redis.RedisClient類型 dist.Init(goredis.Take(redisCli, 100000)) // 第二個(gè)參數(shù)是channel緩沖區(qū)大小,不傳默認(rèn)100
go-redis v8及以上版本
import ( "github.com/orca-zhang/ecache/dist/goredis" ) dist.Init(goredis.Take(redisCli)) // redisCli是*redis.RedisClient類型 dist.Init(goredis.Take(redisCli, 100000)) // 第二個(gè)參數(shù)是channel緩沖區(qū)大小,不傳默認(rèn)100
redigo
注意??
github.com/gomodule/redigo要求最低版本go 1.14
import ( "github.com/orca-zhang/ecache/dist/redigo" ) dist.Init(redigo.Take(pool)) // pool是*redis.Pool類型
主動(dòng)通知所有節(jié)點(diǎn)、所有實(shí)例刪除(包括本機(jī))
當(dāng)db的數(shù)據(jù)發(fā)生變化或者刪除時(shí)調(diào)用
發(fā)生錯(cuò)誤時(shí)會(huì)降級(jí)成只處理本機(jī)所有實(shí)例(比如未初始化或者網(wǎng)絡(luò)錯(cuò)誤)
dist.OnDel("user", "uid1")
不希望你白來(lái)
- 客官,既然來(lái)了,學(xué)點(diǎn)東西再走吧!
- 我想盡力讓你明白
ecache做了啥,以及為什么要這么做
什么是本地內(nèi)存緩存
L1 緩存引用 .................... 0.5 ns
分支錯(cuò)誤預(yù)測(cè) ...................... 5 ns
L2 緩存引用 ...................... 7 ns
互斥鎖/解鎖 ...................... 25 ns
主存儲(chǔ)器引用 .................... 100 ns
使用 Zippy 壓縮 1K 字節(jié) ........3,000 ns = 3 μs
通過(guò) 1 Gbps 網(wǎng)絡(luò)發(fā)送 2K 字節(jié)... 20,000 ns = 20 μs
從內(nèi)存中順序讀取 1 MB ........ 250,000 ns = 250 μs
同一數(shù)據(jù)中心內(nèi)的往返........... 500,000 ns = 0.5 ms
發(fā)送數(shù)據(jù)包 加州<->荷蘭 .... 150,000,000 ns = 150 ms
- 從上表可以看出,內(nèi)存訪問(wèn)和網(wǎng)絡(luò)訪問(wèn)(同數(shù)據(jù)中心)差不多是一千到一萬(wàn)倍的差距!
- 曾經(jīng)遇到不止一個(gè)工程師:“緩存?上redis”,但我想說(shuō),redis不是萬(wàn)金油,某些程度上講,用它還是噩夢(mèng)(當(dāng)然我說(shuō)的是緩存一致性問(wèn)題...??)
- 因?yàn)閮?nèi)存操作非???,相對(duì)于redis/db你基本可以忽略不計(jì),比如現(xiàn)在有一個(gè)查詢接口,我們把結(jié)果緩存1秒,也就是1秒內(nèi)不會(huì)請(qǐng)求redis/db,如果接口的QPS是1000,那回源次數(shù)降低到了1/1000(理想情況),意味著訪問(wèn)redis/db部分的性能提升了1000倍,聽(tīng)上去是不是很棒?
- 繼續(xù)看,你會(huì)愛(ài)上她的?。ó?dāng)然也可能是他,亦或者是牠,ahaha)
使用場(chǎng)景,解決什么問(wèn)題
- 高并發(fā)大流量場(chǎng)景
- 緩存熱點(diǎn)數(shù)據(jù)(比如人氣比較高的直播間)
- 突發(fā)QPS削峰(比如信息流中突發(fā)新聞)
- 節(jié)省成本
- 單機(jī)場(chǎng)景(不部署redis、memcache也能快速提升QPS上限)
- redis和db實(shí)例降配(能攔截大部分請(qǐng)求)
- 不怎么會(huì)變化的數(shù)據(jù)(寫(xiě)少讀多)
- 比如配置等(這類數(shù)據(jù)使用地方多,會(huì)有放大效應(yīng),很多時(shí)候可能會(huì)因?yàn)檫@些配置熱key對(duì)redis實(shí)例的規(guī)格誤判,需要單獨(dú)為它們升配)
- 可以容忍短暫不一致的數(shù)據(jù)
- 信息查詢(用戶頭像、昵稱、商品庫(kù)存(實(shí)際下單會(huì)在db再次檢查)等)
- 配置延遲生效(過(guò)期時(shí)間10秒,那最多10秒生效)
設(shè)計(jì)思路
ecache是lrucache庫(kù)的升級(jí)版本
- 最下層是用原生map和存雙鏈表的
node實(shí)現(xiàn)的最基礎(chǔ)LRU(最久未訪問(wèn)) - 第2層包了分桶策略、并發(fā)控制、過(guò)期控制(會(huì)自動(dòng)適配等于或者略大于輸入大小的2的冪次個(gè)桶,便于掩碼計(jì)算)
- 第2.5層用很簡(jiǎn)單的方式實(shí)現(xiàn)了
LRU-2能力,代碼不超過(guò)20行,直接看源碼(搜關(guān)鍵詞LRU-2)
什么是LRU
- 最久未訪問(wèn)的優(yōu)先驅(qū)逐
- 每次被訪問(wèn),item會(huì)被刷新到隊(duì)列的最前面
- 隊(duì)列滿后再次寫(xiě)入新item,優(yōu)先驅(qū)逐隊(duì)列最后面、也就是最久未訪問(wèn)的item
什么是LRU-2
-
LRU-K是少于K次訪問(wèn)的用單獨(dú)的LRU隊(duì)列存放,超過(guò)K次的另外存放 - 主要優(yōu)化的場(chǎng)景是比如一些遍歷類型的查詢,批量刷緩存以后,很容易把一些本來(lái)較熱的item給驅(qū)逐掉
- 為了實(shí)現(xiàn)簡(jiǎn)單,我們這里實(shí)現(xiàn)的是
LRU-2,也就是第2次訪問(wèn)就放到熱隊(duì)列里,并不記錄訪問(wèn)次數(shù) - 主要優(yōu)化的是熱key的緩存命中率
分布式一致性組件原理
- 其實(shí)簡(jiǎn)單的利用了redis的pubsub功能
- 主動(dòng)告知被緩存的信息有更新,廣播到其他所有節(jié)點(diǎn)
- 某種意義上說(shuō),它只是縮小不一致時(shí)間窗口的一個(gè)方式(有網(wǎng)絡(luò)延遲且不保證一定完成)
- 需要注意??:
- 盡量減少使用,適合用在寫(xiě)少讀多
WORM(Write-Once-Read-Many)的場(chǎng)景- redis性能畢竟不如內(nèi)存,而且有廣播類通信(寫(xiě)放大)
- 以下場(chǎng)景會(huì)降級(jí)(時(shí)間窗口變大),但至少會(huì)保證當(dāng)前節(jié)點(diǎn)的強(qiáng)一致性
- redis不可用、網(wǎng)絡(luò)錯(cuò)誤
- 消費(fèi)goroutine panic
- 存在未生效節(jié)點(diǎn)(灰度
canary發(fā)布,或者發(fā)布過(guò)程中)的情況下,比如- 已使用
ecache但首次添加此插件 - 新加入緩存的數(shù)據(jù)或者新加的刪除操作
- 已使用
- 盡量減少使用,適合用在寫(xiě)少讀多
關(guān)于性能
- 釋放鎖不用defer(單接口性能差20倍,看到有宣稱
高性能還用defer的,直接pass吧) - 不用異步清理(沒(méi)意義,分散到寫(xiě)時(shí)驅(qū)逐更合理,不易抖動(dòng))
- 沒(méi)有用內(nèi)存容量來(lái)控制(單個(gè)item的大小一般都有預(yù)估大小,簡(jiǎn)單控制個(gè)數(shù)即可)
- 分桶策略,自動(dòng)選擇2的冪次個(gè)桶(分散鎖競(jìng)爭(zhēng),2的冪次掩碼操作更快)
- key用string類型(可擴(kuò)展性強(qiáng);語(yǔ)言內(nèi)建支持引用,更省內(nèi)存)
- 不用虛表頭(雖然繞腦一些,但是有20%左右提升)
- 選擇
LRU-2實(shí)現(xiàn)LRU-K(實(shí)現(xiàn)簡(jiǎn)單,近乎沒(méi)有額外損耗) - 沒(méi)用整塊內(nèi)存(寫(xiě)滿后復(fù)用以前的內(nèi)存效果也很好,整塊方式嘗試過(guò)提升不大、但可讀性大大降低)
- 可以直接存指針(不用序列化,如果使用
[]byte那優(yōu)勢(shì)大大降低) - 使用內(nèi)部定時(shí)器計(jì)時(shí)(默認(rèn)100ms精度,每秒校準(zhǔn),剖析發(fā)現(xiàn)time.Now()產(chǎn)生臨時(shí)對(duì)象導(dǎo)致GC耗時(shí)增加)
失敗的優(yōu)化嘗試
- key由string改為reflect.StringHeader,結(jié)果:負(fù)優(yōu)化
- node預(yù)分配連續(xù)空間,通過(guò)游標(biāo)和freelist決定新申請(qǐng)(是否滿)還是復(fù)用,結(jié)果:不明顯
- 互斥鎖改為讀寫(xiě)鎖,Get請(qǐng)求也會(huì)修改數(shù)據(jù),訪問(wèn)違例,即使不改數(shù)據(jù),結(jié)果:讀寫(xiě)混合場(chǎng)景負(fù)優(yōu)化
- time.Timer實(shí)現(xiàn)內(nèi)部定時(shí)器,結(jié)果:觸發(fā)不穩(wěn)定,后直接用Sleep實(shí)現(xiàn)定時(shí)器
- 分布式一致性組件掛inspector自動(dòng)同步更新和刪除,結(jié)果:性能影響較大且需要特殊處理循環(huán)調(diào)用問(wèn)題
關(guān)于GC優(yōu)化
- 就像我在C++版性能剖析器里提到的性能優(yōu)化的幾個(gè)層次,單從一個(gè)層次考慮性能并不高明
- 《第三層次》里有一句“沒(méi)有比不存在的東西性能更快的了”(類似奧卡姆剃刀),能砍掉一定不要想著優(yōu)化
- 比如為了減少GC大塊分配內(nèi)存,卻提供
[]byte的值存儲(chǔ),意味著必須序列化、拷貝(雖不在庫(kù)的性能指標(biāo)里,人家用還是要算,包括:GC、內(nèi)存、CPU) - 如果序列化的部分可以復(fù)用用在協(xié)議層拼接,能做到
ZeroCopy,那也無(wú)可厚非,而ecache存儲(chǔ)指針直接省了額外的部分 - 我想表達(dá)的并不是GC優(yōu)化不重要,而更多應(yīng)該結(jié)合場(chǎng)景,使用者額外損耗也需要考慮,而非宣稱gc-free,結(jié)果用起來(lái)并非那樣
- 我所崇尚的“暴力美學(xué)”是極簡(jiǎn),缺陷率和代碼量成正比,復(fù)雜的東西早晚會(huì)被淘汰,
KISS才是王道 -
ecache一共只有不到300行,千行bug率一定的情況下,它的bug不會(huì)多
常見(jiàn)問(wèn)題
問(wèn):一個(gè)實(shí)例可以存儲(chǔ)多種對(duì)象嗎?
- 答:可以呀,比如加前綴格式化key就可以了(像用redis那樣冒號(hào)分割),注意??別搞錯(cuò)類型。
問(wèn):如何給不同item設(shè)置不同過(guò)期時(shí)間?
- 答:用多個(gè)緩存實(shí)例。(??沒(méi)想到吧)
問(wèn):如果有熱熱熱熱key問(wèn)題怎么解決?
- 答:本身【本地內(nèi)存緩存】就是用來(lái)抗熱key的,這里可以理解成是非常非常熱的key(單節(jié)點(diǎn)幾十萬(wàn)QPS),它們最大的問(wèn)題是對(duì)單一bucket鎖定次數(shù)過(guò)多,影響在同一個(gè)bucket的其他數(shù)據(jù)。那么可以這樣:一是改用
LRU-2不讓類似遍歷的請(qǐng)求把熱數(shù)據(jù)刷掉,二是除了增加bucket,可以用多實(shí)例(同時(shí)寫(xiě)入相同的item)+讀隨機(jī)訪問(wèn)某一個(gè)的方式,讓熱key有多個(gè)副本,不過(guò)刪除(反寫(xiě))的時(shí)候要注意多實(shí)例全部刪除,適用于“寫(xiě)少讀多WORM(Write-Once-Read-Many)”的場(chǎng)景,或者“寫(xiě)多讀多”的場(chǎng)景可以把有變化的diff部分單獨(dú)摘出來(lái)轉(zhuǎn)化為“寫(xiě)少讀多WORM(Write-Once-Read-Many)”的場(chǎng)景。
問(wèn):為什么不用虛表頭方式處理雙鏈表?太弱了吧!
- 答:2019-04-22泄漏的【lrucache】被人在V站上扒出來(lái)噴過(guò),還真不是不會(huì),現(xiàn)在的寫(xiě)法,雖然比pointer-to-pointer方式讀起來(lái)繞腦,但是有20%左右的提升哈?。??沒(méi)想到吧)
問(wèn):為什么不提供int類型的key的接口?
- 答:考慮過(guò),但是為了分布式一致性處理的簡(jiǎn)單,只提供string的接口看著也不錯(cuò),用fmt.Sprint(i)也不麻煩。
