用户关系服务负责处理用户之间的关注关系。关注功能在社交类、内容类、电商类等互联网应用中都很常见,它的价值主要体现在:

关注功能
├── 促进社交互动:让用户更容易发现和关注其他用户动态
├── 支撑个性化推荐:根据关注和兴趣推荐相关内容与服务
└── 增加用户黏性:可以让用户更容易发现和关注自己感兴趣的内容与服务,从而增加用户黏性

本章围绕用户关系服务的存储与查询设计展开:

10.1 用户关系服务的职责
10.2 基于 Redis ZSET 实现用户关系服务的方案。
10.3 基于数据库实现用户关系服务的方案
10.4 如何设计用户关系的缓存
10.5 基于图数据库实现用户关系服务的方案
10.6 本章小结

10.1 用户关系服务的职责

用户关系服务的核心职责是处理用户之间的关注行为。围绕“关注”和“粉丝”这两个方向,它需要提供几类接口。

1. 关注与取消关注接口

用户 1 可以关注用户 2,此时:

用户 1 是关注者
用户 2 是被关注者
用户 1 是用户 2 的粉丝

用户 1 也可以取消对用户 2 的关注,此时用户 1 不再是用户 2 的粉丝。


2. 查询用户关注列表接口

查询某个用户正在关注哪些用户,并且需要按照最新关注在前的顺序返回。

例如:

用户 A 的关注列表
├── 最近关注的用户
├── 更早关注的用户
└── ...

3. 查询用户粉丝列表接口

查询某个用户正在被哪些用户关注,也就是查询这个用户的粉丝列表。

返回结果同样需要按照最新粉丝在前的顺序排列。


4. 查询关注数与粉丝数接口

查询某个用户:

关注了多少人
被多少人关注

这两个数字分别对应关注数和粉丝数。


5. 查询用户关系接口

查询用户之间是否存在关注关系,包括单个查询和批量查询。

单个查询:

用户 1 是否关注了用户 2
用户 2 的粉丝是否包含用户 1

批量查询:

用户 1 是否关注了指定的若干用户
若干用户是否是用户 1 的粉丝

10.2 基于 Redis ZSET 的设计

最容易想到的方案是使用 Redis 的 ZSET 对象实现用户关系服务。

ZSET 同时支持:

插入数据
按 Score 排序查询
获取集合长度
判断元素是否存在

关注关系需要按照关注时间排序,所以可以把关注时间作为 ZSET 的 Score。


Redis 中的数据模型

对于每个用户,使用两个 ZSET 分别维护关注列表和粉丝列表。

1. 关注列表

Key:following_{用户ID}
Member:被关注的用户 ID
Score:关注行为发生的时间

含义是:(用户ID)这个人关注了哪些人。


2. 粉丝列表

Key:follower_{用户ID}
Member:粉丝用户 ID
Score:被关注行为发生的时间

含义是:(用户ID)这个人被哪些人关注。


查询关注列表和粉丝列表

查询某用户的关注列表,就是读取 following_{用户ID}

按照最新关注在前排序,可以使用:

ZREVRANGE following_{ID} 0 -1

如果需要分页,例如每页 100 个关注用户,查询第 2 页可以使用:

ZREVRANGE following_{用户ID} 100 199

// 后两位分别是start和end
// start = (pageNo - 1) * pageSize
// end = start + pageSize - 1

查询粉丝列表也是同理,只是读取的 Key 变为:

follower_{用户ID}

查询关注数和粉丝数

关注数和粉丝数就是对应 ZSET 的长度。

关注数:ZCARD following_{用户ID}
粉丝数:ZCARD follower_{用户ID}

关注与取消关注

当用户 1 关注用户 2 时,需要同时更新两个 ZSET。

ZADD following_用户1 时间戳 用户2
ZADD follower_用户2 时间戳 用户1

取消关注时,也需要同时删除两边的数据。

ZREM following_用户1 用户2
ZREM follower_用户2 用户1

查询用户关系

查询用户 1 是否关注用户 2,可以从两个方向判断:

ZRANK following_用户1 用户2
ZRANK follower_用户2 用户1

只要能在用户 1 的关注列表中找到用户 2,或者能在用户 2 的粉丝列表中找到用户 1,就说明关注关系存在。

批量查询用户 1 与若干指定用户的关注关系时,可以先取出用户 1 的关注列表:

ZRANGE following_用户1 0 -1

然后在结果中判断指定用户是否存在。

批量查询若干指定用户是否是用户 1 的粉丝时,也可以按同样思路处理。


Redis ZSET 方案的局限

Redis ZSET 方案实现简单,但它的主要问题是:内存资源昂贵且有限

对于用户量不大、社交属性不强的应用来说,ZSET 方案可以满足需求。

但对于国民级社交应用,大 V 和网红可能拥有几百万、上千万甚至上亿粉丝。如果为这些用户维护超长 ZSET,会占用大量 Redis 内存。

所以 Redis ZSET 方案的适用范围是:

  • 适合:社交属性不强、用户规模较小的应用
  • 不适合:海量用户、强社交属性、大量超长粉丝列表的应用

本节总结

Redis ZSET 能很自然地表示关注列表和粉丝列表:

Member 存用户 ID
Score 存关注时间
ZREVRANGE 支持按最新时间排序
ZCARD 支持统计数量
ZRANK 支持判断关系

它的优点是设计简单、查询方便、排序天然支持。

但它的问题也很明显:

海量用户关系会占用大量 Redis 内存
大 V 的超长粉丝列表成本很高

**一句话总结:Redis ZSET 适合小规模或弱社交场景,但不适合直接承载海量用户关系的最终存储。 **

10.3 基于数据库的设计

既然用户关系需要大量存储空间,那么更适合把数据库作为最终存储。

数据库方案的重点不是简单建表,而是要解决:

海量关系数据如何分库分表
关注列表和粉丝列表如何都能高效查询
索引如何设计
如何减少回表
关注数和粉丝数如何维护

10.3.1 最初的想法

最初可以创建一张 User_relation 表,表示用户之间的关注关系。

表结构如下:

字段名 类型 含义
id BIGINT 自增主键,无特殊含义
from_user_id BIGINT 关注者用户 ID
to_user_id BIGINT 被关注者用户 ID
type INT 关注关系枚举,例如 1 表示正在关注,2 表示取消关注
update_time DATE 记录修改时间

每一行记录表示:

from_user_id 关注了 to_user_id

单表方案的问题

为了支持查询用户 1 是否关注用户 2,以及查询某用户的关注列表,很自然会想到给 from_user_id 建索引。

为了支持查询某用户的粉丝列表,也需要给 to_user_id 建索引。

但问题在于:用户关系数据规模非常大。

举例:

用户数:1 亿
平均每个用户关注:1000 人
关系记录数:至少 1000 亿条

这样的数据量必然需要分库分表。

分库分表的依据应尽量选择核心查询字段

分库分表不是随便选一个字段来拆表,而是要选择业务中最常用来查询数据的字段(最常查询的字段一般会建立索引),也就是能够帮助系统快速定位数据在哪个子表中的字段。

否则,即使某个字段本身有索引,查询时仍然可能要访问所有子表。


索引只能加快单个子表内部的查询,不能自动定位子表

假设有一张表:T(A, B, C)。其中:A 字段有索引

现在把表 T 拆成 100 个子表。

如果按照 B 字段分表:

T_0, T_1, T_2, ..., T_99

那么查询:

SELECT * FROM T WHERE A = x;

虽然 A 字段有索引,但是系统不知道 A = x 的数据在哪个子表里。

因为数据是按照 B 字段分散到 100 个子表中的,A = x 的记录可能出现在任意一个子表里。

所以查询时可能变成:

去 T_0 查 A = x
去 T_1 查 A = x
去 T_2 查 A = x
...
去 T_99 查 A = x

这就等于要访问所有子表,分库分表带来的性能优势就被削弱了。


分片字段最好和高频查询条件一致

如果业务中经常按照 A 字段查询,例如:

SELECT * FROM T WHERE A = x;

那么分表时最好也按照 A 字段来分。

例如:

根据 A 的值决定数据进入哪个子表

这样查询 A = x 时,系统可以先根据 A 的值计算出它属于哪个子表,然后只访问那一个子表。

例如:

A = x 经过路由计算后,发现数据在 T_23

那么查询只需要访问 T_23,而不需要扫描全部 100 个子表。

核心矛盾

User_relation 同时有两个重要查询方向:

from_user_id:用于查关注关系和关注列表
to_user_id:用于查粉丝列表和粉丝关系

如果按 from_user_id 分库分表,查询粉丝列表时就可能访问所有子表。

如果按 to_user_id 分库分表,查询关注列表时也会遇到同样问题。

这就是单表方案的核心坑点。


10.3.2 应对分库分表

既然一个表很难同时兼顾两个查询方向,那么可以使用两个表来描述同一份用户关系数据:

Following 表:以 from_user_id 为索引核心
Follower 表:以 to_user_id 为索引核心

这两个表结构完全相同,但服务的查询方向不同。

Following 表负责的请求

查询用户 1 是否关注了用户 2
查询某用户的关注列表
批量查询某用户对若干用户的关注关系

也就是从“关注者”方向出发的请求。


Follower 表负责的请求

查询某用户的粉丝列表
批量查询若干用户与某用户的粉丝关系

也就是从“被关注者”方向出发的请求。


两张表的一致性

Following 表和 Follower 表描述的是同一批关注关系,所以数据必须保持一致。

使用伪从技术是比较合适的一种方案:

Following 表作为主表
Follower 表作为 Following 表的伪从
Follower 表消费 Following 表产生的 binlog

如下图所示,当用户 1 关注或取消关注用户 2 时,只需要更新 Following 表,Follower 表通过伪从机制自动同步最新数据。

image-20260529213537407

这套设计的核心是:

关注类请求访问 Following 表
粉丝类请求访问 Follower 表
两张表通过伪从机制保证一致性

10.3.3 Following 表的索引设计

Following 表负责关注方向的查询,所以索引设计要围绕它支持的查询功能展开。

1. 查询用户 1 是否关注用户 2

对应 SQL:

SELECT 1
FROM Following
WHERE from_user_id = 用户1
AND to_user_id = 用户2
AND type = 1;

如果只给 from_user_id 建索引,数据库会先找出用户 1 的所有关注记录,再逐条扫描对比 to_user_idtype

如果用户 1 关注了上万个用户,这个扫描成本就比较高。

更好的做法是建立联合索引:

KEY idx_following (from_user_id, to_user_id)

这样可以快速定位到用户 1 与用户 2 的关注记录,再判断 type 是否为 1。


2. 查询用户关注列表

查询用户 1 的关注列表,并按照关注时间从近到远返回:

SELECT to_user_id
FROM Following
WHERE from_user_id = 用户1
AND type = 1
ORDER BY update_time DESC;

如果只有 from_user_id 索引,数据库仍然需要扫描并筛选 type = 1 的记录。

所以需要把 from_user_idtype 放进联合索引。这样可以免去扫描而直接得到用户关注记录。

同时,因为关注列表要按 update_time 倒序返回,如果不把 update_time 放入索引,对查询到的用户关注记录还需要使用额外的临时空间进行排序,这也会给数据库造成负担。

最终索引为:

KEY idx_following_list (from_user_id, type, update_time)

数据库索引保证:索引中的每个字段均按照值的大小排列,且当某个字段的值相同时, 再按照下一个字段继续排序。

对于idx_following_list索引来说是:

先按 from_user_id 排序
from_user_id 相同时按 type 排序
type 相同时按 update_time 排序

因此查询 from_user_id = 用户1 AND type = 1 的记录时,结果天然按 update_time 有序,数据库可以反向读取得到最新关注在前的列表,不需要额外临时空间排序。

当执行上面的SQL语句时,from_user_id为用户1且 type 为1的记录是天然按照update_time字段排序的,获取时间从近到远的记录无非就是从最后一条记录开始反向读取,数据库无须再专门使用临时空间对数据记录进行排序,进一步提高了数据库查询性能。


3. 批量查询用户 1 是否关注若干用户

对应 SQL:

SELECT to_user_id
FROM Following
WHERE from_user_id = 用户1
AND to_user_id IN (用户2, 用户3, 用户4);

这个查询条件正好可以命中:

KEY idx_following (from_user_id, to_user_id)

所以 Following 表需要两个联合索引:

idx_following       (from_user_id, to_user_id)
idx_following_list (from_user_id, type, update_time)

这两个索引的第一个字段都是 from_user_id,因此 Following 表可以按 from_user_id 分库分表,并充分利用索引优势。


10.3.4 Follower 表的索引设计

Follower 表负责粉丝方向的查询,所以索引设计与 Following 表类似,但方向相反。

1. 查询用户粉丝列表

查询用户 1 的粉丝列表,并按照关注时间从近到远返回:

SELECT from_user_id
FROM Follower
WHERE to_user_id = 用户1
AND type = 1
ORDER BY update_time DESC;

对应联合索引:

KEY idx_follower_list (to_user_id, type, update_time)

2. 批量查询若干用户是否是用户 1 的粉丝

对应 SQL:

SELECT from_user_id
FROM Follower
WHERE to_user_id = 用户1
AND from_user_id IN (用户2, 用户3, 用户4);

为了让 to_user_idfrom_user_id 依次命中索引,需要创建:

KEY idx_follower (to_user_id, from_user_id)

因此 Follower 表需要两个联合索引:

idx_follower_list  (to_user_id, type, update_time)
idx_follower (to_user_id, from_user_id)

10.3.5 进阶:回表问题与优化

以 MySQL InnoDB 为例,索引可以分为聚集索引和非聚集索引。

聚集索引:
数据记录和索引保存在一起,例如主键索引。

非聚集索引:
索引和数据记录分开存储,索引中只保存对应数据记录的指针或主键。

前面创建的几个联合索引都属于非聚集索引。

非聚集索引不包含整条记录

回表是什么

idx_following(from_user_id, to_user_id) 为例:

SELECT 1
FROM Following
WHERE from_user_id = 用户1
AND to_user_id = 用户2
AND type = 1;

数据库可以通过 from_user_idto_user_id 命中非聚集索引,但这个索引里没有 type 字段。

为了判断 type = 1,数据库还要根据主键再访问主键索引,读取完整数据记录。

这个过程就是回表。

如果查询不涉及 type

SELECT 1
FROM Following
WHERE from_user_id = 用户1
AND to_user_id = 用户2;

那么待查询字段都已经在 idx_following 中,数据库可以直接返回结果,不需要访问主键索引。


前面几个索引的回表情况

idx_following(from_user_id, to_user_id)
用于查询用户之间的关注关系
问题:不包含 type,会回表

idx_following_list(from_user_id, type, update_time)
用于查询用户关注列表
问题:不包含 to_user_id,会回表

idx_follower_list(to_user_id, type, update_time)
用于查询用户粉丝列表
问题:不包含 from_user_id,会回表

idx_follower(to_user_id, from_user_id)
用于批量查询粉丝关系
结果:已经包含 from_user_id,不需要回表

使用覆盖索引优化

为了避免回表,可以把查询所需字段补充到非聚集索引中。

优化后的索引:

idx_following:
(from_user_id, to_user_id, type)

idx_following_list:
(from_user_id, type, update_time, to_user_id)

idx_follower_list:
(to_user_id, type, update_time, from_user_id)

优化以后,下面这几句都不用回表了。

# 查询用户1 是否关注 用户2
SELECT 1
FROM Following
WHERE from_user_id = 用户1
AND to_user_id = 用户2;

# 查询用户1 的关注列表
SELECT to_user_id
FROM Following
WHERE from_user_id = 用户1
AND type = 1
ORDER BY update_time DESC;

#查询用户1 的粉丝列表
SELECT from_user_id
FROM Follower
WHERE to_user_id = 用户1
AND type = 1
ORDER BY update_time DESC;

这种索引覆盖了 SQL 查询需要的全部字段,因此称为覆盖索引。

它的取舍是:

优点:减少回表,提高查询性能
代价:索引字段更多,占用更多磁盘空间

这是一种典型的以空间换时间


10.3.6 关注数和粉丝数

虽然可以直接从数据库中使用 COUNT 查询关注数和粉丝数,但这种方式效率很低。

更好的方案是使用计数服务单独维护这些数据。

具体做法仍然可以使用伪从技术:

Following 表产生 binlog
计数消费者订阅 binlog
关注或取消关注发生时,更新计数服务
读取关注数和粉丝数时,直接访问计数服务

例如用户 1 关注用户 2:

用户 1 的关注数 +1
用户 2 的粉丝数 +1

取消关注时则做相反更新。

这样读关注数和粉丝数时,就不需要访问关系数据库。


本节总结

数据库方案的核心不是一张表解决所有问题,而是按查询方向拆成两张表:

Following 表:
以 from_user_id 为核心索引
负责关注关系、关注列表相关查询

Follower 表:
以 to_user_id 为核心索引
负责粉丝关系、粉丝列表相关查询

两张表结构相同,数据通过伪从机制保持一致:

Following 表是主表
Follower 表消费 Following 表 binlog

索引设计重点:

联合索引:让查询条件按顺序命中索引
排序字段入索引:减少额外排序
覆盖索引:减少回表

关注数和粉丝数不适合每次从数据库 COUNT,更适合交给计数服务维护。

一句话总结:

数据库承载用户关系的最终存储,
两张冗余表解决双向查询,
伪从机制解决数据同步,
覆盖索引进一步优化查询性能。

10.4 缓存查询

数据库适合作为最终存储,但它毕竟是磁盘存储,在高并发读场景下仍然可能成为瓶颈。

因此,需要在数据库方案基础上加入缓存,优化用户关系的高并发读取。


10.4.1 缓存什么数据

用户关系服务中的高并发读主要包括:

读取用户关注列表
读取用户粉丝列表
查询用户之间的关注关系

不同数据的特点不同,缓存策略也不同。


1. 关注列表可以全量缓存

大部分互联网应用会限制每个用户最多可以关注多少人。

原因是避免用户滥用关注功能刷粉、刷流量,影响社区体验和社交环境。

例如:

新浪微博限制一个用户最多可关注 2000 人

因为关注列表长度有上限,所以可以把用户关注列表全量缓存到 Redis 中。

数据模型与 10.2 节一致:

Key:following_{用户ID}
Member:被关注用户 ID
Score:关注时间

2. 粉丝列表只缓存最近 10000 个粉丝

粉丝数量通常没有上限。

对于大 V 来说,粉丝列表可能达到:

数百万人
上千万人
甚至上亿人

Redis 无法全量缓存这些粉丝列表。

但大多数用户查看粉丝列表时,只会查看前几页,很少有人一直翻到很后面。

因此可以只缓存用户最近的 10000 个粉丝:

查询最近 10000 个粉丝:优先查 Redis
查询 10000 个以外的粉丝:查数据库

这个策略可以覆盖绝大多数读取粉丝列表的请求。


3. 对粉丝列表深分页请求限流

如果有人恶意大量请求最近 10000 个粉丝以外的数据,这些请求会直接访问数据库,可能打垮数据库。

我们可以采用限流的方式来解决这个问题,比如在收到读取粉丝列表的请求时,用户关系服务先检查此请求查询的数据是否在最近 10000 个粉丝之外,如果是,则检查此时这种请求的请求量是否已达到限流阈值,对于超过限流阈值的请求拒绝执行。


4. 查询用户之间关注关系

查询两个用户之间的关注关系比较容易,可以使用关注列表缓存。

例如查询用户 1 是否关注用户 2:

检查用户 1 的关注列表缓存中是否包含用户 2

批量查询用户 1 是否关注若干用户,也是同样思路。


5. 批量查询若干用户是否是用户 1 的粉丝

这个场景更麻烦。

如果用户 1 的粉丝少于 10000 个,可以把粉丝列表全量缓存,直接查粉丝列表缓存即可。

如果用户 1 是大 V,粉丝数超过 10000,而 Redis 只缓存最近 10000 个粉丝,那么待查询用户可能不在缓存里。

有两种常见思路。

方案一:使用关注列表缓存反查

一种解决方案是使用关注列表缓存反查。

即对于不在用户 1 的粉丝列表缓存中的用户,进一步查询在这些用户的关注列表缓存中是否包含了用户 1。

比如现在要批量查询 100 个指定用户是否是大 V 用户 1 的粉丝,在用户 1 的最近 10000 个粉丝中可以查到其中的 10 个用户,那么对于剩下的 90 个用户,我们开启 90 个线程来分别查询这 90 个用户的关注列表缓存。

为一个批量查询请求额外创建了 90 个线程,来执行 90 个查询 Redis 缓存的请求。

所以,这种解决方案可能会带来:

  • 线程暴涨
  • 读请求被放大

而这取决于批量查询的用户数量。


方案二:进一步缓存粉丝关系

另一种解决方案是进一步缓存粉丝关系。

使用 Redis 来缓存最近查询过的若干用户是否为用户 1 的粉丝的关系。

具体来说,Redis 使用 Hash 对象缓存数据:

  • Key 代表用户 1
  • Field 代表用户 2、用户 3、用户 4 等指定用户
  • 对应的 Value 表示这些用户是否分别是用户 1 的粉丝
    • 值为 1 表示是粉丝
    • 值为 0 表示不是粉丝

如果在待查询的这些用户中至少有一个用户不在此 Hash 对象中,则需要进一步为其回源查询数据库 Follower 表:

SELECT from_user_id
FROM Follower
WHERE to_user_id = 用户1
AND from_user_id IN (...)
AND type = 1;

这种方案会进一步占用 Redis 的存储空间。

并且 Hash 对象可能会随着请求访问量的增加而变得越来越大,因此需要注意设置合理的过期时间。


10.4.2 缓存的创建与更新策略

缓存创建

缓存创建比较直接:

用户请求关注列表或粉丝列表
先查询 Redis
Redis 没有数据时回源数据库
将数据库结果写入 Redis

关注列表和粉丝列表都可以用 ZSET 缓存。


关注事件会导致缓存失效

当用户 1 关注或取消关注用户 2 时,至少会影响两个缓存:

用户1 的关注列表缓存
用户2 的粉丝列表缓存

常规缓存更新方案是:

先更新数据库
再删除缓存

这对关注列表缓存问题不大,因为一个用户不会一直频繁关注别人,关注列表相对稳定。


大 V 粉丝列表不适合频繁删除缓存

大 V 的粉丝列表变化非常频繁。

如果使用“先更新数据库,再删除缓存”,会出现缓存频繁创建又频繁删除的问题。

例如:

第 1 秒:读取 A 的粉丝列表,Redis 没有,回源数据库并创建缓存
第 2 秒:有人关注 A,数据库写入后删除 A 的粉丝列表缓存
第 3 秒:再次读取 A 的粉丝列表,又回源数据库并创建缓存
第 4 秒:又有人关注 A,再次删除缓存
...

这样 Redis 缓存几乎不起作用,读请求仍然不断回源数据库。


使用伪从技术更新缓存

对粉丝列表缓存来说,更适合的方式是:缓存随着数据库更新而更新,而不是简单删除。

实现思路:

创建缓存消费者服务
消费者作为 Following 表的伪从
订阅 Following 表的 binlog
关注或取消关注发生时,消费者更新 Redis 缓存

例如用户 A 关注用户 B:

Following 表写入关注关系
消费者收到 binlog
检查用户 B 的粉丝列表缓存是否存在
如果存在,就把用户 A 加入用户 B 的粉丝列表 ZSET

这样粉丝列表缓存不会因为关注事件被删除,而是近实时更新。

优点:

缓存命中率更高
缓存和数据库一致性更好

代价:

比“先更新数据库,再删除缓存”开发成本更高

关注列表和粉丝列表的更新策略

总结一下:

关注列表缓存:
可以使用先更新数据库,再删除缓存
也可以使用伪从技术更新缓存

粉丝列表缓存:
更适合使用伪从技术更新缓存

如果不想区别对待关注列表和粉丝列表,可以统一使用伪从技术更新缓存。


10.4.3 本地缓存

大 V 的关注列表有两个特点:

访问量大:很多用户会关心大 V 关注了谁
变化少:大 V 关注其他用户通常比较谨慎

因此,可以把大 V 的关注列表进一步放到服务实例的本地缓存中。

这样做可以:

减少访问 Redis
提升查询性能

粉丝列表不适合放本地缓存:

大 V 粉丝变化频繁,不适合本地缓存
普通用户粉丝变化虽然少,但访问量也小,本地缓存命中率不高

所以本地缓存主要适用于:

大 V 的关注列表

10.4.4 缓存与数据库结合的最终方案

下图展示了用户关系服务的最终架构。

image-20260530014320032

这套方案的核心是:

Following 表是数据中心
Follower 表、计数服务、Redis 缓存都以 Following 表为标准更新数据

下面按接口说明整体流程。


1. 关注与取消关注接口

接口只需要直接更新数据库 Following 表即可响应用户。

后续流程对用户来说是异步的:

Follower 表消费 Following 表 binlog,更新粉丝方向数据
计数服务消费 binlog,更新关注数和粉丝数
Redis 缓存消费 binlog,更新关注列表和粉丝列表缓存

例如用户 1 关注用户 2:

Follower 表:
写入 from_user_id = 用户1,to_user_id = 用户2 的记录,设置关注时间

计数服务:
用户1关注数 +1
用户2粉丝数 +1

Redis 缓存:
用户1关注列表加入用户2
用户2粉丝列表加入用户1

2. 查询用户关注列表接口

查询流程:

用户关系服务先查询处理此请求的服务实例的本地缓存中是否有数据。
如果没有数据,则再查询 Redis 中是否存在对应的数据。
如果 Redis 中也不存在对应的数据,则回源数据库查询,并将得到的结果以 ZSET 对象的形式存储到 Redis 中。
然后,检查此用户的粉丝数是否达到一定的阈值,也就是判断该用户是否是大 V
如果达到阈值,则将其关注列表数据也缓存到服务实例的本地缓存中。

注意:本地缓存主要面向大 V 的关注列表。


3. 查询用户粉丝列表接口

如果查询的是最近 10000 个粉丝:

先查 Redis
Redis 没有,再回源数据库
将结果缓存到 Redis

如果查询的不是最近 10000 个粉丝:

先做限流判断
未超过限流阈值,才允许查询数据库
超过阈值则拒绝请求

这样可以避免高并发的深分页粉丝列表请求打垮数据库。


4. 查询关注数和粉丝数接口

这个接口直接调用计数服务获取结果。(不从关系数据库实时 COUNT)


5. 查询用户关系接口

这个接口较为复杂,取决于是否是批量查询

1. 查询用户 1 与用户 2 的关注关系

用户 1 的关注列表为判断标准。

先从 Redis 中查询:用户 1 的关注列表缓存中是否存在用户 2。

如果缓存不存在,则回源数据库,拉取用户 1 的关注列表,并缓存到 Redis 中。

其流程与查询用户关注列表的接口流程非常相似。


2. 查询用户 1 是否关注了若干用户

例如查询用户 1 是否关注了用户 2、用户 3、用户 4。

依然以用户 1 的关注列表为判断标准。(流程其实同上)

先从 Redis 中查询:用户 1 的关注列表缓存中是否存在。

如果 Redis 中存在用户 1 的关注列表缓存,则直接读取该关注列表,并判断是否存在用户 2、用户 3、用户 4。

如果 Redis 中不存在用户 1 的关注列表缓存,则回源数据库,拉取用户 1 的完整关注列表,并将该关注列表缓存到 Redis 中。然后再判断


3. 查询若干用户是否关注了用户 1

例如查询用户 2、用户 3、用户 4 是否关注了用户 1。

此时要先查询用户 1 的粉丝数

3.1 如果粉丝数少于 10000 个

其查询流程与粉丝列表请求的查询流程非常类似。

无论是读取粉丝列表缓存,还是回源数据库,只要获取到用户 1 的粉丝列表后,查看粉丝列表中是否包含用户 2、用户 3、用户 4 即可。

最后给出这些用户是否分别关注了用户 1 的结论。

3.2 如果粉丝数大于 10000 个

说明从粉丝列表缓存中可能无法得到准确的关系判断。

此时无法依赖粉丝列表缓存,只能依赖粉丝关系缓存,也就是使用 Redis Hash 对象形式的缓存。

首先请求查询用户 2、用户 3、用户 4 是否在用户 1 的 Hash 对象中。

然后根据查询结果进行下一步操作:

如果这些用户都可以被查询到,则直接返回是否关注的结果。

如果用户 2 和用户 3 不在 Hash 对象中,则回源数据库 Follower 表,查询两者与用户 1 的关注关系,并将结果回写到 Redis Hash 对象中。

用户关系查询要区分不同情况。

查询用户 1 是否关注用户 2

以用户 1 的关注列表为判断标准:

先查 Redis 中用户1的关注列表缓存
判断是否包含用户2
如果缓存不存在,则回源数据库
再把用户1关注列表写入 Redis

查询用户 1 是否关注了若干用户

仍然以用户 1 的关注列表为判断标准:

先获取用户1关注列表
再从中筛选指定用户

查询若干用户是否关注了用户 1

先查询用户 1 的粉丝数。

如果粉丝数少于 10000:

读取用户1粉丝列表缓存或回源数据库
判断粉丝列表中是否包含指定用户

如果粉丝数大于 10000:

不能完全依赖最近10000个粉丝缓存
改用 Redis Hash 形式的粉丝关系缓存

处理流程:

查询用户2、用户3、用户4是否在用户1的粉丝关系 Hash 中
如果都存在,直接返回结果
如果部分不存在,回源 Follower 表查询
将查询结果回写 Redis Hash

本节总结

缓存设计要根据数据特点区别处理。

关注列表:

有数量上限
可以全量缓存到 Redis
大 V 的关注列表还可以放本地缓存

粉丝列表:

没有数量上限
大 V 粉丝可能非常多
只缓存最近 10000 个粉丝
深分页请求需要限流保护数据库

缓存更新策略:

关注列表缓存:可以删除缓存,也可以伪从更新
粉丝列表缓存:更适合伪从更新,避免频繁删除导致缓存失效

最终架构的中心是:

Following 表
├── Follower 表
├── Redis 缓存
└── 计数服务

这些下游数据都通过 Following 表的 binlog 异步更新。

一句话总结:

数据库负责最终存储,
Redis 负责高并发读,
本地缓存只缓存高频且稳定的数据(大V的关注列表),
伪从机制负责把 Following 表的数据变化同步到其他组件。

10.5 基于图数据库的设计

数据库与缓存结合是一种成熟的传统方案。

本节介绍另一种适合用户关系场景的 NoSQL 数据库:图数据库。

图数据库以实体为点,以实体间的关系为边建立图结构,目的是更高效地描述查询实体间的关系。

用户关系服务是图数据库的典型应用场景之一,此服务本来就是用来处理用户之间的关注关系问题的,其中的用户就是图数据库的点,用户间关系就是图数据库的边。

下面以 Neo4j 为例,介绍如何实现用户关系服务。

注:大厂通常不是直接拿 Neo4j 做核心关注列表,而是自研或基于 KV/MySQL/Redis 封装一套“图存储系统”。


10.5.1 实现用户关系

Neo4j 使用 Cypher 查询语言,也就是 CQL,执行图数据库的读写操作。

CQL不仅遵循数据库SQL语法,而且具有人性化、易理解的语言格式。


1. 创建用户节点

每个用户在 Neo4j 中都是一个节点。

下面创建了 8 个 User 节点,并把用户 ID 作为节点属性:

CREATE (u1:User {user_id:1111111})
CREATE (u2:User {user_id:2222222})
CREATE (u3:User {user_id:3333333})
CREATE (u4:User {user_id:4444444})
CREATE (u5:User {user_id:5555555})
CREATE (u6:User {user_id:6666666})
CREATE (u7:User {user_id:7777777})
CREATE (u8:User {user_id:8888888})

为了可以通过用户 ID 快速找到对应节点,需要为 user_id 创建索引:

CREATE INDEX ON :User(user_id)

2. 创建关注关系边

用户之间的关注关系是连接节点的边。

如果用户 u1 关注用户 u2,则在两个 User 节点之间创建 Follow 类型的边,并把关注时间作为边的属性。

MATCH (u1:User {user_id:1111111}), (u2:User {user_id:2222222})
CREATE (u1)-[:Follow {follow_time:'2022-01-20 22:32:55'}]->(u2)

为了方便介绍用户关系服务各个接口的实现,我们为目前这8个用户随机建立一些关注关系:

CREATE (u1)-[:Follow{follow_time:'2022-04-17 22:05:37'}]->(u2)
CREATE (u1)-[:Follow{follow_time:'2022-11-03 01:46:08'}]->(u4)
CREATE (u1)-[:Follow{follow_time:'2021-09-6 06:26:24'}]->(u5)
CREATE (u1)-[:Follow{follow_time:'2022-06-12 22:50:38'}]->(u8)

CREATE (u2)-[:Follow{follow_time:'2020-06-30 22:42:44'}]->(u5)
CREATE (u2)-[:Follow{follow_time:'2021-09-11 01:44:11'}]->(u6)
CREATE (u2)-[:Follow{follow_time:'2022-03-07 04:58:48'}]->(u7)
CREATE (u2)-[:Follow{follow_time:'2021-06-26 15:09:07'}]->(u8)

CREATE (u3)-[:Follow{follow_time:'2022-06-17 11:41:07'}]->(u2)
CREATE (u3)-[:Follow{follow_time:'2022-07-31 19:00:18'}]->(u5)
CREATE (u3)-[:Follow{follow_time:'2020-06-19 02:08:05'}]->(u8)

CREATE (u4)-[:Follow{follow_time:'2020-02-05 15:11:57'}]->(u1)
CREATE (u4)-[:Follow{follow_time:'2020-01-05 00:10:41'}]->(u3)

CREATE (u6)-[:Follow{follow_time:'2021-12-25 05:31:25'}]->(u4)

CREATE (u7)-[:Follow{follow_time:'2020-07-23 10:43:55'}]->(u5)
CREATE (u7)-[:Follow{follow_time:'2021-12-02 01:04:50'}]->(u8)

CREATE (u8)-[:Follow{follow_time:'2020-02-12 21:06:32'}]->(u1)
CREATE (u8)-[:Follow{follow_time:'2023-04-14 08:18:11'}]->(u2)
CREATE (u8)-[:Follow{follow_time:'2023-02-12 21:58:13'}]->(u5)
CREATE (u8)-[:Follow{follow_time:'2020-09-06 22:32:59'}]->(u7)

在Neo4j操作界面中可以看到,创建这些关系后形成的图形数据如下图所示。

image-20260530101709172


3. 查询用户关注列表

查询用户 u1 的关注列表,就是查询 u1 主动指向了哪些 User 节点。

MATCH (u:User {user_id:1111111})-[f:Follow]->(v:User)
RETURN v.user_id, f.follow_time
ORDER BY f.follow_time DESC

按照 follow_time 倒序排列,就可以得到最新关注在前的关注列表。

执行结果如下图所示。

+-----------+-----------------------+
| v.user_id | f.follow_time |
+-----------+-----------------------+
| 444444 | 2022-11-03 01:46:08 |
| 888888 | 2022-06-12 22:50:38 |
| 222222 | 2022-04-17 22:05:37 |
| 555555 | 2021-09-06 06:26:24 |
+-----------+-----------------------+

4. 查询用户粉丝列表

查询用户 u8 的粉丝列表,就是查询哪些 User 节点有 Follow 边指向 u8

MATCH (u:User)-[f:Follow]->(v:User {user_id:8888888})
RETURN u.user_id, f.follow_time
ORDER BY f.follow_time DESC
+-----------+-----------------------+
| u.user_id | f.follow_time |
+-----------+-----------------------+
| 111111 | 2022-06-12 22:50:38 |
| 777777 | 2021-12-02 01:04:50 |
| 222222 | 2021-06-26 15:09:07 |
| 333333 | 2020-06-19 02:08:05 |
+-----------+-----------------------+

5. 批量查询粉丝关系

批量查询用户 u1u2u3 是否是用户 u8 的粉丝,就是查询这些用户节点是否有 Follow 边指向 u8

MATCH (u:User)-[f:Follow]->(v:User {user_id:8888888})
WHERE u.user_id IN [1111111, 2222222, 3333333]
RETURN u.user_id
+-----------+
| u.user_id |
+-----------+
| 333333 |
| 111111 |
| 222222 |
+-----------+

6. 查询共同关注人

图数据库还适合处理一些传统数据库实现起来较重的复杂关系查询。

例如查询用户 u1 和用户 u2 的共同关注人:

MATCH (u:User {user_id:1111111})-[:Follow]->(commonFollows)<-[:Follow]-(v:User {user_id:2222222})
RETURN commonFollows.user_id
+-----------------------+
| commonFollows.user_id |
+-----------------------+
| 555555 |
| 888888 |
+-----------------------+

7. 查询“我关注的人里谁关注了某用户”

例如查询在用户 u1 的关注列表中,有谁关注了用户 u5

MATCH (u:User {user_id:1111111})-[:Follow]->(someFollows)-[:Follow]->(v:User {user_id:5555555})
RETURN someFollows.user_id
+---------------------+
| someFollows.user_id |
+---------------------+
| 888888 |
| 222222 |
+---------------------+

如果使用传统数据库和缓存方案,需要取用户 u1 的关注列表与用户 u5 的全量粉丝列表做交集,处理会比较重。

图数据库可以更自然地表达这类关系查询。

这些复杂关系接口不是用户关系服务的核心接口,但可以增强产品互动性,例如:

你关注的谁也关注了该用户
你和该用户都关注了谁

10.5.2 应用权衡

图数据库适合处理复杂关系型数据和网络数据,具有:

高效的关系查询能力
灵活的数据模型
一定的可扩展性

但目前图数据库还没有真正广泛应用。

可能的一些原因:

缺乏标准化
不同图数据库产品使用不同数据模型
不同图数据库产品使用不同设计原理
不同图数据库产品使用不同查询语言
研发工程师学习图数据库成本较高
缺少统一基础理论
很多数据库产品在实现图数据库时仍然在底层使用了其他NoSQL数据库

因此,对图数据库产品能否真正发挥图形数据的高效查询能力,以及能否保持高可用性,仍然需要谨慎评估。


图数据库的保守使用方式

如果公司对推广图数据库比较保守,可以让图数据库负责非核心但能发挥优势的接口。

在 10.4.4 的最终架构基础上,引入图数据库,引入的思路也是借助伪从技术:

图数据库作为 Following 表的伪从
消费 Following 表的数据变更来构建用户关系图数据

日常情况下:

核心接口仍然由数据库 + Redis + 计数服务承担
复杂关系查询接口才由图数据库承担

此外,如果 Redis 或其他数据库发生故障,核心接口也可以临时访问图数据库获取数据。

也就是说,图数据库还可以作为:用户关系的热备存储

如下图所示,完整架构是在传统方案基础上增加图数据库,让它通过伪从机制同步 Following 表的数据。

image-20260530105740664


本节总结

图数据库用点和边描述用户关系:

用户 = 节点
关注关系 = Follow 边
关注时间 = 边的属性

它能自然支持:

关注列表查询
粉丝列表查询
批量关系查询
共同关注人查询
复杂关系链路查询

但它也有现实约束:

标准化不足
产品差异大
学习成本高
高可用与性能表现存疑

更稳妥的应用方式是:

核心关系服务继续使用数据库 + Redis
图数据库作为 Following 表的伪从
主要承担复杂关系查询和热备能力

一句话总结:

图数据库很适合表达用户关系,
但在大规模核心链路中是否作为主存储,需要结合团队能力和产品成熟度谨慎取舍。

10.6 本章小结

实现高可用、高性能的用户关系服务,关键在于合理使用不同存储系统


1. 数据库作为用户关系数据的最终存储

首先,将数据库作为用户关系数据的最终存储。

由于用户关系数据量巨大,使用数据库存储海量用户关系时,势必要进行分库分表

因此,关键设计是:

将用户关系数据冗余存储到两个结构完全一样的数据表中。

也就是:

Following 表:主数据表
Follower 表:Following 表的伪从表

1.1 Following 表

Following 表是主数据表。

它以 from_user_id 作为索引核心字段,主要负责:

用户关注/取消关注
查询关注关系
查询关注列表

1.2 Follower 表

Follower 表是 Following 表的伪从表。

它复制 Following 表中完全相同的数据,但索引核心字段不同。

Follower 表以 to_user_id 作为索引核心字段,主要负责:

查询粉丝关系
查询粉丝列表

1.3 针对回表问题性能优化

在这两个表的索引设计中,可以使用覆盖索引,进一步提高数据库查询性能。


2. Redis 作为数据库缓存

Redis 作为数据库的缓存。

用户关系服务中,Redis 主要缓存三类数据:

用户关注列表:ZSET 对象
用户粉丝列表:ZSET 对象
最近查询的粉丝关系:Hash 对象

用户的关注列表和粉丝列表都是使用 Redis 的 ZSET 对象缓存。这样既可以保存用户关系,也可以根据时间顺序进行排序,例如查询最近关注的人、最近的粉丝等。

Redis 也被作为数据库Following表的伪从,用于实时地更新关注列表、粉丝列表的缓存。此外, 对于关注列表还可以由每个服务实例本地缓存,以进一步减轻Redis的访问压力


3. 大 V 粉丝列表的缓存策略

需要重点关注的是大 V 用户

大 V 的粉丝量巨大,Redis 无法全量缓存其粉丝列表。

因此选择:

只缓存最近 10000 个粉丝

原因是:

大部分读取粉丝列表的请求,都是读取粉丝列表的前几页。

至于读取粉丝列表后几页的请求,就只能交给数据库处理。

但这类请求可能给数据库带来压力,所以需要进行限流,防止击穿数据库。


4. 最近查询的粉丝关系使用 Hash 缓存

对于最近查询过的粉丝关系,可以使用 Redis 的 Hash 对象缓存。

例如缓存:

用户 2 是否关注了用户 1
用户 3 是否关注了用户 1
用户 4 是否关注了用户 1

这样后续再次查询这些粉丝关系时,可以直接从 Redis Hash 中读取,减少数据库查询。


5. 服务实例本地缓存关注列表

此外,对于关注列表,还可以由每个服务实例在本地内存中缓存。(但只缓存大V的关注列表,因为其访问量较大且变动较小)

这样做的目的,是进一步减轻 Redis 的访问压力。

整体链路可以理解为:

服务实例本地缓存

Redis 缓存

数据库

6. 图数据库

图数据库也很适合存储用户关系。

图数据库中:

用户:节点
用户之间的关注关系:边

最终形成一张用户关系图。

使用图数据库不仅可以方便地拉取:

用户关注列表
用户粉丝列表

还可以轻松实现一些复杂关系查询。

例如:

你关注的谁也关注了该用户
你和该用户都关注了谁

图数据库可以有两种用法:

直接用图数据库实现用户关系服务
或者作为数据库的热备使用

7. 计数服务维护关注数和粉丝数

最后,将用户的关注数粉丝数交给计数服务维护。

计数服务也可以作为数据库 Following 表的伪从。

Following 表中的关注关系发生变化时,计数服务实时更新:

关注数
粉丝数

这样查询关注数和粉丝数时,不需要每次都去数据库中实时 COUNT


8. 总体架构

总之,用户关系服务以数据库 Following 表为数据中心。

最新的用户关系数据会被复制到:

Follower 表
Redis
图数据库
计数服务

整体可以概括为:

Following 表:用户关系数据中心
├── Follower 表:支持粉丝方向查询
├── Redis:支持高并发缓存查询
├── 图数据库:支持复杂关系查询和热备
└── 计数服务:维护关注数和粉丝数