这是我参与「第三届青训营 -后端场」笔记创作活动的第2篇笔记
本文是我在项目设计过程中,对于关注/粉丝表的设计以及对应缓存的实现的一点见解。
relation的实现思路
-
数据库建表思路
-
一个简单的思路(方案一):
数据库表的结构:(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这个逻辑时就确保了是在行锁保护下的读操作。
-
缓存的优化
由于自己的服务器配置差且只有一台,使用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的架构图:
第一层是缓存容器(Cache)。缓存容器下属多个分片(Segment),使用自定义的哈希算法,将键值对分配到各个段中。段采用内建的map数据结构进行二次哈希,并保存到map数据结构中。当写入操作发生时,只对该数据所属的段加锁,而不是对整个缓存容器加锁,这样可以提升并发访问的性能。
W-TinyLFU方法:
W-TinyLFU的缓存策略:
W-TinyLFU的优点总结:
注:
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→当内存不足以容纳新写入数据时,只在写操作时返回一个错误。(默认配置)