为了账号安全,请及时绑定邮箱和手机立即绑定

Strava的大规模数据存储解决方案:Rain键值存储系统

我们的许多热图是根据存储于Rain中的批量数据构建的。

在 Strava,我们非常喜爱地图——我们一些最受欢迎的功能都位于地图界面上。我所在的 Geo 团队专注于构建和改进这些产品。在 Geo 团队和 Metro 团队中,我们通常处理大型数据集:通过 OpenStreetMaps 聚合的开源地图数据、从上传的活动获取的 GPS 数据、用于地形等特性的第三方数据集,等等。这些聚合数据最终成为我们熟悉的 Geo 功能,例如 全球热力图Strava Metro路线产品路线建议、地形剖面和兴趣点。我们通过一个庞大的数据管道执行这些数据聚合操作,并且定期运行以确保我们提供最新的地理信息。

地球队面临的一个关键挑战是高效地服务于由我们的管道生成的大且不可变(只写一次,多次读取)数据集。这在如路由等计算密集型应用场景中尤为困难,

  1. 写优化与读优化的冲突:传统的以读优化为主的数据存储在处理大规模批量写入时,会严重影响读取性能或显著增加操作复杂性。
  2. 成本限制:在生产数据库中存储使用频率很低的数据集可能会非常昂贵——尤其是像Strava Metro这样的项目。
  3. 模式复杂性:在服务外部定义模式对开发人员来说会既昂贵又缺乏灵活性。

之前那会儿

我们的先前解决方案在处理大量写入时,结合了 PalDB 和 Cassandra。PalDB 是一个开源分布式数据库系统,而 Cassandra 是一个去中心化的分布式数据库系统。

PalDB 是一种非常适合小数据集,但不适合大数据集的二进制数据格式。README 指出它针对你偶尔在服务中读取的一些相对较小的数据集进行了优化。然而,对于较大的数据集,PalDB 并不理想。在我们的情况下,由于每次服务部署都需要从 S3 下载键值文件,我们的部署通常需要二十分钟以上。缓慢的部署过程严重影响了开发人员的快速迭代。此外,PalDB 文件在每个服务实例上占据了大量内存。对于像 Strava 的 Routemaster 这样重要的服务来说,这意味着数据会在多个服务器实例的内存中重复存储。

Cassandra 被宣传为一个写操作较多的数据存储,并且对于批处理数据输出任务来说,它可以成为一个有效的解决方案。尽管 Strava 仍然使用它来存储一些批处理数据存储,但在我们定期替换整个数据集时,我们可能会遇到问题。根据我们的经验,从 Spark 写入 Cassandra 需要密切关注以确保我们不会遇到速率限制/网络/连接限制。由于我们的构建是包含全球 OpenStreetMaps 数据源变更的全面更新,这可能会给生产数据存储带来很大的压力。

介绍Rain

我们开始了一段旅程,致力于改进我们的不可变批处理数据更新,通过创建一个新的服务,该服务可用作任何由Spark生成的数据集的键值存储。我们将其命名为Rain,因为它将云端的数据分发给客户端服务。

本质上,Rain 与您操作系统的缓存行为类似,但在分布式系统这一层面。就像操作系统将文件块缓存一样,Rain 会将 S3(文件系统)上的部分 Spark 输出数据集缓存到 Redis(L1/L2 缓存)中。客户端服务通过一个客户端调用服务来检索数据集。在代码上,Rain 有三个主要组件:一个 Spark 写入 API、一个读取库,以及一个 Thrift 服务,如下所示:

这个结构让什么成为可能?首先,它使我们能够利用Spark优化的分布式写入功能,该功能针对Parquet输出进行了优化。其次,它允许我们实现不可变数据的热替换,只需通过管理界面轻松更改数据集的引用路径即可。第三,它利用LRU(最近最少使用)Redis缓存,以及使用单一的分布式数据存储,而不是在每个服务实例中重复的内存存储,帮助我们降低数据成本。

制作雨量表

Strava 使用 Apache Spark 来运行我们的数据管道,并创建 Rain 表。对于需要定期更新的数据集,我们通常会用 Apache Airflow 安排刷新任务。我们将刷新后的表数据输出到 S3 中的一个新前缀,并更新指向表数据的指针文件,使其指向这个新前缀。

雨负责定期检查更新这个引用数据指针是否已被更新为指向新的表数据。在雨服务层,我们使用一个Caffeine缓存来使指向旧数据集的指针过期。这使我们能够在后台替换不可变的数据集,而无需重新部署任何系统组件。表指针的更新只是一个对雨服务器的API调用。服务器接着更新指针。当Caffeine使旧数据集的指针过期时,雨将开始从新的数据集读取数据。

多态

Rain表中的键值对可以由任何任意的Kryo-可序列化案例类对象组成。这意味着服务可以自行定义其模式,无需数据库管理员的参与。我们使用字节数组格式来写入、存储和传输数据,并且客户端定义键值类型,并确保这些类型是Kryo可序列化的。Spark客户端负责序列化,而客户端服务器读取器则负责反序列化。

客户端库负责将Rain返回的字节数组结果进行反序列化处理,转换成客户端能直接使用的类型。

如何修改 Rain 表的模式?通常,模式更改需要服务部署,因此通常只需创建并使用一个新的 Rain 表,然后重新部署服务即可。

分区

“写Rain表”其实就是将大小一致的Parquet文件写入S3,其中Parquet数据的模式是一个键值对。我们使用Spark的基本Parquet写入API来执行此操作。为了写入这些大小一致的文件,我们需要在Spark中指定一个分区器来执行.repartition操作。

我们根据键进行分区,使用哈希分区器或基于预定义的键排序的范围分区器。哈希分区根据哈希函数将键均匀地分布在所有分区中。范围分区确保了基于排序“接近”的键会被放置在同一分区中。哈希分区能保证最小化的偏差,而范围分区在某些读取模式下可以利用缓存局部性,从而减少读取查询的数量。

对于某些数据集而言,范围分区法更具意义,因为数据通常按范围加载。例如,Strava的routebuilder中的路由边,这些边通过ID进行索引,地理上接近的边其ID也接近地理上接近的边其ID也接近。这使得路由边的数据集成为进行范围分区的良好选择。然而,这也意味着在读取时,某些键可能会比其他键更“热点”。但是,使用像Redis这样的缓存存储,热门键的性能影响相对较小,特别是使用像Redis这样的缓存存储时。

Spark的写客户端和Rain的读客户端需要使用相同的分区器来保持一致。对于哈希分区的表,我们将使用Spark的默认哈希分区器。而对于范围分区的表,我们就需要创建一个单独的索引来。读客户端会加载该索引,然后告诉Rain服务器在哪个分区中查找键。由于反序列化是在客户端通过丰富的Rain客户端库进行的,因此计算排序的操作也需要在客户端进行。当在Rain客户端库中注册类时,还需额外定义键的排序规则。它需要为键定义一个排序规则。

读书

从 Rain 读取数据非常简单。客户端库会接收对象键,并将其序列化为字节,然后向 Rain 服务发出请求,查询 Redis/S3 中的键。如果在 Redis 中找到了键,我们就返回相应的数据。如果没有找到,我们就从 S3 中加载包含该键的文件。数据将以字节数组格式返回,然后 Rain 客户端将这些字节反序列化为一个适合调用服务的对象。

内部有一些机制,以确保在并发读取时性能良好。如果有针对同一个键的并发访问请求,我们使用ThriftMux一致性哈希来确保我们只将S3文件加载到Redis一次,避免重复加载。一致性哈希确保只有一个服务实例负责加载与该键相关的S3文件。我们在每个服务实例中使用单例[Caffeine缓存]来维护当前正在从S3执行的请求记录。更方便的是,AsyncCaffeineCache还隐式地为我们的文件加载提供了一个干净的锁定机制。

我们可以在Rain服务器上进行会计记录,以便按需加载完整的不可变表。

缓存区

Rain 的缓存有多层。S3 的读取速度是最慢的,其次是 Redis,然后是直接在服务客户端上的 Caffeine 缓存。我们理想的情况是尽可能多地将数据放在离服务客户端最近的地方,但当然,我们在一个资源有限的世界中运作。每个服务都有自己的 SLA 和资源要求。Rain 的设计灵活,能够适应这些限制。

和任何缓存系统一样,Rain在读取模式上的异构性有助于提供优异的性能表现。理想情况下,Rain表中的数据应包括既频繁访问也较少访问的数据。存储在有序并分区的表中的数据有助于增加访问SLA的多样性,因为有序表中的分区会被更少地访问。例如,无需缓存夜间无人使用的道路数据。另一个例子是,我们通常不需要缓存放大后的海洋区域卫星图像(预计这些数据会被首先驱逐出缓存),而将中央公园的图像存储在缓存中是有意义的。缓存中数据检索SLA的变化减少了缓存驱逐次数。

只要我们不使用高使用频率的数据填满整个缓存并导致替换频繁,就可以轻松地超出缓存的存储大小。

本地方式

Rain解锁了在本地服务器上使用生产数据集的能力。我们可以设置本地运行的服务实例指向一个Rain服务器。这样一来,我们就能避免在本地运行时将整个数据集加载到内存,本地服务实例的启动速度也会快很多。

当处于本地模式时,Strava的工程师可以选择从远程Rain服务加载哪些Rain表,并选择直接与哪些Rain表进行交互。我们引入了一种Rain表模式,仅使用Caffeine进行本地缓存。这种模式的一个用例是,当开发人员在本地迭代一个输出到Rain表的任务时,开发人员可以在Spark运行中将数据写入本地Rain表,并在本地读取该表,而无需将数据集上线。在Geo团队中,我们利用这些本地Spark运行来输出特定地区的数据,这对新功能开发中的质量保证非常有帮助。

管理界面

当然,使用像Rain这样的工具时,了解使用中的数据集是很重要的。调查缓存指标,了解特定数据集的访问模式也很重要。因此,为此我们构建了一个内部UI,可以让我们执行不可变表的刷新,调查访问模式,预热Redis缓存,以及清理Redis缓存。

以下是来自管理员界面的截图,显示了Redis中键的访问时间按表的分布情况。

一些不足:

我们看到的一个缺点是跨可用区网络使用增多,但额外的成本被减少服务内存带来的节省抵消。我们预计会有轻微的延迟增加,不过很快我们发现Rain和Redis的读取对客户端响应几乎没有增加任何延迟。

另一个我们发现的问题是从同步数据检索迁移到基于Future的异步检索Rain表数据颇具挑战性。Finagle Thrift客户端响应总是异步的,而PalDB调用则是同步的,因此迁移到Rain意味着我们需要调整一些工作流程以使用Future(未来)。这要求我们调整线程池设置,并更好地掌握客户端服务中的并发控制。例如A*图搜索算法这样的算法,它们本身就要求大量串行步骤,并且每个步骤都需要数据查询,因此在迁移过程中会比较困难。

配置一个大型 Redis 集群也会产生成本。据我们计算,这部分成本仍然被我们在云服务费用上的节省所抵消。

结尾

雨季刚来,但我们已经看到了不少进步:

  • 我们看到部署迭代时间从20分钟缩短到了1分钟,并为大型数据集启用了本地模式。我们希望Rain能简化Strava未来开发中的缓存解决方案。
  • 我们以几个数量级减少了我们一些最大服务的内存占用。
  • 我们节省了数万美元的EC2计算费用。
  • 我们启用了后台不可变表的刷新。
  • 我们启用了从Spark生成不可变数据集的分布式写入功能。

总的来说,Rain 是一大进步,在 Strava 处理和提供大规模数据集方面。我们从 Rain 中获得的最大好处是,它使我们能够以低延迟提供基于 Spark 的太字节级数据集。这对我们的产品路线图来说非常重要,我非常期待我们接下来如何利用它。

想加入 Strava 吗?请查看 职业页面
[OOP]: 面向对象编程 (OOP)
[CRUD]: CRUD
[JVM]: Java 虚拟机 (JVM)
[SUT]: 待测系统 (SUT)

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
微信客服

购课补贴
联系客服咨询优惠详情

帮助反馈 APP下载

慕课网APP
您的移动学习伙伴

公众号

扫描二维码
关注慕课网微信公众号

举报

0/150
提交
取消