揭幕无图型设计的神话

本文翻译自《Debunking the Myth of Going Schemaless》一文,原作者为 John Page,他是一名苏格兰人,现在 MongoDB 担任工程师;本篇文章的英文原文版本由 MongoDB 赞助

译者按

在面向有状态(Stateful)应用程序开发的过程中,通常会使用数据库对程序的状态进行持久化存储。与关系型数据库管理系统(RDBMS)使用前需要定义数据库图型(Schema)不同,诸如 MongoDB 在内的一系列数据库是无图型(Schemaless)的。开发者基于无图型数据库设计时,通常会有一些使用误区。本文原文作者从一个概括的角度讨论了这些误区,并提供进行无图型设计时的最佳用例。

前言

各地区的开发者都在拥抱文档型数据库。然而在大多数情况下,是出于错误的缘由。就拿现在对于无图型(schemaless)的炒作来说——几乎太容易将任意构造的 JSON 或者 XML 存入文档型数据库中。但是当你希望去高效地筛选、修改与读取数据时,你可能会让自己失望。

尽管文档型数据库允许你存储数据而无需定义它是什么样的,但当你希望去做比简单地依据键值(keys)来读取数据以外更多的操作时,这些数据的形状(shape)就显得尤为重要。

如果你忽略了图型(schema)设计,而只是简单地将已经存在的文档存储起来,你或许需要的不是一个文档型数据库,而是一个简单的键-值对存储。

文档图型设计 vs 关系型设计

关系型数据库设计为用户提供一个预定义的、持久化且安全的,与数据进行交互的方式。你用来组织数据的方式与这些数据被实际使用的方式无关。这并不对。再说,谁能够预测当不同的需求出现时,不同的用户该用什么样的方式访问已经存在的数据副本呢?

因此,通常的图型设计是基于关系(relationships)已经由数据本身定义,而非数据如何被最终使用——而设计的。这使得数据建模(data modeling)更具可预测性。为模型提供同样的数据集,任何创造图型的、合格的架构师都能够得出同样的结果。但可预测性通常也意味着缺乏灵活性。

文档型设计起源于 19 世纪 60 年代,那个年代同时产生的是面向对象编程(object-oriented programming,OOP)。但经过几十年的发展,计算机才发展到可以欣赏文档型模型的灵活性的地步。

在文档型数据库中,图型的设计是基于数据如何被访问的,而非数据本身。开发者是最能够了解数据是如何被他/她们的应用程序访问的人了。在你并没有想好用户将会以何种方式访问数据的时候,是无法去优化图型的。当然,你也可以在没有决定好用户如何访问数据的情况下就存储这些数据。文档模型允许这样的灵活性。但在这之后,你能够,并且应该去优化这些图型。

更好图型的优势

初学者因为可以直接存储数据而无需预先定义它们而爱上文档型数据库。在不需要任何额外训练的情况下,几乎任何人都可以在不了解文档图型设计的前提下构建可用的应用程序。

然而,在有限服务器硬件条件下如何能够实现更多功能时,了解如何正确地去设计图型就变得关键。在当今云服务提供商按量付费的场景下,就更为重要。文档图型能够通过减少计算、I/O 操作以及用户之间的争用来提高在同等硬件上的性能。

认为文档型数据库缺乏前期强制性图型要求的想法是完全不正确的。

文档型数据库能够像关系型数据库一样,强制要求提供图型。无图型化的设计在文档型数据库中也许很常见,但并不是它们的代名词。现代的文档型数据库也有明确的数据类型、丰富的数据操纵语言(data manipulation language,DML),复杂的基于 B 树的索引、ACID 事务以及数据库内的聚合计算等功能。文档型数据库也与 Postgres、MySQL 以及其他的关系型数据库共享相同的存储引擎基础结构。

而文档型数据库与关系型数据库的真正区别在于能够在原子存储单元中共同定位相关数据,因此单条记录连续地存储在内存或硬盘中,而不是被分解成行并独立存储。

简单地说,在文档型数据库中,一个属性的多个值可以存储于单条记录中。如果一个人有多个电话号码,你就不必使用一张单独的表来存放它们。你也不需要为每一个号码单独定义字段。而可以简单地拥有一个电话号码数组或者号码对象。这就有点像在存储层中,将一张表中的一些列嵌入另一张表一样。

{
  name: "john",
  phones: [ { type: "cell", number: 4475566218},
            { type: "cell", number: 4479927716},
            { type: "voip", number: 17035551234}]
}

多年来,这种将数据放在一起以减少 I/O 的想法一直是数据库实现的基础原则。在关系型数据库中,数据库管理员使用“索引组织存储”的概念来共同定位要预期同时访问的行。但这是事后进行的。而且重要的是,它不允许将来自不同表的数据共同定位以减少读取数据的成本。

文档型是如何减少计算与 I/O 的

当对数据库做查询时,实际上是在过滤这些数据的一个子集,然后直接以原始形式返回它们,或者以某种形式进行计算汇总。如果你希望得到的数据在单个行,且对应查询从单个表来获取它的话,那么关系型数据库或者列存储可能会更有效。

另一方面,当你需要将不同的表连接在一起以执行查询时,查询多个索引以及合并结果的额外计算工作会抵消该优势。你需要访问的每一个额外的列,都会增加多余的 I/O 操作。I/O 操作很慢。加速它的成本很高。

在图型设计良好的文档型数据库中,由于单个文档包含了整个一条业务记录——需要读取和过滤的所有数据将会通过单个 I/O 操作来最小化所需的计算量。这样能够使查询以及读取数据快得多。

对于无法放入单条记录中的任何内容,你将需要多种不同的记录类型,并且可能需要查询相关组。幸运的是,成熟的文档型数据库也提供了一种或多种连接选项,尽管它们应当被谨慎使用。

文档型如何减少争用

数据库必须允许多个用户或进程编辑同一条记录,而不会覆盖彼此的更改。我们不能出现这样的情况,即两个用户正在修改相同的数据,而最后一个写入的用户会覆盖另一个用户的更改。如果你处理过 git 的冲突,你应该懂得理清这些更改的重要性。与尝试合并冲突相比,一致性的数据需要更好的方式来处理。

在数据库中的解决方式是通过锁(locking),这需要以下内容:

  1. 找到你明确希望修改的一条记录
  2. 为其加锁,使其他用户不能更改它
  3. 验证在查找与加锁之间该记录未发生变更
  4. 执行修改
  5. 为其解锁

对每个更改都执行此操作,能够序列化更改并保持数据正确性。例如,如果两个进程同时在库存中查找最后一个项目并修改,以将其放入购物车,那么只有一个进程应该成功。在上述情况下,对单个记录的所有修改都发生在单个锁中,并且该锁只需要在应用检查和更改所需的时间内持续存在,这通常是几微秒。

在文档型数据库中,对单条记录的编辑时,只有一篇文档需要被更新,这是一种非常低的争用操作。应用程序可以同时维持大量同时使用的用户。

这种短暂、低争用的更改操作需要一种能够完全在服务器端执行相关更新的丰富查询语言。如果你被迫去查询记录,在客户端进行更改,然后将它们发送回服务器,这就意味着分别创建和释放了一个时间很长的锁,或者承担被覆盖的风险。来自客户端的两次数据库调用之间的时间是产生争用的地方,有时这个问题会变得很严重。

通过发送一条指令去将某一个字段更新成特定值的这种操作是最简单不过的了。然而文档型数据库需要支持远比上述操作逻辑复杂得多的修改。想象一下你正在为一个分数排名表建模,将前五个分数存储为单个文档以提高检索速度。你可能有以下内容:

{ 
 game: "super_kong",
 highscores: [{ name: "joe", score: 118231},
              { name: "amy", score: 75651},
              { name: "chloe", score: 62352},
              { name: "bryan", score: 54524}, 
              { name: "dwayne", score: 41654}]
}

你需要做的事情只不过是向服务器发送一条指令,告诉它“如果分数表里包含一个小于 X 的分数,那么在一条指令中,把 X 加入到分数表中,按照分数排序分数表,然后只保留分数表的最高五个分数。”MongoDB 支持这样的富查询功能。

所以,当需要同时更新多条零散项以更新记录时,会发生什么呢?这对关系型数据库来说太常见了,对记录的编辑操作意味着不止一行需要被更改。

解决方案是 ACID 事务。ACID 事务将要更改的每一个项目上锁。在整个事务被提交时将它们全部解锁。这通常是在多次调用服务器之后,意味着文档会被实际锁定更长的时间,这包括网络和客户端时间。这是在高吞吐量的关系型数据库用例中导致性能和争用问题的典型原因。

在相同场景下,你可以使用文档型数据库来收尾。不过设计良好的文档图型能够避免这种问题的发生。并且如果你确实必须编辑多个文档,像 MongoDB 这样的文档型数据库也提供了相当于 ACID 事务里的 BEGIN TRANSACTION COMMIT 语法。由于这种方式也会引发像关系型数据库里的争用问题,所以最好的方法是创建一个图型,来避免更改多个文档,或者提供一个能够完成编辑但不会引发数据库争用的方式。

文档图型设计中的权衡

对于图型设计来说,没有最完美的答案。文档模型假设比起写入来说,有更多的读取。这通常会多很多。通过一次更新刻意写入很多数据是对于优化读取速度来说的一个很好的方法。除非系统只是简单地记录或者审计数据,这样可以安全地假设写入的每一位数据将会读取一遍或者可能多于一遍。

在文档型设计中,可以对域表(domain tables)进行非规范化,或者拷贝同一份数据的多个副本。如果你有一个很少被更改的国家/地区列表,那么存储国家/地区名称而非域表的键,并且能够处理国家/地区名称的更改的话,就会是一种可以被接受的权衡。

对于其他类型的记录来说,很可能存在数据的最终副本。比如拿一条客户记录来说,每个客户通常对应一个文档。但为了提高阅读速度,某些字段可能会在写入时复制到其他文档中。

举个例子,你也许会想将客户的名称、地址以及唯一识别符复制到它们的每张收据记录中。如果客户修改了他/她的姓名或地址,你可以修改这些发票记录。

文档型数据库同样鼓励你思考幂等性、可重复的操作以及在发生错误时,始终前滚(roll forward)而非回退。想象一下我们需要给每个人加薪 10%。我们可以将其包装到一个事务中,但这会锁定我们的员工表,取决于这个操作需要多长时间。有可能是几个小时。

变通的办法是,我们可以要求数据库仅对于每个人的薪水进行增加,同时作为更改的一部分,添加一个名为 got_payraise 的新的临时字段。如果在执行部分时失败,那么可以再试一次。但是此时,就只应该将那些没有 got_payraise 字段的文档进行加薪,这样就不会有人被加薪两次,重复这样的操作直到所有人都被加薪。这时,我们就可以删除所有 got_payraise 字段了。这样的模型避免了几乎所有的争用操作,并且没有不对某人加薪,或者不知道哪些人已经被加薪过的问题。这就是图型设计真正灵活的,有帮助的部分。

步入实践

不像关系型数据库,文档型数据库要求设计者或开发者思考有关正确性、争用和性能的问题。但是话说回来,它给了你更好的性能和操纵。不像关系型世界中一个人为数据建模,其他人在其上构建程序,而后数据库管理员再尝试将它优化一样,文档型数据库将开发者置于创造更好的数据库的前沿和中心。从关系型数据库进行迁移时,这可能需要组织或者流程上的调整。

当你理解这些基础概念后——你心里就应该知道好的图型以及坏的图型都是什么样的了——然后你就可以从多种选项中进行选择,并且懂得什么会发生,而什么不会发生。在教授这些课程多年后,我经常听到有开发人员说他们不知道可以通过文档型数据库中的图型设计来完成这么多事情。