你有没有在面试现场接过这么一道题:“设计一下抖音的关注系统。”
我见过太多候选人拿到题目后的第一反应是——这有什么好设计的,不就是一张关注表吗?A关注B,insert一条记录,粉丝数加一,完事。然后他们就挂了。这道题之所以在字节跳动的系统设计面试中挂人率接近九成,恰恰是因为它看起来太简单。关注系统表面上是关系模型,本质上是一个高并发写入、大规模读取、强时效、高扇出的社交关系系统。它同时考察你的数据建模能力、缓存设计、异步架构、一致性取舍,还有你在模糊需求下“问清楚再动手”的职业习惯。
这篇文章会把这道题从需求拆解到落地实现完整过一遍,给出一套可以直接写在白板上的答题框架和可复现的数据结构,再附上我整理过的面试追问与避坑实录。文章内容以抖音这类短视频产品的关注关系为场景展开,但底层的存储选型、分库分表、缓存策略和异步方案,放到微博、知乎、B站、小红书几乎都一样。适合正在准备大厂系统设计面试的后端开发同学,无论是校招还是社招,认真看完都能直接用。
1. 面试官到底在考什么:先想清楚边界,再谈设计
很多人一听到“设计关注系统”就条件反射去画架构图,这是最典型的送人头操作。面试官给的信息通常只有一句话:“设计一下抖音的关注系统。”你想,连DAU、功能范围、团队规模都没有,你能设计出什么来?这是第一道隐藏门槛。
1.1 一道看似简单的需求,背后藏着哪些隐藏约束
先梳理功能需求。关注系统最核心的能力是:关注、取关、查看关注列表、查看粉丝列表、判断两个用户是否互关、展示粉丝数/关注数。稍微往外扩一点,还有黑名单/拉黑、批量关注、关注通知。再往上层走,是Feed流推送、私信权限、直播开播提醒,这些虽然不是关注关系的核心,但也要知道它们会依赖关注数据。
非功能需求更关键。抖音的体量摆在那:日活过亿,注册用户数十亿。一个头部明星可能一夜之间涨粉几千万,这种热点流量峰值非常恐怖。关注关系总量保守估计在数百亿到上千亿行。读多写多,不是典型的读多写少场景。用户可以频繁关注和取关,但单用户的操作频率并不高,问题是用户基数太大,整体请求QPS就会非常高。
一致性上也有讲究:关注这个动作用户是能直接感知的,你点了关注,立刻要在界面上变成“已关注”,并且你关注的人要出现在你的关注列表里,这是必须要保证的。但粉丝数、通知、Feed流这些,稍微延迟几秒甚至几十秒,用户是感知不到的。所以这个系统里最重要的一条原则是:关注关系必须强一致,粉丝数可以最终一致。
我建议你在面试里主动把这点抛出来。它会瞬间拉开你和普通候选人的差距。
1.2 为什么九成候选人挂在这里
我总结过大量模拟面试和真实面经,挂掉的人普遍踩了这么几个坑:
第一,上来就谈技术,不聊需求。开口就是Redis、消息队列、分库分表,却没有先问一句“这个系统的量级大概是多少”。面试官不是不知道这些技术,他是在考察你会不会先弄清楚问题再动手。
第二,只会列功能,不会拆场景。能说出需要关注列表和粉丝列表,但完全不提黑名单、取关、批量关注、互关判断。这些细节不是可有可无,它决定了你的数据模型长什么样。比如黑名单如果不做,你的缓存结构里就要多留一套存储方案。
第三,说不清一致性边界。“粉丝数必须实时更新”这种答案一出来基本就凉了。你要知道,抖音的粉丝数本身就是异步更新且有容错的,甚至可以在Redis里做聚合延迟刷盘。把它当成强一致需求,意味着成本会成倍上升,但又没有任何产品价值。
第四,不做容量估算。问“关注关系有多少行”,很多人答“很大”,但说不出一行估算公式。其实很简单:日活1亿、人均关注200人,关系行数就有1亿×200 = 200亿。一张MySQL单表存5000万行就快扛不住了,200亿行必须分库分表。你能把这个推演过程说出来,就已经赢了一半。
这一节的核心结论是:系统设计面试本质上是沟通加决策的模拟,不是给你一道明确的题目让你做题。谁能主动澄清需求、划定边界、做出合理的容量估算,谁就已经把90%的人甩在身后了。
2. 数据库模型怎么设计,才扛得住亿级关系
需求聊明白了,才轮到技术。设计关注系统的第一步不是画架构图,而是画数据模型。数据模型一旦定了,整个系统的骨架就定了。
2.1 一张核心表,还是两张?关注关系的底层存储方案
关注关系最直接的表结构是这样的:
CREATE TABLE `user_relation` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `user_id` bigint(20) NOT NULL COMMENT '关注者ID', `follow_user_id` bigint(20) NOT NULL COMMENT '被关注者ID', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '1关注 0取关', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_follow` (`user_id`, `follow_user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户关注关系表';这里有两个关键设计决策。
第一个决策:用status软标记,而不是直接删除记录。用户在取关之后可能会再次关注同一个创作者,频繁的insert/delete会产生大量MySQL碎片和binlog风暴,而且无法做幂等。用status=0/1标记状态,配合唯一索引,重复关注时只需要执行:
INSERT INTO `user_relation` (`user_id`, `follow_user_id`, `status`) VALUES (1001, 2001, 1) ON DUPLICATE KEY UPDATE `status` = 1, `update_time` = NOW();这样天然幂等,不管请求重试多少次,结果都是一致的。
第二个决策:关注和粉丝要不要拆成两张表。上面这张表是“关注表”,查“我关注了谁”非常快,因为索引uk_user_follow直接走了user_id。但要查“谁关注了我”,也就是粉丝列表,就必须拿follow_user_id去扫描,这就是全表扫,完全没办法接受。
所以生产环境通常会把关系拆成两张表,或者直接做冗余。一张叫关注表(following),存的是user_id指向follow_user_id;一张叫粉丝表(follower),存的是follow_user_id指向user_id。两张表在写入时通过同一个事务保持一致。虽然会增加写放大,但把读写都变成了标准的“按主查询键走索引”,这个代价非常值得。
2.2 Redis里的数据结构怎么选:别只会用String
Redis是整个关注系统性能的命脉,但数据结构选型很讲究。
关注列表和粉丝列表建议用ZSET,也就是有序集合。member存用户ID,score存关注时间戳。为什么要用ZSET而不是LIST或者SET?因为关注列表天然需要按时间倒序展示,ZSET的score就是排序列,直接ZREVRANGE取一页数据,既去重又排序,性能非常稳。
# 用户1001关注了用户2001,score用当前时间戳 ZADD following:1001 1699999999 2001 # 倒序查看用户1001的关注列表,一页20条 ZREVRANGE following:1001 0 19 # 判断用户1001是否关注了2001 ZSCORE following:1001 2001粉丝数就直接用String计数器,INCR/DECR都是O(1)。但这里有个细节:粉丝数不应该直接存在关注/粉丝列表的同一个key里,要单独用follower_count:{uid},因为计数和列表的生命周期不一样,混在一起会导致缓存失效时互相拖累。
还有,互关判断用ZSCORE比用SISMEMBER更自然,因为你用ZSET时天然能拿到排序关系,两个人都能查到对方的score就说明互关了。如果用的是SET,判断存在性没问题,但你要做时间排序就得重新查一次数据库,多一次往返。
2.3 为什么不能“一张表加一个Redis”走天下
很多基础薄弱的候选人设计到这里就觉得完事了。但你要知道,200亿行的关系数据,单表肯定搞不定,需要分库分表。分片键怎么选?关注表按user_id分片,因为所有查询都带user_id,比如查我的关注列表就是WHERE user_id = 1001,路由到固定分片就行。粉丝表按follow_user_id分片,因为查粉丝列表时带的是目标用户的ID,同样能精确路由。
分片算法可以按user_id取模,也可以按UID范围分片。抖音这种体量通常是按范围加一致性哈希的组合方案。取模实现简单,但扩容时要迁移数据;范围分片有利于顺序扫描和批量导出,但可能造成数据倾斜。面试中说清楚取模的适用场景,再提一句“可以用一致性哈希解决扩容问题”,面试官就会觉得你对分片是真的理解,而不是背概念。
Redis端也有坑。一个千万级粉丝的明星,如果粉丝列表ZSET全量缓存,单key内存可能到几百MB甚至上GB,一旦这个key失效或者热点访问,分片节点会直接被打爆。所以大V场景不要全量缓存,只缓存Top N热门粉丝,完整列表回源数据库,配合本地缓存解决热key问题。这个思路后面会展开讲。
3. 关注、取关、粉丝列表:从接口定义到完整实现
数据模型定完了,开始走接口流程。这一节我会把实际操作中的完整链路拆开,每一步做什么、为什么这么做,一次性讲透。
3.1 关注接口的完整链路:别只看一行SQL
关注接口的对外形态一般是:
POST /api/v1/relation/follow Body: { "user_id": 1001, "target_user_id": 2001 }完整的写入流程分几步。第一步做参数校验:user_id不能等于target_user_id,target_user_id要存在且没有被当前用户拉黑。第二步写Redis,把target_user_id写入following:{user_id}的ZSET,把user_id写入follower:{target_user_id}的ZSET,同时给follower_count:{target_user_id}做INCR。第三步写数据库,执行前面的INSERT ... ON DUPLICATE KEY UPDATE。第四步发送异步消息,通知被关注用户、触发Feed流拉取、更新可能存在的搜索索引。
为什么要先写Redis再写数据库?因为用户关注成功后立刻要看到状态变化,MySQL的写入有磁盘IO和锁竞争,走一趟可能几十毫秒;而Redis写内存是微秒级。先更新缓存,用户感知就是秒开。但是顺序不能反过来,必须缓存先写、消息队列后发。如果先发消息再写缓存,消费者可能读到一个过期的状态。这里真正的核心是:数据库是唯一数据源,Redis是加速层,两者最终一致就行,不需要实时一致。
还有一个实操细节:关注接口要做限频。正常用户一分钟能关注多少人是有上限的,比如默认每分钟最多关注20个。关注行为如果像刷接口一样高频,背后的Feed流、通知、计数都会被打爆。你可以在网关层基于Redis做滑动窗口限流,这个是加分项。
3.2 关注列表与粉丝列表的查询与分页
查询关注列表的接口是:
GET /api/v1/relation/following?user_id=1001&cursor=1699999900&limit=20这里我用cursor而不是page offset。为什么?因为深分页是列表查询的头号杀手。传统LIMIT 10000, 20在200亿行的分片表上会越来越慢,因为它要把前面的10000行全部扫描之后丢掉。而cursor分页只需要基于score游标往下取:
# 第一次:按时间倒序取第一页 ZREVRANGEBYSCORE following:1001 +inf -inf LIMIT 0 20 # 第二次:用上一页最后一个score作为游标继续取 ZREVRANGEBYSCORE following:1001 1699999800 -inf LIMIT 0 20如果Redis没有缓存,回源数据库的SQL是:
SELECT follow_user_id FROM user_relation WHERE user_id = #{userId} AND status = 1 ORDER BY create_time DESC LIMIT #{offset}, #{limit};这个SQL在数据量小的时候没问题,但分片之后建议改成基于时间游标的方式:WHERE user_id = ? AND status = 1 AND create_time < ? ORDER BY create_time DESC LIMIT 20。这样索引下推能精准定位,扫描的行数固定在很小的范围内。
粉丝列表的查询逻辑完全照搬,只需要把表换成粉丝表、把key换成follower:{target_user_id}。这里真正要注意的是缓存穿透:如果一个冷门用户压根没有缓存,大量请求直接打到数据库,数据库会瞬间拥塞。所以查询时加一个“空缓存标记”,比如ZSET follower:{uid}不存在时先查DB,如果DB也没数据,就写入一个空值缓存并设置短过期时间。
3.3 互关、拉黑、批量关注:拉开差距的细节考点
互关判断是高频场景,比如两个用户A和B,A的主页要显示“你们互相关注了”。实现上在Redis里做两个ZSCORE查询:
ZSCORE following:A B ZSCORE following:B A两个都有值就是互关。用MGET合并请求或者Pipeline批量执行,两个查询合并成一次网络IO,性能会好很多。
拉黑场景相对独立。拉黑之后,被拉黑的人不能再关注你、不能给你发私信、你在TA的粉丝列表里也要被过滤掉。存储上单独维护一个ZSET:
ZADD block:1001 1699999999 2001过滤时在应用层做:先取出当前页的粉丝ID列表,批量判断是否在block:1001里,在的话跳过。也可以直接用ZDIFF求差集,但ZDIFF在数据量大时是O(N)复杂度,所以高并发接口建议走应用层批量SISMEMBER判断,几十个ID一批,单次批量查询也就几毫秒。
批量关注接口的核心是一次性处理通讯录导入或推荐关注引导,逻辑上就是在一个循环里批量执行关注流程。但要注意分寸:一次事务里最多处理100个ID,多的拆批,因为单个事务持锁时间越长,死锁和数据库阻塞的概率越大。另外批量接口必须做“部分成功”处理,返回成功和失败的明细,失败原因要么是用户已关注、要么是对方设置了隐私保护,不要说挂就挂。
4. 高并发场景下的架构演进与一致性取舍
到这里,单机方案的完整链路已经清楚了。但抖音这种体量,光靠一台MySQL和一台Redis必然撑不住。下面这部分才是面试的高潮:你要展现出“我知道系统在什么阶段会崩,以及怎么演进”。
4.1 从单机到分布式:哪个环节最先崩
假设一开始是单库单表加单Redis,系统会按这个顺序出问题。
第一波崩溃往往在数据库。关系表数据量涨到几千万行时,写入开始变慢,索引深度变大,查询的响应时间从1毫秒慢慢爬升到几十毫秒。第二波在Redis热key,某个千万粉丝的明星突然上热搜,大量用户涌入TA的主页看粉丝数,follower_count:{uid}这个key一秒被读几十万次,单个Redis分片的CPU直接跑满。第三波在“写放大”,一个头部明星被几百万人同时关注,每一笔关注都要写关注表、粉丝表、Redis ZSET、消息队列,任何一个环节慢下来都会形成积压。
所以架构演进不是一步到位,而是跟着瓶颈走。初期单库单表加Redis缓存可以支撑百万级用户;用户到千万级时,关注表和粉丝表拆开,读写分离,Redis做ZSET缓存;到亿级用户时,关注表粉丝表分库分表,计数服务独立成微服务,消息队列做全链路异步化。你能把这条演进路径说出来,面试官就知道你不只是在背书。
4.2 分库分表怎么做,路由键怎么选
分片是关注系统绕不开的坎。前面提过按user_id分关注表、按follow_user_id分粉丝表,这里我再细讲一下为什么不能反过来。
关注表的查询语义永远是“我关注了谁”,SQL必然带user_id,按user_id分片就能做到单次查询只命中一个分片。如果你按follow_user_id分片,查自己的关注列表就得广播到所有分片,然后聚合结果,这会慢到不可接受。粉丝表的查询语义永远是“谁关注了目标用户”,SQL必然带follow_user_id,所以按follow_user_id分片同理。
这背后其实是一条普适规律:分片键必须和主要查询路径的筛选键一致。谁违反这条规律,谁就在分片集群上写聚合查询,谁就等着超时。
分片之后还有全局唯一ID问题。比如ID不能用MySQL自增,因为多个分片会重复,要用Snowflake算法生成全局唯一ID。你在白板上顺带写出一个64位ID的结构:1位符号位 + 41位时间戳 + 10位机器ID + 12位序列号,面试官会对你的基本功印象很深。
4.3 粉丝数、通知、Feed流的最终一致性方案
关系表的写入必须同步完成,但粉丝数、通知、Feed流这些衍生数据全部都可以异步。
粉丝数的更新链路是这样的:关注接口在事务提交成功后,投递一条“关注事件”到消息队列。计数服务消费事件,在内存里做聚合。什么意思?比如同一秒内有1000个人关注了同一个明星,如果每来一条就刷一次数据库,数据库根本扛不住。计数服务把这1000个事件合并成一条批量更新,一次性把粉丝数加1000。极端情况下粉丝数会有几十秒的短暂延迟,但抖音上的粉丝数本身就不会精确到个位,用户根本感知不到。
Feed流的更新更典型。用户关注了一个新创作者之后,理论上要在TA的首页信息流里立刻看到这个创作者的新内容。这里面有两条路线:拉模式和推模式。拉模式是用户刷新Feed流时才去聚合TA关注的所有创作者的最新内容,实现简单但刷新延迟大;推模式是关注成功后就把创作者最近的N条内容写入用户的时间线收件箱,读取快但写放大严重。抖音这类超大规模场景用的是推拉结合:普通用户走推模式,关注时把内容灌进收件箱;头部大V的数据不灌,等用户刷新时再实时拉取,避免粉丝几千万的大V把全站收件箱写穿。
面试官在这里真正想看的是:你能不能分清楚哪些环节必须同步、哪些可以异步。你要明确说出来——关注关系本身必须同步返回,因为用户点了关注要立刻看到结果;但粉丝数和Feed流这些衍生数据可以异步,因为秒级延迟对用户无感,而异步化能帮系统扛住流量峰值。这句话是整个关注系统设计中最核心的权衡,没有之一。
5. 面试答辩技巧与避坑实录:这些才是真正拉开差距的地方
前面讲的是知识,这一节讲的是临场。我见过太多技术不错的人挂在表达和问题上,所以这部分我会给出一套可以直接背下来的答题框架。
5.1 面试官最爱的三个追问及接法
我把字节系面试官最爱追问的问题整理成一个对照表,每个问题后面附上参考思路。
| 追问方向 | 典型问法 | 参考回答思路 |
|---|---|---|
| 热点问题 | 如果一个大明星突然涨粉1000万,你的方案扛得住吗 | 大V的粉丝列表ZSET不缓存全量,只缓存Top N热门粉丝;计数走多级缓存加异步合并写;消息队列削峰,数据库分片限流 |
| 一致性 | 缓存和数据库不一致怎么办 | 先写数据库,后删缓存;或者监听binlog同步更新缓存;这个场景能接受秒级不一致,所以不用强一致方案,代价可控 |
| 选型 | 为什么粉丝数用String,关注列表用ZSET | String计数是O(1)读写,简单高效;ZSET支持范围查询和按score排序,还能做互关判断和游标分页 |
这里我要特别强调第二问。很多候选人一听到缓存一致性就条件反射去背“Cache Aside、Read Through”这些名词,然后被追问到“到底保不保证强一致”就卡住了。正确的姿势是反问一句:“在这个场景里,我们真的需要强一致吗?”你说不需要,然后解释为什么,面试官会觉得你是在做工程决策,而不是在背八股。
5.2 一套可以直接背下来的答题框架
我建议所有准备这道题的人都按“三段式”组织答案。
第一段是需求澄清,时间控制在2分钟。说辞大概是:“我先确认一下需求。关注系统需要支持关注、取关、关注列表、粉丝列表、互关判断和粉丝数展示,黑名单和批量关注也要考虑进去。我理解当前不需要讨论Feed流和私信,但会为它们预留扩展空间。非功能上我按日活1亿、总关系量200亿行来设计,允许粉丝数和Feed流有秒级延迟,但关注关系必须强一致。”
这段话一出来,整个答辩的基调就定了,面试官会知道你不是上来乱画图的人。
第二段是数据模型设计,时间控制在4分钟。把关注表和粉丝表的设计讲清楚,再补上Redis的ZSET、String、Block三套缓存结构,顺手写下刚才的DDL和Redis命令。这段要画白板,别光说名词。
第三段是架构演进,时间控制在3分钟。从单机方案开始,说明在1000万用户量级的分库分表策略,再说热点大V的实时拉取模式和普通用户的推模式,最后说明消息队列解耦和最终一致性方案。到这里,一个完整的系统设计题就答完了。
整个时长控制在9到10分钟,正好是面试官预期的时间窗口。太短说明没有细节,太长说明抓不住重点。
5.3 我观察到的几个最容易挂的坑
整理真实面经时,我发现挂掉的原因大多数不是技术深度不够,而是下面这些软性问题。
第一个坑,不给数字。说“数据量很大”但没有容量估算,“抗住高并发”但说不出预估QPS。我给一个参考:关注接口写入QPS按DAU的1%峰值估计,1亿日活就是100万QPS的写峰值,读峰值通常是写的5到10倍。这个数字不一定准,但你有估算逻辑就比没有强。
第二个坑,过度设计。一上来就上Kafka、TiDB、分布式事务、异地多活,但连第一版单机方案都说不清楚。面试官会怀疑你根本没有独立做过系统。正确节奏一定是从简到繁,先把最简单可用的方案说透,再按瓶颈点逐个演进。
第三个坑,忽略可用性。只讲了正常流程,不提降级方案。比如Redis全部宕机之后怎么办?你要说数据库里有全量数据,缓存挂了可以降级到数据库查询,只是延迟变高;消息队列积压了怎么办?用对账任务定期校准粉丝数和关系状态。这一句“降级预案”往往就是通关的最后一块拼图。
第四个坑,被追问时慌张改答案。有时候面试官会故意说“你的方案有问题”,然后就盯着你看。他可能是在测试你的抗压和判断力。技术方案只要讲清楚取舍,就没有绝对的对错。你可以坦诚说“这个方案在XX场景下的确有问题,我会考虑用XX去兜底”,但不要因为被质疑就完全推翻自己的设计。
最后,别忘了在白板上写公式比写代码更让面试官眼前一亮。你可以在开头顺手写下:
关系行数 = 日活用户数 × 人均关注数 × 冗余系数 ≈ 1亿 × 200 × 2 = 400亿行
这个公式一写,容量规划就有了依据,后面的分库分表、缓存策略、异步化也都顺理成章。面试官会觉得你不是在背答案,是真的设计过线上系统。
这道题我陪跑过不少候选人,也帮人模拟过很多轮,真正的分水岭从来不在于你背了多少架构名词,而在于你能不能把一个看起来简单的功能拆成一组有主次、有取舍的决策。“需求边界、数据建模、缓存与存储分层、一致性取舍”这四件事想清楚,哪怕最后架构不炫,面试官也会给你一个稳妥的评价。希望这套思路能帮你绕开那九成人掉进去的坑,稳稳拿到想要的Offer。