作者:魏祚,趙立偉
長期以來,Apache Doris在小米集團都有着廣泛的應用。随着小米網際網路業務的快速發展,使用者對Apache Doris的查詢性能提出了更高的要求,Doris 向量化版本在小米内部上線已經迫在眉睫。在 SelectDB 公司和 Apache Doris 社群的鼎力支援下,我們在小米 A/B實驗場景對 Doris 向量化版本進行了一系列的調優操作,使得查詢性能和穩定性有了顯著地提升。
背景
2019 年 9 月,為了滿足小米網際網路增長分析業務中近實時、多元分析查詢的需求,小米集團首次引入了Apache Doris。在過去的三年時間裡,Apache Doris 已經在小米内部得到了廣泛的應用,支援了集團資料看闆、廣告投放、廣告BI、新零售、使用者行為分析、A/B實驗平台、天星數科、小米有品、使用者畫像、小米造車等小米内部數十個業務,并且在小米内部形成了一套以 Apache Doris 為核心的資料生态。小米集團作為 Apache Doris 最早期的使用者之一,一直深度參與社群建設,參與 Apache Doris 的穩定性打磨。
為了保證線上服務的穩定性,小米内部基于 Apache Doris 社群的 0.13 版本進行疊代,為小米的業務提供穩定的報表分析和 BI看闆服務,經過業務的長時間打磨,内部 Doris 0.13 版本已經非常穩定。但是,随着小米網際網路業務的發展,使用者對 Doris 的查詢性能提出了更高的要求,Doris 0.13 版本在某些場景下逐漸難以滿足業務需求了。與此同時,Apache Doris 社群在快速發展,社群釋出的 1.1 版本已經在計算層和存儲層全面支援了向量化,查詢性能相比非向量化版本有了明顯地提升,基于此,小米内部的 Apache Doris 叢集進行向量化版本更新勢在必行。
場景介紹
小米的 A/B實驗平台對 Doris 查詢性能的提升有着迫切的需求,是以我們選擇優先在小米的 A/B實驗平台上線 Apache Doris 向量化版本,也就是 1.1.2 版本。
小米的A/B實驗平台是一款通過 A/B測試的方式,借助實驗分組、流量拆分與科學評估等手段來輔助完成科學的業務決策,最終實作業務增長的一款營運工具産品。在實際業務中,為了驗證一個新政策的效果,通常需要準備原政策A 和新政策B 兩種方案。 随後在總體使用者中取出一小部分,将這部分使用者完全随機地分在兩個組中,使兩組使用者在統計角度無差别。将原政策A和新政策B分别展示給不同的使用者組,一段時間後,結合統計方法分析資料,得到兩種政策生效後名額的變化結果,并以此來判斷新政策B 是否符合預期。
圖1-小米的A/B實驗簡介
小米的A/B實驗平台有幾類典型的查詢應用:使用者去重、名額求和、實驗協方差計算等,查詢類型會涉及較多的 Count(distinct)、Bitmap計算、Like語句等。
上線前驗證
我們基于 Doris 1.1.2 版本搭建了一個和小米線上 Doris 0.13 版本在機器配置和機器規模上完全相同的測試叢集,用于向量化版本上線前的驗證。驗證測試分為兩個方面:單 SQL 串行查詢測試和批量 SQL 并發查詢測試。在這兩種測試中,我們在保證兩個叢集資料完全相同的條件下,分别在 Doris 1.1.2 測試叢集和小米線上 Doris 0.13 叢集執行相同的查詢 SQL 來做性能對比。我們的目标是,Doris 1.1.2 版本在小米線上 Doris 0.13 版本的基礎上有 1 倍的查詢性能提升。
兩個叢集配置完全相同,具體配置資訊如下:
叢集規模:3 FE + 89 BE
BE節點CPU: Intel(R) Xeon(R) Silver 4216 CPU @ 2.10GHz 16核 32線程 × 2
BE節點記憶體:256GB
BE節點磁盤:7.3TB × 12 HDD
1. 單SQL串行查詢測試
在該測試場景中,我們選取了小米A/B 實驗場景中 7 個典型的查詢 Case,針對每一個查詢 Case,我們将掃描的資料時間範圍分别限制為 1 天、7 天和 20 天進行查詢測試,其中單日分區資料量級大約為 31 億(資料量大約 2 TB),測試結果如圖所示:
圖2-單日分區查詢耗時
圖3-7日分區查詢耗時
圖4-20日分區查詢耗時
根據以上小米A/B 實驗場景下的單SQL串行查詢測試結果所示,Doris 1.1.2 版本相比小米線上Doris 0.13版本至少有 3~5 倍的性能提升,效果顯著,提升效果遠高于預期。
2. 批量 SQL 并發查詢測試
在并發測試中,我們将小米A/B 實驗場景的查詢 SQL 按照正常的業務并發分别送出到 Doris 1.1.2 測試叢集和小米線上 Doris 0.13 叢集,對比觀察兩個叢集的狀态和查詢延遲。測試結果為,在完全相同的機器規模、機器配置和查詢場景下,Doris 1.1.2 版本的查詢延遲相比線上 Doris 0.13 版本整體上升了 1 倍,查詢性能下降非常明顯,另外,Doris 1.1.2 版本穩定性方面也存在比較嚴重的問題,查詢過程中會有大量的查詢報錯。Doris 1.1.2 版本在小米A/B 實驗場景并發查詢測試的結果與我們的預期差别較大。并發查詢測試過程中,我們遇到了幾個比較嚴重的問題:
CPU使用率上不去
查詢下發到 Doris 1.1.2 版本所在的叢集,CPU 使用率最多隻能打到 50% 左右,但是完全相同的一批查詢下發到線上 Doris 0.13 版本的叢集,CPU使用率可以打到接近 100%。是以推測 Doris 1.1.2 版本在小米 A/B 實驗場景中将機器的 CPU 利用不起來造成了查詢性能大幅度降低。
圖5-Doris 1.1.2版本和Doris 0.13版本CPU使用率對比
查詢持續報錯
使用者并發送出查詢的時候會出現如下報錯,後續的查詢任務均無法執行,叢集完全處于不可用的狀态,隻有重新開機 BE 節點才能恢複。
Apache RpcException, msg: timeout when waiting for send fragments RPC. Wait(sec): 5, host: 10.142.86.26 |
使用者送出查詢的時候也會頻繁出現如下報錯:
Bash detailMessage = failed to initialize storage reader. tablet=440712.1030396814.29476aaa20a4795e-b4dbf9ac52ee56be, res=-214, backend=10.118.49.24 |
Like 語句查詢較慢
在小米 A/B實驗場景有較多的使用 Like 語句進行字元串模糊比對的查詢,在并發測試過程中,該類查詢普遍性能較低。
記憶體拷貝耗時較長
并發查詢測試過程中,SQL 整體執行較慢,通過抓取查詢過程中的 CPU 火焰圖,發現讀取字元串類型資料的時候記憶體拷貝會占用較多時間。
圖6-CPU火焰圖
調優實踐
為了解決 Doris 1.1.2 版本在小米 A/B實驗場景并發測試過程中暴露出的性能和穩定性問題,推動 Doris 向量化版本盡快在小米 A/B實驗平台上線,我們和 SelectDB 公司以及 Apache Doris 社群一起對 Doris 1.1.2 版本進行了一系列的調優工作。
1. 提升 CPU 使用率
針對并發查詢時 CPU 使用率上不去的問題,我們截取了查詢過程中BE程序的函數調用棧,通過分析發現,有較多的記憶體配置設定和釋放操作在等鎖,這可能會造成 CPU 使用率上不去。
函數調用棧
Rust #0 sys_futex (v3=0, a2=0x0, t=0x7f786c9e7a00, v=<optimized out>, o=128, a=0x560451827c48 <tcmalloc::Static::pageheap_lock_>) at /root/doris/doris/be/src/gutil/linux_syscall_support.h:2419 #1 SpinLockDelay (loop=1822369984, value=2, w=0x560451827c48 <tcmalloc::Static::pageheap_lock_>) at /root/doris/doris/be/src/gutil/spinlock_linux-inl.h:80 #2 base::internal::SpinLockDelay (w=w@entry=0x560451827c48 <tcmalloc::Static::pageheap_lock_>, value=2, loop=loop@entry=20) at /root/doris/doris/be/src/gutil/spinlock_linux-inl.h:68 #3 0x000056044cfd825d in SpinLock::SlowLock (this=0x560451827c48 <tcmalloc::Static::pageheap_lock_>) at src/base/spinlock.cc:118 #4 0x000056044f013a25 in Lock (this=<optimized out>) at src/base/spinlock.h:69 #5 SpinLockHolder (l=<optimized out>, this=0x7f786c9e7a90) at src/base/spinlock.h:124 #6 (anonymous namespace)::do_malloc_pages(tcmalloc::ThreadCache*, unsigned long) () at src/tcmalloc.cc:1360 ... |
Ada #0 sys_futex (v3=0, a2=0x0, t=0x7f7494858b20, v=<optimized out>, o=128, a=0x560451827c48 <tcmalloc::Static::pageheap_lock_>) at /root/doris/doris/be/src/gutil/linux_syscall_support.h:2419 #1 SpinLockDelay (loop=-1803179840, value=2, w=0x560451827c48 <tcmalloc::Static::pageheap_lock_>) at /root/doris/doris/be/src/gutil/spinlock_linux-inl.h:80 #2 base::internal::SpinLockDelay (w=w@entry=0x560451827c48 <tcmalloc::Static::pageheap_lock_>, value=2, loop=loop@entry=2) at /root/doris/doris/be/src/gutil/spinlock_linux-inl.h:68 #3 0x000056044cfd825d in SpinLock::SlowLock (this=0x560451827c48 <tcmalloc::Static::pageheap_lock_>) at src/base/spinlock.cc:118 #4 0x000056044f01480d in Lock (this=<optimized out>) at src/base/spinlock.h:69 #5 SpinLockHolder (l=<optimized out>, this=0x7f7494858bb0) at src/base/spinlock.h:124 #6 (anonymous namespace)::do_free_pages(tcmalloc::Span*, void*) [clone .constprop.0] () at src/tcmalloc.cc:1435 ... |
Doris 記憶體管理機制
Doris 中使用 TCMalloc 進行記憶體管理。根據所配置設定和釋放記憶體的大小,TCMalloc 将記憶體配置設定政策分為小記憶體管理和大記憶體管理兩類。
圖7-TCMalloc記憶體管理機制
(1)小記憶體管理
TCMalloc 使用了 ThreadCache、CentralCache 和 PageHeap 三層緩存來管理小記憶體的配置設定和釋放。
對于每個線程,TCMalloc 都為其單獨維護了一個 ThreadCache,每個 ThreadCache 中包含了多個單獨的 FreeList,每個 FreeList 中緩存了 N 個固定大小的可供配置設定的記憶體單元。進行小記憶體配置設定時,會直接從 ThreadCache 中進行記憶體配置設定,相應地,小記憶體的回收也是将空閑記憶體重新放回 ThreadCache 中對應的 FreeList 中。由于每個線程都有自己獨立的 ThreadCache,是以從 ThreadCache 中配置設定或回收記憶體是不需要加鎖的,可以提升記憶體管理效率。
記憶體配置設定時,如果 ThreadCache 中對應的 FreeList 為空,則需要從 CertralCache 中擷取記憶體來補充自身的 FreeList。CentralCache 中維護了多個 CentralFreeList 連結清單來緩存不同大小的空閑記憶體,供各線程的 ThreadCache 取用。由于 CentralCache 是所有線程共用的,是以 ThreadCache 從 CentralCache 中取用或放回記憶體時是需要加鎖的。為了減小鎖操作的開銷,ThreadCache 一般從 CentralCache 中一次性申請或放回多個空閑記憶體單元。
當 CentralCache 中對應的 CentralFreeList 為空時,CentralCache 會向 PageHeap 申請一塊記憶體,并将其拆分成一系列小的記憶體單元,添加到對應的 CentralFreeList 中。PageHeap 用來處理向作業系統申請或釋放記憶體相關的操作,并提供了一層緩存。PageHeap 中的緩存部分會以 Page 為機關、并将不同數量的 Page 組合成不同大小的 Span,分别存儲在不同的 SpanList 中,過大的 Span 會存儲在一個 SpanSet 中。CentralCache 從 PageHeap 中擷取的記憶體可能來自 PageHeap 的緩存,也可能是來自 PageHeap 向系統申請的新記憶體。
(2)大記憶體管理
大記憶體的配置設定和釋放直接通過 PageHeap 來實作,配置設定的記憶體可能來自 PageHeap 的緩存,也可能來自 PageHeap 向系統申請的新記憶體。PageHeap 向系統申請或釋放記憶體時需要加鎖。
TCMalloc 中的 aggressive_memory_decommit 參數用來配置是否會積極釋放記憶體給作業系統。當設定為 true 時,PageHeap 會積極地将空閑記憶體釋放給作業系統,節約系統記憶體;當該配置設定為 false 時,PageHeap 會更多地将空閑記憶體進行緩存,可以提升記憶體配置設定效率,不過會占用更多的系統記憶體;在 Doris 中該參數預設為 true。
通過分析查詢過程中的調用棧發現,有比較多的線程卡在 PageHeap 向系統申請或釋放記憶體的等鎖階段,是以,我們嘗試将 aggressive_memory_decommit 參數設為false,讓 PageHeap 對空閑記憶體進行更多的緩存。果然,調整完成之後,CPU 使用率可以打到幾乎 100%。在 Doris 1.1.2 版本,資料在記憶體中采用列式存儲,是以,會相比于 Doris 0.13 版本行存的方式有更大的記憶體管理開銷。
圖8-調優後Doris 1.1.2測試叢集的CPU使用率
社群相關的PR:
https://github.com/apache/doris/pull/12427
2. 緩解 FE 下發 Fragment 逾時的問題
在 Doris 1.1.2 版本,如果一個查詢任務的 Fragment 數量超過一個,查詢計劃就會采用兩階段執行(Two Phase Execution)政策。在第一階段,FE 會下發所有的 Fragment 到 BE 節點,在 BE 上對 Fragment 執行相應的準備工作,確定 Fragment 已經準備好處理資料;當 Fragment 完成準備工作,線程就會進入休眠狀态。在第二階段,FE 會再次通過 RPC 向 BE 下發執行 Fragment 的指令,BE 收到執行 Fragment 的指令後,會喚醒正在休眠的的線程,正式執行查詢計劃。
JSON RpcException, msg: timeout when waiting for send fragments RPC. Wait(sec): 5, host: 10.142.86.26 |
在使用者執行查詢時,會持續有上面的報錯,并導緻任何查詢無法執行。通過截取程序的調用棧,分析發現大量的線程均在休眠狀态,均阻塞在 Fragment 完成準備工作并休眠等待被喚醒的狀态。排查發現,查詢計劃的兩階段執行機制中存在 Bug,如果執行計劃被FE取消,BE 上已經完成 Fragment 準備工作并休眠等待的線程就不會被喚醒,導緻 BE 上的 Fragment 線程池被耗盡,後續所有查詢任務的 Fragment 下發到 BE 節點之後,因為沒有線程資源都會等待直到 RPC 逾時。
為了解決這個問題,我們從社群引入了相關的修複 Patch,為休眠的線程增加了逾時喚醒機制,如果線程被逾時喚醒,Fragment 會被取消,進而釋放線程資源,極大地緩解了 FE 下發執行計劃時 RPC 逾時的問題。
該問題還未完全解決,當查詢并發很大時還會偶發地出現。另外,我們還引入了 Doris 社群相關的其他 Patch 來緩解該問題,比如:減小執行計劃的 Thrift Size,以及使用池化的 RPC Stub 替換單一的 RPC Stub 。
社群相關的PR如下:
https://github.com/apache/doris/pull/12392
https://github.com/apache/doris/pull/12495
https://github.com/apache/doris/pull/12459
3. 修複 Tablet 中繼資料彙報的 Bug
在 Doris 中,BE 會周期性地檢查目前節點上所有 Tablet 是否存在版本缺失,并向 FE 彙報所有 Tablet 的狀态和元資訊,由 FE 對每一個 Tablet 的三副本進行對比,确認其中的異常副本,并下發 Clone 任務,通過 Clone 正常副本的資料檔案來恢複異常副本缺失的版本。
JSON detailMessage = failed to initialize storage reader. tablet=440712.1030396814.29476aaa20a4795e-b4dbf9ac52ee56be, res=-214, backend=10.118.49.24 |
在該報錯資訊中,錯誤代碼res=-214 (OLAP_ERR_VERSION_NOT_EXIST)表示查詢計劃執行過程中在 BE 上初始化 Rowset Reader 的時候出現異常,對應的資料版本不存在。在正常情況下,如果 Tablet 的某一個副本存在版本缺失,FE 生成執行計劃的時候就不會讓查詢落在該副本上,然而,查詢計劃在 BE 上執行的過程中卻發現版本不存在,則說明 FE 并沒有檢測到該副本存在版本缺失。
通過排查代碼發現,BE 的 Tablet 彙報機制存在 Bug,當某一個副本存在版本缺失時,BE 并沒有将這種情況正常彙報給 FE,導緻這些存在版本缺失的異常副本并沒有被 FE 檢測到,是以不會下發副本修複任務,最終導緻查詢過程中會發生res=-214的報錯。
社群相關的 PR 如下:
https://github.com/apache/doris/pull/12415
4. 優化 Like 語句性能
在 Doris 1.1.2 版本中使用 Like 語句進行字元串模糊比對查詢時,Doris 底層其實是使用了标準庫中的std::search()函數對存儲層讀出的資料進行逐行比對,過濾掉不滿足要求的資料行,完成 Like 語句的模糊比對。通過調研和對比測試發現,GLIBC 庫中的std::strstr()函數針對字元串比對比std::search()函數有 1 倍以上的性能提升。最終我們使用std::strstr()函數作為 Doris 底層的字元串比對算法,将 Doris 底層字元串比對的性能可以提升 1 倍。
5. 優化記憶體拷貝
在小米的場景中有很多字元串類型的查詢字段,Doris 1.1.2 版本使用 ColumnString 對象來存儲記憶體中的一列字元串資料,底層使用了 PODArray 結構來實際存儲字元串。執行查詢時,需要從存儲層逐行讀取字元串資料,在這個過程中需要多次對 PODArray 執行 Resize 操作來為列資料申請更大的存儲空間,執行 Resize 操作會引起對已經讀取的字元串資料執行記憶體拷貝,而查詢過程中的記憶體拷貝非常耗時,對查詢性能影響極大。
為了降低字元串查詢過程中記憶體拷貝的開銷,我們需要盡量減少對 PODArray 執行 Resize 操作的次數。鑒于小米 A/B實驗場景中同一列不同行的字元串長度相對比較均勻,我們嘗試預先為需要讀取的字元串申請足夠的記憶體來減少 Resize 的次數,進而降低記憶體拷貝的開銷。在資料掃描時,每個 Batch 需要讀取的資料行數是确定的(假設為 n),當字元串資料讀取完指定的前 m(在小米的場景中,該值配置為100,m < n)行時,我們根據前 m 行的 PODArray 大小預估所有 n 行字元串資料需要的 PODArray 大小,并為其提前申請記憶體,避免後面逐行讀取時多次執行記憶體申請和記憶體拷貝。
記憶體預估公式為:
C++ 所需PODArray總大小 = (目前PODArray總大小 / m)* n |
圖9-優化記憶體拷貝開銷
當然,該方法隻是對所需的記憶體進行了預估,根據預估的大小提前申請了記憶體,減少了後面逐行讀取字元串時大量的 Resize 操作,減少了記憶體申請和記憶體拷貝的次數,并不能完全消除字元串讀取過程中的記憶體拷貝。該優化方案隻對一列中字元串長度比較均勻的情況有效,記憶體的預估相對會比較接近實際記憶體。如果一列中字元串長度差别較大,該方法的效果可能不甚明顯,甚至可能會造成記憶體浪費。
調優測試結果
我們基于小米的 A/B實驗場景對 Doris 1.1.2 版本進行了一系列調優,并将調優後的 Doris 1.1.2 版本與小米線上 Doris 0.13 版本分别進行了并發查詢測試。測試情況如下:
測試1
我們選擇了 A/B 實驗場景中一批典型的使用者去重、名額求和以及協方差計算的查詢 Case(SQL 總數量為 3245)對兩個版本進行并發查詢測試,測試表的單日分區資料大約為 31 億(資料量大約 2 TB),查詢的資料範圍會覆寫最近一周的分區。測試結果如圖所示,Doris 1.1.2 版本相比 Doris0.13版本,總體的平均延遲降低了大約 48%,P95 延遲降低了大約 49%。在該測試中,Doris 1.1.2 版本相比 Doris0.13 版本的查詢性能提升了接近 1 倍。
圖10-查詢平均延遲和P95延遲
測試2
我們選擇了 A/B實驗場景下的 7 份 A/B 實驗報告對兩個版本進行測試,每份 A/B 實驗報告對應小米 A/B實驗平台頁面的兩個子產品,每個子產品對應數百或數千條查詢 SQL。每一份實驗報告都以相同的并發向兩個版本所在的叢集送出查詢任務。測試結果如圖所示,Doris 1.1.2 版本相比 Doris 0.13 版本,總體的平均延遲降低了大約 52%。在該測試中,Doris 1.1.2 版本相比 Doris 0.13 版本的查詢性能提升了超過 1 倍。
圖11-查詢平均延遲
測試3
為了驗證調優後的 Doris 1.1.2 版本在小米 A/B 實驗場景之外的性能表現,我們選取了小米使用者行為分析場景進行了 Doris 1.1.2 版本和 Doris 0.13 版本的并發查詢性能測試。我們選取了 2022年10月24日、25日、26日和 27日這 4 天的小米線上真實的行為分析查詢 Case 進行對比查詢,測試結果如圖所示,Doris 1.1.2 版本相比 Doris 0.13 版本,總體的平均延遲降低了大約7 7%,P95 延遲降低了大約 83%。在該測試中,Doris 1.1.2 版本相比 Doris 0.13 版本的查詢性能有 4~6 倍的提升。
圖12-查詢平均延遲和P95延遲
結束語
經過一個多月的性能調優和測試,Apache Doris 1.1.2 版本在查詢性能和穩定性方面已經達到了小米 A/B實驗平台的上線要求,在某些場景下的查詢性能甚至超過了我們的預期,希望本次分享可以給有需要的朋友一些可借鑒的經驗參考。
最後,感謝 SelectDB 公司和 Apache Doris 社群對我們的鼎力支援,感謝衣國壘老師在我們版本調優和測試過程中的全程參與和陪伴。Apache Doris 目前已經在小米集團内部得到了廣泛地應用,并且業務還再持續增長,未來一段時間我們将逐漸推動小米内部其他的 Apache Doris 業務上線向量化版本。