Redis知识点(2)
目录
缓存设计
什么是缓存击穿
什么是缓存穿透
缓存雪崩
布隆过滤器
布隆过滤器存在误判吗
布隆过滤器支持删除吗?
为什么不能用哈希表而是用布隆过滤器
如何保证缓存和数据库的一致性
为什么是删除缓存不是更新缓存
为什么先更新数据库在删缓存
如果对缓存数据库一致性要求很高怎么办
如果保证本地缓存和分布式缓存的一致?
本地缓存和Redis的区别
什么是热key
怎么监控热key呢
怎么处理热key
怎么处理大key
缓存预热怎么做
Redis运维
Redis报内存不足怎么处理
Redis key过期策略有哪些
Redis的内存淘汰策略
LRU和LFU的区别
Redis发送阻塞了怎么解决
Redis应用
Redis如何实现异步消息队列
Redis如何实现延时消息队列
Redis支持事务吗
Redis事务的原理
Redis事务注意点
Redis事务满足原子性吗
Redis事务的ACID特性如何体现
Redis能实现分布式锁吗
SETNX有什么问题?
Redission
Redlcok
底层结构
SDS
字典
压缩列表
listpack
跳表skiplist
整数集合intset
LinkedList
缓存设计
什么是缓存击穿
缓存击穿时指某个热点数据缓存过期时,大量请求就会穿透缓存直接访问数据库,导致数据库瞬间承受的压力巨大。
缓存击穿有两种常用的策略:
第一种是加互斥锁。当缓存失效时,第一个访问的线程先获取锁并负责重建缓存,其他线程等待或重试。
第二种是永不过期策略。缓存项本身不设置过期时间,也就是永不过期,但在缓存值中维护一个逻辑过期时间。当缓存逻辑上过期时,返回旧值的同时,异步启动一个线程去更新缓存。
什么是缓存穿透
缓存穿透是指查询的数据在缓存中没有命中,因为数据压根不存在,所以请求会直接落在数据库中。如果这种查询非常频繁,就会给数据库造成了很大的压力。
缓存击穿是因为单个热点数据缓存失效导致的,而缓存穿透是因为查询的数据不存在,原因可能是自身的业务代码有问题,或者是恶意攻击造成的,比如爬虫。
常用的解决方案有两种:第一种是布隆过滤器,它是一种空间效率很高的数据结构,可以用来判断一个元素是否在集合中。
第二种是缓存空值。对于不存在的数据,我们将空值写入缓存,并设置一个合理的过期时间。这样下次相同的查询就能直接从缓存返回,而不再访问数据库。
缓存雪崩
缓存雪崩是指在某一时间段,大量缓存同时失效或者缓存服务突然宕机了,导致大量请求直接涌向数据库,导致数据库压力剧增,甚至引发系统崩溃的现象。
缓存击穿是单个热点数据失效导致的,缓存穿透是因为请求不存在的数据,而缓存雪崩是因为大范围的缓存失效。
第一种,大量缓存同时过期,解决方法是添加随机过期时间。
第二种,缓存服务崩溃,解决方法是使用高可用的缓存集群。
第三种,缓存服务正常但并发请求量超过了缓存服务的承载能力,这种情况下可以采用限流和降级措施。
布隆过滤器
布隆过滤器是一种空间效率极高的概率性数据结构,用于快速判断一个元素是否在一个集合中。它的特点是能够以极小的内存消耗,判断一个元素“一定不在集合中”或“可能在集合中”,常用来解决 Redis 缓存穿透的问题。
布隆过滤器的核心由一个很长的二进制向量和一系列哈希函数组成。
- 初始化的时候,创建一个长度为 m 的位数组,初始值全为 0,同时选择 k 个不同的哈希函数
- 当添加一个元素时,用 k 个哈希函数计算出 k 个哈希值,然后对 m 取模,得到 k 个位置,将这些位置的二进制位都设为 1
- 当需要判断一个元素是否在集合中时,同样用 k 个哈希函数计算出 k 个位置,如果这些位置的二进制位有任何一个为 0,该元素一定不在集合中;如果全部为 1,则该元素可能在集合中
布隆过滤器存在误判吗
是的,布隆过滤器存在误判。它可能会错误地认为某个元素在集合中,而元素实际上并不在集合中。误判产生的原因是因为哈希冲突。在布隆过滤器中,多个不同的元素可能映射到相同的位置。随着向布隆过滤器中添加的元素越来越多,位数组中的 1 也越来越多,发生哈希冲突的概率随之增加,误判率也就随之上升。、
布隆过滤器支持删除吗?
布隆过滤器并不支持删除操作,这是它的一个重要限制。
当我们添加一个元素时,会将位数组中的 k 个位置设置为 1。由于多个不同元素可能共享相同的位,如果我们尝试删除一个元素,将其对应的 k 个位重置为 0,可能会错误地影响到其他元素的判断结果。
为什么不能用哈希表而是用布隆过滤器
布隆过滤器最突出的优势是内存效率。
假如我们要判断 10 亿个用户 ID 是否曾经访问过特定页面,使用哈希表至少需要 10G 内存(每个 ID 至少需要8字节),而使用布隆过滤器只需要 1.2G 内存。
如何保证缓存和数据库的一致性
先更新数据库,再删缓存。
为什么是删除缓存不是更新缓存
最主要的原因是在并发环境下,假设我们有两个并发的更新操作,如果采用更新缓存的策略,就可能出现这样的时序问题:
- 操作 A 和操作 B 同时发生,A 先更新 MySQL 将值改为 10,B 后更新 MySQL 将值改为 11。但在缓存更新时,可能 B 先执行将缓存设为 11,然后 A 才执行将缓存设为10。这样就会造成 MySQL 是 11 但 Redis 是 10 的不一致状态。
而采用删除策略,无论 A 和 B 谁先删除缓存,后续的读取操作都会从 MySQL 获取最新值。
为什么先更新数据库在删缓存
- 线程 A 要更新用户信息,先删除了缓存
- 线程 B 恰好此时要读取该用户信息,发现缓存为空,于是查询数据库,此时还是旧值
- 线程 B 将查到的旧值重新放入缓存
- 线程 A 完成数据库更新
结果就是数据库是新的值,但缓存中还是旧值。
而采用先更新数据库再删缓存的策略,即使出现类似的并发情况,最坏的情况也只是短暂地从缓存中读取到了旧值,但缓存删除后的请求会直接从数据库中获取最新值。
另外,如果先删缓存再更新数据库,当数据库更新失败时,缓存已经被删除了。这会导致短期内所有读请求都会穿透到数据库,对数据库造成额外的压力。而先更新数据库再删缓存,如果数据库更新失败,缓存保持原状,系统仍然能继续正常提供服务。
如果对缓存数据库一致性要求很高怎么办
第一种,引入消息队列来保证缓存最终被删除,比如说在数据库更新的事务中插入一条本地消息记录,事务提交后异步发送给 MQ 进行缓存删除。即使缓存删除失败,消息队列的重试机制也能保证最终一致性。
第二种,使用 Canal 监听 MySQL 的 binlog,在数据更新时,将数据变更记录到消息队列中,消费者消息监听到变更后去删除缓存。
如果说业务比较简单,不需要上消息队列,可以通过延迟双删策略降低缓存和数据库不一致的时间窗口,在第一次删除缓存之后,过一段时间之后,再次尝试删除缓存。
最后,无论采用哪种策略,最好为缓存设置一个合理的过期时间作为最后的保障。即使所有的主动删除机制都失败了,TTL 也能确保数据最终达到一致。
如果保证本地缓存和分布式缓存的一致?
为了保证 Caffeine (本地缓存框架)和 Redis 缓存的一致性,我采用的策略是当数据更新时,通过 Redis 的 pub/sub 机制向所有应用实例发送缓存更新通知,收到通知后的实例立即更新或者删除本地缓存。
考虑到消息可能丢失,我还会引入版本号机制作为补充。每次从 Redis 获取数据时添加一个最新的版本号。从本地缓存获取数据前,先检查自己的版本号是否是最新的,如果发现版本落后,就主动从 Redis 中获取最新数据。
本地缓存和Redis的区别
Redis 可以部署在多个节点上,支持数据分片、主从复制和集群。而本地缓存只能在单个服务器上使用。
对于读取频率极高、数据相对稳定、允许短暂不一致的数据,我优先选择本地缓存。比如系统配置信息、用户权限数据、商品分类信息等。
而对于需要实时同步、数据变化频繁、多个服务需要共享的数据,我会选择 Redis。比如用户会话信息、购物车数据、实时统计信息等。
什么是热key
所谓的热 Key,就是指在很短时间内被频繁访问的键。
由于 Redis 是单线程模型,大量请求集中到同一个键会导致该 Redis 节点的 CPU 使用率飙升,响应时间变长。
在 Redis 集群环境下,热Key 还会导致数据分布不均衡,某个节点承受的压力过大而其他节点相对空闲。
更严重的情况是,当热Key 过期或被误删时,会引发缓存击穿问题
怎么监控热key呢
临时的方案可以使用 redis-cli --hotkeys
命令来监控 Redis 中的热 Key。
或者在访问缓存时,在本地维护一个计数器,当某个键的访问次数在一分钟内超过设定阈值,就将其标记为热Key。
怎么处理热key
最有效的解决方法是增加本地缓存,将热 Key 缓存到本地内存中,这样请求就不需要访问 Redis 了。对于一些特别热的 Key,可以将其拆分成多个子 Key,然后随机分布到不同的 Redis 节点上。比如将 hot_product:12345
拆分成 hot_product:12345:1
、hot_product:12345:2
等多个副本,读取时随机选择其中一个。
怎么处理大key
大Key 是指占用内存空间较大的缓存键。
在内存有限的情况下,可能导致 Redis 内存不足。另外,大Key 还会导致主从复制同步延迟,甚至引发网络拥塞。
对于大 Key 问题,最根本的解决方案是拆分大 Key,将其拆分成多个小 Key 存储。比如将一个包含大量用户信息的 Hash 拆分成多个小 Hash。
缓存预热怎么做
缓存预热是指在系统启动或者特定时间点,提前将热点数据加载到缓存中,避免冷启动时大量请求直接打到数据库。
我会在项目启动时将热门文章提前加载到 Redis 中,在每天凌晨定时将最新的站点地图更新到 Redis中,以确保用户在第一次访问时就能获取到缓存数据,从而减轻数据库的压力。
Redis运维
Redis报内存不足怎么处理
Redis 报内存不足时,通常是因为 Redis 占用的物理内存已经接近或者超过了配置的最大内存限制。这时可以采取以下几种步骤来处理:
第一,使用 INFO memory
命令查看 Redis 的内存使用情况,看看是否真的达到了最大内存限制。
第二,如果服务器还有可用内存的话,修改 redis.conf
中的 maxmemory
参数,增加 Redis 的最大内存限制。
第三,修改 maxmemory-policy
参数来调整内存淘汰策略。比如可以选择 allkeys-lru
策略,让 Redis 自动删除最近最少使用的键。
Redis key过期策略有哪些
Redis 主要采用了两种过期删除策略来保证过期的 key 能够被及时删除,包括惰性删除和定期删除。
惰性删除是最基本的策略,当客户端访问一个 key 时,Redis 会检查该 key 是否已过期,如果过期就会立即删除并返回 nil。这种策略的优点是不会有额外的 CPU 开销,只在访问 key 时才检查。但问题是如果一个过期的 key 永远不被访问,它就会一直占用内存。
于是就有了定期删除策略,Redis 会定期随机选择一些设置了过期时间的 key 进行检查,删除其中已过期的 key。这个过程默认每秒执行 10 次,每次随机选择 20 个 key 进行检查。
Redis的内存淘汰策略
当内存使用接近 maxmemory 限制时,Redis 会依据内存淘汰策略来决定删除哪些 key 以缓解内存压力。
常用的内存淘汰策略有八种,分别是默认的 noeviction,内存不足时不会删除任何 key,直接返回错误信息,生产环境下基本上不会使用。
然后是针对所有 key 的 allkeys-lru、allkeys-lfu 和 allkeys-random。lru 会删除最近最少使用的 key,在纯缓存场景中最常用,能自动保留热点数据;lfu 会删除访问频率最低的 key,更适合长期运行的系统;random 会随机删除一些 key,一般不推荐使用。
其次是针对设置了过期时间的 key,有 volatile-lru、volatile-lfu、volatile-ttl 和 volatile-random。
lru 在混合存储场景中经常使用。lfu 适合需要保护某些重要数据不被淘汰的场景;ttl 优先删除即将过期的 key,在用户会话管理系统中推荐使用;random 仍然很少用。
LRU和LFU的区别
LRU 是 Least Recently Used 的缩写,基于时间维度,淘汰最近最少访问的键。
LFU 是 Least Frequently Used 的缩写,基于次数维度,淘汰访问频率最低的键。
Redis发送阻塞了怎么解决
Redis 发生阻塞在生产环境中是比较严重的问题,当发现 Redis 变慢时,我会先通过 monitor 命令查看当前正在执行的命令,或者使用 slowlog 命令查看慢查询日志。
通常情况下,大Key 是导致 Redis 阻塞的主要原因之一。比如说直接 DEL 一个包含几百万个元素的 Set,就会导致 Redis 阻塞几秒钟甚至更久。
这时候可以用 UNLINK 命令替代 DEL 来异步删除,避免阻塞主线程。对于非常大的集合,可以使用 SCAN 命令分批删除。
另外,当 Redis 使用的内存超过物理内存时,操作系统会将部分内存交换到磁盘,这时候会导致 Redis 响应变慢。我的处理方式是:
使用 free -h
检查内存的使用情况 ;确认 Redis 的 maxmemory 设置是否合理;如果发生了内存交换,立即调整 maxmemory 并清理一些不重要的数据。
大量的客户端连接也可能会导致阻塞,这时候最好检查一下连接池的配置。
Redis应用
Redis如何实现异步消息队列
Redis 实现异步消息队列是一个很实用的技术方案,最简单的方式是使用 List 配合 LPUSH 和 RPOP 命令。
另外就是用 Redis 的 Pub/Sub 来实现简单的消息广播和订阅。发布者将消息发布到指定的频道,订阅该频道的客户端就能收到消息。
但是这两种方式都是不可靠的,因为没有 ACK 机制所以不能保证订阅者一定能收到消息,也不支持消息持久化。
Redis如何实现延时消息队列
核心思路是利用 ZSet 的有序特性,将消息作为 member,把消息的执行时间作为 score。这样消息就会按照执行时间自动排序,我们只需要定期扫描当前时间之前的消息进行处理就可以了。
具体实现上,我会在生产者发送延时消息时,计算消息应该执行的时间戳,然后用 ZADD 命令将消息添加到 ZSet 中。 消费者通过定时任务,使用 ZRANGEBYSCORE 命令获取当前时间之前的所有消息。处理完成后再用 ZREM 删除消息。
Redis支持事务吗
是的,Redis 支持简单的事务,可以将 multi、exec、discard 和 watch 命令打包,然后一次性的按顺序执行。
基本流程是用 multi 开启事务,然后执行一系列命令,最后用 exec 提交。这些命令会被放入队列,在 exec 时批量执行。
当客户端处于非事务状态时,所有发送给 Redis 服务的命令都会立即执行;但当客户端进入事务状态之后,这些命令会被放入一个事务队列中,然后立即返回 QUEUED,表示命令已入队。
当 exec 命令执行时,Redis 会将事务队列中的所有命令按先进先出的顺序执行。当事务队列里的命令全部执行完毕后,Redis 会返回一个数组,包含每个命令的执行结果。
discard 命令用于取消一个事务,它会清空事务队列并退出事务状态。
watch 命令用于监视一个或者多个 key,如果这个 key 在事务执行之前 被其他命令改动,那么事务将会被打断。
但 Redis 的事务与 MySQL 的有很大不同,它并不支持回滚,也不支持隔离级别。
Redis事务的原理
Redis 事务的原理并不复杂,核心就是一个"先排队,后执行"的机制。
当执行 MULTI 命令时,Redis 会给这个客户端打一个事务的标记,表示这个客户端后面发送的命令不会被立即执行,而是被放到一个队列里排队等着。
当 Redis 收到 EXEC 命令时,它会把队列里的命令一个个拿出来执行。因为 Redis 是单线程的,所以这个过程不会被其他命令打断,这就保证了Redis 事务的原子性。
当执行 WATCH 命令时,Redis 会将 key 添加到全局监视字典中;只要这些 key 在 EXEC 前被其他客户端修改,Redis 就会给相关客户端打上脏标记,EXEC 时发现事务已被干扰就会直接取消整个事务。
DISCARD 做的事情很简单直接,首先检查客户端是否真的在事务状态,如果不在就报错;如果在事务状态,就清空事务队列并退出事务状态。
Redis事务注意点
Redis事务不支持回滚
Redis事务满足原子性吗
Redis 的事务不能满足标准的原子性,因为它不支持事务回滚,也就是说,假如某个命令执行失败,整个事务并不会自动回滚到初始状态。
可以使用 Lua 脚本来替代事务,脚本运行期间,Redis 不会处理其他命令,并且我们可以在脚本中处理整个业务逻辑,包括条件检查和错误处理,保证要么执行成功,要么保持最初的状态,不会出现一个命令执行失败、其他命令执行成功的情况。
Redis事务的ACID特性如何体现
单个 Redis 命令的执行是原子性的,但 Redis 没有在事务上增加任何维持原子性的机制,所以 Redis 事务在执行过程中如果某个命令失败了,其他命令还是会继续执行,不会回滚。
一致性指的是,如果数据在执行事务之前是一致的,那么在事务执行之后,无论事务是否执行成功,数据也应该是一致的。但 Redis 事务并不保证一致性,因为如果事务中的某个命令失败了,其他命令仍然会执行,就会出现数据不一致的情况。
Redis 是单线程执行事务的,并且不会中断,直到执行完所有事务队列中的命令为止。因此,我认为 Redis 的事务具有隔离性的特征。
Redis 事务的持久性完全依赖于 Redis 本身的持久化机制,如果开启了 AOF,那么事务中的命令会作为一个整体记录到 AOF 文件中,当然也要看 AOF 的 fsync 策略。
如果只开启了 RDB,事务中的命令可能会在下次快照前丢失。如果两个都没有开启,肯定是不满足持久性的。
Redis能实现分布式锁吗
分布式锁是一种用于控制多个不同进程在分布式系统中访问共享资源的锁机制。它能确保在同一时刻,只有一个节点可以对资源进行访问,从而避免分布式场景下的并发问题。
可以使用 Redis 的 SETNX 命令实现简单的分布式锁。比如 SET key value NX PX 3000
就创建了一个锁名为 key
的分布式锁,锁的持有者为 value
。NX 保证只有在 key 不存在时才能创建成功,EX 设置过期时间用以防止死锁。
SETNX有什么问题?
使用 SETNX 创建分布式锁时,虽然可以通过设置过期时间来避免死锁,但会误删锁。比如线程 A 获取锁后,业务执行时间比较长,锁过期了。这时线程 B 获取到锁,但线程 A 执行完业务逻辑后,会尝试删除锁,这时候删掉的其实是线程 B 的锁。
可以通过锁的自动续期机制来解决锁过期的问题,比如 Redisson 的看门狗机制,在后台启动一个定时任务,每隔一段时间就检查锁是否还被当前线程持有,如果是就自动延长过期时间。这样既避免了死锁,又防止了锁被提前释放。
Redission
Redisson 是一个基于 Redis 的 Java 客户端,它不只是对 Redis 的操作进行简单地封装,还提供了很多分布式的数据结构和服务,比如最常用的分布式锁。
Redisson 的分布式锁比 SETNX 完善的得多,它的看门狗机制可以让我们在获取锁的时候省去手动设置过期时间的步骤,它在内部封装了一个定时任务,每隔 10 秒会检查一次,如果当前线程还持有锁就自动续期 30 秒。
另外,Redisson 还提供了分布式限流器 RRateLimiter,基于令牌桶算法实现,用于控制分布式环境下的访问频率。
Redlcok
Redlock 是 Redis 作者 antirez 提出的一种分布式锁算法,用于解决单个 Redis 实例作为分布式锁时存在的单点故障问题。
Redlock 的核心思想是通过在多个完全独立的 Redis 实例上同时获取锁来实现容错。
底层结构
SDS
这是 Redis 自己实现的动态字符串,它保留了 C 语言原生的字符串长度,所以获取长度的时间复杂度是 O(1)
,在此基础上还支持动态扩容,以及存储二进制数据。
字典
更底层是用数组+链表实现的哈希表。它的设计很巧妙,用了两个哈希表,平时用第一个,rehash 的时候用第二个,这样可以渐进式地进行扩容,不会阻塞太久。
压缩列表
压缩列表 ziplist,这个设计很有意思。Redis 为了节省内存,设计了这种紧凑型的数据结构,把所有元素连续存储在一块内存里。但是它有个致命问题叫"连锁更新",就是当我们修改一个元素的时候,可能会导致后面所有的元素都要重新编码,性能会急剧下降。
listpack
这个可以说是 ziplist 的完美替代品。它最大的特点是每个元素只记录自己的长度,不记录前一个元素的长度,这样就彻底解决了连锁更新的问题。
跳表skiplist
跳表skiplist 主要用在 ZSet 中。它的设计很巧妙,通过多层指针来实现快速查找,平均时间复杂度是 O(log N)
。相比红黑树,跳表的实现更简单,而且支持范围查询,这对 Redis 的有序集合来说很重要。
整数集合intset
当 Set 中都是整数且元素数量较少时使用,内部是一个有序数组,查找用的二分法。
LinkedList
双向链表LinkedList,早期版本的 Redis 会在 List 中用到,但 Redis 3.2 后就被 quicklist 替代了,因为纯链表的问题是内存不连续,影响 CPU 缓存性能。