Redis的資料型別——探究竟

大愚Talk發表於2019-03-02

接上篇 為什麼要用Redis,今天來聊聊具體的Redis資料型別與命令。本篇是深入理解Redis的一個重要基礎,請坐穩,前方 長文預警。

本系列內容基於:redis-3.2.12

文中不會介紹所有命令,主要是工作中經常遇到的。

平時我們看的大部分資料,都是簡單粗暴的告訴我們這個命令幹嘛,那個命令需要幾個引數。這種方式只會知其然不知其所以然,本文從命令的時間複雜度到用途,再到對應型別在Redis低層採用何種結構儲存資料,希望讓大家認識的更深刻,使用時心裡更有底。

  1. 這裡在閱讀中請注意:雖然很多命令的時間複雜度都是O(n),但要注意其n所代表的具體含義。

  2. 文中會用到 OBJECT ENCODING xxx 來檢查Redis的內部編碼,它其實是讀取的 redisObject 結構體中 encoding 所代表的值。redisObject 對不同型別的資料提供了統一的表現形式。

String型別

應該講這是Redis中使用的最廣泛的資料型別。該型別中的一些命令使用場景非常廣泛。比如:

  • 快取,這是使用非常多的地方;
  • 計數器/限速器技術;
  • 共享Session伺服器也是基於該資料型別
Redis的資料型別——探究竟

注:表格中僅僅說明了String中的12個命令,使用場景也僅列舉了部分。

我們時常被人說教 MSET/MGET 這類命令少用,因為他們的時間複雜度是O(n),但其實這裡注意,n表示的是本次設定或讀取的key個數,所以如果你批量讀取的key並不是很多,每個key的內容也不是很大,那麼使用批量操作命令反而能夠節省網路請求、傳輸的時間。

內部結構

String型別的資料最終是如何在Redis中儲存的呢?如果要細究的話,得先從 SDS 這個結構說起,不過今天先按下不表這原始碼部分的細節,只談其內部儲存的資料結構。最終我們設定的字串都會以三種形式中的一種被儲存下來。

  • Int,8個位元組的長整型,最大值是:0x7fffffffffffffffL
  • Embstr,小於等於44個位元組的字串
  • Raw

結合程式碼來看看Redis對這三種資料結構是如何決策的。當我們在客戶端使用命令 SET test hello,redis 時,客戶端會把命令儲存到一個buf中,然後按照收到的命令先後順序依次執行。這其中有一個函式是:processMultibulkBuffer() ,它內部呼叫了 createStringObject() 函式:

#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
	  // 檢查儲存的字串長度,選擇對應型別
    if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
        return createEmbeddedStringObject(ptr,len);
    else
        return createRawStringObject(ptr,len);
}
複製程式碼

不懂C語言不要緊,這裡就是檢查我們輸入的字串 hello,redis 長度是否超過了 44 ,如果超過了用型別 raw ,沒有則選用 embstr 。實驗看看:

127.0.0.1:6379> SET test 12345678901234567890123456789012345678901234 // len=44
OK
127.0.0.1:6379> OBJECT encoding test
"embstr"
127.0.0.1:6379> SET test 123456789012345678901234567890123456789012345 // len=45
OK
127.0.0.1:6379> OBJECT encoding test
"raw"
複製程式碼

可以看到,一旦超過44,底層型別就變成了:raw 。等等,上面我們不是還提到有一個 int 型別嗎?從函式裡邊完全看不到它的蹤跡啊?不急,當我們輸入的這條命令真的要開始執行時,也就是呼叫函式 setCommand() 時,會觸發一個 tryObjectEncoding() 函式,這個函式的作用是試圖對輸入的字串進行壓縮,繼續看看程式碼:

robj *tryObjectEncoding(robj *o) {
    ... ...
    len = sdslen(s);
	  // 長度小於等於20,並且能夠轉成長整形
    if(len <= 20 && string2l(s,len,&value)) {
        o->encoding = OBJ_ENCODING_INT;
    }
    ... ...
}
複製程式碼

這個函式被我大幅縮水了,但是簡單我們能夠看到它判斷長度是否小於等於20,並且嘗試轉化成整型,看看例子。

9223372036854775807 是8位位元組可表示的最大整數,它的16進位制形式是:0x7fffffffffffffffL

127.0.0.1:6379> SET test 9223372036854775807
OK
127.0.0.1:6379> OBJECT encoding test
"int"
127.0.0.1:6379> SET test 9223372036854775808 // 比上面大1
OK
127.0.0.1:6379> OBJECT encoding test
"embstr"
複製程式碼

至此,關於String的型別選擇流程完畢了。這對我們的參考價值是,我們在使用String型別儲存資料時,要考慮到底層對應不同的型別,不同的型別在Redis內部會執行不同的流程,其所對應的執行效率、記憶體消耗都是不同的。

Hash型別

我們經常用它來儲存一個結構化的資料,比如與一個使用者相關的快取資訊。如果使用普通的String型別,需要對字串進行序列化與反序列化,無疑增加額外開銷,並且每次讀取都只能全部讀取出來。

  • 快取結構化的資料,如:文章資訊,可靈活修改其某一個欄位,如閱讀量。
Redis的資料型別——探究竟

Hash型別儲存的結構話資料,非常像MySQL中的一條記錄,我們可以方便修改某一個欄位,但是它更具靈活性,每個記錄能夠含有不同的欄位。

內部結構

在內部Hash型別資料可能存在兩種型別的資料結構:

  • ZipList,更加節省空間,限制:key與field長度不超過64,key中field的個數不超過512個
  • HashTable

對於Hash,Redis 首先預設給它設定使用 ZipList 資料結構,後續根據條件進行判斷是否需要改變。


void hsetCommand(client *c) {
    int update;
    robj *o;

    if ((o = hashTypeLookupWriteOrCreate(c,c->argv[1])) == NULL) return;
    hashTypeTryConversion(o,c->argv,2,3);// 根據長度決策
    ... ...
    update = hashTypeSet(o,c->argv[2],c->argv[3]);// 根據元素個數決策
    addReply(c, update ? shared.czero : shared.cone);
    ... ...
}
複製程式碼

hashTypeLookupWriteOrCreate() 內部會呼叫 createHashObject() 建立Hash物件。

robj *createHashObject(void) {
    unsigned char *zl = ziplistNew();
    robj *o = createObject(OBJ_HASH, zl);
    o->encoding = OBJ_ENCODING_ZIPLIST;// 設定編碼 ziplist
    return o;
}
複製程式碼

hashTypeTryConversion() 函式內部根據是否超過 hash_max_ziplist_value 限制的長度(64),來決定低層的資料結構。

void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
    int i;

    if (o->encoding != OBJ_ENCODING_ZIPLIST) return;

    for (i = start; i <= end; i++) {
		  // 檢查 field 與 value 長度是否超長
        if (sdsEncodedObject(argv[i]) &&
            sdslen(argv[i]->ptr) > server.hash_max_ziplist_value)
        {
            hashTypeConvert(o, OBJ_ENCODING_HT);
            break;
        }
    }
}
複製程式碼

然後在函式 hashTypeSet() 中檢查field個數是否超過了 hash_max_ziplist_entries 的限制(512個)。

int hashTypeSet(robj *o, robj *field, robj *value) {
    int update = 0;

    if (o->encoding == OBJ_ENCODING_ZIPLIST) {
		  ... ...
        // 檢查field個數是否超過512
        if (hashTypeLength(o) > server.hash_max_ziplist_entries)
            hashTypeConvert(o, OBJ_ENCODING_HT);
    } else if (o->encoding == OBJ_ENCODING_HT) {
		  ... ...
    }
    ... ...
    return update;
}
複製程式碼

來驗證一下上面的邏輯:

127.0.0.1:6379> HSET test name qweqweqwkejkksdjfslfldsjfkldjslkfqweqweqwkejkksdjfslfldsjfkldjsl
(integer) 1
127.0.0.1:6379> HSTRLEN test name
(integer) 64
127.0.0.1:6379> OBJECT encoding test
"ziplist"
127.0.0.1:6379> HSET test name qweqweqwkejkksdjfslfldsjfkldjslkfqweqweqwkejkksdjfslfldsjfkldjslq
(integer) 0
127.0.0.1:6379> HSTRLEN test name
(integer) 65
127.0.0.1:6379> OBJECT encoding test
"hashtable"
複製程式碼

關於key設定超過64,以及field個數超過512的限制情況,大家可自行測試。

List型別

List型別的用途也是非常廣泛,主要概括下常用場景:

  • 訊息佇列:LPUSH + BRPOP(阻塞特徵)
  • 快取:使用者記錄各種記錄,最大特點是可支援分頁
  • 棧:LPUSH + LPOP
  • 佇列:LPUSH + RPOP
  • 有限佇列:LPUSH + LTRIM,可以維持佇列中資料的數量
Redis的資料型別——探究竟

內部結構

List 的資料型別在低層實現有以下幾種:

  • QuickList:它是以ZipList為節點的LinkedList
  • ZipList(省記憶體),在3.2.12版本中發現有地方使用
  • LinkedList,在3.2.12版本中發現有地方使用

網路上有些文章說 LinkedListRedis 4.0 之後的版本沒有再被使用,實際上我發現 Redis 3.2.12 版本中也沒有再使用該結構(不直接做為資料儲存結構),包括 ZipList3.2.12 版本中都沒有再被直接用來儲存資料了。

我們做個實驗來驗證下,我們設定一個List中有 1000 個元素,每個元素value長度都超過 64 個字元。

127.0.0.1:6379> LLEN test
(integer) 1000
127.0.0.1:6379> OBJECT encoding test
"quicklist"
127.0.0.1:6379> LINDEX test 0
"qweqweqwkejkksdjfslfldsjfkldjslkfqweqweqwkejkksdjfslfldsjfkldjslq" // 65個字元
複製程式碼

無論我們是改變列表元素的個數以及元素值的長度,其結構都是 QuickList。還不信的話,我們來看看程式碼:

void pushGenericCommand(client *c, int where) {
    int j, waiting = 0, pushed = 0;
    robj *lobj = lookupKeyWrite(c->db,c->argv[1]);
	  ... ...
    for (j = 2; j < c->argc; j++) {
        c->argv[j] = tryObjectEncoding(c->argv[j]);
        if (!lobj) {
			  // 建立 quick list
            lobj = createQuicklistObject();
            quicklistSetOptions(lobj->ptr, server.list_max_ziplist_size,
                                server.list_compress_depth);
            dbAdd(c->db,c->argv[1],lobj);
        }
        listTypePush(lobj,c->argv[j],where);
        pushed++;
    }
    ... ...
}
複製程式碼

初始話時,呼叫 createQuicklistObject() 設定其低層資料結構是:quick list 。後續流程中沒有地方再對該結構進行轉化。

Set型別

Set 型別的重要特性之一是可以去重、無序。它集合的性質在社交上可以有廣泛的使用。

  • 共同關注
  • 共同喜好
  • 資料去重
Redis的資料型別——探究竟

內部結構

Set低層實現採用了兩種資料結構:

  • IntSet,集合成員都是整數(不能超過最大整數)並且集合成員個數少於512時使用。
  • HashTable

該命令的程式碼如下,其中重要的兩個關於決定型別的呼叫是:setTypeCreate()setTypeAdd()

void saddCommand(client *c) {
    robj *set;
    ... ...
    if (set == NULL) {
		  // 初始化
        set = setTypeCreate(c->argv[2]);
    } else {
        ... ...
    }

    for (j = 2; j < c->argc; j++) {
		  // 內部會檢查元素個數是否擴充到需要改變低層結構
        if (setTypeAdd(set,c->argv[j])) added++;
    }
    ... ...
}
複製程式碼

來看下 Set 結構物件的初始建立程式碼:

robj *setTypeCreate(robj *value) {
    if (isObjectRepresentableAsLongLong(value,NULL) == C_OK)
        return createIntsetObject(); // 使用IntSet
    return createSetObject(); // 使用HashTable
}
複製程式碼

isObjectRepresentableAsLongLong() 內部判斷其整數範圍,如果是整數且沒有超過最大整數就會使用 IntSet 來儲存。否則使用 HashTable 。接著會檢查元素的個數。

int setTypeAdd(robj *subject, robj *value) {
    long long llval;
    if (subject->encoding == OBJ_ENCODING_HT) {
		 ... ...
    } else if (subject->encoding == OBJ_ENCODING_INTSET) {
        if (isObjectRepresentableAsLongLong(value,&llval) == C_OK) {
            uint8_t success = 0;
            subject->ptr = intsetAdd(subject->ptr,llval,&success);
            if (success) {
                /* Convert to regular set when the intset contains
                 * too many entries. */
                if (intsetLen(subject->ptr) > server.set_max_intset_entries)
                    setTypeConvert(subject,OBJ_ENCODING_HT);
                return 1;
            }
        } else {
            /* Failed to get integer from object, convert to regular set. */
            setTypeConvert(subject,OBJ_ENCODING_HT);
			  ... ...
            return 1;
        }
    }
	  ... ...
    return 0;
}
複製程式碼

看看例子,這裡以最大整數臨界值為例:

127.0.0.1:6379> SADD test 9223372036854775807
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"intset"
127.0.0.1:6379> SADD test 9223372036854775808
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"hashtable"
複製程式碼

關於集合個數的測試,請自行完成觀察。

SortSet型別

現在的應用,都有一些排行榜之類的功能,比如投資網站顯示投資金額排行,購物網站顯示消費排行等。SortSet非常適合做這件事。常用來解決以下問題:

  • 各類排行榜
  • 設定執行任務權重,後臺指令碼根據其排序順序執行相關操作
  • 範圍查詢,查詢某個值在集合的哪個範圍
Redis的資料型別——探究竟

內部結構

雖然有序集合也是集合,但是低層的資料結構卻與Set不一樣,它也有兩種資料結構,分別是:

  • ZipList,當有序集合的元素個少於等於128或 member 的長度小於等於64的時候使用該結構
  • SkipList

這個轉變成過程如下:

void zaddGenericCommand(client *c, int flags) {
if (zobj == NULL) {
        if (xx) goto reply_to_client; /* No key + XX option: nothing to do. */
        if (server.zset_max_ziplist_entries == 0 ||
            server.zset_max_ziplist_value < sdslen(c->argv[scoreidx+1]->ptr))
        {
            zobj = createZsetObject();// skip list
        } else {
            zobj = createZsetZiplistObject();// zip list
        }
        dbAdd(c->db,key,zobj);
    } else {
        ... ...
    }
	  ... ...
    if (zobj->encoding == OBJ_ENCODING_ZIPLIST) {
        if (zzlLength(zobj->ptr) > server.zset_max_ziplist_entries)
            zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);// 根據個數轉化編碼
        if (sdslen(ele->ptr) > server.zset_max_ziplist_value)
            zsetConvert(zobj,OBJ_ENCODING_SKIPLIST);// 根據長度轉化編碼
    }
}
複製程式碼

這裡以member長度超過64舉例:

127.0.0.1:6379> ZADD test 77 qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwer // member長度是 64
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"ziplist"
127.0.0.1:6379> ZADD test 77 qwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwertyuiopqwerq // member長度是65
(integer) 1
127.0.0.1:6379> OBJECT encoding test
"skiplist"
複製程式碼

當我們member 超過64位長度時,低層的資料結構由 ZipList 轉變成了 SkipList。剩下的元素個數的測試,動動手試試看。

全域性常用命令

Redis的資料型別——探究竟

對於全域性命令,不管對應的key是什麼型別的資料,都是可以進行操作的。其中需要注意 KEYS 這個命令,不能用於線上,因為Redis單執行緒機制,如果記憶體中資料太多,會操作嚴重的阻塞,導致整個Redis服務都無法響應。

總結

  • Redis每種型別的命令時間複雜度不同,有的跟對應元素的個數有關係;有的跟請求個數有關係;
  • 合理安排元素相關個數以及長度,爭取Redis底層採用最簡單的資料結構;
  • 關注時間複雜度,瞭解自己的Redis內部元素情況,避免阻塞;
  • 越簡單的資料,越能獲得更好的效能;
  • Redis每種資料型別低層都對應多種資料結構,修改與擴充套件對上層無感知。

第一篇講了為什麼要用Redis,本文又講了絕大部分命令吧,以及Redis原始碼中對它們的一些實現,後續開始關注具體實踐中的一些操作。希望對大家有幫助,期待任何形式的批評與鼓勵

公眾號:dayuTalk

image

相關文章