题目
设计实现关注,粉丝列表,且有分页功能
每天1亿的日活量用户
100万的关注和取关修改量
简介
关系链指的是用户A与用户B的关注关系。以关注属性细分,以关注(订阅)为主,还涉及拉黑、悄悄关注、互相关注、特别关注等多种属性或状态
关系链状态机
A和B的状态就5种
数据量少的时候
一张关系表、一张计数表即可满足线上使用
关系表的结构示例
计数表的结构示例
一次mid新增关注fid请求为例
查询mid的计数并加锁,查询fid的计数并加锁;
查询mid->fid的关系并加锁,查询fid->mid的关系并加锁;
根据状态机,在内存计算新关系后,修改mid->fid的关系,修改fid->mid的关系(若有,比如由单向关注变为互关);
修改mid的关注数,修改fid的粉丝数;
上面架构的缺点
中规模可以先分表
一方面,即使做了分表,关系链数据整体规模仍然超出了建议的整体存储容量(TB级);
另一方面,繁重的事务导致mysql无法支撑很高的写流量,在原始的同步写架构下,表现就是关注失败率变高;如果只是单纯地升级到异步写架构,表现就是消息积压,当消息积压持续时间超过临时缓存有效期时,会引起客诉,治标不治本。
使用mysql作为核心存储的“同步”写关系流程图
升级架构
数据存储从mysql迁移到KV存储,逻辑方面把”同步写mysql“改为”异步写KV“。未选择”同步写KV“的原因,一方面是一条关系对应着多条KV记录,而KV不支持事务;另一方面是异步的架构可以扛住可能存在的瞬时大量关注请求。为了兼容订阅了mysql binlog的业务,在“异步写KV”之后还会”异步写mysql“。
在新架构下,对于每一次用户关注请求,投递databus(异步消息)即视为请求成功,mysql binlog只提供给一些对实时性不太敏感的业务方(如数据平台),所以对于异步写mysql事件的偶尔的轻微积压我们并不需要关心,而对实时性要求比较高的业务方,我们在处理完异步写KV事件后,会投递了databus供这些业务订阅。
KV存储最大的优势在于,底层能提供计数(count)方法以替代冗余的mysql计数表,这样的好处是,我们只需要维护一张单纯保存关系的KV表即可。我们设计的存储结构是:
key为{attr|mid}fid,attr为关系拉链类型,mid和fid都表示用户id,{attr|mid}表示拼接attr和mid作为hash,该hash下的多个fid将按照字典序存储,结合KV服务提供的拉链遍历方法(scan)以什么开头的
比如{ATTR_FOLLOW|11}hash出来是AAAA
则查11的关注列表就是遍历AAAA% ,可以获取该hash下的所有的fid;
value为结构体,包含了attribute(关系属性)和mtime(修改时间);
key的解释 (attr和attribute容易混淆)
key中的attr为关系拉链类型,一共有5类(3类正向关系,2类反向关系):ATTR_WHISPER表示悄悄关注类(mid悄悄关注了fid),
ATTR_FOLLOW表示关注类(mid关注了fid),
ATTR_BLACK表示拉黑类(mid拉黑了fid),
ATTR_WHISPERED表示被悄悄关注类(mid被fid悄悄关注了),ATTR_FOLLOWED表示被关注类(mid被fid关注了)。
对用户来说,各类列表和关系链类型的映射关系如下:
关注列表:根据产品需求的不同,大部分时候指关注类关系链(attr=ATTR_FOLLOW),有些场景也会加上悄悄关注类关系链(attr=ATTR_WHISPER);
粉丝列表:被悄悄关注类关系链(attr=ATTR_WHISPERED)和被关注类关系链(attr=ATTR_FOLLOWED)的合集;
黑名单列表:拉黑类关系链(attr=ATTR_BLACK)。
value中的attribute表示当前的关系属性,一共有4种:WHISPER表示悄悄关注、FOLLOW表示关注、FRIEND表示互相关注、BLACK表示拉黑。这里和前文的attr较易混淆,它们之间完整的映射关系如下:
attr=ATTR_WHISPER或ATTR_WHISPERED下可以有attribute=WHISPER;
attr=ATTR_FOLLOW或ATTR_FOLLOWED下可以有attribute=FOLLOW或者FRIEND;
attr=ATTR_BLACK下可以有attribute=BLACK。
midA的五种关系拉链如图:
升级到KV存储后读操作
如果要正向查询mid与fid的关系,只需要点查(get、batch_get)遍历3种正向attr;
如果要查询全量关注关系、黑名单,只需要找对应attr分别执行scan;
如果要查询用户计数,只需要count对应的attr即可;
升级到KV存储后写操作
mysql有事务来保证原子性,而kv存储并不支持事务。而对于用户的请求而言,投递databus即算关注成功,那么在异步处理这条消息时,需要100%保障成功写入,因此我们在处理异步消息时,对每个写入操作都加上了失败无限重试的逻辑。
极端情况下,还可能会遇到写入冲突问题:比如某个时间点用户A关注了用户B,”同时“用户B关注了用户A,此时就可能会引发一些意想不到的数据错误(因为单向关注和互关是两个不同的属性,任一方的关注行为都会影响这个属性)。为了避免这种情况出现,我们利用了消息队列同一个key下数据的有序特性,通过保证同一对用户分配到一个key,保证了同一对用户的操作是有序执行的。
还是以mid新增关注fid为例,对于每一条关注事件:
job需要先put一条正向关注关系,然后进行上限校验,如果超过上限那么回滚退出;
然后批量put因本次关注动作所影响的所有其他反向attr,比如mid的被关注关系(attr=ATTR_WHISPERED)、fid的关注关系(attr=ATTR_FOLLOW,若有,比如由单向关注变为互关);
上述任何一个put操作失败了,都需要重试;直到这些动作都完成了,那么认为此次关注事件成功;
投递databus,告知订阅方发生了关注事件;
投递异步写mysql事件,把关注事件同步mysql,产出binlog供订阅方使用。
全量关注列表、全量黑名单
线上查询请求中,有一定比例是查询全量关注列表、全量黑名单。上一节中提到,为了不冗余存储一份关系链计数,KV的存储设计得比较特殊,一个用户的正向关注关系分布在3个不同的attr(即3个不同的关系拉链)里。如果想从KV存储拉取一个用户的全量关系列表,那么同时需要分别对3种正向关系拉链都做循环scan(因为每次scan有数量上限),但由于scan方法性能相对较差,所以需要在KV存储的上层加一套缓存,通过降低回源比例严格控制scan QPS。
鉴于memcached对大key有比较好的性能,前人在KV存储的上层加了一个memcached缓存,用于存储用户的全量关系列表,具体业务流程如下
Memcached是一个开源的高性能分布式内存对象缓存系统。它可以帮助应用程序加快访问数据库、API调用或其他耗时操作的速度,通过将数据存储在内存中,避免了频繁的磁盘读写操作。
Memcached使用简单的键值对存储模型,可以将数据以键值对的形式存储在内存中,并提供快速的读写访问。它常用于缓存常用的数据库查询结果、网页内容、API响应等。
Memcached是一个分布式系统,它可以在多台服务器上运行,并通过哈希算法将数据分散存储在不同的节点上。这样可以提高系统的可扩展性和容错性,确保即使某个节点失效,数据仍然可用。
Memcached支持多种编程语言,并且有许多客户端库可供开发人员使用。它被广泛应用于Web开发、缓存加速、数据分析等领域,为应用程序提供了高速的数据访问能力。
查询用户和其他一个或多个用户的关系
如果每次都从memcached拉全量关系列表然后内存中取交集,网络的开销会非常大,因此对这种查询场景,也需要设计一套适用于点查的缓存。
活跃用户的关注数一般都在几十到几百的区间,用于点查的缓存不需要严格有序,但要支持指定hashkey的查询,redis hash和其提供的hget、hmget、hset、hmset方法都是非常适合这一场景的。因此查询层缓存设计如下:key为mid,hashkey为mid有关系的每一个用户id,value为他们的关系数据,和前面midA在KV存储的数据对应如下:
Redis中的Hash是一种用于存储键值对的数据结构,其中键是唯一的,值可以是字符串、数字等。Hash是一种适用于存储对象的数据结构,可以实现对对象的部分修改和读取。
Redis提供了一系列操作Hash的命令,包括hget、hmget、hset、hmset等。
- hget命令用于获取Hash中指定字段的值:
Jedis jedis = new Jedis("localhost");
String value = jedis.hget("hashKey", "field");
其中,"hashKey"是Hash的键,"field"是要获取的字段名,返回的是字段对应的值。- hmget命令用于获取Hash中多个字段的值:
Jedis jedis = new Jedis("localhost");
List<String> values = jedis.hmget("hashKey", "field1", "field2");
其中,"hashKey"是Hash的键,"field1"、"field2"是要获取的字段名,返回的是一个列表,包含了字段对应的值。- hset命令用于设置Hash中指定字段的值:
Jedis jedis = new Jedis("localhost");
Long result = jedis.hset("hashKey", "field", "value");
其中,"hashKey"是Hash的键,"field"是要设置的字段名,"value"是要设置的字段值,返回的是设置操作的结果(1表示成功,0表示字段已存在)。- hmset命令用于设置Hash中多个字段的值:
Jedis jedis = new Jedis("localhost");
String result = jedis.hmset("hashKey", Map.of("field1", "value1", "field2", "value2"));
其中,"hashKey"是Hash的键,Map中的键值对表示要设置的字段和对应的值,返回的是设置操作的结果。
需要注意的是,以上示例中使用的是Jedis客户端进行操作,你可以根据自己的需求选择其他的Redis客户端库。
redis hash缓存中关注关系的存储结构
由于hash里保存的是midA的全部正向关注关系,当缓存miss需要回源时,要获取全量关注关系,可以和前面的memcached配合使用,业务流程如下:
热点场景
问题根因:集中在Redis的少数几台实例上,木桶短板效应就会非常明显。
对于热点用户,在请求Redis前会先查询本地Localcache(Localcache中存储的up主关系列表数据,隔十几秒更新一次)。虽然在这十几秒内可能会存在数据不一致的情况,但从实际业务角度看,引发热点请求的都是大up主,这些up主的关系列表较少发生变动,因此几乎不会对用户体验造成影响。