前一段时间redis的数据结构读差不多了,就打算读redis的分布式。可是由于方法不对,看主从复制发现了很多奇怪的地方,浪费了很多时间。最近稍微找到了点眉目,打算从头开始。

因为这里的“连接”一词容易出现混用,所以“连接”指的是两个redis之间的,用户态中记录的一个状态,socket指的是内核中的网络连接。

本问将从源代码详解redis的cluster meet和重连。

准备工作

  1. 准备一份脚本,用于快速生成配置文件。这份脚本我放在附录中,它的作用是生成这样的配置文件。

这个配置文件值得提的是: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
  1. 由于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
  1. 调试建议与步骤

首先开启两个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也很可能刚好为这些值。

image

该图省略了无关紧要的PING/PONG,可以通过看这些PING/PONG的fd推断这些PING/PONG是否是多余的。

从图中也可以轻易地发现,cluster meet返回的时间很早。所以你可能在cluster nodes中发现handshake状态的节点。

image

需要解释的有:

  1. PING/PONG/MEET包的格式

它们是同一个结构体clusterMsg,图示如下:

image

结构体定义如下(省略了暂时没必要列出来的):

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获取。

  1. 随机选取N/10个节点的原因

虽然注释里写了,可是似乎没写明白。但是我知道的有:N是所有已知节点的数量(即server.cluster->nodes的数量),一秒钟发送1个包,发送的内容是这些随机选出的节点的名字等。

  1. fd之间的关系

以上图为例,A节点初步建立的fd分别是,A节点的fd为17,B节点为16。而后的B建立的是,B节点为17,A节点为18

  1. 定时任务

向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);
  1. 节点与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);
    }
  1. 网络传输中的二进制兼容性

二进制协议还要考虑结构体成员的具体偏移量和网络序。

网络序只要用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秒:

  1. 处理重新连接(在clusterNodeCronHandleReconnect中)。所有连接断开的节点都会在这里重新连接。连接断开的含义是,A到B的连接曾经走过了HANDSHAKE状态,后面发现连接已经断开了。具体表现为A报错的B对应的clusterNode的link字段为NULL。
    注意,处于HANDSHAKE状态且超时的话,A会直接删除B对应的clusterNode。
  2. 正在发送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);
            }
        }
  1. 没有正在发送的PING,且距离上一次收到PONG时间超过了ping_interval(默认是timeout/2),发送PING。
  2. 手动failover的从节点,要持续地发送PING(这里暂时未理解)
  3. 如果距到上一次收到PONG或收到数据的时间超过了timeout,标记为PFAIL

每1秒随机挑选5个成功连接的节点,取其中最久没收到PONG的节点,发送一次PING。

连接失败检测

连接失败有两个状态:PFAIL和FAIL。(即CLUSTER_NODE_PFAIL和CLUSTER_NODE_FAIL)

PFAIL,意思是probable fail。使用cluster nodes命令可以看到一个处于PFAIL状态的节点会显示fail?

image

在代码中还可以找到nodeTimeOut:

#define nodeTimedOut(n) ((n)->flags & CLUSTER_NODE_PFAIL)

从clusterCron中可以看出,确实PFAIL可以看成timeout状态。

PFAIL是集群中某个节点A对另一个节点B的“个人观点”,如果发现A到B超时了,就怀疑B可能已经挂了。

整份代码中出现标记PFAIL状态的只有clusterCron、clusterLoadConfig。clusterLoadConfig在启动的时候加载配置文件执行,clusterCron则在前文解释的第5点解释了。

image

那么什么时候转换为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状态。

如何复现一个重连?我目前只想到了两种方法:

  1. 先改配置文件,让timeout为5000ms,然后手动cluster meet,令两个节点建立连接,之后Ctrl+C,中断程序几秒钟,让它进入PFAIL状态,再用gdb的set,设置timeout为一个大值。
  2. 先改配置文件,让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状态。

附录

  1. 一个关于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,
  1. 脚本文件
#!/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