26 缓存异常

缓存雪崩、缓存击穿和缓存穿透,这三个问题一旦发生,会导致大量的请求积压到数据库层,导致数据库宕机或故障。

缓存雪崩

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

如何发现:
监测 Redis 缓存所在机器和数据库所在机器的负载指标,例如每秒请求数、CPU 利用率、内存利用率等。如果我们发现 Redis 缓存实例宕机了,而数据库所在机器的负载压力突然增加(例如每秒请求数激增),此时,就发生缓存雪崩了。

原因一:大量数据同时过期。
解决方案:

  1. 过期时间加随机数
  2. 服务降级:暂停非核心业务访问,直接返回预定义信息;核心数据允许继续查询

原因二:Redis实例宕机
解决方案:

  1. 服务熔断或限流:

    • 服务熔断:暂停访问,直接返回
    • 限流:请求入口设置每秒请求数量,超出直接拒绝
  2. 配置高可用集群

缓存击穿

缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。

缓存击穿的情况,经常在热点数据过期失效时发生。

解决方案:热点数据不设置过期时间。

缓存穿透

缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。

原因:

  1. 业务层误操作,数据被删除
  2. 恶意攻击,访问数据库中没有的数据

解决方案:

  1. 缓存空值或缺省值
  2. 布隆过滤器快速判断
  3. 前端过滤恶意请求

布隆过滤器

布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。

数据写入时标记:

  1. 使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
  2. 们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
  3. 把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。

查询时执行标记过程,并对比bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,
这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。

27 缓存污染

在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。

缓存污染会导致大量不再访问的数据滞留在缓存中,当缓存空间占满,再写入新数据时,把这些数据淘汰需要额外的操作时间开销,影响应用性能。

解决方案:

  1. 知道数据被再次访问的情况,根据访问时间设置过期时间:volatile-ttl
  2. LFU缓存策略

扫描式单次查询:
对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。

因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。在使用 LRU 策略淘汰数据时,这些数据会留存在缓存中很长一段时间,造成缓存污染。

LFU缓存策略

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。

  • 当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。
  • 如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

扫描式单次查询的数据因为不会被再次访问,所以它们的访问次数不会再增加。因此,LFU 策略会优先把这些访问次数低的数据淘汰出缓存。这样一来,LFU 策略就可以避免这些数据对缓存造成污染了。

LRU实现原理:

  • Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
  • Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10 个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。

LFU实现原理:把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分。

  1. ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
  2. counter 值:lru 字段的后 8bit,表示数据的访问次数。

总结一下:当 LFU 策略筛选数据时,Redis 会在候选集合中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。

LFU使用了非线性递增的计数器方法,通过设置 lfu_log_factor 配置项,来控制计数器值增加的速度;lfu_log_factor=100时,实际访问次数小于 10M 的不同数据都可以通过 counter 值区分出来。

LFU 策略时还设计了一个 counter 值的衰减机制,使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。假设 lfu_decay_time 取值为 1,如果数据在 N 分钟内没有被访问,那么它的访问次数就要减 N。

如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1,这样一来,LFU 策略在它们不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。

小结

LRU 策略更加关注数据的时效性,而 LFU 策略更加关注数据的访问频次。

通常情况下,实际应用的负载具有较好的时间局部性,所以 LRU 策略的应用会更加广泛。

但是,在扫描式查询的应用场景中,LFU 策略就可以很好地应对缓存污染问题了,建议你优先使用。

28 大容量实例

Redis 切片集群,把数据分散保存到多个实例上,如果要保存的数据总量很大,但是每个实例保存的数据量较小的话,就会导致集群的实例规模增加,这会让集群的运维管理变得复杂,增加开销。

增加 Redis 单实例的内存容量,形成大内存实例,每个实例可以保存更多的数据,这样一来,在保存相同的数据总量时,所需要的大内存实例的个数就会减少,就可以节省开销。

潜在问题:

  1. 内存快照RDB生成和恢复效率低
  2. 主从同步时长增加,缓冲区易溢出,导致全量复制

解决方案:
基于 SSD 来实现大容量的 Redis 实例,如 Pika键值数据库。

29 并发访问

为了保证并发访问的正确性,Redis 提供了两种方法,分别是加锁和原子操作。

并发访问控制

指对多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在 Redis 实例上执行时具有互斥性。

并发访问控制对应的操作主要是数据修改操作。当客户端需要修改数据时,基本流程分成两步:

  1. 客户端先把数据读取到本地,在本地进行修改
  2. 修改完数据后写回Redis

这个流程叫做“读取 - 修改 - 写回”操作(Read-Modify-Write,简称为 RMW 操作)。

当有多个客户端对同一份数据执行 RMW 操作的话,我们就需要让 RMW 操作涉及的代码以原子性方式执行。访问同一份数据的 RMW 操作代码,就叫做临界区代码。

当有多个客户端并发执行临界区代码时,就会存在一些潜在问题。多个客户端操作不具有互斥行,分别基于相同的初始值进行修改,而不是基于前一个客户端修改后的值再修改。

原子性操作

为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:

  1. 把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
  2. 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。

Redis 提供了 INCR/DECR 原子操作。

Lua脚本:
Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。

缺点:
操作都放在 Lua 脚本中原子执行,会导致 Redis 执行脚本的时间增加,同样也会降低 Redis 的并发性能。
建议:
在编写 Lua 脚本时,你要避免把不需要做并发控制的操作写入脚本中。

30 分布式锁

在分布式系统中,当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。

分布式锁的要求:

  1. 加锁和释放锁涉及多个操作,实现分布式锁要保证操作的原子性
  2. 共享存储系统保存锁变量,实现分布式锁要保证共享存储系统的可靠性

单机锁

Redis 可以使用一个键值对 lock_key:0 来保存锁变量,其中,键是 lock_key,也是锁变量的名称,锁变量的初始值是 0。

加锁时客户端先读取 lock_key 的值,发现 lock_key 为 0,所以,Redis 就把 lock_key 的 value 置为 1,表示已经加锁了。释放锁就是直接把锁变量值设置为 0。

// 加锁
SET key value [EX seconds | PX milliseconds]  [NX]
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_key

NX 选项:SET 命令只有在键值对不存在时,才会进行设置,否则不做赋值操作。
EX 或 PX 选项:设置键值对的过期时间。

风险1:加锁后发生异常,没有释放锁导致阻塞。
解决办法:给锁变量设置过期时间。

风险2:客户端A加的锁被客户端B删掉DEL
解决办法:每个客户端的锁设一个唯一值uuid

加锁示例:

// 加锁, unique_value作为客户端唯一性的标识
SET lock_key unique_value NX PX 10000

解锁脚本unlock.script:

//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

解锁命令:

redis-cli  --eval  unlock.script lock_key , unique_value 

分布式锁

为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。

基本思路是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。

加锁流程:

  1. 客户端获取当前时间
  2. 客户端依序向N个Redis实例执行加锁操作
  3. 客户端完成所有实例加锁后,计算加锁总耗时,加锁成功条件:

    1. 客户端从超过半数实例(N/2+1)获取到锁
    2. 客户端获取锁的总耗时没有超过锁的有效时间
  4. 重新计算所的有效时间:最初有效时间 - 获取锁的总耗时

释放锁流程:
执行释放锁的Lua脚本,注意释放锁时,要对所有节点释放。


IT小马
1.2k 声望166 粉丝

Php - Go - Vue - 云原生