在 Asana,我們建立了一個名為 LunaDb 的資料載入系統,作為我們 Web 應用程式的骨幹。 儘管名字如此,但它並非資料庫。 相反地,它是一個類似 GraphQL 的系統,用於以宣告式方式擷取資料,基本上是一種載入最新版本資料和所有未來更新的方法。
我們最初在 2015 年推出 LunaDb,作為對後端基礎架構的徹底重寫¹。 此新系統的核心元件是同步伺服器,這是一個整體式架構,可執行從用戶端同步到資料載入和存取控制的所有功能。 在沒有重大變更的情況下,這個最初的架構擴展到遠遠超出早期預期的水平,一直到每週數百萬名活躍使用者和每日數十億次查詢。
雖然效能仍然強勁,但隨著流量和功能複雜性的增加,由於同步伺服器的限制,操作和改進 LunaDb 變得越來越困難。
我們資料載入基礎架構的概觀
為什麼難以運作?
流量轉移成本高昂
同步伺服器直接管理與用戶端的持續 websocket 連線。 每個 WebSocket 都由有狀態的用戶端工作階段支援。 當連線中斷時,所有這些狀態都會被丟棄,而用戶端將重新訂閱其關心的所有資料。 當這種情況發生在大量工作階段時,成本很快就會變得很高。 因此,考慮到重新連線所帶來的工作量大幅增加,我們在轉移這些連線時必須謹慎。
流量轉移
部署同步伺服器意味著轉移流量
當然,您無法永遠避免流量轉移。 每當您想推送新程式碼或擴充/縮減規模時,都需要停用同步伺服器,而這需要將所有流量從終止執行個體轉移出去。
持續更新
同步伺服器在啟動時效能不佳
同時,同步伺服器只有在經過大量的流程預熱後才能發揮效能。 管理這兩個問題一直是一個相當脆弱的平衡,過去需要大量的工程工作。
同步伺服器錯綜複雜,難以監控
最後,由於同步伺服器執行許多任意的產品程式碼 (透過自訂伺服器端功能),因此非常容易受到基於噪音鄰居的效能退化影響,且難以歸因²。
噪音鄰居問題
一個合理的問題是:「為什麼同步伺服器在啟動時既複雜又沒有效能?」
這些問題的常見成因是我們的伺服器端產品程式碼。 同步伺服器程式碼的主要部分是用 Scala 撰寫的。 儘管與工作階段狀態管理和 Luna 架構的各個方面相關的一些複雜性,此架構/平台程式碼大多數都按照我們的預期運作(運作和效能問題相對較少)。
另一方面,這些產品伺服器計算值 (我們稱之為 SCV,但請將其視為自訂解析器) 是以 Typescript 撰寫。 這兩組程式碼在 GraalVM 中一起執行,GraalVM 是一個多語言虛擬機器,可透過其 Truffle 架構使用多種語言。 由於 SCV 是以 Typescript 寫成,因此基本上會在啟動時進行解釋,這可預期會導致無法接受的績效和 CPU 使用率。 GraalVM 會嘗試對已叫用的 SCV 執行即時編譯。 這很好! GraalVM/Truffle 可以大幅優化其效能,但這樣做並非免費。 SCV 編譯可能相當昂貴 (在 CPU、程式碼快取等方面)。
我們的多語言 VM 設定
為什麼要使用這兩種語言?
我們最初的 SCV 設計完全使用 Scala。 另一方面,我們的變異和非同步工作系統是用 Javascript/Typescript 寫的。 雖然以 Scala 為基礎的 SCV 可正常運作,但我們的變異和非同步工作系統與 LunaDb 之間的 Business 版邏輯重複,以及產品工程師對 Scala 不熟悉,都成為產品速度的阻礙。
為什麼選擇 GraalVM?
我們在處理過程中快取大量資料,以加快計算 (和重新計算) 訂閱結果的速度。 使用 GraalVM 可讓我們透過簡單的方式跨語言共用這些快取,而不會因為將 Scala 和 TypeScript 部分拆分為單獨的容器而產生正確性或效能方面的問題。
為什麼難以改進?
由於伺服器執行了如此多的工作,而且運作相對脆弱,因此我們傾向於避免進行更大的變更。 這不僅是因為程式碼的複雜性,還因為安全推出新變更的成本很高。
是的,我喜歡問問題
鑑於操作和改進同步伺服器的困難,我們做出了改變架構的艱難決定。 主要是,我們決定用兩種較小的元件來取代整體式同步伺服器:
一個工作階段代理程式,用於管理用戶端連線和狀態解析
負責資料載入的可同步載入器,即處理工作階段代理程式的查詢
工作階段代理程式和可同步載入器
這為什麼有幫助?
這個新架構立即將轉移 WebSocket 流量(即部署工作階段代理程式)與預熱新程序(即部署可同步載入器)分開。 因此,我們可以透過將可同步載入器與工作階段代理程式分開部署,將中斷情況降至最低。
這些新元件都更簡單。
工作階段代理程式的負載輕得多,不需要流程預熱,也不執行產品程式碼。 因此,我們不需要經常部署它們,而且當我們部署時,過程相當簡單。
可同步載入器具有更簡單的介面(無狀態訂閱請求),更容易適應標準 Kubernetes 水平 Pod 自動擴展。 這也使其預熱更快速、更簡單,我們只需在工作階段代理程式和可同步載入器之間流動的環境請求上使用流量鏡像即可。
新架構使我們能夠大幅簡化產品開發流程。 從一開始,伺服器端產品程式碼的獨立部署排程和呼叫用戶端產品程式碼,一直是速度的阻礙,也是營運工作的來源 (由於版本不相容)。 由於可同步載入器現在是唯一剩餘的執行產品程式碼的流程,且部署它們不再造成干擾,因此每當我們推送新的產品程式碼時,都可以重新部署它們。
這種新架構使我們能夠針對不同的工作負荷類型 (例如不同的獨立功能,如收件匣、任務、目標等) 部署不同的可同步載入器集區,從而更好地擴展到新功能。 工作階段代理程式充當服務閘道,可直接控制資料查詢如何路由至不同的上游可同步載入器。
太棒了! 這個新架構聽起來好多了,但我們是如何實現的? 同步伺服器基本上是一個整體式架構,因為它包含多個功能,而拆分整體式架構幾乎總是很棘手。 在我們的情況下,我們必須克服一些關鍵的設計障礙。
拆分 PubSub
PubSub 是我們用於實現反應性的系統,其設計圍繞著單一流程 (同步伺服器),負責載入新資料並將其傳送給用戶端。 我們必須重新設計 PubSub,以確保這兩種現在獨立的流程類型 (可同步的載入器和工作階段代理伺服器) 的正確性。
讓我們簡單瞭解一下它在同步伺服器中的實施方式。 請注意:閱讀我們先前關於無效管道的文章可能會有所幫助,但我們將提供一個無需預備知識的系統檢視。
在同步伺服器上,我們會追蹤每個工作階段的訂閱。 我們使用無效化管道持續監控訂閱的更新。 針對每一則新的無效訊息,同步伺服器將重新載入所有受影響的訂閱。
同步伺服器大量快取資料庫物件/查詢結果、自訂解析器結果和先前的訂閱結果,以優化訂閱重新載入 (即使用直讀模式)。 快取的成品會由與訂閱使用的相同無效管道被動地使其無效。 每當我們嘗試使用快取資料時,我們都會檢查其有效性,並在需要時改為重新計算。
我們可以觀察到重新載入訂閱和使快取資料失效之間的明確相依性。 收到無效化後,若我們在快取資料被無效化之前重新載入訂閱,我們可能會計算出過時的結果。 當資料載入和訂閱管理都在同一個流程中進行時,要確保這種依存關係非常簡單,只需在重新載入之前使快取失效即可。
競爭條件可能導致更新時資料過時
在我們提議的新架構中,工作階段代理程式和可同步載入器都是無效管線的獨立取用者。 那麼,我們如何強制在重新載入訂閱之前使快取失效?
請求和回應版本控制
我們本來可以透過讓無效管道同步傳遞訊息來解決這個問題。 或者,我們本可以建立一個機制來強制執行排序保證,確保在可同步載入器之前,沒有無效管道訊息到達工作階段代理程式。 不過,這兩種解決方案都有不理想的權衡取捨,最重要的是,它們增加了工作階段代理程式和可同步載入器的耦合。
相反地,我們透過在資料載入通訊協定中增加請求和回應版本,依據其在無效化串流中的相對進度來解決此問題。 由於串流代表資料庫更新的總排序,因此我們的串流進度可用作全域版本計數器。
請求和回覆版本
無效重新載入
同步伺服器會載入大量資料,這是網站所有讀取的大部分。 我們的新架構需要工作階段代理程式和可同步載入器透過網路交換大量資料。 對於新訂閱而言,此網路額外負荷相對微不足道。 然而,這對無效重新載入來說尤其效率低下,因為我們通常不需要傳回完整的回應,只需要更新的資料³。 在這裡,某些情況尤其糟糕。 想像一下,一位使用者在專案中分頁顯示了 10000 個任務,而另一位使用者不斷變更此專案中的任務描述:每次失效時都需要傳送所有任務! 顯然,理想情況是只傳回更新的資料,但我們要如何有效地實現這一點?
無效重新載入
指紋
若要讓可同步的載入器計算更新資料的差異,就必須知道請求者已經擁有哪些資料。 但隨請求傳遞最新資料的成本,與傳回完整結果的成本一樣高。 我們需要以更節省空間的方式來表示資料。
嗯,雜湊是節省空間的絕佳方法。 我們關注的每一位精細資料都稱為可同步資料。 我們可以計算每個序列化可同步項目的 128 位元 murmur 雜湊,用作指紋⁴。 具體來說,此指紋是該版本可同步資料的識別碼。
無論我們在哪裡追蹤可同步資料,都可以改用其指紋。 現在,當我們想要追蹤完整的訂閱回應時,只需使用一組指紋,而無需傳遞完整的資料!
附註:什麼是可同步項目,它與訂閱有何關聯?
可同步項目是訂閱結果的內容。 當我們載入訂閱時,結果會以一組可同步項目的形式傳回。 更具體地說,可同步項目可以是物件、查詢或 SCV 結果。
可同步至訂閱對應
顯然,每個訂閱都對應到多個可同步項目。 不過,可同步項目可以由多個訂閱共用 (當它們載入重疊資料時)。 因此,訂閱和可同步項目之間實際上存在多對多對應。
我們會隨著工作階段代理程式的每個請求,傳遞這些指紋的集合。 在可同步載入器上,我們計算完整回應、計算其指紋集合、排除任何與請求重疊的資料,並傳回差異。
考慮到變更的規模和重要性,我們將推出分為大約 4 個階段。
階段 1 - 重構整體式架構
將我們高度耦合的工作階段管理和資料載入程式碼拆分為獨立的元件
階段 2 - 本機 syncable-loader
使用新的資料載入元件建立本機 gRPC 伺服器,並遷移資料載入
階段 3 - 遠端 syncable-loader
建立新的 syncable-loader 二進位檔並部署
將所有 sync-server 資料載入遷移至我們的新 syncable-loader 部署
階段 4 - 獨立的 session-broker 二進位檔
建立新的 session-broker 二進位檔和部署
將所有流量從 sync-server 移轉至 session-broker
太多了。 從何下手?
大型回應
我們很快就遇到了一個問題,那就是回應的大小。 由於同步伺服器上的資料載入都在同一個流程中,因此到目前為止,這並未成為一個大問題⁵。 然而,一旦我們開始跨本機 gRPC 界限載入資料,就開始遇到許多問題。
我們一直懷疑某些回應可能很大,但當我們開始調查時,我們發現了真正令人震驚的結果。 我們每天有數千次載入,經常超過 100 MiB! 我們在實際上無法透過一元 gRPC 方法傳回如此大的回應(您開始達到http2 最大框架大小)。 該怎麼辦?
我們考慮了幾種系統性解決此問題的方法,但最終得出的結論是,我們需要解決根本成因。 我們本來可以實施 gRPC 伺服器端串流,但所產生的高序列化成本和增加的通訊端爭用,將對延遲和輸送量產生相當大的負面影響。 我們可以直接拒絕這些回應,但發生率太高,這樣做是不可接受的。
我們決定採用三階段方法,標記所有大型回應,分析並消除每個有問題的案例,然後對回應大小實施嚴格的上限。
我們標記了所有大於 1MB 的載入,並記錄了有關來源、使用情況和資料細項的詳細事件。 有幾個不同的高成本使用案例,但最臭名昭著的可能是附件縮圖 BLOB。 事實證明,它們被編碼為 base64 字串,並包含在序列化回應中。 當數量不多時,這些回應還可以接受,但當大量載入時,很快就會變得很大,例如載入以網格為基礎的檢視,為每個任務顯示附件縮圖。
我們透過限制大型回應的縮圖、使用較小的縮圖,並最終將二進位資料從回應中移除,得以逐步修補此類問題。 在進行這類簡單的緩解措施後,大型回應的數量消失了,我們隨後能夠執行架構層級的回應大小限制⁶。
主題衝突
我們遇到的另一個奇怪問題是 pubsub 主題衝突。 事實證明,我們的架構使用方式不符合規範,無論網域為何,都會產生相同的訂閱主題。 當 pubsub 僅發生在單一流程類型上時,影響相對較小。 通常,單一 pubsub 主題對應於單一網域的資料。 不過,由於 pubsub 現在分散在工作階段代理程式和可同步載入器之間,因此這兩種程序類型可能會在特定主題的網域上存在分歧。 當發生這種情況時,由於此「網域不相符」,我們會看到無效重新載入的比率穩定上升。 好在,修正方法相當簡單,但有趣的是,這個錯誤在我們的架構中存在了這麼長時間,卻沒有被發現。
重新調整工作負荷
工作階段代理程式和可同步載入器的工作負載與原始同步伺服器的工作負載有很大的不同。 工作階段代理僅負責工作階段管理,而可同步載入器僅負責資料載入。
我們不太確定這會如何影響它們的資源需求,因此我們一開始都使用類似的資源(cpu/mem)請求和水平 Pod 自動擴展器 (HPA) 設定。
session-brokers
根據我們的觀察,很明顯工作階段代理程式的負載量較輕。 它們在非常低的 CPU 使用率下可靠地運作 (如果有的話,它們更受記憶體限制⁷)。 幾個複本似乎足以服務整個基礎架構單元的流量。 然而,當我們實際上減少 HPA 上的 minReplicas 時,我們觀察到資料重新載入和重新載入延遲大幅增加。 發生了什麼事?
簡而言之,我們忽略了考慮所有與快取大小和節流器相關的共用設定。 只有幾個複本時,每個工作階段代理程式在每個 Pod 上處理的工作階段比一般同步伺服器多得多 (約 3.5 倍)。 由於每個工作階段都看到更多資料,因此它們完全填滿了 pubsub 主題 ⇔ 訂閱快取,而每次移除都會觸發重新載入 (出於安全考量)。 將此閾值適當提高約 6 倍,解決了快取移除和重新載入率上升的問題。 同樣地,我們發現我們的階層式重新載入節流器針對新的流量速率設定錯誤。 同樣地,調整這些節流器設定的大小,可大幅降低重新載入延遲和端到端反應延遲 (即 Web 應用程式看到自己的寫入需要多長時間),降低約 5 至 10 倍。
syncable-loaders
另一方面,可同步載入器的負載比預期重得多。 每部伺服器每秒載入的訂閱數 (約 1.5 倍) 會比資源等同的同步伺服器更多。 與工作階段代理不同,它們對 CPU 的依賴性更高⁸。
有趣的是,相當一部分的 CPU 使用量是由於與我們的 TS SCV 程式碼相關的 Truffle 反最佳化增加所致。 這很可能是因為每個可同步載入器存取更多我們的 SCV 程式碼。 無論如何,這需要適度增加我們的程式碼快取大小⁹。
流程預熱
資料載入的流程預熱一直是一項挑戰。 幸運的是,在我們的新架構中,這變得更加簡單。 我們的 syncable-loader 主介面是無狀態資料查詢,因此我們只需重播或鏡像工作階段代理程式與 syncable-loader 之間的現有流量,即可預熱它們。
另一方面,我們仍面臨許多與預熱同步伺服器相同的挑戰。 主要是,預熱流程需要大量 CPU,這會在啟動時導致各種噪音鄰居問題 (對我們來說,這裡的相關指標是部分停滯,因為我們沒有達到 k8s 節流限制)。 我們在這裡做了一項很好的改進,就是使用就地 Pod 重新調整大小,在啟動時限制 syncable-loader 的資源,但允許它在啟動後突增。
儘管如此,預熱每個 Pod 仍需要幾分鐘的時間。 從 JFR 分析檔來看,我們認為主要的瓶頸是預熱期間 TS SCV 程式碼的編譯不足,我們認為這裡還有更多的改進空間。 我們正積極研究如何透過更具針對性的方法,更精確地預熱相關路徑,並重新設計我們的 TS 介面¹⁰,以改善編譯。
我們的工作階段代理程式不負責任何資料載入,實際上從未需要任何形式的預熱。
我們的新架構大幅降低了營運複雜度,加快了部署和程式碼速度,並開啟了未來的速度和擴展機會。
我們的新架構可簡單地自動擴展,以因應總讀取流量的變化,而無需複雜的流量管理。 只需重新啟動 Pod,即可妥善處理每種類型的流量轉移操作。 需要啟動所有工作階段嗎? 循環所有工作階段代理程式。 需要清除我們的快取嗎? 循環所有可同步的載入器。 就運作時間而言,這兩項操作都是安全的。
過去,產品開發速度主要受到部署同步伺服器的瓶頸影響。 在我們的新環境中,我們只需要部署可同步載入器即可部署產品程式碼。 我們將可同步載入器移至其各自的可部署儲存格中,並努力更頻繁地進行部署 (最終與產品程式碼一起部署),從而實現此目標。 部署可同步載入器的速度已經比同步伺服器快約 40% (快約 20 分鐘) (我們可以安全地大幅提高),我們的目標是未來進一步提高速度。
值得注意的是,效能改進並非此工作的目標,但考慮到設計和實施涉及的程度,這仍然值得討論。 在我們的新系統中,計算查詢結果的延遲實際上有所改善(可能是因為更好的負載平衡/調整/自動擴展)。 相對地,初始訂閱延遲明顯改善。 另一方面,端到端變異反映延遲 (即變更分發到其他工作階段所需的時間) 大致相同。 追蹤顯示,這很可能是由於工作階段代理程式和 syncable-loader 之間在消耗 PubSub 串流時存在偏差 (syncable-loader 在與請求版本同步之前無法處理請求)。
我們目前的系統已大幅改善,但每次發佈時仍需考慮向後/向前相容性,這仍然限制了產品速度。 然而,隨著我們在部署速度上的所有改進,現在可以將所有資料模型變更一起部署,從而無需考慮向後/向前相容性。 我們正積極研究在不久的將來建立此功能。
我們進行此工作的主要動機之一是難以歸因的效能退化。 值得注意的是,這是我們在這項工作中尚未取得重大改進的領域。 從好的方面來看,這現在是我們未來要解決的主要問題之一。 與此處討論的大部分工作不同,這是一個更具跨職能性的問題,涉及產品領域、資料模型、架構和基礎設施方面的考量。
可同步的載入器工作者集區 (按功能)
我們很期待探索跨平台基礎架構 (例如:依功能劃分的工作者集區)、平台架構和平台工具 (例如:黑盒/白盒測試) 的解決方案。
Arvind Vijayakumar 是 LunaDb 團隊的工程師,他協助建立和擴展 Asana 的核心資料載入平台,這是確保使用者隨時在我們的 Web 應用程式和 API 中看到準確、反應靈敏且快速的更新的關鍵基礎架構。
擴展 LunaDb 的這項工作是團隊在過去幾年中長期的投入量,涉及 LunaDb 的許多成員,包括現任和過去的成員:Brandon Zhang、Alex Matevish、Sean Wentzel、Eric Walton、Spencer Yu、Sophia Yao、George Ong、Tyler Prete、Koushik Ghosh、Natan Dubitski、Vinodh Chandra Murthy 等
值得注意的是,我們在 Facebook 開放 GraphQL 之前就已經建立了它
在先前的貼文中有更多關於流程預熱的詳細資料,但簡而言之,我們仰賴允許同步 Pod 任意突發,作為快速擴展以應對本機流量峰值的方式
我們還以每個物件的粒度進行更新,而不是每個欄位 (出於歷史原因)。 這會增加無效重新載入所涉及的資料量。
我們使用 128 位元的 Murmur 雜湊來避免衝突。
值得注意的是,我們使用 WebSocket 壓縮已經很久了,這可能會減輕客戶感受到的影響。
值得注意的是,我們最初也遇到一些網路躍點的額外負荷問題。 經過一些調查,我們發現大部分的額外負荷實際上是由於我們的服務網格 (Istio/Envoy) 所致,我們進行了一些有針對性的調整,以改善此處的效能。
保留記憶體的一個重要因素是重寫我們的伺服器端可同步儲存,僅保留資料的雜湊。 這不是一個簡單的問題,我們可能會針對此發佈後續文章! 若沒有這個,儲存的用戶端資料會使工作階段代理程式使用更多記憶體。
在此之前,所有同步伺服器都在記憶體最佳化的執行個體上運行。 另一方面,可同步的載入器可受益於 CPU 更強大的節點類型,例如混合執行個體。
以這種方式在 GraalVM 上執行 TS 的一個顯著特性是,它使用的程式碼快取比較標準的 Scala/Java JVM 應用程式通常使用的要多得多。
據我們所知,高多型性會使 TS 編譯的成本更高。 我們正在研究改用單型介面,並利用報告多型態專用化