併發提升 20+ 倍、單節點數萬 QPS,Apache Doris 高併發特性解讀

語言: CN / TW / HK

隨着用户規模的極速擴張,越來越多用户將 Apache Doris 用於構建企業內部的統一分析平台,這一方面需要 Apache Doris 去承擔更大業務規模的處理和分析——既包含了更大規模的數據量、也包含了更高的併發承載,而另一方面,也意味着需要應對企業更加多樣化的數據分析訴求,從過去的統計報表、即席查詢、交互式分析等典型 OLAP 場景,拓展到推薦、風控、標籤畫像以及 IoT 等更多業務場景中,而數據服務(Data Serving)就是其中具有代表性的一類需求。Data Serving 通常指的是向用户或企業客户提供數據訪問服務,用户使用較為頻繁的查詢模式一般是按照 Key 查詢一行或多行數據,例如:

  • 訂單詳情查詢
  • 商品詳情查詢
  • 物流狀態查詢
  • 交易詳情查詢
  • 用户信息查詢
  • 用户畫像屬性查詢
  • ...

與面向大規模數據掃描與計算的 Adhoc 不同,Data Serving 在實際業務中通常呈現為高併發的點查詢—— 查詢返回的數據量較少、通常只需返回一行或者少量行數據,但對於查詢耗時極為敏感、期望在毫秒內返回查詢結果,並且面臨着超高併發的挑戰。

在過去面對此類業務需求時,通常採取不同的系統組件分別承載對應的查詢訪問。OLAP 數據庫一般是基於列式存儲引擎構建,且是針對大數據場景設計的查詢框架,通常以數據吞吐量來衡量系統能力,因此在 Data Serving 高併發點查場景的表現往往不及用户預期。基於此,用户一般引入 Apache HBase 等 KV 系統來應對點查詢、Redis 作為緩存層來分擔高併發帶來的系統壓力。而這樣的架構往往比較複雜,存在宂餘存儲、維護成本高的問題。融合統一的分析範式為 Apache Doris 能承載的工作負載帶來了挑戰,也讓我們更加系統化地去思考如何更好地滿足用户在此類場景的業務需求。基於以上思考,在即將發佈的 2.0 版本中,我們在原有功能基礎上引入了一系列面向點查詢的優化手段,單節點可達數萬 QPS 的超高併發,極大拓寬了適用場景的能力邊界。

#  如何應對高併發查詢?

一直以來高併發就是 Apache Doris 的優勢之一。對於高併發查詢,其核心在於如何平衡有限的系統資源消耗與併發執行帶來的高負載。換而言之,需要最大化降低單個 SQL 執行時的 CPU、內存和 IO 開銷,其關鍵在於減少底層數據的 Scan 以及隨後的數據計算,其主要優化方式有如下幾種:

分區分桶裁剪

Apache Doris 採用兩級分區,第一級是 Partition,通常可以將時間作為分區鍵。第二級為 Bucket,通過 Hash 將數據打散至各個節點中,以此提升讀取並行度並進一步提高讀取吞吐。通過合理地劃分區分桶,可以提高查詢性能,以下列查詢語句為例:

select * from user_table where id = 5122 and create_date = '2022-01-01'

用户以create_time作為分區鍵、ID 作為分桶鍵,並設置了 10 個 Bucket, 經過分區分桶裁剪後可快速過濾非必要的分區數據,最終只需讀取極少數據,比如 1 個分區的 1 個 Bucket 即可快速定位到查詢結果,最大限度減少了數據的掃描量、降低了單個查詢的延時。

索引

除了分區分桶裁剪, Doris 還提供了豐富的索引結構來加速數據的讀取和過濾。索引的類型大體可以分為智能索引和二級索引兩種,其中智能索引是在 Doris 數據寫入時自動生成的,無需用户干預。智能索引包括前綴索引和 ZoneMap 索引兩類:

  • 前綴稀疏索引(Sorted Index) 是建立在排序結構上的一種索引。Doris 存儲在文件中的數據,是按照排序列有序存儲的,Doris 會在排序數據上每 1024 行創建一個稀疏索引項。索引的 Key 即當前這 1024 行中第一行的前綴排序列的值,當用户的查詢條件包含這些排序列時,可以通過前綴稀疏索引快速定位到起始行。
  • ZoneMap 索引是建立在 Segment 和 Page 級別的索引。對於 Page 中的每一列,都會記錄在這個 Page 中的最大值和最小值,同樣,在 Segment 級別也會對每一列的最大值和最小值進行記錄。這樣當進行等值或範圍查詢時,可以通過 MinMax 索引快速過濾掉不需要讀取的行。

二級索引是需要用手動創建的索引,包括 Bloom Filter 索引、Bitmap 索引,以及 2.0 版本新增的 Inverted 倒排索引和 NGram Bloom Filter 索引,在此不細述,可從官網文檔先行了解,後續將有系列文章進行解讀。

官網文檔:

我們以下列查詢語句為例:

select * from user_table where id > 10 and id < 1024

假設按照 ID 作為建表時指定的 Key, 那麼在 Memtable 以及磁盤上按照 ID 有序的方式進行組織,查詢時如果過濾條件包含前綴字段時,則可以使用前綴索引快速過濾。Key 查詢條件在存儲層會被劃分為多個 Range,按照前綴索引做二分查找獲取到對應的行號範圍,由於前綴索引是稀疏的,所以只能大致定位出行的範圍。隨後過一遍 ZoneMap、Bloom Filter、Bitmap 等索引,進一步縮小需要 Scan 的行數。通過索引,大大減少了需要掃描的行數,減少 CPU 和 IO 的壓力,整體大幅提升了系統的併發能力。

物化視圖

物化視圖是一種典型的空間換時間的思路,其本質是根據預定義的 SQL 分析語句執⾏預計算,並將計算結果持久化到另一張對用户透明但有實際存儲的表中。在需要同時查詢聚合數據和明細數據以及匹配不同前綴索引的場景,命中物化視圖時可以獲得更快的查詢相應,同時也避免了大量的現場計算,因此可以提高性能表現並降低資源消耗

// 對於聚合操作, 直接讀物化視圖預聚合的列
create materialized view store_amt as select store_id, sum(sale_amt) from sales_records group by store_id;
SELECT store_id, sum(sale_amt) FROM sales_records GROUP BY store_id;

// 對於查詢, k3滿足物化視圖前綴列條件, 走物化視圖加速查詢
CREATE MATERIALIZED VIEW mv_1 as SELECT k3, k2, k1 FROM tableA ORDER BY k3;
select k1, k2, k3 from table A where k3=3;

Runtime Filter

除了前文提到的用索引來加速過濾查詢的數據, Doris 中還額外加入了動態過濾機制,即 Runtime Filter。在多表關聯查詢時,我們通常將右表稱為 BuildTable、左表稱為 ProbeTable,左表的數據量會大於右表的數據。在實現上,會首先讀取右表的數據,在內存中構建一個 HashTable(Build)。之後開始讀取左表的每一行數據,並在 HashTable 中進行連接匹配,來返回符合連接條件的數據(Probe)。而 Runtime Filter 是在右表構建 HashTable 的同時,為連接列生成一個過濾結構,可以是 Min/Max、IN 等過濾條件。之後把這個過濾列結構下推給左表。這樣一來,左表就可以利用這個過濾結構,對數據進行過濾,從而減少 Probe 節點需要傳輸和比對的數據量。在大多數 Join 場景中,Runtime Filter 可以實現節點的自動穿透,將 Filter 穿透下推到最底層的掃描節點或者分佈式 Shuffle Join 中。大多數的關聯查詢 Runtime Filter 都可以起到大幅減少數據讀取的效果,從而加速整個查詢的速度。

OPN 優化技術

在數據庫中查詢最大或最小几條數據的應用場景非常廣泛,比如查詢滿足某種條件的時間最近 100 條數據、查詢價格最高或者最低的幾個商品等,此類查詢的性能對於實時分析非常重要。在 Doris 中引入了 TOPN 優化來解決大數據場景下較高的 IO、CPU、內存資源消耗:

  • 首先從 Scanner 層讀取排序字段和查詢字段,利用堆排序保留 TOPN 條數據,實時更新當前已知的最大或最小的數據範圍, 並動態下推至 Scanner

  • Scanner 層根據範圍條件,利用索引等加速跳過文件和數據塊,大幅減少讀取的數據量。

  • 在寬表中用户通常需要查詢字段數較多, 在 TOPN 場景實際有效的數據僅 N 條, 通過將讀取拆分成兩階段, 第一階段根據少量的排序列、條件列來定位行號並排序,第二階段根據排序後並取 TOPN 的結果得到行號反向查詢數據,這樣可以大大降低 Scan 的開銷

通過以上一系列優化手段,可以將不必要的數據剪枝掉,減少讀取、排序的數據量,顯著降低系統 IO、CPU 以及內存資源消耗。此外,還可以利用包括 SQL Cache、Partition Cache 在內的緩存機制以及 Join 優化手段來進一步提升併發,由於篇幅原因不在此詳述。

#  Apache Doris 2.0 新特性揭祕

通過上一段中所介紹的內容,Apache Doris 實現了單節點上千 QPS 的併發支持。但在一些超高併發要求(例如數萬 QPS)的 Data Serving 場景中,仍然存在瓶頸:

  • 列式存儲引擎對於行級數據的讀取不友好,寬表模型上列存格式將大大放大隨機讀取 IO;
  • OLAP 數據庫的執行引擎和查詢優化器對於某些簡單的查詢(如點查詢)來説太重,需要在查詢規劃中規劃短路徑來處理此類查詢;
  • SQL 請求的接入以及查詢計劃的解析與生成由 FE 模塊負責,使用的是 Java 語言,在高併發場景下解析和生成大量的查詢執行計劃會導致高 CPU 開銷;
  • ……

帶着以上問題,Apache Doris 在分別從降低 SQL 內存 IO 開銷、提升點查執行效率以及降低 SQL 解析開銷這三個設計點出發,進行一系列優化。

行式存儲格式(Row Store Format)

與列式存儲格式不同,行式存儲格式在數據服務場景會更加友好,數據按行存儲、應對單次檢索整行數據時效率更高,可以極大減少磁盤訪問次數。因此在 Apache Doris 2.0 版本中,我們引入了行式存儲格式,將行存編碼後存在單獨的一列中,通過額外的空間來存儲。用户可以在建表語句的 Property 中指定如下屬性來開啟行存:

"store_row_column" = "true"

我們選擇以 JSONB 作為行存的編碼格式,主要出於以下考慮:

  • Schema 變更靈活:隨着數據的變化、變更,表的 Schema 也可能發生相應變化。行存儲格式提供靈活性以處理這些變化是很重要的,例如用户刪減字段、修改字段類型,數據變更需要及時同步到行存中。通過使用 JSONB 作為編碼方式,將列作為 JSONB 的字段進行編碼, 可以非常方便地進行字段擴展以及更改屬性。
  • 性能更高:在行存儲格式中訪問行可以比在列存儲格式中訪問行更快,因為數據存儲在單個行中。這可以在高併發場景下顯著減少磁盤訪問開銷。此外,通過將每個列 ID 映射到 JSONB其對應的值,可以實現對個別列的快速訪問。
  • 存儲空間:將 JSONB 作為行存儲格式的編解碼器也可以幫助減少磁盤存儲成本。緊湊的二進制格式可以減少存儲在磁盤上的數據總大小,使其更具成本效益。

使用 JSONB 編解碼行存儲格式,可以幫助解決高併發場景下面臨的性能和存儲問題。行存在存儲引擎中會作為一個隱藏列(DORIS_ROW_STORE_COL)來進行存儲,在 Memtable Flush 時,將各個列按照 JSONB 進行編碼並緩存到這個隱藏列裏。在數據讀取時, 通過該隱藏列的 Column ID 來定位該列, 通過其行號定位到某一具體的行,並反序列化各列。

相關PR:http://github.com/apache/doris/pull/15491

點查詢短路徑優化(Short-Circuit)

通常情況下,一條 SQL 語句的執行需要經過三個步驟:首先通過 SQL Parser 解析語句,生成抽象語法樹(AST),隨後通過 Query Optimizer 生成可執行計劃(Plan),最終通過執行該計劃得到計算結果。對於大數據量下的複雜查詢,經由查詢優化器生成的執行計劃無疑具有更高效的執行效果,但對於低延時和高併發要求的點查詢,則不適宜走整個查詢優化器的優化流程,會帶來不必要的額外開銷。為了解決這個問題,我們實現了點查詢的短路徑優化,繞過查詢優化器以及 PlanFragment 來簡化 SQL 執行流程,直接使用快速高效的讀路徑來檢索所需的數據。

圖片

當查詢被 FE 接收後,它將由規劃器生成適當的 Short-Circuit Plan 作為點查詢的物理計劃。該 Plan 非常輕量級,不需要任何等效變換、邏輯優化或物理優化,僅對 AST 樹進行一些基本分析、構建相應的固定計劃並減少優化器的開銷。對於簡單的主鍵點查詢,如select * from tbl where pk1 = 123 and pk2 = 456,因為其只涉及單個 Tablet,因此可以使用輕量的 RPC 接口來直接與 StorageEngine 進行交互,以此避免生成複雜的Fragment Plan 並消除了在 MPP 查詢框架下執行調度的性能開銷。RPC 接口的詳細信息如下:

message PTabletKeyLookupRequest {
    required int64 tablet_id = 1;
    repeated KeyTuple key_tuples = 2;
    optional Descriptor desc_tbl = 4;
    optional ExprList  output_expr = 5;
}

message PTabletKeyLookupResponse {
    required PStatus status = 1;
    optional bytes row_batch = 5;
    optional bool empty_batch = 6;
}
rpc tablet_fetch_data(PTabletKeyLookupRequest) returns (PTabletKeyLookupResponse);

以上 tablet_id 是從主鍵條件列計算得出的,key_tuples是主鍵的字符串格式,在上面的示例中,key_tuples類似於 ['123', '456'],在 BE 收到請求後key_tuples將被編碼為主鍵存儲格式,並根據主鍵索引來識別 Key 在 Segment File 中的行號,並查看對應的行是否在delete bitmap中,如果存在則返回其行號,否則返回NotFound。然後使用該行號直對__DORIS_ROW_STORE_COL__列進行點查詢,因此我們只需在該列中定位一行並獲取 JSONB 格式的原始值,並對其進行反序列化作為後續輸出函數計算的值。

相關PR:http://github.com/apache/doris/pull/15491

預處理語句優化(PreparedStatement)

高併發查詢中的 CPU 開銷可以部分歸因於 FE 層分析和解析 SQL 的 CPU 計算,為了解決這個問題,我們在 FE 端提供了與 MySQL 協議完全兼容的預處理語句(Prepared Statement)。當 CPU 成為主鍵點查的性能瓶頸時,Prepared Statement 可以有效發揮作用,實現 4 倍以上的性能提升

圖片

Prepared Statement 的工作原理是通過在 Session 內存 HashMap 中緩存預先計算好的 SQL 和表達式,在後續查詢時直接複用緩存對象即可。Prepared Statement 使用 MySQL 二進制協議作為傳輸協議。該協議在文件mysql_row_buffer.[h|cpp]中實現,符合標準 MySQL 二進制編碼, 通過該協議客户端例如 JDBC Client, 第一階段發送PREPAREMySQL Command 將預編譯語句發送給 FE 並由 FE 解析、Analyze 該語句並緩存到上圖的 HashMap 中,接着客户端通過EXECUTEMySQL Command 將佔位符替換並編碼成二進制的格式發送給 FE, 此時 FE 按照 MySQL 協議反序列化後得到佔位符中的值,生成對應的查詢條件。

圖片

除了在 FE 緩存 Statement,我們還需要在 BE 中緩存被重複使用的結構,包括預先分配的計算 Block,查詢描述符和輸出表達式,由於這些結構在序列化和反序列化時會造成 CPU 熱點, 所以需要將這些結構緩存下來。對於每個查詢的 PreparedStatement,都會附帶一個名為 CacheID 的 UUID。當 BE 執行點查詢時,根據相關的 CacheID 找到對應的複用類, 並在 BE 中表達式計算、執行時重複使用上述結構。下面是在 JDBC 中使用 PreparedStatement 的示例:1. 設置 JDBC URL 並在 Server 端開啟 PreparedStatement

url = jdbc:mysql://127.0.0.1:9030/ycsb?useServerPrepStmts=true
  1. 使用 Prepared Statement
// use `?` for placement holders, readStatement should be reused
PreparedStatement readStatement = conn.prepareStatement("select * from tbl_point_query where key = ?");
...
readStatement.setInt(1234);
ResultSet resultSet = readStatement.executeQuery();
...
readStatement.setInt(1235);
resultSet = readStatement.executeQuery();
...

相關 PR:http://github.com/apache/doris/pull/15491

行存緩存

Doris 中有針對 Page 級別的 Cache,每個 Page 中存的是某一列的數據,所以 Page Cache 是針對列的緩存。

圖片

對於前面提到的行存,一行裏包括了多列數據,緩存可能被大查詢給刷掉,為了增加行緩存命中率,就需要單獨引入行存緩存(Row Cache)。行存 Cache 複用了 Doris 中的 LRU Cache 機制, 啟動時會初始化一個內存閾值, 當超過內存閾值後會淘汰掉陳舊的緩存行。對於一條主鍵查詢語句,在存儲層上命中行緩存和不命中行緩存可能有數十倍的性能差距(磁盤 IO 與內存的訪問差距),因此行緩存的引入可以極大提升點查詢的性能,特別是緩存命中高的場景下。

圖片

開啟行存緩存可以在 BE 中設置以下配置項來開啟:

disable_storage_row_cache=false //是否開啟行緩存, 默認不開啟
row_cache_mem_limit=20% // 指定row cache佔用內存的百分比, 默認20%內存

相關 PR:http://github.com/apache/doris/pull/15491

#  Benchmark

基於以上一系列優化,幫助 Apache Doris 在 Data Serving 場景的性能得到進一步提升。我們基於 Yahoo! Cloud Serving Benchmark (YCSB)標準性能測試工具進行了基準測試,其中環境配置與數據規模如下:

  • 機器環境:單台 16 Core 64G 內存 4*1T 硬盤的雲服務器
  • 集羣規模:1 FE + 3 BE
  • 數據規模:一共 1 億條數據,平均每行在 1K 左右,測試前進行了預熱。
  • 對應測試表結構與查詢語句如下:
// 建表語句如下:

CREATE TABLE `usertable` (
  `YCSB_KEY` varchar(255) NULL,
  `FIELD0` text NULL,
  `FIELD1` text NULL,
  `FIELD2` text NULL,
  `FIELD3` text NULL,
  `FIELD4` text NULL,
  `FIELD5` text NULL,
  `FIELD6` text NULL,
  `FIELD7` text NULL,
  `FIELD8` text NULL,
  `FIELD9` text NULL
) ENGINE=OLAP
UNIQUE KEY(`YCSB_KEY`)
COMMENT 'OLAP'
DISTRIBUTED BY HASH(`YCSB_KEY`) BUCKETS 16
PROPERTIES (
"replication_allocation" = "tag.location.default: 1",
"in_memory" = "false",
"persistent" = "false",
"storage_format" = "V2",
"enable_unique_key_merge_on_write" = "true",
"light_schema_change" = "true",
"store_row_column" = "true",
"disable_auto_compaction" = "false"
);

// 查詢語句如下:

SELECT * from usertable WHERE YCSB_KEY = ?

開啟優化(即同時開啟行存、點查短路徑以及 PreparedStatement)與未開啟的測試結果如下:

圖片

開啟以上優化項後平均查詢耗時降低了 96% ,99 分位的查詢耗時僅之前的 1/28,QPS 併發從 1400 增至 3w、提升了超過 20 倍,整體性能表現和併發承載實現數據量級的飛躍!

#  最佳實踐

需要注意的是,在當前階段實現的點查詢優化均是在 Unique Key 主鍵模型進行的,同時需要開啟 Merge-on-Write 以及 Light Schema Change 後使用,以下是點查詢場景的建表語句示例:

CREATE TABLE `usertable` (
  `USER_KEY` BIGINT NULL,
  `FIELD0` text NULL,
  `FIELD1` text NULL,
  `FIELD2` text NULL,
  `FIELD3` text NULL
) ENGINE=OLAP
UNIQUE KEY(`USER_KEY`)
COMMENT 'OLAP'
DISTRIBUTED BY HASH(`USER_KEY`) BUCKETS 16
PROPERTIES (
"enable_unique_key_merge_on_write" = "true",
"light_schema_change" = "true",
"store_row_column" = "true",
);

注意:

  • 開啟light_schema_change來支持 JSONB 行存編碼 ColumnID

  • 開啟store_row_column來存儲行存格式

完成建表操作後,類似如下基於主鍵的點查 SQL 可通過行式存儲格式和短路徑執行得到性能的大幅提升:

select * from usertable where USER_KEY = xxx;

與此同時,可以通過 JDBC 中的 Prepared Statement 來進一步提升點查詢性能。如果有充足的內存, 還可以在 BE 配置文件中開啟行存 Cache,上文中均已給出使用示例,在此不再贅述。

#  總結

通過引入行式存儲格式、點查詢短路徑優化、預處理語句以及行存緩存,Apache Doris 實現了單節點上萬 QPS 的超高併發,實現了數十倍的性能飛躍。而隨着集羣規模的橫向拓展、機器配置的提升,Apache Doris 還可以利用硬件資源實現計算加速,自身的 MPP 架構也具備橫向線性拓展的能力。因此 Apache Doris 真正具備了在 一套架構下同時 滿足高吞吐的 OLAP 分析和高併發的 Data Serving 在線服務的能力,大大簡化了混合工作負載下的技術架構,為用户提供了多場景下的統一分析體驗

以上功能的實現得益於 Apache Doris 社區開發者共同努力以及 SelectDB 工程師的的持續貢獻,當前已處於緊鑼密鼓的發版流程中,在不久後的 2.0 版本就會發布出來。如果對於以上功能有強烈需求,歡迎填寫問卷提交申請,或者與 SelectDB 技術團隊直接聯繫,提前獲得 2.0-alpha 版本的體驗機會,也歡迎隨時向我們反饋使用意見。

作者介紹:

李航宇,Apache Doris Contributor,SelectDB 半結構化研發工程師。

「其他文章」