淺析Redis大Key

資料庫工作筆記發表於2023-11-03

一、背景

在京東到家購物車系統中,使用者基於門店能夠對商品進行加車操作。使用者與門店商品使用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;
            }
        }
    }


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

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

作者:京東零售 高凱

來源:京東雲開發者社群 轉載請註明來源

相關文章