前一段时间redis的数据结构读差不多了,就打算读redis的分布式。可是由于方法不对,看主从复制发现了很多奇怪的地方,浪费了很多时间。最近稍微找到了点眉目,打算从头开始。
因为这里的“连接”一词容易出现混用,所以“连接”指的是两个redis之间的,用户态中记录的一个状态,socket指的是内核中的网络连接。
本问将从源代码详解redis的cluster meet和重连。
准备工作
- 准备一份脚本,用于快速生成配置文件。这份脚本我放在附录中,它的作用是生成这样的配置文件。
这个配置文件值得提的是:cluster-node-timeout设置为一个很大的值,它用于防止debug时连接过期的问题。
# redis.conf file
port $PORT
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 24000000
appendonly yes
在是使用这个脚本之前要创建7000到7005这9个文件夹,最终要成为这个样子:
.
├── 7000
│ └── redis.conf
├── 7001
│ └── redis.conf
├── 7002
│ └── redis.conf
├── 7003
│ └── redis.conf
├── 7004
│ └── redis.conf
├── 7005
│ └── redis.conf
└── run-dist.sh
- 由于debug过程中redis会执行clusterCron定时任务,这会出现一些意料之外的事情。所以建议对它下断点,并且推荐对这几个函数下断点:
b clusterCommand
b clusterCommandSpecial
b clusterCron
b clusterReadHandler
b clusterWriteHandler
b clusterSendPing
b clusterProcessPacket
b createClusterLink
b clusterConnAcceptHandler
b clusterAcceptHandler
b clusterLinkConnectHandler
- 调试建议与步骤
首先开启两个GDB,分别运行不同的redis。此处以7000为B节点,7001为A节点。
然后下断点,用redis-cli -p 7001
,输入cluster meet 127.0.0.1 7000
,让7000和7001汇合。由于下了断点,程序会在clusterCommand函数处暂停,redis-cli也会阻塞。
建议在任何时候都关注conn里的回调。这是因为redis是reactor模型的,这种编程模型会打乱你编程的方式,让代码变得比较支离破碎(Proactor才是真的支离破碎)。reactor中唯一的线索就是注册的回调函数,上面推荐的断点也基本上是回调函数。
cluster meet流程
首先需要注意的是,cluster meet成功的结果是,创建了两个socket。同样以7000和7001举例,成功之后会创建B节点与A节点的17001的一个socket,以及A节点与B节点的17000的一个socket。这里加了10000的原因是,redis默认加10000之后的端口为一个cport,专门用于cluster节点之间的通讯。不用7000的原因是两者通讯协议不同。一个是文本协议,一个是二进制协议。
图中的fd的值是一个例子。由于POSIX总是取最小可用fd,实际debug时,fd也很可能刚好为这些值。
该图省略了无关紧要的PING/PONG,可以通过看这些PING/PONG的fd推断这些PING/PONG是否是多余的。
从图中也可以轻易地发现,cluster meet返回的时间很早。所以你可能在cluster nodes中发现handshake状态的节点。
需要解释的有:
- PING/PONG/MEET包的格式
它们是同一个结构体clusterMsg,图示如下:
结构体定义如下(省略了暂时没必要列出来的):
typedef struct {
size_t totlen; /* Total length of this block including the message */
int refcount; /* Number of cluster link send msg queues containing the message */
clusterMsg msg;
} clusterMsgSendBlock;
typedef struct {
char sig[4]; /* Signature "RCmb" (Redis Cluster message bus). */
uint32_t totlen; /* Total length of this message */
uint16_t ver; /* Protocol version, currently set to 1. */
uint16_t port; /* Primary port number (TCP or TLS). */
uint16_t type; /* Message type */
uint16_t count; /* Only used for some kind of messages. */
uint64_t currentEpoch; /* The epoch accordingly to the sending node. */
uint64_t configEpoch; /* The config epoch if it's a master, or the last
epoch advertised by its master if it is a
slave. */
uint64_t offset; /* Master replication offset if node is a master or
processed replication offset if node is a slave. */
char sender[CLUSTER_NAMELEN]; /* Name of the sender node */
unsigned char myslots[CLUSTER_SLOTS/8];
char slaveof[CLUSTER_NAMELEN];
char myip[NET_IP_STR_LEN]; /* Sender IP, if not all zeroed. */
uint16_t extensions; /* Number of extensions sent along with this packet. */
char notused1[30]; /* 30 bytes reserved for future usage. */
uint16_t pport; /* Secondary port number: if primary port is TCP port, this is
TLS port, and if primary port is TLS port, this is TCP port.*/
uint16_t cport; /* Sender TCP cluster bus port */
uint16_t flags; /* Sender node flags */
unsigned char state; /* Cluster state from the POV of the sender */
unsigned char mflags[3]; /* Message flags: CLUSTERMSG_FLAG[012]_... */
union clusterMsgData data;
} clusterMsg;
union clusterMsgData {
/* PING, MEET and PONG */
struct {
clusterMsgDataGossip gossip[1];
} ping;
...
};
typedef struct {
char nodename[CLUSTER_NAMELEN];
uint32_t ping_sent;
uint32_t pong_received;
char ip[NET_IP_STR_LEN]; /* IP address last time it was seen */
uint16_t port; /* primary port last time it was seen */
uint16_t cport; /* cluster port last time it was seen */
uint16_t flags; /* node->flags copy */
uint16_t pport; /* secondary port last time it was seen */
uint16_t notused1;
} clusterMsgDataGossip;
typedef struct {
uint32_t length; /* Total length of this extension message (including this header) */
uint16_t type; /* Type of this extension message (see clusterMsgPingExtTypes) */
uint16_t unused; /* 16 bits of padding to make this structure 8 byte aligned. */
union {
clusterMsgPingExtHostname hostname; // char []
clusterMsgPingExtHumanNodename human_nodename; // char []
clusterMsgPingExtForgottenNode forgotten_node; // char [40], uint64 ttl
clusterMsgPingExtShardId shard_id; // char [40]
} ext[]; /* Actual extension information, formatted so that the data is 8
* byte aligned, regardless of its content. */
} clusterMsgPingExt;
PING/PONG/MEET这三种包的格式简单说是:协议头clusterMsg(除了其data部分),数据(这里只用上了ping的部分),拓展信息。数据部分是一个数组gossip,它包括了类型信息、字段长度(以字节为单位)。拓展信息clusterMsgPingExt有hostname、用户设置的node名称、黑名单(作用见此ban-list,简单说就是防止删除的节点又被加回来了)、分片信息。gossip数组在结构体结尾,所以是可以作为柔性数组使用,ext数组也是,它在gossip后方。gossip的数量可以从clusterMsg.count获取,ext可以从clusterMsg.extension获取。
- 随机选取N/10个节点的原因
虽然注释里写了,可是似乎没写明白。但是我知道的有:N是所有已知节点的数量(即server.cluster->nodes的数量),一秒钟发送1个包,发送的内容是这些随机选出的节点的名字等。
- fd之间的关系
以上图为例,A节点初步建立的fd分别是,A节点的fd为17,B节点为16。而后的B建立的是,B节点为17,A节点为18
- 定时任务
向server.cluster->nodes加入节点的目的就是了在clusterCron中调用clusterNodeCronHandleReconnect,它来发起到某个节点的连接
di = dictGetSafeIterator(server.cluster->nodes);
while((de = dictNext(di)) != NULL) {
clusterNode *node = dictGetVal(de);
/* We free the inbound or outboud link to the node if the link has an
* oversized message send queue and immediately try reconnecting. */
clusterNodeCronFreeLinkOnBufferLimitReached(node);
/* The protocol is that function(s) below return non-zero if the node was
* terminated.
*/
if(clusterNodeCronHandleReconnect(node, handshake_timeout, now)) continue;
}
dictReleaseIterator(di);
- 节点与Link的关系
一个Link保存了节点的指针,节点也保存了Link的指针。如果一个Link没有节点指针,这以为着它是自己用于Accept的连接。如果一个节点没有Link指针,这意味着它已经失去了连接,或者连接还没建立过(这也决定了clusterNodeCronHandleReconnect是否发起连接),Link的inbound字段也会设置为1(总感觉这个inbound是多余的,不应该是比较Link指针是否是NULL就行了?)。
一个节点有两个link,本别是node->link和node->inbound_link。以该图为例,A节点表示B的node的link为17,inbound_link为18;B节点表示A的node的link的fd为17,inbound_link为16。
根据注释里说的,link表示主动发起的connect的连接(原文是建立,个人觉得这么翻译更合适),inbound_link为接受
clusterLink *link; /* TCP/IP link established toward this node */
clusterLink *inbound_link; /* TCP/IP link accepted from this node */
从handleDebugClusterCommand中也可以看出来这两个字段的意义。
if (!strcasecmp(c->argv[3]->ptr, "from")) {
if (n->inbound_link) freeClusterLink(n->inbound_link);
} else if (!strcasecmp(c->argv[3]->ptr, "to")) {
if (n->link) freeClusterLink(n->link);
} else if (!strcasecmp(c->argv[3]->ptr, "all")) {
if (n->link) freeClusterLink(n->link);
if (n->inbound_link) freeClusterLink(n->inbound_link);
} else {
addReplyErrorFormat(c, "Unknown direction %s", (char *) c->argv[3]->ptr);
}
- 网络传输中的二进制兼容性
二进制协议还要考虑结构体成员的具体偏移量和网络序。
网络序只要用ntoh, hton就没有很大的问题了,但是偏移量是个问题。比如struct {int a; short b;}
, 总不可能在一台机器上编译的b在结构体的第5个字节,而另一台在第7个字节吧?
redis解决这个问题的方案很简单——只要偏移量不对,就无法通过编译:
static_assert(offsetof(clusterMsg, sig) == 0, "unexpected field offset");
static_assert(offsetof(clusterMsg, totlen) == 4, "unexpected field offset");
static_assert(offsetof(clusterMsg, ver) == 8, "unexpected field offset");
static_assert(offsetof(clusterMsg, port) == 10, "unexpected field offset");
static_assert(offsetof(clusterMsg, type) == 12, "unexpected field offset");
然后一定程度上预防偏移量出错的技巧是,加入unused字段,这也预留了协议扩展空间。
typedef struct {
uint32_t length; /* Total length of this extension message (including this header) */
uint16_t type; /* Type of this extension message (see clusterMsgPingExtTypes) */
uint16_t unused; /* 16 bits of padding to make this structure 8 byte aligned. */
...
clusterCron定时任务
clusterCron每一秒钟调用十次,它对每一个节点(即server.cluster->nodes)做以下几件事情:
每0.1秒:
- 处理重新连接(在clusterNodeCronHandleReconnect中)。所有连接断开的节点都会在这里重新连接。连接断开的含义是,A到B的连接曾经走过了HANDSHAKE状态,后面发现连接已经断开了。具体表现为A报错的B对应的clusterNode的link字段为NULL。
注意,处于HANDSHAKE状态且超时的话,A会直接删除B对应的clusterNode。 - 正在发送PING(有active PING),检查在timeout/2的时间内是否收到过数据。如果没有就认为连接已经出现问题,赶紧断开连接。这么做为什么是可行的?因为发送PING的时候,node->send_ping为发送时间,收到PONG了,那么node->send_ping就会设置为0。这里是cluster_legacy.c的clusterProcessPacket处理PONG包的部分。
if (!link->inbound && type == CLUSTERMSG_TYPE_PONG) {
link->node->pong_received = now;
link->node->ping_sent = 0;
/* The PFAIL condition can be reversed without external
* help if it is momentary (that is, if it does not
* turn into a FAIL state).
*
* The FAIL condition is also reversible under specific
* conditions detected by clearNodeFailureIfNeeded(). */
if (nodeTimedOut(link->node)) {
link->node->flags &= ~CLUSTER_NODE_PFAIL;
clusterDoBeforeSleep(CLUSTER_TODO_SAVE_CONFIG|
CLUSTER_TODO_UPDATE_STATE);
} else if (nodeFailed(link->node)) {
clearNodeFailureIfNeeded(link->node);
}
}
- 没有正在发送的PING,且距离上一次收到PONG时间超过了ping_interval(默认是timeout/2),发送PING。
- 手动failover的从节点,要持续地发送PING(这里暂时未理解)
- 如果距到上一次收到PONG或收到数据的时间超过了timeout,标记为PFAIL
每1秒随机挑选5个成功连接的节点,取其中最久没收到PONG的节点,发送一次PING。
连接失败检测
连接失败有两个状态:PFAIL和FAIL。(即CLUSTER_NODE_PFAIL和CLUSTER_NODE_FAIL)
PFAIL,意思是probable fail。使用cluster nodes命令可以看到一个处于PFAIL状态的节点会显示fail?
在代码中还可以找到nodeTimeOut:
#define nodeTimedOut(n) ((n)->flags & CLUSTER_NODE_PFAIL)
从clusterCron中可以看出,确实PFAIL可以看成timeout状态。
PFAIL是集群中某个节点A对另一个节点B的“个人观点”,如果发现A到B超时了,就怀疑B可能已经挂了。
整份代码中出现标记PFAIL状态的只有clusterCron、clusterLoadConfig。clusterLoadConfig在启动的时候加载配置文件执行,clusterCron则在前文解释的第5点解释了。
那么什么时候转换为FAIL状态?
整份代码中出现标记FAIL状态的只有clusterLoadConfig、markNodeAsFailingIfNeeded、clusterProcessPacket。
markNodeAsFailingIfNeeded只在clusterCron和clusterProcessGossipSection中调用。clusterProcessGossipSection是加工收到的PING/PONG/MEET...包的函数,所以这是处理“别人的观点”。(clusterCron会处理整个集群中只有一个分配了slot的主节点的特殊情况,所以似乎cluster模式似乎可以替代普通模式的主从复制。不知道这是否在生产中用了,但我知道官方不推荐这么做)
clusterProcessGossipSection处理的是gossip字段,即前文提到的这个结构体:
typedef struct {
char nodename[CLUSTER_NAMELEN];
uint32_t ping_sent;
uint32_t pong_received;
char ip[NET_IP_STR_LEN]; /* IP address last time it was seen */
uint16_t port; /* primary port last time it was seen */
uint16_t cport; /* cluster port last time it was seen */
uint16_t flags; /* node->flags copy */
uint16_t pport; /* secondary port last time it was seen */
uint16_t notused1;
} clusterMsgDataGossip;
如果flags中FAIL和PFAIL位为1,那么会添加或替换failure reports,也就是说每个clusterNode中有一个叫做fail_reports链表,里面是clusterNode指针,如果clusterMsgDataGossip中的nodename出现在这个链表的节点中,那么就更新failure report的时间,否则新增一个节点。(似乎这里把fail_reports链表看成哈希表更合适一些)
int clusterNodeAddFailureReport(clusterNode *failing, clusterNode *sender) {
list *l = failing->fail_reports;
listNode *ln;
listIter li;
clusterNodeFailReport *fr;
/* If a failure report from the same sender already exists, just update
* the timestamp. */
listRewind(l,&li);
while ((ln = listNext(&li)) != NULL) {
fr = ln->value;
if (fr->node == sender) {
fr->time = mstime();
return 0;
}
}
/* Otherwise create a new report. */
fr = zmalloc(sizeof(*fr));
fr->node = sender;
fr->time = mstime();
listAddNodeTail(l,fr);
return 1;
}
之后还要清理旧的fail_reports。目前redis对“旧的”的标准是它记录的report的时间是两倍timeout。
清理之后统计有多少个节点认为该节点已经无法连接了。如果有大于等于cluster->size / 2 + 1
个节点标记它为PFAIL状态,那么就转换为FAIL状态。
有一个例外,就是自身确实还能够连接到目标节点
重连
重连的过程与建立连接的过程相似——发起connect,发送PING,等待收到PONG,收到PONG后去掉FAIL或PFAIL状态。
如何复现一个重连?我目前只想到了两种方法:
- 先改配置文件,让timeout为5000ms,然后手动cluster meet,令两个节点建立连接,之后Ctrl+C,中断程序几秒钟,让它进入PFAIL状态,再用gdb的set,设置timeout为一个大值。
- 先改配置文件,让timeout为5000ms,然后手动cluster meet,令两个节点建立连接。之后改配置文件的timeout为一个大值,再gdb重启两个redis。这种做法应该是最简单的了,因为可以避免一堆PING/PONG,而且还可以复现两个重连的过程。
一个连接在这些情况下会被断开:IO错误、地址或名字变更、缓冲数据过多、PING花费了timeout/2的时间、手动释放。(之所以敢这么肯定是有原因的)
$ rg freeClusterLink
cluster_legacy.c
89:void freeClusterLink(clusterLink *link); // 声明
1160:void freeClusterLink(clusterLink *link) { // 定义
1194: freeClusterLink(node->inbound_link); // 设置inbound_link时替换旧的连接
1494: if (n->link) freeClusterLink(n->link); // 释放Node的检查
1495: if (n->inbound_link) freeClusterLink(n->inbound_link); // 同上freeClusterNode
2160: if (node->link) freeClusterLink(node->link); // 地址更换的连接释放
2256: if (node->link) freeClusterLink(node->link); // 同上
2913: freeClusterLink(link); // 已连接的Node改名了
3252: freeClusterLink(link); // 处理IOError
3309: freeClusterLink(link); // ConnectHandler处理连接失败的情况
4576: freeClusterLink(link); // CronReconnectHandler设置Connect回调失败
4583:static void freeClusterLinkOnBufferLimitReached(clusterLink *link) { // BufferLimit释放连接用
4593: freeClusterLink(link); // 同上
4600: freeClusterLinkOnBufferLimitReached(node->link); // 同上
4601: freeClusterLinkOnBufferLimitReached(node->inbound_link); // 同上
4721: freeClusterLink(node->link); // PING花费了timeout/2的时间了还没收到PONG,释放连接用
5888: if (n->inbound_link) freeClusterLink(n->inbound_link); // 手动释放连接
5890: if (n->link) freeClusterLink(n->link); // 同上
5892: if (n->link) freeClusterLink(n->link); // 同上
5893: if (n->inbound_link) freeClusterLink(n->inbound_link); // 同上
值得强调的是标记为PFAIL、FAIL状态不会断开socket。为什么这么做是对的?
因为连接出现问题导致的连接删除的只有一种情况:PING花费了timeout/2的时间还没收到PONG。这又包含了几种情况:网络链路断开了或服务没有启动,即socket无法建立,服务端在处理大Key等耗费时间极长的事情上,即服务器无响应。
这里需要聊聊异步connect/accept。
connect/accept需要阻塞等待网络另一端主机,所以要走一遍网络链路,这个过程耗时较久,所以connect/accept一般是异步的。具体做法是等待epoll/poll/kqueue/...通知有事件,然后将事件分发到对应的回调,再由回调执行connect/accept函数。
那么A向B发起异步connect后,什么时候可以得知connect成功?我不太能说清楚,但是我能够确定的是,在B调用accept获取对应的fd之前A就调用了connect成功的回调函数。
如果是socket无法建立,所以不会有任何进一步的操作,直到EPOLLERR触发,此时回调会删除连接。
如果是服务器无响应,那么虽然会建立起socket,但是PING肯定是无响应的,于是在timeout/2的时间还没收到PONG,到此释放连接。
如果有响应,那么将会去掉FAIL和PFAIL状态。
附录
- 一个关于EPOLLERR看起来挺奇怪的define和enum混用的解释:
https://stackoverflow.com/questions/8588649/what-is-the-purpose-of-a-these-define-within-an-enum
enum EPOLL_EVENTS
{
EPOLLIN = 0x001,
#define EPOLLIN EPOLLIN
EPOLLPRI = 0x002,
#define EPOLLPRI EPOLLPRI
EPOLLOUT = 0x004,
#define EPOLLOUT EPOLLOUT
EPOLLRDNORM = 0x040,
#define EPOLLRDNORM EPOLLRDNORM
EPOLLRDBAND = 0x080,
#define EPOLLRDBAND EPOLLRDBAND
EPOLLWRNORM = 0x100,
- 脚本文件
#!/usr/bin/bash
# This script were written for quickly start a dist-system.
# PORT USAGE
# 7000..7003 clusters
# 7004..7006 sentinels
function create-config() {
echo create config
for i in 7000 7001 7002 7003 7004 7005 ; do
echo "write to $i/redis.conf
# redis.conf file
port $i
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 24000000
appendonly yes
"
echo "# redis.conf file
port $i
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 24000000
appendonly yes
" > $i/redis.conf
done
for i in {7006..7008}; do
echo "write to $i/redis.conf
sentinel monitor myprimary 127.0.0.1 7000 2
sentinel down-after-milliseconds myprimary 5000
sentinel failover-timeout myprimary 60000
"
echo "port $i
sentinel monitor myprimary 127.0.0.1 7000 2
sentinel down-after-milliseconds myprimary 5000
sentinel failover-timeout myprimary 60000
" > $i/redis.conf
done
}
function create-cluster() {
for i in {7000..7005}; do
cd $i
../../redis-server ./redis.conf &
echo $!
cd ..
done
}
function run-all() {
# step1: create 4 clusters
create-cluster
sleep 3
# step2: add them to cluster.
redis-cli --cluster create 127.0.0.1:700{0..5} --cluster-replicas 1
# step3: create 3 sentinels.
for i in {7006..7008}; do
cd $i
../../redis-server ./redis.conf --sentinel &
echo $!
cd ..
done
sleep 3
echo Press any key to exit.
wait
}
function cleanup() {
killall redis-server
wait
rm */* -rf
create-config
}
case "$1" in
"create-config")
create-config
;;
"run-all")
run-all
;;
"cleanup")
cleanup
;;
"create-cluster")
create-cluster
;;
*)
run-all
;;
esac