设计一个消息通知系统(站内信、短信、推送)
一句话回答
做一个统一的通知服务:业务方只提交"发给谁、用哪个模板、什么参数、业务唯一键",通知服务负责模板渲染、用户偏好和免打扰、频控和去重、渠道路由,再按渠道和优先级投递到消息队列,由各渠道的发送 worker 异步调用第三方(短信、邮件、APNs/FCM 推送)。失败按指数退避重试,不可重试的错误直接放弃,供应商故障时切换备用供应商或降级到其他渠道。验证码、交易通知和营销消息分队列隔离,互不挤占。站内信的个人消息写收件箱,全员广播用拉模式。每条消息都有发送记录,结合供应商回执追踪到送达和已读。
详细解析
第一步:澄清需求
- 渠道:站内信、App 推送、短信、邮件,以后可能接入更多
- 消息类型:验证码(要秒级送达)、交易通知(发货、支付成功)、营销(活动推广)、系统公告(全员)
- 要求:验证码和交易通知不能被营销挤占;用户能关闭某类通知、设置免打扰;不能轰炸用户;发送结果可查
量级估算(以下数字都是假设):注册用户 1 亿,日活 2000 万;每天站内信 1 亿条、推送 5000 万条、短信 200 万条、邮件 500 万条。
- 站内信平均 1 亿 / 86400 ≈ 1160 条/秒,写入集中在白天,峰值更高
- 一次营销活动要在 1 小时内推送给 1000 万人:1000 万 / 3600 ≈ 2800 条/秒,而第三方通道都有调用频率限制,发送端必须限速
- 站内信每条按 200 字节算,每天 20 GB,保留 90 天约 1.8 TB;一条全员公告如果给 1 亿人每人写一行就是 1 亿行,所以广播不能写扩散
第二步:整体架构
业务方(订单、营销、安全)──> 通知 API:鉴权、参数校验、按业务唯一键幂等
│ 写发送任务,投递到 MQ,立即返回"已受理"
▼
预处理 worker:展开收件人 → 用户偏好、免打扰 → 去重、频控 → 渲染模板 → 选渠道
│ 按渠道和优先级分到不同队列
┌──────────────┬──────────┴───────┬────────────────┐
站内信队列 推送队列 短信队列 邮件队列
│ │ │ │
写收件箱表、 APNs、FCM、 短信供应商 A / B 邮件服务
更新未读数 厂商推送通道 (限速、熔断、切换)
│ 回执回调、主动查询
▼
发送记录表:状态追踪,供业务方和客服查询
第三步:核心模块和数据模型
模板:模板编码 + 渠道 + 内容(带占位符)+ 版本,业务方只传模板编码和参数,文案修改不用改代码。国内短信通常要先在服务商那里审核签名和模板,所以自己的模板要和各家供应商的模板 ID 做映射。
渠道路由:每类消息配置渠道策略。重要的安全提醒可以多渠道同时发;一般通知按优先级降级,比如先发推送,用户没开启推送或长时间没打开 App,再发短信。
CREATE TABLE notify_record ( -- 每个用户、每个渠道一条发送记录
id BIGINT PRIMARY KEY,
biz_key VARCHAR(64) NOT NULL, -- 业务唯一键,如 order_shipped:订单号
user_id BIGINT NOT NULL,
channel VARCHAR(16) NOT NULL, -- inbox / push / sms / email
template_code VARCHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL, -- PENDING / FILTERED / SENT / DELIVERED / FAILED
reason VARCHAR(64), -- 被过滤或失败的原因:免打扰、超频、号码无效……
provider VARCHAR(32), -- 实际使用的供应商
provider_msg_id VARCHAR(64), -- 供应商返回的消息 ID,用来匹配回执
retry_count INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL, sent_at DATETIME,
UNIQUE KEY uk_biz (biz_key, user_id, channel) -- 同一事件、同一用户、同一渠道只发一次
);
-- 另有 template、user_notify_setting(按分类和渠道的开关、免打扰时段)、user_device(推送 token)
站内信存储:
| 类型 | 存储方式 | 说明 |
|---|---|---|
| 个人消息(订单、回复) | 写扩散:每人一行,写进收件箱表 | 按 user_id 分片,按时间倒序游标分页 |
| 全员公告 | 拉模式:只存一条公告 | 用户打开消息中心时,拉取比自己"已读位置"更新的公告,合并展示 |
| 定向群发 | 人少时写扩散;人多时存成公告 + 目标人群条件 | 拉取时判断用户是否属于目标人群 |
和 Feed 流的推拉选择是同一个问题,见 Feed 流。未读数 = 个人消息的未读计数(Redis)+ 比已读位置新的公告数,打开消息中心时校正。在线用户的实时提醒通过长连接推给客户端,见即时通讯系统。
第四步:关键难点
优先级隔离:验证码、交易、营销各用独立的队列和 worker,营销一次发几百万条也不影响验证码。验证码消息带过期时间,排队超时就直接丢弃,过期的验证码发出去也没用。
重试和失败处理:
- 区分错误类型:超时、限流、5xx 可以重试;号码格式错误、用户已退订、推送 token 失效不重试。token 失效(如 APNs 返回 410,FCM 返回
UNREGISTERED)时删除这个 token - 重试用指数退避加随机抖动(见代码示例),有最大次数,超过后进入死信队列并告警
- 短信请求超时不等于没发出去,盲目重发用户会收到两条。重试沿用同一条发送记录,供应商支持的话先查询这条消息的状态再决定
- 某个短信供应商的失败率升高时熔断,切到备用供应商(见熔断和降级);推送不可达的重要通知降级为短信
去重、频控和合并:
- 去重:上游可能重复提交同一个事件,
biz_key唯一索引兜底,Redis 做前置过滤,见幂等 - 频控:按用户、按分类限制每天的条数(如营销消息每天最多 N 条);验证码按手机号限制每分钟、每天的次数,防止被刷
- 合并:短时间内的同类消息("某某赞了你")先进一个时间窗口,窗口结束时合并成一条"某某等 5 人赞了你"
- 免打扰:夜间的非紧急消息不丢弃,改成延时任务,早上再发(见延时任务);验证码、安全提醒不受免打扰限制
第三方限流:供应商对调用频率有限制,超出会被拒绝。按供应商配置全局令牌桶,放在 Redis 里让所有 worker 共享(见限流),拿到令牌才发送;营销的大批量发送按限速平滑发出,不要一次全部压上去。
状态追踪:发送记录的状态从"已受理"开始,经过"已过滤(原因)""已提交供应商""已送达 / 失败",站内信和推送还能追踪到"已读 / 已点击"。送达结果靠供应商的回执回调(按 provider_msg_id 匹配)或主动拉取。业务方按 biz_key 能查到一条通知的完整轨迹,客服排查"为什么没收到短信"也靠它。
第五步:扩展与优化
- 合规:营销消息要提供退订入口,尊重用户的关闭设置;验证码内容不写进日志
- 成本:短信最贵,能用推送和站内信解决的不用短信;统计各渠道、各供应商的送达率和单条成本,按效果分配流量
- 全员推送:按用户分批、限速发送,避免大量用户同时点开推送,把自己的服务打垮
- 数据:模板做 A/B 测试,看打开率和点击率;发送记录量大,按时间分区,过期归档
代码示例
去重和每日频控在一个 Lua 脚本里原子完成,外加退避时间的计算(Node.js + ioredis):
// KEYS[1] 去重 key,KEYS[2] 当天的计数 key;ARGV[1] 去重窗口(秒),ARGV[2] 每日上限,ARGV[3] 计数 key 的过期时间(秒)
const GUARD = `
if redis.call('EXISTS', KEYS[1]) == 1 then return 1 end
local n = tonumber(redis.call('GET', KEYS[2]) or '0')
if n >= tonumber(ARGV[2]) then return 2 end
redis.call('SET', KEYS[1], '1', 'EX', ARGV[1])
redis.call('INCR', KEYS[2])
if n == 0 then redis.call('EXPIRE', KEYS[2], ARGV[3]) end
return 0`
const RESULT = ['PASS', 'DUPLICATE', 'OVER_LIMIT']
export async function guard(redis, { userId, category, dedupKey, date, dailyLimit, dedupSeconds = 3600 }) {
// {userId} 是 hash tag,保证 Cluster 下两个 key 落在同一个槽
const keys = [`notify:dedup:{${userId}}:${dedupKey}`, `notify:freq:{${userId}}:${category}:${date}`]
const code = await redis.eval(GUARD, 2, ...keys, dedupSeconds, dailyLimit, 2 * 86400)
return RESULT[code]
}
// 指数退避 + 随机抖动:第 attempt 次重试前等待的毫秒数
export function backoff(attempt, baseMs = 1000, maxMs = 10 * 60_000) {
const cap = Math.min(maxMs, baseMs * 2 ** attempt)
return Math.round(cap / 2 + Math.random() * (cap / 2)) // 落在 [cap/2, cap] 之间,避免大家同时重试
}
面试官可能追问
业务方调用通知接口超时了,重试会不会让用户收到两条?
不会。通知接口只做校验和落库,按 biz_key 幂等:重复请求命中唯一索引,直接返回第一次的受理结果。真正的发送在后面异步进行,业务方拿到"已受理"就可以结束,不需要等短信发出去。业务方要知道最终结果,就按 biz_key 查询发送记录,或者订阅发送结果事件。
用户换了手机、卸载了 App,推送 token 怎么维护?
一个用户可能有多台设备,user_device 表按设备保存 token 和最后活跃时间。App 每次启动都上报 token,服务端更新时间戳;发送时收到 token 失效的错误就删除它;长时间没有更新的 token 视为过期,FCM 的文档也建议保存 token 的更新时间并定期清理过期的。用户退出登录时解绑设备,避免消息推到别人手机上。
全员公告用拉模式,未读数怎么算?
每个用户记一个"已读到的公告 ID",未读公告数 = 比这个 ID 大的公告条数。公告数量很少,全量列表可以缓存在 Redis 或本地内存里,对每个用户只是一次比较。用户打开消息中心后,把已读位置更新为最新的公告 ID。定向公告再按目标人群条件过滤一次。
营销消息一次要发给几千万人,怎么发?
不要一次性展开成几千万条消息塞进队列。按用户 ID 分段,分批展开收件人,每批走一遍偏好、频控过滤后再投递;发送端按供应商限速匀速发出,整体进度可以暂停、继续和取消。同时监控退订率和投诉率,异常时立即停止。
易错点
- 在业务请求里同步调用短信、推送接口,第三方一慢,下单这样的核心接口也跟着变慢
- 所有消息共用一个队列,营销群发一来,验证码排在后面几分钟才发出
- 失败了无差别重试:号码无效也一直重试,或者短信请求超时就重发,用户收到两条
- 频控用"先查计数、再发送、再加一",查和加不是原子的,并发时会超过上限
AI 模拟面试官
用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。