上百亿的用户关系系统存储应该如何设计

上百亿的用户关系系统存储应该如何设计

一、核心挑战分析

首先,我们必须清晰地认识到这个系统的核心挑战是什么:

  1. 海量数据存储
    • 关注关系:百亿级别。一个用户关注了谁 (followees),被谁关注了 (followers)。这是一个巨大的图(Graph)。
    • 动态(Feed)数据:用户发布的内容,百亿甚至千亿级别。
  2. 读写模式极不均衡(读多写少)
    • 写操作:关注/取关、发布动态。相对低频。
    • 读操作刷新关注页(Feed 流)。这是系统的核心功能,请求量极大,对延迟要求非常高。
  3. **“明星效应”/ 大 V 问题 (The Celebrity Problem)**:
    • 普通用户的粉丝数很少(几十到几百)。
    • 大 V(明星、机构)的粉丝数可能达到千万甚至上亿。
    • 当一个大 V 发布一条动态时,如何高效地将这条动态推送(或让其可见)给所有粉丝,是一个巨大的挑战。直接操作上亿条记录是不可行的。
  4. 实时性要求
    • 用户发布动态后,应尽快出现在其粉丝的 Feed 流中。
    • 关注/取关操作应立即生效。

二、数据模型与存储选型

1. 关注关系存储

这是一个典型的“多对多”关系。我们需要高效地查询:

  • get_followees(user_id) -> 我关注了谁?
  • get_followers(user_id) -> 谁关注了我?

不建议示范:使用单一的关系型数据库(如 MySQL)。

  • 原因:当数据量达到百亿级别时, following 表会变得无比巨大。即使进行分库分表,对于大 V 的查询(SELECT * FROM following WHERE follower_id = 'celebrity_id')仍然是一场灾难,会引发严重的数据库热点和性能问题。

正确选型:面向海量关系查询的 NoSQL 数据库

  • 方案:使用 Redis / TiKV / Cassandra 等键值(KV)存储或宽列存储。
  • 模型
    • 关注列表 (Following List):
      • Key: following:user_id
      • Value: ZSET (有序集合) 或 SETScore 可以是关注时间戳,Member 是被关注者的 user_id
      • ZREVRANGE following:user_id 0 -1 可以快速分页获取我关注的人。
    • 粉丝列表 (Followers List):
      • Key: followers:user_id
      • Value: ZSETSETMember 是关注者的 user_id
  • 优点
    • 极高的读写性能:KV 查询是 O(1),ZSET 的范围查询也很快。
    • 水平扩展:天然支持分布式和水平扩展。
    • 大 V 问题:虽然单个 Key 仍然可能很大,但比关系型数据库的热点问题要好管理。Redis Cluster 或 TiKV 等可以自动处理分片。

三、Feed 流(动态推送)架构设计

这是整个系统设计的核心和难点。主要有两种模式:推模式 (Push)拉模式 (Pull),以及结合两者的推拉结合模式

1. 写时扩散(推模式 / Fan-out on Write)

  • 工作原理:当一个用户 A 发布一条动态 M 时,系统会立即将这条动态 M 的 ID(或完整内容)推送并插入到其所有粉丝的“收件箱(Feed 流时间线)”中。
  • 数据模型
    • Feed Timeline (收件箱): Key: feed:user_id, Value: ZSET (Score 是动态发布时间,Member 是动态 ID)。
  • 用户读取:用户刷新 Feed 流时,只需读取自己的“收件箱” feed:user_id 即可。ZREVRANGE feed:user_id 0 49 就能获取最新的 50 条动态。
  • 优点
    • **读取极快 (O(1))**:读操作非常简单,延迟低,因为 Feed 流是预先计算好的。对普通用户体验极佳。
  • 缺点
    • 写入成本高:写操作被放大。一个有 100 万粉丝的用户发一条动态,就需要进行 100 万次写操作。
    • 大 V 灾难:一个有 1 亿粉丝的大 V 发动态,会触发 1 亿次写操作,引发“**写风暴 (Write Storm)**”,可能导致整个系统延迟飙升甚至崩溃。
    • 在线粉丝问题:很多粉丝可能是不活跃的,为他们推送属于资源浪费。

2. 读时聚合(拉模式 / Fan-in on Read)

  • 工作原理:当一个用户 C 刷新 Feed 流时,系统实时地:
    1. 获取 C 关注的所有人 A, B, ... 的列表。
    2. 分别去获取 A, B, ... 这些人发布的最新动态。
    3. 将所有动态在内存中进行聚合、排序,然后返回给用户 C
  • 数据模型
    • User's Outbox (发件箱): Key: outbox:user_id, Value: ZSET (存放该用户自己发布的所有动态 ID)。
  • 用户读取:一个复杂的多步聚合操作。
  • 优点
    • **写入极快 (O(1))**:用户发动态只需要往自己的“发件箱”里写一次即可。大 V 发动态毫无压力。
  • 缺点
    • 读取极慢:如果一个用户关注了 2000 人,每次刷新都需要进行 2000 次查询再加一次复杂的内存排序,延迟会非常高,系统无法承受。
    • 不适合普通用户

四、终极方案:推拉结合 (Hybrid Approach) - 应对大V问题的关键

既然纯推和纯拉都有致命缺陷,业界的成熟方案都是将两者结合,针对不同类型的用户采用不同策略。

核心思想:

  • 对普通用户:采用推模式。因为他们粉丝少,写扩散成本低,可以保证其粉丝的读取体验。
  • 对大 V 用户:采用拉模式。他们的发布操作只写一次,避免写风暴。由其粉丝在读取时主动拉取。

实现细节:

  1. 用户打标
    • 系统需要一个机制来识别“大 V”。例如,设定一个阈值,粉丝数 > 10 万的被标记为大 V。这个标记可以是用户属性的一部分。
  2. 混合的发布逻辑
    • 当一个普通用户 U发布动态时:
      • 执行推模式逻辑:获取 U 的所有粉丝,将动态 ID 推送到他们的 Feed 时间线中。
    • 当一大 V发布动态时:
      • 执行拉模式逻辑:只将动态 ID 写入自己的“发件箱 (outbox:v_user_id)”。
  3. 混合的读取逻辑
    • 当一个用户 C刷新 Feed 流时:
      1. 读取自己的“收件箱”ZREVRANGE feed:c_user_id 0 49。这里面包含了所有关注的普通用户推送给他的动态。
      2. 获取 C 关注的大 V 列表get_v_followees(c_user_id)。这个列表可以被缓存,因为用户关注的大 V 不会频繁变化。
      3. 主动拉取大 V 动态:遍历这个大 V 列表,分别去他们的“发件箱” outbox:v_user_id 中拉取最新的几条动态。
      4. 最终聚合:将步骤 1 的“收件箱”动态和步骤 3 拉取到的“大 V”动态在应用层或一个专门的聚合服务中进行内存合并、按时间戳排序,然后返回最终的 Feed 流给用户。

针对在线用户的优化 (进一步提升)

为了解决给离线用户推送造成的资源浪费,可以引入一个在线状态服务(可以用 Redis 或其他内存数据库实现)。

  • 新的推模式逻辑:当普通用户发布动态时,只向其当前在线的粉丝进行实时推送。
  • 对于离线粉丝,可以有几种处理方式:
    • 惰性推送:当离线粉丝再次上线时,系统触发一个任务,将他离线期间错过的动态“补”进他的收件箱。
    • 拉模式降级:当离线粉丝上线时,让他临时采用拉模式,主动去拉取所有关注者(包括普通用户)的动态来构建首页,之后再切换回正常的推模式。

五、整体架构图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
                       +-------------------+
| API Gateway |
+-------------------+
|
+-------------------+-------------------+
| |
+-------------------+ +------------------------+
| 关注/发布服务 | | Feed 流读取服务 |
+-------------------+ +------------------------+
| |
| (Write) | (Read)
| |
+-------------------------------------------------------------------+
| 业务逻辑层 |
| |
| IF user is NOT a V: IF user requests feed: |
| - Get followers_list - Get user's inbox (feed:user_id) |
| - Push to each follower's inbox (feed:...) - Get user's V_following_list |
| - Pull from each V's outbox (outbox:v_id) |
| IF user IS a V: - Merge & Sort results |
| - Write to own outbox (outbox:user_id) |
+-------------------------------------------------------------------+
| | |
| | |
+----------------+ +-----------------+ +--------------------+ +-------------------+
| 关注关系存储 | | 用户发件箱 | | 用户收件箱 | | 在线状态服务 |
| (Redis/TiKV) | | (Redis/TiKV) | | (Redis/TiKV) | | (Redis/etcd) |
| - following:* | | - outbox:* | | - feed:* | | - online_users |
| - followers:* | | | | | | |
+----------------+ +-----------------+ +--------------------+ +-------------------+
|
|
+----------------------+
| 动态内容存储 (DB/OSS) |
| - post_id -> content |
+----------------------+

总结

设计这样一个超大规模的系统,关键在于识别核心矛盾并进行拆解和权衡

  1. 存储分离:将关系数据(关注)、时间线数据(Feed)、内容数据分离存储,选择最合适的存储引擎。
  2. 读写分离:通过推拉结合的模式,将读写压力分离。
  3. 用户分层:对普通用户和大 V 用户采用不同的策略,是解决“明星效应”问题的关键。
  4. 缓存和异步:在所有可行的环节大量使用缓存(如大 V 列表缓存),并利用消息队列将耗时的推送任务异步化。
  5. 服务化:将不同功能(用户服务、关系服务、发布服务、Feed流服务)拆分成独立的微服务,便于独立扩展和维护。

这是一个非常复杂的系统,但通过上述分层设计,我们可以构建出一个既能应对海量数据,又能保证核心功能性能和用户体验的健壮系统。


上百亿的用户关系系统存储应该如何设计
https://blog.longpi1.com/2025/09/16/上百亿的用户关系系统存储应该如何设计/