谈一谈站内信的设计


1. 背景介绍

在面试的时候被问到了站内信的设计,包括自己做的项目也有做过站内信,但是还有一些场景可能没有考虑到,在这篇文章中记录一下,并且尝试去思考一下,如何去解决

2. 站内信到底在解决什么问题?

  1. 个人通知

    例如订单支付成功,审核通过,退款到站,评论被回复。这种类型的消息有明确的接收人,通常还需要知道是否已读

  2. 系统通知

    例如节假日活动、平台规则升级、系统维护通知。这类消息通常面向全站或某类用户,更像“公告栏”而不是“收件箱”

  3. 强制确认型通知

    例如协议更新、安全告警、内部合规通知。这类消息不仅要送达,还要知道“谁收到了、谁没读、谁确认了”

3. 针对不同的业务场景,应该采取不同的解决方案

3.1 场景一:个人通知

这类消息最典型的特点是:

  • 接收人明确
  • 消息和用户之间是强关联
  • 往往需要支持已读、未读、删除、归档等状态

所以这种场景的设计思路很直接,消息内容存一份,收件状态存一份

也就是经典的消息主表 + 用户接收表这种类型

3.2 场景二:系统公告

系统公告和个人通知不一样,他不是精准投递给某一个用户,而是对全站或者某类用户进行推送 例如:

  1. 面向普通用户发布一条平台公告
  2. 面向商家发一条结算规则更新公告
  3. 面向全站用户发送一条系统维护公告

这种场景的话,我们就不建议像个人通知一样,使用消息主表 + 用户接收表这种结构了。因为如果用户量比较多,就拿几千个来说,一次全站公告,需要批量插入用户量同等的数据到表中,且批量插入的数据量比较大,怎么做好批量插入也是一个难点呢

就比如有以下的问题:

  1. 一条全站公告,可能需要存储海量的接收记录
  2. 批量插入数据量比较大,可能会导致性能问题
  3. 存储膨胀很快
  4. 未读数,补偿,撤回都会很重

所以针对这种场景,我们不改做成“预先为用户生成一条接收记录”,而是采取:

  1. 公告主表存储 内容和目标人群
  2. 已读表记录哪些用户读过

也就是说,不预生成所有人的未读记录,只在用户真正阅读时写入一条已读记录

这就是一种典型的稀疏存储思路,非常适合大规模广播场景

3.3 场景三:强制确认型通知

还有一类消息是不能走公告 + 已读表 的思路的,例如:

  1. 协议条款更新,要求用户确认
  2. 安全风险通知,需要用户签收
  3. 内部系统中的合规通知,要求可审计

这类场景的关键是:业务本身要求逐用户精确状态管理

这时候即使用户量再大,也必须保留:

  1. 发给了谁
  2. 是否送达
  3. 是否已读
  4. 是否确认

那这种时候就是考虑怎么存储这些数据了

  1. 分库分表
  2. 批量写入
  3. 冷热数据分层
  4. 审计归档
  5. 异步存储消息接收人

4. 不同场景下,表结构应该怎么设计

这里只会做一个大概的,并非一个真正的数据库表设计,实际中仍然需要考虑业务的需求。

4.1 个人通知:消息主表 + 用户收件表

消息主表 site_message

字段名类型作用
idBIGINT消息主键 ID,唯一标识一条站内信内容
biz_typeVARCHAR(50)业务类型,例如订单通知、审核通知、到账通知,用于区分消息来源
biz_idVARCHAR(64)业务关联 ID,例如订单 ID、审批单 ID,可用于幂等控制和问题追踪
sender_typeVARCHAR(20)发送者类型,例如 system、admin、user
sender_idBIGINT发送者 ID,如果是系统发送可以为空
titleVARCHAR(200)消息标题
contentTEXT消息正文内容
content_typeVARCHAR(20)内容类型,例如纯文本、Markdown、JSON
link_urlVARCHAR(500)消息点击后的跳转链接
priorityTINYINT消息优先级,用于控制展示顺序或提醒强度
statusVARCHAR(20)消息状态,例如草稿、已发布、已撤销
publish_timeDATETIME消息发布时间,支持定时发布场景
ext_jsonJSON扩展信息,用于存放附加业务数据,增强扩展性

补充说明:

  • 这张表只存一份消息内容,不直接存用户已读、未读状态
  • 如果一条消息要发给多个用户,内容仍然只保留一份,避免数据重复

用户收件表 site_message_user

字段名类型作用
idBIGINT收件记录主键 ID
message_idBIGINT关联的消息 ID,对应 site_message.id
user_idBIGINT接收该消息的用户 ID
inbox_statusVARCHAR(20)收件箱状态,例如未读、已读、已删除、已归档
read_timeDATETIME用户阅读该消息的时间
delete_timeDATETIME用户删除该消息的时间,通常用于逻辑删除
deliver_statusVARCHAR(20)投递状态,例如待投递、已推送、推送失败
deliver_timeDATETIME消息实际投递或推送的时间

补充说明:

  • 这张表体现“消息和用户之间的关系”
  • 一条消息发给 100 个用户,这张表就会有 100 条记录
  • 已读、未读、删除等用户态信息,应放在这张表,而不是消息主表

4.2 系统公告:公告主表 + 已读表

字段名类型作用
idBIGINT公告主键 ID,唯一标识一条系统公告
titleVARCHAR(200)公告标题
contentTEXT公告正文内容
target_typeVARCHAR(20)目标人群类型,例如全站用户、普通用户、商家用户、指定分群
target_conditionJSON目标人群条件,例如用户标签、用户类型、地域、活跃条件等
need_ackTINYINT是否要求确认或已读,0 表示否,1 表示是
publish_timeDATETIME公告发布时间
expire_timeDATETIME公告失效时间,过期后可不再展示
statusVARCHAR(20)公告状态,例如待发布、已发布、已下线

补充说明:

  • 这张表只保存公告内容和目标人群规则
  • 系统发布公告时,不需要立即为所有目标用户生成收件记录
  • 更适合全站公告、活动通知、平台规则更新等广播类消息

消息已读表 site_announcement_read

字段名类型作用
idBIGINT已读记录主键 ID
announcement_idBIGINT关联的公告 ID,对应 site_announcement.id
user_idBIGINT已阅读该公告的用户 ID
read_timeDATETIME用户阅读公告的时间

补充说明:

  • 这张表只记录“已经读过”的用户,不记录所有接收人
  • 没有读过的用户,在这张表中不会有记录
  • 是否未读的判断方式是:用户命中公告人群规则,且在已读表中不存在对应记录

这里的核心思想就是:

  • 公告发出时,不为每一个接收人生成一条未读记录
  • 仅当用户实际阅读时,才生成一条已读记录
  • 未读状态通过“命中人群且没有已读记录”来判断

4.3 强制确认型通知

这种场景下,表结构接近于4.1中的个人通知,只是状态会更加完整,例如:

  1. received
  2. read
  3. acknowledged
  4. expired

本质上仍然需要一张逐用户状态表,只是状态和审计字段会更多。这里的重点不在于表长什么样,而在于:必须保存接收人快照

5. 站内信的推送方式

站内信通常要拆成三件事来看:

  1. 消息生成
  2. 消息落库
  3. 在线提醒

这三件事往往不是通过一种技术来完成的

5.1 个人通知:事件驱动 + 落库 + 在线提醒

个人通知最合适的方式是,业务事件发生 —> 同步或者异步生成消息 —> 落库 —> 提醒

5.2 系统公告:发布即生效,在线提醒可选

系统公告一般不需要逐用户强投递,所以推送方式更轻:

  1. 公告发布后直接落库
  2. 在线用户可以通过 WebSocket/SSE 收到“有新公告”的提示
  3. 离线用户下次进入站点时,通过接口拉取可见公告列表

5.3 定时任务的作用是:不主动投递,而是定时生产或补偿

定时任务一般的定位是:

  1. 定时发布公告
  2. 定时扫描业务数据并生成提醒消息
  3. 对失败投递做补偿重试
  4. 批量处理过期、归档、失效状态

所以准确说:

  1. 即时个人通知:事件驱动
  2. 定时类消息:定时任务来生成
  3. 在线提醒: WebSocket/SSE
  4. 离线提醒:接口拉取

6. 总结一些坑点

6.1 存储膨胀

一开始为了统一存储模型,觉得所有消息都可以塞进去,这就是我自己在系统里面做的,这种做法就是:

  • 一条主消息
  • 多条用户收件记录

在中小规模下这种存储方式没有问题,一旦遇到广播公告,就会出现:

  • 一次发布海量写入
  • 未读数维护成本过高
  • 批量不发、撤回更加复杂

6.2 Websocket不能作为可靠消息通道

WebSocket 很适合做站内信提醒,但不适合作为唯一消息通道

如果把 WebSocket 直接当“发送成功”的依据,就会遇到:

  • 用户离线收不到
  • 连接断开消息丢失
  • 页面刷新后状态不一致

正确的做法是,先落库 —> 再推送提醒 —> 前端以接口拉取结果为准

6.3 幂等性和重复发送需要提前考虑

站内信经常由业务事件触发,例如:

  • 支付回调
  • 事件审批
  • 补偿任务重试

如果没有幂等设计,就很容易把一条业务通知发多次

常见做法是:

  • biz_type + biz_id + user_id
  • 或者单独业务唯一键

6. 站内信发送失败与重试机制设计

站内信系统在设计时,不能只考虑怎么发,还要考虑失败了怎么办

很多时候,大家一提到“发送失败”,第一反应是 WebSocket 推送失败,但在站内信场景里,这个理解其实不完整

站内信的发送,至少可以拆成两个层面来看:

  1. 消息是否成功落库
  2. 是否成功通知到了在线用户

对于站内信来说,真正可靠的成功标准,通常应该是消息已经成功进入系统,并且用户后续可以查询到 至于 WebSocket 或 SSE 这种实时提醒能力,更适合作为体验增强,而不是唯一可靠通道

6.1 什么叫发送成功

不同业务场景下,发送成功的定义并不完全一样

个人通知

个人通知一般以“消息成功落库,且用户可以在消息中心查询到”作为成功标准

也就是说:

  • site_message 写入成功
  • site_message_user 写入成功

只要这两步完成,即使 WebSocket 没有推送成功,消息也不算真正丢失 因为用户后续仍然可以在站内信列表中看到这条消息

系统公告

系统公告一般以“公告成功发布,并且命中条件的用户可以查询到”作为成功标准

也就是说:

  • site_announcement 写入成功
  • 公告状态已经生效
  • 人群规则可以正常命中

这种场景下,实时提醒失败并不会影响公告本身的有效性

强制确认型通知

强制确认型通知的要求更高,通常不能只看“是否写入成功”,而是要拆成一组更细的状态

例如:

  1. 已创建
  2. 已展开接收人
  3. 已投递
  4. 已读
  5. 已确认

这类场景往往不只是“把消息发出去”这么简单,而是需要完整记录整个发送和确认过程

6.2 站内信里哪些失败需要考虑

站内信系统里的失败,通常至少包括下面几类

1. 消息创建失败

例如:

  • 消息主表插入失败
  • 用户收件表插入失败
  • 事务执行中途异常
  • 幂等控制缺失导致重复消息

这类失败最核心,因为它意味着消息根本没有进入系统

2. 接收人展开失败

这类问题主要出现在批量发送或者强制确认型通知中

例如:

  • 查询目标用户失败
  • 分页扫描中断
  • 批量插入部分成功、部分失败
  • 后台任务执行到一半宕机

这类失败往往会出现“部分成功”的情况,所以不能只用成功或失败两个状态来描述

3. 在线提醒失败

例如:

  • WebSocket 连接不存在
  • 用户当前离线
  • 推送服务异常
  • SSE 连接中断

这类失败通常不应该影响站内信主链路,因为消息已经落库,用户后续仍然可以查询到

4. 下游联动失败

例如:

  • 未读数缓存更新失败
  • 消息投递日志记录失败
  • 触发其他通知渠道失败
  • 补偿任务调度失败

这类失败也不应该拖垮主链路,而应该异步处理

6.3 为什么这里需要引入发送任务表

如果只是单条个人通知,例如订单支付成功后给某一个用户发送一条消息,这种场景通常并不复杂

业务事件触发后,直接写入消息表和收件表就可以

但如果发送过程本身已经变复杂了,就不能只盯着消息内容表,还需要把“发送动作”单独管理起来 这时候就需要引入发送任务表

发送任务表并不是为了替代消息表,而是为了记录“一次发送过程执行得怎么样”

可以这样理解:

  • 消息表负责存“发了什么”
  • 用户收件表负责存“谁收到了”
  • 发送任务表负责存“这次发送执行得怎么样”

哪些场景更适合引入任务表

通常下面这些场景,就比较适合配合发送任务表来做

1. 批量发送场景

例如:

  • 给几十万普通用户发送一条活动通知
  • 给某类商家发送一条规则变更通知
  • 给指定分群发送一条营销消息

这类场景的特点是接收人很多,发送过程不可能在一个同步请求中完成

2. 异步展开接收人场景

例如:

  • 先根据规则圈选目标用户
  • 再分批生成收件记录
  • 再分批写入 site_message_user

这种场景下,任务可能持续很长时间 如果没有任务表,就很难回答:

  • 当前执行到哪一批
  • 一共处理了多少人
  • 哪一批失败了
  • 是否可以断点续跑

3. 强制确认型通知场景

例如:

  • 协议更新确认
  • 风险告警签收
  • 内部合规通知

这类消息不仅要发出去,还要能精确回答:

  • 发给了谁
  • 哪些人已读
  • 哪些人未确认
  • 发送过程中是否出现异常

这种场景下,任务表不仅服务于补偿,也服务于审计和追踪

4. 需要失败重试和人工补偿的场景

只要系统已经开始考虑下面这些问题,任务表基本就很有必要了

  • 全部失败怎么处理
  • 部分成功怎么处理
  • 后续如何补偿失败数据
  • 是否支持人工重新触发发送

因为没有任务表的话,很多状态只能散落在日志里,系统本身并没有沉淀出“发送过程”的状态数据

6.4 发送任务表主要承担什么职责

发送任务表本质上记录的是一次“发送动作”

它至少应该承担下面几个职责

1. 跟踪发送进度

例如:

  • 总共要处理多少人
  • 已成功多少
  • 已失败多少
  • 当前执行到第几批

2. 标记任务状态

一个简化版任务模型,至少应该支持这些状态:

  1. INIT
  2. PROCESSING
  3. PARTIAL_SUCCESS
  4. SUCCESS
  5. FAILED

有了这些状态之后,系统才能区分:

  • 任务还没开始
  • 任务正在执行
  • 任务执行了一部分
  • 任务已经执行完成
  • 任务最终失败

3. 支持失败重试

如果某一批处理失败,任务表需要能记录:

  • 失败原因
  • 当前重试次数
  • 下次计划重试时间

这样系统才能自动补偿,而不是完全依赖人工排查

4. 支持人工介入

线上系统里,并不是所有失败都能靠自动重试解决

比如:

  • 人群规则配置错误
  • 某批数据异常
  • 某个任务反复失败

这时就需要人工根据任务状态来判断:

  • 是否继续重试
  • 是否终止任务
  • 是否只补偿失败部分

5. 提供审计能力

尤其是强制确认型通知场景,任务表还可以帮助回答:

  • 这条消息什么时候开始发送
  • 是哪个任务发起的
  • 发送过程中是否发生过失败
  • 最终状态是什么

6.5 一个简化的发送任务表可以怎么设计

发送任务表不一定一开始就要做得很复杂,但至少要能描述“任务是谁、发给谁、发得怎么样”

字段名类型作用
idBIGINT任务主键 ID,唯一标识一次发送任务
message_idBIGINT关联的消息 ID,表示这次任务发送的是哪条消息
task_typeVARCHAR(20)任务类型,例如个人通知、系统公告、强制确认通知
target_typeVARCHAR(20)目标人群类型,例如单用户、用户分群、全站用户
task_statusVARCHAR(20)当前任务状态,例如初始化中、执行中、部分成功、成功、失败
total_countBIGINT预计需要处理的总人数
success_countBIGINT已成功处理的人数
fail_countBIGINT已失败处理的人数
retry_countINT当前任务已重试次数
next_retry_timeDATETIME下次计划重试时间
last_error_msgVARCHAR(1000)最近一次失败原因
created_atDATETIME任务创建时间
updated_atDATETIME任务更新时间

补充说明:

  • message_id 用来关联消息内容
  • task_status 用来描述发送过程的整体状态
  • total_count、success_count、fail_count 用来跟踪任务进度
  • retry_count 和 next_retry_time 用来支撑自动重试
  • last_error_msg 用来辅助失败排查

6.6 为什么要做重试机制

不是所有失败都值得重试,但对于“临时性失败”,重试是很有必要的

适合重试的场景通常有:

  • 数据库瞬时抖动
  • MQ 消费异常
  • 批量写入超时
  • 网络波动
  • 推送服务短暂不可用

这类问题往往不是业务逻辑错误,而是运行时问题,稍后重试有较大概率恢复成功

但像下面这些问题,一般就不适合盲目重试:

  • 参数非法
  • 用户不存在
  • 消息模板错误
  • 人群规则配置错误
  • 唯一约束冲突且没有幂等控制

这类问题如果不先修复根因,重试多少次都没有意义

6.7 全部失败和部分成功,应该怎么处理

批量发送场景中,不能简单地把结果只分成“成功”或者“失败”

全部失败

例如:

  • 发送任务创建了,但任务根本没执行
  • 第一批接收人就插入失败
  • 主消息生成成功,但一条收件记录都没落下来

这种情况通常应该:

  1. 将任务状态标记为失败
  2. 记录失败原因
  3. 进入重试或人工补偿流程

部分成功

例如:

  • 一共要发送 100 万用户
  • 已成功写入 80 万
  • 剩余 20 万由于数据库超时而失败

这种情况下,如果直接整体回滚,代价很高 如果直接判定为成功,又会丢数据

更合理的做法是:

  1. 将任务状态标记为部分成功
  2. 记录当前处理进度
  3. 记录失败批次和失败原因
  4. 后续只补偿失败部分,而不是整批重跑

这一点非常关键 否则一旦全量重试,就会导致重复写入、重复发送、数据库压力进一步放大

6.8 重试时最重要的是幂等

只要涉及重试,就必须考虑幂等性

否则就会出现:

  • 同一条消息发给同一个用户多次
  • 已成功部分在重试时再次插入
  • 用户收到重复消息

常见做法包括下面几种

1. 数据库唯一约束

例如在个人通知的收件表中增加:

  • UNIQUE KEY (message_id, user_id)

这样即使任务重复执行,也不会插入多条相同记录

2. 业务幂等键

例如:

  • biz_type + biz_id + user_id
  • task_id + user_id

用于标识“这条业务消息对这个用户是否已经处理过”

3. 批次级幂等

如果接收人是分批展开、分批写入的,那么每一个批次最好也有自己的处理标识,便于断点续跑和局部补偿

6.9 不同业务场景下的处理重点

个人通知

这类消息通常和订单、支付、审批等核心业务强相关,所以要求最高

重点在于:

  • 主表和收件表尽量放在同一事务中
  • 失败后要有补偿能力
  • 必须保证幂等,避免重复发送

系统公告

这类消息的重点不是“给每个人都实时推到”,而是“公告发布后用户可查询”

因此更合理的做法是:

  • 公告主表发布成功即可视为主链路成功
  • 实时提醒失败不影响公告有效性
  • 已读记录写入失败可以补偿,但不应阻塞用户查看公告

强制确认型通知

这类消息最严格,因为它往往要求审计和可追踪

重点在于:

  • 必须保存逐用户状态
  • 必须支持失败补偿
  • 必须区分全部失败和部分成功
  • 必须能拉出未确认名单

6.10 这一节的核心结论

站内信系统不仅要设计“怎么发”,还必须设计“失败了怎么办”

尤其是批量通知、强制确认通知这类场景,一定要重点考虑:

  1. 全部失败如何处理
  2. 部分成功如何补偿
  3. 重试是否具备幂等性
  4. 任务进度是否可追踪
  5. 是否支持人工介入和审计排查

可以把整个设计思路总结成一句话:

只要“发送过程本身”已经变成一个独立问题,就应该把它抽象成任务来管理

也只有这样,失败重试、部分成功补偿、人工介入、审计追踪这些能力,才能真正落地