存储与访问一体化:数据库的必经之路

25 阅读16分钟

数据局部性对于数据库性能至关重要。文档数据库通过将相关数据存储在一起,优化了 I/O、内存使用和网络传输。SQL 数据库则侧重数据独立性。MongoDB 等文档数据库允许开发者控制物理存储,而 SQL 数据库的抽象层可能隐藏性能瓶颈。在分布式系统中,局部性对于可扩展性尤为关键。

译自:Why 'Store Together, Access Together' Matters for Your Database

作者:Franck Pachot

当您的应用程序一次需要多个数据片段时,最快的方法是仅在一个位置以一次调用读取它们。在 文档数据库 中,开发人员可以决定逻辑上和物理上将什么数据存储在一起。

碎片化从未对性能有利。在 数据库 中,数据在磁盘、内存或网络上的邻近性对于可扩展性至关重要。将相关数据放在一起,允许单个操作获取所有需要的数据,从而减少磁盘 I/O、内存缓存未命中和网络往返,从而使性能更具可预测性。

“将一起访问的数据存储在一起”的原则是文档数据库建模的核心。然而,其目的是允许开发人员控制物理存储布局,即使数据结构是灵活的。

理解数据局部性的核心原则在当今至关重要,特别是考虑到许多数据库都在模仿文档数据库或在 SQL 之上提供类似的语法。

相比之下,SQL 数据库的设计是为了数据独立性 — 允许用户与独立于数据库管理员管理的物理实现的逻辑模型进行交互。

如今,趋势是打破开发和运维的分离,实现更快的开发周期,而无需协调多个团队或共享模式的复杂性。避免逻辑模型和物理模型的分离可以进一步简化流程。

理解数据局部性的核心原则在当今至关重要,特别是考虑到许多数据库都在模仿文档数据库或在 SQL 之上提供类似的语法。要被视为文档数据库,仅接受具有开发者友好语法的 JSON 文档是不够的。

数据库还必须在存储中保持这些文档的完整性,以便访问它们具有可预测的性能。无论它们是暴露关系型 API 还是文档 API,了解您的目标是数据独立性还是数据局部性都至关重要。

为什么局部性在现代基础设施中仍然很重要

现代硬件在访问分散数据时仍然会受到性能惩罚。硬盘驱动器 (HDD) 突出了局部性的重要性,因为寻道和旋转延迟比传输速度影响更大,尤其是在联机事务处理 (OLTP) 工作负载中。

虽然固态硬盘 (SSD) 消除了机械延迟,但随机写入仍然很昂贵,而云存储由于网络访问存储而增加了延迟。即使是内存访问也并非免疫:在多插槽服务器上,非统一内存访问 (NUMA) 会导致访问时间因数据相对于稍后处理它的 CPU 核心被加载到内存的位置而异。

横向扩展架构进一步增加了复杂性。垂直扩展 — 将所有读写操作保留在具有共享磁盘和内存的单个实例上 — 存在容量限制。大型实例成本高昂,并且对其进行扩展或缩减通常需要停机时间,这对于始终在线的应用程序来说存在风险。

更多节点会增加分布式查询的可能性,其中曾经命中本地内存的操作现在必须通过网络进行,引入不可预测的延迟。数据局部性对于横向扩展数据库变得至关重要。

例如,您可能在黑色星期五需要最大容量的实例,但需要在前期逐步扩展,并在使用量增加时停机。如果没有水平可扩展性,您最终会配置远高于平均负载的容量“以防万一”,就像在本地基础架构中提前几年为偶尔的峰值进行预配一样 — 在云中这可能会非常昂贵。

水平扩展允许在不中断服务的情况下添加或删除节点。但是,更多节点会增加分布式查询的可能性,其中曾经命中本地内存的操作现在必须通过网络进行,引入不可预测的延迟。数据局部性对于横向扩展数据库变得至关重要。

要创建可扩展的数据库应用程序,开发人员应理解存储组织,并优先考虑单文档操作以获得性能关键型事务。在 MongoDB 中针对单个文档的 CRUD 函数(插入、查找、更新、删除)即使在分片部署中也始终由单个节点处理。如果该文档不在内存中,则可以从磁盘读取,只需一次 I/O 操作。修改将应用于内存中的副本,并在异步检查点期间作为单个文档写回,从而避免磁盘碎片。

在 MongoDB 中,WiredTiger 存储引擎将每个文档的字段存储在连续的存储块中,允许开发人员遵循“将一起访问的数据存储在一起”的原则。通过避免跨文档连接,例如查询中的 $lookup 操作,这种设计有助于在内部防止散收操作,从而促进性能一致。这支持了无论文档大小、更新频率或集群规模如何,都具有可预测的性能。

关系型承诺:物理数据独立性

对于使用 NoSQL 数据库 的开发人员来说,我上面解释的内容似乎显而易见:只有一个数据模型 — 领域模型 — 在应用程序中定义,数据库存储的就是该模型。

MongoDB 数据建模研讨会定义数据库模式为描述数据在数据库中如何组织的物理模型。在关系数据库中,逻辑模型通常独立于物理存储模型,无论使用何种数据类型,因为它们服务于不同的目的。

SQL 开发人员通过对象关系映射 (ORM) 工具或手动编码的 SQL 连接器处理关系模型,并将其映射到他们的对象模型。模型和模式被规范化以实现通用性,而不一定是针对特定应用程序访问模式进行优化的。

关系模型的目的是通过提供隐藏物理问题的抽象来为非程序员和普通用户提供在线交互式使用。这包括通过规范化避免数据异常,并启用声明式查询访问而无需过程代码。物理优化,如索引,被认为是实现细节。您不会在 SQL 标准中找到 CREATE INDEX

实际上,SQL 查询规划器根据统计信息选择访问路径。在编写 JOIN 子句时,FROM 子句中的表顺序不应影响结果。SQL 查询规划器会根据成本估算重新排序。数据库至少在理论上保证逻辑一致性,即使在并发用户和内部复制的情况下也是如此。SQL 方法是面向数据库的:规则、约束和事务保证在关系数据库中定义,独立于特定的用例或表大小。

如今,大多数关系数据库都位于应用程序后面。最终用户很少直接与它们交互,分析或数据科学场景除外。应用程序可以强制执行数据完整性并处理代码异常,开发人员可以理解数据结构和算法。尽管如此,关系数据库专家仍然建议将约束、存储过程、事务和连接保留在数据库中。

物理存储保持抽象 — 索引、聚类和分区是管理员级别的概念,而不是应用程序级别的概念,就好像应用程序开发人员是关系数据库早期论文中描述的非程序员普通用户一样。

Codd 的规则仍然适用于 SQL/JSON 文档

由于数据局部性很重要,一些关系数据库拥有在内部强制执行它的机制。例如,Oracle 长期以来支持“聚类表”以将多个列的相关行共置,最近还提供了将 JSON 数据存储为二进制 JSON(OSON,Oracle 的原生二进制 JSON)或分解的关系行(JSON-关系对偶视图)的选择。然而,这些物理属性是使用特定的数据定义语言 (DDL) 在数据库中声明和部署的,并且不对应用程序开发人员公开。这反映了 Codd 的“独立性”规则

  • 规则 8:物理数据独立性
  • 规则 9:逻辑数据独立性
  • 规则 10:完整性独立性
  • 规则 11:分布独立性

规则 8 和 11 直接关系到数据局部性:用户不应该关心数据是否物理上在一起或被分布式。数据库向忽略物理数据模型、访问路径和算法的用户开放。开发人员不知道哪些数据被复制、分片或分布在多个数据中心。

SQL 抽象开始减弱的地方

实际上,没有哪个关系数据库能完美地实现这些规则。性能调整通常需要查看执行计划和物理数据布局。由于两阶段锁定会限制可扩展性,因此很少使用串行化隔离,导致开发人员退回到更弱的隔离级别或显式锁定(SELECT ... FOR UPDATE)。物理共置机制 — 哈希聚类、属性聚类 — 存在,但如果没有精确的访问模式知识,则难以对其进行最佳的大小调整和维护。随着更新可能再次导致碎片化,它们通常需要定期进行数据重组。

规范化模型本质上是与应用程序无关的,因此优化局部性通常意味着破坏数据独立性(反规范化、维护物化视图、接受副本中的过时读取、禁用参照完整性)。通过分片,外键和唯一索引等约束通常无法在分片之间强制执行。必须仔细排序事务以避免长时间等待和死锁。即使有抽象层,应用程序也必须了解某些操作的物理分布。

NoSQL 的转变:针对访问模式进行建模

随着数据量和延迟预期的增长,一种不同的范式出现了:给予开发人员完全的控制权,而不是一个带有例外情况的抽象。

NoSQL 数据库采用面向应用程序的方法:物理模型匹配访问模式,保持完整性和事务范围的责任被推给应用程序。最初,许多 NoSQL 存储将所有责任(包括一致性)委托给开发人员,充当“哑巴”键值或文档存储。大多数缺乏 ACID(原子性、一致性、隔离性和持久性)事务或查询规划器。如果存在二级索引,则需要显式查询它们。

这种 NoSQL 方法与关系数据库世界相反:没有一个共享的、规范化的数据库,而是每个应用程序有许多目的构建的数据存储。这减少了性能和可扩展性方面的意外,但代价是增加了复杂性。

MongoDB 的灵活模式中间道路

MongoDB 通过添加基本的_关系数据库_功能 — 索引、查询规划、多文档 ACID 事务 — 同时保留面向应用程序的文档模型而发展。当您插入一个文档时,它被存储为一个单元。

在 WiredTiger(MongoDB 存储引擎)中,BSON 文档(具有额外数据类型和索引功能的二进制 JSON)以具有可变大小叶页的 B 树形式存储,这使得大文档能够保持连续性,这与许多关系数据库使用的固定大小页面结构不同。这避免了将业务对象拆分到多个块中,并确保对对开发人员而言看起来像单个操作的操作具有一致的延迟。

在应用程序的文档模式下定义的局部性一直流向下层存储,这是关系数据库引擎通常无法比拟的。

MongoDB 中的更新是在内存中应用的。将其作为磁盘上的就地更改提交会导致页面碎片化。相反,WiredTiger 使用协调机制在检查点写入一个全新的版本 — 类似于写时复制文件系统,但具有灵活的块大小。这可能会导致写入放大,但可以保留文档局部性。通过适当大小的实例,这些写入会在后台发生,并且不会影响内存写入延迟。

在应用程序的文档模式下定义的局部性一直流向下层存储,这是关系数据库引擎通常在追求物理数据独立性的目标时无法比拟的。

为什么局部性会改变应用程序性能?

为局部性进行设计可以通过多种方式简化开发和运维:

  • 事务:影响单个聚合(领域驱动设计意义上的)的业务更改变成对单个文档的原子读-修改-写 — 而不是像 BEGINSELECT ... FOR UPDATE、多个更新和 COMMIT 这样的多次往返。
  • 查询和索引:单个文档中的相关数据避免了 SQL 连接和 ORM 延迟/预取映射。单个复合索引可以覆盖跨字段的筛选和投影,而这些字段否则会在单独的表中,从而确保可预测的计划,而没有连接顺序的不确定性。
  • 开发:应用程序中的同一领域模型直接用作数据库模式。开发人员无需映射到单独的模型即可理解访问模式,从而使延迟和计划稳定性可预测。
  • 可扩展性:大多数针对单个聚合的操作,如果分片键选择得当,都可以路由到单个节点,从而避免了关键用例的散收扇出。
  • MongoDB 的乐观并发控制避免了锁,尽管它需要在写入冲突错误时进行重试逻辑。对于单文档调用,重试由数据库透明地处理,数据库对事务意图有完整的视图,使其更简单、更快。

文档数据建模中的嵌入与引用

局部性并不意味着“嵌入一切”。它的意思是:嵌入那些您经常一起访问的内容。有界的一对多关系(例如订单及其明细行)是嵌入的候选者。很少更新的引用和维度也可以复制和嵌入。高基数或无限增长的关系,或独立更新的实体,最好表示为单独的文档,并且可以通过分片键进行共置。

MongoDB 的复合索引和多键索引支持嵌入字段,从而在无需连接的情况下保持可预测、选择性的访问。在同一文档内嵌入是在块级别保证共置的唯一方法。单个集合中的多个文档不会存储在一起,除非是同时插入的小文档,因为它们可能共享同一个块。在分片中,分片键确保在同一节点上共置,但在同一个块内不一定。

在 MongoDB 中,局部性是领域驱动设计中明确的设计选择:

  • 识别一起更改和一起读取的聚合。
  • 在适当的时候将它们存储在一个文档中。
  • 使用与访问路径对齐的索引。
  • 选择分片键,以便相关操作路由到单个节点。

MongoDB 模拟在局部性方面的不足之处

鉴于文档模型的受欢迎程度,一些云服务在 SQL 数据库之上提供了类似 MongoDB 的 API。这些系统可能会暴露类似 MongoDB 的 API,同时保留关系存储模型,而该模型通常不保持同等水平的物理局部性。

关系数据库将行存储在固定大小的块中(通常是 8 KB)。大文档必须跨多个块进行拆分。以下是一些流行 SQL 数据库中的示例:

  • PostgreSQL JSONB:将 JSON 存储在堆表和许多块的大文档中,使用 TOAST,即超大属性存储技术。文档被压缩并拆分成存储在另一个表中的块,通过索引访问。读取大文档就像行与其 TOAST 表之间的嵌套循环连接。
  • Oracle JSON-Relational Duality Views:将 JSON 文档映射到关系表,从而保持数据独立性而不是物理局部性。一起访问的元素可能分散在各个块中,在分布式设置中需要内部连接、多次 I/O 以及可能的网络调用。

在这两种情况下,文档都被分成二进制块或规范化表。虽然 API 类似于 MongoDB,但它仍然是一个缺乏数据局部性的 SQL 数据库。相反,它提供了一个抽象层,让开发人员在检查执行计划并理解数据库内部机制之前,对内部过程一无所知。

结论

“将一起访问的数据存储在一起”反映了分片、I/O 模式、事务和内存缓存效率方面的现实。关系数据库引擎将物理布局抽象化,这对于服务单个单体服务器中多个应用程序的集中式、规范化数据库来说效果很好。在更大的规模下,尤其是在弹性云环境中,水平分片至关重要 — 并且通常与纯粹的数据独立性不兼容。开发人员必须考虑局部性。

在 SQL 数据库中,这意味着反规范化、复制引用数据以及避免跨分片约束。文档模型(当数据库真正将局部性强制执行到存储层时)提供了这种抽象和例外的替代方案。

在 MongoDB 中,局部性可以在应用程序级别明确定义,同时仍然提供索引、查询规划和事务功能。在评估关系引擎上的“MongoDB 兼容”系统时,确定引擎是否在磁盘上连续存储聚合并将它们设计为路由到单个节点很有帮助。如果不是,则性能特征可能与保持物理局部性的文档数据库有所不同。

这两种方法都是有效的。在“数据库优先”的部署中,开发人员依赖于数据库内部的声明来确保性能,并与数据库管理员合作,使用执行计划等工具进行故障排除。相比之下,“应用程序优先”的部署将更多责任转移给开发人员,他们必须验证应用程序的功能及其性能。