分布式数据库“未来工房”:第 2 回 何为高可用性 —— Riak 功能探究与测试

文 / 上西康太(Basho Japan 股份有限公司 kota@basho.com)

译/苏祎

上一回,我们介绍了 Riak 的另类使用方法。但其原始设计思想、API、使用方法等介绍都比较有限,很多读者可能还没很好地了解 Riak。起初,Riak 是 Basho 的工程师为了在自己的服务上使用而创造出来的,它的基本特点为:

  • 扩展性——添加服务器后,容量、性能都能相应地增强。

  • 可用性——压力超过负荷时也能稳定地运行、响应。

  • 容错性——发生多个故障时不丢失数据、不停止服务。

由此可见,维持系统稳定是 Riak 最主要的目标。高可用性,是 Riak 的基础特性,本文将以此为着眼点,介绍 Riak 是基于怎样的目标设计出来的。

Riak 存活监控的机制

首先我们来介绍 Riak 的基础构成。分布式数据库的构成基本上都由“选择并判断数据服务器”和“存放数据到选择好的服务器中”这两个步骤来完成。首先,先介绍选择并判断数据服务器的方法。

Riak 通过 bucket 名和 key 来确定应该保存数据的服务器。ring size 是一个常数。ring 是分区(partition)序号和服务器地址的对照表。分区序号指的是把 2160 的整数空间用 ring size 分割为常数个,这些区间按顺序被分配到的序号。(注:这个常数被称为 ring size)。各个分割开的空间被称为分区。实际存放数据的实体被称为 vnode。存放在 Riak 中的数据基本上以 vnode 为单位进行管理。实际应该存放的服务器,根据 bucket 名 b 和 key k,计算 160 位的整数的 hash 函数 Hash(_b, k),ringsize 假定为 NR 的情况下,按公式

首先计算 n。Riak 用 nn+1、n+2 来表示应复制数据的 vnode。然后,以该分区序号为基础,查找 ring,获得应该存储数据的服务器的地址。这个就相当于从 ring 顺时针挑选出 3 个分区。

实际上在发送请求时,还要考虑该服务器是否还在运行。Riak 经常对组成集群的服务器进行存活监控。如果遇到 ring 中的服务器突然不能访问的情况,就从 ring 上顺时针选出一台应该访问的服务器(图 1)。

图 1 找到 key 位置的机制。被称为 ring 的数据就是可以根据分区号码获得 node ID 的 map

Erlang/OTP 的存活监控

应该如何实现集群中服务器互相监控呢?并不存在其他专门负责监控的分布式数据库服务器,基本上是用 N 对 N 的形式互相监控。这其实不是 Riak 的功能,而是使用了 Erlang/OTP 的功能(distributed Erlang,分布式 Erlang)。

分布式 Erlang 默认每 15 秒交换 KEEPALIVE 通信,若最后一次超过 60 秒,这个节点就被认为退出了集群。接下来,我们来确认一下实际中分布式 Erlang 存活监控的行为。一台服务器上如果针对不同进程、不同操作系统,也可以通过内核检查出 linkdown 等,例如就算只有某个进程终止,也能很快被检查出来。

为了再现实际发生的 silent 故障,需要验证物理服务器网线的连接状况。实验方案就是,以图 2 这样的结构,在左右的服务器使用分布式 Erlang 连接的情况下,拔掉 * 部分的网线看看会发生什么情况。

图 2

首先,在A服务器上启动Erlang(画面1)。使用-name 指定集群上的标识符和IP地址。-setcookie 是 Erlang 进程互相确认连接可行性的信息,若不一致,就不能建立连接。在 B 服务器上已经预先启动好了 Erlang。

$ erl -name dev@192.168.100.128 -setcookie foo
Erlang R15B03 (erts-5.9.3.1) [source] [64-bit] [smp:8:8] [async-threads:0] [kernel- ?
poll:false]

Eshell V5.9.1 (abort with ^G)
(dev@192.168.100.128)1>

画面 1 在 A 服务器上启动 Erlang

接着,在 A 服务器上向 B 服务器发送分布式 Erlang 的 ping。

画面 2 的 net_adm:ping/1 虽然是名为 ping,但包含了若未与 B 建立连接,就与其建立连接的动作。如果能正常和 B 服务器连接的话,会返回 pong。如果不能连接的话,会立即返回 pang(注:这里使用 ping 来检查是否已经成为集群的一员,并不是用来向对方发送包来确认连接情况。关于集群成员会在后面说明)。

$ erl -name dev@192.168.62.223 -setcookie foo
Erlang R15B01 (erts-5.9.1) [source] [64-bit] [smp:2:2] [async-threads:0] [kernel-poll:false]

Eshell V5.9.1 (abort with ^G)
(dev@192.168.62.223)1> net_adm:ping('dev@192.168.100.128').
pong

画面 2 对 B 发送 ping

(dev@192.168.62.223)2> rpc:call('dev@192.168.100.128', erlang, display, [["hogehoge~n"]]).
true

画面 3 从 A 这里进行 RPC 调用

分布式Erlang非常优秀,RPC(Remote Procedure Call,远程过程调用)的调用形式也和普通函数调用相同。例如,在 A 服务器执行画面 3 的代码,在 B 服务器的控制台上会如下显示。

(dev@192.168.100.128)1> ["hogehoge~n"]

这样,通过使用分布式 Erlang,可以方便地在远程机器上启动程序。并且,能向远程机器清晰地发送消息这样的优秀功能也有很多,Riak 在多处使用了分布式 Erlang 的功能。

查看 net_kernel 模块的 manpage,就可以看出它的大致设计。其中最重要的是使用 Ticktime 概念,它主要是检查出延时(timeout)的计时器(Timer)。这个间隔可以通过 net_kernel:set_net_ticktime/2 来设置。

在实际中,Erlang/OTP 以 15 秒为间隔交换 Keepalive 消息。接着,在最后一次成功 keepalive 的 60 秒后,若没有任何响应,就认为这个节点的通信中断了。

我们来实际验证一下这个动作。首先使用 erlang:monitor_node/2,让A服务器监控B服务器,接着,观察拔掉网线后的情况。我们的预想是,在网线拔掉大约 60 秒后,消息 {nodedown, 'dev@192.160.100.128'} 会从 net_kernel 发送过来,如画面 4 所示。

(dev@192.168.62.223)3> erlang:monitor_node('dev@192.168.100.128', true).
true
(dev@192.168.62.223)4> erlang:display(erlang:localtime()), receive R -> {R,
erlang:localtime()} end.
{{2013,6,19},{21,9,30}}
这里拔掉192.168.100.128一侧的网线
{{nodedown,'dev@192.168.100.128'},{{2013,6,19},{21,10,22}}}
(dev@192.168.62.223)5>
=ERROR REPORT==== 19-Jun-2013::21:10:22 ===
** Node 'dev@192.168.100.128' not responding **
** Removing (timedout) connection **

画面 4 拔掉网线后

实际操作中,在时间显示 21:09:30 的瞬间拔掉网线,大约 1 分钟后,nodedown 的消息过来了。这个时间有一定的误差,B 服务器上也显示了如下信息。

=ERROR REPORT==== 19-Jun-2013::21:10:22
===
** Node 'dev@192.168.62.223' not
responding **
** Removing (timedout) connection **

消息中的时间,连秒数都一致纯属偶然,实际中的大规模环境里,可能不会这么精确。

如果可能的话,在相同机器上创建两个进程 再验证一遍,然后与 B 中 kill 掉 UNIX 进程的情况进行比照。这个情况下,接收到 kill 的内核会立即通知 A 的 socket,A 会立即知道 B 已经终止。

这就是分布式 Erlang 的存活监控的机制。若检测到 L2 层以上的中断,基本应用层会立即 收到通知,若是 L1 层级别发生了中断,大约需要 1 分钟才能知道。

收到通知后 Riak 的处理

如上描述,从分布式 Erlang 获知 nodedown 消息的 Riak,调整 PUT 等请求的转送地址,向 ring 上的下一个节点执行 handoff(注:handoff 为将发往 vnode 的请求交予临时代理来处理)。使用这样的机制,除去网络上的 silent 故障,几乎所有的情况下,Riak 都能保证访问正常运行的节点。即便是遇到 silent 故障,客户端等待的 时间也只有 60 秒左右。

因为设计为没有其他特殊锁定处理,所以几乎不会让客户端的请求等待。这就是实现 Riak 高可用性的机制。

故障时的行为和处置

节点故障时

节点故障也分很多情况。大致可分为因 HDD 故障导致数据丢失的情况和其他情况。

HDD 没有发生故障的情况下(即 Riak 用来保存数据的目录没有问题)就非常简单了。换下主板、电源、内存等故障部件,重新安装操作系统,分配一个和故障前相同的 IP 地址。接着,启动 Riak,就和以前一样能加入到集群中了。关机的节点重新启动的话,故障期间其他服务器在 Handoff 中接收到的数据就开始传送过来。这种情形能通过 riak-admin transfers 来观察。

HDD 发生故障,导致服务器上数据丢失的情况下,需要通过复制机(replica)来恢复数据。在这个状态下,其他服务器将其认为是临时故障,会等待相同 IP 地址再次启动(一般来说,难以自动区分是临时故障还是永久性故障,需要操作者在集群中指示)。替代服务器不能立即准备好的情况下,可以使用画面 5 这样的命令把故障的服务器强制从集群中移除。

$ riak-admin down riak@10.1.2.3
$ riak-admin cluster force-remove ?
riak@10.1.2.3
$ riak-admin cluster plan
$ riak-admin cluster commit

画面 5 集群的强制退出

重新设置集群内的 vnode,将减少到 2 的复制数恢复到 3。完成后,执行上期介绍的 join 命令来添加新的服务器。

如果能迅速准备好的话,最好在将故障服务器从集群中移除的同时,把新服务器加入到集群中(画面 6)。

$ riak-admin down riak@10.1.2.3
$ riak-admin cluster force-remove ?
riak@10.1.2.3
$ riak-admin cluster join riak@10.1.2.4
$ riak-admin cluster plan
$ riak-admin cluster commit

画面 6 添加新的集群

这样,vnode 的重新配置处理一次就能进行了,可以节省网络上的 vnode 移动。

在恢复之前,PUT/GET/DELETE 等基于 key 的检索操作仍可以正常执行,但 MapReduce、listkeys 等需要访问集群内所有服务器的操作,在多个节点有故障、某些 vnode 的所有复制都不可见的情况下,从一开始就是不能执行的。

网络故障时

交换机或路由器会偶尔发生故障,这个时候,故障交换机和路由器下的所有服务器就停止工作了。从网络的结构来看,集群中一部分的服务器由于故障变得不可见了。例如,50 台服务器组成的集群中的 25 台由于交换机的故障变得不可见的话,剩下的能访问的 25 台必须和以前一样处理 write 和 read 请求。为了不让负载变为 2 倍,尽量调节进入的流量比较好。

还有,和节点故障一样,API 也会有很多变得不可用。比如,如前述例子所述若一半节点故障,一半的数据就不能读取。因为使用 Consistent Hashing 进行分布化,所以不能确定到底哪部分数据不能读取。还有,只要有 1 个不能访问的 vnode,就无法获得所有的 key,以及执行 Mapreduce 请求。

但是,数据消失或恢复时无法被清零,所以在控制流量的同时需要冷静地恢复网络。若有 Riak 进程停止,用 Riak start 来启动就可以了。

磁盘空间不足时应该如何处理

和其他很多系统一致,当磁盘剩余空间为 0 时,就代表是系统设计和运行的失败。为了避免这样的情况,需要慎重设计系统。

Riak 也一样,在磁盘空间不足时会终止进程。

因此,当因节点磁盘空间不足而进程终止时,就会使用 Handoff,调用其他节点来执行读写请求。接受 Handoff 的节点磁盘空间也会随之被占用,这类似于一种多米诺骨牌现象。因此,最初节点磁盘满之前,应该停止 PUT 和 POST 等请求,以免数据继续增加。若在前端有负载均衡器,可以在那里过滤掉 PUT 请求。

即使这样,在服务器数量较少时,vnode 配置不理想或者无法及时增加节点的情况下,磁盘容量紧张的状况还是有可能发生的。这时,首先要考虑增加节点。重新配置和压缩(compaction),不需要翻倍的容量。基本上,只要保持磁盘有 20% 的空闲容量就可以了。

但是,删除 key 并不会马上空置相应的空间,处理删除请求时,Bitcask 和 LevelDB 也只是添加数据删除用的删除标记,因此,磁盘的使用量还是会轻微增加。直到下一次进行压缩的时候,才会从磁盘上删除。所以,至少需要留有可以执行压缩的容量。

一般压缩是以 heap GC(Garbage Collector,垃圾回收)算法来执行,为了执行压缩,需要有相同容量的空余容量。但是,Riak 上每个 DB 实例以 vnode 为单位,并且 Bitcask 和 LevelDB 都以很小的单位进行压缩,实际上有一点空余容量就够了。若连这样空余容量都不能保证的话,最后的方法就只有删除 vnode 的目录了。

这种方法的前提是,为了在节点重新配置的过程中,能稳定启动 Riak 的进程,需要把复制节点从 3 个减少为 2 个来空出空间,在进程启动过程中,再添加节点空出空间。此方法风险非常高,是万不得已时的最后选择。

什么是可用性

前面,我们已经描述了 Riak 故障时采取的行为、存活监控的机制和处理方法。这些都是现实的问题,是开发、运行系统时必须考虑的问题。和其他的分布式数据库比较起来,Riak 没有主从的功能分割,因此故障时处理的模式也比较少。

CAP 定理和 Riak 可用性

CAP 定理说明了一致性、可用性、容错性这三个特性不可能同时实现。虽然也有专门证明它的论文,但一般都认为它是分布式系统方面的经验法则。Riak 在这之中关注于可用性,以不论何时都能写入、读出作为其根本设计思想。也不能说其他两个性质完全没有,但一定程度上那两性质取决于用户如何实现。

一致性是一个旧瓶装新酒的问题,基本上为了保证多个复制的一致性、加锁、2 段提交等需要多个请求在多个状态等待的处理。因此会出现等待时间,不能保证稳定延迟的情况也会出现。还有,为了保证容错性,就必须保证系统全体是未被分开的(例如,不存在多个主服务器,同一个数据不允许在多个地方进行更新),必须是类似于 HBase 和 BigTable 的中央管理型系统结构。

相反,对这些特性稍稍妥协的话,设计分布式系统就能非常简单了。不使用锁,没有复杂的等待也就几乎没有被 block 的处理,延迟也非常稳定。还有,若不准备主服务器,也不会有主服务器的故障、恢复引起的全体服务器的停止,也不需要设计 metadata 的事务。针对这些问题,去掉不需要的复杂设计,追求可用性的结果,就是现在 Riak 的设计。

总结——为了实现高度可用系统

至此,本回介绍了 Riak 存活监控的机制、设计的考虑要点和各个故障的对应方针。

最重要的是事先的准备工作。最初的集群一定是预算很少的,使用 5 台小服务器来搭建也是可以的。在大负荷和数据增加之前,大幅

提高系统的容量是非常重要的。针对这点的最佳实践 也准备好了官方文档

1 参考 URL http://www.cse.psu.edu/~gcao/teach/513-00/c7.pdf
http://www.cs.duke.edu/courses/fall07/cps212/consensus.pdf
http://rodin.cs.ncl.ac.uk/Publications/avizienis.pdf
http://www.erlang.org/doc/man/net_kernel.html#set_net_ticktime-2
http://docs.basho.com/riak/latest/cookbooks/Linux-Performance-Tuning/

使用 Riak 的话,可以不停止集群,执行各种升级工作。服务器就不用说了,做得好的话,网络的更新也可以只停止部分系统来完成吧。

延伸阅读

Erlang 程序设计(第 2 版)

  • Erlang 之父权威著作

  • 领先一步,精通下一代主流编 程语言

[ 瑞典 ] Joe Armstrong 著

牛化成 译

人民邮电出版社

“Erlang 是目前唯一成熟可靠的能够开发高扩展性并发软件系统的语言,它将成为下一个 Java。”

——Ralph Johnson,软件开发大师,《设计模式》作者之一

在多核、并发、分布为王的时代,谁将成为下一个主流编程语言?来自全世界的众多专家都认为,Erlang 最有可能在竞争中胜出。

本书由 Erlang 之父 Joe Armstrong 编写,是毋庸置疑的经典著作。书中兼顾了顺序编程、并发编程和分布式编程,重点介绍如何编写并发和分布式的 Erlang 程序以及如何在多核 CPU 上自动加速程序,并深入地讨论了开发 Erlang 应用中至关重要的文件和网络编程、OTP、ETS 和 DETS 等主题。第 2 版全新改写,反应了自第 1 版面世以来 Erlang 历经的所有变化,添加了大量针对初学者的内容,并在每章后都附上了练习题。

 

Erlang/OTP 并发编程实战

  • 首部 OTP 开发部署实战指南

  • 三位 Erlang 大师多年智慧结晶

  • WhatsApp 成功背后的语言

[ 美 ] Martin Logan Eric Merritt

[ 瑞典 ] Richard Carlsson 著

连城 译

人民邮电出版社

本书侧重生产环境下的 Erlang 开发,主要讲解如何构建稳定、版本控制良好、可维护的产品级代码,凝聚了三位 Erlang 大师多年的实战经验。书中内容主要分为三大部分:第一部分讲解 Erlang 编程及 OTP 基础;第二部分讲解如何在实际开发中逐一添加 OTP 高级特性,从而完善应用,作者通过贯穿本书的主项目——加速 Web 访问的分布式缓存应用,深入浅出地阐明了实践中的各种技巧;第三部分讨论如何将代码与其他系统和用户集成,以及如何进行性能调优。

目录