Welcome

首页 / 软件开发 / 数据结构与算法 / Jepsen:测试PostgreSQL、Redis、MongoDB以及Riak的分区容忍性

Jepsen:测试PostgreSQL、Redis、MongoDB以及Riak的分区容忍性2014-06-11 infoq/Kyle Kingsbury 译:邵思华分布式系统的特性是能够在高延迟或不可靠的传输条件下进行状态交换。如果 要保证系统的操作的可靠性,必须保证在节点及网络两方面故障发生时的健壮性 ,但并非所有系统都能够满足我们所要求的安全能力。在本文中,我们将探索分 布式数据库在设计上的一些考虑,以及它们是如何对网络分区的情况作出响应。

在两个节点间发送消息时,IP网络可能会任意地删除、延迟、重新排序或复制 消息,因此许多分布式系统都使用TCP以防止消息的重新排序与复制。但TCP/IP在 本质上仍然是异步的:网络会任意地延迟消息,连接可能会被随时切断。此外, 对故障的诊断也并不可靠:要判断某个节点是否出现故障、网络连接是否被切断 、或者操作是否比预计中运行得慢也许是不可能实现的。

消息被任意地延迟或切断的这种故障叫做网络分区。分区可能出于多种原因发 生在生产环境的网络中:垃圾回收(GC)的压力、网卡(NIC)故障、交换机故障 、配置出错、网络拥塞等等。由于分区的发生,使CAP定理限制了分布式系统能够 达到的最大担保能力。当消息被切断时,“一致的”(CP)系统会拒 绝某些节点的请求,以保持线性一致性。“可用的”(AP)系统虽然 能够处理所有节点上的请求,但必须牺牲线性一致性,因为不同的节点对于操作 的顺序可能会产生不同意见。当网络情况良好时,系统可以保证一致性与可用性 ,但由于真实的网络中总会产生分区,因此不存在能够完全做到“一致且可 用”(CA)的系统。

另外值得一提的是,CAP定理不仅仅对数据库的整体有效,对其中的各种子系 统,例如表、键、列,甚至是各种操作也有效。举例来说,数据库可以为每个键 独立地提供一致性,但不能保证键之间的一致性。建立这一折衷的策略可以允许 系统在分区发生时处理更多的请求数量。许多数据库能够调节单个读写操作的一 致性级别,其代价就是性能与正确性。

测试分区

理论界定了一个设计空间,但真实的软件未必能够达到这种范围。我们需要测 试某个系统的表现,以真实地了解它的表现情况。

首先,我们需要准备一组节点以进行测试,我在一台Linux计算机上搭建了5个 LXC节点,但你也可以选择使用Solaris zone、VM、EC2节点以及物理硬件等等。 你需要这些节点共享某种网络,在我的例子中使用了一个单独的虚拟网桥 (virtual bridge interface),我将这些节点命名为n1、n2、…n5,并 且在它们之间建立了DNS以及主机操作系统。

为了创建一个分区,你需要某种方式以切断或延迟消息,例如防火墙规则。在 Linux上,你可以使用iptables -A INPUT -s some-peer -j DROP命令以造成一种 单向的分区,使某些节点传给当前节点的消息会被切断。当你在多台主机上应用 了这些规则之后,就建立了一个网络数据丢失的人为的模式。

在多台主机上重复地运行这些命令需要你花费一些精力,我使用了一个我自己 编写的工具,名为Salticid。但你也可以使用CSSH或其它任意一种集群自动化系 统。关键的因素是延迟——你需要能够快速地启动和终止某个分布, 因此像Chef或其它慢收敛系统可能没有太大用途。

接下来你需要在这些节点上搭建分布式系统,并设计一个应用程序以进行测试 。我写了一个简单的测试:这是一个Clojure程序,它运行在集群的外部,用多线 程模拟五个隔离的客户端。客户端会并发地将N个整数加入到分布式系统中的某个 集合中,一个节点写入0、5、10……,另一个节点写入1、6、 11……等等。无论成功或失败、每个客户端都会将它的写操作记录 在日志中。当所有写操作都完成后,程序会等待集群进行收敛,随后检查客户端 的日志是否与数据库中的实际状态相符。这是一种简单的一致性检查,但可以用 来测试多种不同的数据模型。

本示例中的客户端与配置自动化,包括模拟分区与创建数据库的脚本都可以免 费下载。请单击这里以获取代码与说 明。

PostgreSQL

单节点的PostgreSQL实例是一个CP系统,它能够为事务提供可串行的一致性, 其代价是当节点故障时便不可用。不过,这个分布式系统会选择为服务器进行妥 协,某个客户端并不能保证一致性。

Postgres的提交协议是两阶段提交(2PC)的一个特例,在第一阶段,客户端 提交(或撤消)当前事务,并将该消息发给服务器。服务器将检查它的一致性约 束是否允许处理该事务,如果允许的话就处理该提交。它将该事务写入存储系统 之后,就会通知客户端该提交已经处理完成了(或者在本例中也有可能失败)。 现在客户端与服务器对该事务的执行结果意见一致。

而如果确认了该提交的消息在到达客户端之前就被切断了,又会发生什么情况 呢?此时客户端就不知道该提交到底是成功了还是失败了!2PC协议规定了节点必 须等待确认消息到来,以判断事务的结果。如果消息没有到达,2PC就不能正确地 完成。因此这不是一种分区容忍协议。真实的系统不可能无限制地等待,在某个 点上客户端会产生超时,这使得提交协议停留在一个中间状态。

如果我人为造成这种分区的发生,JDBC Postgres客户端就会抛出类似以下类 型的异常:

217 An I/O error occurred while sending to the backend. Failure to execute query with SQL: INSERT INTO "set_app" ("element") VALUES (?)::[219]PSQLException:Message: An I/O error occured while sending to the backend.SQLState: 08006 Error Code: 0218 An I/O error occured while sending to the backend. 
我们或许会将其解读为“写操作217和218的事务失败了”。但是当 测试应用去查询数据库,以查找有哪些成功的写操作事务时,它会发现这两个 “失败的”写操作也出现在结果中:

1000 total950 acknowledged 952 survivors 2 unacknowledged writes found! ヽ(ー`)ノ (215 218) 0.95 ack rate 0.0 loss rate 0.002105263 unacknowledged but successful rate
在1000次写操作中,有950次被成功地确认了,而且这950条写记录都出现在了 结果集中。但是,215与218这两条写操作也成功了,尽管它们抛出了异常!请注 意,这里的异常并不确保这个写操作是成功了还是失败了:217号操作在发送时也 抛出了一个I/O错误,但因为客户端的提交消息在达到服务器之前,连接就已经被 切断,因此该事务没有执行成功。

没有任何方法能够从客户端可靠地区分这几种情形。网络分区,或者说其实大 多数网络错误并不代表失败,它只是意味着信息的缺失。如果没有一种分区容忍 的提交协议,例如扩展的3阶段提交协议,是没有办法断定这些写操作的状态的。

而如果将你的操作修改为幂等(idempotent)的,并且不断地进行重试,就可 以处理这种不确定性。或者也可以将事务的ID作为事务的一部分,并在分区恢复 后去查询该ID。

Redis

Redis是一个数据结构服务器,它通常会部署在一个共享的堆之内。由于它运 行在一个单线程的服务器内,因此它默认就提供了线性一致性,所有操作都会以 一个单一的、定义良好的顺序进行执行。

Redis还提供了异步式的主-从分发能力。某一台服务器会被选为主节点,它能 够接受写操作,随后将状态的改变分发给各个从节点。在这一上下文中,异步方 式意味着当主节点分发某个写操作时,客户端请求不会被阻塞,因为写操作 “最终”会达到所有从节点。

为处理节点发现(discovery)、选择主结点及故障转移等操作,Redis包含了 一个附加的系统:Redis哨兵(Sentinel)。哨兵节点会不断交流它们访问的各个 Redis服务器的状态,并且尝试对节点进行升级与降级,以维持一个单一的权威主 节点。在这次测试中,我在所有5个节点上安装了Redis和Redis哨兵。起初所有5 个客户端都会从主节点n1以及从结点n2至n5上读取数据,接下来我将n1与n2与其 它节点进行分区。

如果Redis是一个CP系统,那么在分区发生时n1和n2就会变得不可用,而其它 重要组件(n3、n4、n5)中的某一个便会被选为主节点。但事实并非如此,实际 上写操作依然会在n1上成功执行,几秒钟之后,哨兵节点会检测到分区的发生, 并且将另一个节点(假设是n5)选为新的主节点。

在分区发生的这段时间,系统中存在着两个主结点,系统的每个组件中各有一 个,并且两者会独立地接受写操作。这是一种典型的脑分裂场景,并且违反了CP 中的C(一致性)。这种状态下的写(以及读)操作不是可串行的,因为根据客户 端所连接的节点的不同,它们所观察到的数据库状态也不一样。