通知系统常被误解为“业务完成后插入一行 message”。真正上线后,它会同时面对:同一帖子一小时内收到几百个赞、评论回复要准确显示直接回复者、被提及用户可能拉黑作者、目标帖子可能在通知到达前删除、Socket 断线后未读数必须仍然正确、短信和邮件失败不能影响站内收件箱。
通知还连接了几乎所有领域:认证、用户关系、内容、互动、社群、媒体和系统安全。若每个模块自行拼标题、自行写通知表、自行推送,文案、权限、幂等和未读计数会迅速失控。
本文设计一条分层链路:领域事实 → 通知意图 → 规范化命令 → 收件箱事实 → 展示投影 → 实时增量 / 站外投递。每一层只承担一种职责,因此重复事件、目标删除和外部供应商故障都可以在清晰边界内处理。
适用范围与规模假设
该设计适用于同时拥有互动、提及、关注、社群和系统安全通知,并需要站内收件箱、未读分类、实时更新或邮件短信渠道的产品。若只有少量后台提醒,可以先合并命令与收件箱层,但仍应保留幂等键、目标实体和读取状态,避免把不可恢复的文案当作唯一数据。
一、先区分六种对象
1. Domain Event:发生了什么
例如:
PostLiked
PostCommented
CommentReplied
PostMentioned
UserFollowed
CommunityJoinRequestReviewed
MediaProcessingFailed
PasswordChanged
领域事件归源模块所有,描述已成立事实,不应包含最终通知文案。
2. Notification Intent:是否值得通知谁
源模块或通知边界把领域事实转换为意图:收件人是谁、动作人是谁、目标实体是什么、默认渠道是什么、是否需要站内收件箱。此时还未真正写入用户通知。
3. Normalized Command:跨模块稳定合同
不同来源统一成规范化命令:
type NotificationCommand = {
sourceEventId: string;
rootSourceEventId: string;
sourceModule: "auth" | "user" | "content" | "engagement" | "community" | "media";
commandName: string;
recipientUserId: string | null;
actorUserId: string | null;
category: "MENTION" | "INTERACTION" | "COMMUNITY" | "SYSTEM" | null;
entityType: "POST" | "COMMENT" | "USER_PROFILE" | "COMMUNITY" | "SYSTEM_EVENT" | null;
entityId: string | null;
targetPostId: string | null;
commentId: string | null;
preferredChannels: Array<"IN_APP" | "EMAIL" | "SMS">;
templateCode: string;
templateParams: Record<string, unknown>;
dedupeKey: string | null;
occurredAt: string;
};
4. Inbox Record:用户确实拥有哪条通知
这是强一致事实,保存 recipient、类型、实体引用、读取状态、归档状态和单调 streamSeq。站内未读数与它同事务更新。
5. Card Projection:页面如何展示
MongoDB 通知卡片投影包含 actor、实体摘要、社群、媒体预览、主副文案和目标状态。它可重建,不决定收件箱中是否存在通知。
6. Delivery Task:邮件和短信如何发送
站外投递有独立状态、重试、模板和供应商回执。邮件失败不回滚已经创建的站内通知。
二、总体架构

设计中的核心存储角色:
- PostgreSQL:Notification inbox、未读计数、fanout 任务、delivery task、outbox;
- MongoDB:通知卡片展示投影;
- Redis:查询缓存、Socket session/offset、锁与短期去重;
- Kafka:命令、通知创建、投影刷新、实时增量和投递任务传播。
通知系统应拥有自己的边界,不让内容服务直接写通知表。内容服务只产生事实或正式通知命令;通知服务读取设置、权限和模板,决定是否创建、如何展示、通过哪些渠道发送。
三、从领域事实到通知命令
1. 收件人必须由权威关系推导
CommentReplied 的收件人是直接父评论作者,而不是一律根帖作者;顶层评论的收件人通常是根帖作者。自己回复自己是否通知由产品规则决定,通常抑制。
提及通知需要从已解析 mention relation 读取目标 userId,而不是在通知 worker 中重新用正则猜 handle。handle 会修改,业务关系应绑定稳定 userId;展示时再读取当前 handle。
2. 命令规范化与严格校验
每种 commandName 对允许的类型、分类、实体、渠道和模板都有判别联合。例如媒体处理失败必须是系统分类、无 actor、实体为 MEDIA_ASSET;帖子点赞必须有 actor、recipient 和 POST 目标。
严格合同能阻止“有一条通用 New notification”进入系统。未知命令、非法字段组合或模板缺参应进入死信和告警,而不是用默认英文占位静默创建。
3. 偏好和权限在创建前过滤
过滤维度包括:
- 用户关闭某类通知或某渠道;
- 社群级公告偏好;
- actor 与 recipient 的拉黑关系;
- 目标是否仍允许收件人查看;
- 勿扰时段与计划发送时间;
- 安全类通知是否属于不可关闭的必达类型。
站内通知、邮件和短信的策略不必相同。密码修改、安全风险等可以强制邮件,普通点赞可能只进入站内并被聚合。
四、幂等:至少一次投递下只创建一次业务通知
Kafka、Outbox publisher 和 worker 都可能重试。收件箱创建应有两个层次的幂等键:
sourceEventId → 同一领域事实只消费一次
notificationKey / dedupeKey → 同一业务语义只生成一次或进入同一聚合桶
例如单次点赞:
notificationKey = POST_LIKED:{postId}:{actorUserId}:{recipientUserId}
如果取消点赞后再次点赞是否创建新通知,需要事件版本或互动关系 revision 加入 key。不要简单永远压制,因为那会吞掉真实新动作。
收件箱、未读计数、fanout/outbox 必须在一个 PostgreSQL 事务内完成:
BEGIN;
INSERT INTO notifications (...) ON CONFLICT DO NOTHING;
-- 仅当上一步实际插入时增加对应分类未读数
UPDATE notification_unread_counters SET ...;
INSERT INTO outbox_messages (... NotificationCreated ...);
COMMIT;
如果冲突表示已存在,不能再次增加未读数。
五、未读计数是业务不变量,不是缓存猜测
常见反模式是每次打开页面 COUNT(*) WHERE read_at IS NULL,或只在 Redis 中 INCR。前者在大收件箱上昂贵,后者丢缓存后难以恢复。
推荐维护 PostgreSQL 计数行:
type UnreadCounter = {
userId: string;
totalUnreadCount: number;
mentionUnreadCount: number;
interactionUnreadCount: number;
communityUnreadCount: number;
systemUnreadCount: number;
lastInboxSeq: bigint;
};
创建、批量已读、全部已读在事务中同步修改 inbox 与 counter。数据库约束保证各分类非负、总数等于分类和,lastInboxSeq 单调不退。
定期 reconciliation 任务可以从 inbox 重算计数并比对,修复历史异常。Redis 只缓存摘要,不成为唯一事实。
六、卡片投影:快照文案与当前实体的平衡
一条通知需要在两种诉求间取舍:
- 历史快照:用户当时为什么收到通知;
- 当前事实:点击目标现在是否存在、作者当前头像是什么。
推荐 Inbox 保存非敏感的最小渲染快照(模板 code、当时标题/正文、实体 ID),Card Projection 定期或按事件刷新当前展示:
type NotificationCardProjection = {
schemaVersion: 2;
notificationId: string;
recipientUserId: string;
streamSeq: string;
category: string;
type: string;
actor: {
userId: string | null;
handle: string | null;
displayName: string | null;
avatarUrl: string | null;
} | null;
entity: {
entityType: string | null;
entityId: string | null;
title: string | null;
excerpt: string | null;
actionUrl: string | null;
} | null;
primaryText: string;
secondaryText: string | null;
targetState: {
visibilityHint: "VISIBLE" | "MASKED" | "DELETED";
maskedReasonCode: string | null;
};
readAt: Date | null;
createdAt: Date;
};
目标不可用时如何展示
- actor 用户投影暂缺:使用稳定中性头像与“某位用户”,不要断言账号不存在;
- 帖子已删除:保留通知类型与时间,显示“相关内容已删除”,禁用或调整 action;
- 当前无权查看:显示“相关内容当前不可用”,不泄露摘要;
- 投影构建失败:查询可回退 Inbox 快照,并触发单卡重建。
通知卡片不可点,通常不是前端 onClick 一个问题,而是 actionUrl 与目标实体语义没有成为合同。每种类型必须定义 action builder,例如评论回复跳到根帖详情并定位 commentId,而不是把评论 ID 当用户 ID。
七、聚合:减少噪声,但不丢失事实
“A、B、C 等 25 人赞了你的帖子”比 25 张卡片更友好。聚合可以在展示层完成,但要保留每个原始通知事实,便于未读、撤销和审计。
聚合键示例:
(recipientUserId, type=POST_LIKED, targetPostId, timeBucket)
投影显示最近几个 actor 和总数。以下通知通常不宜聚合:
- 评论回复和提及:每条语义不同,需要可定位;
- 安全告警:必须逐条明确;
- 社群审核结果:每个请求有独立状态。
聚合窗口结束后形成新组;新动作到来可把组顶到列表顶部,但 streamSeq 与分页顺序必须有确定规则。
八、实时同步:Socket 是加速器,不是事实源
用户打开通知页时,需要先建立基线,再接收增量。一个稳健协议:
- HTTP 获取收件箱首屏与
lastInboxSeq; - Socket 连接携带已知 seq;
- 服务端返回 bootstrap/unread summary,并补发 seq 之后可用增量;
- 后续推送
NotificationCreated、NotificationReadChanged、UnreadSummaryChanged; - 客户端按 seq 去重,发现缺口则重新拉 delta 或全量摘要。

如果 Socket 先连、HTTP 后拉,可能重复;如果 HTTP 先拉、Socket 后连,可能漏掉中间窗口。seq 协议让两种顺序都可收敛。断线重连时也不能简单把本地未读数加一,而应以服务端摘要校正。
实时推送失败不影响 Inbox:用户刷新页面仍能看到通知。Socket provider 只负责尽力投递,并记录连接数、广播延迟、丢弃和重连。
九、已读 API 与并发语义
GET /api/notifications?category=mention&cursor=...
GET /api/notifications/unread-summary
POST /api/notifications/read-batch
POST /api/notifications/read-all
GET /api/notifications/realtime
批量已读输入 ID 列表,服务端只修改属于当前用户且仍未读的记录,并按实际变更分类扣减计数。重复调用应幂等。
“全部已读”最好带一个上界 throughSeq:
{ "throughSeq": "18233", "category": "ALL" }
这样请求发出后新到的通知不会被误标为已读。事务将 seq <= throughSeq 的目标标记并更新计数,然后发出摘要变更事件。
十、站外投递:独立状态机与敏感数据边界
邮件和短信任务建议状态机:
PENDING → CLAIMED → SENT
├─ RETRY_WAIT → CLAIMED
└─ DEAD_LETTER
任务记录渠道、模板、收件地址哈希、计划时间、尝试次数、下次重试和供应商 message ID。验证码等敏感参数不应进入普通日志、Kafka 可观察载荷或站内卡片;它们需经过专用安全通道和短 TTL。
指数退避可加入抖动:
delay = min(maxDelay, base * 2^attempt) + random(0, jitter)
永久错误(无效地址、模板拒绝)直接进入死信或关闭该渠道,临时错误(超时、限流)重试。供应商回执重复到达同样需要幂等。
十一、Fanout 与大规模广播
个人互动通常有一个收件人,可同步在 worker 事务内创建。社群公告或系统广播可能有大量收件人,应拆成 fanout job:
- 记录广播事实与 audience snapshot/version;
- 按稳定 userId 游标分批领取;
- 每批生成规范化命令或直接走同一创建服务;
- 保存 checkpoint、成功/跳过/失败数;
- 可暂停、重试和审计。
不能在一个事务里插入几百万条通知,也不能使用 offset 在成员持续变化的表上翻页。采用 keyset cursor,并明确受众是“发送时快照”还是“处理时动态集合”。
十二、可观测性
关键指标应贯穿全链路:
- 各源模块命令数、契约拒绝数和消费延迟;
- 创建成功、偏好抑制、权限抑制、自我通知抑制和幂等冲突;
- Inbox 最老未处理时间、未读 counter reconciliation 差异;
- 卡片投影缺失率、目标 VISIBLE/MASKED/DELETED 分布;
- 查询 P95、缓存命中率、卡片回退率;
- Socket 在线数、bootstrap/delta 延迟、seq gap 与重连;
- 邮件/SMS 成功率、供应商错误、重试和死信;
- 点击后目标不可用率。
日志统一携带 sourceEventId、notificationId、recipientUserId(按隐私规范脱敏)、streamSeq 和 traceId。排查一张“New notification”卡片时,应能从 inbox 追到命令、源事件、模板和投影构建结果。
十三、测试策略
- 重复
sourceEventId只创建一次,未读只增加一次; - 回复评论通知的 recipient 为直接父评论作者,action 能定位根帖与当前回复;
- actor 投影缺失不显示“用户不存在”;目标删除显示占位且不泄露摘要;
- 批量已读、全部已读和并发新通知下,分类和总未读不变量成立;
- HTTP bootstrap 与 Socket 增量在各种竞态下无漏无重;
- 聚合点赞保留原始事实,撤销/删除后总数可收敛;
- 偏好、拉黑、社群设置与不可关闭安全通知矩阵;
- 邮件限流、超时、永久错误和回执重复;
- 卡片投影全部删除后可从 Inbox 与 owner 实体重建。
十四、方案权衡
在 Inbox 保存渲染快照提高历史稳定性,却会保留旧显示名;完全动态渲染保持当前事实,但目标缺失时无法解释历史。混合方案通常最好:Inbox 保存非敏感模板快照,Projection enrich 当前 actor/实体,并明确目标状态。
聚合能降低噪声,却增加读取与已读语义复杂度;应从点赞等高频同质事件开始,不要一开始聚合所有类型。实时 Socket 提升体验,但必须建立在可恢复 HTTP 收件箱和 seq 上,绝不能让“在线时收到”成为唯一交付方式。
结语
通知系统不是一个消息表,而是跨领域事实转化为用户收件箱的可靠管道。它既要尊重每个源领域的语义,又要统一幂等、偏好、未读、展示、实时和站外渠道。
最值得坚持的边界是:领域事件描述事实,规范化命令描述通知意图,Inbox 保存用户拥有的通知,Projection 负责卡片,Socket 和邮件只是交付方式。目标失效时保留正确占位,重复消息下保持未读不变量,断线后仍能靠序列恢复——做到这些,通知才真正成为可信产品能力,而不是一串偶尔出现、偶尔点不开的卡片。