公告、活动、推送与站内信(详解架构 + 库表设计)

10,514 阅读9分钟

我可以很负责的说,这篇文章是截至现在网上最全的站内信架构设计。因为我要做这个需求了,Google 了大部分资料,除了掘金有两篇比较精华的架构文,其他都是各种 CVS 来的,很 low 的一个 MySql 表 DDL。但那两篇精华文时间比较久了,执行的时候写的又比较模糊,所以我重新整理了所有的思路,和 leader 一起讨论出一种架构,适用于现在绝大多数的站内信,如果你也有站内信方面的困惑,不妨一起来参考和讨论。

公告、活动、推送与站内信

公告:通常是推送给全量用户的,公告在数据库是公告表的一条数据。例如:

ab3f9348727b58c6c6172c4dec5d387.jpg 活动:和公告类似,区别是推送批量指定用户,比公告多了额外的参数,如起止时间、活动规则、内容等。

推送:创建公告或活动后,需要通知用户,这就是推送,推送一般用个推、极光的 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_typeentity_typenotice_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_typeentity_typenotice_type 方便查询。

至此一个一对一的站内信设计就完成了。触发消息通知时分别在 message_bodymessage_target 新增一条数据,接收人点击时在 message_status_item 新增一条已读记录。

那么问题来了?系统批量或全量发送的公告、活动通知怎么办?肯定不能有多少用户就新增多少条数据,这样玩数据库迟早驾崩。

这里就谈到通知类型 notice_type 了,粗略的讲站内信分两种,一种是一对多的发送,如系统公告、活动通知等,一种是一对一的,如评论、回复。

以顺丰 APP 为例:

375237a32adaf0f8d07edc93221ae00.jpg

这里的几乎每一列通知都是一个 notice_type,快递消息、支付通知、活动精选、系统消息、会员动态等等。

同样的,并夕夕也是。

313ae751ebb3c2ac94dd95764179b5c.jpg

淘宝。

4d78f0114345bb8a2f89328c5072d07.jpg

找了一圈突然发现一个问题,怎么没有一对一的场景呢?严格的讲,是没有一对一的,因为所有的消息都是可以分组的,不管是对话、评论、回复,同一个人的对话、同一个沸点的评论、同一个问题的回复,都可以聚合成一条。就像同一个人给你发了 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 条。

6f83f4ee85178a07c6876a81a91250c.jpg

最后重新整理下几张表的关联和作用。

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 即可。

最后的最后,描述一个完整站内信创建、推送到用户已读的过程。已订单状态变动为例:

  1. 订单发货后,发送异步通知。
  2. 站内信监听到通知,新增站内信几张表的数据。
  3. 请求推送服务 SDK。
  4. 用户不管是在线还是离线,都能收到通知,点击通知后,修改 last_read_time

有疑问欢迎评论区讨论。

参考了这个大佬的文章,为了方便借用了大佬的描述词 juejin.cn/post/684490…