sentinel

第十一章 高可用性(High Availability)和哨兵(Sentinel)机制

1 概述

前一章我们介绍了Redis系统的数据复制特性。因为Redis将数据存储在内存中,当系统出现故障时,Redis可能会丢失所有的数据。因此,Redis开发人员设计了数据复制(Data Replication)特性。当主数据库出现故障时,数据不会丢失,因为从数据库还存放着一份数据拷贝。

在此基础之上,Redis开发人员进一步设计了哨兵(Sentinel)特性,以增强系统的可用性(Availability)。哨兵的功能是实时监视Redis数据库的工作状态。当主数据库出现故障无法提供服务时,哨兵能将一个从数据库"晋升(promote)"为主数据库,继续为用户提供服务。

在Redis系统中,哨兵主要提供以下四种功能。

  1. 监视(Monitoring)。哨兵监视主数据库和从数据库的工作状态。
  2. 通知(Notification)。当故障发生时,哨兵能向管理员推送消息。
  3. 故障迁移(Failover)。当主数据库无法继续提供服务时,哨兵能自动选择一个从数据库,将其晋升为主数据库,继续提供服务。
  4. 配置提供者(Configuration Provider)。客户端能向哨兵查询服务节点的地址。

2 部署场景

在Redis系统中,哨兵是一个可选的、独立的模块(进程)。Redis系统可以运行哨兵,也可以不运行哨兵;可以在同一台服务器上运行Redis数据库和哨兵,也可以分别在不同服务器上运行。以下是几种常见的Redis数据库与哨兵的部署场景。哨兵的运行个数一般为奇数个,这也是为了便于哨兵之间达成共识(Reach Consensus)。

第一个部署场景是一个单机模式的部署场景。在一个服务器上同时运行Redis服务器和哨兵。该哨兵能监视这个Redis服务器,并随时可以向开发人员发送状态消息。

图一 哨兵部署场景一

图一 哨兵部署场景一。

第二个部署场景是一个最为常见的部署场景。Redis系统分别在两个服务器上运行主服务器和从服务器。在该场景下,开发人员还部署了三个哨兵,分别运行在三个不同的服务器上。这三个哨兵同时监视主从两个数据库,并可以随时向开发人员发送状态消息。将数据库与哨兵运行于不同服务器上的好处是,当数据库服务器出现故障时,哨兵服务器仍然能够正常运行,并报告数据库服务器出错信息。

图二 哨兵部署场景二

图二 哨兵部署场景二。

第三个部署场景的特点是将哨兵部署在Redis客户端所在的服务器上。这样部署的好处是,哨兵与Redis服务器连接的线路与Redis客户端与服务器连接的线路一致。因此,哨兵报告的Redis服务器运行状态更加准确。

图三 哨兵部署场景三

图三 哨兵部署场景三。

3 哨兵的工作原理

设置哨兵用于检测系统的状态是分布式系统一种常见的做法。一般来说,哨兵运行在独立的进程中,并且定时向主数据库和从数据库查询状态。如果系统部署了多个哨兵,并且哨兵对系统状态认定的结果不一致时,如何认定故障并采取措施是分布式系统的一类重要的问题。因此,我们将在下面的三个小节中介绍Redis系统如何解决这个问题。

3.1 故障认定

在监控主从数据库的过程中,数据库可能处于三个状态。正常状态是指哨兵认为该数据库运行正常。主观下线(Subjectively Down)是指单个哨兵认为数据库处于异常状态。当某个数据库被多个哨兵(多于quorum个)认定处于异常状态时,这个数据库会被认为处于客观下线(Objectively Down)状态。quorum是一个哨兵的配置参数。

因此,我们将哨兵对数据库的故障认定过程总结如下。

  1. 哨兵会定期向主从数据库和其他哨兵发送消息,以检测其状态。
  2. 如果数据库未能及时应答(超过own-after-milliseconds配置参数),则该数据库会被认为处于主观下线状态。
  3. 当有足够的哨兵认定某一数据库处于主观下线状态后,该数据库会被认定为处于客观下线状态。
  4. 在一段时间后,如果哨兵又接收到了对端的响应,则数据库会从主观下线/客观下线状态恢复为正常状态。

3.2 达成共识的过程

从上述的故障认定的过程可以看出,哨兵之间也需要相互交换信息,以确定将某一数据库从主观下线状态转变为客观下线状态。当需要时,哨兵之间还需要达成共识(Reach Consensus),施行故障迁移(Failover)。

在分布式系统中,如何达成共识也是一个重要的问题。在Redis系统中,有两个问题需要哨兵之间达成共识。其一是上述的认定数据库处于客观下线状态;另一个则是当数据库被认定处于客观下线状态之后,由哪个哨兵发起故障迁移。第二个问题是一个经典的领导人选举(Leader Election)问题,即Redis系统需要选举一个哨兵领导人来发起故障迁移。

在Redis系统中,哨兵领导人是如何被选举产生的呢?哨兵之间会定时相互传递消息。消息中会携带主从数据库的状态,因此,当数据库发生异常时,哨兵能够检查到数据库的出错状态,并能够将其从主观下线转变为客观下线。当某一个哨兵认定了数据库出现客观下线后,就会进入领导人选举状态,进而执行故障迁移。

在领导人选举状态中,哨兵会将自己提名为领导人,并向其他哨兵询问是否可行。如果其他哨兵尚未进入领导人选举状态时,它们会同意领导人提名。但是当哨兵进入领导人选举状态后,就会拒绝后续的提名请求。因此,如果在某一时刻,只有一个哨兵进入领导人选举状态的话,那么,这个哨兵会被选举为领导人。如果在同一时刻,有两个哨兵同时进入领导人选举状态的话,它们会竞争这个领导人的位置,直至最终有一人当选。如果出现平局,或者无人达到领导人选举的要求的话,选举过程将重新进行。

3.3 故障迁移的过程

在选出哨兵领导人后,下一步就是进行故障迁移了。故障迁移的第一步是选择一个合适的从数据库,将其晋升为主数据库。在选择从数据库时,哨兵会考虑以下几个因素。

  1. 从数据库是否在近期内发生过状态异常。
  2. 从数据库的优先级(在配置文件中设置)。
  3. 从数据库中持有的数据偏移量。偏移量越大,从数据库持有的数据越新。
  4. 如果出现平局的话,Redis会选择进程ID较小的那个从数据库。

在选择从数据库时,如果从数据库与主数据库连接超时的次数过多时,这个从数据库是不会被选中的。

在选中一个从数据库之后,哨兵会向该从数据库发送SLAVEOF NO ONE命令。与此同时,哨兵还会向其他从数据库下发命令,使它们从此开始复制新数据库上的数据。当主服务器恢复正常后,它会被设置为新主服务器的从服务器。值得注意的是,当一个从数据库被“晋升”为主数据库后,一切数据均以新主数据库为准。如果在"晋升"的时刻,该新主数据库未获得最新的数据的话,那么这些最新的数据会被认为"丢失"了。

4 实现细节

最后,我们简单的介绍一下Redis哨兵相关的源代码。本章绝大多数的源代码在sentinel.c文件中。

4.1 数据结构

在sentinel.c文件中,结构体sentinelState代表的是哨兵自身的状态,而结构体sentinelRedisInstance代表的是哨兵监控的一个主/从数据库。在sentinelState中,masters字段保存的是该哨兵监控的主数据库节点(主数据库名称到sentinelRedisInstance对象的映射)。一个哨兵可以同时监控多个主数据库。在sentinelRedisInstance对象中,如果该对象表示的是主数据库的状态的话,那么,可以从这个对象找到它的从数据库的状态对象(slaves),和监控这个主数据库的哨兵对象(sentinels)。quorum字段表示的是在发起故障迁移之前,需要得到哨兵同意的最少个数。leader则表示发起故障迁移的哨兵的名字,即哨兵领导人的名字。

// Redis 5.0.8 版本
// sentinel.c
typedef struct sentinelRedisInstance {
    ...
    dict *sentinels;    /* Other sentinels monitoring the same master. */
    dict *slaves;       /* Slaves for this master instance. */
    unsigned int quorum;/* Number of sentinels that need to agree on failure. */
    char *leader;
    ...
}

struct sentinelState {
    ...
    dict *masters;
    ...
} sentinel;

4.2 定时功能

哨兵内部也维护着定时功能。和我们之前讲解过的,哨兵功能相关的定时器是被serverCron()函数调用的。然后,在sentinelHandleDictOfRedisInstances函数中,哨兵会轮询所有的主数据库状态,并且查询对应的从数据库和监控该主数据库的哨兵。最后,还检查该数据库是否需要启动故障迁移过程。

// Redis 5.0.8 版本
// srever.c
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
    ...
    if (server.sentinel_mode) sentinelTimer();
    ...
}

// Redis 5.0.8 版本
// sentinel.c
void sentinelTimer(void) {
    sentinelCheckTiltCondition();
    sentinelHandleDictOfRedisInstances(sentinel.masters);
    sentinelRunPendingScripts();
    sentinelCollectTerminatedScripts();
    sentinelKillTimedoutScripts();
    ...
}

void sentinelHandleDictOfRedisInstances(dict *instances) {
    ...
    // 轮询所监控的所有主数据库
    di = dictGetIterator(instances);
    while((de = dictNext(di)) != NULL) {
        sentinelRedisInstance *ri = dictGetVal(de);
        // 检查该主数据库的状态
        sentinelHandleRedisInstance(ri);
        if (ri->flags & SRI_MASTER) {
            // 并且检查其对应的所有从数据库和哨兵的状态
            sentinelHandleDictOfRedisInstances(ri->slaves);
            sentinelHandleDictOfRedisInstances(ri->sentinels);
        }
    }
    // 如果需要故障迁移的话
    if (switch_to_promoted)
        sentinelFailoverSwitchToPromotedSlave(switch_to_promoted);
    ...
}

4.2 检查数据库状态

在检查数据库状态时,哨兵主要检查数据库是否处于主观下线和客观下线状态。主观下线和客观下线的函数是在sentinelHandleRedisInstance()函数中调用的。

从sentinelCheckSubjectivelyDown()函数可以看出,当长时间主数据库无响应时,会被认定为主观下线。

// Redis 5.0.8 版本
// sentinel.c
void sentinelCheckSubjectivelyDown(sentinelRedisInstance *ri) {
    ...
    /* Update the SDOWN flag. We believe the instance is SDOWN if:
     *
     * 1) It is not replying.
     * 2) We believe it is a master, it reports to be a slave for enough time
     *    to meet the down_after_period, plus enough time to get two times
     *    INFO report from the instance. */
    if (elapsed > ri->down_after_period || // 未响应时间超过down_after_period
        (ri->flags & SRI_MASTER &&
         ri->role_reported == SRI_SLAVE &&
         mstime() - ri->role_reported_time >
          (ri->down_after_period+SENTINEL_INFO_PERIOD*2)))
    {
        /* Is subjectively down */
        if ((ri->flags & SRI_S_DOWN) == 0) { //如果当前未被认为主观下线,则设置主观下线状态
            sentinelEvent(LL_WARNING,"+sdown",ri,"%@");
            ri->s_down_since_time = mstime();
            ri->flags |= SRI_S_DOWN;
        }
    } else {
        /* Is subjectively up */
        if (ri->flags & SRI_S_DOWN) {
            sentinelEvent(LL_WARNING,"-sdown",ri,"%@");
            ri->flags &= ~(SRI_S_DOWN|SRI_SCRIPT_KILL_SENT);
        }
    }
}

在判断客观下线的函数中,哨兵会计算当前有多少哨兵认为这个数据库已主观下线。如果个数(临时变量quorum)超过配置的quorum(master->quorum)的话,该数据库会被认为客观下线。

// Redis 5.0.8 版本
// sentinel.c
void sentinelCheckObjectivelyDown(sentinelRedisInstance *master) {
    ...
    if (master->flags & SRI_S_DOWN) {
        /* Is down for enough sentinels? */
        quorum = 1; /* the current sentinel. */
        /* Count all the other sentinels. */
        di = dictGetIterator(master->sentinels);
        while((de = dictNext(di)) != NULL) {
            sentinelRedisInstance *ri = dictGetVal(de);

            if (ri->flags & SRI_MASTER_DOWN) quorum++;
        }
        dictReleaseIterator(di);
        if (quorum >= master->quorum) odown = 1; // 被认定为客观下线,置odown为1
    }

    /* Set the flag accordingly to the outcome. */
    if (odown) {
        if ((master->flags & SRI_O_DOWN) == 0) {//如果当前未被设置客观下线的话,设置为客观下线
            sentinelEvent(LL_WARNING,"+odown",master,"%@ #quorum %d/%d",
                quorum, master->quorum);
            master->flags |= SRI_O_DOWN;
            master->o_down_since_time = mstime();
        }
    } else {
        if (master->flags & SRI_O_DOWN) {
            sentinelEvent(LL_WARNING,"-odown",master,"%@");
            master->flags &= ~SRI_O_DOWN;
        }
    }
}

4.3 哨兵领导人选举过程

当发现主数据库客观下线之后,哨兵之间需要选举一个领导人出来,下发故障迁移的命令。选择哨兵领导人的主要逻辑在sentinelGetLeader函数中。该函数返回当前哨兵所知道的领导人。值得注意的是,因为当发现数据库客观下线后,如果领导人尚未选出的话,各个哨兵会推选自己为领导人。所以,在同一时刻,可能会出现多个候选人的情况。

在sentinelGetLeader函数中,哨兵遍历当前主数据库的所有哨兵,并统计票数。只有当哨兵获得绝大多数票数,并且超过所配置的master->quorum时,该哨兵才能被确定为领导人。

// Redis 5.0.8 版本
// sentinel.c
char *sentinelGetLeader(sentinelRedisInstance *master, uint64_t epoch) {
    ...
    /* Check what's the winner. For the winner to win, it needs two conditions:
     * 1) Absolute majority between voters (50% + 1).
     * 2) And anyway at least master->quorum votes. */
    di = dictGetIterator(counters);
    while((de = dictNext(di)) != NULL) {
        uint64_t votes = dictGetUnsignedIntegerVal(de);

        if (votes > max_votes) {
            max_votes = votes;
            winner = dictGetKey(de);
        }
    }
    dictReleaseIterator(di);

    /* Count this Sentinel vote:
     * if this Sentinel did not voted yet, either vote for the most
     * common voted sentinel, or for itself if no vote exists at all. */
    if (winner)
        myvote = sentinelVoteLeader(master,epoch,winner,&leader_epoch);
    else
        myvote = sentinelVoteLeader(master,epoch,sentinel.myid,&leader_epoch);
    
    if (myvote && leader_epoch == epoch) {
        uint64_t votes = sentinelLeaderIncr(counters,myvote);

        if (votes > max_votes) {
            max_votes = votes;
            winner = myvote;
        }
    }

    // 只有当winner的票数超过半数后,并且票数超过master->quorum,才能被确定为领导人。
    voters_quorum = voters/2+1;
    if (winner && (max_votes < voters_quorum || max_votes < master->quorum))
        winner = NULL;

    winner = winner ? sdsnew(winner) : NULL;
    sdsfree(myvote);
    dictRelease(counters);
    return winner;
}

4.4 从数据库的选择

当认定主数据库客观下线后,哨兵需要选择一个从数据库来代替主数据库。这个逻辑是在sentinelSelectSlave函数中实现的。在函数中,哨兵会遍历当前主数据库的所有从数据库,并且过滤掉不适合成为主数据库的从数据库。其判断条件包括:不能选择当前状态是主观下线或者客观下线的从数据库;不能选择当前连接状态出问题的从数据库;不能选择在一段时间内无响应的从数据库等。最后,当选出了备选从数据库后,哨兵会简单的选择第一个备选者,成为新的主数据库。

// Redis 5.0.8 版本
// sentinel.c
sentinelRedisInstance *sentinelSelectSlave(sentinelRedisInstance *master) {
    ...
    di = dictGetIterator(master->slaves);
    while((de = dictNext(di)) != NULL) {
        sentinelRedisInstance *slave = dictGetVal(de);
        mstime_t info_validity_time;

        //过滤一些不能被选择的从数据库
        if (slave->flags & (SRI_S_DOWN|SRI_O_DOWN)) continue;
        if (slave->link->disconnected) continue;
        if (mstime() - slave->link->last_avail_time > SENTINEL_PING_PERIOD*5) continue;
        if (slave->slave_priority == 0) continue;

        /* If the master is in SDOWN state we get INFO for slaves every second.
         * Otherwise we get it with the usual period so we need to account for
         * a larger delay. */
        if (master->flags & SRI_S_DOWN)
            info_validity_time = SENTINEL_PING_PERIOD*5;
        else
            info_validity_time = SENTINEL_INFO_PERIOD*3;
        
        //继续过滤一些不能被选择的从数据库
        if (mstime() - slave->info_refresh > info_validity_time) continue;
        if (slave->master_link_down_time > max_master_down_time) continue;
        instance[instances++] = slave;
    }
    dictReleaseIterator(di);
    if (instances) {
        qsort(instance,instances,sizeof(sentinelRedisInstance*),
            compareSlavesForPromotion);
        //选中第一个备选的从数据库
        selected = instance[0];
    }
    zfree(instance);
    return selected;
}

4.5 故障迁移

当选定哨兵领导人和即将晋升的从数据库之后,该哨兵领导人会向该从数据库下发SLAVEOF NO ONE命令。这部分逻辑是在sentinelSendSlaveOf()函数中实现的。当host入参为NULL时,会向对端发送SLAVEOF NO ONE命令。可以从下面的代码看出,SLAVEOF命令包含在一个事务(Transaction)中,这是为了保证数据库状态的正确性。该事务中的命令要么全部执行,要么全部不执行。

// Redis 5.0.8 版本
// sentinel.c
int sentinelSendSlaveOf(sentinelRedisInstance *ri, char *host, int port) {
    ...
    if (host == NULL) {
        host = "NO";
        memcpy(portstr,"ONE",4);
    }

    /* In order to send SLAVEOF in a safe way, we send a transaction performing
     * the following tasks:
     * 1) Reconfigure the instance according to the specified host/port params.
     * 2) Rewrite the configuration.
     * 3) Disconnect all clients (but this one sending the commnad) in order
     *    to trigger the ask-master-on-reconnection protocol for connected
     *    clients.
     *
     * Note that we don't check the replies returned by commands, since we
     * will observe instead the effects in the next INFO output. */
    retval = redisAsyncCommand(ri->link->cc,
        sentinelDiscardReplyCallback, ri, "%s",
        sentinelInstanceMapCommand(ri,"MULTI"));
    if (retval == C_ERR) return retval;
    ri->link->pending_commands++;

    retval = redisAsyncCommand(ri->link->cc,
        sentinelDiscardReplyCallback, ri, "%s %s %s",
        sentinelInstanceMapCommand(ri,"SLAVEOF"),
        host, portstr);
    if (retval == C_ERR) return retval;
    ri->link->pending_commands++;

    retval = redisAsyncCommand(ri->link->cc,
        sentinelDiscardReplyCallback, ri, "%s REWRITE",
        sentinelInstanceMapCommand(ri,"CONFIG"));
    if (retval == C_ERR) return retval;
    ri->link->pending_commands++;
    ...
}

5 小结

本章介绍了Redis系统中哨兵功能的实现细节。哨兵是独立运行的一个进程,用于监控Redis主从数据库的运行状态。当主数据库下线时,哨兵能从从数据库中选出一个替代主数据库,继续提供服务。这个替换的过程被称为故障迁移。实现故障迁移的功能需要注意两点:第一、当存在多个哨兵时,哨兵之间如何达成共识;第二、如何在多个从数据库中选择一个最"好"的替代者。

 

上一章
下一章

注册用户登陆后可留言

Copyright  2019 Little Waterdrop, LLC. All Rights Reserved.