天天看點

redis緩存應用實踐

作者:幹飯人小羽
redis緩存應用實踐

0.1、索引

https://blog.waterflow.link/articles/1663169309611

1、隻讀緩存

隻讀緩存的流程是這樣的:

當查詢請求過來時,先從redis中查詢資料,如果有的話就直接傳回。如果沒有的話,就從資料庫查詢,并寫入到緩存中。

當删改請求過來時,會直接從資料庫中删除修改資料,并把redis中儲存的資料删除。

這樣做的好處是,所有最新的資料都在資料庫中,而資料庫是有資料可靠性保障的。

2、讀寫緩存

讀寫緩存的流程是這樣的:

  • 當查詢請求過來時,先從redis中查詢資料,如果有的話就直接傳回。如果沒有的話,就從資料庫查詢,并寫入到緩存中。
  • 當增删改請求過來時,得益于Redis的高性能通路特性,資料的增删改操作可以在緩存中快速完成,處理結果也會快速傳回給業務應用,這就可以提升業務應用的響應速度。
  • 但是和隻讀緩存不同的是,最新的資料都是在redis中,一旦出現掉電當機,由于redis的持久化機制,最新的資料有可能會丢失,就會給業務帶來風險。

是以,根據業務應用對資料可靠性和緩存性能的不同要求,我們會有同步直寫和異步寫回兩種政策。其中,同步直寫政策優先保證資料可

靠性,而異步寫回政策優先提供快速響應。

2.1、同步直寫

當增删改請求過來時,請求到redis的同時,也會請求mysql,等到redis和mysql都寫完資料才會傳回資料。

這樣,即使緩存當機或發生故障,最新的資料仍然儲存在資料庫中,這就提供了資料可靠性保證。

但是也會降低緩存的使用性能,因為寫緩存很快,但是寫資料庫就要慢很多,整個的響應時間就會增加。

2.2、異步寫回

異步寫回優先考慮了響應速度,寫到緩存會立即響應用戶端。等到資料要從redis中淘汰時,再同步到mysql。

但是如果發生掉電,資料還是沒有寫到mysql,還是有丢失的風險。

3、如何選擇

  • 如果需要對寫請求進行加速,我們選擇讀寫緩存;
  • 如果寫請求很少,或者是隻需要提升讀請求的響應速度的話,我們選擇隻讀緩存。

4、關于一緻性

  • 對于讀寫緩存的異步寫回,由于是隻寫redis,淘汰時才會寫入mysql,如果發生當機不能保證一緻性
  • 對于讀寫緩存的同步寫回,由于redis和mysql是同時寫,需要加入事物機制,要麼都執行要麼都不執行,可以保證一緻性。(問題:如何保證原子性?當有并發寫過來時即使都執行了也可能會不一緻,這是就要引入鎖保證互斥性)
  • 對于隻讀緩存,如果發生删改操作,應用既要更新資料庫,也要在緩存中删除資料。由于redis和mysql是同時操作,需要加入事物機制,要麼都執行要麼都不執行,可以保證一緻性。(問題:如何保證原子性?)

4.1、對于隻讀緩存的一緻性問題

先删除緩存,再更新資料庫

  • 如果緩存删除成功,但是資料庫更新失敗,那麼,應用再通路資料時,緩存中沒有資料,就會發生緩存缺失。然後,應用再通路數庫,但是資料庫中的值為舊值,應用就通路到舊值了。
  • 如果線程A都成功了,但是同時另一個線程B線上程A的這倆個請求中間過來。這個時候緩存已經删除,但是資料庫還是舊值,線程B發現沒有緩存,就從資料庫讀讀取了舊值更新到redis中,然後線程A把新值更新到資料庫。此時redis中是舊值,mysql中是新值。

先更新資料庫,再删除緩存中的值

  • 如果應用先完成了資料庫的更新,但是,在删除緩存時失敗了,那麼,資料庫中的值是新值,而緩存中的是舊值,這肯定是不一緻的。這個時候,如果有其他的并發請求來通路資料,按照正常的緩存通路流程,就會先在緩存中查詢,但此時,就會讀到舊值了。
  • 如果線程A删除了資料庫中的值,但還沒來得及删除緩存值,線程B就開始讀取資料了,那麼此時,線程B查詢緩存時,發現緩存命中,就會直接從緩存中讀取舊值。不過,在這種情況下,如果其他線程并發讀緩存的請求不多,那麼,就不會有很多請求讀取到舊值。而且,線程A一般也會很快删除緩存值,這樣一來,其他線程再次讀取時,就會發生緩存缺失,進而從資料庫中讀取最新值。是以,這種情況對業務的影響較小。(可以了解為最終一緻性,讀到舊資料隻是暫時的,最終都會讀到新資料)

是以一般項目中使用隻讀緩存,先更新資料庫,再删除緩存。這樣的代價是最小的,而且盡量保證了一緻性。

5、緩存異常

5.1、緩存雪崩

緩存雪崩是指,大量的請求無法在redis中處理(redis沒攔住),直接打到了mysql,導緻資料庫壓力激增,甚至服務崩潰。

redis無法處理的原因有兩種:

緩存中大量資料同時過期

解決方案:

  • 給過期時間增加一個較小的随機數,過期的資料通過時間去分攤
  • 服務降級,直接傳回錯誤資訊

Redis緩存執行個體發生故障當機了,無法處理請求,這就會導緻大量請求一下子積壓到資料庫層

解決方案:

  • 服務熔斷或者請求限流,redis用戶端直接傳回,不會請求到redis服務,但是影響範圍比較大
  • 建構redis叢集,提高可用性

5.2、緩存擊穿

緩存擊穿是指,通路某個熱點資料,無法在緩存中處理,大量請求打到mysql,導緻資料庫壓力激增,甚至服務崩潰。

解決方案:

  • 對于頻繁通路的熱點資料不設定過期時間

5.3、緩存穿透

緩存穿透是指,要通路的資料既不在redis中,也不在mysql中。請求redis發現資料不存在,繼續通路mysql發現資料還是不存在,然後也無法寫回緩存,下次繼續請求的時候還是會打到mysql。

解決方案:

  • 緩存空值或者預設值
  • 使用布隆過濾器

布隆過濾器

布隆過濾器由一個初值都為0的bit數組和N個哈希函數組成,可以用來快速判斷某個資料是否存在(準确說是判斷不存在,如果布隆過濾器不存在資料庫中一定不存在,如果布隆過濾器判斷存在,資料庫不一定存在,這是布隆過濾器的機制決定的)。當我們想标記某個資料存在時(例如,資料已被寫入資料庫),布隆過濾器會通過三個操作完成标記:

  • 首先,使用N個哈希函數,分别計算這個資料的哈希值,得到N個哈希值。
  • 然後,我們把這N個哈希值對bit數組的長度取模,得到每個哈希值在數組中的對應位置。
  • 最後,我們把對應位置的bit位設定為1,這就完成了在布隆過濾器中标記資料的操作。

如果資料不存在(例如,資料庫裡沒有寫入資料),我們也就沒有用布隆過濾器标記過資料,那麼,bit數組對應bit位的值仍然為0。

是以當我們寫入資料庫時,使用布隆過濾器做個标記。當緩存缺失後,應用查詢資料庫時,可以通過查詢布隆過濾器快速判斷資料是否存在。如果不存在,就不用再去資料庫中查詢了。

6、應用場景

我們看下go-zero中是如何使用緩存的,go-zero中使用的隻讀緩存,當資料有更新删除操作的時候,redis中的對應Primary記錄和查詢條件記錄會同步删除。go-zero中對某行的緩存,會緩存主鍵到行記錄的緩存,和查詢條件(唯一索引)到主鍵的緩存

我們看下查詢的邏輯(針對的是單行的記錄):

  1. 通過查詢條件查詢某條記錄時,如果沒有查詢條件到主鍵的緩存
  2. 通過查詢條件到mysql查詢行記錄,然後把主鍵到行記錄的緩存,和查詢條件(唯一索引)到主鍵的緩存更新到redis(前者的過期時間會多餘後者幾秒時間)
  3. 繼續回到1,如果有查詢條件到主鍵的緩存,如果沒有主鍵到記錄的緩存,通過主鍵到mysql查詢并寫入redis

下面看下go-zero源碼:

// v - 需要讀取的資料對象
// key - 緩存key
// query - 用來從DB讀取完整資料的方法
// cacheVal - 用來寫緩存的方法
func (c cacheNode) doTake(v interface{}, key string, query func(v interface{}) error,
  cacheVal func(v interface{}) error) error {
  // singleflight一批請求過來,隻允許一個去真正通路資料,防止緩存擊穿
  val, fresh, err := c.barrier.DoEx(key, func() (interface{}, error) {
    // 從cache裡讀取資料
    if err := c.doGetCache(key, v); err != nil {
      // 如果是預先放進來的placeholder(用來防止緩存穿透)的,那麼就傳回預設的errNotFound
      // 如果是未知錯誤,那麼就直接傳回,因為我們不能放棄緩存出錯而直接把所有請求去請求DB,
      // 這樣在高并發的場景下會把DB打挂掉的
      if err == errPlaceholder {
        return nil, c.errNotFound
      } else if err != c.errNotFound {
        // why we just return the error instead of query from db,
        // because we don't allow the disaster pass to the DBs.
        // fail fast, in case we bring down the dbs.
        return nil, err
      }

      // 請求DB
      // 如果傳回的error是errNotFound,那麼我們就需要在緩存裡設定placeholder,防止緩存穿透
      if err = query(v); err == c.errNotFound {
        if err = c.setCacheWithNotFound(key); err != nil {
          logx.Error(err)
        }

        return nil, c.errNotFound
      } else if err != nil {
        // 統計DB失敗
        c.stat.IncrementDbFails()
        return nil, err
      }

      // 把資料寫入緩存
      if err = cacheVal(v); err != nil {
        logx.Error(err)
      }
    }
  
    // 傳回json序列化的資料
    return jsonx.Marshal(v)
  })
  if err != nil {
    return err
  }
  if fresh {
    return nil
  }

  // got the result from previous ongoing query
  c.stat.IncrementTotal()
  c.stat.IncrementHit()

  // 把資料寫入到傳入的v對象裡
  return jsonx.Unmarshal(val.([]byte), v)
}
           

從上面代碼我們可以看到:

  1. 使用sigleflight防止緩存擊穿
  2. 緩存穿透,使用了占位符,即在redis中儲存一個空值

繼續閱讀