golang 源碼分析:singleflight

   singleflight 通常被用來做防止緩存擊穿,代碼位置在 https://github.com/golang/groupcache/tree/master/singleflight,在詳細介紹代碼內容之前,我先區分下雪崩、穿透和擊穿:

 雪崩

      雪崩就是指緩存中大批量熱點數據同時過期或緩存機器意外發生了全盤宕機後系統湧入大量查詢請求,因爲大部分數據在 Redis 層已經失效,請求滲透到數據庫層,大批量請求猶如洪水一般湧入,引起數據庫壓力造成查詢堵塞甚至宕機。

解決辦法:

       將緩存失效時間分散開,比如每個 key 的過期時間是隨機,防止同一時間大量數據過期現象發生,這樣不會出現同一時間全部請求都落在數據庫層,如果緩存數據庫是分佈式部署,將熱點數據均勻分佈在不同 Redis 和數據庫中,有效分擔壓力,別一個人扛。

        簡單粗暴,讓 Redis 數據永不過期(如果業務准許,比如不用更新的名單類)。當然,如果業務數據准許的情況下可以,比如中獎名單用戶,每期用戶開獎後,名單不可能會變了,無需更新。

         事前:redis 高可用,主從 + 哨兵,redis cluster,避免全盤崩潰。- 事中:本地 ehcache 緩存 + hystrix 限流 & 降級,避免 MySQL 被打死。- 事後:redis 持久化,一旦重啓,自動從磁盤上加載數據,快速恢復緩存數據。

緩存穿透

            緩存穿透是指段時間湧入大量請求,緩存中查不到,每次你去數據庫裏查,也查不到。(數據庫 id 是從 1 開始的,結果黑客發過來的請求 id 全部都是負數。)這樣的話,緩存中不會有,請求每次都 “視緩存於無物”,直接查詢數據庫。這種惡意攻擊場景的緩存穿透就會直接把數據庫給打死。

        解決方式很簡單,每次系統 A 從數據庫中只要沒查到,就寫一個空值到緩存裏去,比如 set -999 UNKNOWN。然後設置一個過期時間,這樣的話,下次有相同的 key 來訪問的時候,在緩存失效之前,都可以直接從緩存中取數據。

緩存擊穿

            緩存擊穿,某個 key 非常熱點,訪問非常頻繁,處於集中式高併發訪問的情況,當這個 key 在失效的瞬間,大量的請求就擊穿了緩存,直接請求數據庫,就像是在一道屏障上鑿開了一個洞。

       方法一:我們簡單粗暴點,直接讓熱點數據永遠不過期,定時任務定期去刷新數據就可以了。不過這樣設置需要區分場景,比如某寶首頁可以這麼做。

        方法二:爲了避免出現緩存擊穿的情況,我們可以在第一個請求去查詢數據庫的時候對他加一個互斥鎖,其餘的查詢請求都會被阻塞住,直到鎖被釋放,後面的線程進來發現已經有緩存了,就直接走緩存,從而保護數據庫。但是也是由於它會阻塞其他的線程,此時系統吞吐量會下降。需要結合實際的業務去考慮是否要這麼做。

        方法三:就是 singleflight 的設計思路,也會使用互斥鎖,但是相對於方法二的加鎖粒度會更細

singleflight 源碼分析

        說完了 singleflight 的應用場景,下面詳細分析下 singleflight 的源碼,源碼非常簡潔,目錄下就包含了兩個文件 singleflight.go 和對應的測試的測試文件 singleflight_test.go

         源碼中就定義了兩個結構體和一個方法

// call is an in-flight or completed Do call
type call struct {
  wg  sync.WaitGroup
  val interface{}
  err error
}
// Group represents a class of work and forms a namespace in which
// units of work can be executed with duplicate suppression.
type Group struct {
  mu sync.Mutex       // protects m
  m  map[string]*call // lazily initialized
}

通過 call 的 waitGroup 來阻塞相同 key 的請求,實現了一個指允許一個請求到後端,通過 Group 的 m 來實現相同 key 的數據共享,大家取同一份結果,下面看下 Do 函數的具體實現:

// Do executes and returns the results of the given function, making
// sure that only one execution is in-flight for a given key at a
// time. If a duplicate comes in, the duplicate caller waits for the
// original to complete and receives the same results.
//同一個對象多次同時多次調用這個邏輯的時候,可以使用其中的一個去執行
func (g *Group) Do(key string, fn func()(interface{},error)) (interface{}, error ){
    g.mu.Lock() //加鎖保護存放key的map,因爲要併發執行
    if g.m == nil { //lazing make 方式建立
        g.m = make(map[string]*call)
    }
    if c, ok := g.m[key]; ok { //如果map中已經存在對這個key的處理那就等着吧
        g.mu.Unlock() //解鎖,對map的操作已經完畢
        c.wg.Wait()
        return c.val,c.err //map中只有一份key,所以只有一個c
    }
    c := new(call) //創建一個工作單元,只負責處理一種key
    c.wg.Add(1)
    g.m[key] = c //將key註冊到map中
    g.mu.Unlock() //map的操做完成,解鎖
    c.val, c.err = fn()//第一個註冊者去執行
    c.wg.Done()
    g.mu.Lock()
    delete(g.m,key) //對map進行操作,需要枷鎖
    g.mu.Unlock()
    return c.val, c.err //給第一個註冊者返回結果
}

執行過程如下:     

1,對於相同 key 的請求,大家搶鎖,只有第一個請求可以獲得鎖;

2,然後查詢 map 發現沒有數據,創建一個 call,waitGroup 加 1,寫入 map,然後釋放鎖;做到了鎖的粒度最小化。

3,其他獲得鎖的請求,從 map 中取到 call,由於函數 fn 還沒有執行完畢,所以 waitGroup 還在等待狀態,後面獲得鎖的請求都在等待這個 waitGroup;

4,當函數執行完畢以後,獲得了數據,調用 wg.Done() 通知所有等待的請求獲取數據,實現了大家共享一份數據;

5,然後加鎖做清理工作,清理掉 map 裏存儲的數據。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ncdVgo9LeMh9Gwgtgd16hQ