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

我相信你⼤概率会选择后者。
我认为对于很多应⽤来说,这样的设计都是⾮常合理的,接下来我写写我对于消息系统的设计。
1 系统通知(system notice)
系统通知⼀般是由后台管理员发出,然后指定某⼀类(全体,个⼈等)⽤户接收。基于此设想,可以把系统通知⼤致分为两张表:
- t_manager_system_notice(管理员系统通知表) :记录管理员发出的通知;
- t_user_system_notice(⽤户系统通知表) : 存储⽤户接受的通知。
t_manager_system_notice(管理员系统通知表) 表结构如下:
| 字段名 | 类型 | 描述 |
|---|---|---|
| system-notice-id | LONG | 系统通知 ID |
| title | VARCHART | 标题 |
| content | TEXT | 内容 |
| type | VARCHAR | 发给哪些用户:单独某用户、全体用户、VIP 用户 |
| state | BOOLEAN | 是否已被拉取过 |
| recipient-id | LONG | 接受通知的⽤户的 ID,如果 type 为单⽤户,那么 recipient 为该⽤户的 ID;否则 recipient 为 0 |
| manager-id | LONG | 发布通知的管理员 ID |
| publish-time | TIMESTAMP | 发布时间 |
t_user_system_notice(⽤户系统通知表)
| 字段名 | 类型 | 描述 |
|---|---|---|
| user-noice-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,进⽽避免⽆谓的推送。
以上就是系统通知的设计了,接下来再看看较难的提醒类型的消息。
2 事件提醒 (EventRemind)
之所以称提醒类型的消息为事件提醒,是因为此类消息均是通过⽤户的⾏为产⽣的,如下:
- xxx 在某个评论中@了你;
- xxx 点赞了你的⽂章;
- xxx 点赞了你的评论;
- xxx 回复了你的⽂章;
- xxx 回复了你的评论;
- …
诸如此类事件,我们以单词 action 形容不同的事件(点赞,回复,@)。
可以看到除了事件之外,我们还需要了解⽤户是在哪个地⽅产⽣的事件,以便当我们收到提醒时,点击这条消息就可以去到事件现场,从⽽增强⽤户体验,我以事件源 source 来形容事件发⽣的地⽅。
- 当 action 为点赞,source 为⽂章时,我就知道:有⽤户点赞了我的某篇⽂章;
- 当 action 为点赞,source 为评论时,我就知道:有⽤户点赞了我的某条评论;
- 当 action 为@(at), source 为评论时,我就知道:有⽤户在某条评论⾥@了我;
- 当 action 为回复,source 为⽂章时,我就知道:有⽤户回复了我的某篇⽂章;
- 当 action 为回复,source 为评论时,我就知道:有⽤户回复了我的某条评论;
由此可以设计出事件提醒表 t_event_remind,其结构如下:
| 字段名 | 类型 | 描述 |
|---|---|---|
| event-remind-id | LONG | 消息 ID |
| action | VARCHAR | 动作类型,如点赞、at(@)、回复等 |
| source-id | LONG | 事件源 ID,如评论 ID、⽂章 ID 等 |
| source-type | VARCHAR | 事件类型:“Comment”、“Post”等 |
| source-content | VARCHAR | 事件源的内容,⽐如回复的内容,回复的评论等等 |
| url | VARCHAR | 事件所发⽣的地点链接 url |
| state | BOOLEAN | 是否已读 |
| sender-id | LONG | 操作者的 ID,即谁关注了你,at 了你 |
| recipient-id | LONG | 接受通知的⽤户的 ID |
| remind-time | TIMESTAMP | 提醒的时间 |
2.1 消息聚合
消息聚合只适⽤于事件提醒,以聚合之后的点赞消息来说:
- 100 ⼈ {点赞} 了你的 {⽂章 ID = 1} :《A》;
- 100 ⼈ {点赞} 了你的 {⽂章 ID = 2} :《B》;
- 100 ⼈ {点赞} 了你的 {评论 ID = 3} :《C》;
聚合之后的消息明显有两个特征,即: action 和 source type,这是系统消息和私信都不具备的,所以我个⼈认为事件提醒的设计要稍微⽐系统消息和私信复杂。
2.2 如何聚合?
稍稍观察下聚合的消息就可以发现:某⼀类的聚合消息之间是按照 source type 和 source id 来分组的,
因此我们可以得出以下伪 SQL:
SELECT * FROM t_event_remind
WHERE recipient_id = 用户ID AND action = 点赞 AND state = FALSE
GROUP BY source_id , source_type;当然,SQL 层⾯的结果集处理还是很麻烦的,所以我的想法先把⽤户所有的点赞消息先查出来,然后在程序⾥⾯进⾏分组,这样会简单不少。
2.3 拓展
其实还有⼀种设计提醒表的做法,即按业务分类,不同的提醒存⼊不同的表,这样可以分为:
- 点赞提醒表
- 回复提醒表
- at(@)提醒表。
我认为这种设计⽐第⼀种的更松耦合,不必所有类型的提醒都挤在⼀张表⾥,但是这也会带来表数量的膨胀。所以各位⼩伙伴可以⾃⾏选择⽅案。
3 私信
站内私信⼀般都是点到点的,且要求是实时的,服务端可以采⽤ Netty 等⾼性能⽹络通信框架完成请求。
我们还是以 B 站为例,看看它是怎么设计的:

B 站的私信部分可以分为两部分:
- 左边的与不同⽤户的聊天室;
- 与当前正在对话的⽤户的对话框,显示了当前⽤户与⽬标⽤户的所有消息。
按照这个设计,我们可以先设计出聊天室表 t_private_chat,因为是⼀对⼀,所以聊天室表会包含对话的两个⽤户的信息:
| 字段名 | 类型 | 描述 |
|---|---|---|
| private_chat_id | LONG | 聊天室 ID |
| user1_id | LONG | ⽤户 1 的 ID |
| user2_id | LONG | ⽤户 2 的 ID |
| last_message | VARCHAR | 最后⼀条消息的内容 |
这⾥ user1_id 和 user2_id 代表两个⽤户的 ID,并⽆特定的先后顺序。
接下来是私信表 t_private_message 了,私信⾃然和所属的聊天室有联系,且考虑到私信可以在记录中删除(删除了只是不显示记录,但是对⽅会有记录,撤回才是真正的删除),就还需要记录私信的状态,以下是我的设计:
| 字段名 | 类型 | 描述 |
|---|---|---|
| private_message_id | LONG | 私信 ID |
| content | TEXT | 私信内容 |
| state | BOOLEAN | 是否已读 |
| sender_remove | BOOLEAN | 发送消息的⼈是否把这条消息从聊天记录中删除了 |
| recipient_remove | BOOLEAN | 接受⼈是否把这条消息从聊天记录删除了 |
| sender_id | LONG | 发送者 ID |
| recipient_id | LONG | 接受者 ID |
| send_time | TIMESTAMP | 发送时间字段名 |
4 消息设置
消息设置⼀般都是针对提醒类型的消息的,且肯定是由⽤户⾃⼰设置的。所以我想到⼀般有以下设置选项:
- 是否开启点赞提醒;
- 是否开启回复提醒;
- 是否开启@提醒;
下⾯是 B 站的消息设置:

可以看到 B 站还添加了陌⽣⼈选项,也就是说如果给你发送私信的⽤户不是你关注的⽤户,那么视之为陌⽣⼈私信,就不接受。
以下是我对于消息设置的设计:
| 字段名 | 类型 | 描述 |
|---|---|---|
| user_id | LONG | ⽤户 ID |
| like_message | BOOLEAN | 是否接收点赞消息 |
| reply_message | BOOLEAN | 是否接收回复消息 |
| at_message | BOOLEAN | 是否接收 at 消息 |
| stranger_message | BOOLEAN | 是否接收陌⽣⼈的私信 |