领先技术

文档、数据库和最终一致性

Dino Esposito

Dino Esposito假设您过去几年都被困在山洞里,没有 Internet 连接。 现在您回到现实世界中,并分配给您一个全新的项目。 在第一次技术会议上,您倾听观点明确的团队成员之间进行的关于文档数据库的热烈讨论。

文档数据库到底是什么呢? 我们过去不是常常将数据存储在普通旧关系 SQL Server 实例中吗? 文档数据库可以带来哪些益处? 您努力听取各种意见,试图理解整件事情。 您想知道如何现实地回答是否应该为下一个项目使用文档数据库,而不是关系存储。

对于通常所说的文档数据库和 NoSQL 存储,本文不打算表明明确的立场。 但是,我希望持怀疑的观点看待一些人,他们总是准备探索更好的做事方式并且总是在寻找具体的、可衡量的效益但并非不惜一切代价。

超越 SQL

关系模型和结构化查询语言 (SQL) 出现已有 40 多年了。 对这个瞬息万变的行业而言,这是一个令人难以置信的时间。 在过去的几十年里,在面向对象的语言和模型模式的浪潮中,关系模型已经抵挡住了面向对象的数据库似乎准备取代旧式关系模型的攻击。

不过,这从来没有发生过。 确实发生的是对象关系映射 (ORM) 工具的出现。 在 .NET 空间中,NHibernate 最初作为业界标准出现,最近则更相当于实体框架。 还有由各种供应商和参与者提供的其他相似的商业和开放源框架。 因此,第一点是对象建模并不是一个能将关系模型逼上绝路的足够充分的理由。

然而,更多的公司正在使用 NoSQL 存储。 要问的一个好问题是:“在哪里使用 NoSQL 存储?”这是一个比“我该如何利用 NoSQL 存储?”更好的问题。检查实际技术使用案例以查看它们是否符合您自己要求,而不是盲目为使用给定技术找理由。 如果您使用 NoSQL 存储浏览上下文,您会发现以下常见方面:

  • 大量(通常是不可预见的大容量)数据,可能数以百万计的用户
  • 每秒成千上万次查询
  • 非结构化/半结构化数据可能以不同的形式存在,但仍需要进行相同的处理(多态数据)
  • 实现最大程度可伸缩性的云计算和虚拟硬件

如果您的项目不符合这些条件,则很难指望获得 NoSQL 特别奖励。 在这些条件以外使用 NoSQL 最终可能只是以不同的方式做着同样的事情。

结构差异

构建可推送大量写入和读取到数据库服务器的高度交互应用程序的需求非常适合 NoSQL。 在关系模型中,只要涉及的所有表都有固定架构,在表之间建立的关系可以很好地工作。 所有涉及的表还必须提供对域模型的忠实和实际的表示。

如果您的数据(无论大小和访问频率)非常适合“结构化表”,那么您就可以合理地确定不错的旧 SQL Server 将有效地工作。 经典 SQL 技术还远未终结,并随着时间的推移得到改进。 SQL Server 2014 中的列存储功能帮助您处理成百上千的列。 成百上千的列可能会影响查询性能。 大量的列有时也是尝试映射半结构化数据到关系模型的结果。

列存储是具有常用行和列组的普通表,按物理布局并按列存储的内容除外。 因此,任何给定列中的所有数据都存储于同一个 SQL 物理页面上。 如果您需要从选择的大量列中查询所有数据,这个新的存储形式可以提供显著的性能提升,而无需重新架构持久层。

越来越多的当前应用程序可处理更加错综复杂的几乎不适用于结构化表的数据模型。 因此,应用程序架构师想知道在严格 SQL 架构的界限内,这种部分结构化数据存储和查询的解决方法和折中方案是否确实必要。 如果您能成功地对数据建模为关系架构,那么很可能在 JOIN 语句中导致大量常见查询。 因此,当表增长超出想象,查询性能必然下降。 当大量客户使用此应用程序时,将会发生这种情况,延迟响应会影响应用程序背后的业务。

您有两个其他选项可供选择:SQL 和 NoSQL。 每一个听起来就像是正在取代另一个。 SQL 和 NoSQL 不是同一技术的不同类型, 都处理数据的持久性,但有一些结构上的差异。

NoSQL 数据库不使用固定数据架构和关系。 因此,它们并未规定模型。 NoSQL 数据库把您从域空间建模中解放出来。 您可以坚持在逻辑组中产生对象。 设计域模型,使用对象的关系图和 NoSQL 存储只能处理到磁盘的对象序列化。

使用文档?

在这种情况下,术语“文档”大致等同于术语“记录”。文档集合可能重新调用记录表。 主要的差别在于集合中的每个对象可能拥有与同一集合中所有其他对象不同的架构。 但是,所有文档都是逻辑相关的。

这一区别比它第一次出现时更加微妙。 假设您正在管理书籍作者的个人简历。 您可能不知道每个作者的相同数据量。 描述 Author1 的某些数据可能与描述 Author2 的数据不同。 如果您想让该个人简历成为属性的集合,那么确实要有一个带有几个可选列的固定架构。 如果您想让该个人简历成为类似以下文件的集合:以文字为基础的 CV、列出出版书籍和评论的 XML 流、转到 YouTube 访谈的链接和一些诸如重要统计资料的属性,则架构定义更少,且在 SQL 存储的严格边界内难以适应。

数据中的多态性级别是一个很好的度量,通过它来衡量 NoSQL 可以提供多少帮助。 假设您正在根据事件溯源体系结构构建系统。 在事件溯源体系结构中,应用程序的持久性模型只是事件的简明历史记录。 每个用户操作均源于服务器端的一个或多个事件。 您只需记录事件即可跟踪应用程序状态。

例如,在电子商务方案中,当用户提交订单,则启动工作流。 这可能会生成大量域事件:订单-已提交,订单-已确认、订单-已拒绝、订单-已创建、订单-处理中、订单-发货中、订单-发货、订单-已退货、订单-已更新等。 这些事件都与同一个订单相关。 处理事件的顺序可让您构建和重建订单的当前状态。

在实际电子商务系统中处理订单不仅仅是用更新的“状态”列来维护“订单”表的问题。 这意味着跟踪所有对表示订单的数据执行的操作。 这些操作可能完全不同并涉及不同数据。 例如,“订单-已退货”事件可能几乎没有相关数据。 “订单-已提交”事件可能附带来自用户的完整信息。 “订单-已更新”事件可能只包含对现有订单所做的变更。

您的订单将会更有文档的感觉,因为它完全通过由许多异类事件组成的列表进行描述。 每个事件只是一个要存储的对象。 大多数电子商务系统可能会使用关系存储来解决这些问题。 使用 NoSQL 方法存储此事件列表可能很有趣且很有前景。 要了解有关事件溯源体系结构的更多信息,请从 Microsoft 下载中心 bit.ly/1lesmzm 下载免费电子书《Exploring CQRS and Event Sourcing》。

最终一致性是一个问题吗?

最终一致性是 SQL 和 NoSQL 之间的另一个相关的结构性差异。 即使您可以很容易识别数据模型中的文档,最终一致性对已部署的应用程序的影响仍是最终决策的真正判别因素。

最终一致性是读取和写入相同数据出现不一致的情况。 NoSQL 系统保证如果在足够的时间内未对给定对象进行更新,则查询返回最后一个命令所写的内容,从这种意义上讲,大多数 NoSQL 系统最终一致。

大多数情况下,最终一致性根本不是个问题。 您通常需要在界定的上下文内尽可能地保持一致,但是实际上不需要跨界定上下文的任何一致性级别。 随着系统的增长,不管技术如何发展,您不能依赖一致性。

有一个简单的测试,看看最终一致性是否是一个问题。 您会如何考虑这样一种情况,一个命令写入一些数据,但连续读取却返回过时的数据? 如果让您可以不断地重新读取刚写入的数据是绝对至关重要,那么您有两个选择:

  • 避免使用 NoSQL 数据库
  • 配置 NoSQL 数据库以保持一致

考虑以下代码段,假定使用 RavenDB(一种流行的 .NET NoSQL 数据库):

DocumentSession
  .Store(yourObject);
DocumentSession
  .Query<YourObjectType>()
  .Where(t => t.Id == id)

第一行存储一个对象到 RavenDB 存档。 第二行尝试重新读取该对象,在同一个存储会话上查询保存对象的某 Id 属性的值。 默认数据库配置下,您读取的数据与刚写入的数据并不相同。

就 RavenDB 而言,写入存储以及更新查询引擎使用的索引是不同的操作。 索引更新以计划操作的形式执行。 如果在此期间没有对相同对象的其他更新,该偏差不会持续超过几秒钟。 这里有个方法可强制实现实际的一致性:

_instance.Conventions.DefaultQueryingConsistency =
  ConsistencyOptions.AlwaysWaitForNonStaleResultsAsOfLastWrite;

但在您这样做时,读取在索引更新之后才会返回。 因此,一个普通的读取可能需要几秒钟才能完成。 由此看来,NoSQL 存储填补了该行业的空白。 但是它们确实带来了挑战。 NoSQL 解决了一些体系结构问题而忽略了其他。

有更好的方法“等待”索引。 这些通常取决于您面对的情况。 因此,最好是在查询时决定查询所需的一致性类型,如以下示例所示:

using( var session = store.OpenSession() )
{
  var query = session.Query<Person>()
    .Customize(c=> c.WaitForNonStaleResultsAsOfLastWrite() )
    .Where( p => /* condition */ );
}

以下 WaitForNonStaleResultsAsOfLastWrite 查询自定义正指示服务器等待相关索引,以对编写的最后文档进行索引,忽略查询发出后到达服务器的最终文档。

这对具有高写入比率而索引总是过时的某些情况,很有帮助。 这是设计使然。 许多 WaitForNonStaleResultsXxxx 方法具有不同行为,并解决略有不同的情况。 另一种可能性就是完全接受最终一致性,如果返回的结果过时,对服务器进行简单询问。 之后相应的表现如下:

using( var session = store.OpenSession() )
{
  RavenQueryStatistics stats;
  var query = session.Query<Person>()
    .Statistics( out stats )
    .Where( p => /* condition */ );
}

在此示例中,您无需等待索引。 您还要求服务器输出查询统计信息,以便让您知道返回的结果是否过时。 假定尝试解决的情况,您应该有足够的信息来采取最佳决策。

多语言持久化

在一天结束的时候,关键并不是使关系存储调用无效并将其替换为所选的 NoSQL 产品。 关键应该是了解系统的机制和数据的特征,制定出最佳结构。 NoSQL 存储最可预见的具体应用程序是作为事件存储在事件溯源体系结构的上下文中。

对于系统中是否应该使用关系存储,没有简单的“是”或“否”的答案,您最好考虑多语言持久化。 您可以考虑使用结合 NoSQL 和关系数据库优点的存储层,而不用在 NoSQL 和关系数据库之间进行强迫选择。 这种类型的存储系统以最适当的方法解决不同问题。

在企业环境中,您应该使用不同的存储技术来存储不同类型的数据。 在面向服务的体系结构中尤需如此。 在这种情况下,每个服务都可以有自己的存储层。 也就没有理由在单一技术或产品下统一存储。 多语言持久性确实需要您学习不同的存储技术和产品。 不过这是一个作为培训成本的合理投资。


Dino Esposito 是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2014 年)和《Programming ASP.NET MVC 5》(Microsoft Press,2014 年)的合著者。作为 JetBrains 的 .NET Framework 和 Android 平台的技术推广人员,Esposito 经常在全球行业活动中发表演讲,并在 software2cents.wordpress.com 上以及 twitter.com/despos 上的推文中分享他对于软件的愿景。

衷心感谢以下技术专家对本文的审阅:Mauro Servienti(管理设计)