ovirt-engine水平扩展可行性分析

1. 基础概念

本文先简单介绍下与分布式系统相关的几个概念。

1.1. 分布式锁

所谓分布式锁是指在一个分布式集群中,同一个方法在同一时间只能被一台机器上的一个线程执行,也就是所谓的分布式互斥。就像单机系统上的多线程程序需要用操作系统锁或数据库锁来互斥对共享资源的访问一样,分布式程序也需要通过分布式锁来互斥对共享资源的访问。分布式锁是保障数据一致性的手段之一。
一般情况下,我们可以使用数据库、Redis或者ZooKeeper来做分布式锁服务。不管怎么样,分布式的锁服务需要有以下几个特点:
1)安全性:在任意时刻,只有一个客户端可以获得锁(排他性)
2)避免死锁:客户端最终一定可以获得锁,即使锁住某个资源的客户端在释放锁之前崩溃或网络不可达
3)容错性:只要锁服务集群中的大部分节点存活,客户端就可以进行加锁解锁的操作

即分布式锁服务在实现上一般关注如下三个问题:
1)锁获取机制:超时释放导致多方获取同一把锁问题(CAS机制)
2)锁释放机制:正常释放、超时释放
3)客户端如何知道锁被释放:客户端不断重试、服务端主动通知

1.2. 数据一致性

说起数据一致性,简单说有三种类型:
1)Weak(弱一致性):当你写入一个新值后,读操作在数据副本上可能读出来,也可能读不出来。比如:某些cache系统,网络游戏其它玩家的数据和你没什么关系。
2)Eventually(最终一致性):当你写入一个新值后,有可能读不出来,但在某个时间窗口之后保证最终能读出来。比如:DNS,电子邮件、Amazon S3,Google搜索引擎这样的系统。
3)Strong(强一致性):新的数据一旦写入,在任意副本任意时刻都能读到新值。比如:文件系统,RDBMS,Azure Table都是强一致性的。

从这三种一致型的模型上来说,我们可以看到,Weak和Eventually一般来说是异步冗余的,而Strong一般来说是同步冗余的,异步的通常意味着更好的性能,但也意味着更复杂的状态控制。同步意味着简单,但也意味着性能下降。

1.3. 分布式事务与数据一致性

单纯讲理论比较枯燥,本节我们会结合例子来描述。

让我们用最经典的Use Case:“A帐号向B帐号汇钱”来说明一下,熟悉RDBMS事务的都知道从帐号A到帐号B需要6个操作:

1)从A帐号中把余额读出来。
2)对A帐号做减法操作。
3)把结果写回A帐号中。
4)从B帐号中把余额读出来。
5)对B帐号做加法操作。
6)把结果写回B帐号中。

为了数据的一致性,这6件事,要么都成功做完,要么都不成功,而且这个操作的过程中,对A、B帐号的其它访问必需锁死,所谓锁死就是要排除其它的读写操作,不然会有脏数据的问题,这就是分布式事务。

目前业界用于实现分布式事务的方案有:
1)2PC(两阶段提交):可以数据强一致性,但存在性能问题、协调过程中TimeOut问题(协调者可用性问题)
2)3PC(三阶段提交):其核心理念是:在询问的时候并不锁定资源,除非所有人都同意了,才开始锁资源。
3)事务补偿机制:并行处理一个事务的多个阶段,然后根据不同阶段的执行结果进行相应的业务调整(也称事务补偿),其通常是基于工作流引擎来实现,只保证数据最终一致性,可实现高性能。如电商的秒杀功能经常这么设计:下单成功与订单确认机制。

可进一步学习数据库事务ACID属性的变种:BASE(Basic Availability 基本可用,Soft state 软状态,Eventual Consistency 最终一致性)以及基于BASE的事务补偿。

扩展阅读(加深理解)

当我们在生产线上用一台服务器来提供数据服务的时候,会遇到如下的两个问题:
1)一台服务器的性能不足以提供足够的能力服务于所有的网络请求。
2)我们总是害怕我们的这台服务器停机,造成服务不可用或是数据丢失。

于是我们不得不对我们的服务器进行扩展,加入更多的机器来分担性能上的问题,以及来解决单点故障问题。 通常,我们会通过两种手段来扩展我们的数据服务:
1)数据分区:就是把数据分块放在不同的服务器上。
2)数据镜像:让所有的服务器都有相同的数据,提供相当的服务。

对于第一种情况,我们无法解决数据丢失的问题,单台服务器出问题时,会有部分数据丢失。所以,数据服务的高可用性只能通过第二种方法来完成——数据的冗余存储(一般工业界认为比较安全的备份数应该是3份)。 但是,加入更多的机器,会让我们的数据服务变得很复杂,尤其是跨服务器的事务处理,也就是跨服务器的数据一致性。这个是一个很难的问题。

那么,我们在加入了更多的机器后,这个事情会变得复杂起来:
1)在数据分区的方案中:如果A帐号和B帐号的数据不在同一台服务器上怎么办?我们需要一个跨机器的事务处理。也就是说,如果A的扣钱成功了,但B的加钱不成功,我们还要把A的操作给回滚回去。
2)在数据镜像的方案中:A帐号和B帐号间的汇款是可以在一台机器上完成的,但是别忘了我们有多台机器存在A帐号和B帐号的副本。如果对A帐号的汇钱有两个并发操作(要汇给B和C),这两个操作发生在不同的两台服务器上怎么办?也就是说,在数据镜像中,在不同的服务器上对同一个数据的写操作怎么保证其一致性,保证数据不冲突?此时分布式锁也许就可以派上用场了。

对于分布式系统,除了上面的可用性、数据一致性,我们还要考虑性能的因素,如果不考虑性能的话,事务得到保证并不困难,系统慢一点就行了。除了考虑性能外,我们还要考虑可用性,也就是说,一台机器没了,数据不丢失,服务可由别的机器继续提供。 于是,我们需要重点考虑下面的这么几个情况:

1)容灾:数据不丢、结点的Failover
2)数据的一致性:事务处理(锁)
3)性能:吞吐量 、 响应时间

前面说过,当出现某个节点的数据丢失时可以从副本读到,数据副本是分布式系统解决数据丢失异常的唯一手段。为简单起见,我们只讨论在**数据冗余(数据镜像)**方案下考虑数据的一致性和性能的问题。简单说来:

1)要想让数据有高可用性,就得写多份数据。
2)写多份的问题会导致数据一致性的问题。
3)数据一致性的问题又会引发性能问题

我们似乎看到了分布式场景下的CAP理论的影子(有兴趣的自行深入研究,这里不再展开)。

1.4. 服务的状态

所谓“状态”,是指程序运行中的一些数据或是程序运行上下文。比如用户每一次请求在服务端所保留下来的数据(记录),像用户登录时的Session,我们需要使用这个Session来判断这个请求的合法性;还有一个业务流程中需要让多个服务组合起来形成一个业务逻辑的运行上下文Context,这些都是状态。

无状态的服务(Stateless)

一直以来,无状态的服务被当成分布式服务设计的最佳实践和铁律。因为无状态的服务对于扩展性和运维实在是太方便了,没有状态的服务可以随意地增加和减少节点,可以随意的搬迁,而且可以大幅度降低代码的复杂度。

但是现实世界是一定会有状态的,这些状态可能表现在如下几个方面:

  • 程序调用的结果。
  • 服务组合下的上下文。
  • 服务的配置。

为了做出无状态的服务,我们通常需要把状态保存到一个第三方的地方,比如Redis,ZooKeeper/Etcd这样的高可用存储中。从另一角度讲,为了实现无状态服务会导致应用服务依赖于第三方有状态的存储服务,同时也增加了网络开销,会增加服务的响应时间。

有状态的服务(Stateful)

在互联网领域,有状态的服务看上去比较“反动”。因为无状态服务需要把状态存放在第三方存储上,这样便增加了网络开销,为了减少网络开销有时就引入本地数据缓存,此时如果未引入Sticky Session机制,用户的每次请求并不一定会路由至同一台机器,结果将导致所有机器上都会创建相同的数据缓存,这也算是一种资源浪费。而如果引入了Sticky Session机制便可解决该资源浪费问题。

所谓Sticky Session,就是对于客户端传来的请求,都能保证其落在同一台机器上,相当是数据分片。这样我们完全不需要考虑数据会被加载到不同的节点,这样的架构模型就变简单了。通过一致性哈希便可以实现Sticky Session,但只是简单的使用一致性哈希会导致负载与数据不均匀(哈希环平衡又是另一个话题了,本文不深入讨论,有需要的请求参考这里)。

1.5. 水平扩展

水平扩展也叫横向扩展,是指为分布式应用集群添加一个新的服务节点来提升该分布式应用的处理能力。一般是在单机节点性能已无法进一步优化的情况下才会进一步考虑水平扩展。

一个可水平扩展的应用系统,其架构设计一般都要考虑以下几点:

  • 服务的状态(分布式状态复制 或 集中缓存服务)
  • 分布式环境下的线程安全机制(分布式锁 或 一致性哈希)
  • 数据一致性(分布式事务 )

2. engine水平扩展关键问题(现状)

1)使用本地锁:当前Engine代码中大量使用JDK提供的锁机制来保证对同一个VDSM的并发请求,最典型的就是InMemoryLockManager提供的锁操作,其需要扩展成支持分布式锁。

VDSM是否支持并发请求?

2)有状态服务及认证会话:Engine中使用的EJB、部分服务实例(如ResourceManager)是有状态的服务。

3)定时计划任务:Engine中的定时任务也需要调整,否则会导致每个engine节点在启动后都执行相同的计划任务。

4)数据库:数据库服务目前未提供HA服务(这部分可独立考虑,个人认为不需要在本方案中考虑)

5)配置文件:配置文件也需要在集群成员间同步,目前版本的engine也不支持。

3. 备选方案

3.1. 本地锁问题

为了在分布式环境下实现VM等资源互斥访问需求,有两种方案:
1)基于一致性哈希来保证每次请求都落在同一个engine节点
2)引入分布式锁:Redis、ZooKeeper或基于infinispan自行实现

方案1:实现起来相对简单,只需要在每个engine中实现hash redirect模块,但若出现节点数量变更重建hash环的过程中仍然无法保证资源互斥问题。

方案2:性能会有所损失,同时改造量比较大,需要找出现有代码中所有本地锁并调整为分布式锁;

由于我们云管平台的使用场景并非像互联网产品会频繁的变更engine节点,因此重建hash环的概率相当低,同时VDSM本身也有并发保护机制,因此优先考虑使用方案1,后续随着对engine代码的深入研究再进一步考虑方案2。

3.2. 服务状态问题

同样的两种方案:
1)基于一致性哈希来保证每次请求都落在同一个engine节点
2)分布式缓存

方案1:与3.1节的需求不同,节点宕机导致哈希环重建会导致缓存数据迁移或丢失,该方案容错性太差,因此不能够用它来保障业务。(我们引入一致性哈希只是为了实现资源互斥而已,这点要搞清楚。)

方案2:由于engine是运行在wildfly服务器中,而wildfly原生提供了分布式缓存服务infinispan,同时其对Session复制也提供了现成的支持,在技术上不存在障碍。主要还是工作量问题,即需要识别出各个有状态的服务如ResourceManager,没有其他捷径。

3.3. 定时计划任务

由于engine的计划任务是基于quartz,而quartz是支持分布式场景的,因此这部分也不存在技术问题。

3.4. 配置文件

可选择的方案:
1)使用基础平台提供的文件同步方案
2)基于ZooKeeper或Etcd开源方案实现文件同步(该方法对部署有要求,最少是三个节点)

优先使用方案1,如果基础平台未提供则采用方案2。

4. 总结

总得来说,ovirt-engine的水平扩展方案在技术上是可行的且不存在技术障碍,主要还是源码分析的工作量问题。

参考资料