golang高并發(fā)之本地緩存詳解
一、使用場景
試想一個場景,有一個配置服務系統(tǒng),里面存儲著各種各樣的配置,比如直播間的直播信息、點贊、簽到、紅包、帶貨等等。這些配置信息有兩個特點:
并發(fā)量可能會特別特別大,試想一下,一個幾十萬人的直播間,可能在直播開始前幾秒鐘,用戶就瞬間涌入進來了,那么這時候我們的系統(tǒng)就得加載這些配置信息。此時請求量就如同洪峰一般,一下子就沖擊進入我們的系統(tǒng)。
這些配置通常都是只需要讀取,在B端(管理后臺)設(shè)置好的,一般直播開始后,修改的頻率很低。
那么面對上述的業(yè)務場景,假設(shè)我們的目標是扛住3wQPS,你們會選用什么技術(shù)架構(gòu)和方案呢?
1、直接查數(shù)據(jù)庫,例如MySQL、Doris之類的關(guān)系型數(shù)據(jù)庫。很明顯這肯定扛不住,一般關(guān)系型數(shù)據(jù)庫能讓扛個幾千就基本上到頭了。
2、使用單機版Redis。理論上是可以的,騰訊云(下圖)和一些Redis官方的數(shù)據(jù),都說理論上高配置版本的單機Redis能抗住10W+的QPS。可是理論畢竟是理論,實際上工作中,我使用Redis做過許多壓測,都表明單機Redis上了兩萬多之后就性能會出現(xiàn)瓶頸,壓測就壓不上去。(當然,或許是我司的Redis還沒升到頂配?)
3、使用集群版Redis,當然是可以解決這個問題,就是成本有點點高咯,公司不差錢完全可以使用這個方案。
4、本地緩存,就是本文的重點,完美地解決這個問題。所謂本地緩存就是將這些所需要獲取的數(shù)據(jù)存儲在服務器的內(nèi)存中。服務器讀取本地緩存的速度理論上來說沒有上限,看服務器物理機的配置。但其下限就遠比MySQL和單機Redis之類的高好幾倍了,一臺2核4G的Linux服務器,估計也至少10W+QPS起步。我曾經(jīng)在本地的Windows系統(tǒng)做過壓測(四核八線程,16G),就達到過100W+的QPS。換到同等配置的Linux系統(tǒng)上,那就更不用說了。
二、技術(shù)方案
既然選用了本地緩存這個策略,那么我們怎么設(shè)計這個本地緩存的技術(shù)方案呢?
1、如上圖所示,我們客戶端獲取數(shù)據(jù)首先會讀取本地緩存,如果本地緩存沒有數(shù)據(jù)就會讀取Redis數(shù)據(jù),如果Redis沒有就會讀取DB數(shù)據(jù)。
2、需要注意的是,本地緩存和DB之間一般還會加入Redis這一層緩存。這是因為本地緩存設(shè)置好后就無法再更新了(除非重啟服務器),而Redis緩存我們是可以在DB有更改后,隨時更新。這個也很好理解,因為Redis是有單獨的Redis服務器,而本地緩存就只能在那臺機器上更新和設(shè)置,但實際項目中,設(shè)置本地緩存的DB數(shù)據(jù)源的機器和使用本地緩存的機器大概率都不在同一個系統(tǒng)中。所以我們本地緩存的時間都設(shè)置得很短,大部分都是秒級的,一般不會超過1分鐘,比如1秒、2秒... 。而Redis這個緩存時長明顯可以設(shè)置長一些,比如半小時、1小時...。
三、如何更新本地緩存
上面講了,本地緩存最不好的地方就是更新問題,因為很可能設(shè)置本地緩存的DB數(shù)據(jù)源的系統(tǒng)和使用本地緩存的系統(tǒng)不是同一個,無法在DB數(shù)據(jù)更新的時候就同步更新本地緩存。但是實際使用的時候很可能就需要這種場景,就是在更新數(shù)據(jù)源的時候去更新本地緩存。舉個例子:
我們設(shè)置配置A的DB數(shù)據(jù)源的系統(tǒng)是一個API系統(tǒng),但現(xiàn)在有一個腳本系統(tǒng),需要根據(jù)某個配置A,去處理C端的一些行為數(shù)據(jù),判斷是否滿足該配置A,然后進行對應的業(yè)務處理。好了,現(xiàn)在C端的行為數(shù)據(jù)量是非常龐大的,可以說是海量數(shù)據(jù),平均每秒鐘有五十萬的數(shù)據(jù)通過kafka推送過來。此時我們就必須得用本地緩存存儲配置A的信息了,才能抗的住這個流量洪峰。但是這是一個腳本系統(tǒng)啊,我們更新配置A的DB信息是在對應的API系統(tǒng)中的。那怎么辦呢?
有幾個方法:
1、在腳本系統(tǒng)中維護一個腳本,每隔一段時間就去讀取MySQL的數(shù)據(jù),然后更新到本地緩存。但這個得綜合評估下時間和MySQL的性能,因為要一直掃表。
2、拉取MySQL的binlog日志,每當數(shù)據(jù)有變更時,kafka推送數(shù)據(jù)到下游。腳本監(jiān)聽kafka數(shù)據(jù),當收到kafka數(shù)據(jù)是就更新配置A的本地緩存。但這個也得注意,因為腳本系統(tǒng)一般會同時起很多個服務,所以得注意有多少個服務就得設(shè)置多少個消費者組,因為要保證腳本系統(tǒng)的每個服務都消費到kafka對應的DB更新數(shù)據(jù),進而更新各自機器上的本地緩存。
3、使用Redis的發(fā)布訂閱功能,上游api有更新配置信息時就去發(fā)布信息,每個腳本服務都去訂閱該信息,一有消息就去更新自己機器上的本地緩存。但這也有個弊端,Redis的發(fā)布訂閱功能是沒有確認機制的,所以可能某個腳本服務沒收到信息導致沒更新本地緩存,然后就出現(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ū)分消息隊列,消息隊列是發(fā)布者發(fā)布,只有一個訂閱者能搶到。 func main() { rdb := redis.NewClient(&redis.Options{ Addr: "localhost:16379", Password: "123456", }) i := 0 for { // 模擬數(shù)據(jù)更新時發(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信息,有則更新本地緩存。其實就是延遲隊列。但這個就得上游的配置A增刪改都要寫入這個Redis,有時候增刪改的口子太多,其實實施起來也比較困難。
如上所述,基本上更新本地緩存沒有一個很合適、很高效的方法,只能選取其中一個比較符合自己業(yè)務場景的方法。
四、本地緩存常用類庫
go如何使用本地緩存呢?
1、可以自己實現(xiàn)一個本地緩存,一般可以使用LRU(最近最少使用)算法。下面是自己實現(xiàn)的一個本地緩存的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 // 過期時間 } // 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()) { // 移動到鏈表頭部 (最近使用) c.lruList.MoveToFront(elem) return elem.Value.(entry).value, true } // 如果過期,刪除緩存項 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) } // 清理過期項 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è)置過期時間為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() // 進行清理 }
該代碼中使用LRU算法,通過將最新的緩存移動到鏈表頭部(最近使用)來實現(xiàn)這個算法。但也有一些問題,CleanUp 方法需要手動調(diào)用去清理過期緩存,并沒有定期自動清理的機制。這就意味著使用者可能需要頻繁調(diào)用 CleanUp,否則過期項可能會在緩存中停留較長時間。代碼中也加了鎖,可能還會存在并發(fā)訪問時的數(shù)據(jù)一致性和性能問題等等。
所以,這種自己實現(xiàn)的demo不建議放在生產(chǎn)環(huán)境中使用,可能會存在一些小問題。比如,之前我部門寫的一個本地緩存類庫,就存在一個大的bug:本地緩存的內(nèi)存空間不能釋放,導致內(nèi)存一直蹭蹭地往上漲。隔好幾天內(nèi)存就飆到90%,然后我們臨時處理方法是:隔好幾天就去重啟一次腳本...
2、所以呢,我們還是建議去使用開源的類庫,至少有許多前輩幫我們踩過坑了,這里推薦幾個star數(shù)比較高的:
(1)go-cache:一個簡單的內(nèi)存緩存庫,支持過期和自動清理,適合簡單的緩存key-value需求。(本人項目中使用比較多,方便簡單,推薦)
(2)bigcache:高性能的內(nèi)存緩存庫,適用于大量數(shù)據(jù)的緩存,其設(shè)計旨在減少垃圾回收的壓力。
(3)groupcache:Google 開發(fā)的一個緩存庫,支持分布式緩存和單機緩存。適用于需要高并發(fā)和高可用性的場景。
以上,就是個人使用本地緩存的一些經(jīng)驗了。不得不說,這玩意用著是真香,物美價廉,能扛能打。唯一美中不足的就是本地緩存不太好實時去更新,當然這個上面也給出了幾個解決方案。
到此這篇關(guān)于golang高并發(fā)之本地緩存詳解的文章就介紹到這了,更多相關(guān)go本地緩存內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Go語言繼承功能使用結(jié)構(gòu)體實現(xiàn)代碼重用
今天我來給大家介紹一下在?Go?語言中如何實現(xiàn)類似于繼承的功能,讓我們的代碼更加簡潔和可重用,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2024-01-01