dongjun111111 / blog

BLOG
36 stars 5 forks source link

Web系统站内消息设计与实现 #18

Open dongjun111111 opened 8 years ago

dongjun111111 commented 8 years ago

Web系统站内消息设计与实现

问题

首先站内消息主要包括:个人消息(评论,点赞),系统消息,订阅消息,私信。 其中,订阅区分用户群,即系统消息是一个特殊的所有人订阅的订阅消息,特点是一对多。 前三个实时性比较低,最后一个实时性高,离线状态下是私信,如果双方在线要转为聊天室,特点是一对一。 那么,接下来,该选个方案了,SQL or NoSQL?

Mysql实现

首先,对于个人消息、私信(“UserMessage”),一条消息插一句,Mysql跑跑没问题。 对于系统消息或订阅消息,必然不可以,假如有10万用户,一次性那么要插入10万条消息,Mysql必死。 那么就是说,要设立一个系统库(”SystemMessage”),每当用户登录,就去跑跑系统库(“SystemMessage”),把未读的系统库跑到个人库。 关于订阅消息就比较麻烦了,对用户分组?对消息分组? 关系型数据库处理集合问题是比较麻烦的,目前想到的结论是建立一个表(“RssMessage”)存储消息类型,消息索引。 大致的数据库模型: -------------------- Message Text( id , content(text) )--------- ↓↓ ↓↓ UserMessage SystemMessage ( id, ( id, title(标题) title(标题) sender(发送者) tid(内容ID) receiver(接收者) ctime(创建时间) status(状态) type(消息类型) rtime(读取时间) ctime(创建时间) type(消息类型)
                                  -------------------   UserSystemRelation  -----------------------------------
                                                               id
                                                              uid(用户ID)
                                                             sid(系统条目)

看完这个数据库设计,还是感觉有不妥的地方。 UserSystemRelation表用于记录用户读取到哪个位置的标记。 可以看到,UserMessage与SystemMessage表中,title、tid、ctime、type字段冗余了,好像也没必要。 但是从用户功能上看,当用户登陆后,查找自己站内消息,必然要用到的有:status,必然要显示的有:title、ctime,type作为用户进入消息面板后,要筛选的方式之一,这样的话,Mysql就只要跑一个表就可以完成显示给用户的最新站内消息了。 由于MessageText可能是一个大信息通知,用户查看个人消息时候,并未查看MessageText内容,所以单独放一张表。

相应处理流程

基于用户的习惯,读多写少,大部分时候都是看到消息,删除、更新比较少,如果数据没更新直接读Mongodb,如果数据更新,直接删除Mongodb 的索引。

这个考虑是在,用户数量很大的时候,要在”UserSystem”表里查找到用户消息比较慢的时候用,类似于吧Mongodb当缓存。

Redis实现

看了Mysql下站内消息的数据库设计,我也觉得很蛋疼,临时过渡没事,但是还是NoSQL合适。 Redis自带订阅与发布系统,http://redisbook.readthedocs.org/en/latest/feature/pubsub.html. Redis 通过 PUBLISH 、 SUBSCRIBE 等命令实现了订阅与发布模式, 这个功能提供两种信息机制, 分别是订阅/发布到频道和订阅/发布到模式.

频道的订阅与信息发送

Redis 的 SUBSCRIBE 命令可以让客户端订阅任意数量的频道, 每当有新信息发送到被订阅的频道时, 信息就会被发送给所有订阅指定频道的客户端。

订阅频道

每个 Redis 服务器进程都维持着一个表示服务器状态的 redis.h/redisServer 结构, 结构的 pubsub_channels 属性是一个字典, 这个字典就用于保存订阅频道的信息:

struct redisServer {
    // ...
    dict *pubsub_channels;
    // ...
};

其中,字典的键为正在被订阅的频道, 而字典的值则是一个链表, 链表中保存了所有订阅这个频道的客户端。当客户端调用 SUBSCRIBE 命令时, 程序就将客户端和要订阅的频道在 pubsub_channels 字典中关联起来。 SUBSCRIBE 命令的行为可以用伪代码表示如下:

def SUBSCRIBE(client, channels):

    # 遍历所有输入频道
    for channel in channels:

        # 将客户端添加到链表的末尾
        redisServer.pubsub_channels[channel].append(client)

通过 pubsub_channels 字典, 程序只要检查某个频道是否为字典的键, 就可以知道该频道是否正在被客户端订阅; 只要取出某个键的值, 就可以得到所有订阅该频道的客户端的信息。

发送信息到频道

了解了 pubsub_channels 字典的结构之后, 解释 PUBLISH 命令的实现就非常简单了: 当调用 PUBLISH channel message 命令, 程序首先根据 channel 定位到字典的键, 然后将信息发送给字典值链表中的所有客户端。 PUBLISH 命令的实现可以用以下伪代码来描述:

def PUBLISH(channel, message):
    # 遍历所有订阅频道 channel 的客户端
    for client in server.pubsub_channels[channel]:
        # 将信息发送给它们
        send_message(client, message)

退订频道

使用 UNSUBSCRIBE 命令可以退订指定的频道, 这个命令执行的是订阅的反操作: 它从 pubsub_channels 字典的给定频道(键)中, 删除关于当前客户端的信息, 这样被退订频道的信息就不会再发送给这个客户端。

模式的订阅与信息发送

当使用 PUBLISH 命令发送信息到某个频道时, 不仅所有订阅该频道的客户端会收到信息, 如果有某个/某些模式和这个频道匹配的话, 那么所有订阅这个/这些频道的客户端也同样会收到信息。

订阅模式

redisServer.pubsub_patterns 属性是一个链表,链表中保存着所有和模式相关的信息:

struct redisServer {
    // ...
    list *pubsub_patterns;
    // ...
};

链表中的每个节点都包含一个 redis.h/pubsubPattern 结构:

typedef struct pubsubPattern {
    redisClient *client;
    robj *pattern;
} pubsubPattern;

client 属性保存着订阅模式的客户端,而 pattern 属性则保存着被订阅的模式。

每当调用 PSUBSCRIBE 命令订阅一个模式时, 程序就创建一个包含客户端信息和被订阅模式的 pubsubPattern 结构, 并将该结构添加到 redisServer.pubsub_patterns 链表中。

发送信息到模式

原理伪代码如下:

def PUBLISH(channel, message):

    # 遍历所有订阅频道 channel 的客户端
    for client in server.pubsub_channels[channel]:

        # 将信息发送给它们
        send_message(client, message)

    # 取出所有模式,以及订阅模式的客户端
    for pattern, client in server.pubsub_patterns:

        # 如果 channel 和模式匹配
        if match(channel, pattern):

            # 那么也将信息发给订阅这个模式的客户端
            send_message(client, message)

退订模式

使用 PUNSUBSCRIBE 命令可以退订指定的模式, 这个命令执行的是订阅模式的反操作: 程序会删除 redisServer.pubsub_patterns 链表中, 所有和被退订模式相关联的 pubsubPattern 结构, 这样客户端就不会再收到和模式相匹配的频道发来的信息。

Redis自带订阅与发布系统小结

只要是订阅了相应地频道,就会收到频道的消息。 把用户ID作为频道,私信就是反向的频道订阅,系统消息就是所有用户的订阅,那么离线的消息呢?

1、线上用户

还是存在系统或个人的哈希表里,等上线后再去读取。 在Python中,订阅发布消息(Publish)如下:

import redis,time
queue = redis.StrictRedis(host='localhost', port=6379, db=0)
channel = queue.pubsub()

for i in range(100): 
    queue.publish("test", i)
    time.sleep(0.1)

Python中,订阅监听消息(Subcribe)如下:

import redis,time
r = redis.StrictRedis(host='localhost', port=6379, db=0)
p = r.pubsub()
p.subscribe('test')

while True:
    message = p.get_message()
    if message:
        print "Subscriber: %s" % message['data']

Redis-py的API可以看GitHub:https://github.com/andymccurdy/redis-py

2、线下用户

看过一种做法是建立一个Redis链表,存储登陆用户,当用户登陆就直接发送,没登陆就暂存起来。

这里的话,可以用WebSocket实时监听,定期发送心跳包,如果在线直接返回Redis自带的订阅系统。

系统消息建立一个集合:

SADD system:2015-08-03 7 8 9 10 11

第一段标示系统信息,第二段标示日期,后面的数字标示message id。

个人消息建立一个集合:

SADD user:12345:read 1 2 3 4

第一段标示用户信息集合,第二段标示用户id,下一段标示消息类型为已读,后面的数字标示message id。

关于订阅消息如下:

SADD rss:xiaocao 12 13 14 15

那么你就收到小草的订阅消息,消息ID分别是 12, 13, 14, 15

还有很重要的消息数据存储,

HMSET message:12 title 标题 content 内容 date 2015-08-03

Python创建数据库的例子就是:

import redis,time,threading,random
pool = redis.ConnectionPool(host='localhost', port=6379, db=1)
rs = redis.Redis(connection_pool=pool)

rs.sadd("user:123:read", "1", "2")
rs.sadd("user:123:unread", "4", "5", "6")
rs.sadd("system:2015-08-03", "7", "8", "9", "10", "11")
rs.sadd("rss:xiaocao", "12", "13", "14", "15", "11")

for i in range(15):
    rs.hset("message:"+str(i), "title", "title=>"+str(random.uniform(1, 99999)))
    rs.hset("message:"+str(i), "content","content=>"+str(time.time()))
    rs.hset("message:"+str(i), "date", str(time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime())))

参考

1.http://homeway.me/2015/08/03/website-system-message/ 2.http://origin.redisbook.com/