前兩篇文章我們介紹了快取使用的各種最佳實踐,首先介紹了快取使用的基本姿勢,分別是如何利用go-zero自動生成的快取和邏輯程式碼中快取程式碼如何寫,接著講解了在面對快取的穿透、擊穿、雪崩等常見問題時的解決方案,最後還重點講解了如何保證快取的一致性。因為快取對於高併發服務來說實在是太重要了,所以這篇文章我們還會繼續一起學習下快取相關的知識。
本地快取
當我們遇到極端熱點資料查詢的時候,這個時候就要考慮本地快取了。熱點本地快取主要部署在應用伺服器的程式碼中,用於阻擋熱點查詢對於Redis等分散式快取或者資料庫的壓力。
在我們的商城中,首頁Banner中會放一些廣告商品或者推薦商品,這些商品的資訊由運營在管理後臺錄入和變更。這些商品的請求量非常大,即使是Redis也很難扛住,所以這裡我們可以使用本地快取來進行優化。
在product庫中先建一張商品運營表product_operation,為了簡化只保留必要欄位,product_id為推廣運營的商品id,status為運營商品的狀態,status為1的時候會在首頁Banner中展示該商品。
CREATE TABLE `product_operation` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`product_id` bigint unsigned NOT NULL DEFAULT 0 COMMENT '商品id',
`status` int NOT NULL DEFAULT '1' COMMENT '運營商品狀態 0-下線 1-上線',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '建立時間',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新時間',
PRIMARY KEY (`id`),
KEY `ix_update_time` (`update_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品運營表';
本地快取的實現比較簡單,我們可以使用map來自己實現,在go-zero的collection中提供了Cache來實現本地快取的功能,我們直接拿來用,重複造輪子從來不是一個明智的選擇,localCacheExpire為本地快取過期時間,Cache提供了Get和Set方法,使用非常簡單
localCache, err := collection.NewCache(localCacheExpire)
先從本地快取中查詢,如果命中快取則直接返回。沒有命中快取的話需要先從資料庫中查詢運營位商品id,然後再聚合商品資訊,最後回塞到本地快取中。詳細程式碼邏輯如下:
func (l *OperationProductsLogic) OperationProducts(in *product.OperationProductsRequest) (*product.OperationProductsResponse, error) {
opProducts, ok := l.svcCtx.LocalCache.Get(operationProductsKey)
if ok {
return &product.OperationProductsResponse{Products: opProducts.([]*product.ProductItem)}, nil
}
pos, err := l.svcCtx.OperationModel.OperationProducts(l.ctx, validStatus)
if err != nil {
return nil, err
}
var pids []int64
for _, p := range pos {
pids = append(pids, p.ProductId)
}
products, err := l.productListLogic.productsByIds(l.ctx, pids)
if err != nil {
return nil, err
}
var pItems []*product.ProductItem
for _, p := range products {
pItems = append(pItems, &product.ProductItem{
ProductId: p.Id,
Name: p.Name,
})
}
l.svcCtx.LocalCache.Set(operationProductsKey, pItems)
return &product.OperationProductsResponse{Products: pItems}, nil
}
使用grpurl除錯工具請求介面,第一次請求cache miss後,後面的請求都會命中本地快取,等到本地快取過期後又會重新回源db載入資料到本地快取中
~ grpcurl -plaintext -d '{}' 127.0.0.1:8081 product.Product.OperationProducts
{
"products": [
{
"productId": "32",
"name": "電風扇6"
},
{
"productId": "31",
"name": "電風扇5"
},
{
"productId": "33",
"name": "電風扇7"
}
]
}
注意,並不是所有資訊都適用於本地快取,本地快取的特點是請求量超高,同時業務上能夠允許一定的不一致,因為本地快取一般不會主動做更新操作,需要等到過期後重新回源db後再更新。所以在業務中要視情況而定看是否需要使用本地快取。
自動識別熱點資料
首頁Banner場景是由運營人員來配置的,也就是我們能提前知道可能產生的熱點資料,但有些情況我們是不能提前預知資料會成為熱點的。所以就需要我們能自適應地自動的識別這些熱點資料,然後把這些資料提升為本地快取。
我們維護一個滑動視窗,比如滑動視窗設定為10s,就是要統計這10s內有哪些key被高頻訪問,一個滑動視窗中對應多個Bucket,每個Bucket中對應一個map,map的key為商品的id,value為商品對應的請求次數。接著我們可以定時的(比如10s)去統計當前所有Buckets中的key的資料,然後把這些資料匯入到大頂堆中,輕而易舉的可以從大頂堆中獲取topK的key,我們可以設定一個閾值,比如在一個滑動視窗時間內某一個key訪問頻次超過500次,就認為該key為熱點key,從而自動地把該key升級為本地快取。
快取使用技巧
下面介紹一些快取使用的小技巧
- key的命名要儘量易讀,即見名知意,在易讀的前提下長度要儘可能的小,以減少資源的佔用,對於value來說可以用int就儘量不要用string,對於小於N的value,redis內部有shared_object快取。
- 在redis使用hash的情況下進行key的拆分,同一個hash key會落到同一個redis節點,hash過大的情況下會導致記憶體以及請求分佈的不均勻,考慮對hash進行拆分為小的hash,使得節點記憶體均勻避免單節點請求熱點。
- 為了避免不存在的資料請求,導致每次請求都快取miss直接打到資料庫中,進行空快取的設定。
- 快取中需要存物件的時候,序列化儘量使用protobuf,儘可能減少資料大小。
- 新增資料的時候要保證快取務必存在的情況下再去操作新增,使用Expire來判斷快取是否存在。
- 對於儲存每日登入場景的需求,可以使用BITSET,為了避免單個BITSET過大或者熱點,可以進行sharding。
- 在使用sorted set的時候,避免使用zrange或者zrevrange返回過大的集合,複雜度較高。
- 在進行快取操作的時候儘量使用PIPELINE,但也要注意避免集合過大。
- 避免超大的value。
- 快取儘量要設定過期時間。
- 慎用全量操作命令,比如Hash型別的HGETALL、Set型別的SMEMBERS等,這些操作會對Hash和Set的底層資料結構進行全量掃描,如果資料量較多的話,會阻塞Redis主執行緒。
- 獲取集合型別的全量資料可以使用SSCAN、HSCAN等命令分批返回集合中的資料,減少對主執行緒的阻塞。
- 慎用MONITOR命令,MONITOR命令會把監控到的內容持續寫入輸出緩衝區,如果線上命令操作很多,輸出緩衝區很快就會溢位,會對Redis效能造成影響。
- 生產環境禁用KEYS、FLUSHALL、FLUSHDB等命令。
結束語
本篇文章介紹瞭如何使用本地熱點快取應對超高的請求,熱點快取又分為已知的熱點快取和未知的熱點快取。已知的熱點快取比較簡單,從資料庫中提前載入到記憶體中即可,未知的熱點快取我們需要自適應的識別出熱點的資料,然後把這些熱點的資料升級為本地快取。最後介紹了一些實際生產中快取使用的一些小技巧,在生產環境中要活靈活用盡量避免問題的產生。
希望本篇文章對你有所幫助,謝謝。
每週一、週四更新
程式碼倉庫: https://github.com/zhoushuguang/lebron
專案地址
https://github.com/zeromicro/go-zero
歡迎使用 go-zero
並 star 支援我們!
微信交流群
關注『微服務實踐』公眾號並點選 交流群 獲取社群群二維碼。