Schemaless是Uber Engineering使用MySQL定制设计的数据存储,这让我们能够进行扩展从2014年开始超越。本文是关于Schemaless的三部分系列文章的第一部分。
在项目夹层我们描述了如何将优步的核心出行数据从单个数据中迁移出来Postgres实例到Schemaless,这是我们的容错和高可用性的数据存储。本文进一步描述了Schemaless的体系结构,以及它在Uber基础设施中所扮演的扩展角色,以及它是如何形成的。
我们对新数据库的竞争
在2014年初,由于旅行的蓬勃发展,我们耗尽了数据库空间。每一个新的城市和旅程里程碑都把我们推到了悬崖边,我们意识到Uber的基础设施到年底就会失效:我们根本无法用Postgres存储足够的旅程数据。我们的任务是为Uber实现下一代数据库技术,这项任务花了好几个月,一年的大部分时间,涉及到很多工程师来自我们世界各地的工程办公室。
但是首先,既然已经存在大量的商业和开源替代方案,为什么还要构建可伸缩的数据存储呢?对于新的旅行数据存储,我们有五个关键需求:
- 我们的新解决方案需要能够通过增加更多的服务器来线性增加容量,这是我们的Postgres设置所缺乏的属性。添加服务器既可以增加可用磁盘存储,又可以减少系统响应时间。
- 我们需要写可用性。我们之前实现了一个简单的缓冲机制复述,因此,如果写入Postgres失败,我们可以稍后重试,因为在此期间行程已经存储在Redis中。但是在Redis中,不能从Postgres中读取行程,我们失去了记账等功能。很讨厌,但至少我们没有失去这次旅行!随着时间的推移,Uber不断发展,所以我们基于redis的解决方案并没有规模化。Schemaless必须支持与Redis类似的机制,但更倾向于写可用性而不是读你写的语义。
- 我们需要一种通知下游依赖项的方法。在当前的系统中,我们同时处理了许多行程组件(例如,账单、分析等)。这是一个容易出错的过程:如果任何步骤失败了,我们必须重新重试,即使某些组件成功了。这是不可扩展的,所以我们希望将这些步骤分解为独立的步骤,由数据更改启动。我们确实有一个异步旅行事件系统,但它是基于卡夫卡0.7.我们不能无损运行它,所以我们欢迎一个新的系统,它有一些类似的东西,但可以无损运行。
- 我们需要二级索引.当我们离开Postgres时,新的存储系统必须支持Postgres索引,这意味着二级索引以同样的方式搜索行程。
- 我们需要操作信任在系统中,因为它包含关键任务的行程数据。如果我们在凌晨3点被呼叫,而数据存储没有回答查询并关闭业务,我们是否拥有快速修复它的操作知识?
基于以上,我们分析了一些常用的替代系统,如Cassandra、Riak和MongoDB等的好处和潜在的限制。为了便于说明,下面提供一个图表,显示不同系统选项的功能的不同组合:
| 线性尺度 | 写的可用性 | 通知 | 索引 | 运维的信任 | |
| 选项1 | ✓ | ✓ | ✗ | (✓) | ✗ |
| 选项2 | ✓ | ✓ | ✗ | (✓) | (✓) |
| 选项3 | ✓ | ✗ | ✗ | (✓) | ✗ |
尽管这三个系统都能够通过在线添加新节点来进行线性扩展,但只有两个系统也可以在故障转移期间接收写操作。所有解决方案都没有内置的方法来通知下游依赖项的更改,因此我们需要在应用程序级别实现它。他们都有索引,但如果要索引许多不同的值,查询就会变慢,因为它们使用分散-聚集来查询所有节点。最后,我们使用过的一些系统是单集群的,没有为面向用户的在线流量服务,并且与我们的服务有各种各样的操作问题。
最后,我们的决定最终归结到操作上信任在我们使用的系统中,因为它包含关键任务的行程数据.在理论上,替代方案可能能够可靠地运行,但我们是否拥有立即执行它们的全部功能的操作知识,这在很大程度上决定了我们为Uber用例开发自己的解决方案。这不仅取决于我们使用的技术,还取决于我们在团队中拥有的相关经验。
我们应该注意到,自从两年多前我们调查了这些选项并发现它们都不适用于旅行存储用例以来,我们现在已经在基础设施的其他领域成功地采用了Cassandra和Riak,并在生产中使用它们大规模地为数百万用户服务。
在无模式中我们信任
由于上述选项都不能满足我们在给定的时间框架内的要求,我们决定建立自己的系统,操作尽可能简单,同时应用从其他人那里学到的扩展经验。设计灵感来自于Friendfeed,以及对运营方面的关注受到的启发Pinterest.
我们最终构建了一个键值存储,它允许您以无模式的方式(因此得名)保存任何JSON数据,而无需进行严格的模式验证。它有只追加分片MySQL有缓冲写,以支持失败的MySQL主机和发布-订阅功能的数据更改通知,我们称之为触发器。最后,Schemaless支持数据的全局索引。下面,我们将讨论数据模型的概述和一些关键特性,包括对Uber旅行的剖析,更深入的示例将在后续文章中保留。
无模式数据模型
无模式是一个仅可追加的稀疏三维持久散列映射,与谷歌非常相似Bigtable.Schemaless中最小的数据实体称为单元,是不可变的;一旦写入,就不能覆盖或删除。单元格是JSONBlob由行键、列名和名为ref key的引用键引用。行键是aUUID,而列名是字符串,引用键是整数。
可以将行键看作关系数据库中的主键,而将列名看作列。然而,在Schemaless中没有预定义的或强制的模式,行不需要共享列名;事实上,列名完全由应用程序定义。ref键用于对给定行键和列的单元格进行版本化。因此,如果一个单元格需要更新,您将编写一个具有更高ref key的新单元格(最新的单元格是具有最高ref key的单元格)。ref键也可用作列表中的条目,但通常用于版本控制。应用程序决定在这里使用哪个方案。
应用程序通常将相关数据分组到同一列中,然后每个列中的所有单元格都具有大致相同的应用程序端模式。这种分组是将更改的数据捆绑在一起的好方法,它允许应用程序快速更改模式,而不需要数据库端停机。下面的示例详细说明了这一点。
示例:无模式的行程存储
在深入研究如何在Schemaless中为旅行建模之前,让我们先来分析一下Uber的旅行。旅行数据是在不同的时间点生成的,从取货到结账,这些不同的信息是异步到达的,因为参与旅行的人给出了他们的反馈,或者执行后台流程。下图是优步出行中不同环节的简化流程:
一个旅行是由一个伙伴驱动,由一个骑手进行,并且有一个开始和结束的时间戳。这个信息构成了基本行程,我们从这个信息中计算出旅行的成本(车费),也就是向乘客收取的费用。旅行结束后,我们可能需要调整票价,我们可以赊欠或借记乘客。我们还可以根据骑手或司机的反馈(在上图中用星号表示)给它添加注释。或者,我们可能不得不尝试用多张信用卡结账,以防第一张信用卡过期或被拒绝。Uber的出行流程是一个数据驱动的过程。当数据变得可用或添加时,将在行程中执行特定的流程集。其中一些信息,比如乘客或司机的评分(被认为是上面图表中注释的一部分),可能会在旅行结束后几天到达。
那么,我们如何将上面的trip模型映射到Schemaless中呢?
行程数据模型
使用斜体表示uuid和大写表示列名,下表显示了我们的旅行存储的简化版本的数据模型。我们有两个行程(uuidtrip_uuid1而且trip_uuid2)和四栏(基础、状态、备注和票价调整)。单元格由一个带有数字和JSON blob (缩写与{…}).这些框被覆盖以表示版本控制(即,不同的ref键)。

trip_uuid1有三个单元格:一个在基本列,两个在状态列,没有在票价调整列。trip_uuid2BASE列中有两个单元格,NOTES列中有一个单元格,票价调整列中同样没有单元格。对于Schemaless,列没有什么不同;因此,列的语义由应用程序定义,在本例中是服务夹层.
在Mezzanine中,BASE列单元格包含基本的行程信息,如驾驶员的UUID和行程时间。STATUS列包含旅行的当前支付状态,我们在其中为每次尝试为旅行结账插入一个单元。(如果信用卡没有足够的资金或已经过期,尝试可能会失败。)NOTES列包含一个单元格,如果有任何与司机或Uber DOps(司机运营员工)的行程相关的笔记。最后,票价调整列包含是否调整了票价的单元格。
我们用这个列分割来避免数据竞争并且最小化需要在更新时写入的数据量。BASE列是在行程结束时写入的,因此通常只写入一次。当我们试图对行程进行计费时,就会写入STATUS列,这发生在写入BASE列中的数据之后,如果计费失败,可能会发生多次。NOTES列同样可以在BASE写入之后的某个时刻被写入多次,但它与STATUS列的写入完全不同。类似地,票价调整列仅在票价发生变化时才会被写入,例如由于线路效率低下。
无模式触发
Schemaless的一个关键特性是触发器,即获得关于Schemaless实例更改的通知的能力。由于单元格是不可变的,并且添加了新版本,所以每个单元格还表示一个更改或一个版本,从而允许将实例中的值视为更改日志。对于给定的实例,可以监听这些更改并基于它们触发函数,非常类似于Kafka等事件总线系统。
无模式触发器使Schemaless成为一个完美的真实源数据存储,因为除了随机访问数据之外,下游依赖项还可以使用触发器特性监视和触发任何特定于应用程序的代码(LinkedIn的系统也类似)DataBus),从而分离数据的创建及其处理。
在其他用例中,当Uber的BASE列被写入Mezzanine实例时,Uber使用Schemaless触发器对旅行进行计费。对于上面的例子,当BASE列为trip_uuid1时,在BASE列上触发的计费服务拾取该单元格,并尝试通过向信用卡支付费用来对旅行进行计费。刷卡的结果,无论成功还是失败,都会在STATUS列中写回Mezzanine。通过这种方式,计费服务与行程的创建分离开来,Schemaless充当异步事件总线。

方便查阅的索引
最后,Schemaless支持在JSON blob中的字段上定义的索引。通过这些预定义字段查询索引,以查找与查询参数匹配的单元格。查询这些索引是有效的,因为索引查询只需要到单个碎片找到要返回的单元格集。事实上,可以进一步优化查询,因为Schemaless允许将单元格数据直接反规范化到索引中。在索引中使用非规格化的数据意味着索引查询只需要查询一个碎片就可以查询和检索信息。事实上,我们通常建议Schemaless用户将他们可能认为需要的数据反规范化到索引中,以防止他们需要查询除直接通过行键检索单元格之外的任何信息。在某种意义上,我们因此用存储交换了快速查询。
作为Mezzanine的一个例子,我们定义了一个二级索引,允许我们查找给定司机的行程。我们已经将旅行的创建时间和旅行的城市进行了反规范化。这使得在给定的时间范围内找到一个城市中司机的所有旅行成为可能。的driver_partner_index的定义YAML格式,它是trips数据存储的一部分,在BASE列上定义(该示例使用标准#注释了注释)。
使用这个索引,我们可以找到一个给定的旅行driver_partner_uuid通过过滤city_uuid和/或trip_created_at.在本例中,我们只使用来自BASE列的字段,但是Schemaless支持来自多个列的反规范化数据,这相当于上面的多个条目column_def列表。
如前所述,Schemaless具有高效的索引,通过基于分片字段对索引进行分片实现。因此,索引的唯一要求是索引中的一个字段被指定为shard字段(在上面的例子中就是这样)driver_partner_uuid,因为它是第一个给出的)。shard字段决定索引条目应该写入或从哪个shard中检索。原因是我们需要在查询索引时提供shard字段。这意味着在查询时,我们只需要查询一个碎片来检索索引条目。关于切分字段需要注意的一点是,它应该具有良好的分布。uuid是最好的,城市id是次优的,状态字段(enum)不利于存储。
对于除shard字段外的其他字段,Schemaless支持相等、不相等和范围查询进行过滤,并支持只选择索引中字段的一个子集,并为索引条目指向的行键检索特定或所有列。目前,shard字段必须是不可变的,所以schemalless总是只需要与一个shard对话。但是,我们正在探索如何使它变而不造成太大的性能开销。
索引最终是一致的;每当我们写入一个单元格时,我们也会更新索引条目,但这不会发生在同一个事务中。单元格和索引条目通常不属于同一个碎片。所以如果我们要提供一致的索引,我们就需要引入2 pc在写入过程中,这将产生很大的开销。使用最终一致的索引,我们避免了开销,但是Schemaless用户可能会在索引中看到过时的数据。大多数情况下,单元格变化和相应的索引变化之间的延迟远远低于20ms。
总结
我们已经概述了数据模型、触发器和索引,所有这些都是定义Schemaless的关键特性,Schemaless是我们的旅行存储引擎的主要组件。在以后的文章中,我们将讨论Schemaless的其他一些特性,以说明它是如何成为Uber基础设施中受欢迎的服务伙伴的:更多关于体系结构、使用MySQL作为存储节点以及如何在客户端实现触发容错。
标题图片来源:anim1069”NOAA照片库下许可CC-BY 2.0.标题尺寸和颜色校正的图像裁剪。
标题说明:由于Schemaless是使用MySQL构建的,我们介绍系列使用海豚的一个类似的姿势,但与方向相反MySQL的标志.






