Redis
# Redis基础数据结构
# 5种基础数据结构
5 种基础数据结构 :String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
# String
# 基本介绍
String 是 Redis 中最简单同时也是最常用的一个数据结构。String 是一种二进制安全的数据结构,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。
# 应用场景
需要存储常规数据的场景
- 举例 :缓存 session、token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存)。
- 相关命令 :
SET
、GET
。
需要计数的场景
- 举例 :用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数。
- 相关命令 :
SET
、GET
、INCR
、DECR
。
# 额外内存开销
元数据开销
除了记录实际数据,String类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫做元数据。如果本身数据很小,元数据的空间开销就会显得比较大。
String类型保持数据如下:
保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节的 Long 类型整数。
保存的数据中包含字符时,String 类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存。
- buf:字节数组,保存实际数据。为了表示字节数组的结束,Redis 会自动在数组最后加一个“\0”,这就会额外占用 1 个字节的开销。
- len:占 4 个字节,表示 buf 的已用长度。
- alloc:占个 4 字节,表示 buf 的实际分配长度,一般大于 len。
在SDS中,buf保存实际数据,而len和alloc本身其实是SDS结构体的额外开销。
另外,除了 SDS 的额外开销,还有一个来自于 RedisObject 结构体的开销。一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在。
RedisObject开销
- 当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。
- 当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。
- 当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式。
# List
# 基本介绍
Redis 中的 List 其实就是链表数据结构的实现。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
# 应用场景
信息流展示
- 举例 :最新文章、最新动态。
- 相关命令 :
LPUSH
、LRANGE
。
消息队列
Redis List 数据结构可以用来做消息队列,只是功能过于简单且存在很多缺陷,不建议这样做。
# Hash
# 基本介绍
Redis 中的 Hash 是一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。
# 应用场景
对象数据存储场景
- 举例 :用户信息、商品信息、文章信息、购物车信息。
- 相关命令 :
HSET
(设置单个字段的值)、HMSET
(设置多个字段的值)、HGET
(获取单个字段的值)、HMGET
(获取多个字段的值)。
# Set
# 基本介绍
Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet
。当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个元素是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。
应用场景
需要存放的数据不能重复的场景
- 举例:网站 UV 统计(数据量巨大的场景还是
HyperLogLog
更适合一些)、文章点赞、动态点赞等场景。 - 相关命令:
SCARD
(获取集合数量) 。
需要获取多个数据源交集、并集和差集的场景
- 举例 :共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集) 、订阅号推荐(差集+交集) 等场景。
- 相关命令:
SINTER
(交集)、SINTERSTORE
(交集)、SUNION
(并集)、SUNIONSTORE
(并集)、SDIFF
(差集)、SDIFFSTORE
(差集)。
# Sorted Set
# 基本介绍
Sorted Set 类似于 Set,但和 Set 相比,Sorted Set 增加了一个权重参数 score
,使得集合中的元素能够按 score
进行有序排列,还可以通过 score
的范围来获取元素的列表。有点像是 Java 中 HashMap
和 TreeSet
的结合体。
# 应用场景
需要随机获取数据源中的元素根据某个权重进行排序的场景
- 举例 :各种排行榜比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。
- 相关命令 :
ZRANGE
(从小到大排序) 、ZREVRANGE
(从大到小排序)、ZREVRANK
(指定元素排名)。
# 3种特殊数据结构
3 种特殊数据结构 :HyperLogLogs(基数统计)、Bitmap (位存储)、Geospatial (地理位置)
# Bitmap
# 基本介绍
Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。
# 应用场景
需要保存状态信息(0/1 即可表示)的场景
- 举例 :用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。
# HyperLogLog
# 基本介绍
HyperLogLog 是一种有名的基数计数概率算法 ,基于 LogLog Counting(LLC)优化改进得来,并不是 Redis 特有的,Redis 只是实现了这个算法并提供了一些开箱即用的 API。
Redis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64
个不同元素。这是真的厉害,这就是数学的魅力么!并且,Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:
- 稀疏矩阵 :计数较少的时候,占用空间很小。
- 稠密矩阵 :计数达到某个阈值的时候,占用 12k 的空间。
# 应用场景
数量量巨大(百万、千万级别以上)的计数场景
- 举例 :热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv 统计、
# Geo
# 基本介绍
Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。
通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。
# 应用场景
需要管理使用地理空间数据的场景
- 举例:附近的人。
# Redis线程模型
# 非阻塞IO
当我们调用套接字的读写方法,默认是阻塞的。比如read方法,如果此刻一个字节都没有,线程就会卡着,直到新的数据来或者连接关闭,线程才能继续处理。相似的write方法,如果分配的写缓冲区已经满了,write方法就会阻塞。
非阻塞IO在套接字对象上提供了选项Non_Blocking,这样读写方法不会阻塞,而是能读多少读多少,能写多少写多少。
能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数。
能写多少取决于内核为套接字分配的写缓冲区的空闲空间字节数。
# 时间轮询(多路复用)
通过非阻塞IO,线程在读写IO的时候就不会阻塞,但是存在一个问题。
当一个线程读写完,去做另一件事情了。这时数据来了,那么是不是读线程应该去读这部分数据了。同理写进程也是一样,上次因为缓冲区空间不够了没写完数据,等缓冲区有了空间了,写进程就应该去写了。对于这两个进程我们还需要做一件事情:那就是通知他们去读写。
在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
Redis网络框架调用epoll机制,让内核监听套接字。此时Redis线程不会阻塞在某一个监听或已连接套接字上。
为了在请求达到的时候通知Redis线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
一旦监听到FD上有请求发生,就会触发相应的事件。这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来, Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时, Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。
# Redis为什么快
- Redis基于内存,内存的访问速度是磁盘的上千倍
- 采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因
- Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。
# Redis经典用处(除了缓存,还能做什么)
# 分布式锁
什么是分布式锁
本地锁
分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。
分布式锁
从图中可以看出,这些独立的进程中的线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到分布式锁访问共享资源。
一个最基本的分布式锁需要满足:
- 互斥 :任意一个时刻,锁只能被一个线程持有;
- 高可用 :锁服务是高可用的。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。
- 可重入:一个节点获取了锁之后,还可以再次获取锁。
如何基于Redis实现分布式锁
在 Redis 中, SETNX
命令是可以帮助我们实现互斥。SETNX
即 SET if Not eXists (对应 Java 中的 setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX
啥也不做。
> SETNX lockKey uniqueValue
(integer) 1
> SETNX lockKey uniqueValue
(integer) 0
释放锁的话,直接通过 DEL
命令删除对应的 key 即可。
> DEL lockKey
(integer) 1
# 限流
Redis + Lua
# 消息队列
Redis 自带的 list 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列
方案一:list实现消息队列
- 简单方案:直接队列先进先出
- 即时消费问题:消费者如果想要及时的处理数据,就要在程序中写个类似
while(true)
这样的逻辑,不停的去调用 RPOP 或 LPOP 命令,这就会给消费者程序带来些不必要的性能损失。所以,Redis 还提供了BLPOP
、BRPOP
这种阻塞式读取的命令(带 B-Bloking的都是阻塞式),客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。这种方式就节省了不必要的 CPU 开销。 - ack 机制:上面的实现方案缺少ack确认机制有两个命令,
RPOPLPUSH
、BRPOPLPUSH
(阻塞)从一个 list 中获取消息的同时把这条消息复制到另一个 list 里(可以当做备份),而且这个过程是原子的。这样我们就可以在业务流程安全结束后,再删除队列元素,实现消息确认机制。
方案二:订阅/发布
方案三:Streams发布
它就像是个仅追加内容的消息链表,把所有加入的消息都串起来,每个消息都有一个唯一的 ID 和对应的内容。而且消息是持久化的。
# 复杂业务场景
通过 bitmap 统计活跃用户、通过 sorted set 维护排行榜。
# Redis持久化
Redis的数据全部在内存里,如果突然发生宕机,数据就会全部丢失,所以肯定需要一份安全机制来保证数据不会因为突发情况而丢失,这种机制就是Redis持久化机制。
Redis的持久化则是将内存中的数据备份到硬盘中,在服务器重启时可以恢复。
持久化机制有两种,第一种是快照
,第二种是AOF
日志
# RDB
定义:
快照(RDB)是某一个时间点将Redis的内存数据全量
写入一个临时文件,当写入完成后,用该临时文件替换上一次持久化生成的文件,这样就完成了一次持久化过程。
特点:
- 快照是一次全量备份。
- 快照是内存数据的二进制序列化形式,在存储上非常紧凑。
# 快照的原理
由于需要持久化机制来保证数据,所以Redis需要一边响应请求,一边进行持久化操作,持久化操作必定涉及到文件IO操作,而文件IO操作就会严重拖累服务器的性能。为了解决这个问题Redis引入了操作系统的多进程COW(Copy on Write)来实现持久化。
COW
Redis在持久化时会调用glibc的函数fork产生一个子进程,父进程继续处理客户端的请求,而这个产生的子进程就进行Redis的持久化操作。在产生这个子进程的那一刻,父子进程是共享内存里面的代码段和数据段的。相关流程如下:
1、客户端发送bgsave命令,此刻会判断是否有其它子进程,如果没有就会进行fork操作。
2、Redis父进程通过fork操作创建子进程,在 fork 操作过程中父进程会阻塞。
3、Redis父进程fork 操作完成后,bgsave 命令返回 Background saving started
信息并不再阻塞 Redis 父进程,可以继续响应其他命令了。
4、子进程根据父进程创建那一刻所共享的内存进行持久化操作。
此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。
通过上述操作,现在父子进程各司其责,父进程持续服务客户端请求,然后对内存数据结果进行不间断修改,子进程就做持久化,不会修改现有的内存数据结构。
# 快照的频率
快照的频率并非越短越好,如果频繁地执行全量快照,也会带来两方面的开销。
一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了(所以,在 Redis 中如果有一个 bgsave 在运行,就不会再启动第二个 bgsave 子进程)。
这也是为什么bgsave会去判断是否存在其他子进程的原因。
# 增量快照
所谓增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。
在第一次做完全量快照后,T1 和 T2 时刻如果再做快照,我们只需要将被修改的数据写入快照文件就行。但是,这么做的前提是,我们需要记住哪些数据被修改了。你可不要小瞧这个“记住”功能,它需要我们使用额外的元数据信息去记录哪些数据被修改了,这会带来额外的空间开销问题。
如果对每一个修改的值都做一个记录,如果有 1 万个被修改的键值对,我们就需要有 1 万条额外的记录。而且,有的时候,键值对非常小,比如只有 32 字节,而记录它被修改的元数据信息,可能就需要 8 字节。这样的话,记录这个功能所需的存储空间远远大于了本身存储键值的空间是不太值得的。
# AOF
快照最大的问题就在于它提供的持久化策略是不安全的,不适合做实时持久化,所以 Redis 提供了第二套持久化方式:AOF 来解决这个问题。
AOF(Append Only File)日志是连续的增量备份,记录的是内存数据修改的指令记录文本。
# AOF原理
Redis AOF 持久化机制是在执行完命令之后再记录日志。
为什么是在执行完命令之后记录日志呢?
避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
而写后日志这种方式,就是先让系统执行命令,只有命令能执行成功,才会被记录到日志中,否则,系统就会直接向客户端报错。
在命令执行完之后再记录,不会阻塞当前的命令执行
# AOF重写
随着命令的不断写入,AOF 文件会越来越庞大,会带来一定的性能问题:
- 一是,文件系统本身对文件大小有限制,无法保存过大的文件;
- 二是,如果文件太大,之后再往里面追加命令记录的话,效率也会变低;
- 三是,如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用。
为了解决这些问题,Redis 提供了 “文件重写”功能。
重写 AOF 文件最直观的表现是导致 AOF 文件减小,重写时,Redis 主要做了如下几件事情让 AOF 文件减小:
- 已过期的数据不在写入文件。
- 保留最终命令。例如
set key1 value1
、set key1 value2
、....set key1 valuen
,类似于这样的命令,只需要保留最后一个即可。 - 删除无用的命令。例如
set key1 valuel;del key1
,这样的命令也是可以不用写入文件中的。 - 多条命令合并成一条命令。例如
lpush list a、lpush list b、lpush list c
,可以转化为lpush list a b c
# AOF和快照混合
简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免重写开销。
设置的参数是: aof-use-rdb-preamble yes
# Redis主从数据同步
# 为什么主从模式要采用读写分离的方式
实际上,Redis 提供了主从库模式,以保证数据副本的一致,主从库之间采用的是读写分离的方式。
- 读操作:主库、从库都可以执行。
- 写操作:首先到主库执行,然后,主库将写操作同步给从库。
试想一个问题,如果服务器上有三个Redis的实例(一个主库,两个从库),客户端发起写请求,对某一个key进行了三次修改,而这三次修改又是在不同实例上进行,那么这个数据在三个实例上的副本就不一致了。以后在读取这个数据的时候,就可能读取到旧的数值。
如果在上诉情况下要强行保证这个数据在三个实例上一致,就要涉及到加锁的问题,这样就会带来额外的开销,对于快速的Redis是不能接受的。
而主从库模式一旦采用了读写分离,所有数据的修改只会在主库上进行,不用协调三个实例。主库有了最新的数据后,会同步给从库,这样,主从库的数据就是一致的。
# 主从级联模式
在主从第一次数据同步过程中要进行一次全量复制,这对于主库来说需要完成两个耗时的操作:生成RDB文件和创建RDB文件。
生成RDB文件是需要fork子进程的,由于fork这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。
传输RDB文件是需要占用主库的网络带宽的,这也会给主库的资源使用带来压力。
为了分担主库的压力,就需要引入主-从-从模式:
主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过主 - 从 - 从模式将主库生成 RDB 和传输 RDB 的压力, 以级联的方式分散到从库上。
通过这个方式,某一些从库在同步的时候,就不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这样也减轻主库上的压力。
# Redis哨兵机制
# 哨兵机制流程
在选择某一从库升级为主库的时候存在三个问题:
- 主库是否真的故障
- 选择哪个从库作为主库
- 如何把新主库的相关信息通知给从库和客户端
在Redis主从集群中,哨兵机制是实现主从库自动切换的关键机制。
哨兵是一个运行在特殊模式下的Redis进程,主从库实例运行的同时,它也在运行。
哨兵的三个任务:监控、选主、通知。
监控
监控是指哨兵机制在运行时,周期性地给所有的主从库发送PING命令,如果对应的主从库没有在规定的时间响应哨兵的PING命令,哨兵就会把它认为“下限状态”。特别的,如果主库没有及时响应哨兵的PING命令,那么哨兵就要开始第二个流程自动切换主库
的流程。
选主
主库挂了以后,哨兵就需要从很多从库里,按照一定的规则,选择一个从库实例把它升级为主库实例。
通知
哨兵会把新主库的连接信息发送给其他从库,让他们执行replicaof命令,和新主库建立连接,并进行数据复制。同时哨兵会把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。
通知
流程较为简单,并不涉及到决策问题,而监控
和选主
则需要涉及到决策问题:
- 监控任务中,哨兵需要判断主库是否处于下线状态。
- 选择任务中,哨兵需要抉择哪个从库实例作为主库。
# 主观下线和客观下线
主观下线
哨兵进程会使用PING命令检测主库、从库的网络连接情况,用来判断实例的状态。
- 如果检测的是从库,发现该从库没有在规定时间进行回应,那么哨兵就会简单标记为“主观下线”,因为从库下线影响不会太多,Redis的服务也不会中断。
- 如果检测的是主库,由于主从切换涉及到选择新主和新主库同步,如果发生误判(主库并没有故障,但是哨兵认为其故障了) 这样会消耗大量的资源,所以不能简单主观下线,而需要客观下线。
误判一般发生在集群网络压力较大、网络拥塞或者是主库本身压力较大的情况下。
客观下线
主从切换这种重大事情,如果一个哨兵去判断,难免发生误判。哨兵机制通常会采用多实例组成的集群模式进行部署,这也被成为哨兵集群
。引入多个哨兵实例一起来判断,就可以避免单个哨兵因为自身网络状况不好,而误判主库下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。
在判断主库是否下线时,不能由一个哨兵说了算,只有大多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”,这个叫法也是表明主库下线成为一个客观事实了。这个判断原则就是:少数服从多数。同时,这会进一步触发哨兵开始主从切换流程。
这里就相当于成立了一个评审小组,小组共同对主库进行判断,采用少数服从多数的原则。
# 如何选新主
选新主流程:在多个从库中,按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则, 给剩下的从库逐个打分,将得分最高的从库选为新主库。
初筛流程
一般情况下,我们肯定要先保证所选的从库仍然在线运行。不过,在选主时从库正常在线,这只能表示从库的现状良好,并不代表它就是最适合做主库的。
在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。
具体怎么判断呢?你使用配置项 down-after-milliseconds * 10。其中,down-aftermilliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-aftermilliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。
打分流程
接下来就需要对初筛得到的从库进行打分。可以分别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级、从库复制进度以及从库 ID 号。只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。
第一轮:从库优先级
用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。比如,你有两个从 库,它们的内存大小不一样,你可以手动给内存大的实例设置一个高优先级。在选主时, 哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。
第二轮:从库复制进度
这个规则的依据是,选择和旧主库同步最接近的那个从库作为主库。
在主从同步中过程中,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会 用 slave_repl_offset 这个值记录当前的复制进度。
此时,我们想要找的从库,它的 slave_repl_offset 需要最接近 master_repl_offset。如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。
在上图中,如果旧主库的master_repl_offset为1000,那么最接近1000的就是从库2的990,所以从库2作为新主库。
第三轮:从库ID号
每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。目前,Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。
选主小结
首先,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库,然后,依次按照优先级、复制进度、ID 号大小再对剩余的从库进行打分, 只要有得分最高的从库出现,就把它选为新主库。
# Redis切片集群
# 切片集群概念
切片集群,也叫分片集群。就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。比如25G的数据,我们启动5个Redis实例,如果按照平分的话,那么一个Redis实例就拥有5G的数据。
数据不一定是平分,有可能一个实例有6G数据,另一个实例只有4G数据。
通过切片集群,我们就可以从原来为25G数据生成RDB,到现在每个实例只需要为5G数据生成RDB。这意味着我们fork子进程的时间将大大减小,这样就不会给主线程带来较长时间的阻塞。采用多个实例保存数据切片后,既能保存大数据量,也避免fork子进程阻塞主线程导致响应变慢。
# 数据切片和实例对应关系
在切片集群中,数据需要分布在不同实例上。这就需要数据和实例对应起来。实际上,切片集群是一种保存大量数据的通用机制,这个机制可以有不同的实现方案。从 3.0 开始,官方提供了一个名为 Redis Cluster 的方案,用于实现切片集群。
具体来说,Redis Cluster方案采用哈希槽(Hash Slot),来处理数据和实例之间的映射关系。一个切片集群有16384个哈希槽。
具体的映射过程分为两大步:首先根据键值对的 key,按照CRC16 算法计算一个 16 bit 的值;然后,再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
此时数据已经和槽位对应上了,还需要将槽位和实例对应上。
我们在部署 Redis Cluster 方案时,可以使用 cluster create 命令创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。例如,如果集群中有 N 个实例,那么,每个实例上的槽个数为 16384/N 个。
现在我们假设有三个实例的Redis和5个槽位,那么他们的对应关系如下:
在设置实例和槽位关系的时候,我们也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。
在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
通过哈希槽,切片集群就实现了数据到哈希槽、哈希槽再到实例的分配。现在就需要让客户端知道要访问的数据在哪个实例上了。
# Redis内存碎片
# 内存碎片概念
你可以将内存碎片简单地理解为那些不可用的空闲内存。
举个例子:操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。
Redis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗。
# 内存碎片如何形成
内因:内存分配器的分配策略
内存分配器的分配策略就决定了操作系统无法做到“按需分配”。这是因为,内存分配器一般是按固定大小来分配内存,而不是完全按照应用程序申请的内存空间大小给程序分配。
以jemalloc的分配内存为例,它是按照一系列固定的大小划分内存空间,例如 8 字节、16 字节、32 字节、48 字节,…, 2KB、4KB、8KB 等。Redis申请一个20字节的空间保存数据,jemalloc就会分配32字节。
外因:键值对大小不一样和删改操作
外因一
Redis 通常作为共用的缓存系统或键值数据库对外提供服务,所以,不同业务应用的数据都可能保存在 Redis 中,这就会带来不同大小的键值对。这样一来,Redis 申请内存空间分配时,本身就会有大小不一的空间需求。由于本身内存分配器只能按照固定大小分配内存,所以,分配的内存空间一般都会比申请的空间大一些,不会完全一致,这本身就会造成一定的碎片,降低内存空间存储效率。
外因二
键值对会被修改和删除,就会导致空间的扩容和释放。具体来说,一方面,如果修改后的键值对变大或变小了,就需要占用额外的空间或者释放不用的空间。另一方面,删除的键值对就不再需要内存空间了,此时,就会把空间释放出来,形成空闲空间。
从上图可以发现,在第三步的时候为了保存A数据的空间连续性,操作系统就需要把B的数据拷贝到别的空间,当应用C和应用D分别进行删除字节的时候,整个内存空间上就分别出现了 2 字节和 1 字节的空闲碎片。此时如果来一个新应用E想要3个字节的连续空间,显然是不行的。
# 如何清理内存碎片
最“简单粗暴”的方法就是重启 Redis 实例。当然,这并不是一个“优雅”的方法,毕竟,重启 Redis 会带来两个后果:
- 如果 Redis 中的数据没有持久化,那么,数据就会丢失;
- 即使 Redis 数据持久化了,我们还需要通过 AOF 或 RDB 进行恢复,恢复时长取决于 AOF 或 RDB 的大小,如果只有一个 Redis 实例,恢复阶段无法提供服务。
现在试想一下,如果高铁上有两个不连续的位置,你和好朋友想要坐一起,就可以通过和其他人沟通的方式来进行换位。内存碎片清理,也可以通过这样的方式。当有数据把一块连续的内存空间分割成好几块不连续的空间时,操作系统就会把数据拷贝到别处。此时,数据拷贝需要能把这些数据原来占用的空间都空出来,把原本不连续的内存空间变成连续的空间。否则,如果数据拷贝后,并没有形成连续的内存空间,这就不能算是清理了。
内存清理是存在代价的,操作系统需要把多份数据拷贝到新位置,把原有空间释放出来,这会带来时间开销。因为 Redis 是单线程,在数据拷贝时,Redis 只能等着,这就导致 Redis 无法及时处理请求,性能就会降低。
我们可以通过设置参数,来控制碎片清理的开始和结束时机,以及占用的 CPU 比例,从而减少碎片清理对 Redis 本身请求处理的性能影响。
启动自动内存碎片清理:
config set activedefrag yes
设置什么时候清理:
- active-defrag-ignore-bytes 100mb:表示内存碎片的字节数达到 100MB 时,开始清理;
- active-defrag-threshold-lower 10:表示内存碎片空间占操作系统分配给 Redis 的总空间比例达到 10% 时,开始清理。
控制清理操作占用CPU时间比例的上下限,既保证清理工作能正常进行,又避免了降低 Redis 性能:
- active-defrag-cycle-min 25: 表示自动清理过程所用 CPU 时间的比例不低于 25%,保证清理能正常开展;
- active-defrag-cycle-max 75:表示自动清理过程所用 CPU 时间的比例不高于 75%,一旦超过,就停止清理,从而避免在清理时,大量的内存拷贝阻塞 Redis,导致响应延迟升高。
# Redis内存管理
# Redis为什么要给缓存数据设置过期时间
- 因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接 Out of memory。
- 很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 token 可能只在 1 天内有效。
# 过期数据删除策略
常用的过期数据的删除策略就两个:
- 惰性删除 :只会在取出 key 的时候才对数据进行过期检查。这样对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
- 定期删除 : 每隔一段时间抽取一批 key 执行删除过期 key 操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,所以 Redis 采用的是 定期删除+惰性/懒汉式删除 。
# 内存淘汰机制
Redis一共有8中淘汰策略,按照是否会进行数据淘汰把它们分成两类:
- 不进行数据淘汰的策略,只有 noeviction 这一种。
- 会进行淘汰的 7 种其他策略。
会进行淘汰的 7 种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:
- 在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。
- 在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。
对于不进行数据淘汰的noevction,是指一旦缓冲被写满了,再有写请求过来的时候,Redis 不再提供服务,而是直接返回错误,显然这种策略是不适合Redis缓冲的。
对于设置了过期时间的数据淘汰策略:它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。
volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。
volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。
volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对。
volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对。
对于所有数据中进行淘汰的策略:备选淘汰数据范围,就扩大到了所有键值对,无论这些键值对是否设置了过期时间。它们筛选数据进行淘汰的规则是:
- allkeys-random 策略,从所有键值对中随机选择并删除数据;
- allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。
- allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。
下面我们重点对LRU算法进行学习:
LRU 算法的全称是 Least Recently Used,按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。LRU会把所有的数据组织成一个链表,链表的头和尾分别表示MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据。
如果有一个新数据 15 要被写入缓存,但此时已经没有缓存空间了,也就是链表没有空余位置了,那么,LRU 算法做两件事:
- 数据 15 是刚被访问的,所以它会被放到 MRU 端;
- 算法把 LRU 端的数据 5 从缓存中删除,相应的链表中就没有数据 5 的记录了。
LRU算法的思想:刚被访问的数据,可能会被再次访问,所以就把它放在MRU端;长久不访问的数据,肯定以后就不会再被访问,所以就让它逐渐后移到 LRU 端,在缓存满时,就优先删除它。
但是LRU对于Redis实际使用而言存在两个问题:
- LRU 算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销
- 当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
Redis对LRU算法进行了简化,以减轻数据淘汰对缓存性能的影响。具体来说,Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。
# Redis事务
# Redis如何实现事务
Redis 提供了 MULTI、EXEC 两个命令来完成事务。下面我们来分析下:
- 第一步,客户端要使用一个命令显式地表示一个事务的开启。在 Redis 中,这个命令就是 MULTI。
- 第二步,客户端把事务中本身要执行的具体操作(例如增删改数据)发送给服务器端。虽然这些命令被客户端发送到了服务器端,但是Redis实例只是把这些命令暂存到一个命令队列中,并不会立即执行。
- 第三步,客户端向服务器端发送提交事务的命令,让数据库实际执行第二步中发送的具体操作。Redis 提供的 EXEC 命令就是执行事务提交的。当服务器端收到 EXEC 命令后,才会实际执行命令队列中的所有命令。
#开启事务
127.0.0.1:6379> MULTI
OK
#将a:stock减1,
127.0.0.1:6379> DECR a:stock
QUEUED
#将b:stock减1
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务
127.0.0.1:6379> EXEC
1) (integer) 4
2) (integer) 9
# Redis事务如何保证事务属性
# 原子性
如果事务正常执行,没有发生任何错误,那么,MULTI 和 EXEC 配合使用,就可以保证多个操作都完成。但是,如果事务执行发生错误了,要分三种情况讨论。
情况一
在执行 EXEC 命令前,客户端发送的操作命令本身就有错误(比如语法错误,使用了不存在的命令),在命令入队时就被 Redis 实例判断出来了。
这种情况,等到执行EXEC命令之后,Redis就会拒绝执行所有提交的命令操作,返回事务失败的结果。这样,事务中所有的命令都不会执行,保证了原子性。
情况二
事务操作入队时,命令和操作的数据类型不匹配,但 Redis 实例没有检查出错误。但是,在执行完 EXEC 命令以后,Redis 实际执行这些事务操作时,就会报错。不过,需要注意的是,虽然 Redis 会对错误命令报错,但还是会把正确的命令执行完。在这种情况下,事务的原子性就无法得到保证了。
这里Redis并没有提供回滚机制,虽然有DISCARD 命令,但是,这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
情况三
在执行事务的 EXEC 命令时,Redis 实例发生了故障,导致事务执行失败。
在这种情况下,如果 Redis 开启了 AOF 日志,那么,只会有部分的事务操作被记录到 AOF 日志中。使用 AOF 恢复实例后,事务操作不会再被执行,从而保证了原子性。如果 AOF 日志并没有开启,那么实例重启后,数据也都没法恢复了,此时,也就谈不上原子性了。
小结
- 命令入队时就报错,会放弃事务执行,保证原子性;
- 命令入队时没报错,实际执行时报错,不保证原子性;
- EXEC 命令执行时实例故障,如果开启了 AOF 日志,可以保证原子性。
# 一致性
事务的一致性保证会受到错误命令、实例故障的影响。
情况一
命令入队时就报错,在这种情况下,事务本身就会被放弃执行,所以可以保证数据库的一致性。
情况二
命令入队时没报错,实际执行时报错,在这种情况下,有错误的命令不会被执行,正确的命令可以正常执行,也不会改变数据库的一致性。
情况三
在这种情况下,如果我们没有开启 RDB 或 AOF,那么,实例故障重启后,数据都没有了,数据库是一致的。
如果我们使用了 RDB 快照,因为 RDB 快照不会在事务执行时执行,所以,事务命令操作的结果不会被保存到 RDB 快照中,使用 RDB 快照进行恢复时,数据库里的数据也是一致的。
如果我们使用了 AOF 日志,而事务操作还没有被记录到 AOF 日志时,实例就发生了故障,那么,使用 AOF 日志恢复的数据库数据是一致的。如果只有部分操作被记录到了 AOF 日志,我们可以使用 redis-check-aof 清除事务中已经完成的操作,数据库恢复后也是一致的。
总结来说,无论那种情况,Redis事务机制对一致性属性还是有保证的。
# 隔离性
事务的隔离性保证,会受到和事务一起执行的并发操作的影响。而事务执行又可以分成命令入队(EXEC 命令执行前)和命令实际执行(EXEC 命令执行后)两个阶段。
- 并发操作在 EXEC 命令前执行,此时,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证;
- 并发操作在 EXEC 命令后执行,此时,隔离性可以保证。
WATCH 机制的作用是,在事务执行前,监控一个或多个键的值变化情况,当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。然后,客户端可以再次执行事务,此时,如果没有并发修改事务数据的操作了,事务就能正常执行,隔离性也得到了保证。
# 持久性
因为 Redis 是内存数据库,所以,数据是否持久化保存完全取决于 Redis 的持久化配置模式。
如果 Redis 没有使用 RDB 或 AOF,那么事务的持久化属性肯定得不到保证。
如果 Redis 使用了 RDB 模式,那么,在一个事务执行后,而下一次的 RDB 快照还未执行前,如果发生了实例宕机,这种情况下,事务修改的数据也是不能保证持久化的。
如果 Redis 采用了 AOF 模式,因为 AOF 模式的三种配置选项 no、everysec 和 always 都会存在数据丢失的情况,所以,事务的持久性属性也还是得不到保证。所以,不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。
# Redis生产问题
# 数据不一致
缓冲中的数据和数据库中的数据不一致,那么业务应用从缓冲中读取的数据就不是最新数据,就会导致严重的错误,下面就数据不一致问题进行深入。
# 数据不一致是如何发生的
数据的一致性包括两种情况:
- 缓存中有数据,那么,缓存的数据值需要和数据库中的值相同;
- 缓存中本身没有数据,那么,数据库中的值必须是最新值。
当缓存的读写模式不同时,缓冲数据不一致的发生情况不一样:
读写缓冲
对于读写缓存来说,如果要对数据进行增删改,就需要在缓存中进行,同时还要根据采取的写回策略,决定是否同步写回到数据库中。
- 同步直写策略:写缓存时,也同步写数据库,缓存和数据库中的数据一致;
- 异步写回策略:写缓存时不同步写数据库,等到数据从缓存中淘汰时,再写回数据库。使用这种策略时,如果数据还没有写回数据库,缓存就发生了故障,那么,此时,数据库就没有最新的数据了。
所以,对于读写缓存来说,要想保证缓存和数据库中的数据一致,就要采用同步直写策略。不过,需要注意的是,如果采用这种策略,就需要同时更新缓存和数据库。所以,我们要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性,也就是说,两者要不一起更新,要不都不更新,返回错误信息,进行重试。否则,我们就无法实现同步直写。
只读缓冲
对于只读缓存来说,如果有数据新增,会直接写入数据库;而有数据删改时,就需要把只读缓存中的数据标记为无效。这样一来,应用后续再访问这些增删改的数据时,因为缓存中没有相应的数据,就会发生缓存缺失。此时,应用再从数据库中把数据读入缓存,这样后续再访问数据时,就能够直接从缓存中读取了。
但是除了新增操作,其他操作会同时涉及到Redis和MySQL的修改,这就存在一个原子性操作,这也是出现数据不一致问题的源头,下面我们来分析一下。
新增操作
如果是新增数据,数据会直接写到数据库中,不用对缓存做任何操作,此时,缓存中本身就没有新增数据,而数据库中是最新值,这种情况符合我们刚刚所说的一致性的第 2 种情况,所以,此时,缓存和数据库的数据是一致的。
删改操作
如果发生删改操作,应用既要更新数据库,也要在缓存中删除数据。这两个操作如果无法保证原子性,此时就会出现数据不一致问题了。
假设应用先删除缓存,再更新数据库,如果缓存删除成功,但是数据库更新失败,那么,应用再访问数据时,缓存中没有数据,就会发生缓存缺失。然后,应用再访问数据库,但是数据库中的值为旧值,应用就访问到旧值了。
如果应用先完成了数据库的更新,但是,在删除缓存时失败了,那么,数据库中的值是新值,而缓存中的是旧值,这肯定是不一致的。这个时候,如果有其他的并发请求来访问数据,按照正常的缓存访问流程,就会先在缓存中查询,但此时,就会读到旧值了。
好了,到这里,我们可以看到,在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了,就会导致客户端读取到旧值。
# 如何解决数据不一致问题
采用重试机制,具体来说可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中,当应用没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致了。否则的话,我们还需要再次进行重试。如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
通过重试机制,我们就解决了更新数据库和删除缓存值的过程中,其中一个操作失败的情况。但是在大量并发的请求下,即使两个操作第一次执行都没有失败,应用还是有可能存在数据不一致的情况。
情况一:先删除缓存,再更新数据库。
针对上述情况我们可以在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作,也就是延迟双删。
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)
之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,再把缺失的数据写入缓存,然后,线程 A 再进行删除,所以,线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。
情况二:先更新数据库值,再删除缓存值。
不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。
# 总结
- 删除缓存值或更新数据库失败而导致数据不一致,你可以使用重试机制确保删除或更新操作成功。
- 在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值,应对方案是延迟双删。
# 缓存穿透
缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。此时相当于整个缓存也就成了“摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力。
发生的情况一般有两种:
- 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
- 恶意攻击:专门访问数据库中没有的数据。
方案一:缓存空值或缺省值。
一旦发生缓存穿透,我们就可以针对查询的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值(例如,库存的缺省值可以设为 0)。紧接着,应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取空值或缺省值,返回给业务应用了,避免了把大量请求发送给数据库处理,保持了数据库的正常运行。
方案二:布隆过滤器过滤
当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。这样一来,即使发生缓存穿透了,大量请求只会查询 Redis 和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用 Redis 实现,本身就能承担较大的并发访问压力。
方案三:前端进行请求检测
为了应对恶心攻击,在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。这样一来,也就不会出现缓存穿透问题了。
# 缓存击穿
缓存击穿是指,针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,紧接着,访问该数据的大量请求,一下子都发送到了后端数据库,导致了数据库压力激增,会影响数据库处理其他请求。缓存击穿的情况,经常发生在热点数据过期失效时,如下图所示:
为了避免缓存击穿给数据库带来的激增压力,我们的解决方法也比较直接,对于访问特别频繁的热点数据,我们就不设置过期时间了。这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。
# 缓存雪崩
缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。一般是由两个原因导致的:
原因一
缓存中有大量数据同时过期,导致大量请求无法得到处理。在某一个时刻,大量数据同时过期,此时,应用再访问这些数据的话,就会发生缓存缺失。紧接着,应用就会把请求发送给数据库,从数据库中读取数据。如果应用的并发请求量很大,那么数据库的压力也就很大,这会进一步影响到数据库的其他正常业务请求处理。
- 方案一:避免给大量的数据设置相同的过期时间,可以在用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟),这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,同时也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。
- 方案二:服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式。
- 当业务应用访问的是非核心数据(例如电商商品属性)时,暂时停止从缓存中查询这些数据,而是直接返回预定义信息、空值或是错误信息;
- 当业务应用访问的是核心数据(例如电商商品库存)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。
原因二
Redis 缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层,从而发生缓存雪崩。
方案一:是在业务系统中实现服务熔断或请求限流机制。
服务熔断:是指在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问。服务熔断虽然可以保证数据库的正常运行,但是暂停了整个缓存系统的访问,对业务应用的影响范围大。
请求限流:一旦发生缓存雪崩,在请求入口前端只允许每秒进入系统的请求数减少,再多的请求就会在入口前端被直接拒绝服务。所以,使用了请求限流,就可以避免大量并发请求压力传递到数据库层。
方案二:通过主从节点的方式构建 Redis 缓存高可靠集群,避免了由于缓存实例宕机而导致的缓存雪崩问题。
# 总结
其中,服务熔断、服务降级、请求限流这些方法都是属于“有损”方案,在保证数据库和整体系统稳定的同时,会对业务应用带来负面影响。
# Redis为什么会变慢
# 慢查询命令
慢查询命令,就是指在 Redis 中执行速度慢的命令,这会导致 Redis 延迟增加。不同的操作指令复杂度不同,因此我们需要对某一些复杂的操作指令进行处理。
- 用其他高效命令代替。比如说,如果你需要返回一个 SET 中的所有成员时,不要使用 SMEMBERS 命令,而是要使用 SSCAN 多次迭代返回,避免一次返回大量数据,造成线程阻塞。
- 需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。
KEYS指令它用于返回和输入模式匹配的所有 key,因为 KEYS 命令需要遍历存储的键值对,所以操作延时高,所以一般不被建议用于生产环境中。
# 过期key操作
过期key的自动删除机制是Redis用来回收内存空间的常用机制,它本身就会引起Redis操作阻塞,导致性能变慢。
Redis 键值对的 key 可以设置过期时间。默认情况下,Redis 每 100 毫秒会删除一些过期 key,具体的算法如下:
- 采样 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 个数的 key,并将其中过期的 key 全部删除;
- 如果超过 25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下。
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 是 Redis 的一个参数,默认是 20,那么,一秒内基本有 200 个过期 key 会被删除。
但是算法的第二条,Redis 就会一直删除以释放内存空间,而删除操作是阻塞的,Redis线程一直执行删除,就没办法正常服务其他的键值操作了,就会进一步引起其他键值操作的延迟增加,Redis 就会变慢。
而算法的第二条就是因为频繁使用带有相同时间参数的EXPIREAT 命令设置过期 key,这就会导致,在同一秒内有大量的 key 同时过期。
# 文件写入
AOF 重写会对磁盘进行大量 IO 操作,同时,fsync 又需要等到数据写到磁盘后才能返回,所以,当 AOF 重写的压力比较大时,就会导致 fsync 被阻塞。
当主线程使用后台子线程执行了一次 fsync,需要再次把新接收的操作记录写回磁盘时,如果主线程发现上一次的 fsync 还没有执行完,那么它就会阻塞。所以,如果后台子线程执行的 fsync 频繁阻塞的话(比如 AOF 重写占用了大量的磁盘 IO 带宽),主线程也会阻塞,导致 Redis 性能变慢。
# swap
操作系统为了缓解内存不足对应用程序的影响,允许把一部分内存中的数据换到磁盘上,以达到应用程序对内存使用的缓冲,这些内存数据被换到磁盘上的区域,就是 Swap。当内存中的数据被换到磁盘上后,Redis 再访问这些数据时,就需要从磁盘上读取,访问磁盘的速度要比访问内存慢几百倍。尤其是针对 Redis 这种对性能要求极高、性能极其敏感的数据库来说,这个操作延时是无法接受的。解决方案就是增加机器的内存,让 Redis 有足够的内存可以使用。或者整理内存空间,释放出足够的内存供 Redis 使用。