深入理解Redis的发布/订阅机制(pub/sub)

Redis可以执行发布/订阅模式(publish/subscribe), 该模式可以解耦消息的发送者和接收者,使程序具有更好的扩展性.从宏观上来讲,Redis的发布/订阅模式具有如下特点:

  • 客户端执行订阅以后,除了可以继续订阅(SUBSCRIBE或者PSUBSCRIBE),取消订阅(UNSUBSCRIBE或者PUNSUBSCRIBE), PING命令和结束连接(QUIT)外, 不能执行其他操作,客户端将阻塞直到订阅通道上发布消息的到来.
  • 发布的消息在Redis系统中不存储.因此,必须先执行订阅,再等待消息发布. 但是,相反的顺序则不支持.
  • 订阅的通道名称支持glob模式匹配.如果客户端同时订阅了glob模式的通道和非glob模式的通道,并且名称存在交集,则对于一个发布的消息,该执行订阅的客户端接收到两个消息.

pub/sub API
Redis的发布/订阅设计模式相关的命令有六个:

  • SUBSCRIBE命令执行订阅.客户端可以多次执行该命令, 也可以一次订阅多个通道. 多个客户端可以订阅相同的通道.该命令的响应包括三部分, 依次是:命令名称(字符串subscribe),订阅的通道名称,总共订阅的通道数(包含glob通道).
  • PSUBSCRIBE命令执行glob模式订阅.客户端可以多次执行该命令, 也可以一次订阅多个glob通道. 多个客户端可以订阅相同的glob通道.该命令的响应包括三部分, 依次是:命令名称(字符串psubscribe),订阅的glob通道名称,总共订阅的通道数(包含非glob通道).
  • UNSUBSCRIBE命令取消订阅指定的通道.可以指定一个或者多个取消的订阅通道名称,也可以不带任何参数,此时将取消所有的订阅的通道(不包括glob通道).该命令的响应包括三部分, 依次是:命令名称(字符串unsubscribe),取消的订阅通道名称,总共订阅的通道数(包含glob通道).
  • PUNSUBSCRIBE命令取消订阅指定的glob模式通道.可以指定一个或者多个取消的glob模式的订阅通道名称,也可以不带任何参数,此时将取消所有的glob模式订阅的通道(不包括非glob通道).该命令的响应包括三部分, 依次是:命令名称(字符串punsubscribe),取消的glob模式的订阅通道名称,总共订阅的通道数(包含非glob通道).
  • PUBLISH命令在指定的通道上发布消息.只能在一个通道上发布消息,不能在多个通道上同时发布消息.该命令的响应包括通知的接收者个数,需要注意的是,这里的接收者数目大于等于订阅该通道的客户端数目(因为一个客户端的glob通道和非glob通道同时匹配发布通道的话,则视为两个接收者).而在接收端,收到的响应包括三部分,依次是 :message或者pmessage字符串(取决于是否为glob匹配),匹配的通道名称,发布的消息内容.
  • PUBSUB命令执行状态查询.支持若干子命令.需要注意的是,该命令不能在客户端进入订阅后执行.

下面是一个连续交互使用的例子:

subscribe foo bar
*3
$9
subscribe
$3
foo
:1
*3
$9
subscribe
$3
bar
:2

同时订阅了两个非glob通道,返回的响应中最后给出的订阅的通道总数为2.
接着继续执行:

psubscribe b?r *news
*3
$10
psubscribe
$3
b?r
:3
*3
$10
psubscribe
$5
*news
:4

同时订阅了两个glob通道,这时总共订阅的通道数增加到4个(包括之前订阅的2个非glob通道).
接着,在其他客户端上执行:

publish foo "Hello foo"
:1

说明有一个接收者在该通道上接受了消息.
接收客户端(订阅客户端)的响应为:

*3
$7
message
$3
foo
$9
Hello foo

从响应中,可以看出是在非glob通道上(foo通道)上接受的消息.
发布客户端继续执行:

publish bar "Hi bar"
:2

有两个接收者在该发布通道上接收了消息. 这是因为,订阅客户端的glob通道和非glob通道同时匹配这个通道名称,视为两个接收者.
订阅客户端上的响应为:

*3
$7
message
$3
bar
$6
Hi bar
*4
$8
pmessage
$3
b?r
$3
bar
$6
Hi bar

从响应中看到,messagepmessage类型的通道都收到了消息.
在订阅客户端上,继续执行:

unsubscribe foo
*3
$11
unsubscribe
$3
foo
:3

取消了一个非glob通道的订阅,订阅总数更新为3个.
在订阅客户端上,继续执行:

punsubscribe
*3
$12
punsubscribe
$3
b?r
:2
*3
$12
punsubscribe
$5
*news
:1

取消了所有glob通道的订阅,订阅总数更新为1个.
在发布客户端上,继续执行:

pubsub channels
*1
$3
bar

从响应中,可以看到Redis系统中只存在一个订阅通道了.
在发布客户端上,继续执行:

select 1
+OK

因为每个客户端连接到Redis系统时,默认使用的是0号数据库.该命令将发布客户端连接到Redis系统的1号数据库上.
在发布客户端上,继续执行:

publish bar "howdy bar"
:1

说明有一个接收者在该通道上接收到了消息.
订阅客户端上的情况:

*3
$7
message
$3
bar
$9
howdy bar

订阅客户端接收到了消息.

小结:

  • 只有在订阅客户端上,才能取消订阅. 一个客户端连接不能为另一个客户端连接取消订阅. 这是显而易见的.
  • 在发布/订阅模式下,通道名称是全局的,和客户端连接的Redis数据库没有关系.

源码实现机制
SUBSCRIBE命令执行函数subscribeCommand, 其实现为(pubsub.c):

void subscribeCommand(redisClient *c) {
    int j;

    for (j = 1; j < c->argc; j++)
        pubsubSubscribeChannel(c,c->argv[j]);
    c->flags |= REDIS_PUBSUB;
}

该命令设置客户端对象标志REDIS_PUBSUB,表示进入了订阅模式.
函数pubsubSubscribeChannel的实现为(pubsub.c):

/* 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(redisClient *c, robj *channel) {
    struct dictEntry *de;
    list *clients = NULL;
    int retval = 0;

    /* Add the channel to the client -> channels hash table */
    if (dictAdd(c->pubsub_channels,channel,NULL) == DICT_OK) {
        retval = 1;
        incrRefCount(channel);
        /* Add the client to the channel -> list of clients hash table */
        de = dictFind(server.pubsub_channels,channel);
        if (de == NULL) {
            clients = listCreate();
            dictAdd(server.pubsub_channels,channel,clients);
            incrRefCount(channel);
        } else {
            clients = dictGetVal(de);
        }
        listAddNodeTail(clients,c);
    }
    /* Notify the client */
    addReply(c,shared.mbulkhdr[3]);
    addReply(c,shared.subscribebulk);
    addReplyBulk(c,channel);
    addReplyLongLong(c,clientSubscriptionsCount(c));
    return retval;
}

需要说明的是,通道名称既在客户端对象的哈希表pubsub_channels中保存,也在全局变量server的哈希表pubsub_channels中保存.保存在这两个数据结构的一个原因是, 通过在客户端对象的哈希表中可以快速判断该通道是否添加过. 通道名称并没有保存在连接的数据库中,而是直接保存在了全局的server结构中,因此客户端改变连接的数据库时, 对发布/订阅没有任何影响.在全局哈希表pubsub_channels中保存的是通道名称和订阅该通道的客户端链表.

命令PSUBSCRIBE执行函数psubscribeCommand, 其实现为(pubsub.c):

void psubscribeCommand(redisClient *c) {
    int j;

    for (j = 1; j < c->argc; j++)
        pubsubSubscribePattern(c,c->argv[j]);
    c->flags |= REDIS_PUBSUB;
}

该命令设置客户端对象标志REDIS_PUBSUB,表示进入了订阅模式.

函数pubsubSubscribePattern的实现为(pubsub.c):

/* 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(redisClient *c, robj *pattern) {
    int retval = 0;

    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;
}

和非glob通道名称一样, glob通道名称也是既保存在客户端对象中也保存在全局变量server中. 并且均使用不同的字段,而且字段数据结构都是使用的链表.为什么不和非glob通道一样用哈希表来保存, 个人认为,glob模式的名字在系统中可能数量较少,而且名字长度可能要短一些,所以,使用链表处理就可以了. 这里需要注意的一点是,对于glob通道名字,必须保存其原始编码格式(REDIS_ENCODING_RAW), 因为后续在处理PUBLISH命令的时候,涉及到glob字符匹配.

命令PUBLISH执行函数publishCommand, 其实现为(pubsub.c):

void publishCommand(redisClient *c) {
    int receivers = pubsubPublishMessage(c->argv[1],c->argv[2]);
    forceCommandPropagation(c,REDIS_PROPAGATE_REPL);
    addReplyLongLong(c,receivers);
}

消息发布会replica节点同步.
函数pubsubPublishMessage的实现为(pubsub.c):

/* Publish a message */
int pubsubPublishMessage(robj *channel, robj *message) {
    int receivers = 0;
    struct dictEntry *de;
    listNode *ln;
    listIter li;

    /* Send to clients listening for that 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) {
            redisClient *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 */
    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;
}

LINE8:25在全局的哈希表pubsub_channels中查找订阅该通道的客户端,并把发布的消息写入订阅客户端的发送缓存中.
LINE26:46遍历全局glob通道名称链表pubsub_patterns, 对每个订阅的glob通道,模式匹配发布命令的通道,如果匹配成功, 向该订阅客户端发送缓存写入发布消息.

命令UNSUBSCRIBE执行函数unsubscribeCommand, 其实现为(pubsub.c):

void unsubscribeCommand(redisClient *c) {
    if (c->argc == 1) {
        pubsubUnsubscribeAllChannels(c,1);
    } else {
        int j;

        for (j = 1; j < c->argc; j++)
            pubsubUnsubscribeChannel(c,c->argv[j],1);
    }
    if (clientSubscriptionsCount(c) == 0) c->flags &= ~REDIS_PUBSUB;
}

如果当前客户端所有订阅的通道均被取消,则取消标识REDIS_PUBSUB,退出订阅模式.
函数pubsubUnsubscribeAllChannels的实现会调用pubsubUnsubscribeChannel逐个取消订阅的通道, 其实现为(pubsub.c):

/* 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(redisClient *c, robj *channel, int notify) {
    struct 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... */
    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);
        redisAssertWithInfo(c,NULL,de != NULL);
        clients = dictGetVal(de);
        ln = listSearchKey(clients,c);
        redisAssertWithInfo(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 */
    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;
}

即分别在客户端对象的哈希表pubsub_channels和全局变量server的哈希表pubsub_channels取消该通道.

命令PUNSUBSCRIBE和命令UNSUBSCRIBE的实现基本类似,这里不再分析.

你可能感兴趣的:(Redis)