Rust重写后性能提高了900倍


旧堆栈:
  • 数据库:Neo4j
  • API 层:GraphQL
  • 编程语言:JavaScript / TypeScript
  • 后端框架:Node.js
  • GraphQL 服务器:Apollo 服务器
虽然这种架构最初有效,但出现了几个关键问题:
  1. 模式约束和数据库限制
    • Neo4j GraphQL 模式有显著的限制,例如,我们无法强制执行多键唯一约束。
    • 数据库和 API 层之间紧密耦合,例如所有字段默认公开,迫使我们明确标记私有字段并手动执行访问控制限制。
    • 数据层没有抽象化,这意味着我们对数据存储方式的任何更改都会影响整个 API。由于数据库和 API 紧密耦合,即使对数据存储方法进行微小调整也需要更新所有 API 端点,这使得系统更难更改。
    • 我们的图形数据库非常适合表示关系,但它无法处理需要关系约束的业务逻辑的其他方面(例如用户和组织)。我们需要一个多模式数据库,可以处理我们需要存储的所有其他类型的数据。
    • 我们使用 GraphQL 来简化资源图的编辑,但我们很快发现,大多数更新操作过于复杂,需要自定义解析器。这使得 GraphQL 在我们的用例中效率较低。
  • 复杂的数据处理 
    • 我们必须将配置/代码转换为 JSON 才能进行分析,这让一切都变得非常复杂,而且速度非常慢。最重要的是,这种方法并不通用——有些语言无法转换为 JSON(例如 Rego 策略),而额外的转换层只会让速度变慢!
    • 这导致了过于复杂的验证和错误处理机制,维护和扩展都很困难。
  • 性能瓶颈
    • 大规模数据处理效率低下,例如,我们有很多 CPU 密集型任务阻塞了我们的 NodeJS 线程
    • 处理高流量场景时的性能限制,单个会话可能会消耗过多的 CPU 资源,从而导致其他会话延迟或崩溃。
    • API 很容易出现故障,一个错误就可能导致整个系统崩溃。这不仅导致后端不稳定,还增加了维护的复杂性,难以确保一致的性能。
  • 复杂的前端:
    • 我们的前端过于复杂,许多业务逻辑都是通过复杂的状态机和浏览器内的代码处理和验证来处理的。虽然这使得 Stakpak IDE 响应速度非常快,但也使得支持新技术或在 Stakpak API 之上构建其他客户端(如 VSCode 扩展)变得困难,因为我们必须在客户端之间复制这种复杂的逻辑。
    1. 架构复杂性和不灵活性
      • 我们的数据模型和解析工具与 Terraform 紧密相关,因此很难支持其他配置语言。这种缺乏灵活性的状况导致系统僵化,无法轻松适应新技术或许多其他可用的 DevOps 工具。
      • 实现稳健的错误处理的困难
    例如, TypeScript 并不总是能够帮助识别潜在错误,这意味着某些错误没有得到明确处理。此外,隐式错误传播会导致问题,因为错误并不总是正确传递,从而导致整个系统的行为不一致。

    import { GraphQLError } from "graphql";

    async function getFlow(flowId: string): Promise<any> {
     
    // Simulates a database call that might throw an error
      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
        return { success: true, user };
      } catch (e) {
       
    // Return a GraphQL-friendly error
        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>;
        // The rest of the interface methods
    }

    struct TerraformProvisioner;
    struct KubernetesProvisioner;

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

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

    容忍损坏的输入和损坏的配置
    我们设计的系统能够妥善处理不完美的输入,让用户能够导入所有配置 — 即使配置有问题。我们不会要求输入 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("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 的支持,并自动处理您需要的一切 — 只需说一声,我们就能满足您的需求!
    • 更好的错误处理和稳定性:系统可以优雅地处理边缘情况和损坏的配置,最大限度地减少中断和停机时间。