常见的系统设计场景题
忽有故人心上过,回首山河已是秋。
- 粉丝关注列表如何设计和落地
- 微博的“共同关注”功能如何设计?
- 帖子存储和优化的设计
- 帖子点赞业务如何落地
- 关注用户发帖子了,要如何设计实现和落地(相似场景:朋友发朋友圈了,我可以看见)
- 数据库连接池怎么设计以及优化?
- 分布式集群中如何保证线程安全?
- 扫码登录怎么设计?
粉丝关注列表如何设计和落地
首先呢找一个类似的场景,就比如微博的关注列表,主要分为两块,一个是粉丝,也就是关注我的follower,另一个是我的关注attention
既然是列表,最主要的设计就在与如何高效的查询数据,存储数据
最先想到的是使用mysql,主要用到两张表,一个是user_info,主键是userId,还用用的其他字段比如头像链接,性别等信息;另一个就是建立一个follow_attention表,主要是两个字段,关注者id和被关注者id,我们在查询的时候首先查出follow_attention里的id,然后根据结果去user_info里再去查询即可;
但是呢这样会有一个问题,也就是如果我们总共有10w用户,那么follow_attention表就会有n方也就是100亿的数据,这个量级太大了,先不说存储成本,光考虑查询的话,每次都要数据库单表扫描会非常慢,所以呢可以考虑水平拆分(分片存储),根据用户的id作为分片的键,采用hash的办法可以降低查询成本
但是还是会有问题,比如说我根据关注者的id分片,如果要查我关注了谁,可以用到分片,如果要查谁关注了我是,每个id落在了不同的分片上,还是无法解决,根据被关注者的id分片也会遇到类似的问题;
于是可以考虑使用垂直拆分,将follow_attention表拆分成两个即follower表和attention表
举例说明:
假设我们有一个用户A:
- 用户B、C、D关注了用户A(即用户A的粉丝是B、C、D)。
- 用户A关注了用户E、F、G(即用户A关注了E、F、G)。
那么:
- follower表中会存储:
(A, B)
(A, C)
(A, D)
- attention表中会存储:
(A, E)
(A, F)
(A, G)
粉丝关系和关注关系分开存储,避免了单表的臃肿,降低了单表的数据量。
但是还是面临单表数据过大的问题,不仅如此还有热点用户的性能问题(对于某些热点用户(如明星),他们的粉丝数量可能达到几千万甚至上亿。查询这些热点用户的粉丝列表时:
- 即使使用了分表,单张分片表中的数据量仍然非常大。
- 查询时需要扫描大量数据,导致性能下降)
还有分页查询时的性能问题,比如会有深分页下查询效率很低的问题
为了解决上述问题,我觉得可以引入redis缓存:
我们可以使用Redis来缓存用户的粉丝列表和关注列表,例如使用zset数据结构
- 粉丝列表:使用
zSet
,key
为user:follower:{userId}
,value
为粉丝的ID,score
为关注时间的时间戳。 - 关注列表:使用
zSet
,key
为user:attention:{userId}
,value
为关注的人的ID,score
为关注时间的时间戳。
对于基本查询操作,采用旁路缓存的思路,先查缓存,没有得话在走数据库,然后加入缓存;对于关注他人,或者取消关注,为了尽可能的保证数据库和缓存的一致性,采用先更新数据库再删除缓存的策略;
对于热点用户的查询问题,考虑的实际的应用场景,新增的用户是放在最前面的,往往我们最前面的N页查询是比较频繁的,可以采取增量缓存的方式,例如只缓存最近新增的粉丝(前1000个),其余的从数据库查询,还可以考虑将热点用户的粉丝列表分片存储到多个reids实例中做一个缓存的分片存储
总结
- 数据库设计:使用
follower表
和attention表
来存储关注关系,并进行垂直拆分和水平拆分。 - 缓存设计:使用Redis的
Sorted Set
来缓存粉丝列表和关注列表,加速查询。 - 热点用户处理:采用增量缓存和分片存储来应对粉丝数量特别多的用户。
微博的“共同关注”功能如何设计?
目标:
当用户 A 访问用户 B 的主页时,显示他们 共同关注的用户,比如「你和 B 都关注了 @X、@Y、@Z」。
1. 直接 SQL 查询(适用于小规模数据)
如果数据库里有 follows
表:
1 | CREATE TABLE follows ( |
那么找 A 和 B 的共同关注者,可以用:
1 | SELECT f1.followeeId |
问题:
- 数据量大时查询变慢(例如某些用户关注上万人)
- 每次查询都要遍历大量数据
- 容易造成数据库压力
2. 用 Redis 加速(适用于高并发)
数据结构
利用 Redis 集合(Set) 存储用户的关注列表:
1 | SADD follows:1001 2001 2002 2003 # 用户1001关注了2001, 2002, 2003 |
查询共同关注
用 SINTER
(取交集):
1 | SINTER follows:1001 follows:1002 |
返回:
1 | 2002 2003 |
优点: ✅ 查询速度快(Redis 在内存里完成计算)
✅ 适合 高并发(避免数据库压力)
缺点: ❌ 占用内存较多(如果有 10 亿用户,每人关注 1,000 人,存储量很大)
❌ 不支持模糊查询(比如找「共同关注的明星」)
3. 用布隆过滤器 + Redis 分片(适用于超大规模)
如果关注数据非常庞大(比如微博级别),可以结合 布隆过滤器(Bloom Filter) 和 分布式 Redis:
- 布隆过滤器 用于快速判断是否有交集(减少 Redis 计算压力)
- Redis 分片 用于存储热点用户的数据,减少单机负担
流程:
- Step 1: 先用布隆过滤器判断 A 和 B 是否可能有交集
- Step 2: 只有可能有交集时,才去 Redis 计算
SINTER
- Step 3: 若查询频率高,可缓存结果到 Redis,设置短时间过期(如 10 分钟)
最终方案
方案 | 适用场景 | 查询速度 | 计算压力 |
---|---|---|---|
SQL 直接查 | 小型社交网络 | 较慢 | MySQL 负担大 |
Redis SINTER |
中等规模,千万级用户 | 很快 | Redis 负担 |
布隆过滤器 + Redis | 超大规模(微博、Twitter) | 极快 | 计算压力最小 |
结论:
- 小规模(几百万用户):直接用 MySQL+索引
- 中等规模(千万级):用 Redis
SINTER
- 超大规模(微博级):用 布隆过滤器 + Redis 分片
帖子存储和优化的设计
1. 最基础的帖子存储3
我们先来看最简单的做法:
每条帖子(post)都会存入 数据库(MySQL),常见的字段有:
postId
:帖子的唯一IDuserId
:发帖的用户IDpostTime
:帖子发布时间(精确到秒)content
:帖子内容
这样,当你想要查某个用户的帖子时,可以用 userId
作为筛选条件,再按照 postTime
排序:
1 | SELECT * FROM post WHERE userId = ? ORDER BY postTime DESC; |
但是,这种方法 效率不高,尤其是当数据量大了以后,查询会变慢。
2. 如何优化查询速度?
为了提高查询速度,我们可以 给数据库建立索引。一个常见的做法是给 userId + postTime
建立索引,这样查询就会更快:
1 | SELECT * FROM post WHERE userId = ? AND postTime BETWEEN ? AND ?; |
但是!这里有一个 回表(回查) 的问题。
索引查询的时候,数据库会先查索引表 找到满足条件的 postId,然后还得 去主表查询内容,这就多了一步操作,变慢了。
解决方案:让 postId 设计得更智能!
我们可以把 userId
和 postTime
的信息直接编码到 postId
里,比如:
postId
前 6位 表示userId
(36 进制,支持超大量用户)postId
中间 6 位 是postTime
(用 36 进制编码秒级时间戳,确保递增)postId
后 2 位 是用户该秒内发帖序号,确保不重复
这样一来,查询的时候 直接用 postId 就能查,不需要再回表:
1 | SELECT * FROM post WHERE postId LIKE '用户ID前缀%'; |
好处:
- 避免回表查询,提高查询速度
- 时间戳编码后是递增的,数据库扫描范围会更高效
3. 如何支撑超大规模的数据?
当帖子越来越多,单个数据库承受不住了,怎么办?
我们需要 水平拆分(Sharding),也就是把数据拆到不同的数据库上。
如何拆分?
我们按 userId
进行拆分,比如:
userId % 10 = 0
的用户去 DB0userId % 10 = 1
的用户去 DB1- 依次类推……
这样,每个数据库只存一部分数据,压力就平均分摊了。
问题:大V用户怎么办?
有些用户(比如明星)粉丝太多,查询量特别大,单个数据库还是顶不住!
解决方案:读写分离
- 主库 负责写(插入帖子)
- 多个从库 负责读(查询帖子)
- 读请求随机分配到不同的从库,分散压力
4. 如何加速查询?用 Redis 作为缓存!
数据库查询再快,还是比不过 缓存(Redis)。
我们可以把 最近一周的帖子 存在 Redis 里,避免数据库查询的压力。
Redis 设计
- Key:
userId + 周时间戳
(比如123456_202503
表示 2025 年 3 月的数据) - Value:Redis 的 Hash 结构,
field
是postId
,value
是帖子内容 - 过期时间:设置为 一周,自动清理老数据
查询逻辑
- 查询 Redis,如果缓存里有,就直接返回
- Redis 里没有,再去数据库查询,同时把结果存入 Redis
优势:
- 访问最新数据时,大部分请求直接命中 Redis,查询速度极快
- 旧数据(很少访问)自动淘汰,节省存储空间
5. 超热门用户怎么办?本地缓存!
即使有 Redis,有些大V的查询量太高,Redis 服务器也可能顶不住。
解决方案:在应用服务器(如 Java 服务)上,再加一层本地缓存(Guava Cache / EhCache)。
逻辑:
- 先查 本地缓存(内存)
- 查不到,去 Redis
- Redis 没有,再去 数据库
- 查询到后,把数据写入本地缓存 & Redis,方便下次用
本地缓存 + Redis + 数据库 形成了 三级缓存,热点数据的查询速度会非常快!
6. 最终查询流程(总结)
以查询用户
123456
过去一周的帖子为例:
- 先查本地缓存(如果热点用户,可能已经缓存在内存里)
- 本地缓存没有,就去 Redis 查
userId + 本周时间戳
- Redis 里找不到,就去 数据库 查询,并写回 Redis
- 数据库里也查不到?那就是这个用户真的没发帖子
优化点总结:
- postId 设计优化,避免数据库回表,提高查询速度
- 水平拆分,让不同数据库分摊负载
- 读写分离,多个从库分担查询压力
- Redis 缓存,减少数据库查询
- 本地缓存,解决热点用户的超高查询量
这样,我们就能支撑 亿级用户 & 高并发查询 了!
帖子点赞业务如何落地
根据点赞业务特点可以发现:
- 吞吐量高
- 能够接受数据不一致
如果只考虑点赞可以怎么做?
可以用MySQL做持久存储,Redis做缓存,读写操作落缓存,异步线程定期刷新DB。
counter表:id,postId,count
RedisKV存储:key:postId value:count
但是以微博为例,不仅有点赞还有转发数,评论数,阅读量等。所以,业务拓展性和效率问题是难点,如果这么设计,我们就需要多次查询Redis,多次查询DB。
假设首页有100条消息,就需要for循环每一条消息,每条消息要进行4次Redis访问进行拼装。
所以这里我们进行优化的话,可以在MySQL层面进行增加列优化
conunter表:id,postId,readCount,forwordCount,commentCount,PraiseCount
这样增加的话,缺点是如果增加一个计数服务的话,列就需要改变。那么不想改变列怎么办呢?
conunter表:id,postId,countKey(计数类型名称,比如readCount),countValue
比如说:
id | postId | countKey | countValue |
---|---|---|---|
1 | 12345 | readCount | 100 |
2 | 12345 | forwardCount | 20 |
3 | 12345 | commentCount | 10 |
4 | 12345 | praiseCount | 50 |
Redis来获取业务的话,可以存储为hash用来计数。
比如:
1 | key: counter:postId |
关注用户发帖子了,要如何设计实现和落地(相似场景:朋友发朋友圈了,我可以看见)
术语明确:timeline(时间线):就是我们刷社交平台时看到的一条条帖子列表。每个用户的 timeline 只展示自己关注的人发的帖子,按时间排序。
-
使用push模式,即推送,比如用户A发帖了,那么从follower分片表里去查询出用户A得id分片,然后推送给其所有粉丝,优点:查询快;缺点:对于热点用户,比如明星,每次的推送量非常大,消耗数据库负载
-
使用pull模式,即拉取,比如用户A发帖了,不会主动推送给其粉丝,只有当其粉丝打开timeline的时候才会主动去拉取他关注的博主的帖子,优点:写入没有额外负担,只需要存入自己的post表即可,也比较适合热点用户;缺点:查询开销可能比较大,如果该用户关注了很多博主,每次刷新timeline都要去查询这些博主的最近发布的帖子
-
push和pull的混合模式:
单独使用 push 或 pull 都有问题,所以我们可以结合两者的优点,设计一个更高效的方案。
核心思路
- 大V的帖子 → 只推送到 100 个数据库分片(而不是推送给所有粉丝),减少存储压力。
- 普通用户的帖子 → 还是采用 push 方式,直接推送到关注者的 timeline 里,提高查询效率。
- pull 查询时 → 只查询最近一段时间的新帖子,减少数据库的查询负担。
数据库连接池怎么设计以及优化?
首先介绍一下数据库连接池:数据库连接池的作用是管理数据库连接,避免频繁创建和销毁连接导致的资源浪费。它预先创建一定数量的数据库连接,并复用这些连接,从而提高性能和资源利用率。
然后介绍一下连接池的设计要点(核心参数):
- 最大连接数
- 最小连接数
- 连接的最大空闲时间
- 最大空闲连接数
最后说一下连接池的管理策略:
- 请求连接时的处理策略
- 空闲连接的回收机制
分布式集群中如何保证线程安全?
串行化
通过串行化可能产生并发问题操作,牺牲性能和扩展性,来满足对数据一致性的要求。比如分布式消息系统就没法保证消息的有序性,但可以通过变分布式消息系统为单一系统就可以保证消息的有序性了。另外,当接收方没法处理调用有序性,可以通过一个队列先把调用信息缓存起来,然后再串行地处理这些调用。
分布式锁
需要满足互斥性,在任意时刻,只有一个客户端能持有锁;不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁;加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了;加锁和解锁必须具有原子性。可以用Redis实现分布式锁。
可能会释放其他服务器的锁:在删除锁之前先进行判断看是不是自己的锁,通过uuid进行锁的标识。
删除操作缺乏原子性:需要使得判断和删除时原子性的,可以使用Lua脚本实现判断和删除操作的原子性。
扫码登录怎么设计?
扫码登录的本质可以说是把移动端的登录状态同步到PC端,这个过程中最重要的,就是状态传输的流程设计和安全性设计。可以使用Redis状态流转来搞定设备登陆状态的同步流程设计,使用设备关联、临时token、过期时间等手段来提高安全性
需求明确
比如常见的pc端的QQ登录场景,大概流程就是
- pc端展示二维码
- 手机扫码,pc提示在手机上进行确认
- 手机确认登陆
- pc登陆成功
整体设计
说明:二维码是在pc生成,就是一个字符串通过一些包生成的二维码,二维码id具有几种状态,待扫码、待确认、已激活
整体流程:
- PC端向登陆服务申请二维码ID,从返回中能拿到二维码ID
-
1.1 产生一个二维码ID
-
1.2 服务器将关联二维码ID和设备信息记录到数据库中
- 生成和展示二维码
-
服务器通过二维码ID在二维码服务生成二维码URL
-
PC端展示出二维码
-
开始轮询二维码状态
-
移动端扫二维码拿到二维码ID
-
移动端发起扫码请求
- 4.1 请求到达服务端,关联二维码ID和用户ID,并更新二维码对应的状态,并且生成一个临时token
5.确认登陆
-
5.1 使用临时token确认登陆
-
5.2 生成登陆用的pc token(后续由服务器发给pc端)
-
5.3 把pc token关联上二维码ID,并更新pc token状态为已激活
服务设计
主要是登陆服务,可能还有网关服务
存储设计
会生成一个pc token,一个设备的登陆凭证就是一个token,也就是一个设备一个token,登陆的时候使用这个token去登陆鉴权。可以用redis的kv存储
针对二维码的三个状态,redis的存储数据形式:
- 生成二维码时:key:唯一的二维码ID,value:一个json数据,包括设备信息(设备类型,ip,登录地址)以及二维码状态信息
- 扫码时(待确认)此时移动端已经传递过去了账户的信息:value里会多一个账户的id,并改变二维码状态;扫码后,会生成临时的token,也用kv存储
- 确认后:根据临时token(主要是起一个桥接作用,用来唯一标识这一次的扫码过程,也是为了实现确认过程吧)确认后,二维码状态改为已激活状态,生成一个pctoken,用于管理pc与服务器的会话与连接