如何高效地將SQL資料對映到NoSQL儲存系統中

InfoQ - 邵思華發表於2015-01-08

通常來說,我們都知道:

  • SQL資料庫只限在單機上執行,但它提供了更強的事務管理、schema與查詢功能。
  • NoSQL資料庫為了伸縮性與容錯性的目的,放棄了事務管理與schema。

而FoundationDB的SQL層結合了這兩個方面:它首先是一個開源的SQL資料庫,能夠線性地伸縮與提升容錯性,並且還具有真正的ACID事務功能。曾經互不相容的兩種特性,現在已融合在一個統一的系統中。

對於處於以下幾種情況的公司來說,這一特性是非常重要的:

  • 新的專案要為大規模的伸縮性進行計劃。
  • 現有的專案遇到了資料庫伸縮性的瓶頸。
  • 現有的許多專案希望能用一個唯一的、容錯性強的資料庫抽象層統一工作模式。

在本文中,我將為讀者介紹FoundationDB,並解釋FoundationDB的SQL層是怎樣將SQL資料對映到FoundationDB中的鍵-值儲存後臺系統中的。

NoSQL資料庫 ——FoundationDB的鍵-值儲存系統

FoundationDB是一個分散式的鍵-值儲存系統,支援全域性ACID事務操作,並且效能出眾。在安裝系統時,可以指定資料分發的級別。資料分發為容錯性提供了支援:當某個伺服器或網路的某部分產生故障時,資料庫仍然可以正常操作,你的應用也不會受到影響。

鍵-值與SQL架構

我們開發的這套架構能夠在鍵-值儲存系統上支援多個層,每個層都能夠在FoundationDB的基礎上提供一套不同的資料模型,例如SQL資料庫、文件資料庫或圖形資料庫。許多使用者也自行建立了自定義的層。

下圖中列出架構中的了關鍵部分。處於最底層的是FoundationDB叢集,無論叢集的實際大小如何,對它的操作與一個單獨的邏輯資料庫並沒有分別。SQL層則以一種無狀態的中間層方式執行在鍵-值儲存系統之上。這一層通過SQL與應用程式進行通訊,並使用FoundationDB的客戶端API與鍵-值儲存系統進行通訊。由於SQL層是無狀態的,因此可以並行地執行任意資料的SQL層。

SQL層為鍵-值儲存系統帶來了如Google的F1般的能力

SQL層是對SQL與鍵-值儲存API進行轉換的一套邏輯嚴密的層。首先,SQL層會從一條SQL語句開始,將其轉換為最高效地鍵-值操作。這種方式類似於編譯器將程式碼轉換為低階別的執行格式。並且,這種轉換是完全符合ANSI SQL 92標準的。開發者可以將該功能與ORM、REST API進行接合,或者直接使用SQL層的命令列介面進行呼叫。從程式碼的角度來說,SQL層與鍵-值儲存是完全分離的,它是通過FoundationDB的Java繫結方式與鍵-值儲存進行通訊的。感興趣的讀者可以檢視FoundationDB的SQL層在GitHub上的程式碼庫,其程式碼是完全開源的。眼下唯一能夠和這套系統進行比較的是Google的F1,後者是一套基於該公司的Spanner技術所建立的SQL引擎。

如以下的簡單圖例所示,SQL層是由一系列元件所組成的。應用程式通過某種受支援的SQL客戶端向SQL層傳送查詢語句,在解析之後轉換為一棵計劃節點樹。優化器(Optimizer)會計算最佳的執行計劃,並以一棵操作符樹的方式表現出來,隨後由執行框架(Execution Framework)執行。在執行階段,對資料的請求將被髮送到儲存虛擬(Storage Abstraction)層,這一層通過使用Java的鍵-值API在資料與FoundationDB叢集之間進行傳輸。資料庫模型將存放在Information Schema層中,這一層將被其它多個元件所呼叫。

將SQL資料對映到鍵-值儲存系統

SQL層需要管理兩種型別的資料,首先是資訊Schema的後設資料,它負責描述所建立的表與可用的索引。其次,它還需要儲存實際的資料,包括表內容、索引及序列。我們首先來描述一下這些資料是如何儲存在鍵-值儲存系統中的。

本質上講,每個鍵都是對應了某張表中的特定行的指標,而值則包含了該行的資料。鍵的分配是由Table-Group所決定的,它是包含了一個或多個表的組。稍後會對這個概念的細節進行更深入的講解。SQL層會通過使用鍵-值儲存目錄層為每個Table-Group建立一個目錄,儲存目錄層是為使用者管理鍵空間的一個工具,它為每個獨立的目錄分配一個簡短的位元組陣列,作為該目錄的唯一鍵。同時,它也維護著其它後設資料,以實現通過名稱進行查詢的功能。

下面這個例子演示瞭如何建立目錄的對映,通過以下語句分配鍵。

CREATE TABLE schema_a.table1(id INT PRIMARY KEY, c CHAR(10));
CREATE TABLE schema_a.table2(id INT PRIMARY KEY);

在鍵-值儲存系統中有一些預定義的目錄:

Directory

Tuple

Raw Key

sql/ (9) \x15\x09
sql/data/ (3) \x15\x03
sql/data/table/ (31) \x15\x1F
sql/data/table/schema_a/table1/ (215) \x15\xD7
sql/data/table/schema_a/table2/ (247) \x15\xF7

在儲存資料時,可以選擇使用以下三種格式中的一種:“元組(Tuple)”、“原始資料(Row_Data)”或者是“Protobuf”。如果使用預設的Tuple儲存格式,那麼每一行內容都將儲存為一個單獨的鍵-值對,鍵是通過連線以下字串所生成的元組:目錄字首、該表在Table-Group中的位置,以及主鍵。而值的內容則是由該行中的所有列所組成的一個元組。

舉例來說,以下程式碼對之前建立的表進行操作,產生對應的鍵與值。

INSERT INTO schema_a.table1 VALUES (1, 'hello'), (2, 'world');
INSERT INTO schema_a.table2 VALUES (5);

Raw Key

Tuple Key

Raw Value

Tuple Value
\x15\xD7\x15\x01\x15\x01 (215, 1, 1) \x15\x01\x02hello\x00 (1, ‘hello’)
\x15\xD7\x15\x01\x15\x02 (215, 1, 2) \x15\x02\x02world\x00 (2, ‘world’)
\x15\xF7\x15\x01\x15\x05 (247, 1, 5) \x15\x05 (5)

瞭解了鍵-值儲存系統中鍵的結構之後,你就能夠從儲存系統中直接讀取資料了。我們將使用FoundationDB的Python API來演示這一功能。在SQL層中,鍵與值是通過“.pack()”方法進行編碼,並通過“.unpack()”方法進行解碼的。下面的示例為你演示如何獲取並解碼資料。

import fdb  fdb.api_version(200) 
db = fdb.open() 
directory = fdb.directory.open(db,('sql','data','table','schema_a','table1'))
for key, value in db[directory.range()]:         print fdb.tuple.unpack(key), ' --> ', fdb.tuple.unpack(value)

以上程式碼會輸出類似下面的結果:

(215, 1, 1) --> (1, u'hello') 

(215, 1, 2) --> (2, u'world')

現在讓我們再來近距離觀察一下Table-Group。每個獨立的表都屬於一個單獨的組,如果某張額外的表能夠建立一個對第一張表的“組外來鍵”引用,那麼它也能夠加入到同一個組中。當我們為某張表建立組外來鍵時,字表將與父表所在的目錄進行互動。字表將成為Table-Group的一部分,在源表之後進行命名。這兩張表的資料在將同一個目錄中進行互動,這保證了範圍掃描的高速,並且在Table-Group之內訪問物件及表連線的開銷極小。為了演示這一特性,我們將繼續之前的示例,這一次的SQL語句如下:

CREATE TABLE schema_a.table3(id INT PRIMARY KEY, id_1 INT, GROUPING FOREIGN KEY (id_1) REFERENCES schema_a.table1(id));
INSERT INTO schema_a.table3 VALUES (100, 2), (200, 2), (300, 1);

該語句將返回以下結果:

directory = fdb.directory.open(db,('sql','data','table','schema_a','table1'))
for key, value in db[directory.range()]:     print fdb.tuple.unpack(key), ' --> ', fdb.tuple.unpack(value)
(215, 1, 1)          -->  (1, u'hello')
(215, 1, 1, 2, 300)  -->  (300, 1) 
(215, 1, 2)          -->  (2, u'world')
(215, 1, 2, 2, 100)  -->  (100, 2)
(215, 1, 2, 2, 200)  -->  (200, 2)

由於第三張表的鍵都處於第一張表中各行的名稱空間範圍內,因此第三張表中所有插入的行都能夠與第一張表的行相關聯。鍵中的兩個額外的值分別對應了Table-Group中的位置以及第三張表中的主鍵。對錶1與表3通過引用鍵進行連線也無需通過標準的連線操作實現,直接通過線性掃描就語句了。這種排序方式比起傳統的關係型資料庫系統有著極大的優勢。

由於鍵都已經經過排序,因此索引可以直接利用這一點所帶來的便利性。所有的表索引只包含一個鍵值,其中包括兩部分內容。每個索引都建立於該表所屬的目錄之下,一個名為index的子目錄中,這是該鍵元組的第一部分內容。第二個部分是一個組合,首先是該索引所對應的各個列的值,之後則是指定這一行所必須的列的值。

舉例來說,我們可以為這張表的c列建立一個索引。

CREATE INDEX index_on_c ON schema_a.table1(c) STORAGE_FORMAT tuple;

接下來使用Python讀取這個索引的內容,我們需要在Python直譯器中加入以下內容:

directory = fdb.directory.open(db, ('sql', 'data', 'table', 'schema_a', 'table1', 'index_on_c'))
for key, value in db[directory.range()]:     print fdb.tuple.unpack(key), ' --> ', fdb.tuple.unpack(value)

這段程式碼會輸入類似於下圖中的內容,顯示了鍵的兩個組成部分:即該索引所在的目錄的位元組值,以及建立索引的c列的值加上主鍵的值。最後一個部分將被索引的值連結到某個特定的行,而該索引鍵所對應的值為空。

(20127, u'hello', 1) --> ()
(20127, u'world', 2) --> ()

如果要對SQL層的行為進行更多的控制調整,可以使用以下三種儲存格式:一是之前描述過的元組格式,一是列鍵格式,以及protobuf格式。列健格式會為某一行的每個列值建立一個獨立的鍵-值對。而protobuf儲存格式為會每一行建立一個protobuf訊息。

接下來還需要對後設資料進行儲存與組織。SQL層使用protobuf訊息與基於SQL的資料的結構進行通訊。這個結構是由schema、組、表、列、索引與外來鍵等物件共同組成的。

SQL與NoSQL的混合模式

如果在應用程式級別使用只讀的鍵-值API,那麼SQL層就能夠在客戶端進行直接訪問。可以通過鍵-值API直接訪問資料,但如果增加或改寫了SQL層所用的關鍵資料,那就很可能破壞系統的執行。這裡例舉一些可能會產生的問題:缺乏對索引的維護、缺乏應有的限定,以及忽略了對資料及後設資料的版本維護。而這種方式的好處,哪怕是在進行資料讀取時也並不明顯,因為SQL層本身的額外開銷就非常小。因此總的來說,效能的開銷主要取決於網路延遲。

結論

SQL與NoSQL的結合使用能夠相互利用兩者的優點。FoundationDB的鍵-值儲存系統為SQL層帶來的好處包括可伸縮性、容錯性及全域性ACID的事務屬性。你的應用程式同樣也能從中受益,因此趕緊嘗試一下吧!對應那些要執行大量的小批資料讀取及寫入的應用程式來說,FoundationDB提供了一個高伸縮並且安全的解決方案,並且可以任意使用SQL或NoSQL。

相關文章