作者:京東云開發者-京東零售 高凱
鏈接:https://my.oschina.NET/u/4090830/blog/10139889
一、背景
在京東到家購物車系統中,用戶基于門店能夠對商品進行加車操作。用戶與門店商品使用 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 億
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 操作會拷貝父進程的頁表項,如果過大,會占用更多頁表,主線程阻塞拷貝需要一定的時間。
四、如何檢測 BigKey4.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' with3bytes
[05.14%] Biggest listfoundso far'mylist'with100004items
[35.77%] Biggest stringfoundso far'counter:__rand_int__'with6bytes
[73.91%] Biggest hashfoundso far'myobject'with3fields
-------- 第一部分end -------
-------- summary -------
-------- 第二部分start -------
Sampled 506keysinthe keyspace!
Total keylengthinbytesis3452(avglen6.82)
Biggest stringfound'counter:__rand_int__'has 6bytes
Biggest listfound'mylist'has 100004items
Biggest hashfound'myobject'has 3fields
-------- 第二部分end -------
-------- 第三部分start -------
504strings with1403bytes(99.60% ofkeys, avgsize2.78)
1lists with100004items (00.20% ofkeys, avgsize100004.00)
0setswith0members (00.00% ofkeys, avgsize0.00)
1hashs with3fields(00.20% ofkeys, avgsize3.00)
0zsets with0members (00.00% ofkeys, avgsize0.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 是哪個等信息)
typedefstruct{
char*name;//數據類型,如string
char*sizecmd;//查詢大小命令,如string會調用STRLEN
char*sizeunit;//單位,string類型為bytes,而hash為field
unsignedlonglongbiggest;//最大key信息域,此數據類型最大key的大小,如string類型是多少bytes,hash為多少field
unsignedlonglongcount;//統計信息域,此數據類型的key的總數
unsignedlonglongtotalsize;//統計信息域,此數據類型的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 的大?。╯ize)
/* 檢索類型,然后檢索大小*/
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 %sn",
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 farn", pct, sampled);
}
}
4.1.2、第二部分是如何執行的?
1. 輸出統計信息、最大 key 信息
/* We're done */
printf("n-------- summary -------nn");
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)nn",
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 %sn", 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?
五、如何解決 Bigkey5.1、提前預防
-
設置過期時間,盡量過期時間分散,防止同一時間過期;
-
存儲為 String 類型的 JSON,可以刪除不使用的 Filed;
例如對象為{"userName":"京東到家","ciyt":"北京"},如果只需要用到 userName 屬性,那就定義新對象,只具有 userName 屬性,精簡緩存中數據
-
存儲為 String 類型的 JSON,利用 @JsonProperty 注解讓 FiledName 字符集縮小,代碼例子如下。但是存在緩存數據識別性低的缺點;
importorg.codehaus.jackson.map.ObjectMApper;
importJAVA.io.IOException;
publicclassJsonTest{
@JsonProperty("u")
privateString userName;
publicString getUserName{
returnuserName;
}
publicvoidsetUserName(String userName){
this.userName = userName;
}
publicstaticvoidmain(String[] args)throwsIOException {
JsonTest output = newJsonTest;
output.setUserName("京東到家");
System.out.println(newObjectMapper.writeValueAsString(output));
String json = "{"u":"京東到家"}";
JsonTest r1 = newObjectMapper.readValue(json, JsonTest.class);
System.out.println(r1.getUserName);
}
}
{"u":"京東到家"}
京東到家
-
采用壓縮算法,利用時間換空間,進行序列化與反序列化。同時也存在緩存數據識別性低的缺點;
-
在業務上進行干預,設置閾值。比如用戶購物車的商品數量,或者領券的數量,不能無限的增大;
此命令在 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);
return1;
} else{
// 鍵不存在
return0;
}
}
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。
publicvoidscanRedis(Stringcursor,StringendCursor) {
ReloadableJimClientFactory factory = newReloadableJimClientFactory;
StringjimUrl = "jim://xxx/546";
factory.setJimUrl(jimUrl);
Cluster client = factory.getClient;
ScanOptions.ScanOptionsBuilder scanOptions = ScanOptions.scanOptions;
scanOptions.count(100);
Booleanend = false;
int k = 0;
while(!end) {
KeyScanResult< String> result = client.scan(cursor, scanOptions.build);
for(Stringkey :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 方法;
delGenericCommand(c,server.lazyfree_lazy_user_del);
}
voidunlinkCommand(client *c) {
delGenericCommand(c,1);
}
- lazyfree-lazy-user-del 支持 yes 或者 no。默認是 no;
- 如果設置為 yes,那么 del 命令就等價于 unlink,也是異步刪除,這也同時解釋了之前咱們的問題,為什么設置了 lazyfree-lazy-user-del 后,del 命令就為異步刪除。
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) {
returndbGenericDelete(db, key, 0, DB_FLAG_KEY_DELETED);
}
int dbAsyncDelete(redisDb *db, robj *key) {
returndbGenericDelete(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);
return1;
} else{
return0;
}
}
如果為異步刪除,調用 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;
returnql->len;
} elseif(obj->type == OBJ_SET && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
returndictSize(ht);
} elseif(obj->type == OBJ_ZSET && obj->encoding == OBJ_ENCODING_SKIPLIST){
zset *zs = obj->ptr;
returnzs->zsl->length;
} elseif(obj->type == OBJ_HASH && obj->encoding == OBJ_ENCODING_HT) {
dict *ht = obj->ptr;
returndictSize(ht);
} elseif(obj->type == OBJ_STREAM) {
...
returneffort;
} elseif(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. */
returneffort == 0? ULONG_MAX : effort;
} else{
return1; /* 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?
?http://redisbook.com/?
?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
END