數據庫內核雜談(二):存儲“演化論”

數據庫是用來存儲海量數據的。存儲如此大量的數據,自然而然想到的就是以文件的形式存儲在硬盤(HDD或SSD)中。當然,一些商用數據庫爲了追求性能,是將數據優先存儲在內存中(比如SAP的HANA和MemSQL)來獲得更高速的讀寫。本文主要涉及的是關係型數據庫針對硬盤的存儲。對於內存數據庫來說,依然需要硬盤作爲備份或者2級存儲,所以相關知識也是適用的。

相較於列舉常見的存儲形式然後對比優缺點的分類法,我們今天另闢蹊徑,從"演化論"的角度來看,不同的存儲形式和優化方法是怎麼一步一步進化出來的。

一個數據庫存的是什麼呢?這裏簡單介紹一下關係模型(relational model)。關係模型由 Ted Codd1970年提出,關係模型定義了所有的數據都是以元組(tuple)的形式存在,每個元組定義了多個屬性(attribute)的鍵值對,多個含有相同屬性的元組排列在一起就形成了一個關係(relation)。元組,屬性和關係對應到數據庫中的概念就是行(row),列(column), 和表(table)。一個表定義了多個column,每個column有一個type,每個row對應於每一個column都有一個取值(取值滿足type的定義),每個表又由多個row構成。不同的數據庫雖然有庫(database),schema或者命名空間(namespace)等不同級別的邏輯抽象,但是表卻是每個關係型數據庫最基本的存儲對象。

好了,確認了數據庫需要存儲的基本單元是表。那麼給定一張表,應該怎麼存在文件中呢?如果還能回想起上一講的內容,你會說,可以用comma-separated-value(CSV)格式的文件來存儲。確實,CSV文件中的每一行對應於一條row,每個row的不同column用逗號隔開,一個文件對應了一張表。下圖截取了一段Titanic倖存者的CSV文件。

titanic_survivor.csv
這樣的一一對應確實清楚。那麼問題來了,上述的CSV形式有什麼缺點嗎?有些讀者發現了,文件裏並沒有定義column的類型和命名。我們來看應該怎樣補全這個定義。方法一,我們把CSV文件的第一行預留給column的定義,如下圖所示,補上了所有column的命名(原文中並沒有定義類型,我們可以自行腦補,在每個column後面加入"(type)")。

titanic_survivor_with_header.csv

方法二,把column的定義(我們通常稱之爲表的元數據)和數據分開存儲。比如存在一個叫titanic_survivor.header的文件中,下圖給了文件的示意。

titanic_survivor.header

比較這兩種方法,哪一種更好呢?我們可以從支持數據庫操作的角度出發來看。對於表,數據庫系統會支持新增一個新屬性,修改或刪除已有屬性。如果把屬性放在csv文件的第一行,對於任何一種屬性操作,需要對文件進行大量的改動:對於刪除一個已有屬性,需要刪除所有行的對應數據來保證CSV文件的有效性,對於新增一個新屬性,同樣需要修改每一行。如果數據文件非常大(1 billion rows), 會消耗大量時間來更新數據。對於第二種方法,修改的代價就小很多,只需要對header文件進行修改即可。你可能會有疑問,如果單單修改header文件,豈不是和數據文件就對應不上了。一個可行的解決方案就是在對header文件修改時,加上標註。比如對於刪除一個現有屬性,只需要標註這個屬性被刪除,並不直接在header文件裏刪除這個屬性的定義。當數據庫對錶的數據進行讀取時,我們只需要同時讀取header文件,然後根據最新的定義,對於讀取的每一行數據,忽略已經被刪除的屬性值即可。同理,當新增一個新屬性時,標註這是一個新屬性,並給定默認值(不給定數據庫會定義爲NULL),那在讀取每一行數據時,如果沒有新屬性,就賦予默認值即可。

這種分離元數據和數據的另一個好處在於,方便數據庫統一管理所有表的元數據。幾乎所有的數據庫都會提供類似於information schema的功能,用來顯示數據庫的各種元數據。比如有幾個namespace,對於每個namespace分別有幾個表,每個表都有哪些屬性。單獨存儲表的屬性就更方便讀取和管理。

爲了更好得支對錶的元數據的管理和變更操作,我們從原有的csv文件進化出了第一個特性,分離元數據和數據的存儲。

討論完了元數據的管理,我們再來看CSV文件對於其他常見的數據庫操作還有什麼做得不夠好的。除了最頻繁的查詢語句,另一類常見的操作就是添加,修改或者刪除表裏的數據。對於添加,我們只需要將新數據添加到文件的末尾。對於修改,如改變某一行的某一個屬性或刪除某一行,就需要在數據文件中進行修改。相較於在文件末尾添加,文件中的修改會低效很多,尤其是當數據文件特別大的時候。

有什麼思路來改進呢?那些數據庫先賢就想了一招,分開管理的思路也可以用在數據本身呢。設想一下,除了CSV存放每一行數據外,我們再單獨維護一個slot_table的文件,這個文件存啥呢,就存對應CSV數據文件每一行的標註信息,比如對應原始的CSV文件,我們先生成對應的slot_table如下:

tianic_survivor.slot_table

對應每一行,我們標註V表示(valid)。對應於新增數據操作,我們只要同時append數據行和slot_table行即可。如果我們現在執行了一個更新語句,刪除姓名起始爲"Cumings"的數據,那第二行的數據就要被刪除。對應的,我們可以不用修改CSV文件,只是把slot_table中的2:V改爲2:D(deleted)。如果要執行更新語句呢,比如把姓名爲"Braund, Mr. Owen Harris"年齡紀錄更新成37歲,這又應該怎麼操作呢?我們可以在數據文件中添加一行新數據(第9行),這行數據複製了第一行但是把年齡改成37。在slot_table文件中把1改爲D,然後添加9:V。修改後的slot_table和數據文件如下:

updated titanic_survivor.slot_table

updated titanic_survivor.csv (line 9)
讀者可能會發問,雖然保證了數據文件的append only,但是slot_table還是會在文件中進行修改,如果數據量一大,依然會增加讀寫負擔。還能不能進一步優化?答案是可以的。我們其實可以把標註信息也以append only的形式添加到slot_table中,比如上述的刪除和修改操作完成後,slot_table如下:

append only titanic_survivor.slot_table

然後在讀取數據的時候,先讀取slot_table,然後逆序讀取所有行的標註信息(讀取到2D後就忽略第二行),就能知道哪些行是有效的,哪些行可以略過不用讀取了。

對於數據的增刪改,我們已經可以對數據文件和slot_table都實現append_only。還有什麼問題嗎?對於一個數據表,每次操作都會添加新信息,久而久之,數據文件越來越大,而且無效的行越來越多,豈不是很浪費空間。有什麼辦法可以優化呢?有。數據庫都會支持vacuum操作(或者叫compact),這個操作所做的就是讀取數據文件和slot_table,然後根據標註把有效的數據行寫回新的文件。比如,對我們的示例進行vacuum操作,新的數據文件和slot_table如下所示:

vacuumed titanic_survivor.csv

vacuumed titanic_survivor.slot_table

爲了更高效地實現增刪改數據,我們引入了第二個特性,slot_table以及標註信息來紀錄對數據的增刪改,並且引入vacuum操作定期清理無用的行數據。

對於CSV,還有什麼能改進的嗎?你可能已經發現了,CSV是用明文來存儲數據,太低效了。比如對應一個boolean的類型,明文存成true或者false,如果用ascii編碼就是32或者40個bit(按照8bit的extended ascii編碼)。但如果用byte存儲,只要1個bit即可(即便是用0,1明文來存儲boolean也還是沒有byte高效)。CSV爲了方便用戶直接能夠理解,所以犧牲了效率。但是數據文件完全由數據庫系統管理和讀取,可以存儲raw byte配上高效的編碼和解碼算法來進一步優化。那如何才能更高效得存儲呢?這裏我就不給出具體的實現了,可以參考現在流行的RPC框架比如ThriftProtocol Buffers,這些網絡端傳輸數據的協議,爲了追求效率對數據的編碼和解碼有很多優化。相對應的,slot_table裏存儲的不再只是行號,而應該是該條數據對應在文件中的byte offset。

爲了更高效得存儲數據,我們引入了第三個特性,用raw byte來存儲數據配合高效的編碼和解碼算法來加速讀取和寫入。

還有什麼能再進一步優化嗎?說下一步優化前,我們先來了解這樣一類數據庫。這類數據庫並不進行修改和添加操作,但是存儲了大量的數據,並且要運行大量非常複雜的分析語句。沒錯,這類數據庫是數據倉庫(Data warehouse)。區別於普通的Online transactional processing(OLTP)的數據庫,通過抓取,清洗和變化OLTP數據庫的數據然後導入到數據倉庫負責分析報表等需要查詢大量歷史數據的複雜語句。這類數據庫的表結構稱之爲雪花模型(snowflake schema),由一張或者多張的實體數據表(entity table),配合一些輔助表(英文裏稱dimension table)。實體數據表通常是交易記錄等存有大量數據(億甚至千億級別),輔助表則只有少量的相關信息比如國家,商戶等的具體信息。下圖引用了TPC-H(非常有名的數據庫基準測試)的雪花模型,其中表lineitem_orders就是一個包含所有交易紀錄的實體表。

TPC-H snowflake schema

實體表不僅有大量數據行,屬性也很多(100到200都很常見)。可是,大部分的分析報表語句僅需要讀取相關的幾個屬性(列)。爲了運行該語句,就需要把整個實體表的數據讀到內存中來抽取需要的屬性列。設想一個實體表有100個屬性,10億條數據,但某個語句只需要用到3個屬性。按照CSV方式讀取數據,97%的數據是沒有用處的。貪得無厭的數據庫的大牛想,有什麼辦法可以優化需要讀取的數據嗎?於是列存(column-oriented store)就這樣出現了。

類似於CSV這樣把每一個tuple的數據存放在一起的存儲方式叫行存(row-oriented store)。相對應的列存,就是指把一個表的每個屬性, 單獨存在一個數據文件中。還是沿用上面titanic的例子,我們會有單獨的數據文件(還有slot_table文件)來存儲姓名,船票價格,登船碼頭,等等。在讀取的時候,根據查詢語句需求,需要用到哪個屬性就讀取哪個屬性的數據文件。按照前面的例子,我們只需要讀取原來的3個屬性的數據文件,讀取速度自然就提高了。

除了可以避免讀取不必要的數據,列存還能帶來什麼優勢?因爲每一列的類型是相同的,比如都是整形或者是字符串。在存儲同類型的數據時,壓縮算法能夠更有效地進行壓縮,使得數據量更近一步減少,來加快讀取速度。舉個簡單的例子,上述titanic的例子中有一列Cabin紀錄了倉位信息(假設值分爲A1,A2,A3,B1,…等),相較於對於每一行都直接用字符串來存儲,我們可以採用下面enum的壓縮方式。因爲倉位類型不多,所以對於每一行,只需要用tiny就能存下是哪個倉位了。只需要數據庫系統在讀取數據的時候根據meta把對應數據換出即可。

cabin.titanic_survivor.column_store
爲了應對數據倉庫中複雜報表的查詢語句和超大量的數據讀取,我們引入了第四個優化,把行存轉換爲列存,並且由於存儲的數據是一個類型的,可以進一步用壓縮算法來優化數據量。

小結

至此,我們從最原始的使用CSV文件格式來存儲數據,一步一步根據數據庫的操作需要,"進化"出了下面這些優化方法:

  1. 爲了更好得支持對錶的元數據的管理和變更操作, 分離元數據和數據的存儲

  2. 爲了更高效地實現增刪改數據,引入slot_table以及標註信息來紀錄對數據的增刪改,並且引入vacuum操作定期清理無用的行數據

  3. 爲了更高效得存儲數據,用byte來存儲數據配合高效的編碼和解碼算法來加速讀取和寫入

  4. 爲了應對數據倉庫中複雜報表的查詢語句和超大量的數據讀取,引入列存概念,並且用壓縮算法來進一步優化數據量

具體到真正數據庫的實現,還有無數各個方面的工程優化。比如,爲了提高從文件系統讀取數據到內存的速度,把文件塊的大小設置得和內存頁一致,用內置的緩存機制來提前換進和換出數據頁(相對於操作系統的默認緩存機制,數據庫系統更清楚哪些數據會一起被使用從而可以提前做好準備)。但是各種優化,也並不是數據庫大牛拍腦袋想出來的。而是針對問題,提出思路和解決方案,一步一步實踐出來的。所以面對工作中的工程問題,我們也應該本着這種心態去處理和解決。

最後留個坑,雖然列存的實現,使得我們不用讀取無用列的數據,但針對某些點查詢的語句(point query),比如"select col2 from table1 where col1 = 10", 我們依然需要讀取col1和col2兩列的全部數據。有什麼辦法可以優化這類查詢嗎?

下一篇,我們接着聊這類優化-索引(indexing)。

作者介紹:

顧仲賢,現任Facebook Tech Lead,專注於數據庫,分佈式系統,數據密集型應用後端架構與開發。擁有多年分佈式數據庫內核開發經驗,發表數十篇數據庫頂級期刊並申請獲得多項專利,對搜索,即時通訊系統有深刻理解,愛設計愛架構,持續跟進互聯網前沿技術。

2008年畢業於上海交大軟件學院,2012年,獲得美國加州大學戴維斯計算機碩士,博士學位;2013-2014年任Pivotal數據庫核心研發團隊資深工程師,開發開源數據庫優化器Orca;2016年作爲初創員工加入Datometry,任首席工程師,負責全球首家數據庫虛擬化平臺開發;2017年至今就職於Facebook任Tech Lead,領導重構搜索相關後端服務及數據管道, 管理即時通訊軟件WhatsApp數據平臺負責數據收集,整理,並提供後續應用。

相關閱讀:

數據庫內核雜談(一):一小時實現一個基本功能的數據庫

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章