目录
  1. 1. 一、什么是系统设计面试
  2. 2. 二、用户关系图的存储
  3. 3. 三、Feed生成的两种模式
  4. 4. 四、时间线服务的设计
  5. 5. 五、动态内容的存储
  6. 6. 六、系统架构总览
  7. 7. 七、一致性模型与数据同步
  8. 8. 八、处理热点与缓存策略
  9. 9. 九、面试常见追问
走进系统设计与新鲜事系统

一、什么是系统设计面试

系统设计面试是高级工程师面试的核心环节,考察候选人面对海量数据和高并发场景时的架构能力。与算法面试不同,系统设计没有标准答案——面试官关注的是你如何权衡(trade-off)、如何拆解问题、如何在CAP定理的约束下做出合理的工程选择。

典型的系统设计面试流程分为四个步骤。第一步是需求澄清(Requirements Clarification),你需要主动向面试官提问:系统有多少日活用户?读写比例是多少?需要支持哪些核心功能?延迟要求是什么?第二步是容量估算(Back-of-the-envelope Estimation),快速估算QPS、存储量、带宽需求,这个过程展示了你的量化思维。第三步是高层设计(High-Level Design),画出系统架构图,明确核心组件和它们之间的数据流。第四步是深度设计(Deep Dive),针对关键组件深入讨论数据结构、存储方案、扩展策略。

以新鲜事系统为例,我们设计的社交信息流每日活跃用户假设为一亿,平均每个用户关注二百人,每天发布约五亿条新动态。读操作远多于写操作,读写比例约为一百比一。核心功能包括:用户发布动态(文字、图片、视频)、用户查看关注者的新鲜事时间线、点赞和评论。非功能需求包括:新鲜事加载延迟小于二百毫秒,系统可用性达到四个九(99.99%),数据最终一致性可接受。

二、用户关系图的存储

社交网络本质上是一个有向图。用户A关注了用户B,意味着存在一条从A指向B的有向边。我们需要高效存储和查询两种关系:关注列表(Following List,我关注了谁)和粉丝列表(Follower List,谁关注了我)。

对于用户关系图,我们有两类存储选择。第一类是关系型数据库,使用一张关注关系表:follow(follower_id, followee_id, created_at),以follower_id为分片键(sharding key)。查询“我关注了谁”是单分片操作,效率较高;但查询“谁关注了我”需要扫描所有分片,适合做异步索引。第二类是图数据库,如Neo4j或采用Redis的有向图结构,但互联网公司通常选择MySQL分片方案,因为运维成熟、团队熟悉。

针对大V用户(粉丝数超过百万的超级节点),查询其粉丝列表成本极高。实践中,我们不会实时查询大V的完整粉丝列表,而是在写操作(发布动态)时采用异步扩散(fanout on write),读操作时直接读取预先计算好的时间线。普通用户的动态则可以根据场景选择推模式或拉模式。

数据库分片策略:以user_id为分片键,按user_id % 1024或一致性哈希分配到不同MySQL实例。每个分片包含多个库,每个库包含多张表,形成“分片→库→表”的三层路由。如果用户量达到十亿级别,1024个分片每个承载约一百万用户,单分片数据量可控。

三、Feed生成的两种模式

Feed流生成是新鲜事系统的核心技术决策,主要有两种模式:推模式(Fanout on Write)和拉模式(Fanout on Read)。

推模式的核心思想是:当用户发布一条动态时,立即将这条动态“推送”到所有粉丝的时间线中。优点是读操作极为简单——用户打开App时,直接从自己的时间线存储中按时间倒序拉取即可,延迟极低。缺点是写操作成本高昂——如果某用户拥有一百万粉丝,一次发布就需要执行一百万次写入操作,这在工程上是不可接受的。此外,对于不活跃的粉丝,这些写入是纯粹的浪费。推模式适用于粉丝数量较少的普通用户。

拉模式的核心思想是:用户打开App时,实时查询其所有关注者的最新动态,合并排序后返回。优点是没有额外的写扩散开销,存储节省;缺点是读操作延迟高,尤其是当用户关注了数千人时,需要查询数千个用户的时间线并归并排序。拉模式适用于关注数较少的用户或者粉丝数极多的超级大V。

工业界的最佳实践是推拉结合。对于普通用户(粉丝数小于某个阈值,如一万),使用推模式,将动态写入所有粉丝的时间线。对于大V用户(粉丝数超过阈值),使用拉模式,不主动扩散,而是在粉丝打开时间线时,从大V的“发件箱”中拉取动态,与已推送的普通用户动态合并。Twitter在早期使用纯推模式,后来因为名人用户(如奥巴马)发布一条推文需要写入数千万条时间线记录,才逐步演进为推拉结合的混合架构。新浪微博也采用了类似的策略,普通用户的微博写入所有粉丝的收件箱,大V的微博只在用户请求时从大V的发件箱拉取。

四、时间线服务的设计

时间线服务(Timeline Service)是新鲜事系统的核心组件。每个用户拥有一个“收件箱”(Home Timeline),存储按时间倒序排列的动态ID列表。我们使用Redis的Sorted Set来存储每条时间线,其中member是动态ID,score是发布时间戳(Unix毫秒时间戳)。

Redis Sorted Set的底层实现是跳表(Skip List)加哈希表。插入一条记录的时间复杂度是O(log N),按score范围查询(ZREVRANGEBYSCORE)也是O(log N + M),其中M是返回的记录数。对于时间线场景,每次加载通常取最新的20到50条动态,M很小,性能优异。

时间线数据结构的设计细节:每个用户的时间线是一个独立的Sorted Set,key格式为timeline:{user_id}。当用户发布动态时,如果是推模式,将动态ID和发布时间的映射关系ZADD timeline:{follower_id} {timestamp} {feed_id}写入所有粉丝的时间线。动态的完整内容(文本、图片URL、点赞数等)存储在另一个存储层中,时间线中只存储动态ID。用户加载时间线时,先从Redis获取最新的动态ID列表,再根据ID批量查询动态详情,组装后返回。

对于大V的“发件箱”(Outbox Feed),同样使用Redis Sorted Set存储,key格式为outbox:{user_id}。当粉丝拉取大V的动态时,从大V的发件箱中获取最新动态ID,然后与普通用户已推送的时间线合并。

时间线存储容量估算:假设一亿DAU,每个用户的时间线存储最近一千条动态ID。每条动态ID为8字节的Long类型,时间戳8字节,加上Redis内部开销,每条记录约50字节。每个用户的时间线约50KB,一亿用户需要约5TB内存。考虑到Redis是纯内存数据库,成本较高,我们可以采用冷热分离策略:活跃用户(近七天有登录)的时间线保留在Redis中,非活跃用户的冷时间线迁移到SSD存储的Redis Flash或直接使用MySQL存储。

五、动态内容的存储

动态内容(Feed Content)使用MySQL分片存储,以user_id为分片键。表结构设计如下:

CREATE TABLE feeds (
feed_id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
content_type TINYINT NOT NULL, -- 1:文字 2:图片 3:视频
text TEXT,
media_urls JSON, -- 图片/视频URL列表
like_count INT DEFAULT 0,
comment_count INT DEFAULT 0,
repost_count INT DEFAULT 0,
status TINYINT DEFAULT 1, -- 1:正常 2:删除
created_at DATETIME NOT NULL,
INDEX idx_user_time (user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

对于图片和视频等媒体文件,原始文件上传到对象存储(如AWS S3或阿里云OSS),数据库中只存储文件的URL。图片经过CDN分发,不同设备请求不同尺寸的缩略图,由CDN边缘节点实时压缩转换。

点赞和评论数的更新是高频写操作。如果每次点赞都直接更新主表,会产生大量行锁竞争。实践中采用异步更新策略:点赞操作先写入Redis的计数器(INCR feed:like_count:{feed_id}),定时任务(如每10秒)将Redis中的计数批量同步到MySQL。读取时先查Redis,如果Redis中没有(缓存未命中或已过期),再回源到MySQL并写入缓存。

六、系统架构总览

整个新鲜事系统的高层架构包含以下组件:

API网关层:负责身份认证、限流、请求路由。客户端通过HTTPS与API网关通信,API网关验证JWT Token后将请求转发到对应的微服务。

Feed服务:核心业务逻辑层,处理动态的创建、删除和查询。当用户发布动态时,Feed服务首先将内容写入MySQL,然后调用通知服务(Notification Service)触发异步扩散任务。当用户拉取时间线时,Feed服务从Redis获取动态ID列表,批量查询MySQL获取详情,组装后返回。

时间线服务:管理用户收件箱的Redis Sorted Set。提供两个核心接口:addToTimeline(userId, feedId, timestamp)负责将动态ID插入用户时间线;getTimeline(userId, cursor, limit)根据游标分页拉取时间线。

扩散服务(Fanout Service):异步处理推模式的扩散逻辑。从消息队列(如Kafka)消费新动态事件,查询该用户的粉丝列表,批量写入粉丝的Redis时间线。对于粉丝数超过阈值的大V,跳过扩散步骤,仅将动态ID写入大V自己的发件箱。

通知服务:当用户发布动态时,通过消息队列通知扩散服务进行处理。消息队列在这里起到了削峰填谷的作用——当热点事件发生时(如明星官宣),发布量瞬时暴增,消息队列缓冲请求,避免后端服务被冲垮。

计数服务:管理点赞数、评论数、转发数等计数的读写。使用Redis计数器加异步回写MySQL的架构。

用户关系服务:管理关注/取关操作,维护关注关系图。关注关系存储在MySQL中,热数据(活跃用户的关系)缓存在Redis中。

媒体服务:处理图片/视频的上传、转码、缩略图生成,对接对象存储和CDN。

七、一致性模型与数据同步

新鲜事系统对一致性要求不高,最终一致性(Eventual Consistency)即可接受。用户发布一条动态后,粉丝在几百毫秒内看到即可,不需要严格的线性一致性。

但需要处理几个边界情况。第一,取关后的时间线清理:用户A取关用户B后,B的历史动态应该从A的时间线中移除。实现方式是在A取关B时,异步发送一个“时间线清理”事件,从A的Redis Sorted Set中移除所有B的动态ID(ZREMRANGEBYSCORE无法按member删除,但可以用ZREM timeline:{A_id} feed_id_B1 feed_id_B2 ...)。由于取关操作频率远低于发布操作,这个清理可以异步执行且允许短暂不一致。

第二,删除动态的处理:用户删除了某条动态,需要从所有粉丝的时间线中移除该动态ID。这本质上也是一个扩散操作,但删除的扩散优先级低于发布的扩散。实践中,可以在查询时间线时做一次过滤——获取动态ID列表后,批量查询动态状态,过滤掉已删除的动态。

第三,新注册用户的冷启动问题:新用户没有关注任何人,时间线为空。可以在新用户注册时推荐一批热门用户自动关注,或者展示一个全局热门Feed(Hot Feed),由独立的推荐系统计算。

八、处理热点与缓存策略

热点动态(如明星官宣、重大新闻)会带来极端的读写压力。当一条动态的访问量远超普通动态时,单节点缓存可能被打穿。应对策略包括:

多级缓存架构:浏览器/客户端缓存 → CDN边缘缓存 → 反向代理缓存(Nginx)→ 本地缓存(Caffeine/Guava Cache)→ 分布式缓存(Redis Cluster)→ 数据库。热度越高的内容,越靠近客户端缓存。

对于热点Feed详情,可以在Redis中使用多副本策略:同一个key(如feed:detail:{feed_id})在多个Redis节点上存储副本,读取时随机选择一个节点。这样将读压力分散到多个Redis实例。

对于时间线的热点问题——超级大V发布动态时,大量粉丝同时刷新时间线。在推拉结合模式下,大V的动态不扩散,粉丝通过拉模式获取。但如果有数百万粉丝同时拉取同一个大V的发件箱,这个大V的outbox Redis key会成为热点。解决方案是将发件箱数据通过CDN分发——大V的动态列表可以缓存到CDN边缘节点,由CDN承担大部分读流量。或者使用Redis的读写分离:主节点写入,多个只读副本提供读取。

九、面试常见追问

问题一:如何实现在大V和普通用户同时存在时的时间线归并?

答案是推拉结合模式下的时间线合并算法。假设用户A关注了200个普通用户(推模式,动态已写入A的时间线)和10个大V(拉模式,需要实时拉取)。加载A的时间线时:第一步,从A的Redis时间线获取最新的N条动态ID;第二步,并发查询10个大V的发件箱,分别获取最新N条动态ID;第三步,将所有动态ID按时间戳归并排序,取前N条;第四步,批量查询动态详情返回。这里的N通常取50到100。归并排序可以使用一个大小为K的最小堆(K = 普通用户来源数 + 大V数 = 1 + 10 = 11),堆顶元素即为下一条最新动态。

问题二:Redis Sorted Set的内存优化有哪些手段?

Sorted Set的member存储的是动态ID(Long类型),但Redis内部会将数字字符串也存储为SDS(Simple Dynamic String),内存开销较大。优化手段:使用ziplist编码——当Sorted Set的元素数量较少(默认小于128)且元素长度较短(默认小于64字节)时,Redis使用紧凑的ziplist存储,内存效率远高于跳表。可以通过zset-max-ziplist-entrieszset-max-ziplist-value配置参数调大阈值。对于主要存储最近动态的时间线场景,很多用户的最新几百条动态都可以使用ziplist。另一个优化是定期清理超过一定条数(如1000条)的旧动态,通过ZREMRANGEBYRANK保留最新N条。

问题三:如何估算系统的QPS和存储容量?

这是系统设计面试的必考环节。假设DAU为一亿,平均每个用户每天刷新时间线20次,则每日时间线读取请求为20亿次,平均QPS = 20亿 / 86400 ≈ 23148 QPS。峰值QPS通常取平均的3到5倍,约115K QPS。写操作:每日发布约五亿条新动态,平均写QPS = 5亿 / 86400 ≈ 5787 QPS。存储估算:每天五亿条动态,每条平均1KB(文本+元数据),每日新增约500GB存储,每年约182TB。加上图片视频等媒体资源,总存储量需根据媒体占比调整。Redis内存估算上文已讨论,约需5TB存储活跃用户时间线。

本篇文章从需求分析出发,系统性地拆解了新鲜事系统的核心设计要点。掌握了推拉结合模式、时间线存储方案、Redis Sorted Set的使用以及扩展策略后,你就可以自信地应对类似系统设计的面试问题了。在实际工程中,还有许多细节需要深入——比如垃圾内容过滤、推荐算法排序、多数据中心同步等,但这些通常不是系统设计面试考察的重点。

文章作者: Leo·Cheung
文章链接: http://tufusi.com/2024/04/05/%E7%B3%BB%E7%BB%9F%E8%AE%BE%E8%AE%A1%E4%B9%8B%E8%B5%B0%E8%BF%9B%E7%B3%BB%E7%BB%9F%E8%AE%BE%E8%AE%A1%E4%B8%8E%E6%96%B0%E9%B2%9C%E4%BA%8B%E7%B3%BB%E7%BB%9F/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ONE·PIECE
打赏
  • 微信
  • 支付宝

评论