如何设计一个站内消息系统?
这篇文章是一位朋友投稿给我的,我简单完善了一下。
各位使用过简书,知乎或 B 站的小伙伴应该都有这样的使用体验:当有其他用户关注我们或者私信我们的行为时,我们会收到相关的消息。
虽然这些功能看上去简单,但其背后的设计是非常复杂的,几乎是一个完成的系统,可以称之为 站内消息系统。
我以 B 站举例(个人认为 B 站的消息系统是我见过的非常完美的,UI 也最为人性化的):

可以看到 B 站把消息大致分为了三类:
- 系统推送的通知(System Notice);
- 回复、@、点赞等用户行为产生的提醒(Remind);
- 用户之间的私信(Chat)。
这样设计不仅分类明确,且处于同一个主体的事件提醒还会做一个聚合,极大的提高了用户体验,不让用户收到太多分散的消息。
举个例子:比如你在某个视频或某篇文章下发表了评论,有 100 个人给你的评论点了赞,那么你希望消息页面呈现的是一个一个用户给你点赞的提醒,还是像以下聚合之后的提醒:

我相信你大概率会选择后者。
我认为对于很多应用来说,这样的设计都是非常合理的,接下来我写写我对于消息系统的设计。
系统通知(System Notice)
系统通知一般是由后台管理员发出,然后指定某一类(全体,个人等)用户接收。基于此设想,可以把系统通知大致分为两张表:
- t_manager_system_notice(管理员系统通知表) :记录管理员发出的通知 ;
- t_user_system_notice(用户系统通知表) : 存储用户接受的通知。
t_manager_system_notice(管理员系统通知表) 表结构如下:
| 字段名 | 类型 | 描述 |
|---|---|---|
| system_notice_id | LONG | 系统通知 ID |
| title | VARCHAR | 标题 |
| content | TEXT | 内容 |
| type | VARCHAR | 发给哪些用户:单用户 single;全体用户 all,vip 用户,具体类型各位小伙伴可以根据自己的需求选择 |
| state | BOOLEAN | 是否已被拉取过,如果已经拉取过,就无需再次拉取 |
| recipient_id | LONG | 接受通知的用户的 ID,如果 type 为单用户,那么 recipient 为该用户的 ID;否则 recipient 为 0 |
| manager_id | LONG | 发布通知的管理员 ID |
| publish_time | TIMESTAMP | 发布时间 |
t_user_system_notice(用户系统通知表)结构如下:
| 字段名 | 类型 | 描述 |
|---|---|---|
| user_notice_id | LONG | 主键 ID |
| state | BOOLEAN | 是否已读 |
| system_notice_id | LONG | 系统通知的 ID |
| recipient_id | LONG | 接受通知的用户的 ID |
| pull_time | TIMESTAMP | 拉取通知的时间 |
当管理员发布一条通知后,将通知插入 t_manager_system_notice 表中,然后系统定时的从 t_manager_system_notice 表中拉取通知,然后根据通知的 type 将通知插入 t_user_system_notice 表中。
如果通知的 type 是 single 的,那就只需要插入一条记录到 t_user_system_notice 中。如果是全体用户,那么就需要将一个通知批量根据不同的用户 ID 插入到 t_user_system_notice 中,这个数据量就需要根据平台的用户量来计算。
🌰 举个例子:管理员 A 发布了一个活动的通知,他需要将这个通知发布给全体用户,当拉取时间到来时,系统会将这一条通知取出。随后系统到用户表中查询选取所有用户的 ID,然后将这一条通知的信息根据所有用户的 ID, 批量插入 t_user_system_notice 中。用户需要查看系统通知时,从 t_user_system_notice 表中查询就行了。
👉 需要注意的是:
- 因为一次拉取的数据量可能很大,所以两次拉取的时间间隔可以设置的长一些。
- 拉取 t_manager_system_notice 表中的通知时,需要判断 state,如果已经拉取过,就不需要重复拉取,否则会造成重复消费。
- 有的小伙伴可能有疑问: 某条通知已经被拉取过的话,在其后注册的用户是不是不能再接收到这条通知?是的。但如果你想将已拉取过的通知推送给那些后注册的用户,也不是特别大的问题。只需要再写一个定时任务,这个定时任务可以将通知的 push_time 与用户的注册时间比较一下,重新推送即可。
认真思考的小伙伴应该也发现了,当用户量比较大比如上千万的时候,如果发送一个全体用户的通知需要挨个插入数据到一张表的话,是不靠谱的!
常见的解决办法,有两种方式:
- 每位用户单独有一张或者几张专门用来存放站内消息的表,根据
hash(userId)作为表名后缀。 - 对于系统通知类型,只存放一条数据到 t_user_system_notice 表,用户自己拉取数据然后再判断消息是否已经读取过即可。
并且,当一条通知需要发布给全体用户时,我们还应该考虑到用户的活跃度。因为如果有些用户长期不活跃,我们还将通知推送给他(她),这显然会造成空间的浪费。 所以在选取用户 ID 时,我们可以将用户上次登录的时间与推送时间做一个比较,如果用户一年未登陆或几个月未登录,我们就不选取其 ID,进而避免无谓的推送。
以上就是系统通知的设计了,接下来再看看较难的提醒类型的消息。