引言
之前在一家公司做社交项目的时候 因为我当时接手的时候项目已经上线 正处于迭代开发过程中。随着广告投入和用户量的增加,我们的系统架构也在不断扩容和优化。然而,有一天我们收到客服的反馈,发现好多用户反馈在查看关注列表或者粉丝列表的时候遇到严重的加载延迟问题,有些用户甚至无法加载这两个列表。
为了尽快解决问题,我们立即展开了排查工作。通过查看监控数据,我们发现与该页面相关的某个接口的查询响应时间出现了异常增长,从正常的几百毫秒延长至几秒钟,甚至更长。
在深入排查后,我们首先排除了接口代码和数据库层的潜在问题。接着,进一步分析 Redis 层的表现时,我们发现问题的根源出在了查询 Redis 时。具体来说,许多用户的关注列表集合或者粉丝列表集合过大,导致 Redis 存储的 大 Key 问题,进而导致查询操作变得非常缓慢,严重影响了接口响应时间。
问题分析
在锁定了问题所在后,我们对当前从 Redis 中获取关注和粉丝集合的逻辑进行了深入分析。经过分析,我们发现问题的根源在于我们当初设计这两个集合时,采用了 Redis 的 SET 数据结构来存储每个用户的关注和粉丝列表。由于当时负载该模块的技术在设计的时候并未充分考虑到用户量的快速增长,将每个用户的关注和粉丝集合单独作为一个键来存储。 这样会导致某些用户在疯狂关注用户后 会导致该用户的集合数据量非常庞大。
例如,我们设计了以下结构:
user:{user_id}:following:存储用户{user_id}关注的用户 ID 集合。user:{user_id}:fans:存储关注了用户{user_id}的粉丝 ID 集合。
随着用户数量和关注/粉丝数量的激增,某些用户的关注列表和粉丝列表变得非常庞大,这就导致了 Redis 中相关的键变成了大 Key,严重影响了 Redis 的性能,特别是在查询时。这种设计上的不足,直接影响了系统的响应速度和可扩展性。
下面我先用一段比较官方的说法来描述一下什么是大key以及大key的危害和我们如何排查大key。
什么是大 Key?
大 Key 指的是在 Redis 中,某个键(Key)对应的数据量特别大,超出了 Redis 通常处理的数据量范围。这个数据量大到一定程度,可能会影响 Redis 的性能。简单来说,大 Key 就是存储了大量数据的键,它可能是一个包含上百万个元素的集合、一个庞大的列表,或者一个超大哈希表。
就比如上面我们把某个用户的所有好友存储在一个 Redis SET 里,如果这个用户有成千上万的好友,那么这个 SET 就变成了 大 Key。
大 Key 会导致什么问题?
- 内存占用高:大 Key 会占用 Redis 很多内存,可能导致 Redis 内存吃紧,甚至发生 内存溢出。
- 查询慢:如果要读取一个大 Key,Redis 必须一次性加载所有数据,导致查询速度变慢,响应时间延迟。
- 操作阻塞:因为 Redis 是单线程的,操作大 Key 时会阻塞其他请求,影响系统整体性能。
- 同步延迟:如果 Redis 采用主从复制,主节点更新大 Key 时,会导致从节点同步变慢,增加延迟。
如何扫描大 Key?
-
使用
SCAN命令逐步遍历 Redis 键空间Redis 提供了
SCAN命令来遍历键空间,它比KEYS命令更高效,因为SCAN是渐进式的,不会阻塞 Redis 主线程。我们可以通过SCAN命令逐步扫描所有键,并筛选出可能的 大 Key。示例命令:
SCAN 0 MATCH user:* COUNT 1000:从游标位置 0 开始扫描。MATCH user:*:匹配以user:开头的键,通常用来筛选我们关注的键空间。COUNT 100:一次返回最多 100 个键,控制扫描的速度。
使用
SCAN命令时,需要反复调用,直到游标返回0,表示扫描完成。 -
使用
MEMORY USAGE命令检查每个键的内存占用对于每个扫描到的键,可以使用
MEMORY USAGE命令来检查该键占用的内存大小。对于 大 Key,我们可以设置一个阈值,超过这个阈值的键就是你要关注的目标。示例命令:
MEMORY USAGE user:1234这个命令会返回
user:1234这个键的内存占用大小(字节数)。我们可以根据返回的大小来判断它是否为 大 Key。 -
结合
SCAN和MEMORY USAGE筛选大 Key为了高效扫描并找出大 Key,我们可以将
SCAN和MEMORY USAGE结合使用,扫描 Redis 键空间并检查每个键的内存占用,筛选出占用内存较大的键。例如:
- 执行
SCAN扫描 Redis 键空间,逐步获取所有键。 - 对每个键执行
MEMORY USAGE,查看内存占用。 - 如果某个键的内存占用超过了设定的阈值,就认为它是一个 大 Key。
- 执行
如何判断一个键是否是大 Key?
通常情况下,我们可以通过以下几个标准来判断一个键是否为 大 Key:
- 内存占用超过 1MB:如果一个键占用的内存超过 1MB,尤其是集合(
SET、LIST等)类的数据结构,可能就需要关注它是否是 大 Key。 - 包含大量元素:例如一个
SET或LIST包含数百万个元素,可能会被认为是 大 Key。
实际在我们现在的架构中,我们通常会配置专门的 Redis 客户端工具和相应的监控系统,主动分析和检测项目中可能产生的 大 Key 问题,避免我们需要定期手动扫描。这种方式让我们能够实时监控 Redis 的使用情况,及时发现潜在的性能瓶颈。然而,像我们之前的项目中当时并没有完善的运维监控机制,导致我们直到问题已经影响到用户体验时才被动发现 大 Key 问题。
解决方案
当时,针对上面场景中大规模用户数据导致 Redis 中出现大 Key,我们提出了以下两种可行的大致解决方案:
-
分片存储关注和粉丝集合
将用户的 关注 和 粉丝 集合进行 分片存储,避免单一大 Key 的问题。我们考虑通过以下两种方式进行分片:- 按用户 ID 的哈希值:根据用户 ID 的哈希值,将其关注和粉丝集合分布到多个小的 Redis 键中。例如,对于用户 A,分片后的关注集合可以存储在
user:{A}:following:shard:{shard_id},其中shard_id是基于user_id计算出来的哈希值。这样可以确保每个分片的关注列表不会过大。 - 按时间范围分片:根据关注时间或粉丝增加的时间进行分片,例如可以按季度、半年或每年进行存储,每段时间内的关注和粉丝数据存储在不同的 Redis 键中。
- 按用户 ID 的哈希值:根据用户 ID 的哈希值,将其关注和粉丝集合分布到多个小的 Redis 键中。例如,对于用户 A,分片后的关注集合可以存储在
-
分页或批量获取关注和粉丝数据
使用 分页 或 批量获取 的方式来减少每次 Redis 查询的数据量。例如,当用户请求查看关注列表或粉丝列表时,采用分页的方式返回每次少量的数据,而不是一次性返回整个集合。这样可以显著降低 Redis 查询时的延迟。例如,用户 A 的关注列表可以被拆分为多个分页,分别存储在user:{A}:following:page1、user:{A}:following:page2、user:{A}:following:page3等多个分页中。每次请求可以只返回一个分页的数据。
方案分析
1. 按时间分片方案
基于之前的方案讨论,如果采用分片设计,考虑到产品的增量趋势,我们决定不采用按时间进行分片的方式。原因是,随着节假日等特殊时段的到来,用户活跃度会大幅上升,导致关注和粉丝集合的数据量急剧增加。如果按时间进行分片(例如按月份、季度等),可能会导致某些时间段的分片承载过多的数据,而其他时间段的数据较少。这会造成数据分布不均,从而导致部分分片的查询性能较差,甚至可能引发大 Key 问题。
例如,在某些特殊时期(如“双十一”或春节),用户关注数激增,这会导致相关时间段的分片承载过多数据,而非活跃时间段的分片则较小,导致不均衡的查询压力。因此,我们决定不考虑按时间分片方案。
2. 按分页拆分
如果选择按分页拆分(即将关注列表拆分成多个小的集合,按页数进行划分),我们需要考虑到在实际场景中,用户关注其他用户时,系统需要判断该用户是否已经被关注过。
在分页拆分的设计下,每次查询某个用户是否已经被关注时,需要遍历多个分页(多个 Redis 键)。例如,如果用户 A 关注的用户分布在 user:{A}:following:page1、user:{A}:following:page2、user:{A}:following:page3 等多个分页中,要判断用户 B 是否被 A 关注时,我们必须逐个查询每一页的关注集合,直到找到目标用户。
这种查询过程不仅增加了复杂度,还可能导致性能瓶颈,特别是在关注列表较大时,分页数目增多,查询时需要检查更多的分页,导致查询变得低效。
尽管我们可以引入 bitmap 来统计用户的关注情况,优化查询,但在实际场景中,我们还需要支持取消关注操作。在分页拆分的情况下,删除某个关注记录会变得非常复杂,因为我们必须在多个分页中找到目标用户并删除相应记录。这不仅增加了查询的复杂性,还使得删除操作变得繁琐,难以高效实现。
因此,综合考虑查询和删除操作的复杂性,我们决定暂时不考虑使用分页拆分方案。
3. 按用户 ID 分片
在按用户 ID 分片的设计中,我们将每个用户的关注列表根据其用户 ID 进行哈希分片。具体来说,关注列表会被分布到多个 Redis 键中,这些键的命名可能是 user:{user_id}:following:shard:{shard_id},其中 shard_id 是根据 user_id 哈希值计算出来的分片标识。
这种设计的优点是,可以均匀地分配数据,避免了某个集合成为大 Key 的问题,且每个分片的数据量相对较小,查询和操作的性能较好。
在实际操作中,当需要查询某个用户是否已关注其他用户时,我们首先需要通过该用户的 ID 计算出对应的分片位置(即 shard_id)。然后,通过查询该分片对应的 Redis 键(如 user:{user_id}:following:shard:{shard_id})来判断该用户是否关注了目标用户。由于数据已经在多个分片中分布,查询时的负载相对较低,且每个分片中的数据量更小,有助于提高查询效率。
最终方案
在考虑了不同的分片与分页方案后,结合实际业务需求和技术实现的复杂性,最终我们决定采用 按用户 ID 分片 的方案来存储和管理关注和粉丝集合。
我们将每个用户的关注和粉丝列表根据其用户 ID 进行哈希分片。每个用户的关注列表将存储为多个 Redis 键,命名格式为 user:{user_id}:following:shard:{shard_id},其中 shard_id 是基于 user_id 的哈希值计算出来的。粉丝列表的存储方案同样适用此设计,例如:user:{user_id}:fans:shard:{shard_id}。
优点:
- 均匀分布数据:通过哈希分片,数据能够均匀分布在多个 Redis 键中,避免了单个大 Key 导致的性能瓶颈和 Redis 内存压力。
- 简化查询操作:每个分片的关注数据量较小,查询时只需检查一个较小的 Redis 键,从而提升查询性能。
- 易于扩展:随着用户数的增长,我们可以调整哈希分片策略来均衡数据负载,灵活扩展系统。
缺点:
- 复杂的哈希计算:每次查询用户是否关注其他用户时,需要根据
user_id计算出对应的shard_id,这会对业务逻辑带来一定影响。虽然如此,我们可以通过缓存计算结果来优化哈希计算的开销。
后续优化
为了进一步提升性能,我们计划结合 Bitmap 方案来优化查询。在用户关注列表中,我们为每个用户设置一个 bitmap,其中每一位代表该用户是否关注了其他用户。当查询某个用户是否被关注时,我们可以直接查找 bitmap 中相应的位置,这大大减少了查询的时间和复杂度。