30 个月 ORM 实践经验:与其依赖 ORM,不如直接学 SQL!

30 个月 ORM 实践经验:与其依赖 ORM,不如直接学 SQL!

从 ORM 实践中得出:与其依赖 ORM,不如直接学 SQL!

我得出结论,对我而言,对象关系映射(ORM)弊大于利。简单说,它能辅助程序使用 SQL,但不应取代 SQL。

一些背景:过去 30 个月,我处理需与 Postgres 及一定程度上与 SQLite 交互的代码。大部分用 [SQLAlchemy](http://sqlalchemy.org/)(我很喜欢)和 [Hibernate](http://hibernate.org/)(不太喜欢)。我既处理现有代码和数据模型,也设计过自己的。大部分数据是基于事件的存储(“时间线”),且很注重生成报表。

关于对象/关系阻抗不匹配问题论述很多,不亲身经历难体会难处。Neward 在[著名文章](http://blogs.tedneward.com/post/the-vietnam-of-computer-science/)中,详述 ORM 陷入困境的诸多合理原因。以我经验,我直接处理过不少这类问题,包括 _实体标识问题_、_双模式问题_、_数据检索机制问题_ 和 _部分对象问题_。下面我简要谈谈处理这些问题的经历,并补充一个我遇到的问题。

部分对象、属性膨胀和外键

使用 ORM 时,我遇到最具颠覆性的问题或许是“属性膨胀”或“宽表”,即表中属性不断增加。虽我想避免,但有时不得不如此(像 [Postgres 的 hstore](http://www.postgresql.org/docs/9.3/interactive/hstore.html) 功能有帮助)。比如,客户可能提供大量数据,希望按各种业务逻辑附加到报表中。而且,你对这些数据了解少,只是负责处理。

在数据库中,这不是大问题,但用 ORM 就成痛点。具体说,直接用实体创建查询时问题显现。项目初期,可能有类似这样的 Hibernate 查询:

query(Foo.class).add(Restriction.eq("x", value))

当 `Foo` 只有五个属性时,这样查询可能没问题,但有一百个属性时,就成数据洪流,相当于用 `SELECT *`,常返回比预期多的数据。然而,ORM 鼓励这种用法,编写精确投影查询往往和在 SQL 中一样繁琐。(我通过添加适当投影优化这类查询,将运行时间从几分钟缩短到几秒,原来所有时间都花在将数据库行转换为 Java 对象上。)

这又引出另一个糟糕体验:外键滥用。我用的 ORM 中,类之间关联在数据模型中用外键表示,若配置不当,检索对象会导致大量连接操作。(最近我统计工作中的一个表,用首选查询方法访问单个对象时,该表有超 600 个属性和 14 次连接操作。)

属性膨胀和外键过度使用让我明白,要有效使用 ORM,仍需了解 SQL。我对 ORM 的看法是,若需了解 SQL,不如直接用 SQL,这样无需了解非 SQL 代码如何转换为 SQL。

数据检索

用 ORM 编写查询时,了解如何编写 SQL 更重要,尤其考虑效率时。

据我所见,除非数据模型非常简单(即从不进行连接操作),否则会绞尽脑汁让 ORM 生成高效运行的 SQL。多数时候,ORM 生成的 SQL 比直接编写的更难理解。

若选择简化查询,最终会在代码中做很多本可在数据库中更快完成的工作。[窗口函数](https://en.wikipedia.org/wiki/Window_function_%2528SQL%2529#Window_function)是相对高级的 SQL 功能,用 ORM 编写很痛苦。若不在查询中使用,可能意味着从数据库向应用程序传输大量额外数据。

这些情况下,我选择用模板系统编写查询,用 ORM 描述表。这样既能在应用程序层面方便描述表,又能直接用 SQL,比我用过的其他方法省事得多。

双模式的风险

这似乎是不可避免的冗余情况。若试图消除,只会带来更多问题或增加过多复杂性。

问题在于,最终会在两个地方定义数据:数据库和应用程序。若将定义完全放在应用程序中,需用 ORM 代码编写 SQL 数据定义语言(DDL),这和用 ORM 编写高级查询一样复杂。若放在数据库中,为方便和避免过多“字符串输入”,可能希望在应用程序中也有表示。

我更喜欢将数据定义放在数据库中,然后读取到应用程序中。这不能解决问题,但会让问题更易管理。我发现用反射技术获取数据定义不值得,所以只能接受在两个地方管理数据定义的冗余情况。

但该死的数据迁移问题真让人头疼:在应用程序中更改模型没什么,但在数据库中很麻烦。毕竟,数据库是持久的,而应用程序数据不是。在这方面,ORM 只会碍事,因为它们根本无助于管理数据迁移。我的原则是,不应在应用程序中操作数据库的数据定义,而应操作查询结果。也就是说,查询是你与数据库交互的 API。所以,我不再考虑对象,而是考虑具有返回类型的函数。

因此,人们不禁要问,除方便查询外,是否还有必要使用 ORM 呢?

标识问题

使用 ORM 时,处理实体标识是必须时刻牢记的问题之一,这迫使为两个系统编写代码,却只能使用其中一个系统的表达能力。

使用外键时,会用一个标识符引用相关实体的标识。在应用程序中,“标识符”有多种含义,但通常指内存地址(指针)。而在数据库中,它指对象本身的状态。这两者不兼容,因为实际上只能在数据库(所处理数据的最终存储地)中使用数据库标识符。

结果就是,不得不手动刷新缓存或进行部分提交,以操作 ORM 来获取数据库标识符。

我甚至不能称这为抽象泄漏,因为“泄漏”这个词意味着相对于源内容,只有少量内容泄漏出来。

事务处理

Neward 提到,开发者需要处理事务。事务是动态作用域的,这是强大但在编程语言中大多被忽视的概念,因为过度使用会导致混淆。这会导致大量带有异常处理程序的样板代码,并且需要仔细考虑事务边界的位置。此外,还得将会话对象传递给任何可能需要与数据库通信的函数或方法。

由于事务依赖于基于时间的上下文,因此将其概念应用到应用程序中效果不佳。如前所述,动态作用域是在程序中使用事务的一种方式,但它与占主导地位的词法作用域范式相冲突。因此,在编写与数据库交互的代码时,必须格外注意事务的“时机”,这可能会使模块化变得棘手(“这是一个有用的函数,但只能在特定上下文中使用”)。

未来方向

目前,我开始质疑完全拒绝 [存储过程](http://c2.com/cgi/wiki?StoredProcedures) 是否明智。这听起来可能有些离经叛道(参考 [存储过程是邪恶的](http://c2.com/cgi/wiki?StoredProceduresAreEvil)),但它可能适合我的使用场景。(而且,随着“DevOps”的出现,开发者和数据库管理员之间的界限基本已经消失了。)

我发现自己开始将数据库视为另一种具有 API(即查询)的数据类型。查询返回某种类型的值,在程序中用对象表示。不再将应用程序中的对象视为要存储在数据库中的东西(这是 ORM 的初衷),而是将数据库视为一种(庞大而复杂的)数据类型,我发现从应用程序与数据库交互变得简单多了,也后悔自己为什么没有早点意识到这一点。

(需要明确的是,我并不是说所有应用程序都应该这样处理数据库。我只是说,根据我处理的数据,这种方式适合我的使用场景。)

无论我最终发现存储过程是否真的不那么糟糕,还是继续使用模板化的 SQL,有一点我很清楚:我不会再陷入“ORM 让一切变得简单”的陷阱。ORM 可以作为一种可接受的数据定义表示方式,但不适合编写查询,也不适合存储对象状态。如果你使用的是关系型数据库管理系统(RDBMS),那就咬紧牙关,学习 SQL 吧。

2014 年 8 月 3 日

[comment@wozniak.ca](mailto:comment@wozniak.ca)

生成于 2022 年 1 月 2 日