我可以很负责的说,这篇文章是截至现在网上最全的站内信架构设计。因为我要做这个需求了,Google 了大部分资料,除了掘金有两篇比较精华的架构文,其他都是各种 CVS 来的,很 low 的一个 MySql 表 DDL。但那两篇精华文时间比较久了,执行的时候写的又比较模糊,所以我重新整理了所有的思路,和 leader 一起讨论出一种架构,适用于现在绝大多数的站内信,如果你也有站内信方面的困惑,不妨一起来参考和讨论。
公告、活动、推送与站内信
公告:通常是推送给全量用户的,公告在数据库是公告表的一条数据。例如:
活动:和公告类似,区别是推送批量指定用户,比公告多了额外的参数,如起止时间、活动规则、内容等。
推送:创建公告或活动后,需要通知用户,这就是推送,推送一般用个推、极光的 SDK。
站内信:简单的理解,所有推送给用户消息的集合,就是站内信。
概念
站内信的核心抽象:消息内容、消息和发送人的关系、是否已读。
消息内容
【xxx】【点赞】了你的【文章】【文章的标题】
【xxx】【评论】了你的【文章】【文章的标题】
【xxx】【点赞】了你在【文章】【文章的标题】下的评论
【xxx】【回复】了你的【评论】【被回复的评论的内容】
【xxx】【点赞】了你在【文章】【文章标题】下的回复
【xxx】在【文章】【文章的标题】中【@】了你
【xxx】在【文章】下的【评论】中【@】了你
【xxx】在【文章】下的【回复】中【@】了你
【xxx】回答了你关注的【问题】【问题标题】
【xxx】更新了你关注的【文章】【文章标题】
【xxx】邀请你回答【问答】【问答标题】
【xxx】关注了你
观察这些常见的消息内容,可以总结出,一个完整的消息包括,【站内信的主语】、【主实体类型】、【关联对象】、【触发站内信的事件】、【站内信接收人】。
以一个例子详细阐明:
B:我有一个问题,先有鸡还是先有蛋。
C:回答了 B 的问题,先有鸡蛋。
D:评论了 C 的答案,你在扯蛋。
A:评论了 D 的评论,我也觉得。
A 在 B 的问题下评论了 C 的答案里 D 的评论。
站内信的主语:A。很明显,A 对 D 的评论产生了这条消息。
实体类型:注意是主体类型,只是一个类型,比如掘金沸点、知乎问题、淘宝商品、订单等,根据业务定义。
触发主体:实体是一个 json 对象,可以理解成这个问题在数据库的快照。当多个层级的主体,最多选择两层。以这个回答为例我觉得即可以是最上层 B 先有鸡还是先蛋的问题;也可以是中层 C 的答案或者 D 的评论,取决于你的通知想展示哪些内容。
站内信接收人:D。D 会收到通知。
触发站内信的事件:评论。事件是一个行为,点赞、关注等。
理解了这些,我们可以整理出一个大致的表结构:
CREATE TABLE `message_body`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`event_type` TINYINT NOT NULL DEFAULT 0 COMMENT '事件类型,详见枚举',
`entity` VARCHAR(10240) NOT NULL DEFAULT 0 COMMENT '实体对象,JSON 结构',
`entity_type` TINYINT NOT NULL DEFAULT 0 COMMENT '实体类型,详见枚举',
`notice_type` TINYINT NOT NULL DEFAULT 0 COMMENT '通知类型,详见枚举',
`content` VARCHAR(1024) NOT NULL DEFAULT 0 COMMENT '通知内容',
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
ROW_FORMAT = COMPACT COMMENT ='站内信消息表';
这个表有没有不足的地方?有,我先埋个伏笔,因为要了解这种方式的不足,需要对站内信有更深入的了解。
消息和发送人的关系
这个应该很好理解,代表了这个消息发给谁。
CREATE TABLE `message_target`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`message_body_id` BIGINT NOT NULL DEFAULT 0 COMMENT '站内信id',
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户id',
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '最后修改时间',
PRIMARY KEY (`id`),
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
ROW_FORMAT = COMPACT COMMENT ='站内信-接收人表';
可以适当的冗余 event_type、entity_type、notice_type 方便查询。
是否已读
CREATE TABLE `message_status_item`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`message_body_id` BIGINT NOT NULL DEFAULT 0 COMMENT '站内信id',
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户id',
`read_time` BIGINT NOT NULL DEFAULT 0 COMMENT '已读时间',
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
ROW_FORMAT = COMPACT COMMENT ='站内信-一对一已读表';
同样的,也可以适当的冗余 event_type、entity_type、notice_type 方便查询。
至此一个一对一的站内信设计就完成了。触发消息通知时分别在 message_body 和 message_target 新增一条数据,接收人点击时在 message_status_item 新增一条已读记录。
那么问题来了?系统批量或全量发送的公告、活动通知怎么办?肯定不能有多少用户就新增多少条数据,这样玩数据库迟早驾崩。
这里就谈到通知类型 notice_type 了,粗略的讲站内信分两种,一种是一对多的发送,如系统公告、活动通知等,一种是一对一的,如评论、回复。
以顺丰 APP 为例:
这里的几乎每一列通知都是一个 notice_type,快递消息、支付通知、活动精选、系统消息、会员动态等等。
同样的,并夕夕也是。
淘宝。
找了一圈突然发现一个问题,怎么没有一对一的场景呢?严格的讲,是没有一对一的,因为所有的消息都是可以分组的,不管是对话、评论、回复,同一个人的对话、同一个沸点的评论、同一个问题的回复,都可以聚合成一条。就像同一个人给你发了 10 条未读消息,在消息列表不是有 10 条消息,而是 1 条消息,右边有个气泡里面是数字 10。
所以我前面的那个伏笔的问题在这里,用 JSON 格式存实体,会难以聚合相同主体的消息(比如同一个人的消息),可以如此改造。
CREATE TABLE `message_body_dialogue`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`entity_unique_sign` VARCHAR(32) NOT NULL DEFAULT 0 COMMENT '唯一主体标识',
`message_body_id` BIGINT NOT NULL DEFAULT 0 COMMENT '站内信id',
`from_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '发送人id',
`from_user_name` VARCHAR(32) NOT NULL DEFAULT 0 COMMENT '发送人名称',
`to_user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '接收人id',
`to_user_name` VARCHAR(32) NOT NULL DEFAULT 0 COMMENT '接收人名称',
`content` VARCHAR(1024) NOT NULL DEFAULT 0 COMMENT '消息内容',
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
ROW_FORMAT = COMPACT COMMENT ='站内信-对话消息表';
CREATE TABLE `message_body`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`event_type` TINYINT NOT NULL DEFAULT 0 COMMENT '事件类型,详见枚举',
`expend_id` BIGINT NOT NULL DEFAULT 0 COMMENT '消息主体表id',
`entity_unique_sign` VARCHAR(32) NOT NULL DEFAULT 0 COMMENT '唯一主体标识',
`entity_type` TINYINT NOT NULL DEFAULT 0 COMMENT '实体类型,详见枚举',
`notice_type` TINYINT NOT NULL DEFAULT 0 COMMENT '通知类型,详见枚举',
`content` VARCHAR(1024) NOT NULL DEFAULT 0 COMMENT '通知内容',
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
ROW_FORMAT = COMPACT COMMENT ='站内信消息表';
新增一张表用于存储用户对话类消息,用发送人 id 和接收人 id 组成唯一主体标识,这样就能聚合相同主体的消息了,去掉 message_body 表的 entity 字段,新增 expend_id,根据 notice_type 决定查哪张表,根据 expend_id 定位对应的数据。
message_body_dialogue 表出来了,message_body_system(管理后台公共、通知类)、message_body_subscribe(订阅类、状态变更类)都是照葫芦画瓢。
至此我们解决了所有消息内容相关的问题,下一个问题难点是,怎么修改已读的未读?
如果别人给我发了 10 条未读消息,只要点进去一次,10 条消息都会变为已读。如果新增 10 条已读记录,未免有点呆,我们可以处理的更优雅。
CREATE TABLE `message_status_group`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`entity_unique_sign` VARCHAR(32) NOT NULL DEFAULT 0 COMMENT '唯一主体标识',
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户id',
`last_read_time` BIGINT NOT NULL DEFAULT 0 COMMENT '最近已读时间',
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
ROW_FORMAT = COMPACT COMMENT ='站内信-分组已读表';
使用 message_status_group 表中的 entity_unique_sign 判断,这是同一个主体的唯一键,未读条数就是 message_body 中创建时间大于 last_read_time 的条数,当用户点击列表后,将 last_read_time 修改为当前时间。
同样的思路,针对系统公告这样的推送所有用户的,建表。
CREATE TABLE `message_status_all`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT NOT NULL DEFAULT 0 COMMENT '用户id',
`last_read_time` BIGINT NOT NULL DEFAULT 0 COMMENT '最近已读时间',
`created_at` BIGINT NOT NULL DEFAULT 0 COMMENT '创建时间',
`updated_at` BIGINT NOT NULL DEFAULT 0 COMMENT '最后修改时间',
PRIMARY KEY (`id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
ROW_FORMAT = COMPACT COMMENT ='站内信-全量已读表';
在全量的场景中,不需要新增 message_target 表的数据,而是在打开 APP 时判断所有公告的创建日期是否都小于该用户在 message_status_all 表的 last_read_time,以此判断用户是否有未读公告。
比如顺丰 APP 上图的 99+ 条活动精选,在我点击后就变为了 0 条。
最后重新整理下几张表的关联和作用。
message_body 站内信消息表,消息总表。
message_body_dialogue 对话消息主体表、message_body_system 系统消息主体表等,message_body 中的每个 notice_type 都会对应表,通过 notice_type 和 expend_id 可以查询到具体的消息主体表,为了方便查询冗余了 entity_unique_sign。
message_target 站内信-接收人表,表示这条消息的接收人。微信那令人诟病的朋友圈点赞通知所有人,就是在这里把所有人都关联了。
message_status_item、message_status_group、message_status_all 分别用于处理一对一、一对多、全量的已读标识处理,每个的未读条数计算方式不一样,一对一用判断是否存在已读记录,一对多和全量用 last_read_time。
需要注意,每个消息在点击跳转的页面、携带的参数是不一样的,前后端要协商出一致的方案,基本上是根据 notice_type 区分的。
最后,消息的实时通知,这不属于站内信的范畴,是用推送做的,接入个推、激光的 SDK,在创建 message_body 后异步请求推送 SDK 即可。
最后的最后,描述一个完整站内信创建、推送到用户已读的过程。已订单状态变动为例:
- 订单发货后,发送异步通知。
- 站内信监听到通知,新增站内信几张表的数据。
- 请求推送服务 SDK。
- 用户不管是在线还是离线,都能收到通知,点击通知后,修改
last_read_time。
有疑问欢迎评论区讨论。
参考了这个大佬的文章,为了方便借用了大佬的描述词 juejin.cn/post/684490…