题干与适用场景
设计一个共享通知平台,供登录、订单、社交和营销系统调用。平台支持移动推送、短信、邮件和站内信;事务消息包括 OTP 与付款结果,批量消息包括活动提醒和营销触达。调用方可以立即发送或预约发送,用户可以按渠道和通知类型退订,营销消息还要遵守用户时区内的免打扰时间。
本题采用以下面试假设:
- 每日生成 10 亿个“渠道投递任务”。一条业务通知若同时走短信和邮件,会计为两个任务。
- 平均吞吐约为
1,000,000,000 / 86,400 ≈ 11,600个任务/秒;活动尖峰按平均值的 20 倍估算,约 232,000 个任务/秒。 - API 成功受理的可用性目标为 99.99%,受理延迟 p99 小于 200 毫秒。
- OTP 从受理到提交给渠道供应商的 p99 小于 2 秒;营销任务允许在 15 分钟内平滑发送。这两个目标都不承诺终端实际展示时间。
- 若一个投递任务的持久化信封平均为 1 KB,逻辑写入量约 1 TB/天;保留 30 天约 30 TB,尚未计入索引、副本、回执和压缩。
这些数值用于推导分区、积压和流量隔离,不代表任何供应商的性能。答案不展开模板编辑器、营销人群算法、计费和 A/B 测试。目标岗位是中高级后端、平台或系统设计岗位,核心问题是如何在外部渠道语义各异的情况下,守住优先级、可恢复性和真实可观测性。
面试官考察点
第一项信号是候选人会不会定义“发送成功”。API 返回 202 只表示平台已持久化受理;供应商返回成功通常表示它接受了请求;终端收到、系统展示和用户打开又是后续状态。Firebase 的发送指标可能只表示消息已排队或已交给 APNs;APNs 的成功响应对应单次请求成功。把这些状态都写成 delivered,运营指标和故障处置都会失真。
第二项信号是能否用资源隔离落实优先级。一个带优先级字段的共享队列仍可能被 5,000 万人营销活动占满磁盘、消费者连接和供应商配额。强回答会把事务与批量任务分开排队、分开配置消费者和受保护渠道预算,同时允许批量流量借用空闲容量。OTP 洪峰也需要自身上限,避免“高优先级”绕过全部保护。
第三项信号是对投递保证的表述准确。标准队列可能重复交付;工作进程应按稳定的 delivery_id 幂等处理。网络超时发生在供应商已经接受请求之后时,内部去重不能撤回外部副作用。若供应商没有幂等发送接口,系统只能降低重复概率、记录不确定结果,并按通知风险决定是否重试或切换供应商,不能承诺端到端 exactly-once。
第四项信号是失败路径能闭环:永久错误停重试并清理失效端点;429、5xx 和网络错误进入带抖动退避;过期任务直接终止;死信重放仍使用原 delivery_id 并重新检查过期时间与退订状态。回执可能重复和乱序,所以原始事件要先保存,再按渠道允许的状态转换生成当前视图。
回答前需要澄清的问题
- “送达”的业务定义是什么? OTP 的可控目标应到“供应商已接受”,因为设备离线、运营商和用户权限不受平台控制。若业务要求用户已读,只能依赖支持该能力的渠道回执或客户端埋点,并标注覆盖率。
- 通知类型和优先级由谁决定? 服务端维护受控的通知类型目录,由类型映射优先级、可用渠道、模板和退订规则。调用方自由传
critical=true会让所有团队争抢紧急通道。 - 哪些消息可绕过免打扰或退订? 只有经过合规确认的事务类型可以例外。营销消息在真正入渠道队列前检查最新偏好;预约后退订的用户不应因为早期快照继续收到消息。
- 是否允许跨渠道降级? 短信失败后改发推送会改变成本、可达性和用户预期。必须按通知类型配置降级图,并区分“明确拒绝”和“结果未知”;结果未知时切换渠道可能造成两次触达。
- 顺序要求是什么? 同一用户的密码重置消息可能需要保序,普通社交提醒通常不需要全局顺序。只在
(userid, notificationtype)等必要键上保序,避免把整个系统串行化。 - 数据保留和隐私边界是什么? 消息正文可能包含敏感信息。队列只保存模板版本和最少参数,按字段加密并设置保留期;日志禁止记录 OTP、完整手机号或邮件正文。
30 秒回答框架
“我会先把受理成功、供应商接受、终端送达和用户已读分成不同状态。入口把通知与 outbox 原子落库后返回 202,异步展开收件人并在临近投递时检查偏好、免打扰、过期时间和去重键。每个渠道按事务、普通和批量消息拆队列与消费者,给事务流量保留供应商预算,批量任务只借用空闲容量。工作进程用稳定 delivery_id 处理至少一次任务,按永久错误、限流和暂时故障分类;暂时错误指数退避并加抖动。供应商回执先追加到事件表,再按渠道状态机折叠,避免乱序把 delivered 降回 sent。最后用营销洪峰叠加 OTP、重复任务和乱序回执做压测,验证 2 秒 SLO 与积压恢复。”
分步骤深入解答
入口 API 不直接调用短信或推送供应商。同步路径完成鉴权、通知类型校验、模板版本固定和最小参数校验,然后在同一数据库事务写入通知记录与 outbox:
~~~text POST /v1/notifications { request_id, notification_type, recipients | audience_id, template_version, template_params, schedule_at, expire_at }
202 Accepted { notification_id, accepted_at } ~~~
requestid 用于调用方重试去重,notificationid 标识一次业务通知,delivery_id 标识“用户 × 渠道”的一次投递。它们不能混成一个 ID:同一通知可能展开为多个用户和多个渠道。入口提交后再由 outbox relay 发布事件,避免数据库已提交而消息未入队。对于 5,000 万收件人的活动,入口只保存受众快照引用和游标,不在一个请求或事务内生成 5,000 万行。
受众展开器分片读取快照,生成小批量计划任务。策略服务在任务接近发送时读取通知类型规则和用户最新偏好,依次判断退订、免打扰、渠道可用性、频控、过期时间和降级顺序。免打扰时间以用户 IANA 时区计算,并覆盖夏令时跳变;用户没有时区时采用产品明确的默认值,不能偷偷使用服务器时区。被过滤的任务写入 SUPPRESSED 及机器可读原因,便于解释“为什么没发”。
核心数据可以拆成三类:
~~~text Notification( notificationid, tenantid, notification_type, templateversion, scheduleat, expire_at )
Delivery( deliveryid, notificationid, user_id, channel, trafficclass, state, provider, providermessage_id, attemptcount, nextattemptat, stateversion )
DeliveryEvent( deliveryid, providereventid, eventtype, providertime, receivedat, rawpayloadref ) ~~~
Delivery 是查询当前状态的物化视图,DeliveryEvent 保留供应商回执事实。原始正文和个人信息放在受控存储中,事件表只留引用。providereventid 存在时建立唯一约束;供应商不给事件 ID 时,可对规范化字段生成去重哈希。回执签名必须先验证,未知字段要兼容保存,不能因为供应商新增字段而拒绝合法事件。
状态模型至少区分:
~~~text ACCEPTED -> PLANNED -> QUEUED -> SENDING -> PROVIDER_ACCEPTED | v DELIVERED -> READ
Terminal side states: SUPPRESSED, EXPIRED, FAILED_PERMANENT ~~~
这个图表达业务阶段,不代表所有回执按图中顺序到达。供应商可能先送来 delivered,后到 sent;重复 webhook 也很常见。处理器先追加幂等事件,再用“渠道 + 当前状态 + 新事件”的转换表更新投影。已经 DELIVERED 的短信不会被迟到的 sent 降级;支持已读的渠道可以从 DELIVERED 升到 READ。无法映射的事件留在原始表并报警,避免猜测状态。
发送数据面按“渠道 × 流量等级”拆队列,例如 sms.transactional、sms.bulk、push.transactional。每组有独立积压指标、消费者并发和重试队列。假设短信供应商上限为 10,000 次/秒,可以给事务流量保留 3,000 次/秒;批量流量平时可借用未使用部分,一旦事务流量上升便归还。数字是配置示例,实际值来自供应商合同和压测。租户级公平调度阻止单个活动占满批量预算,任务老化机制则避免普通任务长期饿死。
独立队列比共享队列里的优先级字段占用更多主题、连接和运维配置,却能隔离磁盘积压与消费者资源。若规模较小、渠道只有一个,共享队列加加权公平调度会更简单;当事务 SLO 与批量截止时间相差几个数量级时,物理隔离更容易证明。无论采用哪种队列,供应商侧的实际限额都由每渠道调度器统一执行,不能让每个消费者各自认为拥有完整额度。
渠道工作进程领取至少一次任务后,以 deliveryid 原子认领一次 attempt。发现该投递已处于终态时直接确认队列任务;否则检查 expireat,渲染模板,调用供应商并保存响应。队列重复不会重复创建投递记录。外部请求超时仍有三种结果:供应商未收到、已收到但响应丢失、处理结果未知。优先复用供应商提供的幂等键;没有该能力时,将 attempt 标为 UNKNOWN,通过供应商查询或回执对账。关键通知可在业务明确接受重复风险后重试,营销通知通常可以等待对账或过期。
错误分类决定后续动作:
- 无效参数、无效模板、明确的失效设备令牌属于永久错误,立即进入
FAILED_PERMANENT,并在证据明确时停用该端点。APNs 的 410 表示设备令牌对该 topic 已不活跃。 - 429、供应商 5xx、连接中断通常可重试,使用指数退避、全抖动、最大尝试次数和总截止时间。重试速度还受渠道预算控制,避免故障恢复时形成第二次尖峰。
- 鉴权失败或账户配置错误是平台事件,暂停对应供应商通道并报警;对每条消息重试只会扩大故障。
- 到达
expireat的 OTP 或过时提醒进入EXPIRED。死信重放不能修改原过期时间,也不能换新deliveryid绕过去重。
供应商接受不等于终端送达。APNs 为每个 POST 返回状态与 apns-id,成功只证明请求层成功;Firebase 也把排队或交给 APNs 计入 Sends。短信供应商可能进一步提供 queued、sent、delivered、undelivered 和 read 等状态,且 webhook 可能乱序。对外 API 因此返回分层状态与 status_reason,报表分别统计受理率、供应商接受率、可观测送达率和可观测打开率,并把“该渠道不提供此状态”显示为未知,不能算失败或成功。
扩展时,通知与投递表按 notification_id 或时间分区,工作队列按渠道、等级和分片键水平扩容。单用户保序任务使用稳定用户分区键;不要求顺序的批量任务可更均匀散列。30 TB 的 30 天逻辑信封不适合全部留在高成本在线索引:近期投递状态保留在线,旧事件压缩归档,只保留合规与客服所需索引。容量计算还要单独加入副本、索引、回执放大和重试产生的写入。
最关键的验收场景是“批量洪峰不能拖慢 OTP”。先持续注入约 232,000 个任务/秒的活动负载,再叠加事务流量,检查 OTP 从受理到供应商提交的 p99 是否仍低于 2 秒,批量积压是否在 15 分钟窗口内消化。随后注入 429 与 5xx,验证退避没有形成同步重试波;重复投递同一队列消息,确认只复用原 delivery_id;按 delivered -> sent -> delivered 发送乱序和重复回执,确认状态不回退;再覆盖发送前退订、夏令时切换、过期死信和供应商结果未知。只有这些失败路径通过,架构图上的“可靠”才可验证。
高质量示范回答
“我先定义四层结果:平台持久化受理、供应商接受、终端可观测送达、用户已读。接口只承诺第一层,写通知记录与 outbox 后返回 202。异步展开受众,在接近投递时检查最新退订、免打扰、渠道可用性和过期时间,再生成稳定的 delivery_id。
每日 10 亿个渠道任务平均约 11,600 次/秒,20 倍活动尖峰约 232,000 次/秒。OTP 要求 2 秒内提交供应商,营销允许 15 分钟平滑,所以我按渠道和事务、普通、批量等级拆队列与消费者,并为事务消息保留供应商预算。批量可以借空闲容量,但事务流量上升时要归还;每个租户还有公平份额。
工作进程按至少一次消费设计。队列重复只会认领同一个 delivery_id。永久错误停重试,429、5xx 和网络错误带抖动退避,并受总截止时间约束。供应商请求超时可能已经产生外部副作用;有供应商幂等键就复用,没有时标成 UNKNOWN 并对账,不能宣称端到端 exactly-once。
回执先写不可变事件,再按渠道转换表生成状态投影,避免迟到的 sent 覆盖 delivered。上线验证会同时压入营销尖峰和 OTP,再注入重复任务、429、供应商超时、乱序回执和发送前退订。核心指标是各等级队列年龄、供应商提交延迟、错误分类、UNKNOWN 数量和状态回退次数。”
常见错误
- API 返回 202 就记录 delivered → 平台只完成持久化受理 → 拆分 ACCEPTED、PROVIDER_ACCEPTED、DELIVERED 与 READ。
- 所有任务进入一个优先队列 → 批量积压仍共享磁盘、消费者与供应商额度 → 按渠道和流量等级隔离资源,并允许受控借用。
- 高优先级无限制通行 → OTP 异常流量也能压垮供应商和平台 → 为事务等级设置独立上限、告警和租户公平性。
- 看到队列至少一次就宣称用户只收到一次 → 外部供应商调用可能已成功但响应丢失 → 内部按
delivery_id幂等,外部结果未知时对账并明确重复风险。 - 对所有错误立即重试 → 永久错误浪费资源,429 与 5xx 会形成重试风暴 → 按永久、暂时、限流和平台配置错误分类,暂时错误退避加抖动。
- 直接覆盖当前状态 → 乱序回执可能把 delivered 改回 sent → 先保存幂等事件,再用渠道状态转换表更新投影。
- 预约时一次性冻结用户偏好 → 用户随后退订仍会收到营销消息 → 在临近进入渠道队列时重新检查偏好与合规规则。
- 死信重放生成新 ID → 去重失效,过期通知可能再次触达 → 沿用原
delivery_id,重查退订与过期时间。 - 用全局顺序换取简单性 → 无关用户互相阻塞,吞吐被单分区限制 → 只为确有业务要求的用户与通知类型建立局部顺序。
追问及应对
追问一:5,000 万用户都要求当地时间上午 9 点收到活动通知,怎么处理?
受众展开时按 IANA 时区和当地日期建立时间桶,提前生成可限速的批次;每个桶在目标窗口内加抖动平滑,而不是整点同时放行。夏令时导致的不存在或重复本地时间要有产品规则,例如跳到下一个有效时刻且每天最多一次。营销的“上午 9 点”应定义为交给供应商的窗口,终端展示仍不可控。
追问二:供应商返回成功,但一直没有送达回执,状态是什么?
保留 PROVIDERACCEPTED,不自动升级为 DELIVERED。到达渠道规定的观察窗口后可标记 DELIVERYUNKNOWN 供运营查询,但不能把未知计入失败率。若供应商提供查询 API 或汇总报表,可以做异步对账;对账数据的延迟和覆盖率也要随指标展示。
追问三:delivered 回执先于 sent 到达怎么办?
两个事件都幂等追加,投影规则允许 PROVIDER_ACCEPTED 或 SENDING 升到 DELIVERED,之后到达的 sent 只补充审计事实,不降低状态。若同一供应商后来发送明确撤销或错误事件,应按该渠道文档设计独立转换,不能用全局整数大小猜顺序。
追问四:两个短信供应商能否自动主备切换?
明确拒绝、连接前失败或供应商整体熔断时可以按配置切换。请求超时发生在发送之后时,主供应商可能已经投递;立即切换会增加重复短信。OTP 可在成本和安全团队接受重复风险时切换,并保持相同业务操作标识;营销消息更适合等待查询、回执或过期。切换策略按通知类型配置,不能全局统一。
追问五:如何证明优先级隔离真的有效?
用封闭负载测试同时制造三类压力:批量队列持续积压、某个租户形成热点、供应商连续返回 429。验收条件包括 OTP 供应商提交 p99 仍低于 2 秒、事务消费者没有被批量任务占用、供应商总速率不越过配置、批量任务在 15 分钟截止时间内恢复。再故障一个事务消费者分区,确认预留容量能被其余实例接管;只看平均吞吐无法证明隔离。