26 缓存异常
缓存雪崩、缓存击穿和缓存穿透,这三个问题一旦发生,会导致大量的请求积压到数据库层,导致数据库宕机或故障。
缓存雪崩
缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。
如何发现:
监测 Redis 缓存所在机器和数据库所在机器的负载指标,例如每秒请求数、CPU 利用率、内存利用率等。如果我们发现 Redis 缓存实例宕机了,而数据库所在机器的负载压力突然增加(例如每秒请求数激增),此时,就发生缓存雪崩了。
原因一:大量数据同时过期。
解决方案:
- 过期时间加随机数
- 服务降级:暂停非核心业务访问,直接返回预定义信息;核心数据允许继续查询
原因二:Redis实例宕机
解决方案:
服务熔断或限流:
- 服务熔断:暂停访问,直接返回
- 限流:请求入口设置每秒请求数量,超出直接拒绝
- 配置高可用集群
缓存击穿
缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。
缓存击穿的情况,经常在热点数据过期失效时发生。
解决方案:热点数据不设置过期时间。
缓存穿透
缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。
原因:
- 业务层误操作,数据被删除
- 恶意攻击,访问数据库中没有的数据
解决方案:
- 缓存空值或缺省值
- 布隆过滤器快速判断
- 前端过滤恶意请求
布隆过滤器
布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以用来快速判断某个数据是否存在。
数据写入时标记:
- 使用 N 个哈希函数,分别计算这个数据的哈希值,得到 N 个哈希值。
- 们把这 N 个哈希值对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。
- 把对应位置的 bit 位设置为 1,这就完成了在布隆过滤器中标记数据的操作。
查询时执行标记过程,并对比bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,
这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。
27 缓存污染
在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。
缓存污染会导致大量不再访问的数据滞留在缓存中,当缓存空间占满,再写入新数据时,把这些数据淘汰需要额外的操作时间开销,影响应用性能。
解决方案:
- 知道数据被再次访问的情况,根据访问时间设置过期时间:volatile-ttl
- LFU缓存策略
扫描式单次查询:
对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。
因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。在使用 LRU 策略淘汰数据时,这些数据会留存在缓存中很长一段时间,造成缓存污染。
LFU缓存策略
LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。
- 当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。
- 如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。
扫描式单次查询的数据因为不会被再次访问,所以它们的访问次数不会再增加。因此,LFU 策略会优先把这些访问次数低的数据淘汰出缓存。这样一来,LFU 策略就可以避免这些数据对缓存造成污染了。
LRU实现原理:
- Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
- Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10 个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。
LFU实现原理:把原来 24bit 大小的 lru 字段,又进一步拆分成了两部分。
- ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
- 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 单实例的内存容量,形成大内存实例,每个实例可以保存更多的数据,这样一来,在保存相同的数据总量时,所需要的大内存实例的个数就会减少,就可以节省开销。
潜在问题:
- 内存快照RDB生成和恢复效率低
- 主从同步时长增加,缓冲区易溢出,导致全量复制
解决方案:
基于 SSD 来实现大容量的 Redis 实例,如 Pika键值数据库。
29 并发访问
为了保证并发访问的正确性,Redis 提供了两种方法,分别是加锁和原子操作。
并发访问控制
指对多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在 Redis 实例上执行时具有互斥性。
并发访问控制对应的操作主要是数据修改操作。当客户端需要修改数据时,基本流程分成两步:
- 客户端先把数据读取到本地,在本地进行修改
- 修改完数据后写回Redis
这个流程叫做“读取 - 修改 - 写回”操作(Read-Modify-Write,简称为 RMW 操作)。
当有多个客户端对同一份数据执行 RMW 操作的话,我们就需要让 RMW 操作涉及的代码以原子性方式执行。访问同一份数据的 RMW 操作代码,就叫做临界区代码。
当有多个客户端并发执行临界区代码时,就会存在一些潜在问题。多个客户端操作不具有互斥行,分别基于相同的初始值进行修改,而不是基于前一个客户端修改后的值再修改。
原子性操作
为了实现并发控制要求的临界区代码互斥执行,Redis 的原子操作采用了两种方法:
- 把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
- 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。
Redis 提供了 INCR/DECR 原子操作。
Lua脚本:
Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。
缺点:
操作都放在 Lua 脚本中原子执行,会导致 Redis 执行脚本的时间增加,同样也会降低 Redis 的并发性能。
建议:
在编写 Lua 脚本时,你要避免把不需要做并发控制的操作写入脚本中。
30 分布式锁
在分布式系统中,当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。
分布式锁的要求:
- 加锁和释放锁涉及多个操作,实现分布式锁要保证操作的原子性
- 共享存储系统保存锁变量,实现分布式锁要保证共享存储系统的可靠性
单机锁
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 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。
加锁流程:
- 客户端获取当前时间
- 客户端依序向N个Redis实例执行加锁操作
客户端完成所有实例加锁后,计算加锁总耗时,加锁成功条件:
- 客户端从超过半数实例(N/2+1)获取到锁
- 客户端获取锁的总耗时没有超过锁的有效时间
- 重新计算所的有效时间:最初有效时间 - 获取锁的总耗时
释放锁流程:
执行释放锁的Lua脚本,注意释放锁时,要对所有节点释放。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。