天天看點

Redis(十):pub/sub 釋出訂閱源碼解析

  談到釋出訂閱模式,相信不會陌生,典型的觀察者模式的實作。然而從表面來看,本地實作一個wait/notify通知、register/update調用, 實作一個遠端mq服務, 還有本文說的 pub/sub, 其實道理都差不多。隻是,同樣的需求,針對不同的環境,實作上往往是有天壤之别的。

  是以,我們就來看看 redis 的 pub/sub 是如何實作的吧!

零、redis釋出訂閱相關概念介紹

  Redis 釋出訂閱(pub/sub)是一種消息通信模式:發送者(pub)發送消息,訂閱者(sub)接收消息。Redis 用戶端可以訂閱任意數量的頻道。

  下圖展示了頻道 channel1,以及訂閱這個頻道的三個用戶端 —— client2 、 client5 和 client1 之間的關系:

Redis(十):pub/sub 釋出訂閱源碼解析

  當有新消息通過 PUBLISH 指令發送給頻道 channel1 時, 這個消息就會被發送給訂閱它的三個用戶端:

Redis(十):pub/sub 釋出訂閱源碼解析

  Redis的pub/sub實作中,釋出消息的方式隻有一種,但是訂閱消息卻有很多種方式。

  使用場景如: 可以用做簡單消息通信中間件,監聽某些事件的變化;

  從官方手冊上查到相關使用方法。

1> PUBLISH channel message

功能: 将資訊發送到指定的頻道。

傳回值: 接收到該消息的個數;

2> SUBSCRIBE channel [channel ...]

功能: 訂閱給定的一個或多個頻道的資訊。

傳回值: 等待消息狀态,用戶端不能再處理其他指令了。 除了 SUBSCRIBE, PSUBSCRIBE, UNSUBSCRIBE, PUNSUBSCRIBE, PING and QUIT commands.

3> PSUBSCRIBE pattern [pattern ...]

功能: 訂閱一個或多個符合給定模式的頻道。

4> PUBSUB subcommand [argument [argument ...]]

功能: 檢視訂閱與釋出系統狀态。subcomand 有 CHANNELS,NUMSUB,NUMPAT .

傳回值:

PUBSUB CHANNELS [pattern] 列舉出所有至少有一個訂閱者的符合表達式的channel(精确訂閱的用戶端,即使用 SUBSCRIBE 進行訂閱的用戶端);

PUBSUB NUMSUB [channel-1 ... channel-N] 每個要查詢的channel的訂閱數kv(精确訂閱的用戶端,即使用 SUBSCRIBE 進行訂閱的用戶端);

PUBSUB NUMPAT 傳回使用了 PSUBSCRIBE 訂閱的用戶端總數;

5> UNSUBSCRIBE [channel [channel ...]]

功能: 指退訂給定的頻道。

傳回值: 退訂頻道的影響訂閱數,自身未訂閱時,影響數為0;

6> PUNSUBSCRIBE [pattern [pattern ...]]

功能: 退訂所有給定模式的頻道。

  以上指令的操作,當使用 redis-cli 時,将受限。使用 SUBSCRIBE/PSUBSCRIBE 訂閱channel後,隻能強行退出,不能再接受其他指令。即隻有配合各語言實作的sdk,才能連貫完成上面完整的操作。

一、pub/sub 相關資料結構

  pub/sub 相關接口定義如下:

{"subscribe",subscribeCommand,-2,"rpslt",0,NULL,0,0,0,0,0},
    {"unsubscribe",unsubscribeCommand,-1,"rpslt",0,NULL,0,0,0,0,0},
    {"psubscribe",psubscribeCommand,-2,"rpslt",0,NULL,0,0,0,0,0},
    {"punsubscribe",punsubscribeCommand,-1,"rpslt",0,NULL,0,0,0,0,0},
    {"publish",publishCommand,3,"pltrF",0,NULL,0,0,0,0,0},
    {"pubsub",pubsubCommand,-2,"pltrR",0,NULL,0,0,0,0,0},      

  整個pub/sub使用的資料結構,都是之前介紹過的。主要有  dict, list 兩種,針對模式比對訂閱稍微多了個屬性:

// 使用 PSUBSCRIBE 訂閱方式,做一層資料格式封裝
typedef struct pubsubPattern {
    client *client;
    robj *pattern;
} pubsubPattern;      

  

二、subscribe/psubscribe 訂閱channel實作

  隻有先有訂閱者之後,釋出者發送的消息才會有意義。是以我們先看看訂閱的實作:

// 用法: SUBSCRIBE channel [channel ...]
// pubsub.c
void subscribeCommand(client *c) {
    int j;
    // n 個channel 的訂閱,循環調用即可
    for (j = 1; j < c->argc; j++)
        pubsubSubscribeChannel(c,c->argv[j]);
    // 添加pubsub訂閱辨別,友善其他地方判斷
    c->flags |= CLIENT_PUBSUB;
}
// 具體的單個 channel 訂閱實作
/* Subscribe a client to a channel. Returns 1 if the operation succeeded, or
 * 0 if the client was already subscribed to that channel. */
int pubsubSubscribeChannel(client *c, robj *channel) {
    dictEntry *de;
    list *clients = NULL;
    int retval = 0;

    /* Add the channel to the client -> channels hash table */
    // step1. 将要訂閱的 channel 添加到各自用戶端的 pubsub_channels 容器中
    if (dictAdd(c->pubsub_channels,channel,NULL) == DICT_OK) {
        retval = 1;
        incrRefCount(channel);
        /* Add the client to the channel -> list of clients hash table */
        // step2. 将要訂閱的channel 添加到 server.pubsub_channels 中, 友善在publish時判定是否觸發通知
        de = dictFind(server.pubsub_channels,channel);
        if (de == NULL) {
            clients = listCreate();
            dictAdd(server.pubsub_channels,channel,clients);
            incrRefCount(channel);
        } else {
            clients = dictGetVal(de);
        }
        // step3. 将用戶端自身添加到相應的 server.pubsub_channels 對應的隊列中去, 在通知時隻需周遊該隊列即可
        listAddNodeTail(clients,c);
    }
    /* Notify the client */
    // 響應用戶端: 
    // *3 \r\n
    // $9\r\nsubscribe\r\n
    // channel
    // 111(該用戶端總共訂閱的channel數)
    addReply(c,shared.mbulkhdr[3]);
    addReply(c,shared.subscribebulk);
    addReplyBulk(c,channel);
    addReplyLongLong(c,clientSubscriptionsCount(c));
    return retval;
}
// 用戶端訂閱的總channel數, 兩種訂閱方式相加
/* Return the number of channels + patterns a client is subscribed to. */
int clientSubscriptionsCount(client *c) {
    return dictSize(c->pubsub_channels)+
           listLength(c->pubsub_patterns);
}      

  如上就是單個channel的訂閱方式了,總結如下:

    1. 用戶端自行管理需要訂閱的channel, 放到 c->pubsub_channels 中;

    2. redis使用的一個統一的 server->pubsub_channels dict容器進行管理所有的channel;

    3. 對于多個用戶端訂閱一個channel, redis 使用list進行管理追加;

  整個訂閱過程,其實就是一個注冊的過程,自然複雜不到哪裡去。接下來,我們同步來看一下 使用模式訂閱的方式的注冊如何?

// 用法: PSUBSCRIBE pattern [pattern ...]
// pubsub.c
void psubscribeCommand(client *c) {
    int j;
    // 同樣是n個channel依次注冊
    for (j = 1; j < c->argc; j++)
        pubsubSubscribePattern(c,c->argv[j]);
    c->flags |= CLIENT_PUBSUB;
}
// 注冊單個模式比對的 channel 訂閱
/* Subscribe a client to a pattern. Returns 1 if the operation succeeded, or 0 if the client was already subscribed to that pattern. */
int pubsubSubscribePattern(client *c, robj *pattern) {
    int retval = 0;
    // 直接查找對應的 pattern, 沒有則添加
    if (listSearchKey(c->pubsub_patterns,pattern) == NULL) {
        retval = 1;
        pubsubPattern *pat;
        listAddNodeTail(c->pubsub_patterns,pattern);
        incrRefCount(pattern);
        pat = zmalloc(sizeof(*pat));
        pat->pattern = getDecodedObject(pattern);
        pat->client = c;
        listAddNodeTail(server.pubsub_patterns,pat);
    }
    /* Notify the client */
    addReply(c,shared.mbulkhdr[3]);
    addReply(c,shared.psubscribebulk);
    addReplyBulk(c,pattern);
    addReplyLongLong(c,clientSubscriptionsCount(c));
    return retval;
}      

  PSUBSCRIBE 的管理方式與 SUBSCRIBE 的管理方式不一樣,它是直接使用list儲存訂閱的模式到 server.pubsub_patterns 中,針對不一樣的模式,使用一個新的pubsubPattern來儲存。

  注意:所有用戶端的訂閱管理,server.pubsub_patterns 使用平坦式管理,即相同的模式訂閱,有多少個用戶端,就會有多個元素被添加到 pubsub_patterns 中。(為什麼不使用子連結清單的方式進行管理呢???)

三、publish 釋出消息的實作

  publish 是觸發subscribe的方式,沒有publish動作,subscribe就會一直在等待中。想來應該不難,消息釋出之後,隻要将注冊上來的客戶一個進行消息推送,就實作了相應功能。是以,pub/sub 操作,必然是基于長連接配接的實作方式,沒毛病。

  redis 的釋出指令如下:

// 用法: PUBLISH channel message
// pubsub.c
void publishCommand(client *c) {
    // 使用 channel+message 進行釋出消息
    int receivers = pubsubPublishMessage(c->argv[1],c->argv[2]);
    // 指令傳播
    if (server.cluster_enabled)
        clusterPropagatePublish(c->argv[1],c->argv[2]);
    else
        forceCommandPropagation(c,PROPAGATE_REPL);
    addReplyLongLong(c,receivers);
}
// 釋出一條消息
/* Publish a message */
int pubsubPublishMessage(robj *channel, robj *message) {
    int receivers = 0;
    dictEntry *de;
    listNode *ln;
    listIter li;

    /* Send to clients listening for that channel */
    // 使用 SUBSCRIBE 訂閱的用戶端,直接周遊相應的 channel集合即可
    de = dictFind(server.pubsub_channels,channel);
    if (de) {
        list *list = dictGetVal(de);
        listNode *ln;
        listIter li;
        // 依次進行資料響應,将消息傳播到訂閱端
        listRewind(list,&li);
        while ((ln = listNext(&li)) != NULL) {
            client *c = ln->value;

            addReply(c,shared.mbulkhdr[3]);
            addReply(c,shared.messagebulk);
            addReplyBulk(c,channel);
            addReplyBulk(c,message);
            receivers++;
        }
    }
    /* Send to clients listening to matching channels */
    // 處理使用 PSUBSCRIBE 訂閱消息的用戶端
    // 前面說過, PSUBSCRIBE 在redis使用平坦式管理,是以需要做的模式比對将會更多
    // 也就是說 PSUBSCRIBE 的響應性能也會更差
    if (listLength(server.pubsub_patterns)) {
        listRewind(server.pubsub_patterns,&li);
        channel = getDecodedObject(channel);
        while ((ln = listNext(&li)) != NULL) {
            pubsubPattern *pat = ln->value;

            if (stringmatchlen((char*)pat->pattern->ptr,
                                sdslen(pat->pattern->ptr),
                                (char*)channel->ptr,
                                sdslen(channel->ptr),0)) {
                addReply(pat->client,shared.mbulkhdr[4]);
                addReply(pat->client,shared.pmessagebulk);
                addReplyBulk(pat->client,pat->pattern);
                addReplyBulk(pat->client,channel);
                addReplyBulk(pat->client,message);
                receivers++;
            }
        }
        decrRefCount(channel);
    }
    return receivers;
}      

  可以看到,redis消息的釋出可能比想象中的還要簡單。不過有一點需要注意的是,整個publish的消息并沒有在redis進行存儲操作,也就是說釋出完一個消息之後,就再也找不到蹤迹了。這也是很多消息中間件的實作方式,因為資料的保留可能會顯得沒有意義。

  整個釋出消息的過程,其實就是向各個subscriber進行資料推送的過程,而這些scriber則是基于長連接配接用戶端執行個體,以至于其看起來和本地實作的register/update 的觀察者子產品沒啥兩樣。

  是以,基本上 redis 的釋出訂閱功能實作得,還是實作的粒度還是比較粗的。系統上的應用如哨兵模式下的master/slave的切換。而如果自己應用的話,就需要找準自己的應用場景,不要亂用了。

四、unsubscribe 解除訂閱關系

  當關注的事件處理完成後,可能就不需要再訂閱相關消息了,就需要進行解決訂閱。解決訂閱關系,即不再接受相應的釋出消息,将自身從系統資料庫中删除即可。基本上就是和訂閱進行一個反解操作!

// 用法: UNSUBSCRIBE [channel [channel ...]]
// pubsub.c
void unsubscribeCommand(client *c) {
    // unsubscribe, 直接解決所有的訂閱
    if (c->argc == 1) {
        pubsubUnsubscribeAllChannels(c,1);
    } else {
        int j;
        // 根據指定的 channel 依次解除訂閱關系
        for (j = 1; j < c->argc; j++)
            pubsubUnsubscribeChannel(c,c->argv[j],1);
    }
    // 當一個訂閱也沒有,則自身不再處理 pubsub 相關的事務
    if (clientSubscriptionsCount(c) == 0) c->flags &= ~CLIENT_PUBSUB;
}
/* Unsubscribe a client from a channel. Returns 1 if the operation succeeded, or
 * 0 if the client was not subscribed to the specified channel. */
int pubsubUnsubscribeChannel(client *c, robj *channel, int notify) {
    dictEntry *de;
    list *clients;
    listNode *ln;
    int retval = 0;

    /* Remove the channel from the client -> channels hash table */
    incrRefCount(channel); /* channel may be just a pointer to the same object
                            we have in the hash tables. Protect it... */
    // 先删除自身的訂閱辨別,再删除 server.pubsub_channels 辨別
    if (dictDelete(c->pubsub_channels,channel) == DICT_OK) {
        retval = 1;
        /* Remove the client from the channel -> clients list hash table */
        de = dictFind(server.pubsub_channels,channel);
        serverAssertWithInfo(c,NULL,de != NULL);
        clients = dictGetVal(de);
        ln = listSearchKey(clients,c);
        serverAssertWithInfo(c,NULL,ln != NULL);
        listDelNode(clients,ln);
        if (listLength(clients) == 0) {
            /* Free the list and associated hash entry at all if this was
             * the latest client, so that it will be possible to abuse
             * Redis PUBSUB creating millions of channels. */
            dictDelete(server.pubsub_channels,channel);
        }
    }
    /* Notify the client */
    // 調用 unsubscribe 進行解決訂閱的,此處都需要進行用戶端響應通知
    if (notify) {
        addReply(c,shared.mbulkhdr[3]);
        addReply(c,shared.unsubscribebulk);
        addReplyBulk(c,channel);
        addReplyLongLong(c,dictSize(c->pubsub_channels)+
                       listLength(c->pubsub_patterns));

    }
    decrRefCount(channel); /* it is finally safe to release it */
    return retval;
}
// 解決所有的目前用戶端的訂閱關系 (SUBSCRIBE 建立的訂閱)
/* Unsubscribe from all the channels. Return the number of channels the
 * client was subscribed to. */
int pubsubUnsubscribeAllChannels(client *c, int notify) {
    dictIterator *di = dictGetSafeIterator(c->pubsub_channels);
    dictEntry *de;
    int count = 0;
    // 疊代 c->pubsub_channels 的訂閱,依次删除即可
    while((de = dictNext(di)) != NULL) {
        robj *channel = dictGetKey(de);

        count += pubsubUnsubscribeChannel(c,channel,notify);
    }
    /* We were subscribed to nothing? Still reply to the client. */
    if (notify && count == 0) {
        addReply(c,shared.mbulkhdr[3]);
        addReply(c,shared.unsubscribebulk);
        addReply(c,shared.nullbulk);
        addReplyLongLong(c,dictSize(c->pubsub_channels)+
                       listLength(c->pubsub_patterns));
    }
    dictReleaseIterator(di);
    return count;
}      

  不出所料,就是一個從 pubsub_channels 中的删除一個元素的問題,别無其他。其中需要注意的是,SUBSCRIBE 對應 UNSUBSCRIBE, PSUBSCRIBE 對應 PUNSUBSCRIBE。

五、關于redis pub/sub 之後的思考

  需要注意的是,消息中間件是遠端通信元件,必然存在各種不确定性,是以確定長連接配接的有效性是非常重要,比如通過PING-PONG方式進行續租,以保持連接配接的有效性。

  可以說,我們要實作一個簡單的pub/sub 功能是簡單的,但是要應對各種異常情況則是困難的。

    1. 比如當訂閱的量越來越大時,整個釋出消息過程可能變量緩慢起來,如何處理?

    2. 如果消費者端處理失敗,如何處理?

    3. 訂閱者為什麼隻能做很少的事情,能不能多做一點?

    4. 出現問題時如何進行溯源?

    5. 如何處理單機瓶頸問題?

    6. 如果是多機負載,如何處理資料一緻性問題?

    7. 消費者事務處理能力問題?

  redis是專業的緩存解決方案,但不是專業的消息通信解決方案,它的實作隻能為打開我們的一點思路。我們還是要相信專業的力量,以上問題相信在很多消息中間件中很容易找到相應答案。

不要害怕今日的苦,你要相信明天,更苦!