通知系统看着不起眼,直到你想起:一个产品事件——一次新的点赞、一条物流更新、一次风控告警——可能需要在数秒内、跨四个不同渠道(push、SMS、email、in-app)、经由你无法控制的外部提供商,触达数千万用户,同时还要尊重每个用户的退订和免打扰时段、绝不重复发送同一条消息、并在不可避免的 APNs 或 FCM 抖动下存活。难的部分不是那次"发一封邮件"的调用;而是从一个事件到数百万次投递的扇出、at-least-once 加幂等的投递语义、第三方故障处理,以及让你不至于骚扰用户的偏好与限流逻辑。这些做错,你要么丢掉关键告警,要么在凌晨三点用一场重试风暴把整个用户群叫醒。

⚡ 速览要点
  • 把触发和投递解耦——生产者写一条事件;消息队列吸收尖峰;扇出 worker 把它展开成每个 (user, channel) 的任务。
  • 每渠道一个 worker 池——push、SMS、email、in-app 各有自己的队列、提供商适配器、限流和重试策略。
  • at-least-once + 幂等消费者——跨 APNs/Twilio/SES 的 exactly-once 不可能;改为对 notification_id 在 Redis 里去重。
  • 偏好在扇出路径里检查——每分类开关、用户时区的免打扰时段、频率上限,都在任务入队前应用。
  • 重试带退避 + DLQ——瞬时 5xx/429 用指数退避加抖动重试;永久失败(无效 token)清除;耗尽的任务进死信队列。
  • 模板服务——内容存在带版本、本地化的模板里,发送时渲染;生产者传 ID 和变量,而非原始字符串。
  • 可观测性是一等公民——每条通知带一个 ID,从触发 → 队列 → 提供商 → 投递回执全链路追踪,并有每渠道的漏斗指标。
tldr

把系统拆成:通知 API、扇出/偏好层、每渠道的队列与 worker,以及提供商适配器(APNs、FCM、Twilio、SES)。用消息队列把突发触发和稳定投递解耦。投递做成 at-least-once,消费者经一个 Redis 去重键做到幂等。在入队前应用用户偏好、免打扰时段和频率上限。瞬时提供商错误带退避重试,耗尽的任务路由到 DLQ。内容从带版本的模板渲染,并对每条通知做端到端追踪。

第 1 步 — 澄清需求

通知系统是经典面试题,因为天真答案("循环里调 SendGrid")一旦引入规模、多渠道和可靠性就立刻崩溃。先把范围钉死。

功能需求

  • 四个渠道发送:移动 push(iOS/Android)、SMS、email,以及 in-app(客户端轮询或经 websocket 接收的信箱/feed)。
  • 支持触发式(交易类、事件驱动:OTP、订单已发货)和定时/广播式(对某个人群的营销活动)通知。
  • 尊重用户偏好:每分类的渠道开关、免打扰时段、频率上限,以及全局退订。
  • 模板渲染内容,带每语言变体和运行时变量。
  • 提供投递状态:已入队、已发送、已投递、失败、已打开(在提供商上报的范围内)。

非功能需求

  • 规模——应对突发扇出:一次广播可能产生 5000 万+ 投递;稳态是每小时数百万次交易类发送。
  • 低触发延迟——接收通知请求的 API 在 <100 ms 内返回;实际投递异步进行。
  • 可靠性——关键通知(OTP、安全)绝不能被静默丢弃;目标是 at-least-once 投递加幂等。
  • 无重复——即便管道重试,用户也不该收到同一条逻辑通知两次。
  • 可扩展性——新增一个渠道(如 WhatsApp)不应需要重写核心。
面试提示

尽早把交易类营销类分开。它们的优先级正相反:交易类通知绕过营销偏好、要求 at-least-once 投递、跑在低延迟车道;营销类被严格限流、完全尊重退订、可容忍数秒到数分钟的延迟。用一条把两者一视同仁的管道是个危险信号。

第 2 步 — 容量估算

估算告诉你瓶颈在哪——它几乎从不是数据库;而是队列吞吐和提供商的限流。

流量

  • 假设 1 亿 DAU、平均每用户每天 5 条通知 → 每天 5 亿条 ≈ ~5,800/秒 均值。
  • 峰值很尖:一次对 5000 万用户的营销广播在几分钟内打出。若想在 10 分钟内排空,即 5000 万 / 600 秒 ≈ ~83,000 投递/秒 持续——设计必须把均值的 10–20 倍作为突发吸收掉。
  • 渠道占比不均:push 占主导(≈70%)、in-app(≈20%)、email(≈8%)、SMS(≈2%,但最贵且限流最严)。

存储

  • 通知日志记录:~300 字节(各 id、channel、status、时间戳、template_id)。5 亿/天 × 300 B ≈ ~150 GB/天——这进一个可水平扩展、带 TTL(如 30–90 天)的存储,而不是单台 SQL。
  • 设备 token:1 亿用户 × ~2 设备 × ~100 B ≈ ~20 GB——很小、很热,放在快存储和缓存里。
  • 偏好:1 亿用户 × ~200 B ≈ ~20 GB——每次扇出都要读,所以在 Redis 里激进缓存。
备注

应该驱动设计的数字不是存储;而是那 83K/秒的突发,以及提供商会限流你这个事实。APNs、FCM、Twilio、SES 都施加限流。你的队列存在的意义恰恰是把 83K/秒的突发重塑成提供商愿意接收的稳定流,而不是让你被全局限流或拉黑。

第 3 步 — API 设计

对外接口很小。生产者描述发什么、发给;系统决定怎么发何时发

HTTP
# 发送通知(交易类)
POST /api/v1/notifications
     Authorization: Bearer <service-token>
     Idempotency-Key: "order-9182-shipped"   // 边缘去重
     { "user_id": "u_123",
       "template_id": "order_shipped_v3",
       "channels": ["push", "email"],   // 可选;否则用偏好
       "variables": { "order_id": "9182", "eta": "Jun 30" },
       "priority": "transactional" }
     → 202 { "notification_id": "ntf_8f2a", "status": "accepted" }

# 对一个人群广播(异步,返回任务句柄)
POST /api/v1/broadcasts
     { "segment_id": "seg_us_active", "template_id": "promo_summer",
       "priority": "promotional", "send_at": "2026-07-01T16:00:00Z" }
     → 202 { "broadcast_id": "bc_55", "status": "scheduled" }

# 查询投递状态
GET /api/v1/notifications/{id}
     → { "status": "delivered", "channel": "push",
         "attempts": 1, "delivered_at": "..." }

# 更新用户偏好
PUT /api/v1/users/{id}/preferences
     { "marketing": { "push": false, "email": true },
       "quiet_hours": { "start": "22:00", "end": "08:00", "tz": "America/New_York" } }

两个设计要点。第一,Idempotency-Key 头让调用方可以安全地重试触发——如果订单服务崩溃并重新 POST "order-9182-shipped",API 返回同一个 notification_id 而不是发两次。第二,端点返回 202 Accepted,而非 200:投递是异步的,所以 API 的唯一职责是校验、去重、持久化请求并入队。所有昂贵的事都在下游发生。

第 4 步 — 高层架构

系统是一条由队列连接的、各阶段解耦的管道。核心原则:触发路径便宜且快;投递路径并行且吸收故障。

flow
生产者(订单服务、社交服务、风控服务、活动工具)
   │  POST /notifications  |  POST /broadcasts
   ▼
[ 通知 API ] ── 校验、边缘去重(Idempotency-Key)、持久化
   │  发布 1 条事件
   ▼
[ Kafka: "notification-events" ]   ← 吸收突发
   │
   ▼
[ 扇出 / 偏好 worker ]
   ├── 展开受众(1 事件 → N 用户)
   ├── 加载偏好 + 设备 token(Redis)
   ├── 丢弃 退订 / 免打扰 / 超频率上限
   └── 每个 (user, channel) 产出 1 个任务
   ▼
[ 每渠道队列 ]   push | sms | email | in-app
   │             │         │        │
   ▼             ▼         ▼        ▼
[ Push wkr ] [ SMS wkr ] [ Email ] [ In-app wkr ]
   │  APNs/FCM   │ Twilio   │ SES     │ 写信箱 + websocket 推送
   ▼             ▼          ▼         ▼
[ 提供商适配器 ] ── 重试/退避、限流、耗尽进 DLQ
   │  投递回执 / webhook
   ▼
[ 状态存储 + 可观测性 ]  (逐条通知追踪、漏斗指标)

为什么每个阶段之间都要队列?因为每个阶段都有不同且独立的故障模式和吞吐上限。扇出 worker 展开受众的速度远快于 Twilio 接收 SMS 的速度;每个渠道前的 消息队列 就是那个减震器,让快阶段全速跑、而慢的、被限流的提供商以自己的节奏排空。Kafka 还给你可重放性:如果 email worker 有 bug,你修好后从 offset 重新处理,不丢事件。

第 5 步 — 渠道适配器:APNs、FCM、SMS、Email、In-App

每个渠道本质上都不同。共享的抽象是一个渠道适配器接口——send(job) → {delivered | retryable_error | permanent_error}——但实现差异巨大。

渠道提供商传输关键约束
iOS pushAPNsHTTP/2 长连接,基于 token(JWT)鉴权单连接多路复用;410 = token 过期 → 清除
Android pushFCMHTTP/2 REST,OAuth 服务账号批量最多 500;UNREGISTERED → 清除 token
SMSTwilio / SNSHTTPS REST昂贵;每号码限流严格;运营商过滤
EmailSES / SendGridHTTPS / SMTP信誉/退信管理;DKIM/SPF;异步 webhook
In-app内部DB 写 + websocket无第三方;用户在线则投递,否则存信箱

Push:APNs 与 FCM 细节

Push 是量最大的渠道,运维上也最微妙。APNs 使用长连的 HTTP/2——你用一个提供商 JWT(有效约 1 小时,刷新)鉴权一次,然后在同一连接上多路复用成千上万次发送,而不是每条消息都重连。FCM 允许一次调用批量最多 500 条消息。两者都返回逐 token 结果:成功、可重试错误(提供商 5xx、连接重置),或永久错误(410 Gone / BadDeviceToken / UNREGISTERED)。永久错误是 token 管理信号,不是重试信号——worker 发出一条"清除此 token"事件,这样未来的发送绝不会把一个名额浪费在死设备上。

In-app:在线 vs 离线

In-app 是你完全掌控的那个渠道。worker 把通知写进用户的信箱表(这样离线也能保留),然后——如果用户有一条活的 websocket 连接——实时推过去。如果离线,信箱那行就够了;客户端下次打开时拉取未读项。这种双写(持久信箱 + 尽力而为的实时推送)让 in-app 既感觉即时、又不丢掉离线用户的通知。

可扩展性

适配器接口是让核心保持稳定的接缝。新增 WhatsApp 或 web-push 渠道,意味着写一个新适配器和一个新队列——扇出、偏好、去重、重试和可观测层都不动。抵制把渠道逻辑特判进扇出 worker 的诱惑;把它推进适配器。

第 6 步 — 扇出问题

扇出是系统的心脏:把条触发事件变成 N 个每 (user, channel) 的投递任务。两种形态,成本剖面截然不同。

触发式扇出(1 → 少数)

一个交易事件面向一个用户(或一小撮)。扇出很便宜:加载用户偏好和设备 token、决定渠道、产出寥寥几个任务。这条路径对延迟敏感,跑在专用的高优先级车道上。

广播式扇出(1 → 数百万)

一次活动面向 5000 万用户的人群。在一个 worker 里做这事不可能——它会跑几个小时,任何崩溃都丢失进度。模式是带检查点的分层扇出:

  • 广播被切成分片(如每片 1 万用户),通过游标翻页该人群。每个分片成为它自己的一条消息。
  • 许多扇出 worker 并行消费分片;每个把它的 1 万用户展开成每渠道任务。
  • 进度按分片做检查点,这样一个 worker 崩溃只重做一个 1 万的分片,而不是整个 5000 万广播——并且因为消费者幂等,重做一个分片不会重复发送。

这在结构上和你在 news feed 里看到的写时扇出 vs 读时扇出的张力是一样的:提前预计算投递(写放大,发送时快)对比读时计算。对通知来说答案几乎总是写时扇出——一条通知只有被推送才有意义,所以没有"读时"可供推迟。

扩展性备注

让广播车道和交易车道物理隔离——不同 topic、不同 worker 池、不同优先级。一次 5000 万的营销轰炸绝不能拖延一个 2FA 验证码。在持续负载下,光靠优先级队列不够;隔离 worker 池才能保证关键车道有预留容量。

第 7 步 — 模板服务与个性化

生产者绝不能发原始字符串。如果订单服务硬编码 "Your order shipped!",你就无法本地化、无法 A/B 测试,也无法在不跨所有生产者发版的情况下改一个错字。一个模板服务把内容集中化。

  • 带版本的模板——order_shipped_v3 是一个不可变、经评审的产物。生产者引用模板 ID 加一个变量映射;渲染在发送时发生。
  • 每语言变体——每个模板有本地化的正文;渲染器按用户 locale 选择,回退到默认。
  • 每渠道渲染——同一条逻辑通知按渠道渲染不同:40 字符的 push 标题、完整 HTML 邮件、160 字符 SMS。模板打包所有渠道的呈现。
  • 安全插值——变量按渠道转义(邮件 HTML 转义、SMS 防注入)以防内容注入 bug。
YAML
id: order_shipped_v3
category: transactional
locales:
  en:
    push:  { title: "Shipped!", body: "Order {{order_id}} arrives {{eta}}" }
    email: { subject: "Your order is on the way", html: "templates/shipped.html" }
    sms:   { body: "Order {{order_id}} ships, ETA {{eta}}. Track: {{url}}" }
  zh:
    push:  { title: "已发货!", body: "订单 {{order_id}} 预计 {{eta}} 送达" }

第 8 步 — 用户偏好与退订

偏好在扇出路径里、在任务入队之前检查——绝不先发了再过滤。偏好服务针对给定的 (user, category, channel) 回答:此刻允许吗?

  • 每分类、每渠道开关——用户可能允许安全邮件但关掉营销 push。存为一个 (category × channel) 的布尔矩阵。
  • 免打扰时段——用户睡眠窗口内不发非关键通知,按他们的时区评估。在本地 03:00 生成的通知,要么被丢弃(营销),要么推迟到早晨(摘要)。
  • 频率上限——"每天最多 3 条营销 push"。用一个每 (user, category, day) 的 Redis 计数器执行,由扇出 worker 递增。
  • 全局退订 + 合规——已退订用户被硬过滤。邮件必须遵守一键退订(RFC 8058);SMS 必须遵守 STOP。
关键覆盖

交易类和安全关键通知(OTP、新设备登录、风控告警)绕过营销偏好和免打扰时段。偏好检查必须按分类分支:营销类硬过滤;关键类无论退订与否都投递(用户无法对自己的 2FA 验证码退订)。把这个区分编码错了,既是产品失败,对某些分类也是安全失败。

第 9 步 — 限流与去重

两个不同的关切,二者都既保护用户也保护提供商。

限流

有三个层级,好答案会把三个都点到。每用户频率上限(产品层:别烦用户)。每提供商限流(运维层:超出配额 APNs/Twilio/SES 会限流或封你),用每个适配器前的一个令牌桶执行,让 worker 池自我节流到提供商的上限。每租户限流,如果你是服务多个内部团队的平台,这样一个团队失控的活动不能饿死另一个。这正是 设计限流器 里深入讲的那套机制——一个由 Redis 支撑的分布式令牌桶或滑动窗口。

去重

去重是让 at-least-once 投递可被接受的关键。同一条逻辑通知可能多次到达消费者——生产者重试了触发、Kafka 在再平衡后重投、或一个广播分片在崩溃后被重新处理。消费者必须把这些收敛成一次发送:

Python
def deliver(job):
    # 去重键在多次重试间是确定性的
    key = f"dedup:{job.notification_id}:{job.user_id}:{job.channel}"
    # SETNX 若键已存在返回 False → 已发送过
    first_time = redis.set(key, 1, nx=True, ex=86400)
    if not first_time:
        metrics.incr("dedup.suppressed")   # 丢弃了一个重复
        return
    result = adapter.send(job)        # 调 APNs / Twilio / SES
    if result.permanent_error:
        redis.delete(key)             # 放行一次修正后的重试
        prune_token(job)

去重键是 notification_id + user_id + channel,所以同一条通知在 push 和 email 上都会发出,但一个被重投的 push 任务被抑制。TTL(24 小时)限定内存,同时覆盖任何现实的重试窗口。要避免的微妙 bug:在成功发送之后才设去重键,意味着发送和设键之间的崩溃会重发;在之前设而永不在永久错误时清掉,意味着一个可修复的失败被永远错误地抑制。上面的顺序——先抢键,只在永久错误时释放——把两者都处理了。

第 10 步 — 重试、DLQ 与投递保证

在规模下提供商不断失败:瞬时 5xx、连接重置、429 限流。重试策略必须区分失败类别,且绝不无限重试。

结果例子动作
成功2xx,提供商已接收标记 sent;等待投递回执 webhook
可重试5xx、429、超时、连接重置指数退避 + 抖动,封顶约 5 次,然后 DLQ
永久410 Gone、无效 token、硬退信、STOP不重试;清除 token / 抑制地址;记录

带抖动的指数退避(1s、2s、4s… 再加随机化)防止一次提供商抖动变成同步的重试风暴——所有 worker 步调一致地重试、再次压垮正在恢复的提供商。超过尝试上限后,任务移到一个死信队列——一个单独的 topic,耗尽的任务落在那里供排查、告警,以及在根因修复后可选地手动或自动重放。DLQ 把"我们在 APNs 宕机期间静默丢了 20 万条 OTP"变成"20 万条任务停在 DLQ 里,APNs 恢复后自动重放"。

为什么是 at-least-once,而非 exactly-once

对外部提供商做 exactly-once 投递无法实现:你调用 Twilio、它发出了 SMS 之后,回给你的确认可能丢失。你分不清"已发但确认丢了"和"没发",所以一个安全的系统会重试——这意味着 SMS 可能发两次。诚实、标准的答案是传输上 at-least-once 加幂等消费者:你接受管道可能尝试发送不止一次,并通过第 9 步的去重键让重复尝试无害。这在用户视角下收敛成 effectively-once,而不去声称一个提供商给不了你的保证。

投递语义

把它说清楚:at-least-once 投递 + 幂等消费者 = effectively-once 的用户体验。"exactly-once"是你内部处理的属性(Kafka offset、去重键),绝不是那一跳第三方的属性。声称端到端经 APNs 做到 exactly-once 是经典面试错误。

第 11 步 — 数据模型

三个存储,各自为其访问模式而选:设备 token 和偏好是热 key-value 读;通知日志是高吞吐追加。

SQL
-- 设备 token(热 KV;在 Redis 缓存)
CREATE TABLE device_tokens (
  user_id    BIGINT,
  platform   VARCHAR(10),       -- ios | android | web
  token      VARCHAR(255),
  last_seen  TIMESTAMP,
  PRIMARY KEY (user_id, platform, token)
);

-- 偏好(每次扇出都读;激进缓存)
CREATE TABLE preferences (
  user_id    BIGINT      PRIMARY KEY,
  matrix     JSONB,                 -- {category: {channel: bool}}
  quiet_start TIME, quiet_end TIME, tz VARCHAR(40),
  unsubscribed BOOLEAN  DEFAULT FALSE
);

-- 通知日志(高吞吐追加;NoSQL / Cassandra,TTL 90d)
-- 按 user_id 分区,按 created_at 倒序聚簇
-- { notification_id, user_id, channel, template_id, status,
--   attempts, created_at, sent_at, delivered_at, error_code }

通知日志写多、按"该用户最近的通知"查询——非常适合一个按 user_id 分区、带 TTL 的宽列 NoSQL 存储,而不是一张需要不断 分片 和清理的关系表。设备 token 和偏好很小、且在热扇出路径上读,所以放在前面挡着 Redis(短 TTL)的快存储里;一次 token 写(新设备登录)使缓存项失效。

第 12 步 — 扩展与容错

每个阶段都水平扩展,并独立降级。

扩展

  • worker 无状态——扇出和渠道 worker 是 Kafka 消费组;加消费者即加吞吐,分区数按峰值并行度设定。在需要保序处(如"已发货"先于"已送达")按 user_id 分区以保留每用户顺序。
  • 渠道池独立扩展——push 需要数百个 worker;SMS 只需少数,因为 Twilio 本就限流。各池按自己的队列深度自动伸缩。
  • 提供商连接池化——APNs 的 HTTP/2 连接和 FCM 的批量在多次发送间复用;每 worker 一个连接池摊薄 TLS/握手成本。

容错

  • 提供商宕机——重试 + DLQ 吸收它;受影响渠道在它的队列里堆积,其他渠道照流。提供商恢复后,排空积压并重放 DLQ。关键:队列要有足够保留期来撑住一次数小时宕机量的任务。
  • worker 崩溃——Kafka 把未提交消息重投给另一个消费者;幂等去重让重投无害。
  • Redis(去重/缓存)故障——优雅降级:去重存储宕机时,对非关键渠道可以 fail open(冒几条重复的风险)而不是停止投递;偏好以更高延迟回退到源 DB。
  • 毒消息——一个总是抛错的畸形任务被尝试上限捕获并路由到 DLQ,而不是永远阻塞分区。

第 13 步 — 可观测性

你无法运维一个看不见的通知系统。"用户收到 OTP 了吗?"必须能在数秒内回答。可观测性 围绕逐条通知的追踪和投递漏斗来构建。

  • 端到端追踪——每条通知带一个在 API 打上、并贯穿事件、扇出、队列、worker 和提供商调用的 ID,这样你能重建它的完整时间线:accepted → enqueued → sent → provider-acked → delivery-receipt。
  • 每渠道漏斗指标——accepted、suppressed(偏好/去重)、sent、delivered、failed、opened。push 的 delivered/sent 比突然下降是 APNs 问题;suppressed 突增是偏好或去重异常。
  • 投递回执 / webhook——提供商异步上报最终状态(SES 退信、APNs 反馈、Twilio 状态回调)。摄入这些,把"已发送"到"真的投递/退信"的闭环补上,并喂给 token 清除和退信抑制。
  • 告警——对 DLQ 增长率、每提供商错误率和队列滞后告警,而不只是绝对量。一个增长的 DLQ 是提供商或 token 问题的最早信号。
备注

投递回执是"我们发了"和"他们收到了"之间的区别。一条 push 可能被 APNs 接收,却始终到不了手机(设备关机、token 静默失效)。不摄入回执和反馈,你的仪表盘就在撒谎——它显示 100% "已发送",而真实投递率在悄悄下滑。

第 14 步 — 关键取舍

决策选择接受的取舍
投递语义at-least-once + 幂等可能重复尝试,由去重键抑制;跨提供商的 exactly-once 不可能
触发 vs 投递经队列完全解耦增加端到端延迟(亚秒到数秒),换来巨大吞吐和隔离
扇出时机写时扇出广播时写放大;通知没有读时路径
渠道耦合每渠道一队列 + 一适配器更多活动部件;换来独立的扩展、重试和限流
偏好检查在扇出、入队前热路径上多一次读(已缓存),换来不在被过滤的发送上浪费提供商配额
通知日志宽列 NoSQL + TTL无即席 join;为每用户追加和时间范围读而优化
要点

通知系统是一道关于受控扇出和优雅失败的练习。脊梁是一个把突发触发和被限流的提供商解耦的队列;可靠性来自由幂等、带去重键的消费者保护下的 at-least-once 投递;产品正确性来自在入队前检查偏好、并把交易类通知当作一条单独的、绕过偏好的车道。其余一切——模板、重试、DLQ、可观测性——存在的意义,都是让这三件事在规模下运转,而不骚扰用户、也不丢掉那一条真正重要的 OTP。

🎯 interview hot-takes

怎么把一条通知扇出给数百万用户而不阻塞? 用消息队列把触发和投递解耦。触发服务只写一条事件;扇出 worker 展开受众,为每个 (user, channel) 入队一个任务到 Kafka。成千上万的渠道 worker 并行消费并调用 APNs/FCM/SMS/email 提供商。触发的 API 调用毫秒级返回,因为它只写一条事件,而非数百万次发送。
at-least-once 还是 exactly-once 投递,你选哪个? 选 at-least-once。跨第三方提供商(APNs、Twilio、SES)做 exactly-once 不可能,因为它们已经投递后回给你的确认可能丢失。所以在传输上接受 at-least-once,并让消费者幂等:一个去重键(notification_id + user_id + channel)存在 Redis 里并带 TTL,在重复事件到达提供商前就丢弃它。
怎么处理第三方提供商(APNs/FCM)宕机或被限流? 对瞬时 5xx/429 用指数退避加抖动重试,封顶几次;超过上限后把任务路由到死信队列以便重放。用每提供商一个令牌桶遵守它的限流,这样你不会被全局限流。永久错误(无效 token、410 Gone)不重试——token 被标记为失效并清除。
怎么在规模下尊重用户偏好和免打扰时段? 在扇出路径上、任何任务入队之前先咨询偏好服务。它存每用户、每分类的渠道开关、用户时区下的免打扰窗口和频率上限,缓存在 Redis 里实现个位数毫秒读取。营销类分类被硬性检查;关键交易类通知(安全告警、OTP)绕过非必要偏好。
怎么防止同一条通知发两次? 两层幂等:生产者带上确定性的 notification_id(事件 + 用户 + 模板的哈希);消费者在发送前对 Redis 去重键做 SETNX,若已存在就跳过。再配合频率上限,把重复触发和重试风暴收敛成一条被投递的消息。

← 上一篇
设计一个 News Feed