Redis資料結構之整數集合

sherlock_lin發表於2020-11-22

1、整數集合

Redis 中有集合(set)的操作,常用的指令有 SADD、SCARD 等,而在底層的實現中,整數集合(intset)就是 Redis 集合的實現方式之一。

Redis 的集合是有序集合,intset 也是有序的。

根據 Redis 對集合的操作,我們可以大致想象出,intset 需要哪些功能:

  1. 新增/移除元素要快;
  2. 查詢元素要快;
  3. 方便兩個集合對比;
  4. 統計元素個數;
  5. 佔用記憶體要少;

Redis 作為高效能資料庫,佔用記憶體和操作快速是最主要的功能。intset 為了保證除了在保證快速的同時,使用升級來保證能夠儲存資料但是又節省空間的需求。

Redis 的 intset 相對來說比較簡單,下面也就大致的描述一下

2、intset的實現

intset 宣告位於原始碼目錄下 intset.h 檔案中,宣告如下:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

contents: contents 是一個柔性陣列,這裡不多說,它裡面儲存的就是 intset 的所有整數項;

需要注意的是,雖然 contents 是 int8_t,不表示 contents 內部只能存 int8_t 型別的資料,後面會細說;

length: length 記錄了整個集合的元素數量,即 contents 陣列的長度,當需要獲取元素個數的時候,直接返回這個值就行了,時間複雜度 O(1);

encoding: encoding 決定該集合儲存的整數的型別,它的值有三種,如下:

/* Note that these encodings are ordered, so:
 * INTSET_ENC_INT16 < INTSET_ENC_INT32 < INTSET_ENC_INT64. */
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

可以看到,encoding 其實就可以表示 int8_t、int16_t、int32_t 和 int64_t(32位編譯器下分別表示佔用1一個、2個、4個 和 8個位元組,換做大部分人能認識的就是 char、short、int 和 long),其實可以看出來,encoding 就是這幾種型別的佔用記憶體,所以,如果如要計算集合中的所有元素所佔的記憶體,只需要用 length * encoding 就可以了

Redis 中使用 encoding 無非是為了節約記憶體,假如所有元素都是 int8_t,開闢了 int16_t 的空間,白白的就是在浪費記憶體嘛。

2.1、intset資料的儲存

那麼,contents 陣列內是如何相容不同型別的整數呢?我們都知道,C語言中最小的資料結構 char 佔8位,而16、32、64位均是8位的整數倍,所以我們只需要知道當前陣列儲存的資料型別,就可以根據自己位計算出資料來,而不關注這個陣列的長度了。

比如,現在 contents 陣列共佔16位,從低到高分別是:

0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
1 1 1 0 0 1 0 1 1 0 0 0 1 1 0 1

如果 encoding 表示8位元素,那麼,這裡可以得出兩個 int8_t 資料出來,分別是 229 和 141;

如果 encoding 表示16位元素,那麼,這裡可以得出一個 int16_t 資料出來,是 58765;

可見,不是說 contents 陣列的型別是 int8_t 就只能存8位的資料,但是,contents 只能儲存一種型別的資料。

3、升級和降級

當我們需要將一個新的元素放入到集合中,並且這個新的資料比集合中的其他元素的型別的最大值還要大的時候,Redis 就需要統一使用較大的型別來儲存了,即需要擴容,這在 Redis 中叫做升級(upgrade)。

Redis 中升級集合並新增新元素總共需要三步:

  1. 根據新元素的大小,確定陣列的型別,併為陣列分配空間;
  2. 將底層已存在的舊有轉換成新的型別,並按照原先的順序,放置在固定的記憶體位置上;
  3. 將新元素放在陣列裡。

因為集合中的元素都是有序的,就算我們不需要進行升級,仍然需要從頭遍歷元素,也就是還是上面三步,所以插入元素的時間複雜度為 O(N)。

一旦出現需要升級操作,則表示新元素一定比舊有的元素要大,所以新元素放在最後就好。

舉個例子,現在有一個集合,其中有三個 int8_t 型別的元素:1、2、3,則 intset 中 encoding 值小於 INTSET_ENC_INT16,length 為3,contents 陣列所佔記憶體為 8 * 3 = 24bit,即3個位元組,資料佔記憶體位置如下:

0~7位 8~15位 16~23位
元素 1 2 3

現在插入一個 int16_t 型別的元素 32,我們發現,encoding 的值小於 INTSET_ENC_INT16,所以需要擴容,content 重新分配記憶體,16 *4 = 64bit,即8個位元組。擴容後的記憶體如下:

0~7位 8~15位 16~23位 24~63位
元素 1 2 3 新分配的空間

隨後,將原先的1、2、3轉換成 int16_t 型別,並按照原先的順序放置在固定位置,如下:

0~15位 16~31位 32~47位 48~63位
元素 1 2 3 新分配

最後,將新元素放在數字的最後,即

0~15位 16~31位 32~47位 48~63位
元素 1 2 3 32

元素放置完畢,encoding 值變為 INTSET_ENC_INT16, length 變為 4

Redis 的 intset 是不支援降級操作的,一旦資料升級,就會保持升級之後的型別,哪怕唯一個佔用記憶體大的元素被刪除了,剩下的元素仍然佔用大的型別的元素佔用的記憶體。

4、inset操作API

intset.h 檔案中宣告瞭 intset 操作的API,整理如下:

API 描述 時間複雜度
intsetNew 建立一個新的inset O(1)
intsetAdd 新增新元素 O(N)
intsetRemove 移除元素 O(N)
intsetFind 查詢元素 有序集合可以使用二分法,O(logN)
intsetRandom 隨機返回一個元素 O(1)
intsetGet 根據索引取出元素 O(1)
intsetLen 獲取元素個數 O(1)
intsetBlobLen 獲取佔用記憶體位元組數 O(1)

相關文章