redis源码--notify

个人理解, 通知分为两种类型, 一种基于键的通知(用来监听某个键的修改和被执行了什么命令), 一种是非键的(可以理解为mq,通过指定一个channel)

基于键的通知

1. 通知功能介绍
客户端可以通过 订阅与发布功能(pub/sub)功能,来接收那些以某种方式改动了Redis数据集的事件。

目前Redis的订阅与发布功能采用的是发送即忘(fire and forget)的策略,当订阅事件的客户端断线时,它会丢失所有在断线期间分发给它的事件。

2. 通知的类型
通知功能的类型分别为:

键空间通知(key-space notification)
键事件通知(key-event notification)

//notify.c
#define NOTIFY_KEYSPACE (1<<0)    /* K */   //键空间通知
#define NOTIFY_KEYEVENT (1<<1)    /* E */   //键事件通知

这两种通知的格式如下:

__keyspace@<db>__:<key> <event> notifications.  //键空间通知格式
__keyevente@<db>__:<event> <key> notifications. //键事件通知格式

构建这两种通知格式的源码如下:

// event 是一个字符串类型的事件名
// key 是一个对象代表一个键名
// dbid 是数据库id
void notifyKeyspaceEvent(int type, char *event, robj *key, int dbid) {
    sds chan;
    robj *chanobj, *eventobj;
    int len = -1;
    char buf[24];

    /* If notifications for this class of events are off, return ASAP. */
    // 如果notify_keyspace_events中配置了不发送type类型的通知,则直接返回
    // notify_keyspace_events值为 一个type的亦或值,type保存有不发送的通知
    if (!(server.notify_keyspace_events & type)) return;

    // 创建一个事件通知对象
    eventobj = createStringObject(event,strlen(event));

    /* __keyspace@<db>__:<key> <event> notifications. */
    // 发送 键空间 通知
    if (server.notify_keyspace_events & NOTIFY_KEYSPACE) {
        // 构建一个频道对象,格式如上
        chan = sdsnewlen("__keyspace@",11);
        len = ll2string(buf,sizeof(buf),dbid);
        chan = sdscatlen(chan, buf, len);
        chan = sdscatlen(chan, "__:", 3);
        chan = sdscatsds(chan, key->ptr);
        chanobj = createObject(OBJ_STRING, chan);
        // 通过publish命令发送频道对象chanobj和事件对象eventobj通知
        pubsubPublishMessage(chanobj, eventobj);
        decrRefCount(chanobj);  //释放对象
    }

    /* __keyevente@<db>__:<event> <key> notifications. */
    // 发送 键事件 通知
    if (server.notify_keyspace_events & NOTIFY_KEYEVENT) {
        // 构建一个频道对象,格式如上
        chan = sdsnewlen("__keyevent@",11);
        if (len == -1) len = ll2string(buf,sizeof(buf),dbid);
        chan = sdscatlen(chan, buf, len);
        chan = sdscatlen(chan, "__:", 3);
        chan = sdscatsds(chan, eventobj->ptr);
        chanobj = createObject(OBJ_STRING, chan);
        // 通过publish命令发送频道对象chanobj和键key通知
        pubsubPublishMessage(chanobj, key);
        decrRefCount(chanobj);  //释放对象
    }
    decrRefCount(eventobj); //释放事件对象
}

3. 通知功能的配置
通知功能的配置默认是关闭状态,因为大多数使用者不需要这个功能,因为开启该功能会有一些额外的开销。开启通知功能的两种方式

修改配置文件redis.conf中的notify-keyspace-events
CONFIG SET notify-keyspace-events 字符
notify-keyspace-events参数可以是一下字符的任意组合,它指定了服务器该发送哪种类型的通知:


输入的参数中至少要有一个 K 或者 E ,否则的话,不管其余的参数是什么,都不会有任何通知被分发。例如,如果设置KEA那么表示发送所有类型的通知。

这些字符的源码定义:

// 键空间通知的类型,每个类型都关联着一个有目的的字符
#define NOTIFY_KEYSPACE (1<<0)    /* K */   //键空间
#define NOTIFY_KEYEVENT (1<<1)    /* E */   //键事件
#define NOTIFY_GENERIC (1<<2)     /* g */   //通用无类型通知
#define NOTIFY_STRING (1<<3)      /* $ */   //字符串类型键通知
#define NOTIFY_LIST (1<<4)        /* l */   //列表键通知
#define NOTIFY_SET (1<<5)         /* s */   //集合键通知
#define NOTIFY_HASH (1<<6)        /* h */   //哈希键通知
#define NOTIFY_ZSET (1<<7)        /* z */   //有序集合键通知
#define NOTIFY_EXPIRED (1<<8)     /* x */   //过期有关的键通知
#define NOTIFY_EVICTED (1<<9)     /* e */   //驱逐有关的键通知
#define NOTIFY_ALL (NOTIFY_GENERIC | NOTIFY_STRING | NOTIFY_LIST | NOTIFY_SET | NOTIFY_HASH | NOTIFY_ZSET | NOTIFY_EXPIRED | NOTIFY_EVICTED)      /* A */   //所有键通知

这些字符通常使用一个int类型的flags参数通过多个字符按位或运算保存起来。因此就涉及到flags和字符串的相互转换

字符串 to flags

// 对传入的字符串参数进行分析,返回一个flags,flags保存字符串每个字符所映射的键空间事件类型
int keyspaceEventsStringToFlags(char *classes) {
    char *p = classes;
    int c, flags = 0;    while((c = *p++) != '\0') {
        switch(c) {
        case 'A': flags |= NOTIFY_ALL; break;
        case 'g': flags |= NOTIFY_GENERIC; break;
        case '$': flags |= NOTIFY_STRING; break;
        case 'l': flags |= NOTIFY_LIST; break;
        case 's': flags |= NOTIFY_SET; break;
        case 'h': flags |= NOTIFY_HASH; break;
        case 'z': flags |= NOTIFY_ZSET; break;
        case 'x': flags |= NOTIFY_EXPIRED; break;
        case 'e': flags |= NOTIFY_EVICTED; break;
        case 'K': flags |= NOTIFY_KEYSPACE; break;
        case 'E': flags |= NOTIFY_KEYEVENT; break;
        default: return -1;
        }
    }
    return flags;
}

flags to 字符串

// 根据flags返回一个字符串,字符串中的字符就是设置flags的字符
sds keyspaceEventsFlagsToString(int flags) {
    sds res;

    res = sdsempty();
    if ((flags & NOTIFY_ALL) == NOTIFY_ALL) {
        res = sdscatlen(res,"A",1);
    } else {
        if (flags & NOTIFY_GENERIC) res = sdscatlen(res,"g",1);
        if (flags & NOTIFY_STRING) res = sdscatlen(res,"$",1);
        if (flags & NOTIFY_LIST) res = sdscatlen(res,"l",1);
        if (flags & NOTIFY_SET) res = sdscatlen(res,"s",1);
        if (flags & NOTIFY_HASH) res = sdscatlen(res,"h",1);
        if (flags & NOTIFY_ZSET) res = sdscatlen(res,"z",1);
        if (flags & NOTIFY_EXPIRED) res = sdscatlen(res,"x",1);
        if (flags & NOTIFY_EVICTED) res = sdscatlen(res,"e",1);
    }
    if (flags & NOTIFY_KEYSPACE) res = sdscatlen(res,"K",1);
    if (flags & NOTIFY_KEYEVENT) res = sdscatlen(res,"E",1);
    return res;
}

4. 键通知的实现:

 在每个键的相应操作结束后都会调用 notifyKeyspaceEvent() 函数, 通知绑定该键的客户端

基于非键的通知

Redis提供了订阅和发布的功能,允许客户端订阅一个或多个频道,当其他客户端向某个频道发送消息时,服务器会将消息转发给所有订阅该频道的客户端

这一点有点像群聊的功能,一个客户端将消息发往群中(向某个频道发送消息),所有在群中的客户端(订阅该频道的客户端)都会收到这个消息。事实也正是如此,接下来将会看到,服务器采用字典保存每个频道(键)和订阅该频道的所有客户端(值),每当其他客户端向某个频道发送消息时,服务器便从字典中获取所有订阅该频道的客户端,依次将消息发送。每个频道,可以看成是每个群,一个频道的所有订阅客户端,可以看成该群的所有群成员,唯一不同的是,向频道发送消息的那个客户端并不需要订阅同样的频道,也就是该客户端并不需要也在群中

稍后会看到,除了订阅特定频道,Redis也允许客户端进行模式订阅,即一次订阅所有匹配的频道

Redis的订阅与发布功能由PUBLISH, SUBSCRIBE, PSUBSCRIBE等命令组成

普通订阅
使用SUBSCRIBE [频道名]即可订阅特定频道,频道名可以自定义,也可以同时订阅多个频道,只需要后面添加多个频道名即可

127.0.0.1:6379> SUBSCRIBE "news.redis"  //订阅"news.redis"频道
Reading messages... (press Ctrl-C to quit)
1) "subscribe"  //命令关键字
2) "news.redis" //频道名
3) (integer) 1  //订阅该频道的客户端数量

使用PUBLISH [频道名] [消息]即可向特定频道发送消息

127.0.0.1:6379> PUBLISH "news.redis" "send a message"   //向"news.redis"频道发送消息
(integer) 1 //返回发送给了多少个客户端
127.0.0.1:6379> 

此时,如果再查看订阅news.redis频道的那个客户端,会发现终端上打印出”send a message”信息

//PUBLISH之前
127.0.0.1:6379> SUBSCRIBE "news.redis"
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "news.redis"
3) (integer) 1

//PUBLISH之后
1) "message"    //消息类型
2) "news.redis" //频道名
3) "send a message" //信息

不过如果一个客户端处于订阅状态,它好像就不能执行其他操作了

存储结构
实现一个订阅与发布功能十分简单,开篇也提到了,只需要将每个频道以及它的订阅者记录在字典中,如果客户端向某个频道发送消息,则在字典中查找该频道的所有订阅者,依次将消息发送过去即可。

在深入源代码之前,先看两个结构的定义,一个是客户端,一个是服务器,它们都定义在server.h头文件中

//server.h
typedef struct client {
    ...
    dict *pubsub_channels; 
    ...
} client;
//server.h
struct redisServer {
    ...
    dict *pubsub_channels;  
    ...
};

这两个结构都太长了,不过目前用得到的其实就一个pubsub_channels变量,根据类型得知该变量是一个字典(以下简称为订阅字典),两个变量的作用分别是

客户端的订阅字典记录着当前客户端订阅的所有频道,键是频道名,值为空
服务器的订阅字典记录着所有频道以及每个频道的订阅者,键是频道名,值是客户端链表
到这里其实可以简单猜测订阅功能是如何实现的,当某个客户端使用SUBSCRIBE命令订阅一个或多个频道时,Redis会将<频道名,客户端>这个键值对添加到服务器的订阅字典中,同时也会将频道名添加到客户端自己的订阅字典中

而当客户端使用PUBLISH命令向某个频道发送消息时,Redis会在订阅字典中获取该频道的所有订阅者(客户端),依次将消息发送给客户端。如果该频道不存在或没有订阅者,则不执行任何操作

订阅功能
订阅功能由subscribeCommand函数完成,函数主要任务是遍历每一参数(频道名),调用pubsubSubscribeChannel函数将频道名和客户端添加到订阅字典中

//pubsub.c
/* 订阅命令 */
void subscribeCommand(client *c) {
    int j;

    /* 将客户端和它订阅的频道进行关联,添加到订阅字典中
     * 键是频道名,值是客户端 
     */
    for (j = 1; j < c->argc; j++)
        pubsubSubscribeChannel(c,c->argv[j]);
    /* 标记当前客户端订阅过某些频道 */
    c->flags |= CLIENT_PUBSUB;
}

pubsubSubscribeChannel函数完成实际的添加操作

//pubsub.c
/* 
 * 将客户端和它订阅的频道进行关联,添加到客户端和服务器两个订阅字典中 
 * 
 * 注:服务器和客户端都有订阅字典,分别是
 * c->pubsub_channels
 * server.pubsub_channels
 */
int pubsubSubscribeChannel(client *c, robj *channel) {
    dictEntry *de;
    list *clients = NULL;
    int retval = 0;

    /* 判断当前客户端是否已经订阅了该频道,如果是则不进行处理,否则添加到客户端的订阅字典中 */
    /* 注意这里添加的是客户端的订阅字典,该字典记录当前客户端订阅的所有频道 */
    if (dictAdd(c->pubsub_channels,channel,NULL) == DICT_OK) {
        retval = 1;
        /* 所有的robj对象都是基于引用计数的,因为已将其添加到字典中,所有引用计数加一 */
        incrRefCount(channel);
        /* 从服务器的订阅字典中寻找该频道对应的键节点链表(记录所有订阅该频道的客户端链表) */
        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);
    }
    /* 通知客户端订阅成功 */ 
    addReply(c,shared.mbulkhdr[3]);
    addReply(c,shared.subscribebulk);
    addReplyBulk(c,channel);
    addReplyLongLong(c,clientSubscriptionsCount(c));
    return retval;
}

至此订阅操作完成,可以发现订阅仅仅是将频道名和客户端这个键值对添加到订阅字典中,并不执行其他操作。

退订功能
有订阅就有退订,退订命令是UNSUBSCRIBE,有unsubscribeCommand函数执行。不过既然订阅功能是阻塞的,怎么执行退订啊…

退订分两种,一种是退订当前客户端订阅的所有频道,此时退订命令不带参数。另一种则带参数,仅退订参数指出的频道

//pubsub.c
/* 退订命令 */
void unsubscribeCommand(client *c) {
    if (c->argc == 1) {
        /* 退订当前客户端订阅的所有频道 */
        pubsubUnsubscribeAllChannels(c,1);
    } else {
        int j;

        /* 退订参数指出的频道 */
        for (j = 1; j < c->argc; j++)
            pubsubUnsubscribeChannel(c,c->argv[j],1);
    }
    /* 客户端订阅的频道数为0时,改变标志 */
    if (clientSubscriptionsCount(c) == 0) c->flags &= ~CLIENT_PUBSUB;
}

退订所有频道是遍历当前客户端的订阅字典,对订阅的每个频道调用pubsubUnsubscribeChannel函数,实际上和指定参数效果相同,所以就直接看退订参数指定频道的函数好了

//pubsub.c
/* 
 * 退订
 * c : 客户端
 * channel : 要退订的频道
 * notify : 退订后是否通知客户端
 */
int pubsubUnsubscribeChannel(client *c, robj *channel, int notify) {
    dictEntry *de;
    list *clients;
    listNode *ln;
    int retval = 0;

    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) {
        /* 删除成功,表示这个客户端订阅过channel */
        retval = 1;

        /* 从服务器订阅字典中查找关于该频道的所有订阅信息,返回键节点 */
        de = dictFind(server.pubsub_channels,channel);
        serverAssertWithInfo(c,NULL,de != NULL);
        /* 从键节点中获取客户端链表 */
        clients = dictGetVal(de);
        /* 从客户端链表中搜索当前退订的客户端 */
        ln = listSearchKey(clients,c);
        serverAssertWithInfo(c,NULL,ln != NULL);
        /* 将链表节点ln从链表clients中删除 */
        listDelNode(clients,ln);
        /* 如果该频道只有该客户端订阅过,那么删除后客户端链表为空,从服务器订阅字典中删除该频道的信息 */
        if (listLength(clients) == 0) {
            dictDelete(server.pubsub_channels,channel);
        }
    }
    /* 如果要求通知,则通知客户端 */
    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;
}

退订函数虽然长了点,但是还是蛮好理解的,仅仅是将客户端和频道的关联信息从订阅字典中删除

普通订阅的信息发布
Redis的发布功能由PUBLISH命令实现,底层由pubsubPublishMessage函数实现,该函数向订阅特定频道的所有客户端发送消息。订阅分两种,一个是普通订阅(如上),另一个是模式订阅,所以函数中也分为向普通订阅的客户端发送消息和向模式订阅的客户端发送消息。因为还没有接触模式订阅,所以先看普通订阅的发布好了

普通订阅的发送消息仅仅是在服务器的订阅字典中寻找特定频道的所有订阅者,依次将消息发送就完成了,比较简单

//pubsub.c
/* 发送通知信息 */
/* 
 * channel : 通知信息
 * message : 事件名称
 */
int pubsubPublishMessage(robj *channel, robj *message) {
    int receivers = 0;
    dictEntry *de;
    listNode *ln;
    listIter li;

    /* 服务器的订阅字典保存着所有频道和它的所有订阅者 */
    /* 从该字典中查找频道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++;
        }
    }
    ...
    return receivers;
}

 

posted @ 2019-03-07 18:46  車輪の唄  阅读(15)  评论(0编辑  收藏  举报  来源