Braze 如何大规模利用 Ruby

已发表: 2022-08-18

如果你是一名阅读 Hacker News、Developer Twitter 或任何其他类似信息来源的工程师,你几乎肯定会遇到过一千篇标题为“Rust 与 C 的速度”、“是什么让 Node. js 比 Java 更快?”,或“为什么要使用 Golang 以及如何开始。” 这些文章通常表明,有一种特定的语言是可扩展性或速度的明显选择,而您唯一要做的就是接受它。

当我在大学和作为工程师的第一年或第二年时,我会阅读这些文章并立即启动一个宠物项目来学习新的语言或框架。 毕竟,它可以保证“在全球范围内”工作并且“比你见过的任何东西都快”,谁能抗拒呢? 最终我发现对于我的大多数项目,我实际上并不需要这些非常具体的东西。 随着我职业生涯的发展,我开始意识到没有任何语言或框架选择实际上可以免费为我提供这些东西。

相反,我发现当你想要扩展系统时,架构实际上是最大的杠杆,而不是语言或框架。

在 Braze,我们在全球范围内开展业务。 是的,我们使用 Ruby 和 Rails 作为我们执行此操作的两个主要工具。 然而,没有“global_scale = true”的配置值可以让这一切成为可能——这是一个深思熟虑的架构的结果,从应用程序的深处一直延伸到部署拓扑。 Braze 的工程师一直在检查扩展瓶颈并找出如何使我们的系统更快,而答案通常不是“远离 Ruby”:它几乎肯定会改变架构。

因此,让我们来看看 Braze 如何利用深思熟虑的架构来实际解决速度和大规模的全球规模问题——以及 Ruby 和 Rails 适合它的地方(和不适合)!

一流架构的力量

一个简单的网络请求

由于我们的运营规模,我们知道与客户用户群相关的设备每天都会发出数十亿个 Web 请求,这些请求必须由某个 Braze Web 服务器提供服务。 即使在最简单的网站中,您也会有一个相对复杂的流程,与从客户端到服务器并返回的请求相关联:

  1. 首先是客户端的 DNS 解析器(通常是他们的 ISP)根据您网站 URL 中的域确定要访问的 IP 地址。

  2. 一旦客户端有了 IP 地址,他们就会将请求发送到网关路由器,网关路由器会将请求发送到“下一跳”路由器(​​可能会发生多次),直到请求到达目标 IP 地址。

  3. 从那里,接收请求的服务器上的操作系统将处理网络详细信息,并通知 Web 服务器的等待进程在它正在侦听的套接字/端口上接收到传入请求。

  4. Web 服务器会将响应(请求的资源,可能是 index.html)写入该套接字,该套接字将通过路由器返回客户端。

对于一个简单的网站来说相当复杂的东西,不是吗? 幸运的是,其中许多事情都为我们处理好了(稍后会详细介绍)。 但是我们的系统仍然有数据存储、后台作业、并发问题等等需要处理! 让我们深入了解它的外观。

第一个支持扩展的系统

在大多数情况下,DNS 和名称服务器通常不需要太多关注。 您的顶级域名服务器可能会有一些条目将“yourwebsite.com”映射到您的域的名称服务器,如果您使用的是 Amazon Route 53 或 Azure DNS 等服务,它们将处理该名称您的域的服务器(例如管理 A、CNAME 或其他类型的记录)。 您通常不必考虑缩放这部分,因为这将由您使用的系统自动处理。

然而,流程的路由部分可能会变得有趣。 有几种不同的路由算法,例如开放最短路径优先或路由信息协议,所有这些算法都旨在找到从客户端到服务器的最快/最短路径。 因为互联网实际上是一个巨大的连接图(或者,或者,一个流网络),可能有多个可以利用的路径,每个路径都有相应的更高或更低的成本。 寻找绝对最快路线的工作是令人望而却步的,因此大多数算法都使用合理的启发式方法来获得可接受的路线。 计算机和网络并不总是可靠的,因此我们依靠 Fastly 来增强客户更快地路由到我们的服务器的能力。

通过在世界各地提供快速、可靠的连接,快速工作。 将它们视为互联网的州际高速公路。 我们域的 A 和 CNAME 记录指向 Fastly,这导致我们客户的请求直接进入高速公路。 从那里,Fastly 可以将它们路由到正确的位置。

钎焊的前门

好的,所以我们的客户的请求已经通过 Fastly 高速公路,就在 Braze 平台的前门——接下来会发生什么?

在一个简单的情况下,该前门将是一个接受请求的服务器。 正如你可以想象的那样,这不会很好地扩展,所以我们实际上将 Fastly 指向一组负载均衡器。 负载均衡器可以使用各种策略,但想象一下,在这种情况下,Fastly 循环将请求均匀地发送到负载均衡器池。 这些负载均衡器将对请求进行排队,然后将这些请求分发到 Web 服务器,我们也可以想象以循环方式处理客户端请求。 (实际上,某些类型的亲和力可能有优势,但这是另一个话题。)

这使我们能够根据我们获得的请求的吞吐量和我们可以处理的请求的吞吐量来扩大负载均衡器的数量和 Web 服务器的数量。 到目前为止,我们已经构建了一个架构,可以毫不费力地处理大量请求! 它甚至可以通过负载均衡器请求队列的弹性来处理突发流量模式——这太棒了!

网络服务器

最后,我们进入令人兴奋的(Ruby)部分:Web 服务器。 我们使用 Ruby on Rails,但这只是一个 Web 框架——实际的 Web 服务器是 Unicorn。 Unicorn 的工作原理是在一台机器上启动多个工作进程,其中每个工作进程侦听操作系统套接字以进行工作。 它为我们处理进程管理,并将请求的负载平衡推迟到操作系统本身。 我们只需要我们的 Ruby 代码尽可能快地处理请求; 在 Ruby 之外对我们来说,其他一切都得到了有效的优化。

由于我们的 SDK 在客户应用程序中或通过我们的 REST API 发出的大多数请求都是异步的(即,我们不需要等待操作完成即可向客户返回特定响应),我们的大多数API 服务器非常简单——它们验证请求的结构、任何 API 密钥约束,然后将请求放入 Redis 队列,如果一切顺利,则向客户端返回 200 响应。

这个请求/响应周期大约需要 10 毫秒来处理 Ruby 代码,其中一部分用于等待 Memcached 和 Redis。 即使我们用另一种语言重写所有这些,也不可能从中挤出更多的性能。 而且,最终,正是您到目前为止所阅读的所有内容的架构,使我们能够扩展此数据摄取过程以满足客户不断增长的需求。

作业队列

这是我们过去探讨过的一个主题,因此我不会深入探讨这方面 - 要了解有关我们的工作排队系统的更多信息,请查看我关于通过队列实现弹性的帖子。 在高层次上,我们所做的是利用充当作业队列的大量 Redis 实例,进一步缓冲需要完成的工作。 与我们的 Web 服务器类似,这些实例分布在可用区中——以在特定可用区出现问题时提供更高的可用性——并且它们使用 Redis Sentinel 以主/辅助对形式出现以实现冗余。 我们还可以水平和垂直扩展这些,以优化容量和吞吐量。

工人

这当然是最有趣的部分——我们如何让工人扩大规模?

首先,我们的工作人员和队列按多个维度进行细分:客户、工作类型、所需的数据存储等。这使我们具有高可用性; 例如,如果特定数据存储出现问题,其他功能将继续正常工作。 它还允许我们根据这些维度中的任何一个独立地自动缩放工人类型。 我们最终能够以水平可扩展的方式管理工作人员的能力——也就是说,如果我们有更多的某种工作,我们可以扩展更多的工作人员。

在这里,您可能会开始看到语言或框架选择很重要。 最终,更高效的工人将能够更快地完成更多工作。 像 C 或 Rust 这样的编译语言在计算任务上往往比像 Ruby 这样的解释语言快得多,这可能会在某些工作负载上产生更高效的工作人员。 但是,我花了很多时间查看痕迹,在 Braze 的大局中,原始 CPU 处理只是其中的一小部分。 我们的大部分处理时间都花在等待数据存储或外部请求的响应上,而不是处理数字; 我们不需要为此进行大量优化的 C 代码。

数据存储

到目前为止,我们所涵盖的所有内容都具有相当大的可扩展性。 因此,让我们花点时间谈谈我们的员工将大部分时间花在哪里——数据存储。

任何曾经扩展 Web 服务器或使用 SQL 数据库的异步工作者的人都可能遇到过特定的扩展问题:事务。 您可能有一个负责完成订单的端点,该端点创建两个 FulfillmentRequest 和一个 PaymentReceipt。 如果这一切都没有发生在事务中,您最终可能会得到不一致的数据。 同时在单个数据库上执行大量事务会导致大量时间花在锁上,甚至死锁。 在 Braze,我们通过对象独立性和最终一致性来直接解决数据模型本身的扩展问题。 使用这些原则,我们可以从数据存储中挤出大量性能。

独立数据对象

我们在 Braze 大量使用 MongoDB,原因很充分:也就是说,它使我们能够大幅水平扩展 MongoDB 分片并获得近乎线性的存储和性能提升。 这对我们的用户配置文件非常有效,因为它们彼此独立——用户配置文件之间没有要维护的 JOIN 语句或约束关系。 随着我们每个客户的增长或我们添加新客户(或两者兼而有之),我们可以简单地向现有数据库添加新数据库和新分片以增加我们的容量。 我们明确避免使用多文档事务等功能来保持这种级别的可扩展性。

除了 MongoDB,我们经常使用 Redis 作为临时数据存储,用于缓冲分析信息等。 由于许多这些分析的真实来源作为独立文档存在于 MongoDB 中一段时间​​,因此我们维护了一个水平可扩展的 Redis 实例池作为缓冲区; 在这种方法下,散列文档 ID 用于基于密钥的分片方案,由于独立性而均匀分布负载。 定期作业将这些缓冲区从一个水平扩展的数据存储刷新到另一个水平扩展的数据存储。 规模达到!

此外,我们将 Redis Sentinel 用于这些实例,就像我们对上述作业队列所做的那样。 我们还为不同的目的部署了许多“类型”的 Redis 集群,为我们提供受控的故障流(即,如果一种特定类型的 Redis 集群出现问题,我们不会看到不相关的功能同时开始失败)。

最终一致性

Braze 还利用最终一致性作为大多数读取操作的原则。 这使我们能够在大多数情况下利用 MongoDB 副本集的主要和次要成员的读取,从而使我们的架构更加高效。 我们数据模型中的这一原则允许我们在整个堆栈中大量使用缓存。

我们使用 Memcached 的多层方法——基本上,当从数据库请求文档时,我们将首先检查具有非常低生存时间 (TTL) 的机器本地 Memcached 进程,然后检查远程 Memcached 实例(使用更高的 TTL),在直接询问数据库之前。 这有助于我们显着减少对常见文档的数据库读取,例如客户设置或活动详细信息。 “最终”可能听起来很可怕,但实际上,它只有几秒钟,采用这种方法可以减少大量来自事实来源的流量。 如果您曾经参加过计算机体系结构课程,您可能会认识到这种方法与 CPU L1、L2 和 L3 缓存系统的工作方式有多么相似!

通过这些技巧,我们可以从我们架构中最慢的部分中挤出大量性能,然后在我们的吞吐量或容量需求增加时适当地水平扩展它。

Ruby 和 Rails 适合的地方

事情是这样的:事实证明,当您花费大量精力构建一个整体架构时,每个层都可以很好地水平扩展,语言或运行时的速度并没有您想象的那么重要。 这意味着语言、框架和运行时的选择是根据一组完全不同的要求和约束进行的。

当 Braze 于 2011 年成立时,Ruby 和 Rails 在帮助团队快速迭代方面有着良好的记录——它们仍然被 GitHub、Shopify 和其他领先品牌使用,因为它继续使这成为可能。 它们继续分别由 Ruby 和 Rails 社区积极开发,并且它们仍然拥有大量可满足各种需求的开源库。 这对是快速迭代的绝佳选择,因为它们具有极大的灵活性,并且对于常见的用例保持了大量的简单性。 我们发现,在我们每天使用它的时候,这都是绝对正确的。

现在,这并不是说 Ruby on Rails 是适合所有人的完美解决方案。 但是在 Braze,我们发现它可以很好地为我们的大部分数据摄取管道、消息发送管道和面向客户的仪表板提供动力,所有这些都需要快速迭代,并且是 Braze 成功的核心整个平台。

当我们不使用 Ruby 时

可是等等! 并非我们在 Braze 所做的一切都使用 Ruby。 多年来,出于各种原因,我们在一些地方呼吁将事情转向其他语言和技术。 让我们看一下其中的三个,只是为了提供一些额外的见解,以了解我们何时依赖 Ruby 和不依赖 Ruby。

1. 寄件人服务

事实证明,Ruby 并不擅长在单个进程中处理高度并发的网络请求。 这是一个问题,因为当 Braze 代表我们的客户发送消息时,一些终端服务提供商可能要求每个用户发送一个请求。 当我们准备好发送 100 条消息时,我们不想等待每条消息都完成后再继续下一条。 我们宁愿并行完成所有这些工作。

输入我们的“Sender Services”——即用 Golang 编写的无状态微服务。 上面示例中的 Ruby 代码可以将所有 100 条消息发送到其中一个服务,该服务将并行执行所有请求,等待它们完成,然后向 Ruby 返回一个批量响应。 在并发网络方面,这些服务比我们使用 Ruby 所做的要高效得多。

2. 电流连接器

我们的 Braze Currents 大容量数据导出功能允许 Braze 客户将数据持续流式传输到我们众多数据合作伙伴中的一个或多个。 该平台由 Apache Kafka 提供支持,流式传输是通过 Kafka 连接器完成的。 您可以在技术上用 Ruby 编写这些,但官方支持的方式是使用 Java。 而且由于对 Java 的高度支持,在 Java 中编写这些连接器比在 Ruby 中要容易得多。

3.机器学习

如果你曾经在机器学习方面做过任何工作,你就会知道选择的语言是 Python。 Python 中用于机器学习工作负载的众多软件包和工具使对等的 Ruby 支持黯然失色——TensorFlow 和 Jupyter notebooks 之类的东西对我们的团队很有帮助,而这些类型的工具在 Ruby 世界中根本不存在或没有很好地建立。 因此,在构建利用机器学习的产品元素时,我们倾向于使用 Python。

当语言很重要

显然,我们在上面有几个很好的例子,其中 Ruby 不是理想的选择。 选择其他语言的原因有很多——我们认为这里有一些特别有用的考虑因素。

无需转换成本即可构建新事物

如果您要构建一个全新的系统,使用新的域模型并且不与现有功能紧密耦合集成,那么如果您愿意,您可能有机会使用不同的语言。 尤其是在您的组织正在评估不同机会的情况下,一个较小的、孤立的新建项目可能是尝试新语言或框架的一个很好的现实世界实验。

特定任务的语言生态系统和人机工程学

有些任务使用特定的语言或框架要容易得多——我们特别喜欢 Rails 和 Grape 用于开发仪表板功能,但是用 Ruby 编写机器学习代码绝对是一场噩梦,因为开源工具根本不存在。 您可能希望使用特定的框架或库来实现某种功能或集成,有时您的语言选择会受到影响,因为它几乎肯定会带来更轻松或更快的开发体验。

执行速度

有时,您需要优化原始执行速度,而使用的语言会严重影响这一点。 很多高频交易平台和自动驾驶系统都是用 C++ 编写的,这是有充分理由的; 本机编译的代码可能会非常快! 我们的发送器服务利用了 Golang 的并行/并发原语,正是因为这个原因,这些原语在 Ruby 中根本不可用。

开发者熟悉度

另一方面,您可能正在构建一些孤立的东西,或者有一个想要使用的库,但是您的团队其他成员完全不熟悉您的语言选择。 在 Scala 中引入一个非常倾向于函数式编程的新项目可能会给团队中的其他开发人员带来熟悉障碍,这最终会导致知识隔离或净速度下降。 我们发现这在 Braze 尤为重要,因为我们非常强调快速迭代,因此我们倾向于鼓励使用组织中已经广泛使用的工具、库、框架和语言。

最后的想法

如果我能回到过去,告诉自己关于巨型系统中的软件工程的一件事,那就是:对于大多数工作负载,您的整体架构选择将比语言选择更能定义您的扩展限制和速度。 这种洞察力每天都在 Braze 得到证明。

Ruby 和 Rails 是令人难以置信的工具,当它们成为正确架构的系统的一部分时,它们的扩展性非常好。 Rails 也是一个高度成熟的框架,它支持我们在 Braze 的文化,即快速迭代和产生真正的客户价值。 这些使 Ruby 和 Rails 成为我们的理想工具,我们计划在未来几年继续使用这些工具。

有兴趣在 Braze 工作吗? 我们正在为我们的工程、产品管理和用户体验团队招聘各种职位。 查看我们的职业页面,了解更多关于我们的空缺职位和文化的信息。