redis是一个复杂而又设计优良的系统,说它复杂是因为整个系统涉及到了很多方面的问题,比如:哈希存储、网络模型、集群特性等等。说它设计优良是因为这些问题它都提供了深思熟虑的解决方案。
我们花大量的时间学习一个技术,不仅为了能更好的使用它,同时希望学习它设计上的一些思路,这样在解决日常工作碰到的各种各样的问题的时候思路会更开阔。以下是对redis一小部分内部机制的思考与总结。
redis早期是不支持集群功能的,如果需要做数据分片,需要在客户端使用简单哈希或者一致性哈希的方式。此时集群运行方式为:

这样的方式实现的集群,简单并且几乎能无限线性扩容。但是有一个致命的问题,集群扩容减容需要rehash操作,即重新分配已经存在的数据,否则部分旧的数据将不能访问,随着集群规模的扩大,rehash操作越来越困难并且对线上集群影响越来越大。
redis cluster设计了slot机制来解决这个问题。redis cluster中固定有16384个slot,集群中整个数据集被散列在这些slot中。slot是一系列数据的结合,它用从0到16383的代号表示,如果一个数据的哈希值对16384取模等于某一个slot的代号,则它应该被存放在该slot中。同时redis cluster的每个节点都维护一个slot到节点的映射表,所以此时集群的运行方式为:
上文说道slot的设计主要是为了解决rehash问题。当redis cluster需要扩容或者减容的时候,需要迁移slot中的数据到新的节点,并且指派slot到新的节点。因为同一个slot中的数据在内部都被维护在一个跳表中,所以能方便的设计接口来完成以上操作。对于使用者来说只需要调用相应的接口就能方便的完成扩容减容操作。
小结一下:redis通过引入slot机制并设计迁移slot的接口,解决了基于哈希的集群扩容减容操作复杂性与对线上集群影响的问题。
slot机制还有一个很明显的优势,就是在处理并发的场景,因为它将数据集进行了分割,实际上减小了锁的粒度,从而扩大了并发度。java中的concurrenthashmap容器是应用这种机制来实现并发的典型的例子,我们以它为例来说明slot机制为并发场景带来的好处。
了解java的人都知道concurrenthashmap(这里只讨论jdk7中concurrenthashmap)是一种线程安全的哈希容器,但这里我们不关注它底层并发的实现细节,不管它是cas方式还是锁的方式,我们将注意力放在segment上。segment就是concurrenthashmap中的slot,为了命名统一我们仍然以slot来称呼它。
concurrenthashmap初始化的时候需要指定并行度,并行度其实就是slot的个数,与redis一样slot个数一旦确定就不能更改。slot同样会负责整个数据集中的一部分数据,并且它还会持有一种并发机制,所以当访问同一个slot内的数据的时候需要遵守并发机制。因为并发机制不是针对整个数据集,而是每个slot独立拥有自己的并发控制,所以容器的最大并发数是slot的个数而不是一。通常这种技术被称为分段锁技术。另concurrenthashmap的运行原理有点类似下图:
细心的朋友可能会发现concurrenthashmap中每个slot有自己独立的哈希表,数据在物理上是隔离的。而在redis中整个数据集使用同一个哈希表。虽然redis是单线程模型,数据集并不需要并发机制,但是通过公用数据集的启发,concurrenthashmap的分段锁的机制还可以有以下解决方案:
在上边的方案中slot是一种更虚拟的概念,它没有独立的哈希表,而是所有slot公用一个哈希表,并且只提供同步的功能。当请求过来的时候,key首先通过哈希映射到slot,以获取访问权限,然后再次哈希映射到哈希表以访问数据。同样slot数量一经确定就不能改变。
redis采用单线程模型,这一点上它可能是在众多服务端程序中是最特殊的。单线程模型最大的优势是实现简单,但也有很明显的劣势,请求容易被其它操作阻塞。
学过操作系统的同学都知道早期单核心cpu也是能同时处理多任务的,方式是把任务切分成多小段,cpu不断切换执行多个任务,因为每小段任务执行时间总够短,从用户的角度来说,多个任务就是在同时执行。
redis单线程模型同样是采用这种方式处理多任务。对于执行时间长的任务,redis把它们切分成小段,以减小占用cpu的时间,从而减小对前端请求的阻塞时间。这些执行时间长的任务主要有:
redis对于请求采用面向事件的处理模型,基本思路是把每个请求划分成多个事件,每个事件是处理的最小单元,并且每个请求的多个事件可以不连续处理。这样多个事件处理复用了同一个线程。
redis对数据过期采用主动与被动两种策略。主动方式是,定时扫描过期表,处理过期的数据,但是考虑到过期数据可能会很多,一次处理完毕可能需要阻塞线程时间很长,redis采用的方式是,每次只处理一小部分数据。
当哈希表的哈希因子达到一定阈值的时候,为了减小冲突率,需要进行rehash操作。redis的rehash机制是这样,首先创建新的哈希表,其次进行数据迁移。因为整体数据迁移是及其耗时的操作,redis将数据迁移操作散列在每次数据请求处理中,比如前端请求key,如果发现key在老的哈希表中,会将它迁移到新的哈希表中。
在发送端对于大数据文件的网络传输本身就是分段的,它的分段大小受各个buffer大小的限制。接受端同样会受到buffer的限制,所以接受同样是一部分一部分的接受。所以在rdb文件传输的两端都是分段的,中间是能处理其它任务的。但是在整个传输阶段,传输任务会占用大量资源,对前端请求还是有影响的。
我们拿快递员送包裹的例子来做类比。假设快递员送一个包裹需要2个小时,其中119分钟的时间花费在路上,交接包裹需要1分钟。现在有10个包裹需要他递送到同一个小区。如果他每次拿一个包裹总共需要20个小时。但是如果他一次拿10个包裹只需要两个小时多一些。
对于redis请求而言,一个客户端需要等服务端回复上一个请求后才能发送下一个请求,整个请求流程的时间,绝大部分花费在网络传输上。但是如果使用pipeline的方式,一次传输多个命令,这样就大大减小了大量请求的整体响应时间。
但是对于服务端而言一次性太多的命令会阻塞其它客户端的请求,所以需要对pipeline限制发送命令的数量。
单线程模型避免了处理并发的繁琐,但是需要特别注意长时间操作的分段处理,避免造成前端请求阻塞。
redis cluster是一种无中心的集群,每个节点都持有一份集群状态,同样每个节点都能修改集群状态。集群状态通过节点间不断交换集群消息来达到同步状态。当节点收到集群消息时,如果消息是较新的,需要更新集自己的群状态。说到这里可能有两个疑问:
集群状态,集群消息是什么?
如何判断收到的集群消息是较新的呢?
我们先看第一个问题集群状态与集群消息,本文中所说的集群状态主要指集群中slot与节点的映射关系;集群消息指某一个节点所负责的slot信息。对于某一个节点来说它发出去的消息只能是自己负责的slot信息,因为只有它自己最清楚自己负责哪些slot,对于从节点则发送它的主节点的slot信息。
其实在redis内部集群消息不止是发送节点所负责的slot信息,还包括发送消息节点的主从信息,与发送消息节点认为的其它节点的状态信息(fail/pfail)。这里为了更好的说明问题把与主题不相关的部分忽略掉。
再来看第二个问题,节点如何判断收到的集群消息是较新的。redis中使用版本号机制来判断消息的新旧,版本号越高表示消息越新。
在redis中这种版本号被称作configepoch,每个节点都有一个configepoch,它是一个64位的整数。其实configepoch更应该被称作是slot的版本号,或者某个slot与节点映射关系的版本号。怎么理解呢?
我们从消息冲突的角度看这个问题,因为configepoch就是为了解决消息冲突的嘛。如上文所说集群中每个节点都会维护一份集群状态快照。快照中每个slot都有一个与之对应的节点,每个节点都有一个configepoch,所以直观上configepoch就像是slot的版本号。
当某一个节点负责的slot有变化的时候,节点会更新自己的configepoch,并随着集群心跳传播该消息,集群消息是一系列slot、节点与configepoch组成的三元组。当新的集群消息传播到其它节点的时候,节点会对比节点本身与集群消息中对应slot的configepoch来决定是否更新本地集群状态快照,从这层意义上看configepoch也更适合被称作slot的版本号或者消息的版本号。
下图是slot迁移的集群状态更新过程:
上图的场景中有abc三个节点,它们开始的configepoch分别为8000900010000,并且100号slot由节点c负责。当将100号slot由节点c迁移到节点b的时候,节点b的configepoch变为10001,并且节点b会发送消息称100号slot由自己负责。当节点ac接收到b的消息时比较configepoch,因为b的configepoch比较大它们更新本地集群状态。
当节点b负责的slot发生变化的时候,b的configepoch变为10001,10001是集群中最大的configepoch。为什么需要变为最大值而不是加一呢?其实只需要观察上边100号slot变迁过程就能得出答案,开始的时候100号slot由c节点负责,它的configepoch为10000,如果只是加一b节点的configepoch变为9001,小于10000,这样节点ac的更新就会失败。
通过上边的例子能看出通过configepoch的方式来判断消息的新旧,关键在于处理好如何增加configepoch,保证最近消息的configepoch是集群中唯一且最大的,在redis中有两种增加configepoch的方式:
redis中每个节点都会维护一个currentepoch,它的作用像是一个中介。集群状态发生变化的时候会增加currentepoch,并且节点交换消息的时候不断传播最大的currentepoch。其实redis一直努力保持每个节点都有相同的currentepoch,并且努力保持currentepoch大于等于集群中最大的configepoch。每当需要增加configepoch的时候采用如下方式:
就能在一定程度上保证新的configepoch是集群中唯一且最大的。上例中节点b迁入新的slot的后,其实就是通过这种方式来增加b的configepoch的,只是上图中并没有画出每个节点的currentepoch。
投票方式的主要用在failover场景下,redis的failover算法类似raft协议的选主部分,从节点向集群中的主节点发出选票请求(注意这里的发出请求的从节点可能是多个),主节点收到请求后根据自身条件判断是否应该投赞同票。其中第一个获得过半赞同投票的从节点能够晋升为新的主节点。
其实投票的方式增加configepoch同样是采用上一小节中的公式。只不过从节点在将currentepoch加一后并没有直接赋值给configepoch,而是询问其它主节点,判断自己的currentepoch目前是不是集群中最大的,当有过半主节点投赞同票的时候才会进行赋值,否则该从节点的晋升失败。
主节点判断自己是否应该投赞同票的条件中有一个跟currentepoch有关。主节点会对比自己与发出投票从节点的currentepoch,如果自己的比较大则不投赞同票,因为这说明从节点的集群状态是很旧的。
但是因为并没有得到所有节点的赞同投票,所以这种方式同样不能保证自己的configepoch一定是集群中唯一且最大值,但它是比上一种方式更严格的方式。
其实redis始终努力尝试保持一个原则:每个节点的configepoch在集群中应该是唯一的,并且后发生更改的节点的configepoch应该大于之前发生过更改的节点的configepoch。因为这样能保证每个消息都有唯一的configepoch,并且后发生的消息的configepoch大于前发生消息的configepoch,从而保证最近的消息能完成更新。
我们看以下事件与消息处理流程,它是导致redis消息冲突的典型场景:
上图描述的大致流程是节点a迁入一个slot后,没等消息传播到节点c与c',这对主从就发生了主从切换,切换后节点a与c'的configepoch是相同的,这种现象就是redis中的版本号冲突也即消息冲突。
发生版本号冲突后,如果此时节点a与c'发生slot迁移,这里假设从c'迁移100号slot到a,因为此时a的configepoch已经是集群中最大的configepoch,所以此时不需要增加configepoch。此时100号slot的消息有归属于a与c'两个版本,并且两个版本的configepoch是相同的,所以此次迁移的消息更新就失败了。
为了解决上述冲突,redis规定当节点间交换消息时,如果发现两个节点的configepoch相同,需要改变其中一个节点的configepoch,改变的方式同样是configepoch=currentepoch+1。因为消息已经被处理,此时改变哪一个节点的configepoch都是可以的。
冲突解决是依靠交换心跳的,在两个节点交换心跳包之前,发生迁移操作需要向from与to节点发送setslot命令。虽然setslot命令不会改变to节点的configepoch,但它依然会修改slot与节点的映射关系。这样即便冲突解决时将from节点的configepoch增大,因为此时它应不负责被迁移过去的slot,所以新消息一样更新成功了。
分布式系统消息的时序不能保证的,给每个消息加上版本号是解决冲突的最直观的方法,也是很多分布式场景会采用的方法,但是说起来简单,现实落地却是很复杂的。
以上是我在使用redis过程中的一点总结与思考,同时希望自己能多多积累,以开阔思路,更好的解决工作中遇到的问题。
版权声明:文章为作者辛勤劳动的成果,转载请注明作者与出处。