谈一谈站内信的设计
1. 背景介绍
在面试的时候被问到了站内信的设计,包括自己做的项目也有做过站内信,但是还有一些场景可能没有考虑到,在这篇文章中记录一下,并且尝试去思考一下,如何去解决
2. 站内信到底在解决什么问题?
-
个人通知
例如订单支付成功,审核通过,退款到站,评论被回复。这种类型的消息有明确的接收人,通常还需要知道是否已读
-
系统通知
例如节假日活动、平台规则升级、系统维护通知。这类消息通常面向全站或某类用户,更像“公告栏”而不是“收件箱”
-
强制确认型通知
例如协议更新、安全告警、内部合规通知。这类消息不仅要送达,还要知道“谁收到了、谁没读、谁确认了”
3. 针对不同的业务场景,应该采取不同的解决方案
3.1 场景一:个人通知
这类消息最典型的特点是:
- 接收人明确
- 消息和用户之间是强关联
- 往往需要支持已读、未读、删除、归档等状态
所以这种场景的设计思路很直接,消息内容存一份,收件状态存一份
也就是经典的消息主表 + 用户接收表这种类型
3.2 场景二:系统公告
系统公告和个人通知不一样,他不是精准投递给某一个用户,而是对全站或者某类用户进行推送 例如:
- 面向普通用户发布一条平台公告
- 面向商家发一条结算规则更新公告
- 面向全站用户发送一条系统维护公告
这种场景的话,我们就不建议像个人通知一样,使用消息主表 + 用户接收表这种结构了。因为如果用户量比较多,就拿几千个来说,一次全站公告,需要批量插入用户量同等的数据到表中,且批量插入的数据量比较大,怎么做好批量插入也是一个难点呢
就比如有以下的问题:
- 一条全站公告,可能需要存储海量的接收记录
- 批量插入数据量比较大,可能会导致性能问题
- 存储膨胀很快
- 未读数,补偿,撤回都会很重
所以针对这种场景,我们不改做成“预先为用户生成一条接收记录”,而是采取:
- 公告主表存储 内容和目标人群
- 已读表记录哪些用户读过
也就是说,不预生成所有人的未读记录,只在用户真正阅读时写入一条已读记录
这就是一种典型的稀疏存储思路,非常适合大规模广播场景
3.3 场景三:强制确认型通知
还有一类消息是不能走公告 + 已读表 的思路的,例如:
- 协议条款更新,要求用户确认
- 安全风险通知,需要用户签收
- 内部系统中的合规通知,要求可审计
这类场景的关键是:业务本身要求逐用户精确状态管理
这时候即使用户量再大,也必须保留:
- 发给了谁
- 是否送达
- 是否已读
- 是否确认
那这种时候就是考虑怎么存储这些数据了
- 分库分表
- 批量写入
- 冷热数据分层
- 审计归档
- 异步存储消息接收人
4. 不同场景下,表结构应该怎么设计
这里只会做一个大概的,并非一个真正的数据库表设计,实际中仍然需要考虑业务的需求。
4.1 个人通知:消息主表 + 用户收件表
消息主表 site_message
| 字段名 | 类型 | 作用 |
|---|---|---|
id | BIGINT | 消息主键 ID,唯一标识一条站内信内容 |
biz_type | VARCHAR(50) | 业务类型,例如订单通知、审核通知、到账通知,用于区分消息来源 |
biz_id | VARCHAR(64) | 业务关联 ID,例如订单 ID、审批单 ID,可用于幂等控制和问题追踪 |
sender_type | VARCHAR(20) | 发送者类型,例如 system、admin、user |
sender_id | BIGINT | 发送者 ID,如果是系统发送可以为空 |
title | VARCHAR(200) | 消息标题 |
content | TEXT | 消息正文内容 |
content_type | VARCHAR(20) | 内容类型,例如纯文本、Markdown、JSON |
link_url | VARCHAR(500) | 消息点击后的跳转链接 |
priority | TINYINT | 消息优先级,用于控制展示顺序或提醒强度 |
status | VARCHAR(20) | 消息状态,例如草稿、已发布、已撤销 |
publish_time | DATETIME | 消息发布时间,支持定时发布场景 |
ext_json | JSON | 扩展信息,用于存放附加业务数据,增强扩展性 |
补充说明:
- 这张表只存一份消息内容,不直接存用户已读、未读状态
- 如果一条消息要发给多个用户,内容仍然只保留一份,避免数据重复
用户收件表 site_message_user
| 字段名 | 类型 | 作用 |
|---|---|---|
id | BIGINT | 收件记录主键 ID |
message_id | BIGINT | 关联的消息 ID,对应 site_message.id |
user_id | BIGINT | 接收该消息的用户 ID |
inbox_status | VARCHAR(20) | 收件箱状态,例如未读、已读、已删除、已归档 |
read_time | DATETIME | 用户阅读该消息的时间 |
delete_time | DATETIME | 用户删除该消息的时间,通常用于逻辑删除 |
deliver_status | VARCHAR(20) | 投递状态,例如待投递、已推送、推送失败 |
deliver_time | DATETIME | 消息实际投递或推送的时间 |
补充说明:
- 这张表体现“消息和用户之间的关系”
- 一条消息发给 100 个用户,这张表就会有 100 条记录
- 已读、未读、删除等用户态信息,应放在这张表,而不是消息主表
4.2 系统公告:公告主表 + 已读表
| 字段名 | 类型 | 作用 |
|---|---|---|
id | BIGINT | 公告主键 ID,唯一标识一条系统公告 |
title | VARCHAR(200) | 公告标题 |
content | TEXT | 公告正文内容 |
target_type | VARCHAR(20) | 目标人群类型,例如全站用户、普通用户、商家用户、指定分群 |
target_condition | JSON | 目标人群条件,例如用户标签、用户类型、地域、活跃条件等 |
need_ack | TINYINT | 是否要求确认或已读,0 表示否,1 表示是 |
publish_time | DATETIME | 公告发布时间 |
expire_time | DATETIME | 公告失效时间,过期后可不再展示 |
status | VARCHAR(20) | 公告状态,例如待发布、已发布、已下线 |
补充说明:
- 这张表只保存公告内容和目标人群规则
- 系统发布公告时,不需要立即为所有目标用户生成收件记录
- 更适合全站公告、活动通知、平台规则更新等广播类消息
消息已读表 site_announcement_read
| 字段名 | 类型 | 作用 |
|---|---|---|
id | BIGINT | 已读记录主键 ID |
announcement_id | BIGINT | 关联的公告 ID,对应 site_announcement.id |
user_id | BIGINT | 已阅读该公告的用户 ID |
read_time | DATETIME | 用户阅读公告的时间 |
补充说明:
- 这张表只记录“已经读过”的用户,不记录所有接收人
- 没有读过的用户,在这张表中不会有记录
- 是否未读的判断方式是:用户命中公告人群规则,且在已读表中不存在对应记录
这里的核心思想就是:
- 公告发出时,不为每一个接收人生成一条未读记录
- 仅当用户实际阅读时,才生成一条已读记录
- 未读状态通过“命中人群且没有已读记录”来判断
4.3 强制确认型通知
这种场景下,表结构接近于4.1中的个人通知,只是状态会更加完整,例如:
- received
- read
- acknowledged
- expired
本质上仍然需要一张逐用户状态表,只是状态和审计字段会更多。这里的重点不在于表长什么样,而在于:必须保存接收人快照
5. 站内信的推送方式
站内信通常要拆成三件事来看:
- 消息生成
- 消息落库
- 在线提醒
这三件事往往不是通过一种技术来完成的
5.1 个人通知:事件驱动 + 落库 + 在线提醒
个人通知最合适的方式是,业务事件发生 —> 同步或者异步生成消息 —> 落库 —> 提醒
5.2 系统公告:发布即生效,在线提醒可选
系统公告一般不需要逐用户强投递,所以推送方式更轻:
- 公告发布后直接落库
- 在线用户可以通过 WebSocket/SSE 收到“有新公告”的提示
- 离线用户下次进入站点时,通过接口拉取可见公告列表
5.3 定时任务的作用是:不主动投递,而是定时生产或补偿
定时任务一般的定位是:
- 定时发布公告
- 定时扫描业务数据并生成提醒消息
- 对失败投递做补偿重试
- 批量处理过期、归档、失效状态
所以准确说:
- 即时个人通知:事件驱动
- 定时类消息:定时任务来生成
- 在线提醒: WebSocket/SSE
- 离线提醒:接口拉取
6. 总结一些坑点
6.1 存储膨胀
一开始为了统一存储模型,觉得所有消息都可以塞进去,这就是我自己在系统里面做的,这种做法就是:
- 一条主消息
- 多条用户收件记录
在中小规模下这种存储方式没有问题,一旦遇到广播公告,就会出现:
- 一次发布海量写入
- 未读数维护成本过高
- 批量不发、撤回更加复杂
6.2 Websocket不能作为可靠消息通道
WebSocket 很适合做站内信提醒,但不适合作为唯一消息通道
如果把 WebSocket 直接当“发送成功”的依据,就会遇到:
- 用户离线收不到
- 连接断开消息丢失
- 页面刷新后状态不一致
正确的做法是,先落库 —> 再推送提醒 —> 前端以接口拉取结果为准
6.3 幂等性和重复发送需要提前考虑
站内信经常由业务事件触发,例如:
- 支付回调
- 事件审批
- 补偿任务重试
如果没有幂等设计,就很容易把一条业务通知发多次
常见做法是:
biz_type + biz_id + user_id- 或者单独业务唯一键
6. 站内信发送失败与重试机制设计
站内信系统在设计时,不能只考虑怎么发,还要考虑失败了怎么办
很多时候,大家一提到“发送失败”,第一反应是 WebSocket 推送失败,但在站内信场景里,这个理解其实不完整
站内信的发送,至少可以拆成两个层面来看:
- 消息是否成功落库
- 是否成功通知到了在线用户
对于站内信来说,真正可靠的成功标准,通常应该是消息已经成功进入系统,并且用户后续可以查询到 至于 WebSocket 或 SSE 这种实时提醒能力,更适合作为体验增强,而不是唯一可靠通道
6.1 什么叫发送成功
不同业务场景下,发送成功的定义并不完全一样
个人通知
个人通知一般以“消息成功落库,且用户可以在消息中心查询到”作为成功标准
也就是说:
site_message写入成功site_message_user写入成功
只要这两步完成,即使 WebSocket 没有推送成功,消息也不算真正丢失 因为用户后续仍然可以在站内信列表中看到这条消息
系统公告
系统公告一般以“公告成功发布,并且命中条件的用户可以查询到”作为成功标准
也就是说:
site_announcement写入成功- 公告状态已经生效
- 人群规则可以正常命中
这种场景下,实时提醒失败并不会影响公告本身的有效性
强制确认型通知
强制确认型通知的要求更高,通常不能只看“是否写入成功”,而是要拆成一组更细的状态
例如:
- 已创建
- 已展开接收人
- 已投递
- 已读
- 已确认
这类场景往往不只是“把消息发出去”这么简单,而是需要完整记录整个发送和确认过程
6.2 站内信里哪些失败需要考虑
站内信系统里的失败,通常至少包括下面几类
1. 消息创建失败
例如:
- 消息主表插入失败
- 用户收件表插入失败
- 事务执行中途异常
- 幂等控制缺失导致重复消息
这类失败最核心,因为它意味着消息根本没有进入系统
2. 接收人展开失败
这类问题主要出现在批量发送或者强制确认型通知中
例如:
- 查询目标用户失败
- 分页扫描中断
- 批量插入部分成功、部分失败
- 后台任务执行到一半宕机
这类失败往往会出现“部分成功”的情况,所以不能只用成功或失败两个状态来描述
3. 在线提醒失败
例如:
- WebSocket 连接不存在
- 用户当前离线
- 推送服务异常
- SSE 连接中断
这类失败通常不应该影响站内信主链路,因为消息已经落库,用户后续仍然可以查询到
4. 下游联动失败
例如:
- 未读数缓存更新失败
- 消息投递日志记录失败
- 触发其他通知渠道失败
- 补偿任务调度失败
这类失败也不应该拖垮主链路,而应该异步处理
6.3 为什么这里需要引入发送任务表
如果只是单条个人通知,例如订单支付成功后给某一个用户发送一条消息,这种场景通常并不复杂
业务事件触发后,直接写入消息表和收件表就可以
但如果发送过程本身已经变复杂了,就不能只盯着消息内容表,还需要把“发送动作”单独管理起来 这时候就需要引入发送任务表
发送任务表并不是为了替代消息表,而是为了记录“一次发送过程执行得怎么样”
可以这样理解:
- 消息表负责存“发了什么”
- 用户收件表负责存“谁收到了”
- 发送任务表负责存“这次发送执行得怎么样”
哪些场景更适合引入任务表
通常下面这些场景,就比较适合配合发送任务表来做
1. 批量发送场景
例如:
- 给几十万普通用户发送一条活动通知
- 给某类商家发送一条规则变更通知
- 给指定分群发送一条营销消息
这类场景的特点是接收人很多,发送过程不可能在一个同步请求中完成
2. 异步展开接收人场景
例如:
- 先根据规则圈选目标用户
- 再分批生成收件记录
- 再分批写入
site_message_user
这种场景下,任务可能持续很长时间 如果没有任务表,就很难回答:
- 当前执行到哪一批
- 一共处理了多少人
- 哪一批失败了
- 是否可以断点续跑
3. 强制确认型通知场景
例如:
- 协议更新确认
- 风险告警签收
- 内部合规通知
这类消息不仅要发出去,还要能精确回答:
- 发给了谁
- 哪些人已读
- 哪些人未确认
- 发送过程中是否出现异常
这种场景下,任务表不仅服务于补偿,也服务于审计和追踪
4. 需要失败重试和人工补偿的场景
只要系统已经开始考虑下面这些问题,任务表基本就很有必要了
- 全部失败怎么处理
- 部分成功怎么处理
- 后续如何补偿失败数据
- 是否支持人工重新触发发送
因为没有任务表的话,很多状态只能散落在日志里,系统本身并没有沉淀出“发送过程”的状态数据
6.4 发送任务表主要承担什么职责
发送任务表本质上记录的是一次“发送动作”
它至少应该承担下面几个职责
1. 跟踪发送进度
例如:
- 总共要处理多少人
- 已成功多少
- 已失败多少
- 当前执行到第几批
2. 标记任务状态
一个简化版任务模型,至少应该支持这些状态:
INITPROCESSINGPARTIAL_SUCCESSSUCCESSFAILED
有了这些状态之后,系统才能区分:
- 任务还没开始
- 任务正在执行
- 任务执行了一部分
- 任务已经执行完成
- 任务最终失败
3. 支持失败重试
如果某一批处理失败,任务表需要能记录:
- 失败原因
- 当前重试次数
- 下次计划重试时间
这样系统才能自动补偿,而不是完全依赖人工排查
4. 支持人工介入
线上系统里,并不是所有失败都能靠自动重试解决
比如:
- 人群规则配置错误
- 某批数据异常
- 某个任务反复失败
这时就需要人工根据任务状态来判断:
- 是否继续重试
- 是否终止任务
- 是否只补偿失败部分
5. 提供审计能力
尤其是强制确认型通知场景,任务表还可以帮助回答:
- 这条消息什么时候开始发送
- 是哪个任务发起的
- 发送过程中是否发生过失败
- 最终状态是什么
6.5 一个简化的发送任务表可以怎么设计
发送任务表不一定一开始就要做得很复杂,但至少要能描述“任务是谁、发给谁、发得怎么样”
| 字段名 | 类型 | 作用 |
|---|---|---|
id | BIGINT | 任务主键 ID,唯一标识一次发送任务 |
message_id | BIGINT | 关联的消息 ID,表示这次任务发送的是哪条消息 |
task_type | VARCHAR(20) | 任务类型,例如个人通知、系统公告、强制确认通知 |
target_type | VARCHAR(20) | 目标人群类型,例如单用户、用户分群、全站用户 |
task_status | VARCHAR(20) | 当前任务状态,例如初始化中、执行中、部分成功、成功、失败 |
total_count | BIGINT | 预计需要处理的总人数 |
success_count | BIGINT | 已成功处理的人数 |
fail_count | BIGINT | 已失败处理的人数 |
retry_count | INT | 当前任务已重试次数 |
next_retry_time | DATETIME | 下次计划重试时间 |
last_error_msg | VARCHAR(1000) | 最近一次失败原因 |
created_at | DATETIME | 任务创建时间 |
updated_at | DATETIME | 任务更新时间 |
补充说明:
message_id用来关联消息内容task_status用来描述发送过程的整体状态total_count、success_count、fail_count用来跟踪任务进度retry_count和next_retry_time用来支撑自动重试last_error_msg用来辅助失败排查
6.6 为什么要做重试机制
不是所有失败都值得重试,但对于“临时性失败”,重试是很有必要的
适合重试的场景通常有:
- 数据库瞬时抖动
- MQ 消费异常
- 批量写入超时
- 网络波动
- 推送服务短暂不可用
这类问题往往不是业务逻辑错误,而是运行时问题,稍后重试有较大概率恢复成功
但像下面这些问题,一般就不适合盲目重试:
- 参数非法
- 用户不存在
- 消息模板错误
- 人群规则配置错误
- 唯一约束冲突且没有幂等控制
这类问题如果不先修复根因,重试多少次都没有意义
6.7 全部失败和部分成功,应该怎么处理
批量发送场景中,不能简单地把结果只分成“成功”或者“失败”
全部失败
例如:
- 发送任务创建了,但任务根本没执行
- 第一批接收人就插入失败
- 主消息生成成功,但一条收件记录都没落下来
这种情况通常应该:
- 将任务状态标记为失败
- 记录失败原因
- 进入重试或人工补偿流程
部分成功
例如:
- 一共要发送 100 万用户
- 已成功写入 80 万
- 剩余 20 万由于数据库超时而失败
这种情况下,如果直接整体回滚,代价很高 如果直接判定为成功,又会丢数据
更合理的做法是:
- 将任务状态标记为部分成功
- 记录当前处理进度
- 记录失败批次和失败原因
- 后续只补偿失败部分,而不是整批重跑
这一点非常关键 否则一旦全量重试,就会导致重复写入、重复发送、数据库压力进一步放大
6.8 重试时最重要的是幂等
只要涉及重试,就必须考虑幂等性
否则就会出现:
- 同一条消息发给同一个用户多次
- 已成功部分在重试时再次插入
- 用户收到重复消息
常见做法包括下面几种
1. 数据库唯一约束
例如在个人通知的收件表中增加:
UNIQUE KEY (message_id, user_id)
这样即使任务重复执行,也不会插入多条相同记录
2. 业务幂等键
例如:
biz_type + biz_id + user_idtask_id + user_id
用于标识“这条业务消息对这个用户是否已经处理过”
3. 批次级幂等
如果接收人是分批展开、分批写入的,那么每一个批次最好也有自己的处理标识,便于断点续跑和局部补偿
6.9 不同业务场景下的处理重点
个人通知
这类消息通常和订单、支付、审批等核心业务强相关,所以要求最高
重点在于:
- 主表和收件表尽量放在同一事务中
- 失败后要有补偿能力
- 必须保证幂等,避免重复发送
系统公告
这类消息的重点不是“给每个人都实时推到”,而是“公告发布后用户可查询”
因此更合理的做法是:
- 公告主表发布成功即可视为主链路成功
- 实时提醒失败不影响公告有效性
- 已读记录写入失败可以补偿,但不应阻塞用户查看公告
强制确认型通知
这类消息最严格,因为它往往要求审计和可追踪
重点在于:
- 必须保存逐用户状态
- 必须支持失败补偿
- 必须区分全部失败和部分成功
- 必须能拉出未确认名单
6.10 这一节的核心结论
站内信系统不仅要设计“怎么发”,还必须设计“失败了怎么办”
尤其是批量通知、强制确认通知这类场景,一定要重点考虑:
- 全部失败如何处理
- 部分成功如何补偿
- 重试是否具备幂等性
- 任务进度是否可追踪
- 是否支持人工介入和审计排查
可以把整个设计思路总结成一句话:
只要“发送过程本身”已经变成一个独立问题,就应该把它抽象成任务来管理
也只有这样,失败重试、部分成功补偿、人工介入、审计追踪这些能力,才能真正落地