Redis面试问题

Redis

Redis是什么?

Redis(Remote Dictionary Server)是一个使用 C 语言编写的,高性能非关系型的键值对数据库。与传统数据库不同的是,Redis 的数据是存在内存中的,所以读写速度非常快,被广泛应用于缓存方向。Redis可以将数据写入磁盘中,保证了数据的安全不丢失,而且Redis的操作是原子性的。Redis与其他key-value缓存产品有以下三个特点:

  1. Rdis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
  2. Redis不仅仅支持简单的key-value类型的数据,同时还提供ist,set,zset,hash等数据结构的存储。
  3. Redis支持数据的备份,即master-slave模式的数据备份。

优点

  1. 基于内存操作,内存读写速度快。
  2. Redis是单线程的,避免线程切换开销及多线程的竞争问题。单线程是指网络请求使用一个线程来处理,即一个线程处理所有网络请求,Redis 运行时不止有一个线程,比如数据持久化的过程会另起线程。
  3. 支持多种数据类型,包括String、Hash、List、Set、ZSet等。
  4. 支持持久化。Redis支持RDB和AOF两种持久化机制,持久化功能可以有效地避免数据丢失问题。
  5. 支持事务。Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。
  6. 支持主从复制。主节点会自动将数据同步到从节点,可以进行读写分离。

缺点

  1. 对结构化查询的支持比较差。
  2. Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂
  3. 数据丢失问题——持久化
  4. 并发能力问题——主从集群
  5. 故障恢复能力——哨兵
  6. 存储能力——数据库容量受到物理内存的限制,不适合用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的操作。

Reids为什么快

Redis 之所以快,主要是因为以下几个原因:

  1. 内存数据结构:Redis 的所有数据都存储在内存中,而内存是计算机访问数据最快的存储介质。与关系型数据库相比,Redis 不需要进行磁盘 I/O 操作,这就使得 Redis 对读写操作具有了更高的性能。

  2. 单线程模型:Redis 使用单线程模型处理所有客户端请求,简化了线程同步问题,避免了线程切换、竞态条件等多线程带来的开销,从而提升了并发性能。此外,由于 Redis 并没有使用 Java 等语言虚拟机,也避免了虚拟机和操作系统之间的桥梁带来的额外开销。

  3. 异步非阻塞网络 IO 模型:Redis 使用自己开发的网络 IO 框架,因此可以实现高效的异步非阻塞通信。这个特点使得 Redis 可以支持高并发的连接数和请求量,而不会因为阻塞导致性能下降。

  4. Redis 的 IO 多路复用使用了 Reactor 设计模式,它通过在单个线程中同时监听多个文件描述符(sockets 或者文件句柄)的 IO 事件,在有事件发生时选择对应的处理函数来处理事件,以此实现高效的网络 IO 操作。在 Redis 中,当有客户端连接请求到达时,Redis 将其作为一个文件描述符加入到监听队列中。Redis 使用 IO 多路复用技术来监控这些文件描述符上所发生的各种 IO 事件,包括读取、写入和异常等。当有事件发生时,Redis 会调用对应的处理函数进行处理,例如执行命令、发送响应等操作。

  5. 多种数据结构:Redis 支持多种基于内存的数据结构,例如字符串、哈希表、列表、集合和有序集合等,每种数据结构都经过了严格的优化。 Redis 对于每种数据结构都采用了不同的底层实现,并针对每种数据结构的特点进行了相应的性能优化。例如,按照索引顺序存储的有序集合可以使用跳跃表作为底层实现,在插入、删除和查询等方面比基于红黑树的其他实现方法更快。

总之,Redis 通过各种优化和特殊设计,使得其在读写性能和并发性能上表现出色,成为了目前业界最受欢迎的内存数据库之一。

非阻塞I/O

非阻塞式 IO (Non-blocking I/O)是一种基于事件驱动的 I/O 模型,其在进行 I/O 操作时,会立即返回而不会阻塞进程。当我们传递一个需要等待时间较长的 IO 操作时,在传统的阻塞式 IO 模型中,进程会被阻塞,等待系统完成IO操作后才能继续执行后续代码。而在非阻塞式 IO 模型中,标记了非阻塞属性的 IO 操作请求仅仅是向系统发起了一个 “请调用我届时可用的回调函数” 的请求,瞬间返回给调用者,而不会因为等待系统的响应而被阻塞。

与阻塞 IO 模型相比,非阻塞 IO 模型具有以下优点:

  1. 提高系统的效率和吞吐量:非阻塞 IO 使得多个 IO 操作可以交替执行,从而提高了系统资源的利用率和吞吐量。
  2. 改善流控制:在非阻塞 IO 模型中,进程不会一直等待 IO 操作的完成,当有数据可读或者可写时,进程会被通知,从而充分利用 CPU 的时间,避免了阻塞 IO 模型下的等待时间。
  3. 稳定性高:非阻塞 IO 模型使用事件驱动的方式来处理,可以更好地通过事件机制进行流控制,具有更好的稳定性。

总之,非阻塞 IO 将 IO 操作转化为事件的响应,避免了阻塞带来的问题,提高了系统的效率和吞吐量,并且能够更好地平衡系统的资源和稳定性。在实际应用场景中,非阻塞 IO 模型常被用于网络编程中的并发处理和高性能服务器开发。

Redis采用非阻塞式IO来实现高效的数据读写操作。在传统的阻塞式IO模型中,当一个线程从socket中读取数据时,会一直等待直到有数据可读。但是在非阻塞式IO模型中,线程可以在数据没有准备好时就立即返回,从而避免了长时间的等待。

Redis使用了epoll这种I/O多路复用技术,通过监听多个文件描述符(socket),来实现分别处理多个客户端连接的需求。当一个客户端连接上服务端后,服务端会将该客户端socket设置为非阻塞模式。因此,当客户端发送数据时,服务端不会阻塞等待数据,而是直接读取已经准备好的数据。同时,为了进一步提高性能,Redis还会在socket上设置TCP_NODELAY属性,禁用Nagle算法,从而尽可能地减少数据包的延迟。

由于非阻塞式IO模型的特性,当客户端socket没有准备好读写数据时,服务端会立即返回,而不会一直阻塞等待,这样就能够处理更多的客户端请求,提高Redis的并发性能。

Redis 数据类型有哪些?

RedisObject

Redis在底层都是使用的redisObject对象表示的。redisObject有3个重要的属性:type、encoding、ptr。

  • type表示value的数据类型,也就是5种数据类型(REDIS_STRING、REDIS_LIST、REDIS_HASH、REDIS_SET、REDIS_ZSET)
  • encoding表示value的编码,即底层使用了哪种数据结构、
  • ptr是一个指向保存value的底层数据结构的指针。

String

最常用的一种数据类型,String类型的值可以是字符串、数字或者二进制,但值最大不能超过512MB。String对象的encoding有三种:INT、EMBSTR、RAW。

  • 如果value保存的是一个整数值,并且这个整数值可以用Long类型表示,那么value使用的encoding就是INT,并将ptr的值直接设置为value值(long ptr = 123;)。
  • 如果value保存的是一个字符串,当字符串的长度小于等于44字节时,value使用的encoding是EMBSTR,当字符串长度大于44字节时,value使用的encoding是RAW(44字节的界限是Redis3.2之后的最新界限,3.2版本之前是39字节)。

当value的编码为EMBSTR或者RAW时,Redis是如何存储value这个字符串的呢?我们知道,Redis是使用C语言写的,但Redis底层存储字符串时,并没有直接使用C语言的char*字符数组实现,而是自己封装了一种单独的数据结构,这种数据结构叫做SDS(simple dynamic string),翻译成中文就是简单动态字符串。Redis之所以这样做,是因为在某些常用的情况下,C语言的字符串满足不了需求:

  1. C语言中的字符串是以字符数组的形式表示的,所以,如果要获取字符串的长度,需要遍历这个字符数组才能得到,查询时间复杂度是O(N);
  2. C语言中,字符串的结束是以“\0”作为标识的,也就是说,当遍历一个字符串的时候,碰到“\0”就会自动结束。因此,C语言中的字符串中是不能带有“\0”这个字符的,这就会限制很多使用场景,比如图片、音频、视频这样的二进制数据就无法使用C语言的字符串存储。
SDS(简单动态字符串)

Redis自己封装的SDS数据结构就解决了C语言字符串的不足。SDS中定义了4个属性:

  • len:表示字符串的长度。主要作用是当查询字符串长度的时候,直接返回该字段的值,而不用通过遍历字符数组得到,查询时间复杂度为O(1);
  • alloc:分配给字符串的空间。这个字段的主要作用是在修改字符串的时候,可以先通过alloc-len计算得到当前剩余的空间大小,然后判断剩余的空间是否满足新的字符串的空间要求,如果不满足要求,则进行自动扩容后再执行修改操作(小于1MB时翻倍扩容,大于1MB时按1MB扩容);
  • flags:表示SDS的类型。Redis3.2版本后,Redis定义了5种不同类型的SDS结构。类型的区别主要是它们内部定义的len和alloc成员变量的数据类型不同。Redis会根据要存储字符串的长度自动选择合适的SDS类型,以达到节省内存空间的目的。
  • buf[]:字符数组,用来保存实际的字符串的值。该数组中可以存储任意字符,包括“\0”,所以,SDS数据结构可以存储任意类型的字符串,包括图片、音频和视频二进制数据等。

Hash

Hash 是一个键值对集合。Hash对象的底层编码有两种:ZIPLIST和HASHTABLE。

Hash类型经常用于存储一些对象类型的数据,比如我们可以把User对象存到Hash类型中,当需要修改User的某个属性的值时,可以直接修改对应的属性值,而不用修改整个User对象。

List

List也是我们经常使用的一种数据类型,它是一个列表,支持存储重复的数据。

在Redis3.2版本之前,List对象的encoding有两种:ZIPLIST和LINKEDLIST。当List列表中每个字符串的长度都小于64字节并且List列表中元素数量小于512个时,List对象使用ZIPLIST编码;其他情况使用LINKEDLIST编码。

  • 应用场景:List 应用场景非常多,也是 Redis 最重要的数据结构之一,比如 Twitter 的关注列表,粉丝列表都可以用 List 结构来实现。
ZIPLIST(压缩列表)

压缩列表:一种内存紧凑型的数据结构,它的设计思想就是为了节约内存空间。它借鉴了数组的存储思路,占用一块连续的内存空间,但每个元素的大小为实际存储的大小。

但这样压缩之后,遍历这个数组的时候就会出现问题,因为不知道每个元素的大小,所以无法计算出每个元素的具体位置,所以,我们需要给每个元素增加一个长度的属性length,用来标识当前节点元素的大小,这样在遍历的时候就可以正常计算出下个元素节点的位置了。

Redis的ZIPLIST就是基于上面的压缩列表做了自己的封装,先来看下ZIPLIST的整体结构:

  1. zlbytes:记录整个ZIPLIST占用的内存字节数,即ZIPLIST的大小;
  2. zltail:记录ZIPLIST中尾结点距离起始地址的偏移量(字节数),通过该偏移量可以直接得到最后一个entry节点,方便从尾结点开始遍历列表;
  3. zllen:记录ZIPLIST的节点数量,即entry节点的数量,通过该属性,可以直接得到ZIPLIST的长度;
  4. entry压缩型列表:存储具体数据的节点,每个entry节点中包含3个属性:prevlen(上一个节点的字节大小)、encoding(记录content的类型和长度)、content(当前节点的实际数据)。
  5. zlend:ZIPLIST的结束标识,是一个固定值:0xFF(十进制255)。

缺点

  • 首先,它不能存储太多的元素,否则它的遍历效率就会很低;
  • 其次,当新增或者修改某个元素时,会重新分配内存,甚至可能会引起连锁更新的问题。什么是连锁更新呢?简单来说就是我只更新了一个元素,但由于某种原因,导致所有的元素都要做一次更新的动作,大大降低了效率。

因此,ZIPLIST只适用于保存的节点数量不多的场景。

LINKEDLIST(双向链表)

LINKEDLIST中包含了3个属性:head、tail和len。

其中head和tail分别表示一个双向链表的头节点和尾结点,len表示双向链表的长度。除此之外,LINKEDLIST还封装了3个经常使用到的方法:dup(复制节点值)、free(释放节点值)和match(比较节点值),方便了对LINKEDLIST做一些常用的操作。

LINKEDLIST有哪些优点,首先,LINKEDLIST中维护了双向链表的head节点和tail节点,所以LINKEDLIST可以直接获取到head和tail节点,时间复杂度为O(1);其次,双向链表的每个listNode节点中,都维护了自己上一个节点prev和下一个节点next的定义,所以,获取某个节点的上一和下一节点的时间复杂度也是O(1);然后,LINKEDLIST中维护了链表长度的变量len,所以可以直接获取到链表长度,时间复杂度也是O(1)。

但LINKEDLIST的缺陷也很明显:首先,相比于ZIPLIST,双向链表的每个节点的内存并不是连续的,也就无法很好的利用CPU缓存;其次,双向链表的遍历需要从一端开始向另一端遍历,查询一个节点的时间复杂度是O(N)。

QUICKLIST(快速列表)

由于ZIPLIST和LINKEDLIST都存在不可避免的缺陷,所以,Redis3.2版本之后,引入了一种新的数据结构:QUICKLIST(快速列表)。List对象的底层数据结构也由之前的ZIPLIST和LINKEDLIST变为了新的QUICKLIST。

QUICKLIST结合了ZIPLIST和LINKEDLIST的优点,它是一个以压缩列表(ZIPLIST)为节点的双向链表(LINKEDLIST)。

QUICKLIST的数据结构与LINKEDLIST总体上是很相似的,都包含一个双向链表,并且都维护了双向链表的head节点和tail节点,以及链表的节点数量。

最大的区别就是双向链表中存储的value值的类型发生了变化,它存储是一个指向ZIPLIST的指针zl,ZIPLIST的最大大小由QUICKLIST中的fill属性设置,当某个节点的ZIPLIST的大小超过了fill指定的大小,就会在链表中新建一个ZIPLIST保存到新的quicklistNode节点中。

quicklistNode节点中除了包含prev、next、zl这三个必要的属性之外,还包含一些其他的属性,比如sz,表示当前节点的ZIPLIST占用的总字节数;还有count,表示ZIPLIST中的元素数量。

Set

  • Set 是 String 类型的无序集合。Set对象的编码有两种:INTSET和HASHTABLE。当集合中保存的所有元素都是整数,并且元素个数不超过512个时,Set对象的底层使用INTSET编码;其他情况下统一使用HASHTABLE编码。Set 中的元素是没有顺序的,而且是没有重复的。常用命令:sdd、spop、smembers、sunion 等。Set 提供了交集、并集方法,
  • 应用场景:Redis Set 对外提供的功能和 List 一样是一个列表,特殊之处在于 Set 是自动去重的,而且 Set 提供了判断某个成员是否在一个 Set 集合中。对于实现共同好友、共同关注等功能特别方便。
INTSET

结构:encoding、length、contents。

encoding表示编码方式,有INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64三种类型,代表元素的取值范围。length表示集合的元素总数。contents是一个数组,用来存储元素值。

INTSET的encoding属性,Redis在决定INTSET使用哪种encoding时,是根据集合中最大的元素的大小决定的,如果最大的元素大小在INTSET_ENC_INT16允许范围内,那么就会使用INTSET_ENC_INT16。所以,这样就会出现一个encoding升级的现象,也就是encoding从低位向高位转变。

这样设计的优点在于,Redis可以根据添加的元素大小自动选择合适的encoding类型,可以尽可能地节省content数组占用的内存空间。但也有个缺点,那就是encoding只支持升级,不支持降级。

HASHTABLE

HASHTABLE(哈希表)是一个保存key-value键值对的数据结构,它与Java中的HashMap类似,每个key都是唯一的,可以通过key修改或查询value。

HASHTABLE主要包含4个属性:

  1. table:哈希表的主要组成部分,它是一个数组,数组中每个元素的类型都是dictEntry
  2. dictEntry保存了key和value,还有一个next属性。我们在向哈希表中添加key-value时,会先对key做Hash运算得到一个哈希值,然后根据该哈希值得到具体的key的位置。但随着数据的增多,就有可能产生哈希冲突(即两个key的哈希值相同),那怎么解决哈希冲突呢?next属性就是用来解决哈希冲突的,当两个key的哈希值相同的时候,就将新的key放到原来key的下面组成一个链表,这样就可以将哈希冲突的key正常保存到哈希表中,这种解决哈希冲突的方式就是常用的链式哈希法
  3. size:dictEntry数组的长度。
  4. used:哈希表中的key的数量。
  5. sizemask:哈希表大小掩码,主要用来计算数组下标。

在数据量达到一定数量时,Redis会对哈希表做扩容,然后将原来的key重新经过哈希运算(rehash)后放到扩容后数组的指定位置处。触发rehash的条件有两个,只要满足其中任意一个条件,就会做rehash操作:

  • 负载因子大于等于1,并且Redis服务器没有在执行BGSAVE命令或BGREWRITEAOF命令时,将会执行rehash操作。
  • 负载因子大于等于5时,说明此时该哈希表的哈希冲突已经很严重了,将强制执行rehash操作。

rehash操作:

  1. 给哈希表ht[1]分配空间,分配空间的大小为2的n次方中第一个大于ht[0].used * 2的值。
  2. 将ht[0]中的元素经过rehash运算后迁移到ht[1]中;
  3. 迁移完成后,释放ht[0]的空间,并把ht[1]设置为ht[0];
  4. 创建一个新的ht[1]哈希表,为下次扩容做准备。

但当哈希表中数据量很大的时候,rehash动作就会耗费很长的时间,影响使用。针对这一问题,Redis使用渐进式rehash方法解决。

渐进式rehash的意思是,不一次性把所有数据rehash到新的哈希表中,而是在每次对哈希表元素进行增删改查时,顺带着对原哈希表中某个下标处的所有数据rehash到新的哈希表中,然后下次对哈希表增删改查时,再把下一下标处的所有元素rehash到新的哈希表中,直到原哈希表中的所有数据全部rehash完成后,整个渐进式rehash动作结束。这样就巧妙地解决了一次性hash带来的耗时问题。

在渐进式rehash过程中,两个哈希表ht[0]和ht[1]时同时存在的,并且两个哈希表中都会存储一部分的数据,所以,在对哈希表进行查询、修改、删除操作时,会先从ht[0]中查找,找不到就会去ht[1]中查找。但想哈希表中新增数据时,只能添加到ht[1]中,这样才能确保ht[0]中的数据越来越少,可以顺利完成rehash操作。

SortedSet

  • 有序Set。内部维护了一个score的参数来实现。适用于排行榜和带权重的消息队列等场景。和 Set 相比,Sorted Set关联了一个 Double 类型权重的参数 Score,使得集合中的元素能够按照 Score 进行有序排列,Redis 正是通过分数来为集合中的成员进行从小到大的排序。
  • 实现方式:ZSet对象的底层编码有两种:ZIPLIST和SKIPLIST。ZIPLIST我们上面已经介绍过了,这里着重介绍一下SKIPLIST这种数据结构。
  • 使用场景:Sorted Set 可以通过用户额外提供一个优先级(score)的参数来为成员排序,并且是插入有序的,我们可以使用该类型来实现自动排序的功能。例如新闻热搜排行榜,可以给每条新闻设置不同的score来达到自动排序的效果。
SKIPLIST

SKIPLIST,俗称跳表,我们先来看一下什么是跳表。跳表是一个基于有序链表实现的可以快速查找元素的数据结构。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Nz49ZnFv-1684334269438)(调表.png)]

但是插入性能非常差,针对这一问题,SKIPLIST做了优化,它不要求上下层链表的节点个数严格按照1:2的方式实现,而是在添加节点时,算出一个随机数作为该节点的层数。

Redis中的SKIPLIST与上面传统的SKIPLIST类似,不过有些许不同。由于ZSet的特性,要求每个元素除了带有自身的value值以外,还有一个代表排序权重的score属性,所以,Redis封装了自己的SKIPLIST节点,结构中,ele用来保存字符串格式的value值;score保存元素的分数;backward是一个后项指针,指向链表的上一个节点,方便倒序查找,只有最底层的链表是一个双向链表;level[]数组是一个前项指针数组,保存每层链表上的前向指针和跨度,跨度用来计算节点的排名。

Bitmap:位图,可以认为是一个以位为单位数组,数组中的每个单元只能存0或者1,数组的下标在 Bitmap 中叫做偏移量。Bitmap的长度与集合中元素个数无关,而是与基数的上限有关。

Hyperloglog。HyperLogLog 是用来做基数统计的算法,其优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。典型的使用场景是统计独立访客。

Geospatial :主要用于存储地理位置信息,并对存储的信息进行操作,适用场景如定位、附近的人等。

Redis缓存

先更新缓存,再更新数据库;

先更新数据库,再更新缓存;

先删除缓存,再更新数据库;

先更新数据库,再删除缓存。

缓存常用的3种读写策略

Cache Aside Pattern(旁路缓存模式)

Cache Aside Pattern 是我们平时使用比较多的一个缓存读写模式,比较适合读请求比较多的场景。

:先更新 db;然后直接删除 cache 。

:从 cache 中读取数据,读取到就直接返回;cache 中读取不到的话,就从 db 中读取数据返回;再把数据放到 cache 中。

Read/Write Through Pattern(读写穿透)

Read/Write Through Pattern 中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。

写(Write Through):先查 cache,cache 中不存在,直接更新 db。cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。

读(Read Through):从 cache 中读取数据,读取到就直接返回 。读取不到的话,先从 db 加载,写入到 cache 后返回响应。

Write Behind Pattern(异步缓存写入)

Write Behind Pattern 和 Read/Write Through Pattern 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。

但是,两个又有很大的不同:Read/Write Through 是同步更新 cache 和 db,而 Write Behind 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。

很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。

这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。

Write Behind Pattern 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。

给所有的缓存一个失效期

第三种方案可以说是一个大杀器,任何不一致,都可以靠失效期解决,失效期越短,数据一致性越高。但是失效期越短,查数据库就会越频繁。因此失效期应该根据业务来定。

  1. 并发不高的情况:
    读: 读redis->没有,读mysql->把mysql数据写回redis,有的话直接从redis中取;
    写: 写mysql->成功,再写redis;
  2. 并发高的情况:
    读: 读redis->没有,读mysql->把mysql数据写回redis,有的话直接从redis中取;
    写:异步话,先写入redis的缓存,就直接返回;定期或特定动作将数据保存到mysql,可以做到多次更新,一次保存;

写完数据库后

  1. 如果写数据库的值与更新到缓存值是一样的,不需要经过任何的计算,可以马上更新缓存,但是如果对于那种写数据频繁而读数据少的场景并不合适这种解决方案,因为也许还没有查询就被删除或修改了,这样会浪费时间和资源
  2. 如果写数据库的值与更新缓存的值不一致,写入缓存中的数据需要经过几个表的关联计算后得到的结果插入缓存中,那就没有必要马上更新缓存,只有删除缓存即可,等到查询的时候在去把计算后得到的结果插入到缓存中即可。

所以一般的策略是当更新数据时,先删除缓存数据,然后更新数据库,而不是更新缓存,等要查询的时候才把最新的数据更新到缓存

异步更新缓存(基于订阅binlog的同步机制)

MySQL binlog增量订阅消费+消息队列+增量数据更新到redis读Redis

热数据基本都在Redis写

MySQL:增删改都是操作MySQL

更新Redis数据:MySQ的数据操作binlog,来更新到Redis:

  1. 数据操作主要分为两大块:一个是全量(将全部数据一次写入到redis)一个是增量(实时更新)。这里说的是增量,指的是mysql的update、insert、delate变更数据。
  2. 读取binlog后分析 ,利用消息队列,推送更新各台的redis缓存数据。
    • 这样一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新。
    • 其实这种机制,很类似MySQL的主从备份机制,因为MySQL的主备也是通过binlog来实现的数据一致性。
    • 这里可以结合使用canal(阿里的一款开源框架),通过该框架可以对MySQL的binlog进行订阅,而canal正是模仿了mysql的slave数据库的备份请求,使得Redis的数据更新达到了相同的效果。
    • 当然,这里的消息推送工具你也可以采用别的第三方:kafka、rabbitMQ等来实现推送更新Redis。

Redis的过期键删除策略

定时删除

在设置键的过期时间的同时,创建一个定时器,让定时器在键的过期时间来临时,立即执行对键的删除操作。对内存最友好,对 CPU 时间最不友好。

惰性删除

放任键过期不管,但是每次获取键时,都检査键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。对 CPU 时间最优化,对内存最不友好。删除操作只发生在取key时,且只删除当前key,所以对CPU时间占用较少。但这是不够的,因为有过期key,永远不会再访问。若大量key在超出TTL后,很久一段时间内,都没有被获取过,则可能发生内存泄露(无用垃圾占用了大量内存)。

定期删除

每隔一段时间,默认100ms,程序就对数据库进行一次检査,删除里面的过期键。至 于要删除多少过期键,以及要检査多少个数据库,则由算法决定。前两种策略的折中,对 CPU 时间和内存的友好程度较平衡。

  • 通过限制删除操作的时长和频率,来减少删除操作对CPU时间的占用–处理"定时删除"的缺点
  • 定期删除过期key–处理"惰性删除"的缺点

Redis 使用惰性删除定期删除

惰性删除遇到大key

在Redis中,惰性删除(lazy deletion)是指当键过期时,Redis不会立即删除过期的键值对,而是等到有需要访问该键值对时才进行删除。这种方式可以避免在删除大量过期键时对Redis服务器造成压力。

然而,对于大的键值对,如果采用普通的删除方式,可能会占用较长时间,并且会导致Redis服务器阻塞,影响服务的性能。针对这种情况,可以采用以下策略:

  1. 使用UNLINK命令:Redis自4.0起提供了UNLINK命令,该命令能够以非阻塞的方式缓慢逐步的清理传入的Key。我们可以把大的键值对逐步地使用UNLINK命令删除,而不会对Redis服务器造成过大的压力。
  2. 监控内存使用率:如果我们能够及时发现并处理大的键值对,在其引发问题之前将其删除,就可以提高服务的稳定性。我们可以通过监控Redis的内存使用率并设置合理的报警阈值来提醒我们,例如将Redis内存使用率超过70%、Redis内存1小时内增长率超过20%等作为警戒线。
  3. 分散数据:对于数据量较大的键值对,我们可以考虑将其拆分成多个小的键值对进行存储,避免出现大的键值对。同时,我们还可以将不太适合Redis能力的数据存放至其它存储,以减轻Redis服务器的负担。例如,我们可以将大的键值对存储到外部存储系统中,或者使用分布式存储系统进行数据存储和管理。
  4. 使用srem命令逐个删除:如果是大的set类型的键值对,可以使用 sscan 命令来逐个迭代集合中的元素,然后使用 srem 命令逐个删除集合中的元素 [[2]]。

内存淘汰策略

当 redis 的内存空间(maxmemory 参数配置)已经用满时,redis 将根据配置的驱逐策略(maxmemory-policy 参数配置),进行相应的动作。

网上很多资料都是写 6 种,但是其实当前 redis 的淘汰策略已经有 8 种了,多余的两种是 Redis 4.0 新增的,基于 LFU(Least Frequently Used)算法实现的。

  • noeviction:默认策略,不淘汰任何 key,直接返回错误
  • allkeys-lru:在所有的 key 中,使用 LRU 算法淘汰部分 key(最常用)
  • allkeys-lfu:在所有的 key 中,使用 LFU 算法淘汰部分 key,该算法于 Redis 4.0 新增
  • allkeys-random:在所有的 key 中,随机淘汰部分 key
  • volatile-lru:在设置了过期时间的 key 中,使用 LRU 算法淘汰部分 key
  • volatile-lfu:在设置了过期时间的 key 中,使用 LFU 算法淘汰部分 key,该算法于 Redis 4.0 新增
  • volatile-random:在设置了过期时间的 key 中,随机淘汰部分 key
  • volatile-ttl:在设置了过期时间的 key 中,挑选 TTL(time to live,剩余时间)短的 key 淘汰

LRU

LRU的全称为Least Recently Used,翻译出来就是最近最少使用的意思。它是一种内存淘汰算法,当内存不够时,将内存中最久没使用的数据清理掉。LRU算法常用于缓存的淘汰策略。

首先,针对元素更新后需要大量拷贝数据的问题,我们可以使用双向链表这种数据结构来解决,因为双向链表中,每个节点都维护有自己的前置节点和后置节点的信息,当我们需要移动一个元素到其他位置时,只需要更新几个节点的前置节点和后置节点信息即可,其他没有涉及的节点数据不需更新。

然后,针对查询队列元素性能较差的问题,我们可以使用key-value数据结构来解决,双向链表中维护的是我们实际的数据,HashMap中维护的是双向链表中每个节点的key和node之间的关系,这样,我们在查询数据的时候,可以直接根据HashMap中的key找到对应的双向链表中的节点,然后得到节点中的值;并且在更新链表数据时,也只需要更新涉及节点的指向关系即可。

Redis中的LRU

Redis中存储的数据量是非常庞大的,如果要基于常规的LRU算法,就需要把所有的key全部放到这个双向链表中,这样就会导致这个链表非常非常大,不止需要提供更多的内存来存放这个链表结构,而且操作这么庞大的链表的性能也是比较差的。

所以,Redis中的LRU算法是这样实现的:首先定义一个淘汰池,这个淘汰池是一个数组(大小为16),然后触发淘汰时会根据配置的淘汰策略,先从符合条件的key中随机采样选出5(可在配置文件中配置)个key,然后将这5个key按照空闲时间排序后放到淘汰池中,每次采样之后更新这个淘汰池,让这个淘汰池里保留的总是那些随机采样出的key中空闲时间最长的那部分key。需要删除key时,只需将淘汰池中空闲时间最长的key删掉即可。

redis淘汰策略配置

在配置文件redis.conf 中,可以通过参数 maxmemory 来设定最大内存:当数据内存达到 maxmemory 时,便会触发redis的内存淘汰策略。该参数通常设定为其物理内存的四分之三。

缓存穿透

描述

访问一个缓存和数据库都不存在的 key,此时会直接打到数据库上,并且查不到数据,没法写缓存,所以下一次同样会打到数据库上。此时,缓存起不到作用,请求每次都会走到数据库,流量大时数据库可能会被打挂。此时缓存就好像被“穿透”了一样,起不到任何作用。

解决方案

  1. **接口校验。**在正常业务流程中可能会存在少量访问不存在 key 的情况,但是一般不会出现大量的情况,所以这种场景最大的可能性是遭受了非法攻击。可以在最外层先做一层校验:用户鉴权、数据合法性校验等,例如商品查询中,商品的ID是正整数,则可以直接对非正整数直接过滤等等。
  2. **缓存空值。**当访问缓存和DB都没有查询到值时,可以将空值写进缓存,但是设置较短的过期时间,该时间需要根据产品业务特性来设置。
  3. **布隆过滤器。**使用布隆过滤器存储所有可能访问的 key,不存在的 key 直接被过滤,存在的 key 则再进一步查询缓存和数据库。

布隆过滤器

布隆过滤器的特点是判断不存在的,则一定不存在;判断存在的,大概率存在,但也有小概率不存在。并且这个概率是可控的,我们可以让这个概率变小或者变高,取决于用户本身的需求。

布隆过滤器(Bloom Filter)是一种用于快速判定一个元素是否在一个集合中的空间效率极高的数据结构,它可以大大节省内存,但存在一定的误判率。

布隆过滤器原理:

  1. 首先由一个位数组和k个不同的哈希函数组成;
  2. 初始化时,将位数组全部置为0;
  3. 添加元素时,用k个哈希函数对该元素散列,得到k个哈希值,分别对位数组的对应位置设置为1;
  4. 判断元素是否存在时,将元素散列得到k个哈希值,看对应的k个位置是否都是1。若其中有一个或多个位置为0,则肯定不在集合中;若全部为1,则可能在集合中(存在误判)。

优点:

  1. 空间效率非常高,实现简单,只需要一个长度为m的二进制向量和k个哈希函数;
  2. 查询时间远远小于其他数据结构,特别是与磁盘或数据库等的查询相比;
  3. 可以处理大规模数据,且查询时间不会随着数据规模增加而增加。

缺点:

  1. 会存在一定的误判率,随着集合元素数量增加和哈希函数数量增加,误判率会逐渐增加,但这个误判率可以预期,并可以通过调整位数组长度和哈希函数数量得以缓解;
  2. 删除操作需要实现相对困难,因为一旦存在与其它元素重复的哈希值,就无法确定是否将位数组中的值进行清空。

缓存雪崩

描述

大量的热点 key 设置了相同的过期时间,导在缓存在同一时刻全部失效,造成瞬时数据库请求量大、压力骤增,引起雪崩,甚至导致数据库被打挂。缓存雪崩其实有点像“升级版的缓存击穿”,缓存击穿是一个热点 key,缓存雪崩是一组热点 key。

解决方案

  1. **过期时间打散。**既然是大量缓存集中失效,那最容易想到就是让他们不集中生效。可以给缓存的过期时间时加上一个随机值时间,使得每个 key 的过期时间分布开来,不会集中在同一时刻失效。
  2. **热点数据不过期。**该方式和缓存击穿一样,也是要着重考虑刷新的时间间隔和数据异常如何处理的情况。
  3. **加互斥锁。**该方式和缓存击穿一样,按 key 维度加锁,对于同一个 key,只允许一个线程去计算,其他线程原地阻塞等待第一个线程的计算结果,然后直接走缓存即可。

缓存击穿

描述

某一个热点 key,在缓存过期的一瞬间,同时有大量的请求打进来,由于此时缓存过期了,所以请求最终都会走到数据库,造成瞬时数据库请求量大、压力骤增,甚至可能打垮数据库。

解决方案

  1. **加互斥锁。**在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。

  2. 热点数据不过期。直接将缓存设置为不过期,然后由定时任务去异步加载数据,更新缓存。

    这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,不要到时候缓存刷新不上,一直是脏数据,那就凉了。

Redis 的持久化机制

RDB持久化

RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。快照文件称为RDB文件,默认是保存在当前运行目录。

RDB持久化在四种情况下会执行:

  • 执行save命令:save命令会导致主进程执行RDB,这个过程中其它所有命令都会被阻塞。只有在数据迁移时可能用到。
  • 执行bgsave命令:fork 子进程来生成 RDB 快照文件,主进程可以持续处理用户请求,不受影响。
  • Redis停机时:Redis停机时会执行一次save命令,实现RDB持久化。
  • 触发RDB条件时:Redis内部有触发RDB的机制,可以在redis.conf文件中找到

fork

在 Linux 系统中,调用 fork() 时,会创建出一个新进程,称为子进程,子进程会拷贝父进程的 page table。如果进程占用的内存越大,进程的 page table 也会越大,那么 fork 也会占用更多的时间。如果 Redis 占用的内存很大,那么在 fork 子进程时,则会出现明显的停顿现象。

RDB原理

bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入 RDB 文件。

fork采用的是copy-on-write技术:

  • 当主进程执行读操作时,访问共享内存;
  • 当主进程执行写操作时,则会拷贝一份数据,执行写操作。

RDB的缺点

  • RDB 在服务器故障时容易造成数据的丢失。RDB 允许我们通过修改 save point 配置来控制持久化的频率。但是,因为 RDB 文件需要保存整个数据集的状态, 所以它是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。所以通常可能设置至少5分钟才保存一次快照,这时如果 Redis 出现宕机等情况,则意味着最多可能丢失5分钟数据。
  • RDB 保存时使用 fork 子进程进行数据的持久化,如果数据比较大的话,fork 可能会非常耗时,造成 Redis 停止处理服务N毫秒。如果数据集很大且 CPU 比较繁忙的时候,停止服务的时间甚至会到一秒。

AOF原理

AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。AOF的主要作用是解决了数据持久化的实时性,AOF 是Redis持久化的主流方式。

AOF优点

  1. AOF 比 RDB可靠。你可以设置不同的 fsync 策略:no、everysec 和 always。默认是 everysec,在这种配置下,redis 仍然可以保持良好的性能,并且就算发生故障停机,也最多只会丢失一秒钟的数据。
  2. AOF文件是一个纯追加的日志文件。即使日志因为某些原因而包含了未写入完整的命令(比如写入时磁盘已满,写入中途停机等等), 我们也可以使用 redis-check-aof 工具也可以轻易地修复这种问题。
  3. 当 AOF文件太大时,Redis 会自动在后台进行重写:重写后的新 AOF 文件包含了恢复当前数据集所需的最小命令集合。整个重写是绝对安全,因为重写是在一个新的文件上进行,同时 Redis 会继续往旧的文件追加数据。当新文件重写完毕,Redis 会把新旧文件进行切换,然后开始把数据写到新文件上。
  4. AOF 文件有序地保存了对数据库执行的所有写入操作以 Redis 协议的格式保存, 因此 AOF 文件的内容非常容易被人读懂, 对文件进行分析(parse)也很轻松。如果你不小心执行了 FLUSHALL 命令把所有数据刷掉了,但只要 AOF 文件没有被重写,那么只要停止服务器, 移除 AOF 文件末尾的 FLUSHALL 命令, 并重启 Redis , 就可以将数据集恢复到 FLUSHALL 执行之前的状态。

AOF重写

AOF 持久化是通过保存被执行的写命令来记录数据库状态的,随着写入命令的不断增加,AOF 文件中的内容会越来越多,文件的体积也会越来越大。为了处理这种情况, Redis 引入了 AOF 重写:可以在不打断服务端处理请求的情况下, 对 AOF 文件进行重建(rebuild)。

RDB与AOF对比

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dYXPXC8L-1684334269439)(RDB与AOF对比.png)]

RDB和AOF如何选择?

通常来说,应该同时使用两种持久化方案,以保证数据安全。

  • 如果数据不敏感,且可以从其他地方重新生成,可以关闭持久化。
  • 如果数据比较重要,且能够承受几分钟的数据丢失,比如缓存等,只需要使用RDB即可。
  • 如果是用做内存数据,要使用Redis的持久化,建议是RDB和AOF都开启。
  • 如果只用AOF,优先使用everysec的配置选择,因为它在可靠性和性能之间取了一个平衡。

当RDB与AOF两种方式都开启时,Redis会优先使用AOF恢复数据,因为AOF保存的文件比RDB文件更完整。

Redis主从

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。

全量同步

主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点,流程:

  • slave节点请求增量同步
  • master节点判断replid,发现不一致,拒绝增量同步
  • master将完整内存数据生成RDB,发送RDB到slave
  • slave清空本地数据,加载master的RDB
  • master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
  • slave执行接收到的命令,保持与master之间的同步

增量同步

除了第一次做全量同步,其它大多数时候slave与master都是做增量同步。什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2kHiZt5p-1684334269440)(增量同步.png)]

原理——repl_baklog文件

repl_baklog文件:这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。

  • repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset,和slave已经拷贝到的offset
  • slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset。直到数组被填满。
  • 此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是数组前面的部分。
  • 但是repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步。

主从复制问题

  • 一旦主节点宕机,从节点晋升为主节点,同时需要修改应用方的主节点地址,还需要命令所有从节点去复制新的主节点,整个过程需要人工干预。
  • 主节点的写能力受到单机的限制。
  • 主节点的存储能力受到单机的限制。
  • 原生复制的弊端在早期的版本中也会比较突出,比如:Redis 复制中断后,从节点会发起 psync。
    此时如果同步不成功,则会进行全量同步,主库执行全量备份的同时,可能会造成毫秒或秒级的卡顿。

主从同步优化

可以从以下几个方面来优化Redis主从就集群:

  • 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。
  • Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO
  • 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
  • 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力

Redis哨兵

哨兵的作用

  • 监控:Sentinel 会不断检查您的master和slave是否按预期工作
  • 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
  • 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端

集群监控

Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:

•主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线

•客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

集群故障恢复

一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:

  • 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
  • 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
  • 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
  • 最后是判断slave节点的运行id大小,越小优先级越高。

故障转移步骤有哪些?

  • 首先选定一个slave作为新的master,执行slaveof no one
  • 然后让所有节点都执行slaveof 新master
  • 修改故障节点配置,添加slaveof 新master

特点

  • 哨兵模式是基于主从模式的,所有主从的优点,哨兵模式都有;
  • 主从可以自动切换,系统更健壮,可用性更高;
  • Sentinel 会不断地检查你的主服务器和从服务器是否运作正常。当被监控的某个 Redis 服务器出现问题时, Sentinel 可以通过 API 向管理员或者其他应用程序发送通知。
  • 主从切换需要时间,会丢失数据; 还是没有解决主节点写的压力;
  • 主节点的写能力,存储能力受到单机的限制;
  • 动态扩容困难复杂,对于集群,容量达到上限时在线扩容会变得很复杂。

Redis分片

解决:

  • 海量数据存储问题

  • 高并发写的问题

分片集群特征

  • 集群中有多个master,每个master保存不同数据

  • 每个master都可以有多个slave节点

  • master之间通过ping监测彼此健康状态

  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

原理

Redis cluster采用虚拟槽分区,所有的键根据哈希函数映射到0~16383个整数槽内,每个节点负责维护一部分槽以及槽所映射的键值数据。哈希槽是如何映射到 Redis 实例上的?

  1. 对键值对的key使用 crc16 算法计算一个结果
  2. 将结果对 16384 取余,得到的值表示 key 对应的哈希槽
  3. 根据该槽信息定位到对应的实例

优点:

  • 无中心架构,支持动态扩容;
  • 数据按照slot存储分布在多个节点,节点间数据共享,可动态调整数据分布
  • 高可用性。部分节点不可用时,集群仍可用。集群模式能够实现自动故障转移(failover),节点之间通过gossip协议交换状态信息,用投票机制完成SlaveMaster的角色转换。

缺点:

  • 不支持批量操作(pipeline)。
  • 数据通过异步复制,不保证数据的强一致性
  • 事务操作支持有限,只支持多key在同一节点上的事务操作,当多个key分布于不同的节点上时无法使用事务功能。
  • key作为数据分区的最小粒度,不能将一个很大的键值对象如hashlist等映射到不同的节点。
  • 不支持多数据库空间,单机下的Redis可以支持到16个数据库,集群模式下只能使用1个数据库空间。

多级缓存

传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,但是请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈,而且Redis缓存失效时,会对数据库产生冲击。

多级缓存架构

多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:

  • 浏览器访问静态资源时,优先读取浏览器本地缓存
  • 访问非静态资源(ajax查询数据)时,访问服务端
  • 请求到达Nginx后,优先读取Nginx本地缓存
  • 如果Nginx本地缓存未命中,则去直接查询Redis(不经过Tomcat)
  • 如果Redis查询未命中,则查询Tomcat
  • 请求进入Tomcat后,优先查询JVM进程缓存
  • 如果JVM进程缓存未命中,则查询数据库

在多级缓存架构中,Nginx内部需要编写本地缓存查询、Redis查询、Tomcat查询的业务逻辑,因此这样的nginx服务不再是一个反向代理服务器,而是一个编写业务的Web服务器了。因此这样的业务Nginx服务也需要搭建集群来提高并发,再有专门的nginx服务来做反向代理;另外,我们的Tomcat服务将来也会部署为集群模式

Redis事务

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis事务没有隔离级别的概念

批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。

Redis不保证原子性

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

相关命令

watch key1 key2: 监视一或多个key,如果在事务执行之前,被监视的key被其他命令改动,则事务被打断 ( 类似乐观锁 )
multi: 			标记一个事务块的开始( queued ),执行之前,存在监控
exec: 			执行所有事务块的命令 ( 一旦执行exec后,之前加的监控锁都会被取消掉 ) 
discard: 		取消事务,放弃事务块中的所有命令
unwatch:		取消watch对所有key的监控

KEYS/SMEMBERS命令

Redis Keys 命令用于查找所有符合给定模式 pattern 的 key 。

Keys模糊匹配,会引发Redis锁,并且增加Redis的CPU占用,情况是很恶劣的。

如果有这种需求的话可以自己对键值做索引,比如把各种键值存到不同的set里面,分类建立索引,这样就可以很快的得到数据,但是这样也存在一个明显的缺点,就是浪费宝贵的空间,要知道这可是内存空间啊,所以还是要合理考虑,当然也可以想办法,比如对于有规律的键值,可以存储他们的始末值等等。

keys命令优点: 花的时间短

keys命令的缺点:这个命令会阻塞redis多路复用的io主线程,如果这个线程阻塞,在此执行之间其他的发送向redis服务端的命令,都会阻塞,从而引发一系列级联反应,导致瞬间响应卡顿,从而引发超时等问题,所以应该在生产环境禁止用使用keys和类似的命令smembers,这种时间复杂度为O(N),且会阻塞主线程的命令,是非常危险的。

代替——SCAN

Redis Scan 命令用于迭代数据库中的数据库键。

SCAN cursor [MATCH pattern] [COUNT count]
  • SCAN 命令是一个基于游标的迭代器,每次被调用之后, 都会向用户返回一个新的游标, 用户在下次迭代时需要使用这个新游标作为 SCAN 命令的游标参数, 以此来延续之前的迭代过程。
  • SCAN 返回一个包含两个元素的数组, 第一个元素是用于进行下一次迭代的新游标, 而第二个元素则是一个数组, 这个数组中包含了所有被迭代的元素。如果新游标返回 0 表示迭代已结束。

优缺点:

  • 对比KEYS命令,虽然SCAN无法一次性返回所有匹配结果,但是却规避了阻塞系统这个高风险,从而也让一些操作可以放在主节点上执行。
  • 因为是分段获取key,所以它会多次请求redis服务器,这样势必取同样的key,scan耗时更长。
  • 在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证。

Redis使用场景

缓存——热数据

作为Key-Value形态的内存数据库,Redis 最先会被想到的应用场景便是作为数据缓存。而使用 Redis 缓存数据非常简单,只需要通过string类型将序列化后的对象存起来即可,不过也有一些需要注意的地方:

  • 必须保证不同对象的 key 不会重复,并且使 key 尽量短,一般使用类名(表名)加主键拼接而成。
  • 选择一个优秀的序列化方式也很重要,目的是提高序列化的效率和减少内存占用。
  • 缓存内容与数据库的一致性,这里一般有两种做法:
    • 只在数据库查询后将对象放入缓存,如果对象发生了修改或删除操作,直接清除对应缓存(或设为过期)。
    • 在数据库新增和查询后将对象放入缓存,修改后更新缓存,删除后清除对应缓存(或设为过期)。

计数器

计数功能应该是最适合 Redis 的使用场景之一了,因为它高频率读写的特征可以完全发挥 Redis 作为内存数据库的高效。在 Redis 的数据结构中,stringhashsorted set都提供了incr方法用于原子性的自增操作,由于单线程,可以避免并发问题,保证不会出错,而且100%毫秒级性能。

队列

相当于消息系统,与ActiveMQ,RocketMQ等工具类似,但是觉得简单用一下还行,如果对于数据一致性要求高的话还是用RocketMQ等专业系统。

使用一个列表,让生产者将任务使用LPUSH命令放进列表,消费者不断用RPOP从列表取出任务。由于redis把数据添加到队列是返回添加元素在队列的第几位,所以可以做判断用户是第几个访问这种业务。队列不仅可以把并发请求变成串行,并且还可以做队列或者栈使用。

位操作(大数据处理)

用于数据量上亿的场景下,例如几亿用户系统的签到,去重登录次数统计,某用户是否在线状态等等。腾讯10亿用户,要几个毫秒内查询到某个用户是否在线,能怎么做?

千万别说给每个用户建立一个key,然后挨个记(你可以算一下需要的内存会很恐怖,而且这种类似的需求很多。这里要用到位操作——使用setbit、getbit、bitcount命令。原理是:

redis内构建一个足够长的数组,每个数组元素只能是0和1两个值,然后这个数组的下标index用来表示用户id(必须是数字哈),那么很显然,这个几亿长的大数组就能通过下标和元素值(0和1)来构建一个记忆系统。

最新列表

例如新闻列表页面的最新的新闻列表,如果总数量很大的情况下,尽量不要使用select a from A limit 10这种效率低的操作,尝试redis的 LPUSH命令构建List,一个个顺序都塞进去就可以啦。不过万一内存清掉了咋办?也简单,查询不到存储key的话,用mysql查询并且初始化一个List到redis中就好了。

排行榜

使用sorted set(有序set)和一个计算热度的算法便可以轻松打造一个热度排行榜,zrevrange by score可以得到以分数倒序排列的序列,zrank可以得到一个成员在该排行榜的位置(是分数正序排列时的位置,如果要获取倒序排列时的位置需要用zcard-zrank)。

Redis中的Sorted Set(有序集合)数据结构天然适合实现排行榜。因为Sorted Set内部使用跳表和哈希表等数据结构实现,每个元素都会被赋予一个分数(score),而Sorted Set会按照分数从小到大排序,如果分数相同,再按照元素值(member)的字典序排序。因此只需要将每个用户的得分作为元素的分数,将用户id作为元素的member,就可以使用Redis实现一个排行榜。以下是实现步骤:

  1. 使用zadd命令添加/更新当前用户的得分:
zadd rank_key score user_id

其中,rank_key是排行榜的名称,score为用户的得分,user_id为用户的唯一标识。

  1. 如果要展示整个排行榜,则使用zrevrange命令获取排行榜前N名的用户信息:
zrevrange rank_key 0 N WITHSCORES

其中,WITHSCORES参数表示同时显示用户的得分。

  1. 如果想查询某个特定用户的排名,则使用zrevrank命令获取该用户在排行榜中的排名:
zrevrank rank_key user_id
  1. 如果想查询某个特定用户的得分,则使用zscore命令获取该用户的得分:
zscore rank_key user_id

需要注意的是,如果在使用Redis Sorted Set实现排行榜时需要处理并发请求,可能需要加上分布式锁来保证操作的原子性。

分布式锁

Redis可以使用它的原子操作实现分布式锁,常见的方式是使用SETNX命令,即"SET if Not eXists"。此外,还可以结合使用SET和EXPIRE命令来实现锁的自动过期。以下是基本的实现步骤:

  1. 客户端向Redis中尝试设置一个key-value对,其中key表示锁的名称,value可以为任何值,但建议使用随机字符串等较为安全的方式。
SETNX lock_key random_value
  1. 判断SETNX的返回值,如果返回1,表示设置成功,加锁成功。否则,表示锁已经被其他客户端持有,加锁失败。可以采用循环重试等方式来保证加锁的成功性。

  2. 如果加锁成功,为了防止锁被一直占用而导致死锁,可以为该锁设置一个合适的过期时间,例如10秒钟:

EXPIRE lock_key 10
  1. 在完成加锁后,后续的业务逻辑需要记得要释放锁,以免锁一直被某个客户端持有。可以使用DEL命令来删除对应的key:
DEL lock_key

需要注意的是,由于Redis是单线程的,如果多个客户端同时尝试获取同一个key的锁,Redis会按照先后顺序依次执行它们的操作,因此可以保证锁的正确性。如果需要加强锁的安全性,可以考虑使用Redlock分布式锁算法

在Redis分布式环境中,我们假设有N个Redis Master节点。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。使用传统的SETNX命令等方式实现分布式锁时,可能会存在脑裂(split-brain)等问题,即当某个Master节点与其他节点失去联系时,会形成独立的小集群,导致多个客户端同时获取到了锁,而本应只有其中一个客户端能够获取成功。

Redlock通过加入多个Redis Master节点,并对节点进行加锁和解锁操作,来提高分布式锁的安全性和可靠性。具体实现方式如下:

  1. 客户端向N个Redis Master节点尝试加锁,每个节点的锁的名称和值都应该相同,相互之间没有主从关系。
  2. 对于每个加锁请求,使用一定的超时时间来避免锁被某个节点独占。如果在超时时间内有至少N/2+1个节点都成功加锁,则认为加锁成功;否则认为加锁失败。
  3. 在解锁时,需要向所有加锁成功的节点发送解锁请求,如果解锁成功,则认为整个分布式锁的解锁成功;否则认为解锁失败。

由于加锁和解锁操作需要在多个Redis Master节点之间进行协调,因此Redlock分布式锁算法相比传统的分布式锁实现,虽然复杂度更高一些,但也能提供更高层次的安全性和可靠性保障。

Redis 线程

单线程

在 redis 6.0 之前,redis 的核心操作是单线程的。

因为 redis 是完全基于内存操作的,通常情况下CPU不会是redis的瓶颈,redis 的瓶颈最有可能是机器内存的大小或者网络带宽。

既然CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了,因为如果使用多线程的话会更复杂,同时需要引入上下文切换、加锁等等,会带来额外的性能消耗。

而随着近些年互联网的不断发展,大家对于缓存的性能要求也越来越高了,因此 redis 也开始在逐渐往多线程方向发展。

多线程

redis 4.0 时,redis 引入了多线程,但是额外的线程只是用于后台处理,例如:删除对象,核心流程还是完全单线程的。这也是为什么有些人说 4.0 是单线程的,因为他们指的是核心流程是单线程的。

而在最近,redis 6.0 版本又一次引入了多线程概念,与 4.0 不同的是,这次的多线程会涉及到上述的核心流程。redis 6.0 中,多线程主要用于网络 I/O 阶段,也就是接收命令和写回结果阶段,而在执行命令阶段,还是由单线程串行执行。由于执行时还是串行,因此无需考虑并发安全问题。

Redis 速度快的原因

主要有以下几点:

1、基于内存的操作

2、使用了 I/O 多路复用模型,select、epoll 等,基于 reactor 模式开发了自己的网络事件处理器

3、单线程可以避免不必要的上下文切换和竞争条件,减少了这方面的性能消耗。

4、以上这三点是 redis 性能高的主要原因,其他的还有一些小优化,例如:对数据结构进行了优化,简单动态字符串、压缩列表等。

Redis 的网络事件处理器

redis 基于 reactor 模式开发了自己的网络事件处理器,由4个部分组成:套接字、I/O 多路复用程序、文件事件分派器(dispatcher)、以及事件处理器。

套接字

socket 连接,也就是客户端连接。当一个套接字准备好执行连接、写入、读取、关闭等操作时, 就会产生一个相应的文件事件。因为一个服务器通常会连接多个套接字, 所以多个文件事件有可能会并发地出现。

I/O 多路复用程序

提供 select、epoll、evport、kqueue 的实现,会根据当前系统自动选择最佳的方式。负责监听多个套接字,当套接字产生事件时,会向文件事件分派器传送那些产生了事件的套接字。当多个文件事件并发出现时, I/O 多路复用程序会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序、同步、每次一个套接字的方式向文件事件分派器传送套接字:当上一个套接字产生的事件被处理完毕之后,才会继续传送下一个套接字。

文件事件分派器

接收 I/O 多路复用程序传来的套接字, 并根据套接字产生的事件的类型, 调用相应的事件处理器。

事件处理器

事件处理器就是一个个函数, 定义了某个事件发生时, 服务器应该执行的动作。例如:建立连接、命令查询、命令写入、连接关闭等等。

redis网络IO

这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路I/O

复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗),且Redis在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis具有很高的吞吐量。因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,所以采用单线程

redis实现分布式锁的缺点

非原子操作

加锁操作和后面的设置超时时间是分开的,并非原子操作。假如加锁成功,但是设置超时时间失败了,该lockKey就变成永不失效。假如在高并发场景中,有大量的lockKey加锁成功了,但不会失效,有可能直接导致redis内存空间不足。

解决方法:

而在redis中还有set命令,该命令可以指定多个参数。set命令是原子操作,加锁和设置超时时间,一个命令就能轻松搞定。

  • lockKey:锁的标识
  • requestId:请求id
  • NX:只在键不存在时,才对键进行设置操作。
  • PX:设置键的过期时间为 millisecond 毫秒。
  • expireTime:过期时间

释放了别人的锁

在多线程场景中,可能会出现释放了别人的锁的情况。假如线程A和线程B,都使用lockKey加锁。线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间。这时候,redis会自动释放lockKey锁。此时,线程B就能给lockKey加锁成功了,接下来执行它的业务操作。恰好这个时候,线程A执行完了业务功能,接下来,在finally方法中释放了锁lockKey。这不就出问题了,线程B的锁,被线程A释放了。

解决方法:

在使用set命令加锁时,除了使用lockKey锁标识,还多设置了一个参数:requestId,在释放锁的时候,先获取到该锁的值(之前设置值就是requestId),然后判断跟之前设置的值是否相同,如果相同才允许删除锁,返回成功。如果不同,则直接返回失败。

或者使用lua脚本,也能解决释放了别人的锁的问题,lua脚本能保证查询锁是否存在和删除锁是原子操作,用它来释放锁效果更好一些。redisson框架就是使用了lua脚本。

大量失败请求

如果有1万的请求同时去竞争那把锁,可能只有一个请求是成功的,其余的9999个请求都会失败。在秒杀场景下,会有什么问题?每1万个请求,有1个成功。再1万个请求,有1个成功。如此下去,直到库存不足。这就变成均匀分布的秒杀了,跟我们想象中的不一样。

解决方法

自旋锁

  • 如果锁被占用的时间很短,自旋等待的效果就会非常好;
  • 如果锁被占用的时间很长,那么自旋的线程只会白浪费处理器资源。

所以,自旋等待的时间必须要有一定的限度,如果自旋超过了限定次数没有成功获得锁,就应当挂起线程。

锁重入问题

假如我们对某个key加锁了,如果该key对应的锁还没失效,再用相同key去加锁,大概率会失败。

锁竞争问题

锁的粒度越粗,多个线程抢锁时竞争就越激烈,造成多个线程锁等待的时间也就越长,性能也就越差。

锁超时问题

如果线程A加锁成功了,但是由于业务功能耗时时间很长,超过了设置的超时时间,这时候redis会自动释放线程A加的锁。

主从复制的问题

终极解决方案:使用redisson分布式锁,注意解锁之前是否本线程所持有

Redisson

Redis设计索引

Redis有广泛应用场景,比如在分布式应用中,当你定义的数据结构(e.g List, Set, Hash)被多个线程或者多个进程同时修改时就不安全了,需要加锁避免脏数据的产生。如果将这些自定义的复杂数据结构放在Redis进行存储与管理,则每个操作都能保证是原子的,Redis保证了无竞争的并发访问。

维护这样的效率背后是付出了一定的代价:**Redis缺少了在传统关系数据库系统中常见的特性,最典型的是“二级索引”。**二级索引允许用户在不修改数据模型的情况下高效地查询数据。

Redis Key设计原则

Redis的一个局限性是它没有表空间和表的概念。**如果不同的实体共享一个共同的ID,就可能出现问题,比如由于覆盖而导致数据的丢失。**因此,为键引入层次的命名方案是一个好的实践。一个常见的模式是在键前面加入实体名称作为前缘。

  • 第一部分:将上面MySQL表名转为Redis Key的前缀 (users)
  • 第二部分:MySQL表中主键列引入 (users:user_id)
  • 第三部分:MySQL表中主键列的值引入 (users:user_id:1)
  • 第四部分:MySQL表中索引列引入 (user:user_id:1:age)。此处,我们需要对age列建立一个索引。

Redis建立二级索引模型

Redis支持多种数据结构,包含有序集合set. 它有二个显著的特性,可以选择成为Redis的二级索引

  • set集合的成员元素项保证唯一。
  • 每个成员元素项可以指定一个分数score。支持范围查询。

假如你希望根据用户的年龄来实现高效查询。可以使用Redis set进行二级索引的创建。

zadd users_index:age 60 1 (60,1)->(年龄,用户id)

要查询用户记录时,可以使用zrangebyscore命令,此命令允许查询指定年龄区间的用户集合。

如何维护二级索引?

对于Redis中的二级索引的更新维护,需要在应用程序级别进行处理。使用Redis事务

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值