从单体应用到高度响应式的分布式系统


单体架构和分布式架构之间的对峙始于几年前,几乎就是在微服务于2011年首次作为一种新的架构方法问世之后。到现在,人们普遍认为,两者各有其优点和缺点。单体架构通常只有一个代码库,并且被部署成一个单独的应用程序。相比之下,微服务体系结构允许我们使用多个小型的服务来构建应用程序,每个服务都是独立开发和部署的。

在合适的场景下,单体服务和微服务都是理想的选择。但是,对于现实世界的系统,你无须在两个极端之间进行选择,实际上,你可以在两者之间选择一个最适合你的产品的架构方案。

在这篇文章中,我们将展示如何通过几个步骤将体系结构从单体系统转变为分布式系统,每次增加服务以找到系统复杂性和响应能力之间的平衡。

系统一览

我们正在开发的系统是数字版权和规则管理器。

一般来说,数字版权管理(DRM)是一种用于数字媒体版权保护的方法。DRM系统可防止未经授权的数字媒体重新分发,并限制了消费者复制其购买内容的方式。

从本质上讲,我们的系统会生成用于加密的密钥,并将加密后的内容存储在运营商的内容交付网络中。当用户想要播放此内容时,他们需要一个许可证,其中包含用于解密内容和策略的密钥。这些政策定义了内容的质量(HD,UHD),设置了地理限制和时间限制等。为了获得这个许可,用户需要访问我们的服务。

第一阶段:单体架构

最初,该系统是纯粹的单体架构,仅执行2个任务:
  1. 注册用户订阅详细信息,密钥和业务策略,并将它们存储在数据库中。终端用户通过管理UI获得了内容包的订阅。
  2. 当用户尝试播放内容时,处理来自客户端设备的许可请求;并颁发带有内容密钥和策略的许可证。


1.png

这种设计足以应付具有初始功能的多个租户:
  • 这个应用程序是水平可伸缩的,因为在负载平衡器后面部署应用程序的n个副本非常容易;
  • 开发和测试很容易:我们可以轻松地对整个应用程序进行端到端的测试并部署一个程序包。
  • 延迟是令人满意的,因为方法调用和共享内存访问比使用消息传递,RPC等进程间通信要快得多。


但是,在不断添加新功能的过程中,我们开始看到单体设计中的一些瓶颈
  1. 粗粒度的部署和扩展 - 意味着不可能分别扩展读或者写任务;
  2. 数据模型的妥协 - 只有一个数据模型,会导致在添加新索引以提高读取任务性能时对写入任务产生不利影响。
  3. 共享数据库 - 读取或写入的任何增长都会影响另一个。
  4. 数据库可伸缩性 - RDS数据库只能在垂直方向上扩展到硬件极限。
  5. 每个请求一个线程 - 由于应用程序使用“每个请求一个线程”的模型,线程需要等待IO操作(尤其是数据库调用),并且在此期间处于空闲状态。这使得基于CPU使用率的扩展策略变得毫无用处。


第二阶段:走向CQRS模式的第一步

一个合理的方案是将读和写的操作分别放到单独的服务中,因而可以按照所谓的“命令查询责任隔离”(CQRS)模式对它们进行独立扩展。

当我们将基础架构迁移到AWS时,我们开始使用PostgreSQL的复制集功能。PgSQL数据库最多可扩展到5个只读副本,从而使我们可以提供更多的许可证调用。但是此功能不支持多主(multi-master)。因此,一个集群只有一个主实例可以接受写入,同时具有5个只读副本。
2.png

这个设计对于中小型负载的场景非常有效,它具备以下优点:
  • 得益于只读副本,该系统可以服务于更多的许可证。
  • 在不同地区都有只读副本,因此我们能够从最近的数据中心向客户提供许可证。


但是,这让人感觉有点意犹未尽,因为其他系统的瓶颈仍然存在:
  1. 读取和写入仍使用相同的数据模型,标准化设计的数据库表并不适合服务于复杂的读取的业务场景,为此需要做多次表连接(Join),从而更加消耗CPU资源。
  2. 数据库仍然是主要瓶颈,无法对写入的场景进行扩展,因为我们只有单个主库能够写入,而读取限制为5个副本。
  3. 由于数据库的可伸缩性有限,因此随着负载的增加,对API调用的响应时间会有所不同,因此系统的响应速度不够快。


重要提示:

通过创建只读副本,我们已经放弃了重要的关系数据库(RDBMS)的特性:一致性。所有读取的副本最终都会达到一致。我们无法保证只读副本始终处于最新状态,因为从主节点到只读副本节点的数据同步需要时间。从这一点上理解,我们说这个系统能够做到最终一致性。

第三阶段:事件驱动的 (CQRS) 分布式系统

随着来自客户的负载增加,我们采用了事件溯源模式(Event-Sourcing)来解决数据库问题:
  1. 我们将许可服务拆分成侦听事件总线的多个微服务;
  2. 每个微服务都使用了自己的可横向扩展的键值存储,而不是PostgreSQL。
  3. 对应用程序的任何写入都将发布到事件总线,并且任何服务都可以侦听事件并针对最佳读取方案构建其自己的状态。


为了提高应用服务端的资源利用率,我们切换到具有Rx-Java的响应式框架Vertx来构建应用程序:
  1. 响应式框架是完全异步的,因此没有阻塞调用;
  2. 由于事件循环线程与CPU核心绑定,因此上下文切换较少,从而提高了CPU缓存的使用率;
  3. 没有上下文切换开销;
  4. 较小的线程数减少了内存占用;
  5. 现在CPU的使用是正常的,扩展策略可以依赖于CPU的使用情况。


3.png

最后,我们获得了一个真正的分布式系统,该系统具有分别用于读取和写入操作的单独的数据模型以及用于每个服务的单独的数据库。这种设计带来了新的优势:
  • 高度可扩展:由于Redis具有水平可扩展性,并且处于为查询服务提供数据的关键路径上,因此该系统现在具有比以前更高的可扩展性。
  • 灵活性:作为微服务的优势,可以用不同的技术栈构建每个服务
  • 响应能力:每个服务都有针对性优化的数据模型,使系统响应速度更快,性能更高。
  • 低延迟:由于Redis用作查询服务的持久数据存储,因此系统具有开箱即用的分布式缓存。
  • 可维护性:微服务将有助于将关注点分离,从而更易于维护代码。
  • 可调试性:通过在Kafka中导入所有的事件,现在我们可以在开发环境中重播事件并轻松调试问题。
  • 插件化:在任何时候,都可以向系统添加新服务以建立其状态。例如:Datawarehouse中新的爆表服务等
  • 可重播:由于每个事件按照发生的确切事件顺序存储在Kafka中,因此我们可以重播事件并建立状态。


需要考虑的事情

  1. 最终一致性:消费者需要花费一些时间从事件总线中读取消息并将其填充到数据存储中。因此,随着服务的响应,一些数据可能尚未填充。
  2. 约束和验证:由于数据存储在非关系数据库中,因此关系数据库提供的所有完整性验证都需要在应用程序中进行处理。
  3. 时序性:并行处理和关键逻辑的处理应谨慎进行。相关的事件应处于Kafka的同一分区(Partition)中,以维持顺序。
  4. 伸缩指标:应用程序不仅可以依靠传统的指标进行扩展,还应该根据消费主题中的消息数量来扩展消费者服务。
  5. 事件版本控制:在系统演化的过程中,事件架构会发生变化,因此最新使用者可能无法消费旧的事件模型,因为它们可能不兼容。由于无法重播旧事件,每当Redis发生故障时,就无法重新创建应用程序状态。一种可行的解决方案是对Redis进行定期快照,然后仅重播最新事件。


如你所见,每种解决方案都可以很好地完成某些特定任务,并且需要进行微调以找到针对特定挑战的最佳方法。在系统中添加新服务或拆分现有服务时,应始终牢记要实现的目标和所需要付出的成本。

目前,这个分布式系统运行良好,与此同时我们仍然在时刻关注前沿技术,以期将系统提升到一个新的水平,以应对不断增加的负载和性能要求。

原文链接:From a Monolithic to a Highly Responsive Distributed System(翻译:小灰灰)

0 个评论

要回复文章请先登录注册