KaiwuDB 時(shí)序引擎數(shù)據(jù)存儲(chǔ)內(nèi)存對(duì)齊技術(shù)解讀

一、理論
1、什么是內(nèi)存對(duì)齊
現(xiàn)代計(jì)算機(jī)中內(nèi)存空間都是按照 byte 劃分的,在計(jì)算機(jī)中訪問一個(gè)變量需要訪問它的內(nèi)存地址,從理論上看,似乎對(duì)任何類型的變量的訪問都可以從任何地址開始。
但在實(shí)際情況中,通常在特定的內(nèi)存地址才能訪問特定類型變量,這就需要對(duì)數(shù)據(jù)在內(nèi)存中存放的位置有限制。各種類型不是按照順序排放,它們需要根據(jù)一定的規(guī)則在空間上排列,這就是對(duì)齊。
2、為什么需要內(nèi)存對(duì)齊
(1)移植原因:
不是所有的硬件平臺(tái)都能訪問任意地址上的任意數(shù)據(jù)的,各個(gè)硬件平臺(tái)對(duì)存儲(chǔ)空間的處理上有很大的不同,部分平臺(tái)對(duì)某些特定類型的數(shù)據(jù)只能從某些特定地址開始存取。
比如,市面上有些架構(gòu)的 CPU 在訪問一個(gè)沒有進(jìn)行對(duì)齊的變量時(shí)會(huì)發(fā)生錯(cuò)誤,這時(shí) CPU 會(huì)進(jìn)入異常處理狀態(tài)并且通知程序不能繼續(xù)執(zhí)行。
舉個(gè)例子,在 ARM 硬件平臺(tái)上,當(dāng)操作系統(tǒng)被要求存取一個(gè)未對(duì)齊數(shù)據(jù)時(shí)會(huì)默認(rèn)給應(yīng)用程序拋出硬件異常。所以,如果不進(jìn)行內(nèi)存對(duì)齊,代碼就不具有移植性,而且難以開展在很多平臺(tái)上的開發(fā)工作。
(2)性能原因:
盡管內(nèi)存是以字節(jié)為單位,但是大部分處理器并不是按字節(jié)塊來存取內(nèi)存的。它一般會(huì)以雙字節(jié)、4 字節(jié)、8 字節(jié)、16 字節(jié)甚至 32 字節(jié)為單位來存取內(nèi)存,我們將上述這些存取單位稱為內(nèi)存存取粒度。
如果變量的地址沒有對(duì)齊,可能需要多次訪問才能完整讀取到變量內(nèi)容,而對(duì)齊后可能就只需要一次內(nèi)存訪問。因此,內(nèi)存對(duì)齊可以減少 CPU 訪問內(nèi)存的次數(shù),提高 CPU 訪問內(nèi)存的吞吐量。
舉個(gè)例子,考慮 4 字節(jié)存取粒度的處理器訪問 int 類型變量,該處理器只能從地址為 4 的倍數(shù)的內(nèi)存地址開始讀取數(shù)據(jù)。如果未經(jīng)過內(nèi)存對(duì)齊,獲取該 int 類型的數(shù)據(jù)需要進(jìn)行兩次內(nèi)存訪問,最后再進(jìn)行數(shù)據(jù)整理得到完整數(shù)據(jù):

如果經(jīng)過內(nèi)存對(duì)齊,一次內(nèi)存訪問就能得到完整數(shù)據(jù),減少了一次內(nèi)存訪問:

CPU 讀取內(nèi)存是高耗時(shí)的指令,內(nèi)存對(duì)齊是在內(nèi)存的使用量和 CPU 計(jì)算間的居中的優(yōu)化策略。這種策略是由編譯器和 CPU 共同決定,并且程序員可以設(shè)置對(duì)齊的長度。
通過上述介紹的內(nèi)存對(duì)齊的必要性,我們可以知道,如果不理解內(nèi)存對(duì)齊,在編程時(shí)就可能產(chǎn)生下面的問題:
1) 程序運(yùn)行速度變慢;
2) 應(yīng)用程序產(chǎn)生死鎖;
3) 操作系統(tǒng)崩潰;
4) 程序會(huì)毫無征兆的出錯(cuò),產(chǎn)生錯(cuò)誤的結(jié)果。
但是,我們在寫程序時(shí)一般無需考慮對(duì)齊,通常是依賴編譯器來為我們選擇適合的對(duì)齊策略。
3、內(nèi)存對(duì)齊規(guī)則
在學(xué)習(xí)內(nèi)存對(duì)齊規(guī)則前,我們先一起了解下四個(gè)重要的基本概念:
指定對(duì)齊值:
#pragma pack (n) 時(shí)指定的對(duì)齊值 n;
基本數(shù)據(jù)類型的自身對(duì)齊值:
基本數(shù)據(jù)類型自身占用的存儲(chǔ)空間大小,如 char 類型為 1,short 類型為 2,int 類型為 4,double 類型為 8 等;
結(jié)構(gòu)體或類類型的自身對(duì)齊值:
結(jié)構(gòu)體或類的成員中自身對(duì)齊值最大的值,如 struct a 中有 char、int 和 double 共 3 個(gè)類型的數(shù)據(jù)成員,那么 struct a 的自身對(duì)齊值是 8 字節(jié);
數(shù)據(jù)成員、結(jié)構(gòu)體和類的有效對(duì)齊值:
自身對(duì)齊值或指定對(duì)齊值中的較小值。
了解上述概念后,我們一起來了解具體的內(nèi)存對(duì)齊規(guī)則。
內(nèi)存地址對(duì)齊包含了兩種相互獨(dú)立又相互關(guān)聯(lián)的部分:基本數(shù)據(jù)對(duì)齊和結(jié)構(gòu)體數(shù)據(jù)對(duì)齊。
基本數(shù)據(jù)對(duì)齊比較簡單,其自身對(duì)齊值就等于自身占用的存儲(chǔ)空間大小,可以通過 alignof 獲取一個(gè)類型的對(duì)齊值。
結(jié)構(gòu)體數(shù)據(jù)對(duì)齊需要保證結(jié)構(gòu)體的數(shù)據(jù)成員對(duì)齊以及結(jié)構(gòu)體的整體對(duì)齊:
(1)數(shù)據(jù)成員對(duì)齊規(guī)則:
第一個(gè)數(shù)據(jù)成員放在 offset 為 0 的地方,也就是結(jié)構(gòu)體變量本身的起始地址,以后每個(gè)數(shù)據(jù)成員的偏移為其有效對(duì)齊值的整數(shù)倍;
(2)結(jié)構(gòu)體整體對(duì)齊規(guī)則:
在數(shù)據(jù)成員完成各自對(duì)齊后,結(jié)構(gòu)體本身也要進(jìn)行對(duì)齊,對(duì)齊會(huì)將結(jié)構(gòu)體的大小增加為該結(jié)構(gòu)體有效對(duì)齊值的整數(shù)倍,如有需要編譯器會(huì)在最末成員后加上填充字節(jié)。
二、應(yīng)用
1、前提介紹
在 KaiwuDB 時(shí)序引擎中,一條時(shí)序數(shù)據(jù)由不同的列數(shù)據(jù)組成,其中每列都對(duì)應(yīng)一種數(shù)據(jù)類型。在存儲(chǔ)的代碼實(shí)現(xiàn)中,每條時(shí)序數(shù)據(jù)都存放在一塊連續(xù)的內(nèi)存空間里,不同列的數(shù)據(jù)按照列的順序連續(xù)緊鄰的排放,如下圖所示:

值得一提的是,上圖示例中的?char?類型,并非是一個(gè)字符,而是代表一個(gè)不定長的字符數(shù)組,也就是說這條數(shù)據(jù)的第二列,存放的是一個(gè)長度為 3 的 char 數(shù)組。另外,TIMESTAMP 的類型是 uint64_t,長度為 8 字節(jié);Bitmap 的類型也是 char 數(shù)組,長度不定。
顯然,這種存放方式并不滿足內(nèi)存對(duì)齊的要求,會(huì)對(duì)我們的程序產(chǎn)生兩種可能的影響:
(1)在不同硬件平臺(tái)上的程序 crash;
(2)降低存取效率。
由于我們的程序需要在 ARM 平臺(tái)上運(yùn)行,而且會(huì)有高密集地進(jìn)行內(nèi)存訪問,所以時(shí)序數(shù)據(jù)存放滿足內(nèi)存對(duì)齊要求是十分必要的。
2、使用內(nèi)存對(duì)齊生成存儲(chǔ)格式
存儲(chǔ)記錄的數(shù)據(jù)類型可以分類為兩種:
(1)定長:
TIMESTAMP、SMALLINT、INT、BIGINT、FLOAT、DOUBLE、BOOL、BINARY,長度包括 8、4、2、1 字節(jié),都屬于基本數(shù)據(jù)類型。前文介紹過,基本數(shù)據(jù)類型的對(duì)齊系數(shù)等于自身的類型長度。
(2)不定長:
char、Bitmap,這兩種類型都是不定長的 char 數(shù)組,基本組成元素都是 char 類型,因此它們的對(duì)齊系數(shù)都為 1。
由上可知,目前存儲(chǔ)中存放的都是一些基本數(shù)據(jù)類型記錄,不存在結(jié)構(gòu)體或類類型。在這個(gè)條件下,一條時(shí)序數(shù)據(jù)的格式滿足內(nèi)存對(duì)齊的要求就相對(duì)而言比較簡單了,存儲(chǔ)格式生成方案如下:
對(duì)于定長的數(shù)據(jù)類型對(duì)應(yīng)的列,按類型長度降序排列,先存放 8 字節(jié)的列、再存放 4 字節(jié)的列、再存放 2 字節(jié)和 1 字節(jié)的列,這樣就可以滿足內(nèi)存對(duì)齊的要求。
存放完定長的列后,char?和 Bitmap 都是 char 數(shù)組,對(duì)齊系數(shù)都為 1,所以可以直接存放在定長的列之后,先存放 char 類型,最后存放 Bitmap。
通過上述內(nèi)存對(duì)齊的存儲(chǔ)格式生成方案生成的一條時(shí)序數(shù)據(jù)的格式如下:

3、測試
通過編寫一個(gè)簡單的測試,驗(yàn)證一下內(nèi)存對(duì)齊對(duì)存取效率的影響。
測試場景:
內(nèi)存對(duì)齊系數(shù)為 8 字節(jié),申請(qǐng) 10K 內(nèi)存空間,在內(nèi)存對(duì)齊和非內(nèi)存對(duì)齊的情況下,分別寫入數(shù)據(jù) 1G 次并且讀取數(shù)據(jù) 1G 次。寫入的數(shù)據(jù)類型均為 uint64_t,長度 8 字節(jié),內(nèi)存寫滿后循環(huán)使用。分別統(tǒng)計(jì)其耗時(shí)并進(jìn)行對(duì)比,測試代碼和測試結(jié)果如下:

測試結(jié)果顯示,在只進(jìn)行讀寫操作的情況下,進(jìn)行 1G 次讀寫操作,非內(nèi)存對(duì)齊要比內(nèi)存對(duì)齊的耗時(shí)多 24.3% 左右。
