本文将借助主人公小美介绍亿级流量关注关系系统的存储模型设计及常见挑战!
爱思考的程序媛小美被安排负责社交产品 关注关系系统。
小美公司的社交产品新业务可以类比快手、抖音、微信等产品形态,用户和用户之间可以互相关注,App内大量场景需要查询关注关系,例如查询A是否关注B、AB是否是互相关注的朋友关系等。关注关系系统是社交类产品的基石,往往一个社交产品从名不见经传到大规模爆发只需要几个月甚至几天的时间,在系统设计阶段就要考虑未来可能高并发、高用户基数下对系统巨大的存储压力、查询压力。
小美第一时间梳理了现有系统产品功能、系统架构设计。在未进行同类系统调研前,她已经发现系统存在的巨大不合理之处。问题恰恰出在存储模型上。
历史设计的缺陷
原来现有的关注关系系统居然没有进行分库分表。
现有系统Follow表是单表,fromUserId代表主动关注者 ,toUserId表示被关注者。
小美对此表示无语。在询问历史背景后,才得知设计者小笨考虑到未来可能存在大数据量,但是因为没有想通一个问题,所以没有进行分库分表。
"如果分表那么分表键选择 fromUserId还是toUserId呢? 如果使用fromUserId,那么查询我的关注列表功能可以实现。但是查询我的粉丝列表如何实现呢? 反之也同样如此,如果使用toUserId作为分表键,也会有相反的困扰。" 小笨理直气壮的说。
"可以使用两个表维护关注关系。我来设计一下新的方案" 。小美说完就回工位设计方案了,小笨则陷入思考中。
全新的系统设计
"为了查询方便,数据存储是可以冗余的。" 小美明白这个道理。她设计了两张表维护关注关系,Follows表支持查询我的关注列表,Fans表支持查询我的粉丝列表。A是否关注B的查询实现则通过将关系数据异构到redis,通过查询redis更高效的支持是否关注关系的查询场景。
查询我的关注列表场景:指定fromUserId查询Follow表。
查询我的粉丝列表场景:指定toUserId查询Fans表。
用户的关注行为触发后,同时保证写入Follow表、Fans表。因为分库后无法保证事务Follow表和Fans表在一个数据库内,小美也遇到如何保证数据一致性的问题。
如何保证跨数据库的事务性需要根据场景进行评估。社交关注场景就是典型的弱一致性场景,即使一半成功一半失败也不会有什么问题,不会对用户造成资损,也很难导致用户客诉。回到关注关系Follow、Fans各新增一条记录分别影响我的关注列表,我的粉丝列表。对于主动关注者来说,点击失败后,他更关心关注列表,而不关心被关注者的粉丝列表。所以当出现更新失败时,用户被告知关注异常,此时要确保关注列表没有记录。 所以小美打算先新增Fans记录,再增加Follow记录。具体流程如下
主要思路是先增加Fans记录,然后重试新增Follow记录,新增失败则尝试回滚Fans记录。 当前系统可能得几种场景:
rust
新增Fans失败 关注失败
新增Fans成功、新增Follow成功。 关注成功
新增Fans成功、新增Follow失败-> 回滚Fans成功。 关注失败
新增Fans成功、新增Fans失败-> 回滚Fans失败。 关注异常,人工介入处理。
从经验来看,一般情况下除非系统机房网络故障、数据库故障。场景4 很少发生。我们提供了专门的故障MQ,进行人工介入处理。也不会出现脏数据无法清理的情况。(后续会介绍业务异常的兜底平台-> 故障运维系统的建设)
即便问题4发生,用户关注异常,用户也大概率不会关注到数据不一致。因为用户关注别人后,基本不会去别人的粉丝列表查看我是否在他的粉丝列表中。
小美并没有使用复杂中间件来保证分布式事务的强一致性,而是针对弱一致性场景,尽可能优化常见的异常场景,并针对极端case提供了人工介入修复的工具。这是值得肯定的思路。
关注关系查询设计
社交类产品对关注关系的查询场景非常多,常见的查询需求包括
- A的粉丝列表
- A的关注列表
- A是否关注B
- A是否关注 B、C、D 【批量查询】
- A和B是否互相关注
问题1、2 场景,考虑到查询粉丝列表、查看关注列表是用户相对低频的操作,而且是分页查询。小美认为通过从库完全可支撑这两个场景的并发查询压力。
问题3 4 5 场景则是并发量较高的场景。例如用户A刷到用户B的帖子,就需要展示用户A是否已关注用户B。用户浏览的Feeds场景并发量级是相当高的。接口并发量、接口性能、延迟时间要求都是极高的。
基于此,小美认为必须基于redis才能满足这个场景查询诉求。
考虑到先更新Fans再更新Follow,说明Follow记录新增成功,关注关系则建立成功。小美认为可以通过订阅 Follow表的binlog 通知 (公司基建能力,不作介绍)将关注关系异构到 Redis中。这样可以通过 MQ的可靠性,不断重试更新redis,保证数据库和 redis之间数据最终一致。
在谈到redis存储模型前,小美还在考虑一个问题。即个别用户的粉丝数量会非常庞大,可能上百万、上千万。一个用户的关注数量则不会出现过高的数量级,一般不会超过10K。 但为了避免极端用户对系统性能的挑战,小美决定主动和产品经理讨论 关注规模和粉丝规模的问题。
在和产品的沟通中,产品也认可了小美的思路,对用户的行为进行限制。 粉丝列表只支持分页查询最近2K个粉丝。用户最多支持关注2K。
小美为什么急匆匆找产品确认用户最多的关注规模呢?因为这决定了redis的存储模型,如果用户可以关注100w个人,系统设计的挑战就太大了。主动和产品沟通需求,抓住需求的重点,协调产品放弃支持极端的场景,简化系统设计,缩短开发难度和周期。 小美在这方面做的非常不错。
Redis存储模型
小美打算存储用户A的所有关注列表到Redis中,使用redis hash结构,A的userId为大key。 A关注的用户UseId为 hash key。
针对A是否关注B,A是否关注B、C、D 【批量查询】两个场景都可以通过一次redis HMGET 查询返回结果。 A、B是否相互关注,则通过两次Redis查询 解决。
如果小美没有和产品经理沟通限制用户的最大关注规模,那么小美就需要考虑如果一个用户关注列表巨大,是否会导致hash结构过大,出现redis大key,进而阻塞整个redis集群。
Redis缓存失效时机
因为关注关系严重依赖redis的稳定性和容量,小美单独申请了一个redis集群。由于容量充足,她设置了较长的过期时间。当然cache miss时,会加载对应用户的关注列表到缓存中。缓存和数据库的一致性保证,不再细讲。
后来,社交产品的发展非常迅速,对关系查询的并发量、存储量要求越来越高,得益于小美的前瞻性设计,数据库容量、查询性能都没出现瓶颈。平稳的支撑了产品的爆发式增长。但是随着接入方的越来越多,关系服务的服务器规模也越来越大,同时因为服务发布、GC、网络抖动等原因,关系服务也遇到了性能挑战。
总有上游反馈调用关系接口存在耗时尖刺。同时关系服务的服务器规模也十分巨大,一直是部门的成本预算大头。让领导也十分头疼,削减完机器后,突然遇到流量高峰,就会出现大量告警。
客户端接入问题
小美发现上游通过RPC调用关系服务的网络阶段、rpc处理阶段的总耗时远大于从redis取数的耗时。
"为什么不让上游直接从redis取关注关系呢?我们提供Client 给上游使用,封装了查询Redis的逻辑。这样就减少了1次RPC网络耗时,也减少了我们服务器的规模。"
后来的一段时期,小美十分痛苦的推动上游修改调用方式。这个技术迁移对于上游收益不大,导致推动阻力非常大。小美感慨到:"写代码真是幸福的事情啊"。推动别的团队是一件费时费力的事情。小美心里想,如果我当初就想到通过Client方式接入,而非RPC接入就好了。
当然小美已经做的很不错了。
作为程序员,我们都喜欢设计新系统,不喜欢维护全是坑的老系统。但真正设计新系统时我们自己又会埋坑给后来人。小笨就是这样的人。他因为一时没有想清楚如何解决 分库分表后 粉丝列表和关注列表的查询矛盾就选择了单表方案,这实在让人气愤和看不起。 当然他还会有借口,例如时间紧任务重、新产品抓紧上线先实现再优化。类似 完成胜于完美等等言论都是他的借口。 但是如果我们平时多充充电,多调研业界成熟的系统设计,学习参考其他系统或业务场景的设计,跳出自己工作的一亩三分地,逃出舒适区。眼界会更加开阔,这样真正遇到系统设计困境时,不至于像 小笨 设计出这么差劲的方案。
即便非常善于思考的小美,也会踩坑。如果当初推广Client方式接入,而非RPC接入,就省去了后续乏味的协调迁移工作。
所以我们要不断学习,不断思考啊。多多调研业界前辈的设计方案,多掌握一个经验技巧,就少走好多弯路~。
不断进步的小美,在方案设计完成之余,想起leader王哥的一句话:"系统设计之初就要想到未来的平台化、中台化如何做"。小美恍然大悟,原来自己的系统还存在巨大缺陷......