公告
同事们,仲夏时节,我计划发布另一系列关于排队系统设计的文章:《VTrade 实验》——尝试编写一个交易系统框架。 该系列将探讨建立交易所、拍卖和商店的理论和实践。 在文章的最后,我邀请您为您最感兴趣的主题投票。
这是 Erlang/Elixir 分布式反应式应用程序系列中的最后一篇文章。 在
今天我们将提出代码库和项目的总体开发问题。
服务组织
在现实生活中,开发一项服务时,您通常必须在一个控制器中组合多种交互模式。 例如,解决管理项目用户配置文件问题的用户服务必须响应 req-resp 请求并通过 pub-sub 报告配置文件更新。 这种情况非常简单:消息传递背后有一个控制器来实现服务逻辑并发布更新。
当我们需要实现容错的分布式服务时,情况会变得更加复杂。 假设用户的需求发生了变化:
- 现在服务应该处理 5 个集群节点上的请求,
- 能够执行后台处理任务,
- 并且还能够动态管理个人资料更新的订阅列表。
备注: 我们不考虑一致性存储和数据复制的问题。 我们假设这些问题已经得到了较早的解决,并且系统已经具有可靠且可扩展的存储层,并且处理程序具有与其交互的机制。
用户服务的正式描述变得更加复杂。 从程序员的角度来看,由于消息传递的使用,变化很小。 为了满足第一个要求,我们需要在 req-resp 交换点配置平衡。
处理后台任务的需求经常出现。 对于用户来说,这可能是检查用户文档、处理下载的多媒体或与社交媒体同步数据。 网络。 这些任务需要以某种方式分布在集群内并监视执行进度。 因此,我们有两个解决方案选项:要么使用上一篇文章中的任务分配模板,要么,如果不适合,则编写一个自定义任务调度程序,它将按照我们需要的方式管理处理器池。
第 3 点需要 pub-sub 模板扩展。 为了实现,在创建 pub-sub 交换点后,我们需要在我们的服务中额外启动该点的控制器。 因此,就好像我们正在将处理订阅和取消订阅的逻辑从消息传递层转移到用户的实现中。
结果,问题的分解表明,为了满足要求,我们需要在不同的节点上启动5个服务实例,并创建一个额外的实体——一个pub-sub控制器,负责订阅。
要运行 5 个处理程序,您不需要更改服务代码。 唯一的额外操作是在交换点设置平衡规则,我们稍后会讨论。
还有一个额外的复杂性:发布-订阅控制器和自定义任务调度程序必须在单个副本中工作。 同样,消息服务作为基础服务,必须提供一种选择领导者的机制。
领导者的选择
在分布式系统中,领导者选举是指定单个进程负责调度某些负载的分布式处理的过程。
在不易中心化的系统中,会使用通用且基于共识的算法,例如 paxos 或 raft。
由于消息传递是一个代理和一个中心元素,因此它了解所有服务控制器 - 候选领导者。 消息可以指定领导者而无需投票。
启动并连接到交换点后,所有服务都会收到系统消息 #'$leader'{exchange = ?EXCHANGE, pid = LeaderPid, servers = Servers}
。 如果 LeaderPid
与...匹配 pid
当前流程,被指定为leader,列表 Servers
包括所有节点及其参数。
当新的集群节点出现并且工作集群节点断开连接时,所有服务控制器都会收到 #'$slave_up'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts}
и #'$slave_down'{exchange = ?EXCHANGE, pid = SlavePid, options = SlaveOpts}
分别
这样,所有组件都知道所有更改,并且保证集群在任何给定时间都有一个领导者。
中介
为了实现复杂的分布式处理过程,以及优化现有架构的问题,使用中介很方便。
为了不更改服务代码并解决其他处理、路由或记录消息等问题,您可以在服务之前启用代理处理程序,它将执行所有其他工作。
pub-sub 优化的一个经典示例是分布式应用程序,其业务核心生成更新事件(例如市场价格变化),以及访问层 - N 个服务器为 Web 客户端提供 websocket API。
如果你正面决定,那么客户服务看起来像这样:
- 客户端与平台建立连接。 在终止流量的服务器端,启动一个进程来服务该连接。
- 在服务进程的上下文中,发生更新的授权和订阅。 该过程调用主题的订阅方法。
- 一旦在内核中生成事件,它就会被传递到为连接提供服务的进程。
假设我们有 50000 个“新闻”主题的订阅者。 订阅者均匀分布在 5 台服务器上。 因此,到达交换点的每个更新都将被复制 50000 次:根据服务器上的订阅者数量,在每台服务器上复制 10000 次。 这不是一个非常有效的计划,对吗?
为了改善这种情况,我们引入一个与交换点同名的代理。 全局名称注册器必须能够按名称返回最接近的进程,这一点很重要。
让我们在访问层服务器上启动这个代理,所有服务于 websocket api 的进程都将订阅它,而不是内核中原始的 pub-sub 交换点。 代理仅在唯一订阅的情况下订阅核心,并将传入消息复制到其所有订阅者。
结果,内核和访问服务器之间将发送 5 条消息,而不是 50000 条。
路由和平衡
请求-响应
在当前的消息传递实现中,有7种请求分发策略:
default
。 该请求被发送到所有控制器。round-robin
。 请求被枚举并在控制器之间循环分发。consensus
。 提供服务的控制器分为领导者和从者。 请求仅发送给领导者。consensus & round-robin
。 该组有一名领导者,但请求会分发给所有成员。sticky
。 计算哈希函数并将其分配给特定的处理程序。 具有此签名的后续请求将转到相同的处理程序。sticky-fun
。 初始化交换点时,哈希计算函数为sticky
平衡。fun
。 与 Sticky-fun 类似,只有您可以额外重定向、拒绝或预处理它。
分配策略是在交换点初始化时设置的。
除了平衡之外,消息传递还允许您标记实体。 我们来看一下系统中的标签类型:
- 连接标记。 让您了解事件是通过哪个连接发生的。 当控制器进程连接到同一交换点但具有不同的路由键时使用。
- 服务标签。 允许您将处理程序组合成一项服务的组,并扩展路由和平衡功能。 对于 req-resp 模式,路由是线性的。 我们向交换点发送请求,然后交换点将其传递给服务。 但是,如果我们需要将处理程序拆分为逻辑组,则可以使用标签完成拆分。 当指定标签时,请求将被发送到特定的一组控制器。
- 请求标签。 允许您区分答案。 由于我们的系统是异步的,为了处理服务响应,我们需要能够在发送请求时指定 RequestTag。 从中我们将能够了解我们收到的请求的答案。
发布-订阅
对于发布-订阅,一切都稍微简单一些。 我们有一个发布消息的交换点。 交换点在订阅了所需路由密钥的订阅者之间分发消息(我们可以说这类似于主题)。
可扩展性和容错性
系统整体的可扩展性取决于系统各层和组件的可扩展程度:
- 通过向集群添加额外的节点以及该服务的处理程序来扩展服务。 试运行时,您可以选择最优的平衡策略。
- 单独集群内的消息传递服务本身通常通过将特别负载的交换点移动到单独的集群节点,或者通过向集群的特别负载的区域添加代理进程来扩展。
- 整个系统的可扩展性作为一个特征取决于架构的灵活性以及将各个集群组合成公共逻辑实体的能力。
项目的成功通常取决于扩展的简单性和速度。 当前版本中的消息传递随着应用程序的发展而增长。 即使我们缺少 50-60 台机器的集群,我们也可以诉诸联邦。 不幸的是,联合主题超出了本文的范围。
预订
在分析负载均衡时,我们已经讨论过服务控制器的冗余。 然而,消息传递也必须保留。 如果节点或机器崩溃,消息传递应该在尽可能短的时间内自动恢复。
在我的项目中,我使用额外的节点来承受跌倒时的负载。 Erlang 为 OTP 应用程序提供了标准的分布式模式实现。 分布式模式通过在另一个先前启动的节点上启动失败的应用程序来在发生故障时执行恢复。 该过程是透明的;发生故障后,应用程序会自动转移到故障转移节点。 您可以阅读有关此功能的更多信息
Производительность
让我们尝试至少粗略地比较一下rabbitmq 和我们自定义消息传递的性能。
我发现
在第 6.14.1.2.1.2.2 段中。 原始文档显示了 RPC CAST 的结果:
我们不会提前对操作系统内核或erlang VM进行任何额外的设置。 测试条件:
- erl 选择:+A1 +sbtu。
- 单个 erlang 节点内的测试是在移动版本带有旧 i7 的笔记本电脑上运行的。
- 集群测试在10G网络的服务器上进行。
- 该代码在 Docker 容器中运行。 网络采用NAT模式。
测试代码:
req_resp_bench(_) ->
W = perftest:comprehensive(10000,
fun() ->
messaging:request(?EXCHANGE, default, ping, self()),
receive
#'$msg'{message = pong} -> ok
after 5000 ->
throw(timeout)
end
end
),
true = lists:any(fun(E) -> E >= 30000 end, W),
ok.
1脚本: 测试在旧版 i7 移动版笔记本电脑上运行。 测试、消息传递和服务在一个 Docker 容器的一个节点上执行:
Sequential 10000 cycles in ~0 seconds (26987 cycles/s)
Sequential 20000 cycles in ~1 seconds (26915 cycles/s)
Sequential 100000 cycles in ~4 seconds (26957 cycles/s)
Parallel 2 100000 cycles in ~2 seconds (44240 cycles/s)
Parallel 4 100000 cycles in ~2 seconds (53459 cycles/s)
Parallel 10 100000 cycles in ~2 seconds (52283 cycles/s)
Parallel 100 100000 cycles in ~3 seconds (49317 cycles/s)
2脚本:3个节点在docker(NAT)下运行在不同的机器上。
Sequential 10000 cycles in ~1 seconds (8684 cycles/s)
Sequential 20000 cycles in ~2 seconds (8424 cycles/s)
Sequential 100000 cycles in ~12 seconds (8655 cycles/s)
Parallel 2 100000 cycles in ~7 seconds (15160 cycles/s)
Parallel 4 100000 cycles in ~5 seconds (19133 cycles/s)
Parallel 10 100000 cycles in ~4 seconds (24399 cycles/s)
Parallel 100 100000 cycles in ~3 seconds (34517 cycles/s)
在所有情况下,CPU 利用率均不超过 250%
结果
我希望这个周期看起来不像是一个思维转储,我的经验将对分布式系统的研究人员和刚开始为其业务系统构建分布式架构并对 Erlang/Elixir 感兴趣的从业者带来真正的好处,但有疑问是否值得...
照片
只有注册用户才能参与调查。
作为 VTrade 实验系列的一部分,我应该更详细地介绍哪些主题?
-
理论:市场、订单及其时间:DAY、GTD、GTC、IOC、FOK、MOO、MOC、LOO、LOC
-
订单书。 实现一本书分组的理论与实践
-
交易可视化:价格变动、柱线、分辨率。 如何储存和如何粘合
-
后台。 规划和发展。 员工监控和事件调查
-
API。 让我们弄清楚需要哪些接口以及如何实现它们
-
信息存储:交易系统中的 PostgreSQL、Timescale、Tarantool
-
交易系统的反应性
-
其他。 我会写在评论里
6 位用户投票。 4 名用户弃权。
来源: habr.com