防止快取擊穿之程式內共享呼叫

kevwan 發表於 2020-09-15

go-zero 微服務框架中提供了許多開箱即用的工具,好的工具不僅能提升服務的效能而且還能提升程式碼的魯棒性避免出錯,實現程式碼風格的統一方便他人閱讀等等。

本文主要講述程式內共享呼叫神器SharedCalls

使用場景

併發場景下,可能會有多個執行緒(協程)同時請求同一份資源,如果每個請求都要走一遍資源的請求過程,除了比較低效之外,還會對資源服務造成併發的壓力。舉一個具體例子,比如快取失效,多個請求同時到達某服務請求某資源,該資源在快取中已經失效,此時這些請求會繼續訪問 DB 做查詢,會引起資料庫壓力瞬間增大。而使用 SharedCalls 可以使得同時多個請求只需要發起一次拿結果的呼叫,其他請求"坐享其成",這種設計有效減少了資源服務的併發壓力,可以有效防止快取擊穿。

高併發場景下,當某個熱點 key 快取失效後,多個請求會同時從資料庫載入該資源,並儲存到快取,如果不做防範,可能會導致資料庫被直接打死。針對這種場景,go-zero 框架中已經提供了實現,具體可參看sqlcmongoc等實現程式碼。

為了簡化演示程式碼,我們通過多個執行緒同時去獲取一個 id 來模擬快取的場景。如下:

func main() {
  const round = 5
  var wg sync.WaitGroup
  barrier := syncx.NewSharedCalls()

  wg.Add(round)
  for i := 0; i < round; i++ {
    // 多個執行緒同時執行
    go func() {
      defer wg.Done()
      // 可以看到,多個執行緒在同一個key上去請求資源,獲取資源的實際函式只會被呼叫一次
      val, err := barrier.Do("once", func() (interface{}, error) {
        // sleep 1秒,為了讓多個執行緒同時取once這個key上的資料
        time.Sleep(time.Second)
        // 生成了一個隨機的id
        return stringx.RandId(), nil
      })
      if err != nil {
        fmt.Println(err)
      } else {
        fmt.Println(val)
      }
    }()
  }

  wg.Wait()
}

執行,列印結果為:

837c577b1008a0db
837c577b1008a0db
837c577b1008a0db
837c577b1008a0db
837c577b1008a0db

可以看出,只要是同一個 key 上的同時發起的請求,都會共享同一個結果,對獲取 DB 資料進快取等場景特別有用,可以有效防止快取擊穿。

關鍵原始碼分析

  • SharedCalls interface 提供了 Do 和 DoEx 兩種方法的抽象
// SharedCalls介面提供了Do和DoEx兩種方法
type SharedCalls interface {
  Do(key string, fn func() (interface{}, error)) (interface{}, error)
  DoEx(key string, fn func() (interface{}, error)) (interface{}, bool, error)
}
  • SharedCalls interface 的具體實現 sharedGroup
// call代表對指定資源的一次請求
type call struct {
  wg  sync.WaitGroup  // 用於協調各個請求goroutine之間的資源共享
  val interface{}     // 用於儲存請求的返回值
  err error           // 用於儲存請求過程中發生的錯誤
}

type sharedGroup struct {
  calls map[string]*call
  lock  sync.Mutex
}
  • sharedGroup 的 Do 方法

    • key 引數:可以理解為資源的唯一標識。
    • fn 引數:真正獲取資源的方法。
    • 處理過程分析:
// 當多個請求同時使用Do方法請求資源時
func (g *sharedGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
  // 先申請加鎖
  g.lock.Lock()

  // 根據key,獲取對應的call結果,並用變數c儲存
  if c, ok := g.calls[key]; ok {
    // 拿到call以後,釋放鎖,此處call可能還沒有實際資料,只是一個空的記憶體佔位
    g.lock.Unlock()
    // 呼叫wg.Wait,判斷是否有其他goroutine正在申請資源,如果阻塞,說明有其他goroutine正在獲取資源
    c.wg.Wait()
    // 當wg.Wait不再阻塞,表示資源獲取已經結束,可以直接返回結果
    return c.val, c.err
  }

  // 沒有拿到結果,則呼叫makeCall方法去獲取資源,注意此處仍然是鎖住的,可以保證只有一個goroutine可以呼叫makecall
  c := g.makeCall(key, fn)
  // 返回撥用結果
  return c.val, c.err
}
  • sharedGroup 的 DoEx 方法

    • 和 Do 方法類似,只是返回值中增加了布林值表示值是呼叫 makeCall 方法直接獲取的,還是取的共享成果
func (g *sharedGroup) DoEx(key string, fn func() (interface{}, error)) (val interface{}, fresh bool, err error) {
  g.lock.Lock()
  if c, ok := g.calls[key]; ok {
    g.lock.Unlock()
    c.wg.Wait()
    return c.val, false, c.err
  }

  c := g.makeCall(key, fn)
  return c.val, true, c.err
}
  • sharedGroup 的 makeCall 方法

    • 該方法由 Do 和 DoEx 方法呼叫,是真正發起資源請求的方法。
// 進入makeCall的一定只有一個goroutine,因為要拿鎖鎖住的
func (g *sharedGroup) makeCall(key string, fn func() (interface{}, error)) *call {
  // 建立call結構,用於儲存本次請求的結果
  c := new(call)
  // wg加1,用於通知其他請求資源的goroutine等待本次資源獲取的結束
  c.wg.Add(1)
  // 將用於儲存結果的call放入map中,以供其他goroutine獲取
  g.calls[key] = c
  // 釋放鎖,這樣其他請求的goroutine才能獲取call的記憶體佔位
  g.lock.Unlock()

  defer func() {
    // delete key first, done later. can't reverse the order, because if reverse,
    // another Do call might wg.Wait() without get notified with wg.Done()
    g.lock.Lock()
    delete(g.calls, key)
    g.lock.Unlock()

    // 呼叫wg.Done,通知其他goroutine可以返回結果,這樣本批次所有請求完成結果的共享
    c.wg.Done()
  }()

  // 呼叫fn方法,將結果填入變數c中
  c.val, c.err = fn()
  return c
}

總結

本文主要介紹了 go-zero 框架中的 SharedCalls 工具,對其應用場景和關鍵程式碼做了簡單的梳理,希望本篇文章能給大家帶來一些收穫。

專案地址

https://github.com/tal-tech/go-zero

微信交流群

wechat

更多原創文章乾貨分享,請關注公眾號
  • 防止快取擊穿之程式內共享呼叫
  • 加微信實戰群請加微信(註明:實戰群):gocnio