以秋招原题为例,复盘系统设计
大家好。今天要复盘的这道系统设计题,是我秋招时在面试某大厂遇到的真题。
当时面试官一脸和善地问我:“平时刷抖音吗?如果要你设计一个类似的点赞、关注系统,你会怎么做?”
看似简单的业务场景,一旦加上“高并发”三个字,难度瞬间就不是一个量级了。这不仅考你的CRUD能力,更是在考你对流量洪峰、数据一致性以及极端场景(如大V)的处理经验。
今天我就带大家还原一下当时的解题思路。
题目:一个高并发社交互动系统
背景:
假设你正在为一个类似小红书/抖音/快手的短视频平台设计核心社交互动模块。该模块需要支持用户对内容(如视频)和其他用户进行多种“轻量级”互动行为,包括:
关注 (Follow):用户 A 关注用户 B。
点赞 (Like):用户对视频进行点赞。
收藏 (Favorite):用户将视频收藏。
系统需要高效支持以下核心功能:
1.写入操作:
用户执行关注、点赞、收藏操作。
用户取消关注、取消点赞、取消收藏操作。
2.读取操作:
正向查询:获取指定用户 user_id 的“关注列表”、“点赞视频列表”、“收藏视频列表”。
反向查询:获取指定视频 video_id 的“点赞人列表”、“收藏人列表”,以及指定用户 user_id 的“粉丝列表”(即关注他的人)。
计数查询:获取指定视频 video_id 的“点赞总数”、“收藏总数”,以及指定用户 user_id 的“粉丝总数”。
3.非功能需求:
高并发:点赞、收藏等操作在热门视频下可能达到极高峰值(如每秒数万次)。
低延迟:列表查询和计数查询需要在毫秒级响应。
高可用:核心功能不可用时间需极短。
数据一致性:点赞数、粉丝数等计数必须准确,避免超卖或丢失。
解题思路:我们在面对系统设计题,笔者建议可以从以下几个方向去思考:
1.数据模型抽象 2.存储方案设计 3.核心功能实现 4.性能与扩展性优化 5.容错与数据一致性
1.数据模型抽象:
仔细一看你会发现,不管是“关注”、“点赞”还是“收藏”,它们本质上就只有两件事:
一、计数(Counting): 这个视频有多少赞?这个人有多少粉丝?
二、关系(Relationship): 谁赞了谁?A关注了B。
所以,我们的系统核心其实就是两个通用模型:通用计数服务 和 通用社交关系服务。
对于第一点,很多同学第一反应是:这还不简单?MySQL存一条记录,Redis缓存一下数值。
由此,我们回顾一下 通用计数模型 的底层存储结构
Mysql的主要设计如下:
id 自增id(无业务含义) entity_id 业务实体唯一id(比如笔记id)用于分库分表key count_type 计数类型(比如1表示笔记点赞数)全局唯一 value 计数值 create_time 记录的创建时间戳 update_time 记录的修改时间戳
Redis主要设计如下:
{
"${entity_id}": {
"like_count": "1000", // 点赞数
"collect_count": "100", // 收藏数
"comment_count": "10" // 评论数
}
}
但我们需要思考一下,这样做会有什么问题?在高并发下,这就涉及到了经典的“缓存一致性”问题。先写库还是先写缓存?怎么保证Redis挂了数据不丢?其实角度很简单,就从读和写的角度拆开来看
1.读,读操作比较好理解,从Redis读,如果没有就从Mysql回源
2.写,相信大家对于写操作的四种模型都有过理解,无论先写Mysql还是先写Redis,在这里四种模型都会有问题,因篇幅有限,问题在此不作详细赘述。
其实笔者想介绍一种更新的,类似本地消息表的机制,即引入操作记录表(流水表)。我们不直接在高频更新的表上做 update count = count + 1,那样数据库锁竞争太严重。
其实我们可以把所有的点赞、取消点赞,关注、取消关注等,本质上都看作是一条流水记录,我们要把“计数”变成“流水”,由此我们引入了操作记录表。
操作记录表的设计如下,实际上就是参考本地消息表中的流水表设计:
id 自增id(无业务含义) entity_id 业务实体唯一id(如笔记id)用于分库分表key count_type 计数类型(如1=笔记点赞数、2=笔记收藏数)全局唯一 value 计数值 operation_type 操作类型(如SET=1、INCR=2)全局唯一 status 数据流转状态 retry_count 重试次数 create_time 记录的创建时间戳 update_time 记录的修改时间戳
我们可以把操作记录表和Redis的操作放在一个事务里,注意这里的操作记录表是顺序写,写性能比随机写(直接写计数表)性能高,其次可以利用该流水表的特性,做DTS数据同步加后续的MQ处理操作,相当于在性能提高的同时,把操作解耦开来。
这样一来,我们的写操作就变成了,Transaction{写操作记录表 + 写Redis},注意在事务里这两个操作的顺序是不能变的!然后再通过定时任务/mq等方式去同步至Mysql通用计数表中。并且由于将写操作解耦,还可以用此表做数据补偿,并且下游业务功能有扩展时满足了良好的开闭原则。
对于第二点,社交关系模型Mysql主要设计如下:
1.首先是社交关系表,其实最关键的就三个字段,发起方,承接方,关系类型,简化设计如下:
id 主键 type 关系类型 from_id 发起方 to_id 承接方 create_time 创建时间 update_time 更新时间
比如用户A关注用户B,则写入两条记录,A关注B,B被A关注,这就是单表双向的社交关系设计,不要把表看作是“A关注了B”的动作记录,而是看作“A的社交状态列表”。
因此,只需要在form_id和type上建索引即可,而无需关注to_id。
同理,分库分表也如此,我们可以先按type分库,比如点赞库、关注分库,然后再按from_id去分表即可;或者只按from_id去分库分表即可。通常而言,第二种方案会更常见,因为你打开一个笔记,通常需要展示这个笔记的点赞数、收藏数、转发数,这是由业务场景决定的。
2.社交关系模型缓存设计:
对于关注列表、点赞列表,我们通常需要按时间倒序(或算法分数)排列,通常采用Zset解决,简化设计如下:
Key: following:{user_id} 或 video_likers:{video_id}
Value: target_id (关注的人的ID 或 点赞人的ID)
Score: timestamp (操作时间戳)
这样一来我们可以轻松实现 ZREVRANGE 分页查询。
但重点来了,面试官肯定会问你, 分页“如果我是周杰伦,几千万粉丝,你的缓存怎么设计?”
这是一个明显的大V热点问题
解决方案:截断缓存 (Cache Truncation)
如果一个视频有100万个赞,或者一个大V有5000万粉丝,直接把这5000万个ID全塞进一个 ZSet 会发生什么? Redis 会卡死,读取大 Key 会阻塞网络,扩容迁移也是灾难。我们要承认一个业务现实:没人会真的去翻完某大V的5000万个粉丝列表。 大家通常只看最近关注的几百个人,或者最近点赞的一批人。
所以,策略如下:
1.只缓存头部: Redis 的 ZSet 中,对于单个 Key,我们限制长度(例如只存最新的 5000 条)。
2.长尾读库: 当用户翻页翻到5000条以后(极低频操作),直接去查询 MySQL(或者更底层的搜索引擎/HBase)。
3.写入策略: 新增点赞时,往ZSet里ZADD,同时检查如果超过长度,就ZREMRANGE删掉最旧的数据,保持缓存轻量。
那么第2个问题来了,如何快速判断“我是否点赞过”?
在渲染视频列表时,系统需要知道“我”有没有点赞过这些视频,把心形图标点亮。如果每次都去查DB或遍历 ZSet,效率太低。这里我们可以采用一种轻量级的解决方案,可以在客户端或服务端维护一个布隆过滤器。虽然有误判率(显示点赞了实际没点,或者反之),但在这种轻量社交场景下,用户体验是可以接受的,换来的是极大的性能提升。
这是在面试过程中可以回答出来的一些点,如果想了解完整的社交关系模型,笔者建议大家可以去看一下YouTube中的Tao设计,或小红书的RedTao设计,这些解决方案都是可以支持社交关系过万亿,QPS超百万的场景。
那最后的最后,笔者想再聊一下容错与数据一致性的事情
我们首先聊一下一致性级别
1.强一致性
要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
2.弱一致性
系统在写入成功后,不承诺立即可以读到写入的值,也不久承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
3.最终一致性
最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。最终一致性是业界在大型分布式系统的数据一致性上比较推崇的模型
常见的分布式事务解决方案通常分为三种:2PC、3PC、TCC,大家有兴趣可以了解一下
那本设计实际上是采用了最终一致性,也就是依赖流水表去做DTS同步
到此,我们的拆解题目就结束了,由于笔者还在上学精力有限,文中有描述不完整或漏掉的地方大家还请多多包涵,后续笔者也会补充~
最后,笔者想推荐几本书籍,
《亿级流量系统架构设计与实战》
《Redis 高手心法》
《MySQL 是怎样运行的-从根上理解MySQL》
《Apache RocketMQ 进阶之路》
《数据密集型应用系统设计》
《深入理解JVM虚拟机》
祝大家新的一年工作顺利,身体健康~#牛客AI配图神器#
凡岛公司福利 613人发布