集群的概念早在 Redis 3.0 之前讨论了,3.0 才在源码中出现。Redis 集群要考虑的问题:
- 节点之间怎么据的同步,如何做到数据一致性。一主一备的模式,可以用 Redis 内部实现的主从备份实现数据同步。但节点不断增多,存在多个 master 的时候,同步的难度会越大。
- 如何做到负载均衡?请求量大的时候,如何将请求尽量均分到各个服务器节点,负载均衡算法做的不好会导致雪崩。
- 如何做到平滑拓展?当业务量增加的时候,能否通过简单的配置即让新的 Redis 节点变为可用。
- 可用性如何?当某些节点鼓掌,能否快速恢复服务器集群的工作能力。
- ……
一个稳健的后台系统需要太多的考虑。
一致性哈希算法(consistent hashing)
背景
通常,业务量较大的时候,考虑到性能的问题(索引速度慢和访问量过大),不会把所有的数据存放在一个 Redis 服务器上。这里需要将一堆的键值均分存储到多个 Redis 服务器,可以通过:
target = hash(key)\%N
,其中 target 为目标节点,key 为键,N 为 Redis 节点的个数哈希取余的方式会将不同的 key 分发到不同的服务器上。
但考虑如下场景:
- 业务量突然增加,现有服务器不够用。增加服务器节点后,依然通过上面的计算方式:
hash(key)%(N+1)
做数据分片和分发,但之前的 key 会被分发到与之前不同的服务器上,导致大量的数据失效,需要重新写入(set)Redis 服务器。 - 其中的一个服务器挂了。如果不做及时的修复,大量被分发到此服务器请求都会失效。
这也是两个问题。
一致性哈希算法
设定一个圆环上 0 23̂2-1 的点,每个点对应一个缓存区,每个键值对存储的位置也经哈希计算后对应到环上节点。但现实中不可能有如此多的节点,所以倘若键值对经哈希计算后对应的位置没有节点,那么顺时针找一个节点存储它。
考虑增加服务器节点的情况,该节点顺时针方向的数据仍然被存储到顺时针方向的节点上,但它逆时针方向的数据被存储到它自己。这时候只有部分数据会失效,被映射到新的缓存区。
考虑节点减少的情况。该缺失节点顺时针方向上的数据仍然被存储到其顺时针方向上的节点,设为 beta,其逆时针方向上的数据会被存储到 beta 上。同样,只有有部分数据失效,被重新映射到新的服务器节点。
这种情况比较麻烦,上面图中 gamma 节点失效后,会有大量数据映射到 alpha 节点,最怕 alpha 扛不住,接下去 beta 也扛不住,这就是多米诺骨牌效应;)。这里涉及到数据平衡性和负载均衡的话题。数据平衡性是说,数据尽可能均分到每个节点上去,存储达到均衡。
虚拟节点简介
将多个虚拟节点对应到一个真实的节点,存储可以达到更均衡的效果。之前的映射方案为:
key -> node
中间多了一个层虚拟节点后,多了一层映射关系:
key -> <virtual node> -> node
为什么需要虚拟节点
虚拟节点的设计有什么好处?假设有四个节点如下:
节点 3 突然宕机,这时候原本在节点 3 的数据,会被定向到节点 4。在三个节点中节点 4 的请求量是最大的。这就导致节点与节点之间请求量是不均衡的。
为了达到节点与节点之间请求访问的均衡,尝试将原有节点 3 的数据平均定向到到节点 1,2,4. 如此达到负载均衡的效果,如下:
总之,一致性哈希算法是希望在增删节点的时候,让尽可能多的缓存数据不失效。
怎么实现?
一致性哈希算法,既可以在客户端实现,也可以在中间件上实现(如 proxy)。在客户端实现中,当客户端初始化的时候,需要初始化一张预备的 Redis 节点的映射表:hash(key)=> . 这有一个缺点,假设有多个客户端,当映射表发生变化的时候,多个客户端需要同时拉取新的映射表。
另一个种是中间件(proxy)的实现方法,即在客户端和 Redis 节点之间加多一个代理,代理经过哈希计算后将对应某个 key 的请求分发到对应的节点,一致性哈希算法就在中间件里面实现。可以发现,twemproxy 就是这么做的。
twemproxy - Redis 集群管理方案
twemproxy 是 twitter 开源的一个轻量级的后端代理,兼容 redis/memcache 协议,可用以管理 redis/memcache 集群。
twemproxy 内部有实现一致性哈希算法,对于客户端而言,twemproxy 相当于是缓存数据库的入口,它无需知道后端的部署是怎样的。twemproxy 会检测与每个节点的连接是否健康,出现异常的节点会被剔除;待一段时间后,twemproxy 会再次尝试连接被剔除的节点。
通常,一个 Redis 节点池可以分由多个 twemproxy 管理,少数 twemproxy 负责写,多数负责读。twemproxy 可以实时获取节点池内的所有 Redis 节点的状态,但其对故障修复的支持还有待提高。解决的方法是可以借助 redis sentinel 来实现自动的主从切换,当主机 down 掉后,sentinel 会自动将从机配置为主机。而 twemproxy 可以定时向 redis sentinel 拉取信息,从而替换出现异常的节点。
twemproxy 的更多细节,这里不再做深入的讨论。
Redis 官方版本支持的集群
最新版本的 Redis 也开始支持集群特性了,再也不用靠着外援过日子了。基本的思想是,集群里的每个 Redis 都只存储一定的键值对,这个“一定”可以通过默认或自定义的哈希函数来决定,当一个 Redis 收到请求后,会首先查看此键值对是否该由自己来处理,是则继续往下执行;否则会产生一个类似于 http 3XX 的重定向,要求客户端去请求集群中的另一个 Redis。
Redis 每一个实例都会通过遵守一定的协议来维护这个集群的可用性,稳定性。有兴趣可前往官网了解 Redis 集群的实现细则。
redis cluster 就是想要让一群的节点实现自治,有自我修复的功能,数据分片和负载均衡。
数据结构
基本上集群中的每一个节点都需要知道其他节点的情况,从而,如果网络中有五个节点就下面的图:
其中每条线都代表双向联通。特别的,如果 redis master 还配备了 replica,图画起来会稍微复杂一点。
redis cluster 中有几个比较重要的数据结构,一个用以描述节点 struct clusterNode,一个用以描述集群的状况 struct clusterState。
节点的信息包括:本身的一些属性,还有它的主从节点,心跳和主从复制信息,和与该节点的连接上下文。
typedef struct clusterNode { mstime_t ctime; /* Node object creation time. */ char name[REDIS_CLUSTER_NAMELEN]; /* Node name, hex string, sha1-size */ int flags; /* REDIS_NODE_... */ uint64_t configEpoch; /* Last configEpoch observed for this node */ // 该节点会处理的slot unsigned char slots[REDIS_CLUSTER_SLOTS/8]; /* slots handled by this node */ int numslots; /* Number of slots handled by this node */ // 从机信息 int numslaves; /* Number of slave nodes, if this is a master */ // 从机节点数组 struct clusterNode **slaves; /* pointers to slave nodes */ // 主机节点数组 struct clusterNode *slaveof; /* pointer to the master node */ // 一些有用的时间点 mstime_t ping_sent; /* Unix time we sent latest ping */ mstime_t pong_received; /* Unix time we received the pong */ mstime_t fail_time; /* Unix time when FAIL flag was set */ mstime_t voted_time; /* Last time we voted for a slave of this master */ mstime_t repl_offset_time; /* Unix time we received offset for this node */ long long repl_offset; /* Last known repl offset for this node. */ // 最近被记录的地址和端口 char ip[REDIS_IP_STR_LEN]; /* Latest known IP address of this node */ int port; /* Latest known port of this node */ // 与该节点的连接上下文 clusterLink *link; /* TCP/IP link with this node */ list *fail_reports; /* List of nodes signaling this as failing */ } clusterNode; 集群的状态包括下面的信息: typedef struct clusterState { clusterNode *myself; /* This node */ // 配置版本 uint64_t currentEpoch; // 集群的状态 int state; /* REDIS_CLUSTER_OK, REDIS_CLUSTER_FAIL, ... */ // 存储所有节点的哈希表 int size; /* Num of master nodes with at least one slot */ dict *nodes; /* Hash table of name -> clusterNode structures */ // 黑名单节点,一段时间内不会再加入到集群中 dict *nodes_black_list; /* Nodes we don't re-add for a few seconds. */ // slot 数据正在迁移到migrating_slots_to[slot] 节点 clusterNode *migrating_slots_to[REDIS_CLUSTER_SLOTS]; // slot 数据正在从importing_slots_from[slot] 迁移到本机 clusterNode *importing_slots_from[REDIS_CLUSTER_SLOTS]; // slot 数据由slots[slot] 节点来处理 clusterNode *slots[REDIS_CLUSTER_SLOTS]; // slot 到key 的一个映射 zskiplist *slots_to_keys; // 记录了故障修复的信息 /* The following fields are used to take the slave state on elections. */ mstime_t failover_auth_time; /* Time of previous or next election. */ int failover_auth_count; /* Number of votes received so far. */ int failover_auth_sent; /* True if we already asked for votes. */ int failover_auth_rank; /* This slave rank for current auth request. */ uint64_t failover_auth_epoch; /* Epoch of the current election. */ int cant_failover_reason; /* Why a slave is currently not able to failover. See the CANT_FAILOVER_* macros. */ // 人工故障修复的一些信息 ...... } clusterState;
在正常的 Redis 集群中的任何一个节点都能感知到其他节点。
上面频繁出现 slot 单词。之前我们学哈希表的时候,可以把 slot 理解为哈希表中的桶(bucket)。为什么需要slot?这和 redis cluster 的数据分区和访问有关。建议大概看完 Redis 对数据结构后,接着看 clusterCommand() 这个函数,由此知道 redis cluster 能提供哪些服务和功能。接着往下看。
数据访问
在 http 有 301 状态码:301 Moved Permanently,它表示用户所要访问的内容已经迁移到一个地址了,需要向新的地址发出请求。redis cluster 很明显也是这么做的。在前面讲到,redis cluster 中的每一个节点都需要知道其他节点的情况,这里就包括其他节点负责处理哪些键值对。
在主函数中,Redis 会检测在启用集群模式的情况下,会检测命令中指定的 key 是否该由自己来处理,如果不是的话,会返回一个类似于重定向的错误返回到客户端。而“是否由自己来处理”就是看 hash(key) 值是否落在自己所负责的 slot 中。
typedef struct clusterNode { ...... // 该节点会处理的slot unsigned char slots[REDIS_CLUSTER_SLOTS/8]; /* slots handled by this node */ int numslots; /* Number of slots handled by this node */ ...... } clusterNode;
可能会有疑问:这样的数据访问机制在不是会浪费一个请求吗?确实,如果直接向集群中的节点盲目访问一个 key 的话,确实需要发起两个请求。为此,redis cluster 配备了 slot 表,用户通过 slots 命令先向集群请求这个 slot 表,得到这个表可以获取哪些节点负责哪些 slot,继而客户端可以访问再访问集群中的数据。这样,就可以在大多数的场景下节省一个请求,直达目标节点。当然,这个 slot 表是随时出现变更的,所以客户端不能够一 本万利一直使用这个 slot 表,可以实现一个定时器,超时后再向集群节点获取 slot 表。
你可以阅读 getNodeByQuery(),流程不难。
新的节点
Redis 刚刚启动时候会检测集群配置文件中是否有预配置好的节点,如果有的话,会添加到节点哈希表中,在适当的时候连接这个节点,并和它打招呼–握手。
// 加载集群配置文件 int clusterLoadConfig(char *filename) { ...... // 如果该节点不在哈希表中,会添加 /* Create this node if it does not exist */ n = clusterLookupNode(argv[0]); if (!n) { n = createClusterNode(argv[0],0); clusterAddNode(n); } /* Address and port */ if ((p = strrchr(argv[1],':')) == NULL) goto fmterr; *p = '