- 背景:描述软件开发中所谓的越南问题,理解和分类映射器。
- 我们的旅程:我们转型的原因和方式以及经验教训。
- 解决的问题和收获:性能、可扩展性、可维护性和更多云原生代码的改进以及以数据为中心的透明文化。
越南问题的背景与思考
在2010 年左右的DjangoCon上,有人告诉我使用 ORM 就像美国在越南开战一样。该评论指的是Ted Neward在他 2006 年题为“计算机科学的越南”的博文中创造了这个词并将他的比较形式化。
这种比较引起了我的共鸣,但我从未考虑过放弃 ORM。从类比中吸取了完全错误的教训,我花了几年时间寻找完美的 ORM,无论是通过我自己的决策还是其他人的决策,我都接触到了名副其实的环法自行车赛:Python、Active Record、Linq 中的 Django 和 SQLAlchemy, Hibernate、实体框架,以及最近在 Bridge Financial Technology 的 Golang 支持的后端中的 Gorm。
当我们在软件开发中发现痛苦时,倾其所有是很重要的。让它痛到你无法忍受为止。疼痛越严重,您就越能更好地描述和确定疼痛的来源。我们的痛苦让我重新评估了所谓的越南问题,并质疑 ORM 是否有意义。在对面向对象和关系世界之间的不匹配进行全面分析之前,我不会去。为此,我建议阅读 Ted Neward 的永恒帖子。但我将总结重要的要点。
越南:对象-关系不匹配
我首先以大多数历史学家今天所看到的方式过度简化了越南战争。越南人正在打一场内战以统一他们的国家。美国与苏联和共产主义本身进行了代理人战争,以防止其蔓延。也就是说,越南人将其视为南北问题,而美国则将其视为东/西问题。
整件事都不值得。越南今天是一个统一的共产主义国家(越共得到了它想要的东西),而没有目睹共产主义学说在亚洲以外的中国传播,中国于 1949 年成为共产主义国家(美国得到了它想要的东西)。然而,它跨越了近 15 年,3 届总统政府和超过 100 万人伤亡(横跨美国、越南北部和南部、双方的盟友以及军事和文职人员)。从表面上看,更好的策略是完全脱离接触。
转向软件开发:对象和关系同样是根本不同的东西。他们来自不同的地方,有着不同的目标,没有错也没有坏处。当然,技术可以在这些世界中进行堆栈排名,使用您的团队知道、尊重和信任的堆栈,您将获得更多里程。对我们来说,我们相信 Postgres 是关系数据库(比 MySQL 或 SQL Server 更好)的同类最佳实现。同样,我们喜欢并欣赏 Go,特别是它没有通过继承实现面向对象的设计目标。如果您在数据库或编程语言方面遇到困难,我建议您先解决该问题,然后再解决交叉路口的问题,或者至少是我们的轨迹,我们对结果感到满意。充实下面的类比是对这些“方面”的概述:
面向对象原则
面向对象的系统旨在提供:
- 身份管理:将状态的等价性与对象本身分开。当两个对象的值相同时发生对象等价,而当两个对象相同时发生身份等价,也就是说,它们都指向内存中的相同位置。
- 状态管理:将几个原语关联成一个更大的捆绑包的能力,该捆绑包代表有关世界或问题的某些东西。
- 行为:操作所述状态的操作集合。
- 封装:将对象的简化的、导出的表面区域定义到系统的其余部分的能力,从而隐藏不必要的细节。
- 多态性:同质处理不同对象的能力,尽管它们是不同的东西,但它们可以以相似的方式做出反应。
大多数编程语言通过继承模型来实现这些原则。但值得注意的是,许多非常成功的语言确实是没有继承的面向对象的:特别是 Go、Erlang 和 Rust。
关系原则
关系存储引擎旨在规范化数据并记录世界的事实。SQL 提供操作以与围绕集合论建立的数据进行交互,以确保数学正确性并在数据可变性期间实现属性;即ACID 合规性。在事务中执行的 SQL 操作是原子的、一致的、隔离的和持久的。规范化通常是通过适当的设计来实现的,黄金标准是第三正常或 Boyce-Codd 形式。
(一些)差异
这些是完全不同的系统。他们的一些区别包括:
- 对象对聚合状态有意义,而关系试图将其分割成多个表。
- 对象世界中的数据可变性问题围绕并发系统中的意外覆盖问题,并通过封装进行保护。关系世界中的可变性需要交易来保证状态改变时的正确性。在数据被持久化之前,不太需要在改变对象状态时获得正确性。
- 存在对象集合以实现跨这些对象的行为,而存在关系集合以建立关于世界的事实。
概念问题
以下是由这些差异引起的问题列表。
映射问题。由于以下问题,使用任何映射器都很难将表映射到对象:
- 对象关系将通过将一个对象与另一个对象组合来表示。但是,相关对象依赖JOIN于 SQL 中的隐式操作,否则相关对象将不会被数据初始化。
- 关系引擎中的多对多表涉及 3 个表:两个数据源和第三个连接表。然而,这种关系只需要两个对象以及另一个对象的列表。
如果您的编程语言支持继承,那么以这种方式对对象建模可能很诱人,但数据库中没有 IS-A 类型的关系。
谁/什么拥有架构定义?编程语言,以及应用程序开发人员,还是数据库的 DDL,以及 DBA?即使您在组织中没有区分这些角色,您仍然会遇到这个问题,即所谓的“O”受制于“R”,反之亦然。
元数据去哪儿了?大多数字段自然会有 1:1 的对应关系。例如,整数、布尔值等将在数据库和您的编程语言中具有众所周知的类型。但是枚举呢?很可能这些是带有有限数量选项的字符串或整数,并且数据库和编程语言都可以强制执行约束。那么你把这些元数据放在哪里呢?应用程序?数据库?两个都?
所有这些问题都需要通过将一方(对象或关系)声明为权威来解决。这导致了 ORM 的分类——它对这个权威有什么看法?对象还是关系?
理解和分类 ORM
实际上,您不能简单地“退出”并摆脱问题。这篇文章的有点营销动机。毕竟,您不会将对象持久保存到平面文件中并将其称为“数据库”,也不会使用 SQL 构建应用程序。这些事情必须在某个时候相遇。但我认为,许多 ORM 采用的通用方法是有限的,主要是出于方便,通常以牺牲一方为代价。
从广义上讲,有两种类型的 ORM:代码优先和数据库优先。
- 在代码优先方法中,您将对象模型定义定义为将映射到数据库实体(一个或多个表)的类或类型。这种方法即时生成 SQL 并大量使用反射。Go 社区中的一个例子是gorm。
- 数据库优先方法通常依赖于从您的数据库定义语言 (DDL) 生成对象定义代码。Golang 示例是SQLBoiler。
您可能会对其中之一产生直观和直觉的反应。这种观点可以顽固地持有。就个人而言,我总是优先考虑对象而不是关系,并使用代码优先的 ORM。但最终我将想法转变为优先考虑数据库。该决定通过生成用于与数据库交互的代码,促使将 ORM 全部丢弃。
我们放弃 ORM 的旅程
以上几点都是理论上的。我们的旅程从我们从 ORM Gorm 开始感受到的实际痛苦和问题开始。
问题 1:封装的 API
更新数据库中的一些记录是一个早期的脸面时刻。执行此操作的 SQL 是:
UPDATE <table>
SET <values>
WHERE <conditions>
但是 Gorm API 改变了接受值和条件的顺序。
// 预期
db.Model(<table>).Where(<conditions>).Updates(<values>)// 更新整个表,忽略条件
db.Model(<table>).Updates(<values>).Where(<conditions>)
我们经历了一个艰难的过程,即犯错会带来灾难性的后果。ORM 的 API 是可链接的,但它并不完全是惰性的。某些声明正在敲定,包括更新。如果您颠倒顺序,更新将应用,但 Where 条件不生效,这意味着您已更新模型中的所有内容。
现在你可以争辩说 SQL 有这种倒退,而 ORM 只是对 API 的真正含义进行了更正。你可以说我们的团队应该更清楚。或者 API 可以有更好的文档记录。无论争论如何,结果都是一样的:我们度过了糟糕的一天。更广泛的观点是:ORM 实际上是 SQL 的包装器。
在我职业生涯的早期,一位工程经理告诉我要对包装器持怀疑态度,因为它们只会给工作量增加认知负担。我反驳说,根据合乎逻辑的结论,他会用汇编编写所有代码。
两个极端都不正确:抽象是关于实现平衡的行为。但随着我职业生涯的发展,我将 ORM 置于不必要的包装阵营中。为什么我们要处理一个与几乎所有开发人员都接受过培训的超级稳定和众所周知的 ANSI SQL API 相反的中间件?每个开发人员都知道如何使用 SQL 更新表中的记录(或者可以轻松地通过 Google 搜索),并且在出错时可能会出现错误。不是每个开发者,实际上是极少数的开发者,都知道 Gorm 的抽象。对于您使用的任何 ORM,(在新员工中)也是如此;您将不断地在堆栈的关键任务部分培训人员。
问题2:性能和过多的内存消耗
我们的后端在内存容量有限的无服务器堆栈 (AWS Fargate) 上运行。随着时间的推移,我们不得不不断增加实例的内存容量,最终达到最大值,然后看着我们的容器死亡。随着数据量的增长,我们看到容器的数量以某种线性方式增长。人们会希望后端改为亚线性扩展。
ORM 是一个自然的罪魁祸首:很容易看出许多 ORM 将使用对象自省来构建 SQL 语句、水合结果或两者兼而有之。Gorm 的内存占用是极端的,但遗憾的是并不少见。Bridge 在后端使用 Python 开始,我们使用 Django 的 ORM 与存在类似问题的数据库进行交互。
直到我们最终将其从堆栈中删除以给我们一个比较点时,我们才意识到问题的严重程度。详细信息包含在下一节中,但作为预览:我们将执行性能提高了约 2 倍,并将内存占用减少了近 10 倍。
问题 3:了解我们的 I/O 配置文件
随着时间的推移,我们注意到自己使用数据库日志工具来了解我们自己的用例,并认为这是不匹配的。我们在 AWS 上设置了RDS Performance Insights并pganalyze来识别数据库中的瓶颈。这些工具很早就证明了它们的价值,我们最终使用它们来了解我们与数据库交互的方式。我们是否过度获取列?我们是否正在运行未索引的查询?
当然,这些问题都有已知的、确定的答案。事实上,我们需要一个外部工具来阐明这个问题,这是代码中一个明显的结构缺陷。对我来说,潜在的问题是 ORM 让与数据库的交互变得太容易了。代码没有集中或模块化到代码库中的中间件层。相反,它自始至终都被意大利面条化了。了解我们的数据库交互性需要对与业务逻辑有关的内容进行广泛的代码审计和审查,而不是读写。
备择方案
使用 ORM 的替代方案似乎相当有限:使用低级数据库驱动程序,在运行时构建 SQL 查询,然后自己将结果映射回对象。当然,ORM 以自动化的方式完成所有这些事情,因此走这条路线将对可维护性做出巨大牺牲。我们的团队得出结论(相当容易且没有太多决策),无论这里有什么好处,成本都太高了,无法考虑。
然而,还有第三条路线:使用代码生成器来自动化这些步骤。我们将 Go 社区中的项目分为两条线:
- 在运行时生成代码的 SQL(例如:squirrel)
- 在编译时生成应用程序代码(示例:jet,sqlc)
生成 SQL 代码是一个有趣的想法,与生成应用程序代码相比,它需要更少的工具和承诺。然而,我们认为这将是我们代码可维护性的横向移动。SQL 生成将需要字符串插值,这意味着在应用数据库迁移时审核代码,这是我们希望结束的一个劳动密集型且耗费精力的过程。
婴儿在洗澡水里?
我们想了很久,想着要不要把婴儿和洗澡水一起倒掉。也许问题不在于 ORM 本身,而在于代码优先子集。在 Go 社区中, sqlboiler是一个有趣的项目,它从您的 DDL 生成模型定义。
我们决定不使用这个项目,原因如下:
- 有太多的代码生成这样的事情。生成的代码需要灵活的配置来控制输出,这是一条好走的路。一方面,您不希望交换代码以进行配置,并在代码库中放置大量的 yaml 或 toml 文件,这些文件需要自己的一组维护问题。在另一个极端,如果您想要控制或自定义配置中未公开的生成代码的某些内容,那么您就不走运了。
- Sqlboiler 很大程度上受到 Active Record 的启发,我们觉得它过于抽象了数据库。我们试图拥抱数据库,因为从文化上讲,我们是一个以数据为中心的组织,希望我们的数据库在我们的应用程序和 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("English")).
AND(Category.Name.NOT_EQ(String("Action" ))).
AND(Film.Length.GT(Int(180))),
).ORDER_BY(
Actor.ActorID.ASC(),
Film.FilmID.ASC(),
)var dest []struct {
model.Actor 电影 []struct {
model.Film 语言模型.语言
类别 []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 驱动的数据交互层,红色表示我们当前使用 Gorm ORM 的延迟。越低越好。
在没有 ORM 的情况下,运行时性能提高了 52%
在我们的工作负载中,我们正在享受大约 2 倍的执行性能加速。令人高兴的是,当工作负载获取更多数据时,这个数字往往会更高。
可扩展性是指消耗尽可能少的内存,这对我们来说尤其重要,因为我们在无服务器后端 (AWS Fargate) 上运行所有工作负载,因此我们更适合横向扩展而不是向上扩展。我们在每个实例上使用的内存越少,意味着需要上线的实例就越少才能达到结果,这意味着成本更低,整体使用率更高。换句话说,如果您需要的实例数量是当前使用数量的一半(预算内),您应该能够处理双倍的数据量,而无需与您的 CFO 交谈。
没有 ORM 的内存消耗减少了 78%
我们平均减少了 78% 的内存消耗。现在你可能会争辩说,也许 Gorm 在这里做的事情效率太低了,而其他 ORM 可能会更好,但从根本上说,大多数映射器都需要类型自省,这将导致糟糕的内存配置文件。
这两项改进都是由每个操作需要发生的分配数量减少驱动的,我们已经将其基准为另一个 80% 的下降:
在没有 ORM 的情况下,每个操作的分配量减少了 80%
代码可维护性
我认为所有与数据层交互的代码都是中间件。当然,如果您使用的是 ORM,您可能没有将这个中间件显式地打包到一个包或一组函数中,我认为这会让您的情况变得更糟:中间件仍然存在,但它不是孤立的。相反,数据库交互性在整个代码库中被意大利面条化了。
当我们想要检索、更新、创建或删除数据时,我们会调用为我们执行此操作的函数:
q.GetAccounts(ctx, ids)// 更复杂的查询采用生成的 Params 类型
q.GetAccountsPage(ctx, db.GetAccountsPageParams{ })
我们的端点甚至不这样做;他们通过调用接口抽象出细节:
结果,错误:= fetch.Page(ctx, fetch.PageParams{
Fetcher: accounts.Fetcher{},
})
了解我们的数据 I/O 配置文件
当您拥有 ORM 时,您正在邀请组织中的所有软件开发人员以可能无法解释的方式访问数据库。尽管尽最大努力培训您的团队了解哪些索引可用或设置 DBA 角色,但最终您将拥有无法在没有代码审查的情况下解释的数据库交互代码。这不可避免地导致人们转向数据库日志和监控解决方案,以了解如何访问数据库。这些工具对于任何审查运行时性能和实现 SLA 的流程都是受欢迎的补充,但如果您使用它们来了解如何访问您的数据库,那就太迟了。
我们仍然使用RDS Performance Insights和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 的更多所有权,减少了“越界”的心态。这也许是最好的收获。