前言

這是 Airbnb 語意層系列的第三篇,也是最後一篇(前兩篇請見第一篇第二篇),談的是我們的語意層 Minerva 一路走來的工程決策。Minerva 在 Airbnb 有四千多位使用者,他們的使用情境差異很大,而查詢層就是讓大家在這種規模下還能順利取用資料的關鍵。如果你想先有個概念再往下讀,Barak 有一場閃電講介紹了我們的查詢層 MinervaSQL。

MinervaSQL

上一篇裡,我們提到 Minerva 的運算層會產出一萬多張具體化的資料表。在這種規模下,沒有人記得住該查哪些資料表。常見的解法之一,是把經常被一起查詢的資料反正規化(denormalize),讓使用者只要打少數幾張寬表就好。這個想法推到極致,就是一張大表(One Big Table,OBT):把所有東西都反正規化進單一一張寬表裡。但以 Minerva 的規模來說,OBT 的成本高到不切實際。光是想像這樣一張表要扛多少相依關係就夠嚇人了,更別提回填它要花的時間,還有它會吃掉的儲存空間。

Minerva 的查詢層把 OBT 的概念套用在一張虛擬資料表magic.all)上,這樣就不必真的把東西具體化成一張巨大的實體資料表。使用者用一種叫做 MinervaSQL 的自訂方言對這張虛擬表下查詢,查詢層再把它改寫成對底層實體資料表的查詢,並自動處理該怎麼 join。指標則透過一個特殊的 AGG 語法來查詢,它會依照指標的定義,被改寫成對應的彙總運算式。

這層抽象大幅減少了使用者為了回答問題所要寫的 SQL。這些年來,我們陸續替這個方言加了更多語法,來支援比較進階的查詢模式。FILTER 讓使用者可以即時定義帶篩選條件的指標;有了 SHIFT,跨期間的比較(例如跟前一期相比)也變得很好表達。我們還加了一些設定,讓進階使用者在多個來源都能提供同一份資料時,可以自己指定要查哪一個。

查詢改寫

「一張大虛擬表」(One Big Virtual Table,OBVT)這個做法最大的挑戰,是要把一個對虛擬表下的查詢,轉換成對底層實體資料表的查詢。我們靠 Rewriter 做到這件事,它是一條在 SQL 語法樹(AST)上運作的改寫規則管線。每條規則收進一棵語法樹、回傳一棵轉換過的語法樹,而且每走完一步,這棵樹都仍然是一個合法的 MinervaSQL 運算式。

這條管線的規則很多,但整個改寫過程可以歸納成幾個關鍵步驟。第一步是時間與指標解析(metric resolution)。前面提過 AGG 運算式,它本身並沒有包含真正的指標彙總運算式,而指標解析這條規則會把它展開成該運算式,並附上一個記錄指標從哪裡來的來源提示。這些提示,就是後面整條管線在展開和改寫查詢時一路依循的線索。

接著,改寫器會照著這些來源提示(source hints),試著在每個作用域(scope)裡收斂成一個 SELECT,這正是我們最後能把 entity 換成實體資料表的前提。如果指標只來自單一來源,這一步相對單純;衍生指標和跨來源鑽取(drill-across)的查詢就麻煩多了:那些被標上來源的彙總運算會被拆成一個來源一個子查詢,再用 FULL OUTER JOIN 依共用的維度接回來。最後一步則是把所有 entity 參照換成實體資料表,讓整棵樹上不再有任何 magic 資料表,也不再有 @ 開頭的欄位。

以上只碰到這條管線的皮毛而已。這些年來,為了配合大家實際的查詢習慣,我們加了非常多規則。由於每條規則都是純函式、可以獨立測試,我們針對各種情境建立了完整的測試案例和 fixture,這也是為什麼我們可以放心改動改寫邏輯,而不太需要擔心會弄壞什麼。

來源選擇

上一節讀起來,好像對於一個「指標 × 維度」的查詢,永遠都剛好有一張實體資料表可以回答。實際上通常沒有,因為 Minerva 還會另外建立像維度集(dimension set)和彙總來源(rollup source)這類預先算好的資料集來服務查詢。在寫入端多付一點成本,可以在讀取端換來很大的節省,而替每個查詢挑出最便宜又正確的資料集,正是 Minerva 既跑得快、又值得信任的一大原因。

這個演算法會走一條層層退回的路徑。彙總來源會最先被試,因為預先彙總過的資料讀起來最便宜。一個彙總來源要能被採用,得同時滿足兩個條件:它的維度是查詢 group by 維度的超集合,這樣查詢才能再往上彙總到更粗的顆粒度;而且查詢裡的每個彙總運算,都能拆解成該彙總來源已經存好的可加性成分。

有不少查詢過不了這兩關,可能是因為它們根本不是彙總查詢,也可能是某個指標或某個顆粒度沒被涵蓋到。這些查詢會退到維度集,我們會找一個涵蓋查詢所碰到的全部維度的維度集。從維度集出發還是幫助很大,因為在一個牽涉到多個維度的寬查詢裡,join 通常才是最貴的部分。如果連預先算好的資料集都沒有合適的,我們才退回去即時做 join 和彙總。

這一切的前提,是每個選項都要回傳一樣的結果,不然使用者看到的數字就會取決於當下是哪個來源勝出。而這件事其實很難做到,因為這些資料集背後的 pipeline 各自在不同時間才跑完,導致它們之間的資料處於最終一致(eventually consistent)的狀態。

MinervaSQL 剛推出時,不一致是個大問題,我們引入了 WATERMARK 來處理。Watermark 的做法是一律以最舊的可用分區為準來讀取資料,讓所有來源對「現在是什麼時候」有一致的認知。至於那些速度和即時性都不能妥協的儀表板,則可以用 HYBRID DENORMALIZATION,它會在一個切分日期上,把預先算好的資料和近期資料 UNION 起來。這一塊到現在都還在持續發展,因為即時性和一致性之間本來就是互相拉扯的。

自動反正規化

如同上面所說,預先算好的資料集讀起來效率高得多,但要維護這些資料集卻相當繁瑣。Minerva 早期,維度集都是使用者自己手工建的。這很花時間,而且不同團隊往往會建出非常相似的維度集。Minerva 1.0 完全沒有預先彙總資料集的機制,所以所有東西都改成灌進 Druid。這個做法撐了一陣子,但維運起來一直很痛苦,因為它需要專門的 Druid 知識。

到了 Minerva 2.0,我們把這個模式反了過來:絕大多數資料集都是在背景被建出來的,使用者甚至不會意識到它們的存在。負責這件事的是一套自動反正規化框架,它會讀取 MinervaSQL 的查詢歷史,把可能的反正規化空間建模出來,再從中挑出那些「查詢端省下的成本」最能蓋過「具體化所付出的成本」的反正規化資料集。

在 Airbnb,查詢歷史存放在 Elasticsearch 裡,MinervaSQL 服務過的每一個查詢都會被記成一份文件。因為在改寫查詢的過程中,系統本來就已經知道會碰到哪些來源、彙總了哪些指標、以哪些維度分組,所以對任何一個查詢,我們都能還原出它查了什麼、彙總了什麼、以及在什麼顆粒度上。接著我們會把重複的查詢去掉並統計次數,藉此估算查詢成本。我們也會追蹤維度的基數(cardinality)、列數和 join 的數量,這些之後都會拿去算具體化成本。

剩下的就是選取演算法本身了。預先算好的資料集並不是免費的,所以問題就變成:要找出哪些組合能讓查詢端省下的成本,最大幅度地超過它們的具體化成本。Harinarayan、Rajaraman 和 Ullman 在 Implementing Data Cubes Efficiently 這篇論文裡把這個問題解掉了,我們實作的核心用的正是文中那套貪婪選取(greedy selection)程序。省略掉很多細節的話,選取器會告訴我們哪些預先算好的資料集最划算,我們就在背後把它們建起來。這件事會定期跑,因為查詢模式一直在變。

總結

MinervaSQL 剛推出的時候,並沒有馬上變成使用者的主力工具。很多人還是習慣自己寫查詢去打實體資料集。我們花了很多時間去推廣 MinervaSQL 能做到什麼,久了之後大家才慢慢看見,原來複雜的查詢可以用一種簡單得多的方言表達出來,也是從那時候開始,它才真正被廣泛採用。

進入 AI 時代後,MinervaSQL 成了資料 agent 背後的引擎。自然語言可以先被翻譯成 MinervaSQL,而 MinervaSQL 再翻譯成運算引擎看得懂的方言時,過程是完全確定的。這讓我們能做到自助式分析,又不必犧牲正確性。這裡面投入了非常多優秀的工程,我認為這正是 Minerva 的語意層在業界稱得上頂尖的原因。要特別感謝像 Barak 這樣的同事,是他們從無到有把這套系統打造出來的。

這個三部曲系列就在這裡告一段落。如果你一路讀到了這裡,希望你對我們當初怎麼在 Airbnb 打造並擴展 Minerva,有了更清楚的認識。這一路走來很有趣,我也很慶幸自己參與其中。