深蓝黑背景封面:左侧大标题「评论、点赞、关注提醒,满天飞也不漏」,副标题「消息队列在削峰、解耦与可靠投递里的角色」;右侧原创示意图——三种事件图标(对话泡、爱心、人形加号)从上方汇入一个漏斗,漏斗出口是一条消息队列传送带,带上消息块排成整齐队列,末端分三岔流向站内信、推送、邮件三个通道;左上标注「面试经典 · 系统设计」,右上标注「Day 08 · 11-05」 图 1:本文封面,洪峰进漏斗,出来排好队。

先说明属性:这是一篇历史题目复盘,题目出自本地历史笔试资料(原题从测试角度提问,本文按系统设计角度重写),与任何公司的当前招聘无关。题面涉及的消息系统包含评论、点赞、关注提醒、创作者账号提醒、问答邀请和系统活动消息。设计这样一套通知系统,难点不在「发出去」,而在三个词:不重、不漏、不炸用户。

第一步,事件接入与触达解耦。

评论、点赞、关注发生在业务接口里,若让接口同步完成「写通知库 + 调推送通道 + 发邮件」,延迟被拖垮,下游抖动还会传染回主流程。所以第一层设计:业务代码只负责落一条事件到消息队列,触达整体异步化。消息队列承担两个角色——解耦(业务方不知道有哪些触达通道)与削峰(洪峰先进队列排队,消费端按自己的能力匀速处理)。

第二步,聚合与去重:别把洪峰转嫁给用户。

某篇内容爆了,作者一分钟收到几千个点赞事件。每个事件一条通知,用户直接被打炸。聚合窗口的作用:同一接收者、同一类型的事件在窗口内合并成一条——「user1、user2、user3 等 1200 人赞了你」。聚合键(接收者 + 类型 + 对象)与窗口长度是核心参数。

第三步,可靠投递:ack、重试与死信。

消息队列的消费确认机制是「不漏」的根基。以主流消息中间件官方文档描述的机制为准:消费者处理成功之后才回 ack;中途崩溃、连接断开,未 ack 的消息会被自动重新投递。由此推出一条铁律:重投必然发生,消费必须幂等——用事件 ID 建去重表,重复消息直接丢弃。单条消息连续重试多次仍失败,转入死信队列,告警加人工排查,不让坏消息堵住整个队列。

第四步,触达分发与分层 SLA。

聚合后的通知分发到不同通道,可靠性要求不一样:站内信必须落库可回看,推送走厂商通道属尽力而为,营销类甚至可以丢弃。按通道定 SLA,而不是全链路一刀切追「必达」,成本才能打住。

整条链路,用代码跑一遍。

下面这段模拟注入一次点赞风暴(1200 个点赞事件)和一次消费者中途崩溃,验证削峰、聚合、幂等与重投语义,用 Node.js 22.15.0 直接运行(node notify.js):

'use strict';

// 消息通知系统核心链路模拟:事件接入 -> MQ 削峰 -> 聚合去重 -> 幂等消费 -> 触达。
// 注入一次点赞风暴和一次消费者中途崩溃,验证不重、不漏、不炸用户。

// 事件流:瞬间涌入 1200 个点赞事件、3 条评论、2 个关注
const events = [];
for (let i = 1; i <= 1200; i++) events.push({ id: `like-${i}`, type: 'like', from: `user${i}`, to: '大V' });
events.push({ id: 'c1', type: 'comment', from: 'userA', to: '大V' });
events.push({ id: 'c2', type: 'comment', from: 'userB', to: '大V' });
events.push({ id: 'c3', type: 'comment', from: 'userC', to: '大V' });
events.push({ id: 'f1', type: 'follow', from: 'userD', to: '大V' });
events.push({ id: 'f2', type: 'follow', from: 'userE', to: '大V' });

const mq = [...events]; // 消息队列:先把洪峰整个接下来
console.log(`事件洪峰:${events.length} 个事件瞬间写入 MQ(业务接口只负责落事件,不做触达)`);

// 消费端每轮最多处理 200 条:MQ 把尖峰削成平坡
const processed = new Set();      // 幂等表:已处理的事件 ID
const agg = new Map();            // 聚合窗口:同接收者同类型合并成一条通知
let cursor = 0, rounds = 0, crashAtRound = 3, redelivered = 0;
let unacked = [];                 // 已取出但尚未 ack 的消息

while (cursor < mq.length || unacked.length > 0) {
  rounds++;
  let batch;
  if (rounds === crashAtRound) { // 消费者崩溃:本轮取出的消息没来得及 ack
    unacked = mq.slice(cursor, cursor + 200);
    console.log(`第 ${rounds} 轮:消费者处理到一半崩溃,${unacked.length} 条消息未 ack`);
    continue; // 不推进 cursor:未 ack 的消息会被重投
  }
  if (unacked.length > 0) { batch = unacked; unacked = []; redelivered += batch.length; console.log(`第 ${rounds} 轮:MQ 重投 ${batch.length} 条未 ack 消息`); }
  else { batch = mq.slice(cursor, cursor + 200); cursor += batch.length; }

  for (const e of batch) {
    if (processed.has(e.id)) continue; // 幂等:重投的重复事件直接丢弃
    processed.add(e.id);
    const key = `${e.to}:${e.type}`;
    if (!agg.has(key)) agg.set(key, { to: e.to, type: e.type, actors: [] });
    agg.get(key).actors.push(e.from);
  }
  // 全部处理完才 ack
}
const verb = { like: '赞', comment: '评论', follow: '关注' };
const outbox = [];
for (const n of agg.values()) {
  const head = n.actors.slice(0, 3).join('、');
  outbox.push(n.actors.length > 3 ? `${head} 等 ${n.actors.length} 人${verb[n.type]}了你` : `${head} ${verb[n.type]}了你`);
}
console.log(`消费完成:共 ${rounds} 轮(每轮 200 条上限),重投 ${redelivered} 条,幂等表 ${processed.size} 条`);
console.log(`用户实际收到的通知 ${outbox.length} 条(而不是 ${events.length} 条轰炸):`);
outbox.forEach(n => console.log('  - ' + n));

// 死信兜底:反复失败的消息进死信队列
console.log('兜底:单条消息连续重试 5 次仍失败 -> 转入死信队列,告警 + 人工排查,不阻塞主队列');

实测输出:

事件洪峰:1205 个事件瞬间写入 MQ(业务接口只负责落事件,不做触达)
第 3 轮:消费者处理到一半崩溃,200 条消息未 ack
第 4 轮:MQ 重投 200 条未 ack 消息
消费完成:共 9 轮(每轮 200 条上限),重投 200 条,幂等表 1205 条
用户实际收到的通知 3 条(而不是 1205 条轰炸):
  - user1、user2、user3 等 1200 人赞了你
  - userA、userB、userC 评论了你
  - userD、userE 关注了你
兜底:单条消息连续重试 5 次仍失败 -> 转入死信队列,告警 + 人工排查,不阻塞主队列

三个数字值得盯着看:1205 个事件进去、3 条通知出来——聚合把轰炸压成摘要;第 3 轮崩溃、第 4 轮重投 200 条——未 ack 的消息一条没丢;幂等表 1205 条、无重复通知——重投的重复消息被事件 ID 全部拦下。「不重、不漏、不炸用户」各有一个机制兜着。

架构链路图:上方主链路五步——业务事件接入(评论点赞关注各自发事件)、消息队列承接(突发流量削平、业务与触达解耦)、聚合与去重(同人同事多次点赞合并成一条)、触达分发(站内信落库、推送走厂商通道、邮件短信走网关)、消费可靠性(处理成功才 ack,失败重试,多次失败进死信队列人工兜底)。中部左侧讲两个关键设计点:幂等消费用事件 ID 去重表,保证重试不产生重复通知;聚合窗口在点赞风暴时把一百条提醒合成「一百人赞了你」。右侧讲分层 SLA:站内信必须落库可回看,推送尽力而为,营销类可丢弃——不同通道的可靠性要求不同。底部提示:通知系统的难不在发出去,而在「不重、不漏、不炸用户」 图 2:主链路五步,可靠性三件套各管一段。

可能的追问。「通知要保证实时性怎么办?」聚合窗口是实时与免打扰的交换器:点赞类窗口放长,评论、问答邀请这类强互动的放短甚至直达。「消息顺序重要吗?」同一接收者按哈希分区投递,让同一用户的消息总落在同一消费者。「怎么回到原题的测试视角?」链路每一环对应一类用例:洪峰压测削峰、杀消费者验证重投、重放消息验证幂等、构造坏消息验证死信。

Agent 任务事件流、审核回调、训练完成通知,都是这套消息通知系统的变体:异步化与可靠投递的决策直接决定体验。你维护的通知链路里,聚合窗口和幂等是怎么落地的?有没有被「重复通知」投诉过?欢迎交流。