关注/粉丝表的设计以及对应缓存的实现|青训营笔记

1,022 阅读4分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记

本文是我在项目设计过程中,对于关注/粉丝表的设计以及对应缓存的实现的一点见解。

relation的实现思路

  1. 数据库建表思路

    • 一个简单的思路(方案一):

      数据库表的结构:(user_id,liker_id)

      如果A关注B,即插入A B

      如果B关注A,即插入B A

    • 我们的思路(方案二):

      数据库表结构:(user_id,liker_id,relation_ship)

      保证user_id严格小于liker_id

      即A关注B,若A>B,则插入B A 2

      若A< B,则插入A B 1

      relation_ship可取值0,1,2,3

      值是0的时候,表示互相没关注(此时,删除此行)

      值是1的时候,表示user_id 关注 liker_id;

      值是2的时候,表示liker_id 关注 user_id;

      值是3的时候,表示互相关注。

      我们思路的优点:

      • 减少了数据库的冗余,存储量减少了将近一半

      • 解决了将来业务可能存在的一个并发问题:

        业务需求:A,B两个用户,如果相互关注,则赋予一些权限(比如将A,B插入friend表),可以互相发消息;否则只是单向关注关系。

        在这种业务需求下,方案一可能会存在的情况

        session1(A关注B)session1(B关注A)
        begin; select * from 'relation' where user_id = B and liker_id = A; 返回空
        begin; select * from 'relation' where user_id = A and liker_id = B; 返回空
        insert into 'relation'(user_id,liker_id) values(B,A);
        insert into 'relation'(user_id,liker_id) values(A,B);
        commit;
        commit;

由于一开始A和B之间没有关注关系,所以两个事务里面的select语句查出来的结果都是空。因此,session 1的逻辑就是“既然B没有关注A,那就只插入一个单向关注关系”。session 2也同样是这个逻辑。这个结果对业务来说就是bug了。因为在业务设定里面,这两个逻辑都执行完成以后,是应该赋予一些权限的。方案一第1步即使使用了排他锁也不行,因为记录不存在,行锁无法生效。

我们的方案可以完美解决这个问题:

如果A<B,就执行下面的逻辑:

begin; /*启动事务*/
insert into `relation` 
(user_id, liker_id, relation_ship) 
values(A, B, 1) on duplicate key 
update relation_ship = relation_ship | 1;

select relation_ship from `relation` 
where user_id=A and liker_id=B;
/*代码中判断返回的 relation_ship,
  如果是1,事务结束,执行 commit。
  如果是3,则执行赋予权力的操作。
  */

commit;    

如果A>B,则执行下面的逻辑:

begin; /*启动事务*/
insert into  `relation` 
(user_id, liker_id, relation_ship)
 values(B, A, 2) on duplicate key
 update relation_ship=relation_ship | 2;

select relation_ship from `relation` 
where user_id=B and liker_id=A;
/*代码中判断返回的 relation_ship,
  如果是2,事务结束,执行 commit
  如果是3,则执行赋予权力的操作。
*/
commit;

 这个设计里,让“relation”表里的数据保证user_id < liker_id,这样不论是A关注B,还是B关注A,在操作“relation”表的时候,如果反向的关系已经存在,就会出现行锁冲突。然后,insert … on duplicate语句,确保了在事务内部,执行了这个SQL语句后,就强行占住了这个行锁,之后的select 判断relation_ship这个逻辑时就确保了是在行锁保护下的读操作。

  1. 缓存的优化

    由于自己的服务器配置差且只有一台,使用redis的话服务上线需要较高的运行成本,不太好管理。所以,relation服务和vedio服务自己实现了一套单机缓存MiniCache。

    MiniCache,拥有懒清理和哨兵清理两种清理机制,内存淘汰策略使用W-TinyLFU方法。

    功能特性:

    • 引入 option function 模式,可定制化各种操作的过程
    • 引入分片机制,以此来减少锁的粒度,提高并发
    • 拥有懒清理和哨兵清理两种清理机制
    • 手写 singleflight 机制,减少缓存穿透的伤害
    • 手写布隆过滤器记录全量数据,不存在直接返回,减少对DB的访问压力(单元测试通过,待加入MiniCache)
    • 支持W-TinyLFU内存淘汰算法,解决LRU热点数据命中率不高以及LFU难以应对突发流量,可能存在旧数据长期不被淘汰的问题
    • 支持使用随机过期时间,减少缓存失效的伤害

目前支持带过期时间的key支持懒清理和哨兵清理两种清理机制,当内存到达分段上限后,随机淘汰;不带过期时间的key支持W-TinyLFU内存淘汰算法;将来会支持带过期时间的key支持W-TinyLFU内存淘汰算法(待加入MiniCache)

MiniCache的架构图:

MiniCache的架构图.png

第一层是缓存容器(Cache)。缓存容器下属多个分片(Segment),使用自定义的哈希算法,将键值对分配到各个段中。段采用内建的map数据结构进行二次哈希,并保存到map数据结构中。当写入操作发生时,只对该数据所属的段加锁,而不是对整个缓存容器加锁,这样可以提升并发访问的性能。

W-TinyLFU方法:

W-TinyLFU的架构.png

W-TinyLFU的缓存策略:

W-TinyLFU缓存策略.png

W-TinyLFU的优点总结:

W-TinyLFU的优势总结.png

注:

redis的内存淘汰策略:

  • volatile-lru→当内存不足以容纳新写入数据时,在带有过期时间的 key 中删除最近最少使用的 key
  • allkeys-lru→当内存不足以容纳新写入数据时,删除最近最少使用的 key(推荐)
  • volatile-lfu→当内存不足以容纳新写入数据时,在带有过期时间的 key 中删除最少使用的 key
  • allkeys-lfu→当内存不足以容纳新写入数据时,删除最少使用的 key
  • volatile-random→当内存不足以容纳新写入数据时,在带有过期时间的 key 中随机删除 key
  • allkeys-random→当内存不足以容纳新写入数据时,随机删除 key
  • volatile-ttl→当内存不足以容纳新写入数据时,删除最近最少使用的 key
  • noeviction→当内存不足以容纳新写入数据时,只在写操作时返回一个错误。(默认配置)