PolarDB-X 全局二級索引

簡介: 索引是數據庫的基礎組件,早在1970年代,SystemR 就已經通過增加索引來支持多維度查詢。單機數據庫中,索引主要按照用途和使用的數據結構分爲 BTree 索引、Hash 索引、全文索引、空間索引等。通常,每張表中包含一個主鍵索引(Primary Index),主鍵索引以外的索引,統稱爲二級索引(Secondary Index)。

背景

索引是數據庫的基礎組件,早在1970年代,SystemR 就已經通過增加索引來支持多維度查詢。單機數據庫中,索引主要按照用途和使用的數據結構分爲 BTree 索引、Hash 索引、全文索引、空間索引等。通常,每張表中包含一個主鍵索引(Primary Index),主鍵索引以外的索引,統稱爲二級索引(Secondary Index)。

採用存儲計算分離和 shared-nothing 架構的分佈式數據庫具備良好的水平擴展能力,通過數據分區和無狀態的計算節點,允許計算和存儲獨立擴縮容,大量分佈式數據庫都採用這種架構(Spanner, CockroachDB, YugabyteDB 等)。

a1.png

全局索引解決什麼問題?

shared-nothing 架構引入了 分區 的概念,數據需要按照固定的 分區鍵 進行切分,這導致包含分區鍵的查詢可以快速定位到一個具體分區,而其它查詢需要全分區掃描。這個情況類似單機數據庫中按照主鍵進行查詢可以快速定位到數據所在的page,而按照非主鍵查詢需要全表掃描。

與單機數據庫不同的是,全分區掃描對於分佈式數據庫,除了會增加慢查詢數量降低系統吞吐,還可能導致系統喪失線性擴展能力。參考下圖的例子

a2.png

擴容前:兩個存儲節點(Data Node, DN),兩個數據分區,假設單個 DN 能承載的物理 QPS 爲3,整體物理 QPS 爲6,每個查詢都是全分區掃描,邏輯 QPS: 6/2=3
擴容後:三個存儲節點,三個數據分區,整體物理 QPS 爲9,每個查詢都是全分區掃描,邏輯 QPS: 9/3=3。機器成本上升50%,查詢性能沒有任何提升!

單機數據庫使用二級索引來避免全表掃描,具體來說,二級索引選擇非主鍵列作爲 key,value 部分保存主鍵的值(也可能是到行記錄的引用,具體實現不影響解題思路)。使用二級索引的查詢過程變爲,首先根據二級索引的索引列定位到page,讀取主鍵的取值,然後返回主鍵索引查詢整行記錄(這一步稱爲回表)。本質上,二級索引通過冗餘一份數據的方式,避免了全表掃描,屬於系統優化的標準思路“空間換時間”

分佈式數據庫要消除全分區掃描,也可以採用類似的思路,冗餘一份索引數據,索引採用與主表不同的分區鍵。查詢時首先根據索引的分區鍵定位到一個分區,然後從分區中查到主表的分區鍵和主鍵,回表得到完整數據,整個只需要掃描固定數量的分區(比如對於點查,至多掃描兩個分區)。

這種與主表分區維度不同的索引,我們稱之爲全局二級索引(Global Secondary Index, GSI, 也經常簡稱爲全局索引),對應的與主表分區維度相同的索引,稱爲局部索引(Local Secondary Index,LSI)

爲什麼一定需要全局索引?

前面一直在說,全分區掃描會導致系統不可擴展,那麼如果用戶能夠嚴格保證所有 SQL 都包含分區鍵,是不是就不需要全局索引了?

是的,這種情況確實不需要,但現實情況的複雜性決定了這是小概率事件,更常見的場景是:

● 用戶表需要支持用戶按照手機號和用戶ID登錄,分區鍵選哪個?
● 電商系統,需要按照買家ID和賣家ID查詢訂單,訂單表分區鍵怎麼選?
● 現有業務代碼是外包公司寫的,大範圍修改SQL不現實,怎麼辦?

更多場景分析可以參考 TPCC與透明分佈式,結論:要想提供與單機數據庫相似的“透明分佈式”使用體驗,必須支持全局索引。

用戶想要怎樣的全局索引使用體驗?

單機數據庫中索引是非常常用的組件,用戶接受度很高,全局索引如果能夠做到與單機數據庫索引相似的使用體驗,就可以稱得上是“透明”的索引使用體驗。以下從用戶使用視角出發,列舉四個影響索引使用體驗的關鍵特性

a3.jpg

要滿足這四個特性並不容易,讀、寫、schema 變更流程都需要做相應的設計。相關問題大到分佈式事務,CBO 索引選擇,Asynchronous Online Schema Change 如何實現,小到包括 on update current_timestamp 屬性的列如何處理,affected rows 如何兼容 MySQL 行爲,都需要考慮,同時還需要保證高性能。

以下介紹 PolarDB-X 在實現兼容 MySQL 索引使用體驗的全局二級索引過程中,做出的技術探索。

全局二級索引實現

一致性

對於 OLTP 系統中的全局索引,首先需要保證數據與主表強一致,解決這個問題需要用到分佈式事務、邏輯多寫和 Asynchronous Online Schema Change (AOSC)。

數據寫入的一致性

數據寫入時,由於主表和 GSI 的數據可能位於不同分區,需要分佈式事務保證原子提交,同時 由於寫入存在併發,還需要處理寫寫衝突。對於沒有全局索引的表,可以將 DML 語句路由到數據所在的分區,由 DN 完成併發控制,但對於包含 GSI 的表,更新數據時需要首先讀取並鎖定要變更的數據,然後按照主鍵更新主表和索引,這種先讀後寫的方法稱爲邏輯多寫。

a4.jpg

先讀後寫聽上去並不難實現,只是 SELECT + UPDATE/DELETE 而已,但實際情況比想象的要複雜一些。首先,早期 DRDS 的 DML 實現完全依賴下推執行,缺少相應的邏輯計劃,MySQL 的 DML 語法大約有 13 種,每種都需要支持,並且對於能夠下推的場景,依然保留下推執行方案;其次,MySQL 很多細節行爲並沒有在官方文檔上介紹,需要根據代碼逐一適配,比如 類型轉化、affected_rows、隱式 default 值等。另外爲了支持全局唯一索引,還需要增加衝突檢測的流程,導致 INSERT 語句的執行方式又增加了四種。上圖展示了邏輯多寫的執行流程,詳細介紹可以參考源碼解讀

索引創建的數據一致性

保證數據一致性的第二個方面,是在索引創建過程當中保證數據一致。比如,下面左邊這幅圖,分佈式場景下,多個節點對元數據的感知可能存在時間差。參考圖中的情況,一個節點已知存在索引,所以它對索引進行了插入,同時寫入主表和索引表。另一個節點並不知道索引的存在,所以它只對主表上的內容進行刪除,沒有刪除索引表上的內容,這就導致索引表上多了一條數據。

a5.jpg

PolarDB-X 爲了解決這問題,參考 Google F1 的方案,通過引入多個相互兼容的階段,來保證元數據的過渡是平滑的,詳細實現參考這篇文章。同時由於 Schema Change 過程中,切換元數據版本的次數增加,我們也對單個 CN 上的元數據版本演進做了優化,使得 DDL 完全不會影響讀寫執行,具體參考這篇文章

引入以上技術之後,我們的整個 DDL 框架就可以對全局索引進行不阻塞的創建了。值的一提的是,MySQL 從 8.0 版本開始支持原子 DDL,這方面 PolarDB-X 也有自己的實現,詳見這篇文章

索引掃描的數據一致性

數據寫入過程中由於存在併發,需要處理寫寫衝突,同樣的,數據讀取過程中由於存在併發讀寫,還需要處理讀寫衝突。現代數據庫基本都通過 MVCC 來解決讀寫衝突,查詢開始前從發號器獲取一個版本號,通過版本號來判斷數據行的最新版本是否對當前事務可見,使得讀取到的數據滿足指定隔離級別。PolarDB-X 支持基於 TSO 的 MVCC 實現,能夠保證回表過程中索引表和主表讀到相同的快照,MVCC 實現參考這篇文章

索引選擇

索引選擇的核心目標是讓用戶在使用 GSI 的時候不需要手動指定索引,方案是基於 CBO 的自動索引選擇,實現上涉及優化器如何評估和選擇包含索引掃描 (特指二級索引上的索引掃描,常見名稱有 IndexScan,IndexSeek 等,以下統稱爲 IndexScan)的執行計劃。單機數據庫的做法是將 TableScan 替換爲 IndexScan,如果索引不能覆蓋所需的列,則再增加一步回表操作,對 IndexScan 的優化主要是列裁剪和謂詞下推,使用獨立的算法計算 IndexScan 和回表的代價。

a6.png

代價評估方面一個比較關鍵的問題是如何去評估回表的代價,GSI 本身也是一張邏輯表,回表操作相當於索引表和主表在主鍵上做 Join。因此我們做了工程上的優化,將索引回表的動作適配爲 Project 加 Join 的操作,由此可以把整個關於索引的代價評估適配到普通查詢計劃的代價評估當中。

a7.jpg

爲了能夠將包含 IndexScan 的計劃納入執行計劃枚舉流程,需要將索引掃描和回表算子適配到現有 CBO 框架。具體實現如上圖所示,通過 AccessPathRule 生成使用 GSI 的執行計劃,在後續迭代中通過比較代價選出最合適的計劃。關於 CBO 框架參考這篇文章。同時,由於分佈式數據庫中回表需要網絡 IO,比單機數據庫的回表代價更高,PolarDB-X 還支持將 Join/Limit 等操作提前到回表之前,與索引掃描一起下壓到 DN 上執行,達到減少回表的數據量降低網絡 IO 的目的,具體參考這篇文章

覆蓋索引

覆蓋索引是一種特殊的索引,允許用戶在索引中保存更多列的數據,目的是滿足更多查詢語句對引用列的需求,儘量避免回表。單機數據庫中覆蓋索引是一種常見的優化手段,比如 Sql Server 很早就支持通過覆蓋索引優化查詢性能

a7.jpg

對於分佈式數據庫,回表還可能影響系統的水平擴展能力。參考上圖的例子,訂單表按照 buyer_id 分區,當按照 seller_id 查詢時需要全分區掃描。創建一個 seller_id 上的 GSI 來優化,由於索引表默認僅包含分區鍵、主表分區鍵和主鍵,沒有 content 列,需要回表。隨着賣家銷售的訂單數量增加,回表操作涉及的分區越來越多,最終也會變成一個全分區掃描,通過增加索引避免全分區掃描的目標並沒有實現。爲了避免這種情況出現,PolarDB-X 支持創建“覆蓋索引”,通過 COVERING 語法在 GSI 中添加指定列,使得 GSI 更容易達到索引覆蓋的情況。

除了缺少列,缺少歷史版本也可能導致回表,比如 MySQL 沒有爲二級索引保存版本信息,僅在二級索引每個 page 頭部保存了執行最後一次寫入的事務id,導致如果需要查詢歷史版本必須回表。PolarDB-X 在寫入過程中爲 GSI 單獨記錄 undo-log,能夠讀取到索引的歷史版本,不會因爲查詢歷史版本而產生額外的回表操作,並且支持將閃回查詢(Flashback Query)直接下發到 GSI 上執行。

性能優化

由於寫入數據時必須使用分佈式事務和邏輯多寫,有額外的開銷,需要優化寫入性能,保證系統吞吐。具體來說,分佈式事務依賴兩階段提交保證原子性,相比單機事務增加了 prepare 階段和寫入 commit-point 的步驟,同時依賴 TSO 獲取提交時間戳,TSO 服務的吞吐量也可能成爲瓶頸。針對分佈式事務的優化,包括一階段提交優化、單機多分區優化、TSO Grouping 等內容,可以參考分佈式事務實現全局時間戳服務設計

邏輯多寫需要先讀取數據到 CN,原因有兩個,首先 PolarDB-X 兼容 MySQL 的悲觀事務行爲,寫入操作使用當前讀,對於 UPDATE、DELETE 等根據謂詞確定更新範圍的語句,需要先查詢並鎖定需要修改的數據,避免不同分支事務讀取到不一致的快照。其次對於 INSERT 語句,如果目標表上有唯一約束,也需要先讀取數據進行唯一約束衝突檢測。

單機數據庫中也存在類似的流程,比如 MySQL 執行 DML 時先由 server 層從 innodb 中查詢並鎖定需要修改的數據,然後調用 ha_innobase::write_row 寫入數據,MySQL 的唯一約束實現也要求 INSERT 前先做唯一約束檢查。區別在於 MySQL server 層和 innodb 層的交互發生在單臺機器內,只涉及內存和磁盤IO,代價較低,分佈式數據庫中 CN 和 DN 通過網絡機交互,代價更高。

a8.jpg

PolarDB-X 執行 DML 時,優先選擇下推執行,對於必須使用邏輯多寫的場景,針對“查詢並鎖定需要修改的數據”和“唯一約束衝突檢測”分別進行了工程優化

● 讀寫並行:思路很簡單,將“讀取-緩存-寫入”的串行執行過程轉變爲多個小批次的並行的“讀取“和”寫入”過程。要解決的一個關鍵問題是,MySQL 的事務與連接是綁定的,事務內如果創建多個讀連接,會出現數據可見性問題。爲了解決這個問題,我們引入了事務組的概念,使得多個連接可以共享相同的 ReadView,由此來解決事務內讀寫連接綁定的問題,使得不同批次的讀取和寫入可以並行執行。
● 唯一約束衝突檢測下推:主要解決數據導入場景下的性能問題,數據導入爲了做斷點續傳,通常會使用 INSERT IGNORE 這樣的語句,但實際上插入的數據幾乎都是沒有衝突的,於是每條數據都做衝突檢測就顯得很不划算。我們優化的方法是採用樂觀處理的思路,通過 RETURNING 語句 加補償的方式。使得數據導入場景下,INSERT IGNORE 的性能與 INSERT 相同。

DDL 兼容性

良好的 DDL 兼容性是“透明”全局索引必須要關注的部分。試想一下,如果每次修改列類型之前,都需要先刪除引用這一列的全局索引,類型變更完成後在重建索引,是多麼令人“頭大”的事情。PolarDB-X 全面兼容 MySQL DDL 語句,表、列、分區變更相關的語句都會自動維護 GSI。DDL 執行算法分爲 Instant DDL 和 Online DDL 兩種,Instant DDL 主要用於加列,Online DDL 基於 AOSC,針對不同 DDL 語句有細化設計,下面簡單介紹比較有代表性的 ADD COLUMN 和 CHANGE COLUMN 的實現

ADD COLUMN

PolarDB-X 支持全局聚簇索引(Clustered Secondary Index, CSI),特點是始終保持與主表相同的結構,保證對所有查詢都不需要回表。因此,主表加列時需要在 CSI 上也增加一列,一般情況下按照 AOSC 流程完成就可以保證加列過程中索引數據一致,但如果新增列包含了 ON UPDATE CURRENT_TIMESTAMP 屬性,則可能產生問題。比如下面的場景,物理表加列完成,但 CN 還不知道新增列的存在,於是由 DN 獨立填充主表和索引表中的值,導致數據不一致。爲了解決這個問題,我們會在所有 CN 都感知到元數據更新後,使用回填流程重新刷新一遍索引上新增列的值,保證索引與主表數據一致。

CHANGE COLUMN

變更列類型是 DDL 中最複雜的操作,對寫入有比較大的影響,比如 MySQL 8.0 在變更列類型過程中依然需要鎖表。PolarDB-X 支持 Online Modify Column(OMC),通過“加列-複製數據-修改元數據映射”的方式,結合 Instant Add Column,實現支持 GSI 的不鎖表列類型變更。

a9.jpg

上圖展示了在一張沒有 GSI 的表上執行 CHANGE COLUMN 的過程。分爲七個階段,首先增加一個對用戶不可見的 COL_B,在寫入過程中使用相同的值填充 COL_A 和 COL_B,然後對錶中已有的數據用 COL_A 的值回填到 COL_B,最後交換 COL_A 和 COL_B 的元數據映射,並刪除 COL_A,完成列類型變更。存在 GSI 的場景也採用相同的流程,只是在每個步驟中都對 GSI 做相同處理。

DDL 兼容性使用的底層技術與創建 GSI 相同(AOSC、數據回填、邏輯多寫、異步DDL引擎等),但實現上需要考慮每種 DDL 語句的語義,還需要考慮 MySQL 的細節行爲,比如變更列類型中通過 UPDATE 語句在新老列之間回填數據,但 MySQL ALTER TABLE 和 UPDATE 的類型轉換邏輯並不相同,爲此我們實現了專門的類型轉換邏輯在 UPDATE 中模擬 ALTER TABLE 的行爲。總的來說,DDL 兼容性看起來只是支持了一些語法,但裏面的工作量其實是很大的。

性能測試

全局索引對讀寫性能的影響,與具體業務場景有比較大關係,本質上是犧牲一部分寫入性能換取讀性能的大幅提升,下圖以 Sysbench 場景爲例,展示該場景下 GSI 對讀寫吞吐的影響。
a10.jpg

a10.jpg

總結

基於存儲計算分離和 shared-nothing 架構的分佈式數據庫,需要支持全局二級索引來消除全分區掃描,保證線性可擴展性。單機數據庫很早就引入了二級索引,使用體驗用戶接受度很高,良好的全局索引使用體驗應該向單機數據庫看齊,需要保證數據強一致,支持通過 DDL 語句創建,支持自動選擇索引,同時索引的存在不應當阻礙其他 DDL 語句執行。PolarDB-X 通過分佈式事務和邏輯多寫保證索引數據強一致;支持 Online Schema Change,索引創建過程可以與寫入並行;支持覆蓋索引,解決回錶帶來的全分區掃描問題;精細化處理了包含 GSI 表的 DDL 語句兼容性。爲用戶提供”透明“的索引使用體驗,降低分佈式數據庫的使用門檻。

作者:墨城
本文來源:PolarDB-X知乎號

原文鏈接:https://click.aliyun.com/m/1000360967/
本文爲阿里雲原創內容,未經允許不得轉載。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章