跟著大彬讀原始碼 - Redis 10 - 物件編碼之整數集合

北國丶風光發表於2019-08-12

整數集合是 Redis 集合鍵的底層實現之一。當一個集合只包含整數值元素,並且元素數量不多時,Redis 就會使用整數集合作為集合鍵的底層實現。

1 整數集合的實現

整數集合是 Redis 用於儲存整數值的集合抽象資料結構。它可以儲存型別為 int16_t、int32_t、int64_t 的整數值,並且保證集合中不會出現重複元素。

每個 intset.h/intset 結構表示一個整數集合:

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;
  • encding:編碼方式
  • length:集合包含的元素數量
  • contents[]:儲存元素的陣列

contents 陣列是整數集合的底層實現:整數集合的每個元素都是 contents 陣列的一個陣列項,各個項在陣列中按值的大小從小到大有序排列,並且陣列中不包含重複項。

length 屬性記錄了整數集合記錄的元素數量,也就是 contents 陣列的長度。

雖然 intset 結構將 contents 屬性宣告為 int8_t 型別的陣列,但實際上 contents 陣列並不儲存任何 int8_t 型別的值,contents 陣列的真正型別取決於 encoding 屬性的值,比如:如果 encoding 屬性的值為 INTSET_ENC_INT16,那麼 contents 就是一個 int16_t 型別的陣列,陣列裡的每個項都是一個 int16_t 型別的整數值,取值範圍為:[-32768-32767](2^(16-1))。

與之類似,encoding 的值為 INTSET_ENC_INT32,那麼陣列每項的取值範圍為:[-2147483648, 2147483647](2^(32-1)。

這裡也引發了一個問題,當我們對一個 encoding 為 INTSET_ENC_INT8 的 intset,插入 129 時(int8_t 的取值範圍是 [-128, 127]),會出現什麼?

這也就引發了 intset 的升級操作。與之對應,也有降級操作。接下來,我們來詳細認識下 intset 的升降級操作。

2 升級操作

每當我們要將一個新元素新增到整數集合時,如果新元素的型別比整數集合的 encoding 型別大,整數集合就需要先進行升級操作(upgrade),然後才能將新元素新增到整數集合中。

整個升級操作原始碼如下:

// intset.c/intsetUpgradeAndAdd()
/* Upgrades the intset to a larger encoding and inserts the given integer. */
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
    uint8_t curenc = intrev32ifbe(is->encoding);
    uint8_t newenc = _intsetValueEncoding(value);
    int length = intrev32ifbe(is->length);
    int prepend = value < 0 ? 1 : 0;

    /* First set new encoding and resize */
    is->encoding = intrev32ifbe(newenc);
    is = intsetResize(is,intrev32ifbe(is->length)+1);

    /* Upgrade back-to-front so we don't overwrite values.
     * Note that the "prepend" variable is used to make sure we have an empty
     * space at either the beginning or the end of the intset. */
    while(length--)
        _intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));

    /* Set the value at the beginning or the end. */
    if (prepend)
        _intsetSet(is,0,value);
    else
        _intsetSet(is,intrev32ifbe(is->length),value);
    is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
    return is;
}

升級整數集合並新增新元素,共分為三步進行:

  1. 擴充套件底層陣列大小。根據新元素的型別,擴充套件整數集合底層陣列的大小,併為新元素分配空間。
  2. 元素轉換,並保持原有順序。將底層陣列現有的所有元素,都轉換成與新元素相同的型別,並將轉換後的元素放在正確的位置上,保證原有順序不發生改變。
  3. 將新元素新增到底層陣列中。

此外,一旦因插入新元素引發升級操作,就說明新插入的元素比集合中現有的所有元素的長度大,所以這個新元素的值要麼大於所有現有元素(正值),要麼就小於所有現有元素(負值),那麼:

  • 在新元素小於所有現有元素時,新元素就會被放在底層陣列的最開頭的位置,即索引為 0 的位置;
  • 在新元素大於所有現有元素時,新元素就會被放在底層陣列的最末尾的位置;

3 升級優勢

整數集合的升級策略主要有以下兩個好處:

  1. 提示整數集合的靈活性;
  2. 儘可能的節約記憶體;

3.1 提示靈活性

因為 C 語言是靜態型別語言,為了避免型別錯誤,我們通常不會將兩種不同型別的值放在同一個資料結構中。

但是,因為有了升級操作,整數集合可以通過它來自適應新元素,所以我們可以隨意地將 int16_t、int32_t、和 int64_t 型別的整數新增到集合中,而不必擔心出現型別錯誤,大大的提升了整數集合的靈活性。

3.2 節約記憶體

當然,要讓一個陣列可以同時儲存 int16_t、int32_t、和 int64_t 型別的整數值,我們可以粗暴的直接使用 int64_t 型別的陣列作為整數集合的底層實現,來儲存不同型別的值。但是,這樣一來,即使新增到集合中的都是 int16_t、int32_t 型別的值,陣列也都是需要使用 int64_t 型別的空間去儲存,出現浪費記憶體的情況。

而整數集合的升級操作,既能同時儲存三種不同型別的值,又可以確保升級操作只會在有需要的時候進行,達到節省記憶體的目的。

4 交、並、差集演算法

Redis 中的集合實現了交、並、差等操作,相關操作可參加 t_set.c,其中
sinterGenericCommand() 實現交集,sunionDiffGenericCommand() 實現並集和差集。

它們都能同時對多個集合進行元素。當對多個集合進行差集運算時,會先計算出第一個和第二個集合的差值,然後再與第三個集合做差集,依次類推。

接下來,我們一起來認識下三個操作的實現思路。

4.1 交集

計算交集的過程大概可以分為三部分:

  1. 檢查各個集合,對於不存在的集合當做空集來處理。一旦出現空集,則不用繼續計算了,最終的交集就是空集。
  2. 對各個集合按照元素個數由少到多進行排序。這個排序有利於後面計算的時候從最小的集合開始,需要處理的元素個數較少。
  3. 對排序後第一個集合(也就是最小集合)進行遍歷,對於它的每一個元素,依次在後面的所有集合中進行查詢。只有在所有集合中都能找到的元素,才加入到最後的結果集合中。

需要注意的是,上述第 3 步在集合中進行查詢,對於 intset 和 dict 的儲存來說時間複雜度分別是 O(log n) 和 O(1)。但由於只有小集合才使用 intset,所以可以粗略地認為 intset 的查詢也是常數時間複雜度的。

4.2 並集

並集操作最簡單,只要遍歷所有集合,將每一個元素都新增到最後的結果集中即可。向集合中新增元素會自動去重,所以插入的時候無需檢測元素是否已存在。

4.3 差集

計算差集有兩種可能的演算法,它們的時間複雜度有所區別。

第一種演算法

對第一個集合進行遍歷,對於它的每一個元素,依次在後面的所有集合中進行查詢。只有在所有集合中都找不到的元素,才加入到最後的結果集合中。

這種演算法的時間複雜度為O(N*M),其中N是第一個集合的元素個數,M是集合數目。

第二種演算法

  1. 將第一個集合的所有元素都加入到一箇中間集合中。
  2. 遍歷後面所有的集合,對於碰到的每一個元素,從中間集合中刪掉它。
  3. 最後中間集合剩下的元素就構成了差集。
  4. 這種演算法的時間複雜度為O(N),其中N是所有集合的元素個數總和。

在計算差集的開始部分,會先分別估算一下兩種演算法預期的時間複雜度,然後選擇複雜度低的演算法來進行運算。還有兩點需要注意:

  • 在一定程度上優先選擇第一種演算法,因為它涉及到的操作比較少,只用新增,而第二種演算法要先新增再刪除。
  • 如果選擇了第一種演算法,那麼在執行該演算法之前,Redis的實現中對於第二個集合之後的所有集合,按照元素個數由多到少進行了排序。這個排序有利於以更大的概率查詢到元素,從而更快地結束查詢。

5 總結

  1. 整數集合是集合鍵的底層實現之一。
  2. 整數集合以有序、無重複的方式儲存集合元素。在有需要時,會根據新新增元素的型別,改變底層陣列的型別。
  3. 升級操作提升了操作的靈活性,並儘可能的節約了記憶體。
  4. 集合可以進行交、並、差集操作。

相關文章