深蓝黑背景封面:左侧上下两条数据流对比——上方紫色标注「写扩散」,管理员方框经箭头连到消息服务,再扇出三支箭头分别连到三个「用户收件箱」小方框,下方注「一次群发 = 写 100 万份,存储 ×N」;下方青色标注「读扩散」,管理员方框连到「公共信箱」方框,再由信箱引出三支箭头连到三个「用户」小方框,下方注「写 1 份,用户上线时拉取比对」;右侧大标题「百万用户站内信,读扩散还是写扩散」,副标题「群发一次的写放大,决定数据模型选型」,灰字「存储成本与读取延迟的经典权衡」;左上标注「笔试复盘 · 系统设计」,右上圆角胶囊标注「Day 10 · 11-17」 图 1:本文封面,同一条群发消息在两种模型下的命运完全不同。

先说明属性:这是一篇笔试真题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面大意是:一个 SNS 系统有百万级注册用户、日活只有千级,要实现用户之间的站内私信,还要实现管理员对全体用户的群发,请给出数据设计与实现方案。这道题的分水岭就一句话:数据模型按读扩散还是写扩散设计。

先把两种模型说清楚。

写扩散是「发送时算账」:消息一发出,立刻往每个接收者的收件箱各写一份,每人一份副本。读取极其简单——用户打开收件箱直读自己的数据,延迟低、逻辑直白;代价是发送方的写入量随接收人数线性放大。

读扩散是「读取时算账」:消息只写一份,放在公共信箱(或发送者的发件箱)里;用户上线时主动拉取,和自己的已读位点比对,算出哪些是未读。写入恒定是一份,存储全站共享;代价是读取时要多一步拉取与比对,延迟略高,实现也复杂一点。

题面里藏着一个决定性数字:日活占注册的比例。

百万注册、千级日活,意味着每天只有约千分之一的用户会真的上线。这个比例直接宣判了写扩散在群发场景的命运:管理员每群发一条消息,写扩散都要写 100 万行,其中 99.9% 的副本是写给当天根本不会登录的沉默用户的——这些写入纯是浪费,还要占用存储。反过来,读扩散那条唯一的公共消息,只需要等服务千级日活用户来拉,写入几乎为零。

定量算一笔账,结论不用拍脑袋。

// node fanout.js(Node.js 22 实测)
const USERS = 1_000_000;      // 注册用户百万级
const DAU = 1_000;            // 日活千级
const MSG_BYTES = 500;        // 一条消息正文约 500 字节

// 写扩散:每天群发 1 条,向每人收件箱写一行
const wRows = USERS * 1;
console.log('写扩散:每天写入', wRows.toLocaleString(), '行,约',
  (wRows * MSG_BYTES / 1e6).toFixed(0), 'MB 正文,一年约',
  (wRows * MSG_BYTES * 365 / 1e9).toFixed(0), 'GB');
// 读扩散:公共信箱只写 1 行,日活用户上线拉取
console.log('读扩散:每天写入 1 行,拉取请求约', DAU, '次');
// 私信场景:日活用户互发,人均每天 5 条
console.log('私信写扩散:每天写入约', DAU * 5, '行,量级可接受');

实测输出:写扩散每天群发写入 100 万行、约 500MB 正文、一年滚到约 180GB;读扩散每天只写 1 行、拉取请求约 1000 次。写入放大是一百万倍对一倍——在「低日活 + 群发为主」的画像下,读扩散完胜。而私信场景恰恰相反:一对一发送本来就是天然的一条写入,日活千级、人均每天 5 条也只有约 5000 行写入,写扩散的直读体验优势完全可以保留。

上半部左右两张数据流面板:左面板紫色边框「写扩散:发送时落到每人收件箱」,管理员方框经消息服务扇出箭头到一叠「用户收件箱」方框,底部注「群发一次:写 N 行,N = 100 万 → 存储放大 ×N」;右面板青色边框「读扩散:共享信箱 + 读时拉取」,管理员写入「公共信箱(1 行)」,用户上线时从信箱拉取,底部注「写 1 次;用户上线拉取,按已读位点比对」。下半部是一张四行对比表,列为维度、写扩散、读扩散:单次群发写次数——100 万行(随用户数线性增长)对 1 行;存储放大——×N(每个用户一份)对 ×1(全站共享一份);读取延迟——低(直读本人收件箱)对略高(拉取 + 位点比对);适合场景——高日活、私信互动为主对低日活、管理员群发为主。读扩散一列带淡青色底高亮。底部结论条:本题画像为注册用户百万级、日活低、群发为主,因此读扩散更合适,活跃用户可混合写扩散 图 2:两种模型的数据流、代价对比与本题的选型结论。

把结论推广成选型框架。

这道题的答案不是「读扩散永远更好」,而是「看读写比和活跃率做决定」。可以把权衡收成三条经验:群发的写入代价随接收者总数线性增长,而收益只随活跃接收者增长,活跃率越低写扩散越亏;读扩散把成本挪到读取侧,适合「写少读也少、但潜在接收者极多」的广播型流量;一旦画像反转成高日活、互动密集(比如熟人社交的私信与评论),写扩散的低延迟直读就重新占优。画像决定模型,而不是模型决定画像。

落地时的几个工程细节。

读扩散方案里,公共信箱可以按消息 ID 递增存储,每个用户只需记录一个已读位点,拉取时取位点之后的消息即可;私信继续走写扩散,落到各自的收件箱表。群发消息还要考虑过期清理与分页拉取,避免单用户积压过多。如果未来出现高活跃用户群体,可以对活跃用户混合写扩散——群发时只给活跃用户预写收件箱,沉默用户仍走拉取,这是 Feed 系统里常见的折中。

回到笔试现场,建议的答题结构:先定义读写扩散两种模型,再抓出题面「百万注册、千级日活、管理员群发」三个关键参数,用写放大与存储放大的定量对比推出「群发走读扩散、私信走写扩散」的结论,最后补位点设计与混合方案。定量推演过程才是得分点。你手上的消息系统当初是怎么选型的?欢迎在评论区分享。