golang高并發(fā)之本地緩存詳解
一、使用場景
試想一個(gè)場景,有一個(gè)配置服務(wù)系統(tǒng),里面存儲(chǔ)著各種各樣的配置,比如直播間的直播信息、點(diǎn)贊、簽到、紅包、帶貨等等。這些配置信息有兩個(gè)特點(diǎn):
并發(fā)量可能會(huì)特別特別大,試想一下,一個(gè)幾十萬人的直播間,可能在直播開始前幾秒鐘,用戶就瞬間涌入進(jìn)來了,那么這時(shí)候我們的系統(tǒng)就得加載這些配置信息。此時(shí)請求量就如同洪峰一般,一下子就沖擊進(jìn)入我們的系統(tǒng)。
這些配置通常都是只需要讀取,在B端(管理后臺(tái))設(shè)置好的,一般直播開始后,修改的頻率很低。
那么面對上述的業(yè)務(wù)場景,假設(shè)我們的目標(biāo)是扛住3wQPS,你們會(huì)選用什么技術(shù)架構(gòu)和方案呢?
1、直接查數(shù)據(jù)庫,例如MySQL、Doris之類的關(guān)系型數(shù)據(jù)庫。很明顯這肯定扛不住,一般關(guān)系型數(shù)據(jù)庫能讓扛個(gè)幾千就基本上到頭了。
2、使用單機(jī)版Redis。理論上是可以的,騰訊云(下圖)和一些Redis官方的數(shù)據(jù),都說理論上高配置版本的單機(jī)Redis能抗住10W+的QPS??墒抢碚摦吘故抢碚摚瑢?shí)際上工作中,我使用Redis做過許多壓測,都表明單機(jī)Redis上了兩萬多之后就性能會(huì)出現(xiàn)瓶頸,壓測就壓不上去。(當(dāng)然,或許是我司的Redis還沒升到頂配?)
3、使用集群版Redis,當(dāng)然是可以解決這個(gè)問題,就是成本有點(diǎn)點(diǎn)高咯,公司不差錢完全可以使用這個(gè)方案。
4、本地緩存,就是本文的重點(diǎn),完美地解決這個(gè)問題。所謂本地緩存就是將這些所需要獲取的數(shù)據(jù)存儲(chǔ)在服務(wù)器的內(nèi)存中。服務(wù)器讀取本地緩存的速度理論上來說沒有上限,看服務(wù)器物理機(jī)的配置。但其下限就遠(yuǎn)比MySQL和單機(jī)Redis之類的高好幾倍了,一臺(tái)2核4G的Linux服務(wù)器,估計(jì)也至少10W+QPS起步。我曾經(jīng)在本地的Windows系統(tǒng)做過壓測(四核八線程,16G),就達(dá)到過100W+的QPS。換到同等配置的Linux系統(tǒng)上,那就更不用說了。
二、技術(shù)方案
既然選用了本地緩存這個(gè)策略,那么我們怎么設(shè)計(jì)這個(gè)本地緩存的技術(shù)方案呢?
1、如上圖所示,我們客戶端獲取數(shù)據(jù)首先會(huì)讀取本地緩存,如果本地緩存沒有數(shù)據(jù)就會(huì)讀取Redis數(shù)據(jù),如果Redis沒有就會(huì)讀取DB數(shù)據(jù)。
2、需要注意的是,本地緩存和DB之間一般還會(huì)加入Redis這一層緩存。這是因?yàn)楸镜鼐彺嬖O(shè)置好后就無法再更新了(除非重啟服務(wù)器),而Redis緩存我們是可以在DB有更改后,隨時(shí)更新。這個(gè)也很好理解,因?yàn)镽edis是有單獨(dú)的Redis服務(wù)器,而本地緩存就只能在那臺(tái)機(jī)器上更新和設(shè)置,但實(shí)際項(xiàng)目中,設(shè)置本地緩存的DB數(shù)據(jù)源的機(jī)器和使用本地緩存的機(jī)器大概率都不在同一個(gè)系統(tǒng)中。所以我們本地緩存的時(shí)間都設(shè)置得很短,大部分都是秒級(jí)的,一般不會(huì)超過1分鐘,比如1秒、2秒... 。而Redis這個(gè)緩存時(shí)長明顯可以設(shè)置長一些,比如半小時(shí)、1小時(shí)...。
三、如何更新本地緩存
上面講了,本地緩存最不好的地方就是更新問題,因?yàn)楹芸赡茉O(shè)置本地緩存的DB數(shù)據(jù)源的系統(tǒng)和使用本地緩存的系統(tǒng)不是同一個(gè),無法在DB數(shù)據(jù)更新的時(shí)候就同步更新本地緩存。但是實(shí)際使用的時(shí)候很可能就需要這種場景,就是在更新數(shù)據(jù)源的時(shí)候去更新本地緩存。舉個(gè)例子:
我們設(shè)置配置A的DB數(shù)據(jù)源的系統(tǒng)是一個(gè)API系統(tǒng),但現(xiàn)在有一個(gè)腳本系統(tǒng),需要根據(jù)某個(gè)配置A,去處理C端的一些行為數(shù)據(jù),判斷是否滿足該配置A,然后進(jìn)行對應(yīng)的業(yè)務(wù)處理。好了,現(xiàn)在C端的行為數(shù)據(jù)量是非常龐大的,可以說是海量數(shù)據(jù),平均每秒鐘有五十萬的數(shù)據(jù)通過kafka推送過來。此時(shí)我們就必須得用本地緩存存儲(chǔ)配置A的信息了,才能抗的住這個(gè)流量洪峰。但是這是一個(gè)腳本系統(tǒng)啊,我們更新配置A的DB信息是在對應(yīng)的API系統(tǒng)中的。那怎么辦呢?
有幾個(gè)方法:
1、在腳本系統(tǒng)中維護(hù)一個(gè)腳本,每隔一段時(shí)間就去讀取MySQL的數(shù)據(jù),然后更新到本地緩存。但這個(gè)得綜合評(píng)估下時(shí)間和MySQL的性能,因?yàn)橐恢睊弑怼?/p>
2、拉取MySQL的binlog日志,每當(dāng)數(shù)據(jù)有變更時(shí),kafka推送數(shù)據(jù)到下游。腳本監(jiān)聽kafka數(shù)據(jù),當(dāng)收到kafka數(shù)據(jù)是就更新配置A的本地緩存。但這個(gè)也得注意,因?yàn)槟_本系統(tǒng)一般會(huì)同時(shí)起很多個(gè)服務(wù),所以得注意有多少個(gè)服務(wù)就得設(shè)置多少個(gè)消費(fèi)者組,因?yàn)橐WC腳本系統(tǒng)的每個(gè)服務(wù)都消費(fèi)到kafka對應(yīng)的DB更新數(shù)據(jù),進(jìn)而更新各自機(jī)器上的本地緩存。
3、使用Redis的發(fā)布訂閱功能,上游api有更新配置信息時(shí)就去發(fā)布信息,每個(gè)腳本服務(wù)都去訂閱該信息,一有消息就去更新自己機(jī)器上的本地緩存。但這也有個(gè)弊端,Redis的發(fā)布訂閱功能是沒有確認(rèn)機(jī)制的,所以可能某個(gè)腳本服務(wù)沒收到信息導(dǎo)致沒更新本地緩存,然后就出現(xiàn)bug了。demo如下:
(1)發(fā)布者:
package main import ( "context" "fmt" "github.com/go-redis/redis/v8" "time" ) var ctx = context.Background() // 發(fā)布訂閱功能 // 發(fā)布者發(fā)布,所有訂閱者都能接收到發(fā)布的消息。注意區(qū)分消息隊(duì)列,消息隊(duì)列是發(fā)布者發(fā)布,只有一個(gè)訂閱者能搶到。 func main() { rdb := redis.NewClient(&redis.Options{ Addr: "localhost:16379", Password: "123456", }) i := 0 for { // 模擬數(shù)據(jù)更新時(shí)發(fā)布消息 rdb.Publish(ctx, "money_updates", "New money value updated "+fmt.Sprintf("%d", i)) fmt.Println("Message published " + fmt.Sprintf("%d", i)) time.Sleep(5 * time.Second) i++ } }
(2)訂閱者:
package main import ( "context" "fmt" "github.com/go-redis/redis/v8" ) var ctx = context.Background() func main() { rdb := redis.NewClient(&redis.Options{ Addr: "localhost:16379", Password: "123456", }) pubsub := rdb.Subscribe(ctx, "money_updates") defer pubsub.Close() // 等待消息 for { msg, err := pubsub.ReceiveMessage(ctx) if err != nil { fmt.Println("Error receiving message:", err) return } fmt.Println("Received message:", msg.Payload) } }
4、跟方法1類似,只是可以把修改的配置A信息推送到Redis中,然后腳本去掃描Redis信息,有則更新本地緩存。其實(shí)就是延遲隊(duì)列。但這個(gè)就得上游的配置A增刪改都要寫入這個(gè)Redis,有時(shí)候增刪改的口子太多,其實(shí)實(shí)施起來也比較困難。
如上所述,基本上更新本地緩存沒有一個(gè)很合適、很高效的方法,只能選取其中一個(gè)比較符合自己業(yè)務(wù)場景的方法。
四、本地緩存常用類庫
go如何使用本地緩存呢?
1、可以自己實(shí)現(xiàn)一個(gè)本地緩存,一般可以使用LRU(最近最少使用)算法。下面是自己實(shí)現(xiàn)的一個(gè)本地緩存的demo。
package main import ( "container/list" "fmt" "sync" "time" ) type Cache struct { capacity int cache map[int]*list.Element lruList *list.List mu sync.Mutex // 確保線程安全 } type entry struct { key int value int expiration time.Time // 過期時(shí)間 } // NewCache 創(chuàng)建新的緩存 func NewCache(capacity int) *Cache { return &Cache{ capacity: capacity, cache: make(map[int]*list.Element), lruList: list.New(), } } // Get 從緩存中獲取值 func (c *Cache) Get(key int) (int, bool) { c.mu.Lock() defer c.mu.Unlock() if elem, found := c.cache[key]; found { // 檢查是否過期 if elem.Value.(entry).expiration.After(time.Now()) { // 移動(dòng)到鏈表頭部 (最近使用) c.lruList.MoveToFront(elem) return elem.Value.(entry).value, true } // 如果過期,刪除緩存項(xiàng) c.removeElement(elem) } return 0, false } // Put 將值放入緩存 func (c *Cache) Put(key int, value int, ttl time.Duration) { c.mu.Lock() defer c.mu.Unlock() if elem, found := c.cache[key]; found { // 更新現(xiàn)有值 elem.Value = entry{key, value, time.Now().Add(ttl)} c.lruList.MoveToFront(elem) } else { // 添加新條目 if c.lruList.Len() == c.capacity { // 刪除最舊的條目 oldest := c.lruList.Back() if oldest != nil { c.removeElement(oldest) } } newElem := c.lruList.PushFront(entry{key, value, time.Now().Add(ttl)}) c.cache[key] = newElem } } // removeElement 從緩存中刪除元素 func (c *Cache) removeElement(elem *list.Element) { c.lruList.Remove(elem) delete(c.cache, elem.Value.(entry).key) } // 清理過期項(xiàng) func (c *Cache) CleanUp() { c.mu.Lock() defer c.mu.Unlock() for e := c.lruList.Back(); e != nil; { next := e.Prev() if e.Value.(entry).expiration.Before(time.Now()) { c.removeElement(e) } e = next } } func main() { cache := NewCache(2) cache.Put(1, 1, 5*time.Second) // 設(shè)置過期時(shí)間為5秒 cache.Put(2, 2, 5*time.Second) fmt.Println(cache.Get(1)) // 輸出: 1 true time.Sleep(6 * time.Second) // 過期后的訪問 fmt.Println(cache.Get(1)) // 輸出: 0 false cache.CleanUp() // 進(jìn)行清理 }
該代碼中使用LRU算法,通過將最新的緩存移動(dòng)到鏈表頭部(最近使用)來實(shí)現(xiàn)這個(gè)算法。但也有一些問題,CleanUp 方法需要手動(dòng)調(diào)用去清理過期緩存,并沒有定期自動(dòng)清理的機(jī)制。這就意味著使用者可能需要頻繁調(diào)用 CleanUp,否則過期項(xiàng)可能會(huì)在緩存中停留較長時(shí)間。代碼中也加了鎖,可能還會(huì)存在并發(fā)訪問時(shí)的數(shù)據(jù)一致性和性能問題等等。
所以,這種自己實(shí)現(xiàn)的demo不建議放在生產(chǎn)環(huán)境中使用,可能會(huì)存在一些小問題。比如,之前我部門寫的一個(gè)本地緩存類庫,就存在一個(gè)大的bug:本地緩存的內(nèi)存空間不能釋放,導(dǎo)致內(nèi)存一直蹭蹭地往上漲。隔好幾天內(nèi)存就飆到90%,然后我們臨時(shí)處理方法是:隔好幾天就去重啟一次腳本...
2、所以呢,我們還是建議去使用開源的類庫,至少有許多前輩幫我們踩過坑了,這里推薦幾個(gè)star數(shù)比較高的:
(1)go-cache:一個(gè)簡單的內(nèi)存緩存庫,支持過期和自動(dòng)清理,適合簡單的緩存key-value需求。(本人項(xiàng)目中使用比較多,方便簡單,推薦)
(2)bigcache:高性能的內(nèi)存緩存庫,適用于大量數(shù)據(jù)的緩存,其設(shè)計(jì)旨在減少垃圾回收的壓力。
(3)groupcache:Google 開發(fā)的一個(gè)緩存庫,支持分布式緩存和單機(jī)緩存。適用于需要高并發(fā)和高可用性的場景。
以上,就是個(gè)人使用本地緩存的一些經(jīng)驗(yàn)了。不得不說,這玩意用著是真香,物美價(jià)廉,能扛能打。唯一美中不足的就是本地緩存不太好實(shí)時(shí)去更新,當(dāng)然這個(gè)上面也給出了幾個(gè)解決方案。
到此這篇關(guān)于golang高并發(fā)之本地緩存詳解的文章就介紹到這了,更多相關(guān)go本地緩存內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Go接口構(gòu)建可擴(kuò)展Go應(yīng)用示例詳解
本文深入探討了Go語言中接口的概念和實(shí)際應(yīng)用場景。從基礎(chǔ)知識(shí)如接口的定義和實(shí)現(xiàn),到更復(fù)雜的實(shí)戰(zhàn)應(yīng)用如解耦與抽象、多態(tài)、錯(cuò)誤處理、插件架構(gòu)以及資源管理,文章通過豐富的代碼示例和詳細(xì)的解釋,展示了Go接口在軟件開發(fā)中的強(qiáng)大功能和靈活性2023-10-10Go項(xiàng)目配置管理神器之viper的介紹與使用詳解
viper是一個(gè)完整的?Go應(yīng)用程序的配置解決方案,它被設(shè)計(jì)為在應(yīng)用程序中工作,并能處理所有類型的配置需求和格式,下面這篇文章主要給大家介紹了關(guān)于Go項(xiàng)目配置管理神器之viper的介紹與使用,需要的朋友可以參考下2023-02-02Golang使用WebSocket通信的實(shí)現(xiàn)
這篇文章主要介紹了Golang使用WebSocket通信的實(shí)現(xiàn),文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2021-02-02Go語言繼承功能使用結(jié)構(gòu)體實(shí)現(xiàn)代碼重用
今天我來給大家介紹一下在?Go?語言中如何實(shí)現(xiàn)類似于繼承的功能,讓我們的代碼更加簡潔和可重用,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2024-01-01