摘自《Redis深度历险:核心原理和应用实践》与网上文章,进行的归纳和整理。

Redis过期策略

Redis 所有的数据结构都可以设置过期时间,时间一到,就会自动删除。你可以想象Redis 内部有一个死神,时刻盯着所有设置了过期时间的 key,寿命一到就会立即收割。
你还可以进一步站在死神的角度思考,会不会因为同一时间太多的 key 过期,以至于忙不过来。同时因为 Redis是单线程的,收割的时间也会占用线程的处理时间,如果收割的太过于繁忙,会不会导致线上读写指令出现卡顿。

过期的 key 集合

redis 会将每个设置了过期时间的 key 放入到一个独立的字典中,以后会定时遍历这个字典来删除到期的 key。除了定时遍历之外,它还会使用惰性策略来删除过期的 key,所谓惰性策略就是在客户端访问这个 key 的时候,redis 对 key 的过期时间进行检查,如果过期了就立即删除。定时删除是集中处理,惰性删除是零散处理。

定时扫描策略

Redis 默认会每秒进行十次过期扫描,过期扫描不会遍历过期字典中所有的 key,而是采用了一种简单的贪心策略

  1. 从过期字典中随机 20 个 key;
  2. 删除这 20 个 key 中已经过期的 key;
  3. 如果过期的 key 比率超过 1/4,那就重复步骤 1;

同时,为了保证过期扫描不会出现循环过度,导致线程卡死现象,算法还增加了扫描时间的上限,默认不会超过 25ms
设想一个大型的 Redis 实例中所有的 key 在同一时间过期了,会出现怎样的结果?
毫无疑问,Redis 会持续扫描过期字典 (循环多次),直到过期字典中过期的 key 变得稀疏,才会停止 (循环次数明显下降)。这就会导致线上读写请求出现明显的卡顿现象。导致这种卡顿的另外一种原因是内存管理器需要频繁回收内存页,这也会产生一定的 CPU 消耗。
也许你会争辩说“扫描不是有 25ms 的时间上限了么,怎么会导致卡顿呢”?这里打个比方,假如有 101 个客户端同时将请求发过来了,然后前 100 个请求的执行时间都是25ms,那么第 101 个指令需要等待多久才能执行?2500ms,这个就是客户端的卡顿时间,是由服务器不间断的小卡顿积少成多导致的。

所以业务开发人员一定要注意过期时间,如果有大批量的 key 过期,要给过期时间设置一个随机范围,而不能全部在同一时间过期。

# 在目标过期时间上增加一天的随机时间
redis.expire_at(key, random.randint(86400) + expire_ts)

在一些活动系统中,因为活动是一期一会,下一期活动举办时,前面几期的很多数据都可以丢弃了,所以需要给相关的活动数据设置一个过期时间,以减少不必要的 Redis 内存占用。如果不加注意,你可能会将过期时间设置为活动结束时间再增加一个常量的冗余时间,如果参与活动的人数太多,就会导致大量的 key 同时过期。
掌阅服务端在开发过程中就曾出现过多次因为大量 key 同时过期导致的卡顿报警现象,通过将过期时间随机化总是能很好地解决了这个问题,希望读者们今后能少犯这样的错误。

从库的过期策略

从库不会进行过期扫描,从库对过期的处理是被动的。主库在 key 到期时,会在 AOF文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的key。
因为指令同步是异步进行的,所以主库过期的 key 的 del 指令没有及时同步到从库的话,会出现主从数据的不一致,主库没有的数据在从库里还存在,比如上一节的集群环境分布式锁的算法漏洞就是因为这个同步延迟产生的。


缓存穿透 & 缓存击穿 & 缓存雪崩

一、缓存穿透

定义:指查询一个根本不存在的数据,缓存层和持久层都不会命中,使得后端负载增大,失去缓存层保护后端持久层的意义。
场景:自身业务中的get/set对应的key不一致;恶意攻击、爬虫造成大量空命中(超大循环递增爬商品ID)
方案:
(1)缓存空对象:对穿透的key进行set(key, null),设置不超过5分钟的短暂过期时间或通过消息系统清除缓存中的空对象。
(2)布隆过滤器拦截:在访问缓存层和存储层之前,将存在的key用布隆过滤器提前保存起来,做第一层拦截,当收到一个对key请求时先用布隆过滤器验证是key否存在,如果存在在进入缓存层、存储层。可以使用bitmap做布隆过滤器。
附:布隆算法
初始状态时,BloomFilter是一个长度为m的位数组,每一位都置为0。
添加元素x时,x使用k个hash函数得到k个hash值,对m取余,对应的bit位设置为1。
判断y是否属于这个集合,对y使用k个哈希函数得到k个哈希值,对m取余,所有对应的位置都是1,则认为y属于该集合(哈希冲突,可能存在误判),否则就认为y不属于该集合。可以通过增加哈希函数和增加二进制位数组的长度来降低错报率。

对比:

二、缓存击穿

定义:持久层中存在,但是缓存层不存在。如果此时有大量的热点key请求打过来,会走到持久层并将查到的数据回设到缓存层。且回设的缓存可能是复杂的计算(复杂的SQL、多次IO、多个依赖等)无法在短时间完成,这就会造成在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。
场景:上一期的秒杀活动
方案:
(1)分布式互斥锁:只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。setnx(key, value, timeout)
(2)永不过期:为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去更新缓。
对比:

解决缓存击穿适用场景维护成本
分布式互斥锁低并发,吞吐小代码维护简单,降低后端负载
保证一致性
高并发场景下吞吐量会急剧降低
永不过期一致性要求不高代码维护复杂

三、缓存雪崩

定义: 缓存层由于某些原因不可用(宕机)或大量缓存在同一时间段失效(大量key/热点数据失效),大量请求直接到达存储层,存储层压力过大导致系统雪崩。
场景:机房断电、机器宕机、大量key/热点数据失效
方案:
(1)缓存层高可用化:接入Sentinel/Cluster等保障缓存层高可用。
(2)多级缓存:本地进程作为一级缓存,redis作为二级缓存,不同级别的缓存设置的超时时间不同,即使某级缓存过期了,也有其他级别缓存兜底。
(3)缓存过期时间用随机值强烈建议,尽量让同批次的key过期时间不同。

Last modification:September 8th, 2021 at 10:06 am
喵ฅฅ