一、背景
在京東到家購物車系統中,使用者基於門店能夠對商品進行加車操作。使用者與門店商品使用Redis的Hash型別儲存,如下程式碼塊所示。不知細心的你有沒有發現,如果單門店加車商品過多,或者門店過多時,此Key就會越來越大,從而影響線上業務。
userPin:{
storeId:{門店下加車的所有商品基本資訊},
storeId:{門店下加車的所有商品基本資訊},
......
}
二、BigKey的界定和如何產生
2.1、BigKey的界定
BigKey稱為大Key,通常以Key對應Value的儲存大小,或者Key對應Value的數量來進行綜合判斷。對於大Key也沒有嚴格的定義區分,針對String與非String結構,給出如下定義:
- String:String型別的 Key 對應的 Value 超過 10KB
- 非String結構(Hash,Set,ZSet,List):Value的數量達到10000個,或者Vaule的總大小為100KB
- 叢集中Key的總數超過1億
2.2、如何產生
1、資料結構設定不合理,例如集合中元素唯一時,應該使用Set替換List;
2、針對業務缺少預估性,沒有預見Value動態增長;
3、Key沒有設定過期時間,把快取當成垃圾桶,一直再往裡面扔,但是從不處理。
三、BigKey的危害
3.1、資料傾斜
redis資料傾斜分為資料訪問傾斜和資料量傾斜,會導致該Key所在的資料分片節點CPU使用率、頻寬使用率升高,從而影響該分片上所有Key的處理。
資料訪問傾斜:某節點中key的QPS高於其他節點中的Key
資料量傾斜:某節點中key的大小高於其他節點中的Key,如下圖,例項1中的Key1儲存高於其他例項。
3.2、網路阻塞
Redis伺服器是一個事件驅動程式,有檔案事件和時間事件,檔案事件和時間事件都是主執行緒完成。其中檔案事件就是伺服器對套接字操作的抽象,客戶端與服務端的通訊會產生相應的檔案事件,伺服器透過監聽並處理這些事件來完成一系列網路通訊操作。
Redis基於Reactor模式開發了自己的網路事件處理器,即檔案事件處理器,該處理器內部使用I/O多路複用程式,可同時監聽多個套接字,並根據套接字執行的任務來關聯不同的事件處理器。檔案事件處理器以單執行緒的方式執行,但是透過I/O多路複用程式來監聽多個套接字,既實現了高效能網路通訊模型,又保持了內部單執行緒設計的簡單性。檔案事件處理器構成如下圖:
檔案事件是對套接字操作的抽象,包括連線應答,寫入,讀取,關閉,因為一個伺服器會連線多個套接字,所以檔案事件可能併發出現,即使檔案事件併發的出現,但是I/O多路複用程式會將套接字放入一個佇列,透過佇列有序的,同步的每次一個套接字的方式向檔案事件分派器傳送套接字,當讓一個套接字產生的事件被處理完畢後,I/O多路複用程式才會繼續向檔案事件分派器傳送下一個套接字,當有大key時,單次操作時間延長,導致網路阻塞。
3.3、慢查詢
嚴重影響 QPS 、TP99 等指標,對大Key進行的慢操作會導致後續的命令被阻塞,從而導致一系列慢查詢。
3.4、CPU壓力
當單Key過大時,每一次訪問此Key都可能會造成Redis阻塞,其他請求只能等待了。如果應用中設定了超時等,那麼上層就會丟擲異常資訊。最後刪除的時候也會造成redis阻塞,到時候記憶體中資料量過大,就會造成CPU負載過高。單個分片cpu佔用率過高,其他分片無法擁有cpu資源,從而被影響。此外,大 key 對持久化也有些影響。fork 操作會複製父程式的頁表項,如果過大,會佔用更多頁表,主執行緒阻塞複製需要一定的時間。
四、如何檢測BigKey
4.1、redis-cli --bigkeys
首先我們從執行結果出發。首先透過指令碼插入一些資料到redis中,然後執行redis-cli的--bigkeys選項
$ redis-cli --bigkeys
# Scanning the entire keyspace to find biggest keys as well as
# average sizes per key type. You can use -i 0.01 to sleep 0.01 sec
# per SCAN command (not usually needed).
-------- 第一部分start -------
[00.00%] Biggest string found so far 'key-419' with 3 bytes
[05.14%] Biggest list found so far 'mylist' with 100004 items
[35.77%] Biggest string found so far 'counter:__rand_int__' with 6 bytes
[73.91%] Biggest hash found so far 'myobject' with 3 fields
-------- 第一部分end -------
-------- summary -------
-------- 第二部分start -------
Sampled 506 keys in the keyspace!
Total key length in bytes is 3452 (avg len 6.82)
Biggest string found 'counter:__rand_int__' has 6 bytes
Biggest list found 'mylist' has 100004 items
Biggest hash found 'myobject' has 3 fields
-------- 第二部分end -------
-------- 第三部分start -------
504 strings with 1403 bytes (99.60% of keys, avg size 2.78)
1 lists with 100004 items (00.20% of keys, avg size 100004.00)
0 sets with 0 members (00.00% of keys, avg size 0.00)
1 hashs with 3 fields (00.20% of keys, avg size 3.00)
0 zsets with 0 members (00.00% of keys, avg size 0.00)
-------- 第三部分end -------
以下我們分三步對bigkeys選項原始碼原理進行解析,簡要流程如下圖:
4.1.1、第一部分是如何進行找key的呢?
Redis找bigkey的函式是static void findBigKeys(int memkeys, unsigned memkeys_samples),因為--memkeys選項和--bigkeys選項是公用同一個函式,所以使用memkeys時會有額外兩個引數memkeys、memkeys_sample,但這和--bigkeys選項沒關係,所以不用理會。findBigKeys具體函式框架為:
1.申請6個變數用以統計6種資料型別的資訊(每個變數記錄該資料型別的key的總數量、bigkey是哪個等資訊)
typedef struct {
char *name;//資料型別,如string
char *sizecmd;//查詢大小命令,如string會呼叫STRLEN
char *sizeunit;//單位,string型別為bytes,而hash為field
unsigned long long biggest;//最大key資訊域,此資料型別最大key的大小,如string型別是多少bytes,hash為多少field
unsigned long long count;//統計資訊域,此資料型別的key的總數
unsigned long long totalsize;//統計資訊域,此資料型別的key的總大小,如string型別是全部string總共多少bytes,hash為全部hash總共多少field
sds biggest_key;//最大key資訊域,此資料型別最大key的鍵名,之所以在資料結構末尾是考慮位元組對齊
} typeinfo;
dict *types_dict = dictCreate(&typeinfoDictType);
typeinfo_add(types_dict, "string", &type_string);
typeinfo_add(types_dict, "list", &type_list);
typeinfo_add(types_dict, "set", &type_set);
typeinfo_add(types_dict, "hash", &type_hash);
typeinfo_add(types_dict, "zset", &type_zset);
typeinfo_add(types_dict, "stream", &type_stream);
2.呼叫scan命令迭代地獲取一批key(注意只是key的名稱,型別和大小scan命令不返回)
/* scan迴圈掃描 */
do {
/* 計算完成的百分比情況 */
pct = 100 * (double)sampled/total_keys;//這裡記錄下掃描的進度
/* 獲取一些鍵並指向鍵陣列 */
reply = sendScan(&it);//這裡傳送SCAN命令,結果儲存在reply中
keys = reply->element[1];//keys來儲存這次scan獲取的所有鍵名,注意只是鍵名,每個鍵的資料型別是不知道的。
......
} while(it != 0);
3.對每個key獲取它的資料型別(type)和key的大小(size)
/* 檢索型別,然後檢索大小*/
getKeyTypes(types_dict, keys, types);
getKeySizes(keys, types, sizes, memkeys, memkeys_samples);
4.如果key的大小大於已記錄的最大值的key,則更新最大key的資訊
/* Now update our stats */
for(i=0;i<keys->elements;i++) {
......//前面已解析
//如果遍歷到比記錄值更大的key時
if(type->biggest<sizes[i]) {
/* Keep track of biggest key name for this type */
if (type->biggest_key)
sdsfree(type->biggest_key);
//更新最大key的鍵名
type->biggest_key = sdscatrepr(sdsempty(), keys->element[i]->str, keys->element[i]->len);
if(!type->biggest_key) {
fprintf(stderr, "Failed to allocate memory for key!\n");
exit(1);
}
//每當找到一個更大的key時則輸出該key資訊
printf(
"[%05.2f%%] Biggest %-6s found so far '%s' with %llu %s\n",
pct, type->name, type->biggest_key, sizes[i],
!memkeys? type->sizeunit: "bytes");
/* Keep track of the biggest size for this type */
//更新最大key的大小
type->biggest = sizes[i];
}
......//前面已解析
}
5.對每個key更新對應資料型別的統計資訊
/* 現在更新統計資料 */
for(i=0;i<keys->elements;i++) {
typeinfo *type = types[i];
/* 跳過在SCAN和TYPE之間消失的鍵 */
if(!type)
continue;
//對每個key更新每種資料型別的統計資訊
type->totalsize += sizes[i];//某資料型別(如string)的總大小增加
type->count++;//某資料型別的key數量增加
totlen += keys->element[i]->len;//totlen不針對某個具體資料型別,將所有key的鍵名的長度進行統計,注意只統計鍵名長度。
sampled++;//已經遍歷的key數量
......//後續解析
/* 更新整體進度 */
if(sampled % 1000000 == 0) {
printf("[%05.2f%%] Sampled %llu keys so far\n", pct, sampled);
}
}
4.1.2、第二部分是如何執行的?
1.輸出統計資訊、最大key資訊
/* We're done */
printf("\n-------- summary -------\n\n");
if (force_cancel_loop) printf("[%05.2f%%] ", pct);
printf("Sampled %llu keys in the keyspace!\n", sampled);
printf("Total key length in bytes is %llu (avg len %.2f)\n\n",
totlen, totlen ? (double)totlen/sampled : 0);
2.首先輸出總共掃描了多少個key、所有key的總長度是多少。
/* Output the biggest keys we found, for types we did find */
di = dictGetIterator(types_dict);
while ((de = dictNext(di))) {
typeinfo *type = dictGetVal(de);
if(type->biggest_key) {
printf("Biggest %6s found '%s' has %llu %s\n", type->name, type->biggest_key,
type->biggest, !memkeys? type->sizeunit: "bytes");
}
}
dictReleaseIterator(di);
4.1.3、第三部分是如何執行的?
di為字典迭代器,用以遍歷types_dict裡面的所有dictEntry。de = dictNext(di)則可以獲取下一個dictEntry,de是指向dictEntry的指標。又因為typeinfo結構體儲存在dictEntry的v域中,所以用dictGetVal獲取。然後就是輸出typeinfo結構體裡面儲存的最大key相關的資料,包括最大key的鍵名和大小。
di = dictGetIterator(types_dict);
while ((de = dictNext(di))) {
typeinfo *type = dictGetVal(de);
printf("%llu %ss with %llu %s (%05.2f%% of keys, avg size %.2f)\n",
type->count, type->name, type->totalsize, !memkeys? type->sizeunit: "bytes",
sampled ? 100 * (double)type->count/sampled : 0,
type->count ? (double)type->totalsize/type->count : 0);
}
dictReleaseIterator(di);
4.2、使用開源工具發現大Key
在不影響線上服務的同時得到精確的分析報告。使用redis-rdb-tools工具以定製化方式找出大Key,該工具能夠對Redis的RDB檔案進行定製化的分析,但由於分析RDB檔案為離線工作,因此對線上服務不會有任何影響,這是它的最大優點但同時也是它的最大缺點:離線分析代表著分析結果的較差時效性。對於一個較大的RDB檔案,它的分析可能會持續很久很久。
redis-rdb-tools的專案地址為:https://github.com/sripathikrishnan/redis-rdb-tools
五、如何解決Bigkey
5.1、提前預防
- 設定過期時間,儘量過期時間分散,防止同一時間過期;
- 儲存為String型別的JSON,可以刪除不使用的Filed;
例如物件為{"userName":"京東到家","ciyt":"北京"},如果只需要用到userName屬性,那就定義新物件,只具有userName屬性,精簡快取中資料
- 儲存為String型別的JSON,利用@JsonProperty註解讓FiledName字符集縮小,程式碼例子如下。但是存在快取資料識別性低的缺點;
import org.codehaus.jackson.annotate.JsonProperty;
import org.codehaus.jackson.map.ObjectMapper;
import java.io.IOException;
public class JsonTest {
@JsonProperty("u")
private String userName;
public String getUserName() {
return userName;
}
public void setUserName(String userName) {
this.userName = userName;
}
public static void main(String[] args) throws IOException {
JsonTest output = new JsonTest();
output.setUserName("京東到家");
System.out.println(new ObjectMapper().writeValueAsString(output));
String json = "{\"u\":\"京東到家\"}";
JsonTest r1 = new ObjectMapper().readValue(json, JsonTest.class);
System.out.println(r1.getUserName());
}
}
{"u":"京東到家"}
京東到家
- 採用壓縮演算法,利用時間換空間,進行序列化與反序列化。同時也存在快取資料識別性低的缺點;
- 在業務上進行干預,設定閾值。比如使用者購物車的商品數量,或者領券的數量,不能無限的增大;
5.2、如何優雅刪除BigKey
5.2.1、DEL
此命令在Redis不同版本中刪除的機制並不相同,以下分別進行分析:
redis_version < 4.0 版本:在主執行緒中同步刪除,刪除大Key會阻塞主執行緒,見如下原始碼基於redis 3.0版本。那針對非String結構資料,可以先透過SCAN命令讀取部分資料,然後逐步進行刪除,避免一次性刪除大key導致Redis阻塞。
// 從資料庫中刪除給定的鍵,鍵的值,以及鍵的過期時間。
// 刪除成功返回 1,因為鍵不存在而導致刪除失敗時,返回 0
int dbDelete(redisDb *db, robj *key) {
// 刪除鍵的過期時間
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 刪除鍵值對
if (dictDelete(db->dict,key->ptr) == DICT_OK) {
// 如果開啟了叢集模式,那麼從槽中刪除給定的鍵
if (server.cluster_enabled) slotToKeyDel(key);
return 1;
} else {
// 鍵不存在
return 0;
}
}
4.0 版本 < redis_version < 6.0 版本:引入lazy-free,手動開啟lazy-free時,有4個選項可以控制,分別對應不同場景下,是否開啟非同步釋放記憶體機制:
- lazyfree-lazy-expire:key在過期刪除時嘗試非同步釋放記憶體
- lazyfree-lazy-eviction:記憶體達到maxmemory並設定了淘汰策略時嘗試非同步釋放記憶體
- lazyfree-lazy-server-del:執行RENAME/MOVE等命令或需要覆蓋一個key時,刪除舊key嘗試非同步釋放記憶體
- replica-lazy-flush:主從全量同步,從庫清空資料庫時非同步釋放記憶體
開啟lazy-free後,Redis在釋放一個key的記憶體時,首先會評估代價,如果釋放記憶體的代價很小,那麼就直接在主執行緒中操作了,沒必要放到非同步執行緒中執行
redis_version >= 6.0 版本:引入lazyfree-lazy-user-del,只要開啟了,del直接可以非同步刪除key,不會阻塞主執行緒。具體是為什麼呢,現在先賣個關子,在下面進行解析。
5.2.2、SCAN
SCAN命令可以幫助在不阻塞主執行緒的情況下逐步遍歷大量的鍵,以及避免對資料庫的阻塞。以下程式碼是利用scan來掃描叢集中的Key。
public void scanRedis(String cursor,String endCursor) {
ReloadableJimClientFactory factory = new ReloadableJimClientFactory();
String jimUrl = "jim://xxx/546";
factory.setJimUrl(jimUrl);
Cluster client = factory.getClient();
ScanOptions.ScanOptionsBuilder scanOptions = ScanOptions.scanOptions();
scanOptions.count(100);
Boolean end = false;
int k = 0;
while (!end) {
KeyScanResult< String > result = client.scan(cursor, scanOptions.build());
for (String key :result.getResult()){
if (client.ttl(key) == -1){
logger.info("永久key為:{}" , key);
}
}
k++;
cursor = result.getCursor();
if (endCursor.equals(cursor)){
break;
}
}
}
5.2.3、UNLINK
Redis 4.0 提供了 lazy delete (unlink命令) ,下面基於原始碼(redis_version:7.2版本)分析下實現原理
- del與unlink命令底層都呼叫了delGenericCommand()方法;
void delCommand(client *c) {
delGenericCommand(c,server.lazyfree_lazy_user_del);
}
void unlinkCommand(client *c) {
delGenericCommand(c,1);
}
- lazyfree-lazy-user-del支援yes或者no。預設是no;
- 如果設定為yes,那麼del命令就等價於unlink,也是非同步刪除,這也同時解釋了之前我們們的問題,為什麼設定了lazyfree-lazy-user-del後,del命令就為非同步刪除。
void delGenericCommand(client *c, int lazy) {
int numdel = 0, j;
// 遍歷所有輸入鍵
for (j = 1; j < c->argc; j++) {
// 先刪除過期的鍵
expireIfNeeded(c->db,c->argv[j],0);
int deleted = lazy ? dbAsyncDelete(c->db,c->argv[j]) :
dbSyncDelete(c->db,c->argv[j]);
// 嘗試刪除鍵
if (deleted) {
// 刪除鍵成功,傳送通知
signalModifiedKey(c,c->db,c->argv[j]);
notifyKeyspaceEvent(NOTIFY_GENERIC,"del",c->argv[j],c->db->id);
server.dirty++;
// 成功刪除才增加 deleted 計數器的值
numdel++;
}
}
// 返回被刪除鍵的數量
addReplyLongLong(c,numdel);
}
下面分析非同步刪除dbAsyncDelete()與同步刪除dbSyncDelete(),底層同時也是呼叫dbGenericDelete()方法
int dbSyncDelete(redisDb *db, robj *key) {
return dbGenericDelete(db, key, 0, DB_FLAG_KEY_DELETED);
}
int dbAsyncDelete(redisDb *db, robj *key) {
return dbGenericDelete(db, key, 1, DB_FLAG_KEY_DELETED);
}
int dbGenericDelete(redisDb *db, robj *key, int async, int flags) {
dictEntry **plink;
int table;
dictEntry *de = dictTwoPhaseUnlinkFind(db->dict,key->ptr,&plink,&table);
if (de) {
robj *val = dictGetVal(de);
/* RM_StringDMA may call dbUnshareStringValue which may free val, so we need to incr to retain val */
incrRefCount(val);
/* Tells the module that the key has been unlinked from the database. */
moduleNotifyKeyUnlink(key,val,db->id,flags);
/* We want to try to unblock any module clients or clients using a blocking XREADGROUP */
signalDeletedKeyAsReady(db,key,val->type);
// 在呼叫用freeObjAsync之前,我們應該先呼叫decrRefCount。否則,引用計數可能大於1,導致freeObjAsync無法正常工作。
decrRefCount(val);
// 如果是非同步刪除,則會呼叫 freeObjAsync 非同步釋放 value 佔用的記憶體。同時,將 key 對應的 value 設定為 NULL。
if (async) {
/* Because of dbUnshareStringValue, the val in de may change. */
freeObjAsync(key, dictGetVal(de), db->id);
dictSetVal(db->dict, de, NULL);
}
// 如果是叢集模式,還會更新對應 slot 的相關資訊
if (server.cluster_enabled) slotToKeyDelEntry(de, db);
/* Deleting an entry from the expires dict will not free the sds of the key, because it is shared with the main dictionary. */
if (dictSize(db->expires) > 0) dictDelete(db->expires,key->ptr);
// 釋放記憶體
dictTwoPhaseUnlinkFree(db->dict,de,plink,table);
return 1;
} else {
return 0;
}
}
如果為非同步刪除,呼叫freeObjAsync()方法,根據以下程式碼分析:
#define LAZYFREE_THRESHOLD 64
/* Free an object, if the object is huge enough, free it in async way. */
void freeObjAsync(robj *key, robj *obj, int dbid) {
size_t free_effort = lazyfreeGetFreeEffort(key,obj,dbid);
if (free_effort > LAZYFREE_THRESHOLD && obj->refcount == 1) {
atomicIncr(lazyfree_objects,1);
bioCreateLazyFreeJob(lazyfreeFreeObject,1,obj);
} else {
decrRefCount(obj);
}
}
size_t lazyfreeGetFreeEffort(robj *key, robj *obj, int dbid) {
if (obj->type == OBJ_LIST && obj->encoding == OBJ_ENCODING_QUICKLIST) {
quicklist *ql = obj->ptr;
return ql->len;
} else if (obj->type == OBJ_SET && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
return dictSize(ht);
} else if (obj->type == OBJ_ZSET && obj->encoding == OBJ_ENCODING_SKIPLIST){
zset *zs = obj->ptr;
return zs->zsl->length;
} else if (obj->type == OBJ_HASH && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
return dictSize(ht);
} else if (obj->type == OBJ_STREAM) {
...
return effort;
} else if (obj->type == OBJ_MODULE) {
size_t effort = moduleGetFreeEffort(key, obj, dbid);
/* If the module's free_effort returns 0, we will use asynchronous free
* memory by default. */
return effort == 0 ? ULONG_MAX : effort;
} else {
return 1; /* Everything else is a single allocation. */
}
}
分析後我們們可以得出如下結論:
- 當Hash/Set底層採用雜湊表儲存(非ziplist/int編碼儲存)時,並且元素數量超過64個
- 當ZSet底層採用跳錶儲存(非ziplist編碼儲存)時,並且元素數量超過64個
- 當List連結串列節點數量超過64個(注意,不是元素數量,而是連結串列節點的數量,List的實現是在每個節點包含了若干個元素的資料,這些元素採用ziplist儲存)
- refcount == 1 就是在沒有引用這個Key時
只有以上這些情況,在刪除key釋放記憶體時,才會真正放到非同步執行緒中執行,其他情況一律還是在主執行緒操作。也就是說String(不管記憶體佔用多大)、List(少量元素)、Set(int編碼儲存)、Hash/ZSet(ziplist編碼儲存)這些情況下的key在釋放記憶體時,依舊在主執行緒中操作。
5.3、分而治之
採用經典演算法“分治法”,將大而化小。針對String和集合型別的Key,可以採用如下方式:
- String型別的大Key:可以嘗試將物件分拆成幾個Key-Value, 使用MGET或者多個GET組成的pipeline獲取值,分拆單次操作的壓力,對於叢集來說可以將操作壓力平攤到多個分片上,降低對單個分片的影響。
- 集合型別的大Key,並且需要整存整取要在設計上嚴格禁止這種場景的出現,如無法拆分,有效的方法是將該大Key從JIMDB去除,單獨放到其他儲存介質上。
- 集合型別的大Key,每次只需操作部分元素:將集合型別中的元素分拆。以Hash型別為例,可以在客戶端定義一個分拆Key的數量N,每次對HGET和HSET操作的field計算雜湊值並取模N,確定該field落在哪個Key上。
如果線上服務強依賴Redis,需要考慮到如何做到“無感”,並保證資料一致性。我們們基本上可以採用三步走策略,如下圖所示。分別是進行雙寫,雙讀校驗,最後讀新Key。在此基礎上可以設定開關,做到上線後的平穩遷移。
六、總結
綜上所述,針對文章開頭我們們購物車大Key問題,相信你已經有了答案。我們們可以限制門店數,限制門店中的商品數。如果不作限制,我們們也能進行拆分,將大Key分散儲存。例如。將Redis中Key型別改為List,key為使用者與門店唯一鍵,Value為使用者在此門店下的商品。
儲存結構拆分成兩種:
第一種:
userPin:storeId的集合
第二種:
userPin_storeId1:{門店下加車的所有商品基本資訊};
userPin_storeId2:{門店下加車的所有商品基本資訊}
以上介紹了大key的產生、識別、處理,以及如何使用合理策略和技術來應對。在使用Redis過程中,防範大於治理,在治理過程中也要做到業務無感。
七、參考
https://github.com/redis/redis.git
https://github.com/huangz1990/redis-3.0-annotated.git
https://blog.csdn.net/ldw201510803006/article/details/124790121
https://blog.csdn.net/kuangd_1992/article/details/130451679
http://sd.jd.com/article/4930?shareId=119428&isHideShareButton=1
https://www.liujiajia.me/2023/3/28/redis-bigkeys
https://www.51cto.com/article/701990.html
https://help.aliyun.com/document_detail/353223.html
https://juejin.cn/post/7167015025154981895
https://www.jianshu.com/p/9e150d72ffc9
https://zhuanlan.zhihu.com/p/449648332
作者:京東零售 高凱
來源:京東雲開發者社群 轉載請註明來源