Rust重寫後效能提高了900倍

banq發表於2024-12-12


舊堆疊:

  • 資料庫:Neo4j
  • API 層:GraphQL
  • 程式語言:JavaScript / TypeScript
  • 後端框架:Node.js
  • GraphQL 伺服器:Apollo 伺服器

雖然這種架構最初有效,但出現了幾個關鍵問題:
  1. 模式約束和資料庫限制
    • Neo4j GraphQL 模式有顯著的限制,例如,我們無法強制執行多鍵唯一約束。
    • 資料庫和 API 層之間緊密耦合,例如所有欄位預設公開,迫使我們明確標記私有欄位並手動執行訪問控制限制。
    • 資料層沒有抽象化,這意味著我們對資料儲存方式的任何更改都會影響整個 API。由於資料庫和 API 緊密耦合,即使對資料儲存方法進行微小調整也需要更新所有 API 端點,這使得系統更難更改。
    • 我們的圖形資料庫非常適合表示關係,但它無法處理需要關係約束的業務邏輯的其他方面(例如使用者和組織)。我們需要一個多模式資料庫,可以處理我們需要儲存的所有其他型別的資料。
    • 我們使用 GraphQL 來簡化資源圖的編輯,但我們很快發現,大多數更新操作過於複雜,需要自定義解析器。這使得 GraphQL 在我們的用例中效率較低。
  2. 複雜的資料處理 
    • 我們必須將配置/程式碼轉換為 JSON 才能進行分析,這讓一切都變得非常複雜,而且速度非常慢。最重要的是,這種方法並不通用——有些語言無法轉換為 JSON(例如 Rego 策略),而額外的轉換層只會讓速度變慢!
    • 這導致了過於複雜的驗證和錯誤處理機制,維護和擴充套件都很困難。
  3. 效能瓶頸
    • 大規模資料處理效率低下,例如,我們有很多 CPU 密集型任務阻塞了我們的 NodeJS 執行緒
    • 處理高流量場景時的效能限制,單個會話可能會消耗過多的 CPU 資源,從而導致其他會話延遲或崩潰。
    • API 很容易出現故障,一個錯誤就可能導致整個系統崩潰。這不僅導致後端不穩定,還增加了維護的複雜性,難以確保一致的效能。
  4. 複雜的前端: <ul>
    • 我們的前端過於複雜,許多業務邏輯都是透過複雜的狀態機和瀏覽器內的程式碼處理和驗證來處理的。雖然這使得 Stakpak IDE 響應速度非常快,但也使得支援新技術或在 Stakpak API 之上構建其他客戶端(如 VSCode 擴充套件)變得困難,因為我們必須在客戶端之間複製這種複雜的邏輯。

    1. 架構複雜性和不靈活性
      • 我們的資料模型和解析工具與 Terraform 緊密相關,因此很難支援其他配置語言。這種缺乏靈活性的狀況導致系統僵化,無法輕鬆適應新技術或許多其他可用的 DevOps 工具。
      • 實現穩健的錯誤處理的困難
      <ul>
      例如, TypeScript 並不總是能夠幫助識別潛在錯誤,這意味著某些錯誤沒有得到明確處理。此外,隱式錯誤傳播會導致問題,因為錯誤並不總是正確傳遞,從而導致整個系統的行為不一致。
    2. import { GraphQLError } from <font>"graphql";

      async function getFlow(flowId: string): Promise<any> {
       
      // Simulates a database call that might throw an error<i>
        if (!flowId) throw new Error(
      "User ID is required");
        return { id: flowId, name:
      "Flow 1" };
      }
      async function resolveFlow(_, args): Promise<any> {
        try {
          const flow = await getFlow(args.flowId);
         
      // Further processing<i>
          return { success: true, user };
        } catch (e) {
         
      // Return a GraphQL-friendly error<i>
          throw new GraphQLError(
      "Failed to fetch flow data");
        }
      }

      1. 隱式錯誤源:像getFlow這樣的函式沒有在其型別簽名中指定它們可能引發的錯誤,導致呼叫者不知道可能發生什麼錯誤或如何正確處理這些錯誤。
      2. 錯誤吞噬:catch 塊將錯誤包裝到通用GraphQLError中而不保留原始錯誤詳細資訊,這使得除錯變得更加困難。
      3. 錯誤傳播複雜性:解析器假設所有錯誤都應轉換為 GraphQLError,但某些錯誤(如資料庫連線問題)需要不同的處理方法。這種缺乏區分的做法增加了不必要的複雜性,並使得有效管理錯誤變得困難。

      為什麼不進行重構呢?
      當我們審視現有堆疊中的挑戰時,我們面臨一個關鍵的決定:我們應該重構當前系統還是徹底重寫?雖然重構很誘人,但我們很快意識到,我們架構中根深蒂固的限制使得我們幾乎不可能實現所需的靈活性和可擴充套件性。這就是為什麼我們決定徹底重寫是唯一的出路。

      1. 架構考慮


        • 當前堆疊嚴重依賴於 Neo4j/GraphQL 的自定義解析器,我們需要一個更靈活的資料庫。


        • 遷移資料庫需要我們徹底重建整個 API。
        • 我們需要一個更靈活的介面來支援 Terraform 之外的不同供應商並適應其他客戶端型別,例如 VSCode 擴充套件和 CLI 工具。

           2.語言和架構目標

        • 我們意識到增量重構無法解決我們的技術堆疊的核心限制。

           3.新架構的戰略目標

        • 我們希望將資料層與 API 層分離
        • 實現更靈活的程式碼解析和驗證
        • 建立更加模組化和可擴充套件的程式碼庫
        • 我們需要提高效能以加快資料處理速度,因為開發人員重視響應能力,而緩慢的工具會很快失去他們的注意力。
        • 我們目前堅持使用單體架構,因為我們的團隊規模很小,我們希望快速行動,並且我們不希望服務數量超過開發人員數量。保持簡單意味著要處理的事情更少,可以有更多的時間用於真正重要的事情:釋出精彩內容!
        • 我們希望支援多種 DevOps 工具(又名 Provisioners),以將 Stakpak 擴充套件到 Terraform 之外,正如我們一直以來的意圖。
        • 我們將核心功能(例如驗證)從前端轉移到後端。這簡化了前端的職責,使其能夠專注於提供無縫的使用者體驗。

      為什麼是 Rust?
      Rust 脫穎而出,成為滿足我們需求的理想選擇,它提供了強大的效能、可靠性和靈活性組合:



        支援通用語言解析器: Rust 對tree-sitter的支援使其能夠輕鬆處理所有支援的語言。
      • 錯誤可靠性:藉助 Rust 的強型別系統和安全保障,我們可以獲得端到端的型別錯誤,幫助我們最大限度地減少錯誤和執行時錯誤。
      • 出色的效能: Rust 在處理大型檔案時表現出色,處理速度快、效率高。這也使其非常適合分析原始碼。

      • 快速開發:它讓我們能夠快速迭代而不會犧牲程式碼質量或效能——這對生產力和可維護性來說是雙贏的。

      • 可擴充套件性: Rust 成熟的泛型使我們可以輕鬆構建對多個供應商、LLM 提供商(Stakpak 使用 4 種以上不同的 AI 模型)的可擴充套件支援,併為我們的工作提供未來保障。
      • 明確性:Rust 的明確性和多執行緒使我們能夠確保隔離故障並正確處理錯誤。
      • 趣味性:Rust 有一些功能元素,比如列舉型別和模式匹配,這有助於我們編寫更少的程式碼,並從中獲


      我們考慮過的其他語言
      在評估替代方案時,Elixir 和 Go 脫穎而出,但未能滿足我們的需求。



      • Typescript + Effect:這讓我們可以保留現有的堆疊,但為 Typescript 新增了強大的端到端型別錯誤和更多功能元素。但這是有代價的,我們必須用新的 Effect 語言重寫所有內容。
      • Elixir:透過 Phoenix 提供出色的併發性和快速開發,但缺乏原生的樹狀支援,並且在執行 CPU 密集型任務時速度極慢。我們嘗試用 Rust 編寫原生擴充套件來加快速度,但這增加了額外的維護開銷。
      • Go:一個強大的競爭者,擁有成熟的生態系統、簡單的語法和良好的效能,但有限的語言功能、缺乏列舉、成熟的泛型和手動錯誤管理使其不太適合我們的用例。我們必須編寫大量程式碼或嚴重依賴程式碼生成來構建通用資料處理和 LLM 生成管道。

      儘管我們在 Go 方面擁有豐富的經驗(我們用 Go 將 2 個產品投入生產),但 Rust 的功能元素、效率和現代特性的組合被證明更適合我們的目標。

      構建新的後端
      新堆疊



        主要語言:Rust
      • Web 框架:Axum
      • 資料庫:EdgeDB

      • API 設計:RESTful

      架構決策與實施
      解耦的 Provisioner 架構
      為了快速支援新的配置語言,我們設計了一個用於特定於配置程式的邏輯的介面。此架構:



        保持供應商功能隔離:確保與核心系統元件完全分離,減少潛在的連鎖反應。
      • 最大限度地減少核心影響:核心系統保持穩定,不受供應商變化的影響。
      • 可擴充套件:新增或修改供應商非常簡單且風險低。

      trait Provisioner {
          fn parse(&self, code: &str) -> Result<Vec<Blocks>, AppError>;
          fn validate(&self, config: &Config) -> Result<ValidationReport, ValidationError>;
          <font>// The rest of the interface methods<i>
      }

      struct TerraformProvisioner;
      struct KubernetesProvisioner;

      impl Provisioner for TerraformProvisioner {
         
      // Implementation for Terraform-specific analysis<i>
      }

      impl Provisioner for KubernetesProvisioner {
         
      // Implementation for Kubernetes-specific analysis<i>
      }

      容忍損壞的輸入和損壞的配置
      我們設計的系統能夠妥善處理不完美的輸入,讓使用者能夠匯入所有配置 — 即使配置有問題。我們不會要求輸入 100% 有效,而是專注於幫助使用者無縫識別和修復問題。

      擁抱整體式架構
      我們刻意保持簡單,抵制過早拆分成微服務的衝動。整體架構降低了複雜性,確保我們的團隊可以專注於創造價值,而不會被不必要的溝通或基礎設施開銷所困擾。

      最小化資料儲存複雜性
      最初,管理文字嵌入需要專用的向量資料庫,例如 Weaviate 或 Qdrant。如今,向量儲存已成為大多數資料庫的標準功能,我們透過最大限度地減少所依賴的資料儲存數量來簡化我們的架構,從而使事情變得簡單。

      將複雜性轉移到後端
      我們決定這樣做是為了簡化前端,並使其更容易在 API 之上構建其他型別的客戶端。例如,核心驗證功能透過套接字(如 replit)從前端轉移到後端。這種轉變降低了前端的複雜性,並確保驗證邏輯在一個地方維護,從而提高了可維護性和可擴充套件性。

      應對挑戰並做出權衡
      構建一個強大且可擴充套件的系統並非一帆風順,它更像是在飛行途中修理飛機。每個挑戰都有自己的驚喜,我們必須創造性地提出解決方案,同時做出權衡,以免讓我們(再次)後悔自己的人生選擇。

      挑戰 1:LLM 工具鏈整合
      Rust 生態系統缺乏對整合 LLM 工具鏈的強大支援。我們透過為 AI 模型提供商設計一個抽象的與提供商無關的介面來解決這個問題,類似於 LangChain 風格的 API(但更簡單,僅適用於我們的用例)。此外,我們使用 AI 驅動的程式碼生成根據每個提供商的 API 文件快速構建輕量級 SDK,從而加快了這一過程。Rust linter Clippy、Rust 編譯器和一批單元測試使這個過程變得可靠。

      挑戰 2:資料庫模擬
      在 Rust 中測試特徵並不簡單,我們的一些測試需要模擬資料庫的能力。我們構建了一個自定義模擬資料庫(專為測試設計的輕量級實現),使我們能夠靈活地模擬真實的資料庫互動,而無需成熟資料庫的複雜性(讚揚泛型和介面)。

      pub struct MockDatabase {
          responses: RefCell<HashMap<String, Vec<Value>>>,
      }

      impl MockDatabase {
          pub fn new() -> Self {
              let mut responses: HashMap<String, Vec<Value>> = HashMap::new();
              responses.insert(String::from(<font>"query_json"), vec![]);
              responses.insert(String::from(
      "query_single_json"), vec![]);
              responses.insert(String::from(
      "query_required_single_json"), vec![]);
              Self {
                  responses: RefCell::new(responses),
              }
          }

          pub fn add_response(&self, method: DatabaseClientMethod, response: Value) {
              let mut responses = self.responses.borrow_mut();
              responses
                  .get_mut(method.as_str())
                  .expect(
      "Object not found")
                  .push(response);
          }
      }

      impl DatabaseClient for MockDatabase {
          async fn transaction<T, B, F>(&self, mut body: B) -> Result<T, Error>
          where
              B: FnMut(Option<Transaction>) -> F,
              F: Future<Output = Result<T, Error>>,
          {
             ......
          }
          async fn query_json<T: AsRef<str>, U: QueryArgs>(
              &self,
              _query: T,
              _arguments: &U,
          ) -> Result<Json, Error> {
              ......
          }

          async fn query_single_json<T: AsRef<str>, U: QueryArgs>(
              &self,
              _query: T,
              _arguments: &U,
          ) -> Result<Option<Json>, Error> {
              .....
          }

          async fn query_required_single_json<T: AsRef<str>, U: QueryArgs>(
              &self,
              _query: T,
              _arguments: &U,
          ) -> Result<Json, Error> {
              .....
          }
      }


      權衡

      • 雖然 Rust 在文字操作和分析等低階操作方面表現出色,但其與 AI 工具鏈和 LLM 整合的社群支援並不像 Python 或 JavaScript 那麼好,這使得某些任務更具挑戰性。
      • 我們的 CI 管道也因構建和測試時間過長而受到影響,尤其是因為編譯 Rust 程式碼的速度非常非常慢(Go 寵壞了我們)。我們在 Apple Silicon 的構建引數方面遇到了一些問題,這進一步降低了速度。
      • 此外,Rust 的學習難度很高。雖然我們還沒有完全掌握絕地武士的技能,但我們正在取得進步。該語言的設計非常豐富,需要時間和練習才能掌握,但我們正在穩步前進。

      結果和見解
      Stakpak 後端的重寫在效能、可靠性和操作簡便性方面帶來了顯著的改進。以下是變化之處:
      史詩升級

      • 處理速度提高 900 倍:大型程式碼庫現在可以以創紀錄的速度處理,從而減少等待時間並提高開發人員的工作效率。如果您需要更多說服力,只需看看上面發生的神奇事情!
      • 擴充套件平臺支援:無論您是 Team Terraform、OpenTofu 的支持者,還是嘗試新事物,Stakpak 都能滿足您的需求。自遷移以來,我們推出了對 GitHub Actions、Dockerfiles 的支援,並自動處理您需要的一切 — 只需說一聲,我們就能滿足您的需求!
      • 更好的錯誤處理和穩定性:系統可以優雅地處理邊緣情況和損壞的配置,最大限度地減少中斷和停機時間。

       

      相關文章