Cloud Spanner 是一項關(guān)系型、支持水平擴展的數(shù)據(jù)庫服務(wù),基于云端/分布式設(shè)計構(gòu)建,可為開發(fā)者和數(shù)據(jù)庫管理員(DBA)提高效率,具有高可用性,且在結(jié)構(gòu)上區(qū)別于典型數(shù)據(jù)庫。在本系列博客中,我們將探討 DBA 和開發(fā)者在從傳統(tǒng)的垂直擴展關(guān)系型數(shù)據(jù)庫的管理系統(tǒng)(RDBMS),遷移到 Cloud Spanner 時,可能遇到的關(guān)鍵不同。我們并進一步討論哪些是該做的,哪些是不該做的,最佳實踐是什么,以及 Cloud Spanner 中存在這種不同的原因。
在本系列中,我們會就一系列主題進行探討,包括:
鍵的選擇和索引的使用
如何匹配業(yè)務(wù)邏輯
導(dǎo)入和導(dǎo)出數(shù)據(jù)
從現(xiàn)有的 RDBMS 中遷移
性能優(yōu)化
訪問控制和日志記錄
您將了解如何更好地使用 Cloud Spanner,讓其在海量數(shù)據(jù)庫中實現(xiàn)線性性能擴展。在第一部分,我們先來詳細(xì)了解一下 Cloud Spanner 中鍵和索引的概念。
在 Cloud Spanner 中選擇鍵
與其他數(shù)據(jù)庫類似,鍵的選擇對于優(yōu)化數(shù)據(jù)庫的性能至關(guān)重要。鑒于 Cloud Spanner 分配數(shù)據(jù)庫負(fù)載的機制,因此鍵的選擇對其來說更為重要。與傳統(tǒng) RDBMS 不同的是,在選擇表(Table)的主鍵和要索引的列(Column)時,你需要格外注意。
使用分布恰當(dāng)?shù)逆I會使表的大小和性能隨著Cloud Spanner 節(jié)點數(shù)量線性擴展,而使用分布不良的鍵會導(dǎo)致熱點問題,即一個節(jié)點承擔(dān)表的大部分讀和寫。
在傳統(tǒng)的垂直擴展 RDBMS 中,單個節(jié)點管理所有表。(根據(jù)安裝的不同,可能會有副本,用于讀取或故障轉(zhuǎn)移)。因此,該單個節(jié)點完全控制表格的行鎖(row lock)和從數(shù)字序列(numberic sequence)中生成的唯一鍵。
Cloud Spanner 是一個分布式系統(tǒng),在任何時候都會有很多節(jié)點對數(shù)據(jù)庫進行讀寫。但是,為了實現(xiàn)可擴展性、全局 ACID 事務(wù)和強一致性,只有一個節(jié)點可以隨時對給定的行進行寫入。
Cloud Spanner 通過使用按照字典序排序的主鍵的范圍,將每個表拆分成若干個分片,從而讓表的行的管理分布在多個節(jié)點上。
這使得 Cloud Spanner 能夠?qū)崿F(xiàn)高可用性和可擴展性,但這也意味著使用任何連續(xù)增加或減少的序列作為主鍵,都不利于性能。為了解釋原因,讓我們來探討一下 Cloud Spanner 是如何創(chuàng)建和管理表的分片的。
表格拆分和鍵的選擇
Cloud Spanner 使用 Paxos 管理分片(您可以通過以下文檔了解詳情:Cloud Spanner 的讀寫生命周期 和 Spanner: Google 的全球式分布式數(shù)據(jù)庫)。) 在 Cloud Spanner 區(qū)域?qū)嵗校x取/寫入每個分片的責(zé)任被分配到一組三個節(jié)點上,分別位于 Cloud Spanner 實例的三個可用性地區(qū)中。
這個組中的一個節(jié)點被選為「Split Leader」,負(fù)責(zé)管理分片中所有行的寫入和鎖定。該組中的三個節(jié)點都可以進行讀取。
為了創(chuàng)建一個比較直觀的例子,我們假設(shè)有一個 600 行的表,該表格使用簡單、連續(xù)、遞增的整數(shù)鍵(類似傳統(tǒng) RDBMS 中常見的那樣),這個表被拆分為 6 個分片,運行在一個雙節(jié)點(每個地區(qū))Cloud Spanner 實例上。在理想情況下,該表會有六個分片,其中的 Leaders 將是實例中可用的六個單獨節(jié)點。
只要讀取和更新均勻地分布在鍵范圍之內(nèi),這種分布就會提供理想的讀寫性能。
熱點問題
當(dāng)新行被添加到數(shù)據(jù)庫時,問題就出現(xiàn)了。每一條新的行都會有一個遞增的ID,并且會被添加到最后一個分片,這意味著在六個可用的節(jié)點中,只有一個節(jié)點會處理所有的寫入。在上面的例子中,節(jié)點 2c 將處理所有的寫入。如此一來,這個節(jié)點就會成為一個熱點,限制了數(shù)據(jù)庫的整體寫入性能。此外,行的分布也會變得不平衡,最后一個拆分會明顯變大,因此它會處理更多的行讀取。
為了補償不平衡的負(fù)載,Cloud Spanner嘗試過在后臺根據(jù)讀寫負(fù)載的不同,添加和刪除分片,以及在分片大小越過設(shè)定的閾值后,創(chuàng)建新的分片,但是在頻繁插入的表格中,這種情況不會很快發(fā)生,以避免產(chǎn)生熱點。
除了單調(diào)增加或減少鍵外,這個問題還影響到由任意確定性的鍵索引的表–例如,事件日志表格中不斷增加的時間戳。時間戳鍵的表也更容易出現(xiàn)讀取熱點,因為在大多數(shù)情況下,最近的時間戳行相較于其他行的訪問頻率更高。(閱讀《Cloud Spanner - 選擇正確的主鍵》,了解更多關(guān)于檢測和避免熱點問題的詳細(xì)信息)。
序列生成器問題
序列生成器的概念,或者說稀缺性,是一個需要進一步探索的重要領(lǐng)域。傳統(tǒng)的垂直 RDBMS都有集成的序列生成器,在事務(wù)過程中從一個序列中創(chuàng)建新的整數(shù)鍵。Cloud Spanner 由于其分布式架構(gòu),無法做到這一點,因為在插入新鍵時,要么在分片的主要節(jié)點之間會出現(xiàn)競爭,要么在生成新鍵時,表必須全局鎖,而這兩者都會降低性能。
一個可行的辦法是,鍵由應(yīng)用程序生成(例如,將下一個鍵值存儲在數(shù)據(jù)庫中的一個單獨的表格中,或者從表格中獲取當(dāng)前最大的鍵值)。然而,你會遇到同樣的性能問題。考慮到由于應(yīng)用程序也可能是分布式的,可能會有多個數(shù)據(jù)庫客戶端試圖同時插入一條記錄,根據(jù)新鍵的生成方式,可能會出現(xiàn)兩種結(jié)果:
如果在事務(wù)中執(zhí)行對現(xiàn)有鍵的 SELECT,一個試圖插入記錄的應(yīng)用程序?qū)嵗龝驗樾墟i定,而阻止所有其他試圖插入記錄的應(yīng)用程序?qū)嵗?/span>
如果現(xiàn)有鍵的 SELECT 是在事務(wù)之外執(zhí)行的,那么每個試圖插入記錄的應(yīng)用程序?qū)嵗g都會產(chǎn)生競爭。其中一個會成功,而其他的實例在插入記錄失敗后必須重新嘗試(包括生成一個新鍵),因為這個鍵已經(jīng)存在了。
好的鍵是如何產(chǎn)生的?
那么,如果順序鍵會限制 Cloud Spanner 中的數(shù)據(jù)庫性能,那么應(yīng)該使用什么鍵呢?理想情況下,在選擇主鍵時,主鍵的靠左的字段的數(shù)據(jù)應(yīng)該是均勻的、半隨機分布的。
生成這樣的鍵有一種簡單方法,是使用隨機數(shù),例如隨機的通用唯一標(biāo)識碼(UUUID)。注意,UUUID 有好幾種。版本 1 和 2 使用確定性前綴,如時間戳或 MAC 地址等。確保你使用的 UUUID 生成方法是隨機分布的,即v4,至少在高階字節(jié)上是隨機分布的。這將確保鍵空間中的鍵均勻分布,從而讓負(fù)載均勻地分布在spanner節(jié)點上。
雖然另一種方法是使用一些現(xiàn)實世界中的數(shù)據(jù)屬性,這些屬性是不可變的,并且在鍵范圍內(nèi)均勻分布,但這是一個相當(dāng)大的挑戰(zhàn),因為大多數(shù)均勻分布的屬性都是離散的,不是連續(xù)的。例如,擲骰子的隨機結(jié)果是均勻分布的,有六個有限值。而連續(xù)分布可以依靠一個無理數(shù),比如說π。
如果我真的需要一個整數(shù)序列作為鍵,怎么辦?
雖然這不是我們推薦的,但在某些情況下,整數(shù)序列鍵是必須的,無論是出于遺留問題還是外部原因,例如員工 ID。
要使用整數(shù)序列鍵,你首先需要一個在分布式系統(tǒng)中穩(wěn)定的序列生成器。其中一種方法是在 Cloud Spanner 中的表里,為每個要求的序列包含一個行,這個行又序列中的下一個值,因此它看起來類似這樣:
CREATE TABLE Sequences (
Sequence_ID STRING(MAX) NOT NULL, -- The name of the sequence
Next_Value INT64 NOT NULL
) PRIMARY KEY (Sequence_ID)
當(dāng)需要新 ID 值的時候,序列的下一個值會被讀取、遞增,并在插入新行的同一事務(wù)中更新。
注意,當(dāng)插入多行時,這會降低性能,因為我們上面創(chuàng)建的序列表更新的時候,每次插入都會中斷其他插入。
這個性能問題可以解決----代價是序列中可能出現(xiàn)的斷檔----比如如果每個應(yīng)用程序?qū)嵗ㄟ^將 Next_Value 增加至 100,來一次性保存 100 個序列值,然后在塊內(nèi)部管理單個ID。
在使用序列的表中,不能簡單地以數(shù)字序列值本身作為鍵,因為這將導(dǎo)致最后一個拆分值成為熱點(如前文所述)。因此,應(yīng)用程序必須生成一個復(fù)雜鍵,將行隨機分配到各分片之間。
這就是所謂的應(yīng)用級分片(application-level sharding),通過在主鍵中的序列ID前綴一個包含均勻分布的值的附加列來實現(xiàn),例如,原始ID的哈希值,或者是ID的位反轉(zhuǎn)。如下所示:
CREATE TABLE Table1 (
Hashed_Id INT64 NOT NULL,
ID INT64 NOT NULL,
-- other columns with data values follow....
) PRIMARY KEY (Hashed_Id, Id)
即使是一個簡單的循環(huán)冗余校驗(CRC)32校驗和,也足以提供一個適當(dāng)?shù)膫坞S機Hashed_Id。它不一定必須是安全的,只要能打散順序編號鍵的行序就可以了,如下表所示。
注意,每次直接讀取一條記錄,都必須指定 ID 和 Hashed_Id,以防止全表掃描,參考下面這個例子:
SELECT * FROM Table1
WHERE t1.Hashed_Id = 0xDEADBEEF
AND t1.Id = 1234
同樣的,每當(dāng)這個表與查詢中的其他表通過 Id 連接時,連接也必須同時使用ID和Hashed_Id。否則,你就要損失性能,因為需要進行表掃描才能找到該行。這意味著引用 ID 的表必須同時包含 Hashed_Id,如下:
CREATE TABLE Table2 (
Id String(MAX), -- UUID
Table1_Hashed_Id INT64 NOT NULL,
Table1_Id INT64 NOT NULL,
-- other columns with data values follow....
) PRIMARY KEY (Id)
SELECT * from Table2 t2 INNER JOIN Table1 t1
ON t1.Hashed_Id = t2.Table1_Hashed_Id
AND t1.Id = t2.Table1_Id
WHERE ... -- some criteria
如果我真的需要使用時間戳作為鍵,怎么辦?
很多情況下,用時間戳作為鍵的行也會指向其他表的一些數(shù)據(jù)。例如,一個銀行賬戶上的交易將指向源賬戶。在這種情況下,假設(shè)源賬戶已經(jīng)合理地平均分布了,你可以先用一個包含賬號的組合鍵,先賬號,再時間戳。
CREATE TABLE Transactions (
account_number INT64 NOT NULL,
timestamp TIMESTAMP NOT NULL,
transaction_info ...,
) PRIMARY KEY (account_number, timestamp DESC)
分片將主要用到賬號而不是時間戳,從而將新添加的行分布在不同的分片中。
請注意,在這個表中,時間戳是按降序排列的。這是因為在大多數(shù)情況下,你想讀取最近的交易----這在表里排在最前邊----所以你不需要掃描整個表來找到最近的記錄。
如果你沒這么做,或者不能有外部引用,又或者有其他數(shù)據(jù)可以在鍵中使用,以分配順序,那么你就需要執(zhí)行應(yīng)用級分片,如前面講到的整數(shù)序列示例。
注意,簡單哈希值會使按時間戳范圍進行查詢的速度變得非常慢,因為檢索一個時間戳范圍,需要進行一次完整的表掃描,才能覆蓋所有的哈希值。相反,我們建議從時間戳中生成一個 ShardId。如圖所示:
TimestampShardId = CRC32(Timestamp) % 100
然后會從時間戳中返回一個 0-99 之間的偽隨機值。你可以在表的主鍵中使用這個
ShardId,這樣就可以將順序時間戳分布在多個拆分中,類似這樣:
CREATE TABLE Events (
TimestampShardId INT64 NOT NULL
Timestamp TIMESTAMP NOT NULL,
event_info...
) PRIMARY KEY (TimestampShardId, Timestamp DESC)
例如,一個包含 2018 年前 10 天日期的表(如果沒有 ShardId 就會按日期順序存儲在表中),會給出如下排序:
進行查詢時,必須使用 BETWEEN 子句,以便能夠在不執(zhí)行表掃描的情況下,跨所有分片進行選擇。
Select * from Events
WHERE
TimestampShardId BETWEEN 0 AND 99
AND Timestamp > @lower_bound
AND Timestamp < @upper_bound;
注意,ShardId 只是改進鍵分布的一種方法,以便 Cloud Spanner 可以使用多個拆分來存儲順序時間戳。它并不能識別實際的數(shù)據(jù)庫拆分,不同表中具有相同的 ShardId 的行可能處于不同的分片中。
遷移的影響
當(dāng)你要從現(xiàn)有的 RDBMS 遷移到Cloud Spanner 時,如果使用的鍵不適合 Cloud Spanner,請注意到上述事項。如有必要,請在表中添加鍵哈希值或更改鍵順序。
決定 Cloud Spanner 中的索引
在傳統(tǒng) RDBMS 中,通過非主鍵的值來查找表中的行,索引是非常有效的方法。在大多數(shù)情況下,通過索引進行的行查詢與通過主鍵進行的行查詢所需的時間大致相同。這是因為表和索引是由單一節(jié)點管理的,所以索引可以直接指向表的磁盤行。
在 Cloud Spanner 中,索引實際上是用表來實現(xiàn)的,這使得索引可以分布式實現(xiàn),并且可以具有與普通表一樣的擴展性和性能。
但是,由于這種類型的實現(xiàn),使用索引從表行讀取數(shù)據(jù)的效率,不如傳統(tǒng)RDBMS 的效率高。它實際上是與原始表的內(nèi)部連接,所以使用索引鍵從表中讀取數(shù)據(jù)就變成了這個過程:
查找分片中的索引鍵
從分片中讀取索引行,以獲得表鍵
查找分片中的表鍵
從分片中讀取表行,以獲得行值
返回行值
注意,不能保證索引鍵的拆分,和表鍵的分片位于同一個節(jié)點上,所以一個簡單的索引查詢,可能就需要跨節(jié)點通信去讀取一行。
同樣,更新一個有索引的表,很可能需要多節(jié)點寫入,來更新表行和索引行。因此,在 Cloud Spanner 中使用索引,總需要在提高讀取性能和降低寫入性能之間進行權(quán)衡。
索引鍵和熱點
由于索引在 Cloud Spanner 中是以表的形式呈現(xiàn)的,因此你會遇到與表鍵相同的問題。即使底層表使用的是分布良好的主鍵,但如果在具有較差的值(如時間戳)的列上建立索引,也會導(dǎo)致創(chuàng)建一個熱點。這是因為當(dāng)行被插入到表上時,索引也會插入新的行,而對這些新行的寫入,總會被發(fā)送到同一個分片。
因此,在創(chuàng)建索引時必須小心謹(jǐn)慎,我們建議你只使用具有良好分布值的列來創(chuàng)建索引,就像選擇表的主鍵時一樣。
在某些情況下,您需要對索引列進行應(yīng)用級分片,以便創(chuàng)建一個合成的ShardId 列,該列可以在索引中使用,從而在分片上分配值。
例如,下面的這個配置會在因索引插入事件時,創(chuàng)建一個熱點,即使 UserId是隨機分布的。
CREATE TABLE Events (
UserId String(MAX),
Timestamp TIMESTAMP,
EventData)
PRIMARY KEY (UserId, Timestamp DESC);
CREATE INDEX EventsByTimestamp ON Events (Timestamp DESC);
與僅以時間戳為鍵的表一樣,需要在表中添加一個合成的 ShardId 列,然后作為第一個索引列,以幫助索引在各拆分之間分布。
如下是一個簡單的 ShardId 生成器:
TimestampShardId = CRC32(Timestamp) % 100
這會輸出一個哈希值在 0-99 之間時間戳。你需要把它作為新列添加到原始表中,然后用它作為索引鍵的第一個字段鍵,類似這樣:
CREATE TABLE Events (
UserId String(MAX),
Timestamp TIMESTAMP,
TimestampShardId INT64,
EventData)
PRIMARY KEY (UserId, Timestamp DESC);
CREATE INDEX EventsByTimestamp ON Events (TimestampShardId,Timestamp);
這將去掉索引更新時的熱點,但會降低時間戳范圍查詢的速度,因為你必須為每個 ShardId值(0-99)運行查詢,以獲得所有分片的時間戳范圍:
Select * from Events@{FORCE_INDEX=EventsByTimestamp}
WHERE
TimestampShardId BETWEEN 0 AND 99
AND Timestamp > @lower_bound
AND Timestamp < @upper_bound;
使用這種類型的索引和分片策略,必須在讀取時的額外復(fù)雜性,和索引性能的提高之間,做取舍。
你應(yīng)該知道的其他索引
當(dāng)你遷移到 Cloud Spanner 后,你還需要了解這些索引的功能,以及什么時候使用它們。
When you’re migrating to Cloud Spanner, you’ll also want to understand how these other index types function and when you might need to use them:
NULL_FILTERED 索引
默認(rèn)情況下,Cloud Spanner 將使用 NULL 索引列值對行進行索引。NULL 被認(rèn)為是盡可能小的值,因此這些值將出現(xiàn)在索引的開頭。
也可以通過使用 CREATE NULL_FILTERED INDEX 語法創(chuàng)建一個索引,這個索引會忽略NULL 索引列值的行。
這個索引會比完整的索引小,因為它將有效地成為表上的具體化過濾視圖,當(dāng)需要進行表掃描時,查詢速度將比完整的表更快。
UNIQUE索引
你可以使用一個 UNIQUE 索引,強制讓表的一個列具有唯一值。這個約束將在事務(wù)提交時(和索引創(chuàng)建時)應(yīng)用。
覆蓋索引和 STORING 子句
為了優(yōu)化索引讀取的性能,Cloud Spanner 可以將表行的列值存儲在索引中,從而無需讀取表。這被稱為覆蓋索引。這可以通過定義索引時使用 STORING 子句來實現(xiàn)。然后可以直接從索引中讀取列的值,所以從索引中讀取的效果和從表中讀取的效果一樣好。例如,這個表包含了員工數(shù)據(jù)。
CREATE TABLE Employees (
CompanyUUID INT64,
EmployeeUUID INT64,
FullName STRING(MAX)
...
) PRIMARY KEY (CompanyUUID,EmployeeUUID)
如果你經(jīng)常需要查詢員工的全名,例如,你可以在雇員 UUID 上創(chuàng)建一個索引,存儲全名,以便快速查詢:
CREATE INDEX EmployeesById
ON Employees (EmployeeUUID)
STORING (FullName);
強制使用索引
Cloud Spanner 的查詢引擎只有在極少數(shù)情況下才會自動使用索引(當(dāng)查詢完全被索引覆蓋時),所以在 SQL SELECT 語法中使用 FORCE_INDEX 指令來確保 Cloud Spanner 從索引中查找值很重要。你可以在文檔中找到更多介紹)。
Select *
from Employees@{FORCE_INDEX=EmployeesById}
Where EmployeeUUID=xxx;
注意,使用 Cloud Spanner 讀取 API(Read API)時,你只能執(zhí)行完全覆蓋的查詢,即索引存儲所有請求列的查詢。要使用索引從原始表讀取列,必須使用 SQL 查詢。有關(guān)示例,請參閱入門文檔中的「使用二級索引 」部分。
繼續(xù)學(xué)習(xí) Cloud Spanner
當(dāng)你使用 Cloud Spanner 這樣的基于云構(gòu)建、橫向擴展的數(shù)據(jù)庫時,會發(fā)現(xiàn)與你多年來一直使用的 RDBMS 相比,概念差異很大。一旦你熟悉了鍵和索引的工作原理,你就可以開始利用 Cloud Spanner 的優(yōu)勢來實現(xiàn)更快的可擴展性。
在本系列的下一篇文章中,我們將探討如何處理以前通過觸發(fā)器和存儲過程,來實現(xiàn)的業(yè)務(wù)邏輯,而 Cloud Spanner 并不支持這兩種方式。