想写的Cassandra系列文章的第一篇。
本文的中心思想是:不谈需求场景只谈利弊是耍流氓;只谈利不谈弊是臭流氓。
分布式数据库,自然是相对于传统的单机数据库。从传统的单机数据库到多机分布式数据库无非是有两大类需求:单机在性能或数据容量上扛不住;单机有单点问题,一旦挂了系统就挂了甚至数据都丢了。这两个核心需求决定了分布式数据库的根本目标有两个:多个节点承接读写请求,从而加大整体性能;多个节点存储数据,每个数据存不止一份从而防止丢数据并且提高整体的数据上限。
最简单直接的办法是,把单机数据搭很多个。每个节点处理一部分数据,从而加大整体读写吞吐和数据上限;同时提出“主从”概念,多个节点处理同一批数据,这样其中一个节点挂了另一个节点马上顶上。无论Mysql、redis、mongodb都有类似的方式可以将单机数据库扩展成集群。
另一种新思路绕开了单机数据库,从设计上便实现分布式,是天生的分布式数据库。Google基于GFS设计了BigTable,因为GFS是分布式文件系统因此只要把数据持久化在GFS上便实现了第二个目标既数据存多份;因为GFS可以通过rpc调用读写整个文件系统上的任何数据所以每个BitTable的节点理论上都可以处理整个数据库的请求,从而实现了第一个目标既多个节点可以承接读写请求。开源的HBase在总体思路上山寨了BigTable,后文用HBase代指这种设计。
还有一种思路,不基于分布式文件系统,把数据写在节点本地,但是client一次读写请求可能要去读写多个节点,从而同时实现两个目标。这就是Amazon提出的Dynamo。而开源的Cassandra在分布式模型上山寨了Dynamo,并且有所区别,本文不看重Dynamo的设计,只介绍Cassandra的设计。
三种模式的利弊先不说,但无论哪种模式针对第一个目标时都需要处理一个问题——客户端想读写某个数据时应该请求集群中的哪个节点?一般来说数据库都是以行为基本单元,通过对行的partition key来定位一行数据应该放在哪。而key->node的映射,每个key单独存储映射关系显然存不下,因此一般将key分组,满足同一个条件的key作为一组,每组key都在一个节点上,也就是key->group->node。具体key->group的映射分为两大类——pre-sharding和resharding。
Pre-sharding的意思是key->group的映射关系不会动态修改,在启动的时候就确立了,一般这种情况的group也叫“slot”,通过哈希取模的方式来确定一个key落在哪个slot上。通常单机数据库扩展出来的分布式数据库都是pre-sharding的。
而resharding(一般也称为auto-sharding)是可以动态修改的,比如发现某个group的key太多,就修改下分配方式从而让每个group均匀些。当然即使是动态修改,也一样要确保每个group的key满足同一种规律,否则还是要每个key存一个路由信息。而sharding的修改就是修改key->group的规律,而具体规律的不同决定了sharding算法的不同。比如把字节序相邻的key分在一个group,resharding负责记录每个group的起止key;也可以先哈希成一个数字,把相邻数字的key分在一个group,resharding 负责记录每个group的起止hash。字节序的好处是可以按key顺序读取,hash的好处是防止冷热不均。一般天生的分布式数据库都是sharding的,HBase和Cassandra都同时支持两种sharding算法,但大多数情况下,HBase使用第一种,Cassandra使用第二种。但HBase用第二种也还不错无非是不能顺序扫行,而Cassandra用第一种就容易悲剧了,原因后面再说。
知道一个key在哪个group后,group->node又分为三种思路:在数据库上层提供一层proxy,client可以全部请求proxy而proxy知道每个数据该读写哪个节点;在一个外部系统存储这个信息(如zookeeper),拿到这个信息后直接请求数据库节点;数据库节点自己维护这个路由信息,任何一个节点都知道某个数据应该放在哪个节点。Codis、各种MySQL的proxy都是第一种方案;HBase是第二种方案;Cassandra和redis cluster是第三个方案。
因为group->node的路由信息是可能随时改变的(如增减节点、有节点挂了等等),所以需要有一个逻辑来分配key->node的映射关系来保证每个key都有节点能服务并可以根据集群信息动态调整。这又分两种:一个master节点来进行分配,这个master只有一个从而确保不会分乱;数据库节点自己掌控全局,一起判断,一起调整到收敛状态。HBase、Codis属于第一种;Cassandra属于第二种;Redis Cluster介于两者之间,主从切换的逻辑是自动的通过第二种来实现,而一个slot该存哪个group是外部脚本人工控制所以是第一种。
通过实现方式的不同把分布式数据库按各种维度进行分类之后,总结一下Cassandra在分布式架构上的设计思路:数据存本地,多个节点都可以读写同一个数据;对key做sharding而非pre-sharding来判断一行数据该放在哪(几)个节点;每个节点都存储这个路由信息;这个路由表是整个集群共同维护、修改的而非某个master修改。
知道了Cassandra是如何在设计上做选择的,就知道了其利弊了,当然一些特性的利弊不明显,而一些特性的利弊可以说是不得不做的抉择。
每个节点都存路由信息、共同维护无中心节点——client变得很好写,随便请求一个节点都能转发请求,没有中心节点更容易夸机房;整个集群保存、调整集群信息,用gossip协议协调一致,实现逻辑比有中心节点的复杂。
数据存本地,不依赖分布式文件系统——可以少维护一个系统,维护成本低一些;但是不同节点存的数据的一致性不能保证,因此需要额外的逻辑保证一致性。
其中前者gossip等部分后文再单独来写,但是后者还是需要着重强调一下,并且也会单独一篇来写。
HBase的设计依赖HDFS,让HBase实现起来比较简单,只需要master确保一个group(region)在同一个时间内只有一个节点来读写,那么实际上就把HBase的读写变成了单机数据库的读写,只是把内存+磁盘变成了内存+HDFS的rpc,而单机读写的一致性很容易保证。而Cassandra每个group的数据是要写在多个节点的。就会有一些额外的问题。
稍微知道分布式系统的都知道传说中的CAP,这是一种“不可能三角”,三个因素注定最多做到同时两个。经济学上还有个“蒙代尔三角”也是类似,不知道其他领域还有没有不可能三角。CAP简单来说就是要可用性就没一致性,或者反过来,因为P肯定不能放弃的。HBase是严格保证强一致性,放弃了A,一旦一个节点挂了那么短时间内这个节点所服务的那些region是不可读写的,各种优化只是尽可能的保证这个不可用的时间更短。而Cassandra因为有多个节点可以读写一行数据,理论上只要有一个节点还能用,可用性就有保证。
因为rpc调用实际上是两次网络传输(传过去一个request再传回来一个response),如果成功当然没问题,但是发了request没收到response,也就是“无响应”,属于一种很麻烦的中间状态,因为你不确定server是否处理了这条请求,甚至server也不确定它是否成功返回了response所以不确定client是否知道自己是否成功。所以普遍的做法是让client认为这次请求“超时”从而让client去重试或者放弃(但放弃不代表这条记录没写成)。HBase因为只有一个节点读写这行,所以行就是行,不行就是不行。而Cassandra因为有多个节点(配置上来说是N个节点,可自行设置),那么同时写N个节点的话,如果一部分成功一部分不成功算是成功还是不成功呢?Cassandra选择的做法是让client自己指定。写的时候,W个节点成功了算作成功,不到W个节点在timeout时间内成功算做这次请求整体失败。“整体失败”就是这次请求client收到一个timeout的错误,但其实不代表每个节点都没处理这个写请求。也就是说,N个节点中有0到W-1个节点是有数据的,另外那些节点是可能有数据的(因为可能只是response没发回来)。读的时候,R个节点返回数据或者返回没有数据算作成功。如果R或者W等于N,那么意味着只要有一个节点不响应,就会导致client请求失败,失去了可用性。所以一般R和W都小于N。此外如果W和R相对于N设置的太小,那么有可能写的时候和读的时候请求的节点恰好错开,发现没数据,相当于读不到数据了。因此需要保证W和R是一定能有重叠部分的,也就是W+R>N。这样就保证了每次读数据都能读到每次写的数据。那这是强一致性吗?并不是。(当然不是了,不然不就破了CAP了……)
具体原因要看读数据时是如何处理的。如果R个节点返回的数据是不一致的,比如一个节点说a=3,一个节点说a=2,分布式系统的时间戳是无法一致的,所以不能靠时间戳来判定先后,而Cassandra的做法是,照样看谁时间戳大,谁大听谁的。而时间戳以接受client请求的节点的系统时间戳或client自行提供为准。也就是说这里是存在误差的,也因此Cassandra必须要配套NTP来降低时间戳的误差。但毕竟时间戳是不可能完全没有误差的,所以理论上是可能出现后写入的数据被先写入的覆盖,连最终一致性都不是,而我自己把这种一致性命名为“NTP一致性”。但是因为Cassandra不仅是NoSQL而且是和BigTable一样的Wide Column,并且支持用CQL定义column,因此实际上只有不同client在NTP误差范围的时间(同机房内小于1ms)内同时写同一个row key+column key+column才会出现这种冲突。所以这就是个看需求的事情了,短时间内同时写一个字段的话读到哪个都行、或者完全不会短时间重复写一个字段的话就能忍,能忍就可以用,忍不了千万不要用。此外Cassandra从2.0开始支持轻量级事务——一个用paxos实现的CAS写操作,能在一定程度上解决这个问题,但是因为paxos的逻辑比单纯一次写大很多,所以性能差了很多。