【redis设计原理】

什么是Redis

Redis 是一种基于内存的数据库,它对数据的读写操作都是在内存中完成,所以它的读写速度非常快,常用于缓存,消息队列这些场景。
Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)等等,并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。
除此之外,Redis 还支持事务 、持久化、过期删除机制等等。

Redis 和 Memcached 区别

Redis 和 Memcached 都是基于内存的数据库,性能都比较高,一般都用来当做缓存使用。

Redis 与 Memcached 区别:
Redis 支持的数据类型更丰富,比如String、Hash、List、Set、ZSet;而 Memcached 只支持最简单的 key-value 数据类型;
Redis 支持数据的持久化;而 Memcached 没有持久化功能,数据全部存在内存之中;
Redis 支持事务、发布订阅模型等功能,而 Memcached 不支持;

为什么用 Redis 作为 MySQL 的缓存

主要是因为 Redis 具备高性能高并发两种特性。
1、Redis 具备高性能
假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。

2、 Redis 具备高并发
单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理请求的次数) 大约是 MySQL 的 10 倍。
所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

Redis 数据类型和各自的使用场景

Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
在这里插入图片描述

String类型

String适用场景:缓存对象、常规计数、分布式锁等。
String 类型的底层数据结构实现主要是 SDS简单动态字符串。虽然redis是用C语言开发的,但是其中的SDS 和原生的C字符串不太一样

1、SDS 不仅可以保存文本数据,而且能保存图片、视频、压缩文件这样的二进制数据。
2、SDS 获取字符串长度的效率更高,因为它内部用 len 属性记录了字符串长度,它的时间复杂度大约是 O(1)。而C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);
3、Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。

List类型

List场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)。
List 类型的底层数据结构是由双向链表或压缩列表实现的:

如果列表的元素个数小于默认的512 个,而且每个元素的值都小于默认的64 字节,那么Redis 会使用压缩列表作为 List 类型的底层数据结构;
如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了。

Hash类型

Hash场景:缓存对象、购物车等。
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:

如果哈希类型元素个数小于默认的512 个,而且所有的值小于默认的 64 字节,那么Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Set类型

Set 场景:一些聚合计算场景,比如点赞、共同关注、抽奖活动等。
Set 类型的底层数据结构是由哈希表或整数集合实现的:

如果集合中的元素都是整数且元素个数小于默认的 512 个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

Zset类型

Zset场景:排序场景,比如排行榜、电话和姓名排序等。
Zset 类型的底层数据结构是由压缩列表或跳表实现的:

如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:
BitMap:二值状态统计的场景,比如签到、判断用户登陆状态等;
HyperLogLog:海量数据基数统计的场景,比如百万级网页访问量计数等;
GEO:存储地理位置信息的场景,比如滴滴叫车;
Stream:消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据。

Redis 是单线程吗?

Redis 程序并不是单线程的,Redis 在启动的时候,还会启动一些后台线程。

之所以说Redis 单线程指的是「接收客户端请求->解析处理操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

Redis 程序在启动时还会会启动 3 个后台线程,分别处理关闭文件、AOF 刷盘、异步释放 Redis 内存(lazyfree 线程)这三个任务;

之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

对于lazyfree 线程,当执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大key。

Redis 6.0 之前为什么采用单线程

官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒。
Redis 采用单线程(网络 I/O 和执行命令)那么快的原因:
1、Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
2、Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
3、Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

Redis 6.0 之后为什么引入了多线程

虽然 Redis 的主要工作比如网络 I/O 和执行命令一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理。

Redis 持久化

Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。
Redis 共有三种数据持久化的方式
1、AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个AOF 日志文件里;然后 Redis 重启时,会读取该日志文件记录的命令,然后逐一执行命令来进行数据恢复。
AOF 写回策略有3种:Always、Everysec每秒、No。AOF 日志过大,会触发 AOF 重写机制。
AOF 重写机制:读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到新的 AOF 文件,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。

2、RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘;

因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要把日志都执行一遍,一旦 AOF 日志非常多,就会造成 Redis 的恢复操作缓慢。
而RDB 快照只是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。所以RDB 恢复数据的效率会比 AOF 高些。

Redis 提供了两个命令来生成 RDB 文件, save 命令会在主线程生成 RDB 文件,但由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;bgsave命令会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞;

RDB 在执行快照的时候,数据能修改吗?
可以,在执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的,关键的技术就在于写时复制技术(Copy-On-Write, COW)。

执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

3、混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点;
AOF 优点是丢失数据少,但是数据恢复比较慢。
RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。

为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

这样的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。

Redis 如何实现服务高可用?

1、主从复制
主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是读写分离的方式。
主服务器可以进行读写操作,当有写操作时自动将写操作同步给从服务器,从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器。

注意,主从服务器之间的命令复制是异步进行的。
具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。
所以,无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。

2、哨兵模式
在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。为了解决这个问题,Redis 增加了哨兵模式(Sentinel),因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。

3、切片集群模式
当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
切片集群采用哈希槽来处理数据和节点之间的映射关系。一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。

集群脑裂导致数据丢失

由于网络问题,集群节点之间失去联系。主节点有部分数据在缓冲区里,还没有同步给从节点;
哨兵在从节点中新选出一个主节点。
网络恢复后,旧主节点会降级为从节点并请求数据同步。此时的旧主节点会清空掉自己本地的数据,然后再做全量同步。所以导致之前缓冲区的数据丢失。

解决方法
当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。
这样当主节点网络出问题时,主节点就会被限制接收客户端写入新的数据。
等到新的主节点上线后,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。

Redis 过期删除

Redis 是可以对 key 设置过期时间的,每当我们对一个 key 设置了过期时间时,Redis 会把这个 key 附上过期时间存储到一个过期字典里,每次使用这个key之前都要到这个字典里去看一看是否过期了,过期的话就要删除,过期删除策略主要有两种:惰性删除和定期删除。
1、惰性删除
每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

因为每次访问时,才会检查 key 是否过期,所以这种方法只会使用很少的系统资源。
但如果一个 key 已经过期,只要这个过期 key 一直没有被访问,那它所占用的内存就不会释放,造成了一定的内存空间浪费。

2、定期删除
每隔一段时间就随机从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。
但难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。

内存淘汰机制

当 Redis 的运行内存达到了我们设置的最大运行内存,就会触发内存淘汰机制。Redis 内存淘汰策略共有八种,这八种策略大体分为不进行数据淘汰进行数据淘汰两类策略。
不进行数据淘汰表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
进行数据淘汰策略比较有代表性的就是LRU:淘汰整个键值中最久未使用的键值;和LFU淘汰整个键值中最少使用的键值。

LRU算法和LFU 算法

LRU

LRU 全称是 Least Recently Used最近最少使用的,它会选择淘汰最近最少使用的数据。

传统 LRU 算法的实现基于链表结构,链表中的元素按照操作顺序排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。

Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:
1、需要用链表管理所有的缓存数据,这会带来额外的空间开销;
2、当有数据被访问时,需要在链表上把这个数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它默认会随机取 5 个值,然后淘汰最长时间没有使用的那个。

但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,然后内存淘汰的时候用随机采样的方式来淘汰,那就会剩下许多数据在 Redis 缓存中,造成缓存污染。因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。

LFU

LFU 全称是 Least Frequently Used最近最不常用的,它是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

所以, LFU 算法会不断更新记录每个数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中时间长的问题。

缓存雪崩、击穿、穿透

缓存雪崩

通常我们为了保证缓存中的数据与数据库中数据的一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户再来请求就会去访问数据库,然后把最新数据更新到 Redis 里,这样后续请求都可以直接命中缓存。
但是如果有大量缓存数据在同一时间过期,然后刚好这个时候有大量的用户请求过来,那这些请求肯定都不能在 Redis 里处理,都会直接去访问数据库,这就会导致数据库的压力骤增,严重的可能会造成数据库宕机,从而造成整个系统崩溃,这就是缓存雪崩。

解决方法
1、设置缓存不过期: 我们可以通过后台服务来更新缓存数据。
2、把缓存失效时间随机打散: 我们可以在原有的失效时间基础上增加一个随机值(比如 1 到 10 分钟)这样每个缓存的过期时间都不会重复了,也就降低了缓存集体失效的概率。

缓存击穿

缓存击穿跟缓存雪崩很相似,缓存击穿就像是缓存雪崩的一个子集。有的时候,我们有一些业务的热门数据会被频繁地访问,比如说一个秒杀活动,那肯定许多客户都一直关注访问。
如果有某个热点数据在缓存里过期了,然后有大量的请求来访问这个热点数据,那肯定这些请求就会直接去访问数据库,那数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

解决方法
不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;
互斥锁方案,保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

缓存穿透

当发生缓存雪崩或击穿的时候,数据库中还是有应用要访问的数据,一旦缓存恢复了这些数据,就可以减轻数据库的压力。
而缓存穿透就不一样了。当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

缓存穿透的发生场景
1、业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
2、黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

解决方法
1、API入口限制:在 API 入口处判断求请求参数是否合理,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
2、设置空值或者默认值:当我们发现有缓存穿透时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
3、使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

布隆过滤器
布隆过滤器是一种空间效率很高比较高的概率数据结构,主要用于判断一个元素是否在一个集合中。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值