我们最近决定在BridgeFinancialTechnology放弃我们的对象关系映射器(ORM),并且一直很喜欢这个决定。放弃你的ORM需要挑战和承诺,但好处是实实在在的。我将介绍:背景:描述软件开发中所谓的越南问题,理解和分类映射器。我们的旅程:我们转型的原因和方式以及经验教训。解决的问题和要点:性能、可扩展性、可维护性和更多云原生代码的改进以及以数据为中心的透明文化。越南问题的背景和思考在2010年左右的DjangoCon上,我被告知使用ORM就像美国在越南开战一样。该评论指的是TedNeward在他2006年题为“计算机科学中的越南”的博客文章中创造了这个词并正式化了他的比较。这种比较引起了我的共鸣,但我从未考虑过放弃ORM。从类比中吸取了所有错误的教训后,我花了几年时间寻找完美的ORM,无论是通过我自己的决定还是其他人的决定,我遇到了名副其实的环法自行车赛:Linq中的Python、ActiveRecord、Django和SQLAlchemy、Hibernate、EntityFramework,以及最近在BridgeFinancialTechnology的Golang后端中使用的Gorm。当我们在软件开发中发现痛苦时,全力以赴是很重要的。让它痛到你受不了为止。疼痛越严重,您就越能描述和识别疼痛的来源。我们的痛苦让我重新评估所谓的越南问题并质疑ORM是否有意义。在对面向对象世界和关系世界之间的不匹配进行全面分析之前,我不会去。为此,我建议阅读TedNeward的经典文章。但我会总结要点。越南:客体关系的不匹配我从今天大多数历史学家对越南战争的看法过度简化开始。越南人正在进行内战以统一他们的国家。美国与苏联和共产主义本身发动了一场代理人战争,以防止其蔓延。也就是说,越南人将其视为南北问题,而美国将其视为东西方问题。整件事不值得。今天的越南是一个统一的共产主义国家(越共得到了它想要的东西),没有目睹共产主义学说在亚洲以外传播到中国,中国在1949年成为共产主义国家(美国得到了它想要的东西)。然而,它跨越了将近15年、3届总统任期和超过100万人的伤亡(遍及美国、北越和南越、双方的盟友以及军人和文职人员)。从表面上看,更好的策略是完全脱离。转向软件开发:对象和关系也是根本不同的东西。他们来自不同的地方,有着不同的目标,他们没有错也没有错。当然,技术可以在这些世界中提升排名,而您的团队了解、尊重和信任的技术栈会让您获得更多里程。对于我们来说,我们相信Postgres是同类最佳的关系数据库实现(优于MySQL或SQLServer)。同样,我们喜欢并欣赏Go,尤其是它没有通过继承实现面向对象的设计目标。如果您在使用数据库或编程语言时遇到问题,我建议您先解决这个问题,然后再解决交叉点,或者至少是我们的轨迹,我们对结果很满意。充实下面的类比是对这些“方面”的概述:面向对象的原则面向对象的系统旨在提供:身份管理:将状态的等效性与对象本身分开。当两个对象具有相同的值时发生对象等价,当两个对象相同时发生身份等价,即它们都指向内存中的相同位置。状态管理:将几个原语关联到一个更大的束中的能力,该束代表了关于世界或问题的某些东西。行为:操作所描述状态的操作集合。封装:能够为系统的其余部分定义一个对象的简化的、派生的表面区域,隐藏不必要的细节。多态性:能够同质化地对待不同的对象,尽管它们是不同的事物,但它们可以以相似的方式做出反应。大多数编程语言通过继承模型来实现这些原则。但值得注意的是,许多非常成功的语言确实是没有继承的面向对象的:特别是Go、Erlang和Rust。关系原则关系存储引擎旨在规范数据并记录有关世界的事实。SQL提供与围绕集合论构建的数据进行交互的操作,以确保数学正确性并在数据可变期间实现属性;即ACID合规性。在事务中执行的SQL操作是原子的、一致的、隔离的和持久的。归一化通常通过适当的设计来实现,黄金标准是第三范式或Boyce-Codd形式。(一些)差异这些是完全不同的系统。它们的一些差异包括:对象对聚合状态有意义,而关系试图将其拆分为多个表。对象世界中的数据可变性问题围绕着并发系统中的意外覆盖问题,通过封装进行保护。关系世界中的可变性要求交易在状态改变时保证正确性。在数据持久化之前,更改对象状态时不太需要实现正确性。对象集合的存在是为了实现跨这些对象的行为,而关系集合的存在是为了建立关于世界的事实。概念性问题下面列出了由这些差异引起的问题。映射问题。由于以下问题,任何映射器都很难将表映射到对象:对象关系将通过将一个对象与另一个对象组合来表示。但是关联对象依赖于SQL中JOIN的隐式操作,否则关联对象不会被数据初始化。关系引擎中的多对多表涉及3个表:两个数据源和第三个连接表。但是,这种关系只需要两个对象和另一个对象的列表。如果您的编程语言支持继承,那么以这种方式对对象建模可能很诱人,但是数据库中没有IS-A类型的关系。谁/什么拥有模式定义?编程语言和应用程序开发人员,还是数据库的DDL和DBA?即使您不区分组织中的这些角色,您仍然会遇到所谓的“O”受制于“R”的问题,反之亦然。元数据去哪里了?大多数字段自然会有1:1的对应关系。例如,整数、布尔值等在数据库和您的编程语言中都有众所周知的类型。但是枚举呢?这些很可能是具有有限数量选项的字符串或整数,并且数据库和编程语言都可以强制执行约束。那么你把这个元数据放在哪里呢?应用程序?数据库?两个都?所有这些问题都需要通过将一方(对象或关系)声明为权威来解决。这导致了ORM的分类——它对这个权威有什么看法?对象还是关系?理解和分类ORM你不能简单地“退出”并摆脱这个问题。这篇文章的标题有点营销动机。毕竟,您不会将对象保存到平面文件中并将其称为“数据库”,也不会使用SQL构建应用程序。这些东西必须在某个时候相遇。但我认为许多ORM采用的一般方法是有限的,主要是出于方便,通常以牺牲一方为代价。从广义上讲,有两种类型的ORM:代码优先和数据库优先。在代码优先方法中,您将对象模型定义定义为将映射到数据库实体(一个或多个表)的类或类型。这种方法动态生成SQL并大量使用反射。Go社区中的一个例子是gorm。数据库优先的方法通常依赖于从数据库定义语言(DDL)生成对象定义代码。Golang的例子是SQLBoiler。您可能对其中之一有直觉和直觉的反应。这种观点可以顽固地持有。就个人而言,我总是更喜欢对象而不是关系,并使用代码优先的ORM。但最终我改变了主意,优先考虑数据库。这个决定通过生成与数据库交互的代码来完全放弃ORM。OurJourneyAbandingORMs以上几点都是理论上的。我们的旅程始于我们从ORMGorm开始感受到的实际痛苦和问题。问题1:封装的API更新数据库中的一些记录是一个早期的面对时刻。执行此操作的SQL是:UPDATE
SETWHERE但GormAPI更改了接受值和条件的顺序。//预期的db.Model().Where().Updates()//更新整个表,忽略条件db.Model().Updates()。Where()我们已经经历了错误可能会带来灾难性后果的艰难过程。ORM的API是可链接的,但它并不完全是惰性的。某些公告正在定稿,包括更新。如果您颠倒顺序,将应用更新,但Where条件不会生效,这意味着您已更新模型中的所有内容。现在你可能会争辩说SQL有这种回归,而ORM只是对API的真实情况的修正。你可能会争辩说我们的团队应该更清楚。或者可以更好地记录API。无论争论如何,结果都是一样的:我们度过了糟糕的一天。更广泛的观点是:ORM实际上是SQL的包装器。在我职业生涯的早期,一位工程经理告诉我要对包装器持怀疑态度,因为它们只会增加工作量的认知负荷。我反驳说,按照合乎逻辑的结论,他会用汇编编写所有代码。这两个极端都不正确:抽象是为了实现平衡。但是随着我职业生涯的进步,我已经将ORM置于不必要的包装器阵营中。为什么我们要处理与几乎所有开发人员都接受过培训的超级稳定和众所周知的ANSISQLAPI相反的中间件?每个开发人员都知道如何使用SQL更新表中的记录(或者可以轻松地google一下),并且当出现问题时可能会出现错误。不是每个开发人员,事实上很少,都知道Gorm的抽象。对于您使用的任何ORM,情况也是如此(在新员工中);您将不断地就堆栈的关键任务部分对人员进行培训。问题2:性能和内存消耗过多我们的后端运行在内存容量有限的无服务器堆栈(AWSFargate)上。随着时间的推移,我们不得不不断增加实例的内存容量,最终达到最大值,然后眼睁睁地看着我们的容器消亡。随着数据量的增长,我们看到容器的数量以某种线性方式增长。人们会希望后端以次线性方式扩展。ORMs是一个自然的罪魁祸首:很容易看出许多ORMs将使用对象内省来构建SQL语句、hydrate结果或两者。Gorm的内存占用非常大,但遗憾的是并不少见。Bridge开始在后端使用Python,我们使用Django的ORM与存在类似问题的数据库进行交互。直到我们最终将它从堆栈中删除以给我们一个比较点之前,我们才意识到问题的严重性。下一节包含详细信息,但作为预览:我们将执行性能提高了约2倍,并将内存占用减少了近10倍。问题3:了解我们的I/O配置文件随着时间的推移,我们注意到自己使用数据库日志记录工具来了解我们自己的用例,并认为这是不匹配的。我们在AWS上设置RDSPerformanceInsights并进行pganalyze以确定数据库中的瓶颈。这些工具很早就证明了它们的价值,我们最终使用它们来了解我们与数据库交互的方式。我们是否过度获取列?我们正在运行未索引的查询吗?当然,这些问题都有已知的、明确的答案。事实上,我们需要一个外部工具来阐明这一点,这是代码中一个明显的结构缺陷。对我来说,潜在的问题是ORM使得与数据库交互变得太容易了。代码没有集中或模块化到代码库的中间件层。相反,它始终是意大利面条化的。了解我们的数据库交互性需要广泛的代码审计和审查与业务逻辑相关的事情,而不是读写。备选方案使用ORM的备选方案似乎相当有限:使用低级数据库驱动程序,在运行时构建SQL查询,然后自己将结果映射回对象。当然,ORM以自动化方式完成所有这些事情,因此走这条路将在可维护性方面做出巨大牺牲。我们的团队得出结论(很容易并且没有太多决定),无论这里有什么好处,成本都太高了,无法考虑。但是,还有第三条路线:使用代码生成器来自动执行这些步骤。我们将Go社区中的项目分为两行:在运行时生成代码的SQL(例如:squirrel)在编译时生成应用程序代码(例如:jet,sqlc)生成SQL代码是一个有趣的想法,不像生成它需要更少的工具和承诺比应用程序代码。但是,我们认为这将是我们代码可维护性方面的横向举措。SQL生成将需要字符串插值,这意味着在应用数据库迁移时审计代码,这是一个我们希望结束的劳动密集型和能源消耗过程。婴儿在洗澡水里?我们考虑了很久才把婴儿连同洗澡水一起倒掉。也许问题不在于ORM本身,而在于代码优先子集。在Go社区中,sqlboiler是一个有趣的项目,它从您的DDL生成模型定义。我们决定不使用这个项目,原因如下:这样的东西生成的代码太多了。生成的代码需要灵活的配置来控制输出,这是一个很好的方法。一方面,您不希望为配置交换代码并将大量yaml或toml文件放入您的代码库中,这些文件需要自己的一组维护问题。在另一个极端,如果您想控制或自定义未在配置中公开的生成代码的某些内容,那您就不走运了。Sqlboiler深受ActiveRecord的启发,我们觉得它过于抽象了数据库。我们正在努力拥抱数据库,因为在文化上我们是一个以数据为中心的组织,并希望我们的数据库在我们的应用程序和API中更加透明。选择代码生成器我们仔细研究了两个代码生成器:jet和sqlc,最终选择了sqlc。使用jet,您可以在应用程序中将SQL编写为DSL。但是因为它生成代码,所以它比squirrel等运行时SQL生成器提供的功能更进一步。模型和字段是一流的可引用类型,而不是需要字符串插值,这避免了在审计期间要进行更改时需要grep代码。更有吸引力的是,它提供了一种聚合或非规范化数据库中数据的方法。ORM的目标是使关系遍历变得容易,而Jet的目标是以完整且类型良好的结构提供数据包,清楚地宣传其中的可用内容。这是一个示例:stmt:=SELECT(Actor.ActorID,Actor.FirstName,Actor.LastName,Actor.LastUpdate,Film.AllColumns,Language.AllColumns,Category.AllColumns,).FROM(Actor.INNER_JOIN(FilmActor,Actor.ActorID.EQ(FilmActor.ActorID)).INNER_JOIN(Film,Film.FilmID.EQ(FilmActor.FilmID)).INNER_JOIN(Language,Language.LanguageID.EQ(Film.LanguageID)).INNER_JOIN(FilmCategory,FilmCategory.FilmID.EQ(Film.FilmID))).INNER_JOIN(Category,Category.CategoryID.EQ(FilmCategory.CategoryID)),).WHERE(Language.Name.EQ(String("英文")).AND(Category.Name.NOT_EQ(String("Action"))).AND(Film.Length.GT(Int(180))),).ORDER_BY(Actor.ActorID.ASC(),Film.FilmID.ASC(),)vardest[]结构{model.Actormovie[]struct{model.Filmlanguagemodel.languagecategory[]model.Category}}//执行查询并存储结果err=stmt.Query(db,&dest)有很多数据聚合这里。正在构建的应用程序端模型是一个演员,其中包含他们出演过的所有电影、电影使用的语言以及他们所属的类别。我们最初被这个设计所吸引,但在尝试之后感觉不太对劲。在这个例子中,查询驱动应用程序中的数据模型,而不是相反,我们担心这种方法会导致大量丢弃聚合模型。我们的目标是推广可重用模型,其中包含大量业务逻辑和类型化方法中捕获的可变性。此外,我们更喜欢将SQL完全移出代码。这里的问题是任何开发人员都可以随心所欲地查询数据库。虽然这是最初的生产力胜利,但它是以代码的长期可维护性和运行时性能为代价的。如果开发人员在不使用索引的情况下以次优方式查询数据库怎么办?随着数据模型变得更大和更复杂,这种风险会很高,因为它是从SQL中删除的一个步骤。虽然DSL很流行,但我们仍然觉得它最终像一个包装器。答案:sqlc我们决定使用sqlc,一个可配置的可选sql编译器。这种方法引起了我们的共鸣;我们喜欢它不会生成您不需要的东西,并且可以根据我们定义的类型和标签自定义生成的代码。它使代码感觉像我们的代码,同时提供了一个明显的路径来迁移我们当前的实现。我将在以后的文章中详细介绍如何让sqlc为我们工作。删除ORM的好处这个项目是一项艰巨的任务,不仅需要我们的开发人员的承诺,还需要我们的产品团队和整个公司的承诺。我们遇到了功能冻结,同时运行ORM和生成的代码,并且不得不仔细规划我们的迁移和部署路径。所有这一切都发生在资源有限的小型(但正在成长)公司的背景下。考虑到所有这些成本,收益最好是显着的,而且确实如此。其中,我们在后端运行时实现了更好的性能和可扩展性、更好的代码库可维护性、更少依赖数据库日志来了解我们的数据I/O配置文件、更多的云原生实现和后端数据模型对所有人的透明性。我们的开发人员,无论他们是否每天都在堆栈中接近数据库。性能和规模如果您的ORM是动态的,不使用生成的代码或使用泛型类型或接口,那么它可能会在幕后进行某种程度的反射。在我们的例子中,Gorm大量使用反射,因为Go不支持泛型,Gorm没有定义很多接口,除了要求您声明与应用程序模型对应的表名。所以我们期待在这里有很大的收获,但是当我们开始对我们的系统进行基准测试时,我们很高兴地留下了深刻的印象。性能是关于实现低运行时执行。我们通过识别后端中典型的各种工作负载来对结果进行基准测试,这些工作负载要么是因为API正在执行它们,要么是因为离线或批处理过程导致对数据库的大量I/O。在下图中,我们在水平轴上有案例;蓝色代表我们的sqlc驱动的数据交互层,红色代表我们当前使用GormORM的延迟。越低越好。在没有ORM的情况下,运行时性能提高了52%在我们的工作负载中,我们正在享受大约2倍的执行性能加速。令人高兴的是,随着工作负载获取更多数据,这个数字往往会更高。可扩展性是指消耗尽可能少的内存,这对我们来说尤为重要,因为我们在无服务器后端(AWSFargate)上运行所有工作负载,因此我们更适合横向扩展而不是纵向扩展。我们在每个实例上使用的内存越少,意味着需要联机的实例越少才能获得结果,这意味着成本更低,整体利用率更高。换句话说,如果您需要当前使用的实例数量的一半(在预算范围内),您应该能够处理双倍的数据量而无需与您的CFO交谈。在没有ORM的情况下内存消耗减少78%我们将内存消耗平均减少了78%。现在你可能会争辩说,也许Gorm在这里做的事情效率太低,而其他ORM可能会更好,但从根本上说,大多数映射器都需要类型自省,这将导致糟糕的内存配置文件。这两项改进都是由每个操作需要发生的分配数量的减少驱动的,我们已经将其作为另一个80%下降的基准:没有ORM代码的每个操作减少80%的分配可维护性我认为所有与数据层是中间件。当然,如果您使用的是ORM,您可能还没有明确地将这个中间件打包到一个包或一组函数中,我认为这会让事情变得更糟:中间件仍然存在,但它不是孤立的。相反,数据库交互性在整个代码库中都是面条式的。当我们想要检索、更新、创建或删除数据时,我们调用为我们执行此操作的函数:q.GetAccounts(ctx,ids)//更复杂的查询采用生成的参数类型q.GetAccountsPage(ctx,db.GetAccountsPageParams{...})我们的端点甚至不这样做;他们通过调用接口抽象出细节:results,errors:=fetch.Page(ctx,fetch.PageParams{Fetcher:accounts.Fetcher{},...})理解我们的数据I/O配置文件当你有一个ORM,您正在邀请您组织中的所有软件开发人员以可能无法解释的方式访问数据库。尽管您尽了最大努力来培训您的团队使用哪些索引或设置DBA角色,但您最终会得到没有代码审查就无法解释的数据库交互代码。这不可避免地导致人们求助于数据库日志记录和监控解决方案,以了解数据库是如何被访问的。这些工具是审查运行时性能和满足SLA的任何过程的一个受欢迎的补充,但如果您使用它们来了解您的数据库是如何被访问的,那就太晚了。我们仍然使用RDSPerformanceInsights和pganalyze等工具,但我们不再依赖它们来了解一般配置文件,也不再担心我们是否在使用索引。这项工作已转移到我们的中央存储库,它充当我们所有数据库I/O的中间件,我们简称为数据存储库。它不是无流程的,但现在它是一个托管流程。当应用程序开发人员需要新查询时,她需要在数据仓库中打开一个PR,PR会附带一个代码审查,人们可以在其中询问是否正在使用索引或事务。诚然,这样的代码审查标准应该适用于所有存储库,但数据库I/O将只是下游应用程序中的一个点。我们的数据仓库专注于一件事,而且只专注于一件事:托管数据库交互。此外,事后进行代码审计也很容易。DDL和SQL查询都是并行的,因此很容易知道查询是否正确使用了索引。更多云原生实现你的里程可能会有所不同,但我们使用的两个ORM(Gorm和Django)都包装了数据库连接,导致了两个问题。首先,在这两种情况下,包装器对象公开的功能都少于底层驱动程序中可用的功能。随着数据库和驱动程序的更新以满足特定需求,这可能会变得非常令人沮丧。其次,尤其是在Django的情况下,它让我们远离了云原生设计。我们特别努力的一个领域是从我们的Lambda函数中访问数据。像Lambda这样的函数即服务平台希望您将数据库连接定义为全局变量,以便它可以冻结。这个任务用Django基本上是不可能的。虽然我们在Gorm中更容易解决这个问题,但我们在获得我们想要的连接池功能时遇到了其他问题,即使是在云中长期存在的计算层上也是如此。最终,能否实现云原生设计取决于您选择的数据库驱动程序,您需要确保您的ORM支持它。我们很幸运能够使用Postgres,更幸运的是Go社区有一个专门的驱动程序:jackc/pgx。能够在没有ORM的情况下直接使用此驱动程序,使我们在云原生设计中具有更大的灵活性,并能够利用Postgres特定的功能,这些功能通常被其他驱动程序使用,这些驱动程序优先考虑错过广泛的跨数据库支持。数据模型透明度最后,或许也是最重要的是,放弃我们的ORM改变了我们的工程文化,通过提高数据模型的透明度,使我们更加以数据为中心。Bridge是一家数据处理公司。我们为注册投资顾问、企业和其他金融科技平台标准化和丰富财务数据。我们重视数据完整性、准确性和一致性以实现这些目标,除非每个人都认为他们了解数据模型,否则我们无法做到这一点。许多ORM在哲学上是围绕从开发过程中隐藏或抽象数据库而构建的,这最终会导致您的团队专注于“O”并降低“R”的优先级。而“O”被锁定在一个单一的存储库中,他们中的任何一个都可能知道也可能不知道。但是每个人都能理解数据库的结构安排:组织成模式、DDL、E/R图等。对于我们来说,我们的数据库不仅仅是我们读写信息的容器。它是我们思想的一种表达;我们如何简化和模拟行业挑战的复杂性。删除ORM将所有这些细节放在人们脑海中的最前面和中心位置,从而使数据模型和Postgres拥有更多的所有权,并减少“越界”的心态。这可能是最好的收获。