剖析JS和Redis的資料結構設計:陣列

趙帥強發表於2020-06-15

語言的資料結構相通性

最近讀了Redis的原理實現,感受到程式語言的相通性,只要你掌握了語言的共性,觸類旁通其他語言的開發就變得非常簡單了。

總體來說,各種程式語言底層的設計思想是非常相通的,首先針對需要解決的問題和場景選擇不同的資料結構和演算法,根據執行環境設計不同的架構和特性,根據作者的喜好選擇開發的風格,根據應用場景開發對外的介面,根據程式設計師的實踐維護社群和bug反饋區。

不要將某種資料結構固化成你理解的某種語言的一種實現方式,它們都只是一種方便理解的概念,有許多種實現它的方式,甚至完全不同。

我們下面看下陣列這種資料結構的設計思路。

資料型別:陣列

當我們想要設計一種陣列的資料結構時,最容易想到的就是排成一隊的學生,每個學生就是一個元素,我們可以對他們進行增刪查改。他們緊緊相連,就像一塊連續的儲存空間。

資料結構.png

當我們可以從頭到尾的看完所有學生資訊(遍歷),也可以從頭開始查詢第4個學生(索引)。我們可以加入一個學生到任意位置(插入),也可以將任意一位同學移出佇列(刪除),但為了保持緊密連續的佇列,我們需要做一些額外的調整。

這就是最常用的資料結構:陣列。

優勢:

  1. 資料儲存連續緊密,佔用空間少。
  2. 遍歷資料時可以充分利用磁碟連續空間,減少磁碟臂的移動,提高訪問速度。
  3. 在每個元素佔用空間相同時,能夠支援快速索引訪問。

缺點:

  1. 只有頭部指標,無法得知當前陣列有多少元素,只能全部遍歷後統計。
  2. 元素佔用空間不同時,缺乏隨機讀寫的能力,必須從陣列頭部順序訪問和查詢。
  3. 如果中間元素出現增刪,後續元素的位置需要依次更新。

改進版1:支援總數查詢

在使用陣列時,查詢元素的總數是常見的需求,遍歷元素獲取陣列長度的方式非常低效,如mysql普通的查詢總行數,select count(*) from table_name,就會掃描全表。

為了支援總數快速查詢,我們可以看下javascript的陣列實現方式,它通過增加一個欄位length,在每次變更時更新這個數字,即可無需遍歷,直接讀取長度資訊。

資料結構 (4).png

改進版2:支援下標的快速訪問

陣列經常會進行遍歷,但也會使用下標獲取指定的元素,而典型的陣列只能通過使用單獨的計數器來遍歷查詢指定的元素,時間複雜度為O(n),在元素很多時耗時很久。

方式一:元素長度固定

這種方式下,我們就可以使用(目標元素地址 = 陣列頭部地址 + 元素長度 * 元素下標)的方式訪問指定元素。

但是缺點也很明顯,應用場景比較狹窄,因為所有元素佔用空間都相同的情況非常少,在大部分場景下各個元素使用的空間不盡相同,這樣就會導致空間的浪費。所以基本不會使用這種方式。

方式二:使用Hash方式

資料結構 (2).png
在這種儲存方式中,我們先使用一個指定長度l的連續陣列作為槽,這個長度就是hash的模值,我們用陣列元素的索引i對陣列長度l取模,得到槽的索引,然後用連結串列的方式進行儲存,這樣就能夠進行快速的下標訪問。

但是缺點也很明顯,就是如果中間的元素增加或刪除,後面的所有元素都需要重新hash和排列,因此也比較低效。

改進版3: 無需後置元素依次更新

資料結構 (5).png
在原陣列更新時,我們可以直接在原位置上進行重寫,而如果需要刪除元素2,我們可以直接申請一塊記憶體空間,將元素2之前和之後的連續記憶體空間直接拷貝到新空間中,就完成了陣列的縮容。

擴容也是一樣的,新增了元素5,我們同樣重新申請一塊記憶體空間,然後將元素5之前的拷貝到新空間,寫入元素5,再將元素5之後的連續記憶體空間進行批量拷貝。

JS陣列實現

// The JSArray describes JavaScript Arrays
//  Such an array can be in one of two modes:
//    - fast, backing storage is a FixedArray and length <= elements.length();
//       Please note: push and pop can be used to grow and shrink the array.
//    - slow, backing storage is a HashTable with numbers as keys.
class JSArray: public JSObject {
public:
 // [length]: The length property.
 DECL\_ACCESSORS(length, Object)

首先看原始碼實現,會發現JS中陣列是基於物件的,根據陣列狀態不同,元素屬性分為固定長度的快陣列,和hashTable儲存的慢陣列。

快陣列和慢陣列

快陣列和慢陣列最大的區別就是儲存使用的資料結構不同,快陣列採用連續空間的方式儲存,慢陣列採用hashTable的連結串列方式儲存。

// Constants for heuristics controlling conversion of fast elements

// to slow elements.

// Maximal gap that can be introduced by adding an element beyond

// the current elements length.

static const uint32\_t kMaxGap = 1024;

// JSObjects prefer dictionary elements if the dictionary saves this much

// memory compared to a fast elements backing store.

static const uint32\_t kPreferFastElementsSizeFactor = 3;

檢視快慢陣列轉換原始碼

static inline bool ShouldConvertToSlowElements(JSObject object,

uint32\_t capacity,

uint32\_t index,

uint32\_t\* new\_capacity) {

STATIC\_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <=

JSObject::kMaxUncheckedFastElementsLength);

if (index < capacity) {

\*new\_capacity = capacity;

return false;

}

if (index - capacity >= JSObject::kMaxGap) return true;

\*new\_capacity = JSObject::NewElementsCapacity(index + 1);

DCHECK\_LT(index, \*new\_capacity);

// TODO(ulan): Check if it works with young large objects.

if (\*new\_capacity <= JSObject::kMaxUncheckedOldFastElementsLength ||

(\*new\_capacity <= JSObject::kMaxUncheckedFastElementsLength &&

ObjectInYoungGeneration(object))) {

return false;

}

// If the fast-case backing storage takes up much more memory than a

// dictionary backing storage would, the object should have slow elements.

int used\_elements = object->GetFastElementsUsage();

uint32\_t size\_threshold = NumberDictionary::kPreferFastElementsSizeFactor \*

NumberDictionary::ComputeCapacity(used\_elements) \*

NumberDictionary::kEntrySize;

return size\_threshold <= \*new\_capacity;

}

快陣列、慢陣列兩者轉化的臨界點有兩種:

  1. if (index - capacity >= JSObject::kMaxGap) return true;
  2. return size\_threshold <= \*new\_capacity;

其中kEntrySize根據陣列儲存的內容不同,會在1|2|3中選擇一個作為係數,當為陣列索引時一般為2。

根據程式碼可知,也就是空洞元素大於1024個,或者新容量 > 3*舊容量*2 時,會將快陣列轉化為慢陣列。

所謂的空洞就是未初始化的索引值,如

const a = [1,2];
a[1030] = 1;

此時就會產生1028個空洞產生,會直接使用滿陣列來儲存,這樣能夠節省大量的儲存空間。

總之,在JS V8引擎中,陣列使用快慢兩種方式設計,快陣列提高操作效率,慢陣列節省空間。

陣列的操作

陣列的常用push/pop是通過直接在記憶體尾部追加或刪除,一般申請記憶體時會留有冗餘,空間不夠時再次申請。

 // Number of element slots to pre-allocate for an empty array.
 static const int kPreallocatedArrayElements \= 4;
};

從上面的程式碼中可以看到,初次申請就會分配4個元素槽位置。

static const uint32\_t kMinAddedElementsCapacity = 16;

// Computes the new capacity when expanding the elements of a JSObject.

static uint32\_t NewElementsCapacity(uint32\_t old\_capacity) {

// (old\_capacity + 50%) + kMinAddedElementsCapacity

return old\_capacity + (old\_capacity >> 1) + kMinAddedElementsCapacity;

}

當空間不夠用時,就會申請新的空間,新空間容量=原空間+原空間/2+16

然後根據需要變動的位置分為前後兩塊,直接按照連續記憶體空間的長度一次性拷貝到新記憶體地址上,效率是很高的。

Redis陣列實現

Redis(Remote Dictionary Service, 遠端字典服務)是使用最為廣泛的儲存中介軟體,由於其超高的效能和豐富的客戶端支援,常常用於快取服務,當然它也可以用於持久化儲存服務。

Redis陣列常用來儲存任務佇列,使用佇列或者棧的方式,進行任務分發和處理。

ziplist壓縮列表

Redis在陣列元素較少時,使用ziplist(壓縮列表)來儲存,它是一塊連續的記憶體空間,元素緊密儲存,沒有空隙。

資料結構 (6).png

// 壓縮列表結構體
struct ziplist<T> {
    int32 zlbytes;  // 整個壓縮列表佔用位元組數
    int32 zltail_offset;    // 最後一個元素的偏移量
    int16 zllength;     // 元素個數
    T[] entries;    // 元素內容列表
    int8 zlend;     // 結束標誌位,值為0xFF
}
// 壓縮列表元素結構體
struct entry {
    int<var> prevlen;   // 前一個entry的位元組長度
    int<var> encoding;      // 元素型別編碼
    optional byte[] content;    // 元素內容
}

因此通過zltail_offset我們可以快速定位到最後一個元素,通過prevlen可以支援雙向遍歷,通過zllength屬性我們可以不用遍歷就能支援整個陣列的元素個數。

由於ziplist採取緊湊儲存,因此沒有空間冗餘,導致每次插入新元素時,我們都需要申請新的記憶體空間進行擴充套件,然後將原記憶體地址空間直接拷貝到新空間中。由於Redis是單執行緒,因此如果壓縮列表的容量過大,就會導致服務卡頓,因此不適合儲存過大空間的內容。當更新資料時,如果內容是減少的或者沒有超過已佔用的指定位元組數閾值,就可以原地更新。

quicklist快速列表

由於ziplist不適合大容量儲存,因此在陣列元素較多時,我們結合linkedlist(連結串列)的方式設計了quicklist

資料結構 (7).png

struct quicklist {
    quicklistNode* head;    // 頭部指標
    quicklistNode* tail;    // 尾部指標
    long count;     // 元素總數
    int nodes;      // ziplist節點個數
    int compressDepth;      // LZF壓縮演算法深度
}
struct quicklistNode {
    quicklistNode* prev;    // 前節點指標
    quicklistNode* next;    // 後節點指標
    ziplist* zl;    // ziplist指標
    int32 size;     // ziplist位元組總數
    int16 count;    // ziplist元素總數
    int2 encoding;      // 儲存形式:原生陣列|LZF壓縮陣列
}

一般每個ziplist的空間上限為8KB,超過就會建立新的節點,這樣保證每個節點在更新時不會操作過大的空間進行復制,同時在檢索時也大大提高了效率。每個節點的空間限制可以由list-max-ziplist-size引數配置。

在該結構體中,為了進一步壓縮空間佔用,可以使用LZF演算法進行壓縮,壓縮深度為0|1|2三種,0就是不壓縮,1就是首尾的前兩個元素不壓縮,其餘都壓縮,2就是首尾的一個元素不壓縮,其餘都壓縮。

首尾元素不壓縮是為了保證push/pop的快速操作時不用再解壓縮改指標內容,而其他元素的壓縮預計可以節省一半的空間。

總結

在語言的陣列設計中,我們會發現幾個通性:

  1. 優先採用連續儲存的記憶體空間,提升操作的效率。
  2. 在新增元素時,採用連續記憶體空間複製的方式提升操作效率。
  3. 使用專用的變數來儲存陣列長度,而不是通過遍歷。
  4. 在元素很多時,採用連結串列的方式儲存,減少大塊記憶體的申請和佔用。同時提升查詢效率。

參考資料

  1. Redis深度歷險-核心原理與應用實踐
  2. 探究V8引擎的陣列底層實現:https://juejin.im/post/5d8091...
  3. 從Chrome原始碼看JS Array的實現:https://www.yinchengli.com/20...
  4. V8原始碼:https://github.com/v8/v8/tree...

相關文章