分布式系统面临的挑战
Is it better to be alive and wrong or right and dead?
随着计算机技术的发展,系统架构从集中式演进到分布式。分布式系统相对于单台机器来说提供了更好的可扩展性,容错性以及更低的延迟,但在单台计算机上运行软件和分布式系统上运行软件却有着根本的区别,其中一点便是单台计算机上运行软件,错误是可预测的。当硬件没有故障时,运行在单台计算机的软件总是产生同样的结果;而硬件如果出现问题,那么后果往往是整个系统的故障。因此,对于单体系统来说,要么功能完好且正确,要么完全失效,而不是介于两者之间。
而分布式系统则复杂的多。分布式系统涉及到多个节点和网络,因而存在部分失效的问题。分布式系统中不可靠的网络会导致数据包可能会丢失或任意延迟,不可靠的时钟导致某节点可能会与其他节点不同步 ,甚至一个节点上的进程可能会在任意时候暂停一段相当长的时间(比如由于垃圾收集器导致)而被宣告死亡,这些都给分布式系统带来了不确定性和不可预测性。事实上,这些问题在分布式系统中是无法避免的,就像著名的CAP理论中提出的,P(网络分区)是永远存在的,而不是可选的。
既然分布式系统中故障是无法避免的,那么处理故障最简单的方法便是让整个服务失效,让应用“正确地死去”,但这并不是所有应用都能接受。故障转移企图解决该问题,当故障发生时将其中一个从库提升为主库,使新主库仍然对外提供服务。但是主从数据不一致、脑裂等问题可能会让应用“错误地活着”。代码托管网站Github在一场事故中,就因为一个过时的MySQL从库被提升为主库 ,造成MySQL和 Redis中数据产生不一致,最后导致一些私有数据泄漏到错误的用户手中 。为了减轻故障带来的影响,我们需要通过某种手段来确保数据的一致性,而如何验证大规模分布式系统在故障下依然正确和稳定(可靠性)成为了新的难题。
可靠性验证
分布式系统可靠性的验证可以采用形式化规范来进行,比如TLA+,但是这样的验证需要大量的特定理论知识。另一个方式是通过测试来验证,但普通的单元测试和集成测试无法覆盖到一些只有在高并发或者故障发生时才会出现的边缘情况,这些给分布式系统测试带来了新的挑战。
混沌工程的出现带来了新的验证思路,企业需要在测试阶段发现问题,通过“蓄意”引发故障来确保容错机制不断运行并接受考验,从而提高故障自然发生时系统能正确处理的信心。出身于SRE的Pavlos Ratis,在自己的GitHub 仓库awesome-chaos-engineering ,维护了与混沌工程相关的书籍、工具、论文、博客、新闻资讯、会议、论坛和 Twitter 账号。另外,故障注入后,除了观察系统的可用性,还需要保证系统提供的服务是正确的,也就是系统仍然需要符合预期的一致性,Jepsen目前被认为是工程领域在一致性验证方面的最佳实践(下图展示了Jepsen可验证的一致性模型)。
Jepsen能在特定故障下验证系统是否满足一致性,在过去5年里,Kyle Kingsbury已经帮助无数的早期分布式系统进行过测试,比如Redis、Etcd、Zookeeper等。Jepsen系统如下图所示,其由 6 个节点组成,一个控制节点,五个DB 节点。控制节点可以通过SSH登录到DB节点,通过控制节点的控制,可以在DB节点完成分布式系统的部署,组成一个待测试的集群。测试开始后,控制节点会创建一组进程,进程包含了待测试分布式系统的客户端。另一个Generator进程产生每个客户端执行的操作,并将操作应用于待测试的分布式系统。每个操作的开始和结束以及操作结果记录在历史记录中。同时,一个特殊进程Nemesis将故障引入系统。测试结束后,Checker分析历史记录是否正确,是否符合一致性。
Jepsen一方面提供了故障注入的手段,能模拟各种各样的故障,比如网络分区,进程崩溃、CPU超载等。另一方面,它提供了各种校验模型,比如Set、Lock、Queue等来检测各种分布式系统在故障下是否仍然满足所预期的一致性。通过Jepsen测试,能发现分布式系统在极端故障下的隐藏错误,从而提高分布式系统的容错能力。因此Jepsen测试被应用到许多分布式数据库或分布式协调服务集群的可靠性检测中,成为验证分布式系统一致性验证的重要手段。而现在我们以基于日志的分布式存储库DLedger和分布式消息队列RocketMQ为例,介绍Jepsen测试在分布式消息系统中的应用。
DLedger的Jepsen测试
DLedger是一个基于raft的java库,用于构建高可用性、高持久性、强一致性的commitlog。如下图所示,DLedger去掉了raft协议中状态机的部分,但基于Raft协议保证commitlog是一致的,并且是高可用的。
在对DLedger进行Jepsen测试之前,首先需要明确DLedger需要满足怎样的一致性。在Jepsen测试中,许多基于raft的分布式应用都采用线性一致性对系统进行验证。线性一致性是最强的一致性模型之一,满足线性一致性的系统,能提供一些唯一性约束的服务,比如分布式锁,选主等。但从DLedger的定位来看,它是一个Append only的日志系统,并不需要如此严格的一致性,数据的最终一致性更加符合我们对DLedger在故障下的正确性要求。因此采用Jepsen的Set测试对DLedger在各种故障下的一致性进行检测。
Set测试流程如下图所示,主要分为两个阶段。第一阶段由不同的客户端并发地向待测试集群添加不同的数据,中间会进行故障注入。第二阶段,向待测试的集群进行一次最终读取,获得读取的结果集。最后验证每一个成功添加的元素都在最终结果集中,并且最终的结果集也仅包含企图添加的元素。
在实际测试中,我们开启30个客户端进程并发地向待测试的DLedger集群添加连续不重复的数字,中间会引入特定故障,比如非对称网络分区,随机杀死节点等。故障引入的间隔时间是30s,即30s正常运行,30s故障注入,一直循环,整个阶段一共持续600s。并发写阶段结束以后,执行最终的读取,获得结果集并进行校验。
故障注入方面,我们测试以下几种故障注入:
- partition-random-node和partition-random-halves故障是模拟常见的对称网络分区。
- kill-random-processes和crash-random-nodes故障是模拟进程崩溃,节点崩溃的情况。
- hammer-time故障是模拟一些慢节点的情况,比如发生Full GC、OOM等。
- bridge和partition-majorities-ring模拟比较极端的非对称网络分区。
我们以随机网络分区故障partition-random-halves为例,分析测试结果。在测试完成后,日志中会出现如下图所示的结果:
可以看到测试过程中30个客户端一共发送了167354个数据(attempt-count),add成功返回167108个数据(acknowledged-count),实际成功添加167113个数据(ok-count),有5个由于请求超时或者多数认证超时导致无法确定是否添加成功,但却出现在最终读取结果集中的数据(recovered-count)。由于lost-count=0并且unexpected-count=0,因此最终一致性验证结果是通过的。
以图表的形式更好分析DLedger集群在测试过程中的表现情况。客户端对DLedger集群每一次操作的时延如下图所示。
其中蓝色框表示数据添加成功,红色框表示数据添加失败,黄色框表示不确定是否数据添加成功,图中灰色部分表示故障注入的时间段。可以看出一些故障注入时间段造成了集群短暂的不可用,一些故障时间段则没有,这是合理的。由于是随机网络分区,所以只有当前leader被隔离到少数节点区域才会造成集群重新选举,但即使造成集群重新选举,在较短时间内,DLedger集群也会恢复可用性。此外,可以看到由于DLedger对对称网络分区有较好的容错设计,每次故障恢复后,集群不会发生重新选举。
下图展示了DLedger在测试过程中时延百分位点图。
可以看到除了在一些故障引入后造成集群重新选举的时间段,时延升高,在其他的时间段,Dledger集群表现稳定,95%的数据添加延迟在5ms以下,99%的数据添加延迟在10ms以下。DLedger在随机对称网络分区故障注入下,表现稳定,符合预期。
除了随机对称网络分区,DLedger在其他5种故障注入下也均通过了Set测试的一致性验证,证明了DLedger对网络分区,进程、节点崩溃等故障的容错能力。
RocketMQ的Jepsen测试
Apache RocketMQ是一个具有低延迟、高性能、高可靠性和灵活可扩展性的分布式消息队列。RocketMQ从4.5.0版本之后支持DLedger方式部署,使单组broker具有故障转移能力,具有更好的可用性和可靠性。现在我们用Jepsen来检测RocketMQ DLedger部署模式的容错能力。
首先依旧需要明确RocketMQ在故障下需要满足怎样的一致性。Jepsen为分布式系统提供了total-queue的测试,total-queue测试需要系统满足入队的数据必须出队,也就是消息的传输必须满足at-least-once。这符合我们对RocketMQ在故障下正确性要求,因此采用total-queue对RocketMQ进行Jepsen测试。
total-queue测试如下图所示,主要分为两个阶段。第一阶段客户端进程并发地向集群随机调用入队和出队操作,入队和出队操作比例各占一半,中间会注入故障。第二阶段,为了保证每一个数据都出队,客户端进程调用drain操作,抽干队列。
在实际的测试过程中,我们开启4个客户端进程并发地向待测试的RocketMQ集群进行入队和出队操作,中间会引入特定故障。故障注入间隔时间是200s,整个阶段一共持续1小时。第一阶段结束以后,客户端执行drain操作,抽干队列。
依旧采用上文所述的六种故障注入进行测试,以随机杀死节点故障为例来分析测试结果(为了保证杀死节点个数不会导致整个集群不可用,代码保证每次故障注入只杀死少数个节点),测试完成后,出现如下图所示结果:
可以看到测试过程中30个客户端一共试图入队65947个数据(attempt-count),入队成功返回64390个数据(acknowledged-count),实际成功入队64390个数据(ok-count),无重复出队的数据,因此故障下的一致性验证是通过的。
我们以图表形式更好的分析故障下RocketMQ的表现。下图是客户端对RocketMQ集群每一次操作的时延图。
其中红色小三角形表示入队失败,如果一段时间内存在大量的红色小三角形则表示该时间段系统不可用,从图中可以发现在故障注入(灰色区域)初期存在一些系统不可用的时间段,这是故障引发集群重新选举造成的,一段时间后集群仍能恢复可用性。但是可以发现在故障恢复后,也存在系统不可用的时间段,这并不符合预期。
通过日志排查发现,故障恢复后集群不可用的时间几乎都在30秒左右,这正是broker向nameserver的注册间隔。进一步排查发现,这段时间内nameserver中master broker路由信息出现了丢失。原来在故障恢复后,被杀死的broker进程进行重启,此时默认brokerId为零,在brokerId被修改之前,broker向nameserver进行注册,从而覆盖了原本master broker路由信息,造成集群在该段时间内不可用。对该问题进行修复并重新进行Jepsen测试,重新测试的时延图如下图所示。
重新测试的结果表明问题已经被修复,故障恢复后不存在不可用的时间段。通过Jepsen测试,我们发现了RocketMQ DLedger部署模式在故障注入下可用性方面的问题,并从代码上进行了优化,贡献给RocketMQ社区。我们也检测了其他故障注入下RocketMQ的表现情况,均通过了total-queue测试的一致性验证。
Jepsen测试的一些思考
以DLedger和RocketMQ为例,我们利用Jepsen对分布式消息系统进行了故障下的一致性验证。在测试过程中,也发现了Jepsen框架存在的一些缺陷。
Jepsen测试无法长时间运行。Jepsen测试长时间运行会产生大量的数据,这导致其校验阶段出现OOM,但在实际场景中,许多深藏的bug需要长时间的压力测试、故障模拟才能发现,同时系统的稳定性也需要长时间的运行才能被验证。
Jepsen测试提供的模型还无法完全覆盖到特定领域。比如在分布式消息领域,Jepsen仅提供了queue和total-queue的测试,来验证消息系统在故障下是否会出现消息丢失,消息重复。但是对于分布式消息队列重要的分区顺序性、全局顺序性、重平衡算法的有效性并未覆盖到。
分布式消息标准openmessaging社区也试图解决这些问题,从而提供消息领域更加完备的可靠性验证。DLedger的Jepsen测试代码也已经放到openmessaging的openmessaging-dledger-jepsen仓库下,并提供了Docker启动模式,方便用户能快速地在单台机器上进行测试。
故障所引发的错误代价非常大,电商网站的中断会导致收入和声誉的巨大损失,云厂商提供的系统发生宕机或故障时,会给用户或他们的用户带来沉痛的代价。如何让分布式系统在困境(硬件故障、软件故障、人为错误)中仍可正确完成功能,并能达到期望的性能水准,这不仅要从算法设计和代码实现上解决,还需要利用分布式系统测试工具提前模拟各种故障,从失败中找到深层的问题,提高系统的容错能力。这样才能在意外真的发生时,将意外的损失降到最低。
本文作者:金融通