Redis
Redis 可以用来干什么?
Redis 有哪些数据结构?
字符串:这是Redis最简单的数据类型,可以包含任何形式的字符串,含有二进制数据。字符串类型是二进制安全的,意味着可以存储任何形式的数据,例如:图片、序列化对象等。用途广泛,例如:常用作计数器,令牌,分布式锁等。
哈希:HashMap类型,可以储存对象,比如我们可以存储一个用户的对象,键可以是用户的ID,值就是用户的各种属性。
列表(List):List是字符串链表,按插入顺序排序,你可以添加一个元素到头部(左边)或尾部(右边)。列表适合用于消息队列,用户关系链,最新项目列表等。
集合(Set):Set是一个无序且唯一的字符串集合。集合的主要应用场景包括交集、并集、差集、以及元素的查询,适用于标签系统,好友关系链等场景。
有序集合(Sorted Set):在集合类型的基础上,为每个元素关联了一个分数。元素的排列顺序由分数决定。有序集合适合用于排行榜应用,优先队列等。
Redis 的 Zset 主要使用两种数据结构:跳表(Skip List) 和 哈希表(Hash Table)。以下是它们的具体实现细节:
- 跳表(Skip List)
- 结构:跳表是一种随机化的数据结构,它由多层链表组成。最底层的链表存储所有的元素,随着层数的增加,链表中元素的数量逐渐减少,每一层的元素都是下一层的子集。
跳表的查询、插入和删除操作的时间复杂度平均为 O(log N),这使得它在有序数据的操作中非常高效。
- 作用:在 Redis 的 Zset 中,跳表用于维护元素的顺序。每个 Zset 元素都包含一个分数(score),通过分数可以快速地确定元素在集合中的位置。
- 哈希表(Hash Table)
结构:哈希表是由一组键值对组成的,键是元素的值,值是元素的分数。Redis 使用哈希表来存储 Zset 中的元素和它们对应的分数。
作用:哈希表提供 O(1) 的时间复杂度来查找、插入和删除元素。
当 Zset 中的元素较少时,Redis 首先使用哈希表进行存储。通过哈希表可以快速查找元素是否存在以及获取元 素的分数。
- Zset 的底层实现
插入:当新元素插入到 Zset 中时,Redis 首先在哈希表中添加该元素及其分数。然后,通过跳表将元素插入到合适的位置,以保持有序性。
删除:删除元素时,Redis 首先在哈希表中找到该元素,然后从跳表中删除该元素。
查找:查找某个元素的分数时,Redis 先在哈希表中查找该元素,如果存在,则返回对应的分数;如果不存在,则返回 nil。
范围查询:当需要进行范围查询(例如获取分数在某个范围内的所有元素)时,Redis 可以利用跳表的有序特性高效地完成这项操作。
字符串(String)
set:设置指定 key 的值
get:获取指定 key 的值
incr:将 key 中储存的数字值增一
decr:将 key 中储存的数字值减一
append:在 key 的值尾部添加指定字符串
哈希(Hash)
hset:为哈希表中的字段赋值
hget:获取哈希表中指定字段的值
hmget:获取所有指定字段的值
hdel:删除一个或多个哈希表字段
hlen:获取哈希表中字段的数量
列表(List)
lpush:将一个或多个值插入到列表头部
rpush:将一个或多个值插入到列表的尾部
lpop:移出并获取列表的第一个元素
rpop:移出并获取列表最后一个元素
lrange:获取列表指定范围内的元素
集合(Set)
sadd:向集合添加一个或多个成员
srem:移除集合中一个或多个成员
smembers:返回集合中的所有成员
sismember:判断成员元素是否是集合的成员
scard:返回集合的成员数
有序集合(Sorted Set)
zadd:向有序集合添加一个或多个成员,或者更新已存在成员的分数
zrem:移除有序集合中的一个或多个成员
zrange:通过索引区间返回有序集合成指定区间内的成员
zrank:返回有序集合中指定成员的索引
zrevrange:返回有序集中指定区间内的成员,通过索引,分数从高到底
Redis 为什么快呢?
①、基于内存的数据存储,Redis 将数据存储在内存当中,使得数据的读写操作避开了磁盘 I/O。而内存的访问速度远超硬盘,这是 Redis 读写速度快的根本原因。
②、单线程模型,Redis 使用单线程模型来处理客户端的请求,这意味着在任何时刻只有一个命令在执行。这样就避免了线程切换和锁竞争带来的消耗。
③、IO 多路复⽤
NIO 是同步非阻塞的( I/O 多路复用模型),服务器可以用一个线程处理多个客户端连接,通过 Selector 监听多个 Channel 来实现多路复用,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到连接有 IO 请求就进行处理:
Redis 持久化⽅式有哪些?有什么区别?
RDB
RDB 持久化通过创建数据集的快照(snapshot)来工作,在指定的时间间隔内将 Redis 在某一时刻的数据状态保存到磁盘的一个 RDB 文件中。
AOF
AOF 持久化通过记录每个写操作命令到AOF缓冲中,并将其追加到 AOF 文件中来工作,恢复时通过重新执行这些命令来重建数据集。
AOF 的主要作用是解决了数据持久化的实时性,目前已经是 Redis 持久化的主流方式。
Redis 的数据恢复?
Redis 启动时加载数据的流程:
AOF 持久化开启且存在 AOF 文件时,优先加载 AOF 文件。
AOF 关闭或者 AOF 文件不存在时,加载 RDB 文件。
加载 AOF/RDB 文件成功后,Redis 启动成功。
AOF/RDB 文件存在错误时,Redis 启动失败并打印错误信息。
高可用
Redis 保证高可用主要有三种方式:主从、哨兵、集群
主从复制
主从复制,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 主节点(master),后者称为 从节点(slave)。且数据的复制是 单向 的,只能由主节点到从节点。Redis 主从复制支持 主从同步 和 从从同步 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。
主从复制主要的作用
数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
故障恢复: 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 (实际上是一种服务的冗余)。
负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 (即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
高可用基石: 除了上述作用以外,主从复制还是哨兵和集群能够实施的 基础,因此说主从复制是 Redis 高可用的基础
主从复制存在哪些问题
一旦主节点出现故障,需要手动将一个从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令其他从节点去复制新的主节点,整个过程都需要人工干预。
主节点的写能力受到单机的限制。
主节点的存储能力受到单机的限制。
Redis Sentinel(哨兵)
主从复制存在一个问题,没法完成自动故障转移。所以我们需要一个方案来完成自动故障转移,它就是 Redis Sentinel(哨兵)。
Redis Sentinel
Redis Sentinel ,它由两部分组成,哨兵节点和数据节点:
哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据,对数据节点进行监控。
数据节点: 主节点和从节点都是数据节点;
在复制的基础上,哨兵实现了 自动化的故障恢复 功能,下面是官方对于哨兵功能的描述:
监控(Monitoring): 哨兵会不断地检查主节点和从节点是否运作正常。 ping请求
自动故障转移(Automatic failover): 当 主节点 不能正常工作时,哨兵会开始 自动故障转移操作,它会将失效主节点的其中一个 从节点升级为新的主节点,并让其他从节点改为复制新的主节点。
配置提供者(Configuration provider): 客户端在初始化时,通过连接哨兵来获得当前 Redis 服务的主节点地址。
通知(Notification): 哨兵可以将故障转移的结果发送给客户端
缓存设计
什么是缓存击穿、缓存穿透、缓存雪崩?
缓存击穿(热点key问题)
一个并发访问量比较大的 key 在某个时间过期,导致所有的请求直接打在 DB 上。
解决⽅案:
- 加锁更新,⽐如请求查询 A,发现缓存中没有,对 A 这个 key 加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。
- 将过期时间组合写在 value 中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象。
缓存穿透
缓存穿透指的查询缓存和数据库中都不存在的数据,这样每次请求直接打到数据库,就好像缓存不存在一样。
缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。
缓存穿透可能会使后端存储负载加大,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。
缓存穿透可能有两种原因:
自身业务代码问题
恶意攻击,爬虫造成空命中
它主要有两种解决办法:
- 缓存空值/默认值
一种方式是在数据库不命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。
缓存空值/默认值
缓存空值有两大问题:
空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。 例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。 这时候可以利用消息队列或者其它异步方式清理缓存中的空对象。
- 布隆过滤器 除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。
布隆过滤器里会保存数据是否存在,如果判断数据不不能再,就不会访问存储。
两种解决方案的对比:
缓存雪崩
某⼀时刻发⽣⼤规模的缓存失效的情况,例如缓存服务宕机、大量 key 在同一时间过期,这样的后果就是⼤量的请求进来直接打到 DB 上,可能导致整个系统的崩溃,称为雪崩。
缓存雪崩是三大缓存问题里最严重的一种,我们来看看怎么预防和处理。
- 提高缓存可用性
集群部署:通过集群来提升缓存的可用性,可以利用 Redis 本身的 Redis Cluster 或者第三方集群方案如 Codis 等。
多级缓存:设置多级缓存,第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。
- 过期时间
均匀过期:为了避免大量的缓存在同一时间过期,可以把不同的 key 过期时间随机生成,避免过期时间太过集中。
热点数据永不过期。
- 熔断降级
服务熔断:当缓存服务器宕机或超时响应时,为了防止整个系统出现雪崩,暂时停止业务服务访问缓存系统。
服务降级:当出现大量缓存失效,而且处在高并发高负荷的情况下,在业务系统内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的 fallback(退路)错误处理信息。
能说说布隆过滤器吗?
布隆过滤器,它是一个连续的数据结构,每个存储位存储都是一个bit
,即0
或者1
, 来标识数据是否存在。
存储数据的时时候,使用 K 个不同的哈希函数将这个变量映射为 bit 列表的的 K 个点,把它们置为 1。
我们判断缓存 key 是否存在,同样,K 个哈希函数,映射到 bit 列表上的 K 个点,判断是不是 1:
如果全不是 1,那么 key 不存在;
如果都是 1,也只是表示 key 可能存在。
布隆过滤器也有一些缺点:
它在判断元素是否在集合中时是有一定错误几率,因为哈希算法有一定的碰撞的概率。
不支持删除元素。
三种常见的缓存策略
1. 旁路缓存模式(Cache-Aside Pattern)
在这种模式下,应用程序负责从缓存中读取数据,并在缓存未命中时,从数据源(如数据库)加载数据,并将其放入缓存供后续使用。
读取操作:先从缓存读取数据,如果缓存没有命中,则从数据库加载数据,再将数据写入缓存。
写入操作:写入操作更新数据库后,删除缓存,下一次读取时再从数据库中加载新的数据到缓存。
可能导致数据在缓存和数据库之间的不一致性。
2. 读写穿透模式(Read/Write Through Pattern)
读写穿透模式与旁路缓存模式不同,它由缓存层自身来负责数据的加载和存储。
读取操作:应用程序从缓存读取数据。如果缓存未命中,缓存层会自动从数据库加载数据,然后返回给应用程序,并在缓存中保存备份。
写入操作:应用程序写数据到缓存,缓存层将新数据写入数据库,并同时更新缓存中的数据副本。
读写穿透模式的优势在于能保证缓存与数据库的一致性,减少了应用程序直接操作数据库的次数。
3. 异步缓存模式(Asynchronous Caching)
相当于把缓存当作一个缓冲器,先把数据写入到缓存中,然后由缓存系统在合适的时候异步地把数据保存到数据库,从而提高了数据写入的性能。
读取操作:与传统模式一致,从缓存中读取数据,如果缓存未命中,则从数据库加载数据,并放入缓存再返回给用户。
写入操作:写入操作首先更新缓存,随后异步地将更新的数据写入到数据库。
这种模式可以提高写操作的性能,因为它避免了直接写缓存的延时,但可能会存在一段时间的数据不一致性,直到缓存被异步更新。
如何保证缓存和数据库数据的⼀致性?
采用的是先写 MySQL,再删除 Redis 的方式来保证缓存和数据库的数据一致性。
选择合适的缓存更新策略
- 删除缓存而不是更新缓存
当一个线程对缓存的 key 进行写操作的时候,如果其它线程进来读数据的时候,读到的就是脏数据,产生了数据不一致问题。
相比较而言,删除缓存的速度比更新缓存的速度快很多,所用时间相对也少很多,读脏数据的概率也小很多。
- 先更数据,后删缓存
因为更新数据库的速度比删除缓存的速度要慢得多。因为更新 MySQL 是磁盘 IO 操作,而 Redis 是内存操作。内存操作比磁盘 IO 快得多(这是硬件层面的天然差距)。
那假如是先删除缓存,再更新数据库,就会造成这样的情况:
缓存中不存在,数据库又没有完成更新,此时有请求进来读取数据,并写入到缓存,那么在更新完缓存后,缓存中这个 key 就成了一个脏数据。
缓存和数据库数据不一致常见原因及避免方法
缓存 key 删除失败
并发导致写入了脏数据
- 消息队列保证 key 被删除 可以引入消息队列,把要删除的 key 或者删除失败的 key 丢进消息队列,利用消息队列的重试机制,重试删除对应的 key。
- 设置缓存过期时间兜底
这是一个朴素但是有用的办法,给缓存设置一个合理的过期时间,即使发生了缓存数据不一致的问题,它也不会永远不一致下去,缓存过期的时候,自然又会恢复一致。
怎么处理热 key?
①、把热 key 打散到不同的服务器,降低压⼒。
②、加⼊⼆级缓存,当出现热 Key 后,把热 Key 加载到 JVM 中,后续针对这些热 Key 的请求,直接从 JVM 中读取。
这些本地的缓存工具有很多,比如 Caffeine、Guava 等,或者直接使用 HashMap 作为本地缓存都是可以的。
缓存预热怎么做呢?
所谓缓存预热,就是提前把数据库里的数据刷到缓存里,通常有这些方法:
1、直接写个缓存刷新页面或者接口,上线时手动操作
2、数据量不大,可以在项目启动的时候自动进行加载
3、定时任务刷新缓存.
热点 key 重建?问题?解决?
互斥锁(mutex key) 这种方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。
永远不过期 “永远不过期”包含两层意思:
从缓存层面来看,确实没有设置过期时间,所以不会出现热点 key 过期后产生的问题,也就是“物理”不过期。
从功能层面来看,为每个 value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存
Redis应用
使用 Redis 如何实现异步队列?
使用 Redis 的 pub/sub 来进行消息的发布/订阅
发布/订阅模式可以 1:N 的消息发布/订阅。发布者将消息发布到指定的频道频道(channel),订阅相应频道的客户端都能收到消息。
但是这种方式不是可靠的,它不保证订阅者一定能收到消息,也不进行消息的存储。
所以,一般的异步队列的实现还是交给专业的消息队列。
Redis 如何实现延时队列?
- 使用 zset,利用排序实现
可以使用 zset 这个结构,用设置好的时间戳作为 score 进行排序,使用 zadd score1 value1 ....命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务,通过循环执行队列任务即可。
Redis 支持事务吗?
Redis 支持简单的事务,可以将多个命令打包,然后一次性的,按照顺序执行(MULTI/EXEC)
Redis 事务的注意点有哪些?
Redis 事务是不支持回滚的,不像 MySQL 的事务一样,要么都执行要么都不执行
Redis 事务为什么不支持回滚?
Redis 是一个基于内存的数据存储系统,其设计重点是实现高性能。事务回滚需要额外的资源和时间来管理和执行,这与 Redis 的设计目标相违背。因此,Redis 选择不支持事务回滚。
换句话说,就是我 Redis 不想支持事务,也没有这个必要。
为什么需要 Lua?
既然 Redis事务能保证原子性,为什么还需要 Lua脚本呢?
Lua 脚本一般比 MULTI/EXEC 更快、更简单;
Redis 事务中,事务队列中的所有命令都必须在 EXEC命令执行才会被执行,对于多个命令之间存在依赖关系,比如后面的命令需要依赖上一个命令结果的场景,Redis事务无法满足,因此 Lua 脚本更适合复杂的场景;
Redis 和 Lua 脚本的使用了解吗?
Redis 的事务功能比较简单,平时的开发中,可以利用 Lua 脚本来增强 Redis 的命令。
Lua 脚本能给开发人员带来这些好处:
Lua 脚本在 Redis 中是原子执行的,执行过程中间不会插入其他命令。
Lua 脚本可以帮助开发和运维人员创造出自己定制的命令,并可以将这 些命令常驻在 Redis 内存中,实现复用的效果。
Lua 脚本可以将多条命令一次性打包,有效地减少网络开销
ACID的原子性和 Redis执行 Lua脚本原子性在概念上的差异:
ACID的原子性是指:命令要么全执行,要么全部不执行;
Redis中执行 Lua脚本原子性是指:Lua脚本需要作为一个整体执行且不被其他事务打断,至于 Lua脚本里面的命令是否必须全部成功,或者全部失败,并不要求
redis.call() 用于执行 Redis的命令。当命令执行出错时,会阻断整个脚本执行,并将错误信息返回给客户端。
redis.pcall() 也用于执行 Redis的命令。当命令执行出错时,不会阻断脚本的执行,而是内部捕获错误,并继续执行后续的命令。
redis保证原子性
当客户端向服务器发送一段带有 Lua 脚本的请求时,Redis会把该 Lua脚本当作一个整体,将 Lua脚本加载到一个脚本缓存中,因为 Redis读写命令是单线程操作,因此,所有的 Lua脚本会按照进入顺序放入队列中,然后串行进行读写,这样就保证了原子性
它采用 IO 多路复用机制同时监听多个 Socket,并把所有产生事件的socket压入一个队列中,然后有序地每次仅一个socket的方式传送给文件事件分派器,文件事件分派器接收到socket之后会根据socket产生的事件类型调用对应的事件处理器进行处理。
单机部署 不管 Lua脚本中操作的 key是不是同一个,都能保证原子性;
主从部署 Redis 主从复制是用于将主节点的数据同步到从节点,以保持数据的一致性。而Redis的所有写操作都在主节点上,所以,不管 Lua脚本中操作的 key是不是同一个,都能保证原子性; 需要注意:当主节点执行写命令时,从节点会异步地复制这些写操作。在这个复制的过程中,从节点的数据可能与主节点存在一定的延迟。因此,如果在 Lua 脚本中包含读操作,并且该脚本在主节点上执行,可能会读到最新的数据,但如果在从节点上执行,可能会读到稍有延迟的数据。
假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如何将它们全部找出来?
使用 keys
指令可以扫出指定模式的 key 列表。但是要注意 keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan
指令,scan
指令可以无阻塞的提取出指定模式的 key
列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys
指令长。
SCAN cursor [MATCH pattern] [COUNT count]
Redis 运维
Redis 报内存不足怎么处理?
Redis 内存不足有这么几种处理方式:
修改配置文件 redis.conf 的 maxmemory 参数,增加 Redis 可用内存
也可以通过命令 set maxmemory 动态设置内存上限
修改内存淘汰策略,及时释放内存空间
使用 Redis 集群模式,进行横向扩容。
Redis 的过期数据回收策略有哪些?
Redis 主要有 2 种过期数据回收策略:
- 惰性删除
惰性删除指的是当我们查询 key 的时候才对 key 进⾏检测,如果已经达到过期时间,则删除。
显然,他有⼀个缺点就是如果这些过期的 key 没有被访问,那么他就⼀直⽆法被删除,⽽且⼀直占⽤内存。
- 定期删除
定期删除指的是 Redis 每隔⼀段时间对数据库做⼀次检查,删除⾥⾯的过期 key。由于不可能对所有 key 去做轮询来删除,所以 Redis 会每次随机取⼀些 key 去做检查和删除。
Redis 有哪些内存溢出控制/内存淘汰策略?
当 Redis 所用内存达到 maxmemory 上限时,会触发相应的溢出控制策略。
Redis六种内存溢出控制策略
noeviction:默认策略,不进行任何淘汰,当内存不足以容纳更多数据时,对写操作返回错误。(但仍然允许删除操作)
volatile-lru:仅从设置了过期时间的键中 使用 LRU(Least Recently Used,最近最少使用) 算法淘汰。
volatile-ttl:从设置了过期时间的键中 选择将要过期的键淘汰。
volatile-random:仅从设置了过期时间的键中 随机淘汰。
allkeys-lru:从所有的键中使用 LRU算法淘汰数据。
allkeys-random:从所有的键中随机淘汰数据。
Redis 阻塞?怎么解决?
Redis 发生阻塞,可以从以下几个方面排查:
对慢查询的处理分为两步:
发现慢查询: slowlog get{n}命令可以获取最近 的 n 条慢查询命令;
发现慢查询后,可以从两个方向去优化慢查询:
1)修改为低算法复杂度的命令,如 hgetall 改为 hmget 等,禁用 keys、sort 等命令
2)调整大对象:缩减大对象数据或把大对象拆分为多个小对象,防止一次命令操作过多的数据。
大 key 问题了解吗?
大 key 指的是存储了大量数据的键,比如:
单个简单的 key 存储的 value 很大,size 超过 10KB
hash,set,zset,list 中存储过多的元素(以万为单位)
大 key 会造成什么问题呢?
客户端耗时增加,甚至超时
对大 key 进行 IO 操作时,会严重占用带宽和 CPU
清理大key时,主动删除、被动删等,可能会导致阻塞
如何处理大 key?
①、删除大 key
当 Redis 版本大于 4.0 时,可使用 UNLINK 命令安全地删除大 Key,该命令能够以非阻塞的方式,逐步地清理传入的大 Key。
当 Redis 版本小于 4.0 时,建议通过 SCAN 命令执行增量迭代扫描 key,然后判断进行删除。
②、压缩和拆分 key
当 vaule 是 string 时,比较难拆分,则使用序列化、压缩算法将 key 的大小控制在合理范围内,但是序列化和反序列化都会带来额外的性能消耗。
当 value 是 string,压缩之后仍然是大 key 时,则需要进行拆分,将一个大 key 分为不同的部分,记录每个部分的 key,使用 multiget 等操作实现事务读取。
当 value 是 list/set 等集合类型时,根据预估的数据规模来进行分片,不同的元素计算后分到不同的片。