go-zero微服務實戰系列(五、緩存代碼怎麼寫)

緩存是高併發服務的基礎,毫不誇張的說沒有緩存高併發服務就無從談起。本項目緩存使用Redis,Redis是目前主流的緩存數據庫,支持豐富的數據類型,其中集合類型的底層主要依賴:整數數組、雙向鏈表、哈希表、壓縮列表和跳錶五種數據結構。由於底層依賴的數據結構的高效性以及基於多路複用的高性能I/O模型,所以Redis也提供了非常強悍的性能。下圖展示了Redis數據類型對應的底層數據結構。

基本使用

在go-zero中默認集成了緩存model數據的功能,我們在使用goctl自動生成model代碼的時候加上 -c 參數即可生成集成緩存的model代碼

goctl model mysql datasource -url="root:123456@tcp(127.0.0.1:3306)/product" -table="*"  -dir="./model" -c

通過簡單的配置我們就可以使用model層的緩存啦,model層緩存默認過期時間爲7天,如果沒有查到數據會設置一個空緩存,空緩存的過期時間爲1分鐘,model層cache配置和初始化如下:

CacheRedis:
  - Host: 127.0.0.1:6379
    Type: node
CategoryModel: model.NewCategoryModel(conn, c.CacheRedis)

這次演示的代碼主要會基於product-rpc服務,爲了簡單我們直接使用grpcurl來進行調試,注意啓動的時候主要註冊反射服務,通過goctl自動生成的rpc服務在dev或test環境下已經幫我們註冊好了,我們需要把我們的mode設置爲dev,默認的mode爲pro,如下代碼所示:

s := zrpc.MustNewServer(c.RpcServerConf, func(grpcServer *grpc.Server) {
    product.RegisterProductServer(grpcServer, svr)
    if c.Mode == service.DevMode || c.Mode == service.TestMode {
      reflection.Register(grpcServer)
    }
})

直接使用go install安裝grpcurl工具,so easy !!!媽媽再也不用擔心我不會調試gRPC了

go install github.com/fullstorydev/grpcurl/cmd/grpcurl

啓動服務,通過如下命令查詢服務,服務提供的方法,可以看到當前提供了Product獲取商品詳情接口和Products批量獲取商品詳情接口

~ grpcurl -plaintext 127.0.0.1:8081 list

grpc.health.v1.Health
grpc.reflection.v1alpha.ServerReflection
product.Product

~ grpcurl -plaintext 127.0.0.1:8081 list product.Product
product.Product.Product
product.Product.Products

我們先往product表裏插入一些測試數據,測試數據放在lebron/sql/data.sql文件中,此時我們查看id爲1的商品數據,這時候緩存中是沒有id爲1這條數據的

127.0.0.1:6379> EXISTS cache:product:product:id:1
(integer) 0

通過grpcurl工具來調用Product接口查詢id爲1的商品數據,可以看到已經返回了數據

~ grpcurl -plaintext -d '{"product_id": 1}' 127.0.0.1:8081 product.Product.Product

{
  "productId": "1",
  "name": "夾克1"
}

再看redis中已經存在了id爲1的這條數據的緩存,這就是框架給我們自動生成的緩存

127.0.0.1:6379> get cache:product:product:id:1

{\"Id\":1,\"Cateid\":2,\"Name\":\"\xe5\xa4\xb9\xe5\x85\x8b1\",\"Subtitle\":\"\xe5\xa4\xb9\xe5\x85\x8b1\",\"Images\":\"1.jpg,2.jpg,3.jpg\",\"Detail\":\"\xe8\xaf\xa6\xe6\x83\x85\",\"Price\":100,\"Stock\":10,\"Status\":1,\"CreateTime\":\"2022-06-17T17:51:23Z\",\"UpdateTime\":\"2022-06-17T17:51:23Z\"}

我們再請求id爲666的商品,因爲我們表裏沒有id爲666的商品,框架會幫我們緩存一個空值,這個空值的過期時間爲1分鐘

127.0.0.1:6379> get cache:product:product:id:666
"*"

當我們刪除數據或者更新數據的時候,以id爲key的行記錄緩存會被刪除

緩存索引

我們的分類商品列表是需要支持分頁的,通過往上滑動可以不斷地加載下一頁,商品按照創建時間倒序返回列表,使用遊標的方式進行分頁。

怎麼在緩存中存儲分類的商品呢?我們使用Sorted Set來存儲,member爲商品的id,即我們只在Sorted Set中存儲緩存索引,查出緩存索引後,因爲我們自動生成了以主鍵id索引爲key的緩存,所以查出索引列表後我們再查詢行記錄緩存即可獲取商品的詳情,Sorted Set的score爲商品的創建時間。

下面我們一起來分析分類商品列表的邏輯該怎麼寫,首先先從緩存中讀取當前頁的商品id索引,調用cacheProductList方法,注意,這裏調用查詢緩存方法忽略了error,爲什麼要忽略這個error呢,因爲我們期望的是盡最大可能的給用戶返回數據,也就是redis掛掉了的話那我們就會從數據庫查詢數據返回給用戶,而不會因爲redis掛掉而返回錯誤。

pids, _ := l.cacheProductList(l.ctx, in.CategoryId, in.Cursor, int64(in.Ps))

cacheProductList方法實現如下,通過ZrevrangebyscoreWithScoresAndLimitCtx倒序從緩存中讀數據,並限制讀條數爲分頁大小

func (l *ProductListLogic) cacheProductList(ctx context.Context, cid int32, cursor, ps int64) ([]int64, error) {
  pairs, err := l.svcCtx.BizRedis.ZrevrangebyscoreWithScoresAndLimitCtx(ctx, categoryKey(cid), cursor, 0, 0, int(ps))
  if err != nil {
    return nil, err
  }
  var ids []int64
  for _, pair := range pairs {
    id, _ := strconv.ParseInt(pair.Key, 10, 64)
    ids = append(ids, id)
  }
  return ids, nil
}

爲了表示列表的結束,我們會在Sorted Set中設置一個結束標誌符,該標誌符的member爲-1,score爲0,所以我們在從緩存中查出數據後,需要判斷數據的最後一條是否爲-1,如果爲-1的話說明列表已經加載到最後一頁了,用戶再滑動屏幕的話前端就不會再繼續請求後端的接口了,邏輯如下,從緩存中查出數據後再根據主鍵id查詢商品的詳情即可

pids, _ := l.cacheProductList(l.ctx, in.CategoryId, in.Cursor, int64(in.Ps))
if len(pids) == int(in.Ps) {
  isCache = true
  if pids[len(pids)-1] == -1 {
    isEnd = true
  }
}

如果從緩存中查出的數據爲0條,那麼我們就從數據庫中查詢該分類下的數據,這裏要注意從數據庫查詢數據的時候我們要限制查詢的條數,我們默認一次查詢300條,因爲我們每頁大小爲10,300條可以讓用戶下翻30頁,大多數情況下用戶根本不會翻那麼多頁,所以我們不會全部加載以降低我們的緩存資源,當用戶真的翻頁超過30頁後,我們再按需加載到緩存中

func (m *defaultProductModel) CategoryProducts(ctx context.Context, cateid, ctime, limit int64) ([]*Product, error) {
  var products []*Product
  err := m.QueryRowsNoCacheCtx(ctx, &products, fmt.Sprintf("select %s from %s where cateid=? and status=1 and create_time<? order by create_time desc limit ?", productRows, m.table), cateid, ctime, limit)
  if err != nil {
    return nil, err
  }
  return products, nil
}

獲取到當前頁的數據後,我們還需要做去重,因爲如果我們只以createTime作爲遊標的話,很可能數據會重複,所以我們還需要加上id作爲去重條件,去重邏輯如下

for k, p := range firstPage {
      if p.CreateTime == in.Cursor && p.ProductId == in.ProductId {
        firstPage = firstPage[k:]
        break
      }
}

最後,如果沒有命中緩存的話,我們需要把從數據庫查出的數據寫入緩存,這裏需要注意的是如果數據已經到了末尾需要加上數據結束的標識符,即val爲-1,score爲0,這裏我們異步的寫會緩存,因爲寫緩存並不是主邏輯,不需要等待完成,寫失敗也沒有影響呢,通過異步方式降低接口耗時,處處都有小優化呢

if !isCache {
    threading.GoSafe(func() {
      if len(products) < defaultLimit && len(products) > 0 {
        endTime, _ := time.Parse("2006-01-02 15:04:05", "0000-00-00 00:00:00")
        products = append(products, &model.Product{Id: -1, CreateTime: endTime})
      }
      _ = l.addCacheProductList(context.Background(), products)
    })
}

可以看出想要寫一個完整的基於遊標分頁的邏輯還是比較複雜的,有很多細節需要考慮,大家平時在寫類似代碼時一定要細心,該方法的整體代碼如下:

func (l *ProductListLogic) ProductList(in *product.ProductListRequest) (*product.ProductListResponse, error) {
  _, err := l.svcCtx.CategoryModel.FindOne(l.ctx, int64(in.CategoryId))
  if err == model.ErrNotFound {
    return nil, status.Error(codes.NotFound, "category not found")
  }
  if in.Cursor == 0 {
    in.Cursor = time.Now().Unix()
  }
  if in.Ps == 0 {
    in.Ps = defaultPageSize
  }
  var (
    isCache, isEnd   bool
    lastID, lastTime int64
    firstPage        []*product.ProductItem
    products         []*model.Product
  )
  pids, _ := l.cacheProductList(l.ctx, in.CategoryId, in.Cursor, int64(in.Ps))
  if len(pids) == int(in.Ps) {
    isCache = true
    if pids[len(pids)-1] == -1 {
      isEnd = true
    }
    products, err := l.productsByIds(l.ctx, pids)
    if err != nil {
      return nil, err
    }
    for _, p := range products {
      firstPage = append(firstPage, &product.ProductItem{
        ProductId:  p.Id,
        Name:       p.Name,
        CreateTime: p.CreateTime.Unix(),
      })
    }
  } else {
    var (
      err   error
      ctime = time.Unix(in.Cursor, 0).Format("2006-01-02 15:04:05")
    )
    products, err = l.svcCtx.ProductModel.CategoryProducts(l.ctx, ctime, int64(in.CategoryId), defaultLimit)
    if err != nil {
      return nil, err
    }
    var firstPageProducts []*model.Product
    if len(products) > int(in.Ps) {
      firstPageProducts = products[:int(in.Ps)]
    } else {
      firstPageProducts = products
      isEnd = true
    }
    for _, p := range firstPageProducts {
      firstPage = append(firstPage, &product.ProductItem{
        ProductId:  p.Id,
        Name:       p.Name,
        CreateTime: p.CreateTime.Unix(),
      })
    }
  }
  if len(firstPage) > 0 {
    pageLast := firstPage[len(firstPage)-1]
    lastID = pageLast.ProductId
    lastTime = pageLast.CreateTime
    if lastTime < 0 {
      lastTime = 0
    }
    for k, p := range firstPage {
      if p.CreateTime == in.Cursor && p.ProductId == in.ProductId {
        firstPage = firstPage[k:]
        break
      }
    }
  }
  ret := &product.ProductListResponse{
    IsEnd:     isEnd,
    Timestamp: lastTime,
    ProductId: lastID,
    Products:  firstPage,
  }
  if !isCache {
    threading.GoSafe(func() {
      if len(products) < defaultLimit && len(products) > 0 {
        endTime, _ := time.Parse("2006-01-02 15:04:05", "0000-00-00 00:00:00")
        products = append(products, &model.Product{Id: -1, CreateTime: endTime})
      }
      _ = l.addCacheProductList(context.Background(), products)
    })
  }
  return ret, nil
}

我們通過grpcurl工具請求ProductList接口後返回數據的同時也寫進了緩存索引中,當下次再請求的時候就直接從緩存中讀取

grpcurl -plaintext -d '{"category_id": 8}' 127.0.0.1:8081 product.Product.ProductList

緩存擊穿

緩存擊穿是指訪問某個非常熱的數據,緩存不存在,導致大量的請求發送到了數據庫,這會導致數據庫壓力陡增,緩存擊穿經常發生在熱點數據過期失效時,如下圖所示:

既然緩存擊穿經常發生在熱點數據過期失效的時候,那麼我們不讓緩存失效不就好了,每次查詢緩存的時候不要使用Exists來判斷key是否存在,而是使用Expire給緩存續期,通過Expire返回結果判斷key是否存在,既然是熱點數據通過不斷地續期也就不會過期了

還有一種簡單有效的方法就是通過singleflight來控制,singleflight的原理是當同時有很多請求同時到來時,最終只有一個請求會最終訪問到資源,其他請求都會等待結果然後返回。獲取商品詳情使用singleflight進行保護示例如下:

func (l *ProductLogic) Product(in *product.ProductItemRequest) (*product.ProductItem, error) {
  v, err, _ := l.svcCtx.SingleGroup.Do(fmt.Sprintf("product:%d", in.ProductId), func() (interface{}, error) {
    return l.svcCtx.ProductModel.FindOne(l.ctx, in.ProductId)
  })
  if err != nil {
    return nil, err
  }
  p := v.(*model.Product)
  return &product.ProductItem{
    ProductId: p.Id,
    Name:      p.Name,
  }, nil
}

緩存穿透

緩存穿透是指要訪問的數據既不在緩存中,也不在數據庫中,導致請求在訪問緩存時,發生緩存缺失,再去訪問數據庫時,發現數據庫中也沒有要訪問的數據。此時也就沒辦法從數據庫中讀出數據再寫入緩存來服務後續的請求,類似的請求如果多的話就會給緩存和數據庫帶來巨大的壓力。

針對緩存穿透問題,解決辦法其實很簡單,就是緩存一個空值,避免每次都透傳到數據庫,緩存的時間可以設置短一點,比如1分鐘,其實上文已經有提到了,當我們訪問不存在的數據的時候,go-zero框架會幫我們自動加上空緩存,比如我們訪問id爲999的商品,該商品在數據庫中是不存在的。

grpcurl -plaintext -d '{"product_id": 999}' 127.0.0.1:8081 product.Product.Product

此時查看緩存,已經幫我添加好了空緩存

127.0.0.1:6379> get cache:product:product:id:999
"*"

緩存雪崩

緩存雪崩時指大量的的應用請求無法在Redis緩存中進行處理,緊接着應用將大量的請求發送到數據庫,導致數據庫被打掛,好慘吶!!緩存雪崩一般是由兩個原因導致的,應對方案也不太一樣。

第一個原因是:緩存中有大量的數據同時過期,導致大量的請求無法得到正常處理。

針對大量數據同時失效帶來的緩存雪崩問題,一般的解決方案是要避免大量的數據設置相同的過期時間,如果業務上的確有要求數據要同時失效,那麼可以在過期時間上加一個較小的隨機數,這樣不同的數據過期時間不同,但差別也不大,避免大量數據同時過期,也基本能滿足業務的需求。

第二個原因是:Redis出現了宕機,沒辦法正常響應請求了,這就會導致大量請求直接打到數據庫,從而發生雪崩

針對這類原因一般我們需要讓我們的數據庫支持熔斷,讓數據庫壓力比較大的時候就觸發熔斷,丟棄掉部分請求,當然熔斷是對業務有損的。

在go-zero的數據庫客戶端是支持熔斷的,如下在ExecCtx方法中使用熔斷進行保護

func (db *commonSqlConn) ExecCtx(ctx context.Context, q string, args ...interface{}) (
  result sql.Result, err error) {
  ctx, span := startSpan(ctx, "Exec")
  defer func() {
    endSpan(span, err)
  }()

  err = db.brk.DoWithAcceptable(func() error {
    var conn *sql.DB
    conn, err = db.connProv()
    if err != nil {
      db.onError(err)
      return err
    }

    result, err = exec(ctx, conn, q, args...)
    return err
  }, db.acceptable)

  return
}

結束語

本篇文章先介紹了go-zero中緩存使用的基本姿勢,接着詳細介紹了使遊標通過緩存索引來實現分頁功能,緊接着介紹了緩存擊穿、緩存穿透、緩存雪崩的概念和應對方案。緩存對於高併發系統來說是重中之重,但是緩存的使用坑還是挺多的,大家在平時項目開發中一定要非常仔細,如果使用不當的話不但不能帶來性能的提升,反而會讓業務代碼變得複雜。

在這裏要非常感謝go-zero社區中的@group和@尋找,最美的心靈兩位同學,他們積極地參與到該項目的開發中,並提了許多改進意見。

希望本篇文章對你有所幫助,謝謝。

每週一、週四更新

代碼倉庫: https://github.com/zhoushuguang/lebron

項目地址

https://github.com/zeromicro/go-zero

歡迎使用 go-zerostar 支持我們!

微信交流羣

關注『微服務實踐』公衆號並點擊 交流羣 獲取社區羣二維碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章