图 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 行写入,写扩散的直读体验优势完全可以保留。
图 2:两种模型的数据流、代价对比与本题的选型结论。
把结论推广成选型框架。
这道题的答案不是「读扩散永远更好」,而是「看读写比和活跃率做决定」。可以把权衡收成三条经验:群发的写入代价随接收者总数线性增长,而收益只随活跃接收者增长,活跃率越低写扩散越亏;读扩散把成本挪到读取侧,适合「写少读也少、但潜在接收者极多」的广播型流量;一旦画像反转成高日活、互动密集(比如熟人社交的私信与评论),写扩散的低延迟直读就重新占优。画像决定模型,而不是模型决定画像。
落地时的几个工程细节。
读扩散方案里,公共信箱可以按消息 ID 递增存储,每个用户只需记录一个已读位点,拉取时取位点之后的消息即可;私信继续走写扩散,落到各自的收件箱表。群发消息还要考虑过期清理与分页拉取,避免单用户积压过多。如果未来出现高活跃用户群体,可以对活跃用户混合写扩散——群发时只给活跃用户预写收件箱,沉默用户仍走拉取,这是 Feed 系统里常见的折中。
回到笔试现场,建议的答题结构:先定义读写扩散两种模型,再抓出题面「百万注册、千级日活、管理员群发」三个关键参数,用写放大与存储放大的定量对比推出「群发走读扩散、私信走写扩散」的结论,最后补位点设计与混合方案。定量推演过程才是得分点。你手上的消息系统当初是怎么选型的?欢迎在评论区分享。