Redis相关面试题

redis 作为我们最常用的内存数据库,很多地方你都能够发现它的身影,比如说登录信息的存储,分布式锁的使用,其经常被我们当做缓存去使用。

PS:都是微信公众号找来的,方便上班背八股文。


1.什么是 redis?它能做什么?

图片

redis: redis 即 Remote Dictionary Server,用中文翻译过来可以理解为远程数据服务或远程字典服务。其是使用 C 语言的编写的key-value存储系统

Redis 是一种开源的、高性能的基于内存的键值对(key-value)数据库,被数百万开发人员用作缓存、消息代理等。它支持多种数据类型(如 String(字符串)、Hash(哈希)、 List (列表)、Set(集合)、Sorted Set(有序集合)、JSON、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流)等)以及丰富的数据操作命令,能够满足各种复杂的应用场景需求。

Redis的设计理念强调速度与灵活性,其核心特性包括:

  1. 内存存储:数据主要存储在内存中,提供亚毫秒级的读写速度,显著提升应用程序的性能。并支持内存淘汰、过期删除机制。根据Redis官方文档的测试数据[1],Redis可以支持每秒10W次请求。

  2. 持久化:支持 RDB 和 AOF 两种持久化机制,确保在服务器重启或故障时数据不会丢失。

  3. 数据结构丰富高效:除了基本的字符串类型外,还支持哈希表、列表、集合、有序集合等数据结构,方便构建复杂的数据模型和实现高效查询。

  4. 事务支持:提供简单的多条命令一次性、原子性执行的功能,保证数据一致性。

  5. 发布/订阅:内置消息队列功能,支持发布与订阅模式的消息传递。

  6. 分布式特性:可通过主从复制实现数据冗余和读写分离,通过哨兵机制(Sentinel)、切片集群(Redis Cluster)等实现数据分片和高可用性。

应用场景:缓存,数据库,消息队列,分布式锁,点赞列表,排行榜等等

2.redis 有哪八种数据类型?有哪些应用场景?

redis 总共有八种数据结构,五种基本数据类型和三种特殊数据类型

图片

五种基本数据类型:

  • 1.string:字符串类型,常被用来存储计数器,粉丝数等,简单的分布式锁也会用到该类型

  • 2.hashmap:key - value 形式的,value 是一个map

  • 3.list:基本的数据类型,列表。在 Redis 中可以把 list 用作栈、队列、阻塞队列。

  • 4.set:集合,不能有重复元素,可以做点赞,收藏等

  • 5.zset:有序集合,不能有重复元素,有序集合中的每个元素都需要指定一个分数,根据分数对元素进行升序排序。可以做排行榜

    图片

    三种特殊数据类型:

  • 1.geospatial: Redis 在 3.2 推出 Geo 类型,该功能可以推算出地理位置信息,两地之间的距离

  • 2.hyperloglog:基数:数学上集合的元素个数,是不能重复的。这个数据结构常用于统计网站的 UV

  • 3.bitmap: bitmap 就是通过最小的单位 bit 来进行0或者1的设置,表示某个元素对应的值或者状态。一个 bit 的值,或者是0,或者是1;也就是说一个 bit 能存储的最多信息是2。bitmap 常用于统计用户信息比如活跃粉丝和不活跃粉丝、登录和未登录、是否打卡等

Redis作为一个内存数据库系统,提供了丰富且高效的内存数据结构,包括字符串(String)、列表(List)、集合(Set)、有序集合(Sorted Set)、哈希(Hash)等。这些数据结构不仅具有简单易用的特点,还能够在内存中高效地存储和操作数据,为Redis的快速性能提供了坚实的基础。

Redis五种数据结构

动态字符串

动态字符串是一种能够动态扩展长度的字符串实现方式。在许多编程语言和数据结构中都有类似的实现,如C语言中的动态数组(dynamic array)。而SDS是Redis中的一种简单动态字符串结构,它是一种动态大小的字节数组,用于存储和操作字符串数据。SDS是Redis内部数据结构的基础,也是字符串数据结构的底层实现。它的结构如下:

/*
 * redis中保存字符串对象的结构
 */
struct sdshdr {
    //用于记录buf数组中使用的字节的数目,和SDS存储的字符串的长度相等 
    int len;
    //用于记录buf数组中没有使用的字节的数目 
    int free;
    //字节数组,用于储存字符串
    char buf[]; //buf的大小等于len+free+1,其中多余的1个字节是用来存储’\0’的
};

C语言中传统字符串是使用长度为N+1的字符数组来表示长度为 的字符串,并且字符串数组的最后一个元素总是空字符'\0'

图片

image.png

如果我们想要获取上述CODERACADEMY的长度,我们需要从头开始遍历,直到遇到 '\0' 为止。

而Redis的SDS的数据结构使用一个len字段记录当前字符串的长度,使用free表示空闲的长度。想要获取长度只需要获取len字段即可。

图片

image.png

我们可以看出C语言获取字符串长度的时间复杂度为O(N),而SDS获取字符串长度的时间复杂度为O(1)。除此之外,SDS相对于C语言字符串还有如下区别:

特征C语言字符串SDS
类型静态字符数组动态字符串结构
内存管理需手动分配和释放内存自动扩展和释放内存
存储空间需要提前预留足够的空间根据需要动态调整大小
长度计算需要遍历整个字符串计算长度O(1)复杂度直接获取字符串长度
二进制安全不二进制安全二进制安全
缓冲区溢出保护不提供缓冲区溢出保护提供缓冲区溢出保护
操作复杂度操作复杂度随字符串长度增加而增加操作复杂度不受字符串长度影响
可拓展性不易扩展,需要手动处理内存扩展自动扩展,支持动态调整大小

细说下来,SDS相对于C语言字符串有如下优点:

  1. 二进制安全: SDS可以存储任意二进制数据,而不仅仅是文本字符串。这意味着SDS可以存储包括图片、视频、音频等在内的各种二进制数据,而不会受到特殊字符或者空字符的限制,具有更广泛的适用性。

  2. 动态扩展: SDS的大小可以根据存储的字符串长度动态调整,可以根据实际需要动态分配和释放内存空间。这种动态扩展的能力使得SDS能够处理任意长度的字符串数据,而不受到固定大小的限制。

  3. O(1)复杂度的操作: SDS支持常数时间复杂度的操作,包括添加字符、删除字符、修改字符等。无论字符串的长度是多少,这些操作的时间开销都是固定的,具有高效的性能。

  4. 缓冲区溢出保护: SDS在存储字符串时,会自动添加一个空字符('\0')作为字符串的结束标志,保证字符串的有效性和安全性。这种缓冲区溢出保护能够防止缓冲区溢出的问题,提高了系统的稳定性和安全性。

  5. 惰性空间释放: 当SDS缩短字符串时,并不会立即释放多余的空间,而是将多余的空间保留下来,以备后续的再利用。这种惰性空间释放的策略可以减少内存分配和释放的开销,提高内存利用率。

这些优点使得SDS在Redis中被广泛应用于存储和操作字符串数据,为Redis的高性能和高可靠性提供了坚实的基础。

双端链表

Redis中的双端链表是一种经过优化的数据结构,用于存储有序的元素集合。它具有双向链接的特性,每个节点都包含指向前一个节点和后一个节点的指针。

图片

image.png

双端链表中的节点是链表的基本构建单元,它存储了链表中的数据元素以及指向前一个节点和后一个节点的指针。在Redis中,双端链表节点的定义通常如下:

typedef struct listNode {
    struct listNode *prev;  // 指向前一个节点的指针
    struct listNode *next;  // 指向后一个节点的指针
    void *value;            // 存储的数据元素
} listNode;

双端链表中的节点包含了以下几个关键属性:

  1. prev指针prev指针是指向前一个节点的指针,它指向链表中当前节点的前一个节点。如果当前节点是链表的头节点,则prev指针为NULL。通过prev指针,可以在双端链表中方便地向前遍历节点。

  2. next指针next指针是指向后一个节点的指针,它指向链表中当前节点的后一个节点。如果当前节点是链表的尾节点,则next指针为NULL。通过next指针,可以在双端链表中方便地向后遍历节点。

  3. value数据域value数据域用于存储链表节点所包含的数据元素。这个数据元素可以是任意类型的数据,因此在Redis中的双端链表中,通常使用void *类型来表示。这种设计使得双端链表可以存储任意类型的数据元素。

通过这些属性,双端链表节点构成了链表的基本组成部分,它们通过prevnext指针连接在一起,形成了双向链接的链表结构。

对于链表中描述链表整体属性的元数据,它的结构如下:

typedef struct list {
    listNode *head;  // 头节点指针
    listNode *tail;  // 尾节点指针
    unsigned long len;  // 链表长度
    // 其他字段...
} list;

从结构中可以看出元数据中还有两个特殊的节点:头节点(head node)和尾节点(tail node),它们分别位于链表的头部和尾部。而他们的作用如下:

  1. 头节点(head node)
    头节点是双端链表中的第一个节点,也是链表的入口。它通常用于存储链表的起始位置信息,以便快速定位链表的起始位置。在双端链表中,头节点的特点是没有前一个节点,即头节点的prev指针为NULL。头节点通常用于存储链表的头部元数据或者哨兵节点。

  2. 尾节点(tail node)
    尾节点是双端链表中的最后一个节点,也是链表的结束位置。它通常用于存储链表的结束位置信息,以便快速定位链表的结束位置。在双端链表中,尾节点的特点是没有后一个节点,即尾节点的next指针为NULL。尾节点通常用于存储链表的尾部元数据或者哨兵节点。

在Redis中,通常会使用头节点和尾节点来表示双端链表的起始位置和结束位置,以方便对链表进行操作。Redis中的双端链表常见操作如下:

  • 头节点(head):表示双端链表的头部节点,通过头节点可以快速定位链表的起始位置,通常用于添加和删除链表的头部元素。

  • 尾节点(tail):表示双端链表的尾部节点,通过尾节点可以快速定位链表的结束位置,通常用于添加和删除链表的尾部元素。

通过头节点和尾节点,可以方便地对双端链表进行头部插入、尾部插入、头部删除、尾部删除等操作,从而实现了对双端链表的高效操作。

除了上述头尾节点以外,链表的元数据中还有len参数,这个参数用于记录链表的当前长度。每当链表中添加或删除节点时,Redis会相应地更新len字段的值,以反映链表的当前长度。这个参数与SDS里类似,获取链表长度时不用再遍历整个链表,直接拿到len值就可以了,这个时间复杂度是 O(1)。

压缩列表

Redis中的压缩列表(ziplist)是一种特殊的数据结构,用于存储列表和哈希数据类型中的元素。压缩列表通过将多个小的数据单元压缩在一起,以节省内存空间,并提高访问效率。

图片

image.png

对于压缩列表,它的主要作用如下:

  1. 紧凑的存储形式: 压缩列表以一种紧凑的方式存储数据,将多个元素紧密地排列在一起,节省了存储空间。在压缩列表中,相邻的元素可以共享同一个内存空间,这种紧凑的存储形式可以大大减少内存的消耗。

  2. 灵活的编码方式: 压缩列表中的每个元素都可以采用不同的编码方式进行存储,包括整数编码、字符串编码和字节数组编码等。根据元素的类型和大小,压缩列表会选择合适的编码方式来存储数据,以进一步节省内存空间。

  3. 快速的随机访问: 压缩列表支持快速的随机访问操作,可以通过下标索引来访问压缩列表中的任意元素。由于压缩列表采用紧凑的存储形式,因此可以通过简单的偏移计算来实现快速的元素访问,具有较高的访问效率。

  4. 动态调整大小: 压缩列表支持动态调整大小,可以根据实际需要自动扩展或收缩内存空间。当压缩列表中的元素数量增加时,可以动态地分配额外的内存空间,以容纳更多的元素;当元素数量减少时,可以释放多余的内存空间,以节省内存资源。

  5. 适用于小型数据集: 压缩列表适用于存储小型数据集,例如长度较短的列表或者哈希表。由于压缩列表采用紧凑的存储形式,并且支持快速的随机访问,因此特别适合于存储数量较少但访问频繁的数据。

字典

在Redis中,字典(dictionary)是一种用于存储键值对数据的数据结构,也称为哈希表(hash table)。字典是Redis中最常用的数据结构之一,具有快速查找、动态调整大小、哈希冲突处理、迭代器支持等特点,适用于各种数据存储和操作需求,实现键值对存储和快速查找。

字典以键值对的形式存储数据,每个键都与一个值相关联。在Redis中,键和值都可以是任意类型的数据,如字符串、整数、列表或哈希表。

字典利用哈希表实现,具备快速查找的特性。通过将键映射到哈希表的索引位置,字典能以常数时间复杂度(O(1))内查找、插入和删除键值对,即使在大型数据集中也能保持高效。

此外,字典支持动态调整大小,随着键值对数量的变化,能自动扩展或收缩内存空间,以适应数据量的变化。

在存储数据时,如果产生了哈希冲突,字典可以采用开放寻址法或链表法等策略,根据哈希表的大小和负载因子选择合适的冲突解决方法,确保查找性能高效。

跳跃表

跳跃表(Skip List)是一种基于链表的数据结构,它利用多级索引来加速查找操作,类似于平衡树,但实现起来更加简单,具有较好的平均查找性能。在Redis中,跳跃表用于有序集合(Sorted Set)数据类型的实现,提供了高效的有序数据存储和检索功能。

图片

image.png

跳跃表通过维护多级索引,每个级别的索引都是原始链表的子集,用于快速定位元素。每个节点在不同级别的索引中都有一个指针,通过这些指针,可以在不同级别上进行快速查找,从而提高了查找效率。

图片

image.png

跳跃表的平均查找性能为O(log n),与平衡树相当,但实现起来更加简单。跳跃表通过多级索引来实现快速查找,使得查找时间随着数据量的增加而呈对数增长。但是跳跃表的空间复杂度相对较高,因为它需要额外的空间来维护多级索引。不过跳跃表的空间占用通常是合理的,且具有可控性,可以根据实际需求调整级别和索引节点的数量,以平衡空间和性能的需求。

除此之外,跳跃表支持动态调整大小,可以根据实际需要自动扩展或收缩内存空间。当有序集合中的元素数量增加时,跳跃表会动态地增加级别和索引节点,以提高查找效率;当元素数量减少时,可以收缩跳跃表的大小,以节省内存资源。并且跳跃表的插入和删除操作具有较高的效率,通过维护多级索引,可以在O(log n)的时间复杂度内完成插入和删除操作。

Redis的单线程模型

Redis中的单线程模型是指Redis在其核心数据处理部分采用单一的主线程来执行网络IO操作、接收客户端命令请求、执行命令操作以及返回结果。Redis服务端的网络IO和键值对读写操作都由一个线程统一负责,而诸如持久化、集群数据同步等任务则是由其他线程来执行。在单线程模型下,Redis服务器是单线程运行的,即每个客户端的请求都是依次顺序执行的。

而使用单线程所带来的好处:

  1. 避免上下文切换
    多线程环境下,线程间的上下文切换会带来额外的CPU开销。Redis通过单线程模型消除了多线程环境下的上下文切换成本,使得CPU资源更多地用于执行实际的命令处理。

  2. 简化数据操作的并发控制
    单线程模型确保了同一时间内只有一个操作在处理数据,因此不需要使用锁机制来保护数据的完整性,避免了多线程编程中常见的锁竞争和死锁问题,从而提高了系统的执行效率。

  3. 内存操作性能优越
    Redis是一个基于内存操作的数据库,大部分操作都在内存中完成,本身就有很高的执行速度。单线程模型下,内存操作无需考虑并发控制,因此能够实现更高的内存读写效率。

在日常开发中,我们通常会使用并发编程来提高服务的吞吐量。这时,我们可能会产生一个疑问:Redis的单线程模型是否能够充分利用CPU资源呢?

实际上,由于Redis是基于内存的操作,使用Redis时,CPU很少会成为瓶颈。相反,Redis主要受限于服务器内存和网络带宽。例如,在典型的Linux系统上,通过使用pipelining技术,Redis能够实现较高的吞吐量,每秒可以处理大量的请求。因此,如果应用程序主要使用O(N)或O(log(N))的命令,它几乎不会对CPU资源造成过多的负载。综上所述,考虑到单线程模型的实现简单且CPU很少成为瓶颈,因此采用单线程方案是合理的选择。

单线程模型限制了Redis的并发能力。由于只有一个线程在处理请求,无法充分利用多核处理器的性能优势,所以可能到达服务端的请求不可能被立即处理。那么Redis是如何保证单线程的资源利用率和处理效率呢?

Redis的IO多路复用技术
Redis通过使用IO多路复用技术(如epoll、kqueue或select等),在一个线程内同时监听多个socket连接,当有网络事件发生时(如读写就绪),再逐一处理。这样可以处理大量并发连接,并在单线程中高效地调度网络事件,使得单线程也能应对高并发场景。所以Redis服务端,整体来看,就是一个以事件驱动的程序,它的操作都是基于事件的方式进行的。Redis的事件驱动架构如图:

图片

Redis的事件驱动架构.png

Redis的事件驱动架构是一种基于非阻塞I/O多路复用技术设计的高效处理并发请求的机制。在Redis中,事件驱动架构通过监听和处理各种网络I/O事件以及定时事件,使得Redis服务端能够在一个线程内高效地服务于多个客户端连接,并执行相关的命令操作。

事件驱动架构主要由以下几个组成部分构成:

  1. 套接字(Socket)
    套接字是客户端与Redis服务端之间进行通信的基础接口,用于双向数据传输。

  2. I/O多路复用
    Redis服务端通过使用如epoll、kqueue等I/O多路复用技术,可以同时监听多个套接字上的读写事件。当某个客户端的套接字上有数据可读或可写时,内核会通知Redis服务端,而无需Redis反复检查每一个套接字状态。

Redis默认使用的IO多路复用技术确实是epoll。其主要优点如下:

  • 并发连接限制
    相比于select和poll,epoll没有预设的并发连接数限制,能够处理的并发连接数只受限于系统资源,适合处理大规模并发连接。

  • 内存拷贝优化
    epoll采用事件注册机制,仅关注和通知就绪的文件描述符,无需像select和poll那样在每次调用时都拷贝整个文件描述符集合,从而减少了内存拷贝的开销。

  • 活跃连接感知
    epoll提供了水平触发(level-triggered)和边缘触发(edge-triggered)两种模式,可以更准确地感知活跃连接,仅当有事件发生时才唤醒处理,避免了无效的轮询操作,提升了事件处理的效率。

  • 高效事件处理
    epoll利用红黑树存储待监控的文件描述符,并使用内核层面的回调机制,当有文件描述符就绪时,会直接通知应用程序,从而减少了CPU空转和上下文切换的成本。

  1. 文件事件分派器(File Event Demultiplexer)
    文件事件分派器是Redis事件驱动的核心组件,它负责将内核传递过来的就绪事件分发给对应的处理器。在Redis中,每个套接字都关联了一个或多个事件处理器,如客户端连接请求处理器、命令请求处理器和命令响应处理器等。

  2. 事件处理器(Event Handlers)
    事件处理器是Redis中处理特定事件的实际执行者。当文件事件分派器接收到一个就绪事件时,它会调用对应的事件处理器来执行相应操作,如读取客户端的命令请求,执行命令并对结果进行编码,然后将响应数据写回客户端。

而对于Redis中设计的事件主要分为两个大类:

  • 文件事件(File Events):主要对应网络I/O操作,包括客户端连接请求(AE_READABLE事件)、客户端命令请求(AE_READABLE事件)和服务端命令回复(AE_WRITABLE事件)。

  • 时间事件(Time Events):对应定时任务,如键值对过期检查、持久化操作等。所有时间事件都被存放在一个无序链表中,每当时间事件执行器运行时,会遍历链表并处理已到达预定时间的事件。

通过事件驱动架构,Redis能够在一个线程内并发处理大量客户端请求,而无需为每个客户端创建独立的线程。此外,由于Redis的高效内存管理、数据结构优化和单线程模型,避免了多线程环境下的锁竞争和上下文切换开销,从而实现了极高的吞吐量和响应速度。

在Redis 6.x版本中,虽然引入了多线程处理网络IO的部分,但核心命令执行依然保持单线程事件驱动的模型,以维持Redis原有的性能优势。

IO多路复用模型

IO多路复用的核心在于内核关注的是应用程序的文件描述符而非直接监控连接本身。客户端运行时产生的不同事件类型的套接字操作,会被内核捕获。在服务器端,I/O多路复用机制负责收集这些事件并将它们加入事件队列,随后通过文件事件分发器分发至对应事件处理器进行处理。

以Redis为例,在其单线程模型下,内核不间断地监测所有客户端socket的连接请求和数据传输状况。只要检测到任何socket上有待处理的动作,便会立即将控制权转交给Redis线程。这样一来,尽管仅依靠单线程,Redis仍能有效地处理多个并发的IO流。

select/epoll等IO多路复用技术提供了一种基于事件触发的回调模式,每当有不同事件发生时,Redis能够迅速调用相应的事件处理器,始终保持在处理事件的状态,从而提升了其响应速度。

图片

高性能 IO 多路复用.png


由于Redis线程并不会因为等待某个特定socket的IO操作完毕而停滞,它可以流畅地在多个客户端间切换,即时响应每个客户端的不同请求,从而实现在单线程环境下对大量并发连接的有效处理和高并发性能。

  Redis缓存一致性

面试官:好。那你就先聊一聊Redis作为缓存的数据一致性问题。

候选者:好的,首先之所以使用Reids作为缓存是因为,在高并发的场景下,传统的关系型数据库的并发能力是相对比较薄弱的;并且往往在这种情况下数据库都是最后一道防线,是相对比较薄弱的环节。所以可以使用Redis做一个缓存。让用户请求先打到Redis上而不是直接打到数据库上。但是如果出现数据更新操作:数据库与缓存更新,就会出现缓存(Redis)和数据库(MySQL)之间的数据一致性问题。

面试官:OK,那你说说缓存一致性的问题如何解决呢?

候选者:首先要根据系统不同的架构去设计,如果是非读写分离架构的话:解决方案是延时双删的方式当然我也可以简单说一下其它方式为什么不可以

面试官:好的,那你可以展开说一说其它方式,以及延时双删的大致流程

候选者:首先说下方案一:先更新数据库,再更新缓存。结论:不可行的。原因如下:

原因一:从线程安全角度:

  • 假设同时有请求A、B进行更新操作。执行顺序如下:

    • 线程A更新了数据库

    • 线程B更新了数据库

    • 线程B更新了缓存

    • 线程A更新了缓存

正常情况下A的所有更新操作应该早于B的所有更新操作,结果由于网络等原因导致了更新缓存的时候B的操作早于A的操作,此时数据库虽然是正确的,但是缓存和数据库出现了不一致的问题。所以不能考虑此方案。

原因二:从业务角度

  • 如果某个业务场景是写多读少的情况,就会导致缓存并未被读取就会被频繁的更新,极大的浪费了服务器的性能。会有冷数据的产生(中间值)

  • 如果写入数据库的值,并不是直接写入缓存的,而是要经过一系列复杂的计算再写入缓存。那么,每次写入数据库后,都再次计算写入缓存的值,无疑是浪费性能的。

方案二:先删除缓存,再更新数据库。结论:不可行。线程安全问题,和上述一样
  • 请求A进行写操作,删除缓存

  • 请求B查询发现缓存不存在

  • 请求B去数据库查询得到旧值

  • 请求B将旧值写入缓存

  • 请求A更新数据库 此时数据库中的值是新值,缓存的值是旧值,就发生了数据不一致问题

可行方案三延时双删

线程 A:

  1. 当应用程序需要更新数据时,首先将数据更新到后端数据存储中(如数据库)。

  2. A 线程向 Redis 发送删除缓存的指令,将对应的缓存数据标记为过期。

  3. A 线程等待一定的时间窗口(通常是几十毫秒至几百毫秒),让 B 线程有足够的时间去访问缓存。

线程 B:

  1. 在时间窗口内,当有请求访问过期的缓存数据时,B 线程发现缓存已过期,并触发缓存更新的操作。

  2. B 线程从后端数据存储中获取最新数据,并将其存储到缓存中。

  3. B 线程继续处理请求,返回更新后的缓存数据。

线程 A(续):

  1. 在时间窗口结束后,A 线程再次向 Redis 发送删除缓存的指令,彻底删除缓存数据。

  2. 如果在时间窗口内没有请求访问到过期的缓存数据,A 线程会删除已标记为过期的缓存数据。

通过上述流程,A 线程负责标记缓存过期并等待一段时间,给 B 线程足够的时间去访问缓存并更新。B 线程则负责处理实际的缓存更新操作。这样,即使在缓存更新期间有请求访问过期的缓存数据,也能获取到最新的数据,避免了脏读的问题。

需要注意的是,具体的时间窗口大小和线程的实现方式可以根据实际需求和系统性能进行调整。同时,对于高并发环境,还需要考虑线程安全和并发控制的实现,以确保操作的正确性和性能。

如果想学Java项目的,强烈推荐我的👉项目消息推送平台Austin8K stars),可以用作毕业设计,可以用作校招,可以看看生产环境是怎么推送消息的。 

仓库地址(可点击阅读原文跳转):https://gitee.com/zhongfucheng/austin

候选者如果是读写分离架构的话,解决的方案则有所不同

先说结论:可以采用先更新数据库,再删除缓存,配合上重试机制

同样还是有两个请求请求A进行更新操作,请求B进行查询操作

  • 请求A进行写操作,删除缓存

  • 请求A将数据写入数据库了,

  • 请求B查询缓存发现,缓存没有值

  • 请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值

  • 请求B将旧值写入缓存

  • 数据库完成主从同步,从库变为新值

仍然会出现缓存与数据库数据不一致问题

此时仍然采用延时双删策略,但是延时时间需要在主从同步的延时时间基础上,加几百ms。

双删失败

如果第二次删除缓存失败,仍然会出现缓存与数据库数据不一致的问题

同样还是有两个请求请求A进行更新操作,请求B进行查询操作(单库)

  • 请求A进行写操作,删除缓存

  • 请求B查询发现缓存不存在

  • 请求B去数据库查询得到旧值

  • 请求B将旧值写入缓存

  • 请求A将新值写入数据库

  • 请求A试图去删除请求B写入的缓存值,结果失败了

解决方案:重试机制

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

同样存在并发问题,但是发生几率很低

同样还是有两个请求请求A进行更新操作,请求B进行查询操作(单库)

  • 缓存刚好失效

  • 请求A查询数据库,得一个旧值

  • 请求B将新值写入数据库

  • 请求B删除缓存

  • 请求A将查到的旧值写入缓存

该情况发生的必要条件就是请求B写数据库的操作比请求A读数据库的操作耗时更短,才能使请求B先删除缓存,但是通常来说数据库的读操作是远远快于写操作的,所以这种并发问题很难发生。

如果在极端情况下,这种并发问题仍然发生了

  • 给缓存设置一定的有效时间

  • 采用异步延时双删策略,另起一个线程,异步删除,保证读请求完成以后,再进行删除操作。

重试机制

与先删除缓存,再更新数据一样,如果删除缓存失败,那么仍然会出现数据不一致问题

我们选择靠谱的重试机制,利用消息队列进行删除的补偿

方案一:

  1. 更新数据库数据;

  2. 缓存因为种种问题删除失败

  3. 将需要删除的key发送至消息队列

  4. 自己消费消息,获得需要删除的key

  5. 继续重试删除操作,直到成功

图片

该方案有一个缺点,对业务线代码造成大量的侵入,需要自己在业务代码中额外添加生成消息和消费消息的功能,业务代码变得不再专注于业务需求。改进:启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据。在应用程序中,另起一段程序(避免业务侵入),获得这个订阅程序传来的信息,进行删除缓存操作

方案二:

  1. 更新数据库数据

  2. 数据库会将操作信息写入binlog日志当中

  3. 订阅程序提取出所需要的数据以及key

  4. 另起一段非业务代码,获得该信息

  5. 尝试删除缓存操作,发现删除失败

  6. 将这些信息发送至消息队列

  7. 重新从消息队列中获得该数据,重试操作订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能

图片

候选者:以上就是我对Redis作为缓存出现的一致性问题如何解决的理解,和具体解决方案。

候选者:额...... 这次先这样吧。累了🐶

面试官:好的好的,希望你能重点考虑下我们的公司。期待你的加入呀

redis事务

事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

Redis事务的概念

Redis 事务的本质是通过MULTI、EXEC、WATCH等一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis的事务总是具有ACID中的一致性和隔离性,其他特性是不支持的。当服务器运行在AOF持久化模式下,并且appendfsync选项的值为always时,事务也具有耐久性。

Redis事务功能是通过MULTI、EXEC、DISCARD和WATCH 四个原语实现的。

事务命令:

MULTI:用于开启一个事务,它总是返回OK。MULTI执行之后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中,当EXEC命令被调用时,所有队列中的命令才会被执行。

EXEC:执行所有事务块内的命令。返回事务块内所有命令的返回值,按命令执行的先后顺序排列。当操作被打断时,返回空值 nil 。

WATCH :是一个乐观锁,可以为 Redis 事务提供 check-and-set (CAS)行为。可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到EXEC命令。(秒杀场景)

DISCARD:调用该命令,客户端可以清空事务队列,并放弃执行事务,且客户端会从事务状态中退出。

UNWATCH:命令可以取消watch对所有key的监控。

3.redis为什么这么快?

图片

(1) 纯内存操作
Redis利用内存进行数据存储,其操作基于内存读写,由于内存访问速度远超硬盘,使得Redis在处理数据时具有极高的读写速度。特别是对于简单的存取操作,由于线程在内存中执行的时间非常短,主要的时间消耗在于网络I/O,因此Redis在处理大量快速读写请求时表现出卓越的性能。

(2) 单线程模型
Redis采用单线程模型处理客户端请求,这一设计确保了操作的原子性,避免了多线程环境下的上下文切换和锁竞争问题。这使得Redis在处理命令请求时能够保持高度的确定性和一致性,同时也简化了编程模型,降低了并发控制的复杂性。

由于Redis是基于内存的操作,使用Redis时,CPU很少会成为瓶颈。相反,Redis主要受限于服务器内存和网络带宽。例如,在典型的Linux系统上,通过使用pipelining技术,Redis能够实现较高的吞吐量,每秒可以处理大量的请求。因此,如果应用程序主要使用O(N)或O(log(N))的命令,它几乎不会对CPU资源造成过多的负载。综上所述,考虑到单线程模型的实现简单且CPU很少成为瓶颈,因此采用单线程方案是合理的选择。

(3) IO多路复用技术
Redis通过采用IO多路复用模型,如epoll,能够在一个线程中高效地处理多个客户端连接。单线程轮询监听多个套接字描述符,并将数据库的读、写、连接建立和关闭等操作转化为事件,通过自定义的事件分离器和事件处理器来高效地处理这些事件,从而避免了在等待IO操作时的阻塞。

(4) 高效数据结构

Redis的整体设计围绕高效数据结构展开,其中包括但不限于全局哈希表(字典),该结构提供O(1)的平均时间复杂度,并通过rehash操作动态调整哈希桶数量,减少哈希冲突,采用渐进式rehash避免一次性操作过大导致的阻塞。

除此之外,Redis还广泛应用了多种优化过的数据结构,如压缩表(ziplist)用于存储短数据以节省内存,跳跃表(skiplist)用于有序集合提供快速的范围查询,以及其他如列表、集合等数据结构,均针对不同场景进行深度优化,确保了在读取和操作数据时的高性能。

4.听说 redis 6.0之后又使用了多线程,不会有线程安全的问题吗?

不会

其实 redis 还是使用单线程模型来处理客户端的请求,只是使用多线程来处理数据的读写和协议解析,执行命令还是使用单线程,所以是不会有线程安全的问题。

之所以加入了多线程因为 redis 的性能瓶颈在于网络IO而非CPU,使用多线程能提升IO读写的效率,从而整体提高redis的性能。

redis分布式锁(核心)

(1) 分布式锁应包含以下特征:

1、互斥性:任意时刻,只有一个客户端能持有锁;

2、锁超时释放:持有锁超时,可以释放,防止死锁;

3、锁释放安全:锁只能被持有的客户端删除,不能被其他客户端删除;

4、高性能、高可用:加锁、解锁效率高,同时要保证高可用;

类比Java等语言中的本地锁实现,一些分布式锁框架也大多实现了可重入、公平锁等特性,即:

5、可重入:一个线程获得锁之后,可以再次对其请求加锁,也可以支持设置可重入的次数;

6、公平性:先来的先拿到锁,还是采用抢占的方式争抢锁。

这些也都可以参考Redission等优秀客户端中的具体实现。

(2) Redis的setNX实现分布式锁

Redis的setNX(set if not exists)命令是原子操作,当键不存在时才设置值,设置成功则返回true,否则返回false。通过这个命令可以快速地在Redis中争夺一把锁。

利用Redis,我们可以生成一个唯一的锁ID作为key的一部分。然后使用setNX尝试设置key-value对,value可以是过期时间戳。若设置成功,则认为获取锁成功,执行业务逻辑。在业务逻辑完成后,删除对应key释放锁,或设置过期时间自动释放。

@Slf4j
public class RedisDistributedLock implements AutoCloseable{

    private final StringRedisTemplate stringRedisTemplate;
    private final DefaultRedisScript<Boolean> unlockScript;

    /**锁的key*/
    private final String lockKey;
    /**锁过期时间*/
    private final Integer expireTime;

    private static final String UNLOCK_LUA_SCRIPT = "if redis.call(\"get\", KEYS[1]) == ARGV[1] then\n" +
                                                    "    return redis.call(\"del\", KEYS[1])\n" +
                                                    "else\n" +
                                                    "    return 0\n" +
                                                    "end";
    public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockKey, Integer expireTime) {
        this.stringRedisTemplate = stringRedisTemplate;
        this.lockKey = lockKey;
        this.expireTime = expireTime;
        // 初始化Lua解锁脚本
        this.unlockScript = new DefaultRedisScript<>();
        unlockScript.setScriptText(UNLOCK_LUA_SCRIPT);
        unlockScript.setResultType(Boolean.class);
    }

    /**
     * 获取锁
     * @return 是否获取成功
     */
    public Boolean getLock() {
        String value = UUID.randomUUID().toString();
        try {
            return stringRedisTemplate.opsForValue().setIfAbsent(lockKey, value, expireTime, TimeUnit.SECONDS);
        } catch (Exception e) {
            log.error("获取分布式锁失败: {}", e.getMessage());
            return false;
        }
    }

    /**
     * 释放锁
     * @return 是否释放成功
     */
    public Boolean unLock() {
        // 使用Lua脚本进行解锁操作
        List<String> keys = Collections.singletonList(lockKey);
        Object result = stringRedisTemplate.execute(unlockScript, keys, stringRedisTemplate.opsForValue().get(lockKey));
        boolean unlocked = (Boolean) result;
        log.info("释放锁的结果: {}", unlocked);
        return unlocked;
    }


    @Override
    public void close() throws Exception {
        unLock();
    }
}

然后我们在处理库存时,先尝试获取锁,如果获取到锁,则就可以更新库存。

@Transactional(rollbackFor = Exception.class)
    @Override
    public void confirmOrderWithRedisNx(String customerNo, String orderNo) {
        // 查询订单信息
        OutboundOrderDO outboundOrderDO = outboundOrderMapper.selectOrderByOrderNo(customerNo, orderNo);
        String warehouseCode = outboundOrderDO.getWarehouseCode();

        // 查询订单明细  假设我们的出库订单是一单一件
        OutboundOrderDetailDO detailDO = orderDetailMapper.selectDetailByOrderNo(outboundOrderDO.getOrderNo());
        String sku = detailDO.getSku();
        Integer qty = detailDO.getQty();

        String lockKey = String.format("inventory:%s_%s_%s", customerNo, warehouseCode, sku);
        // 30秒过期
        try (RedisDistributedLock lock = new RedisDistributedLock(stringRedisTemplate, lockKey, 30)) {
            if (lock.getLock()) {
                // 查询库存
                TbInventoryDO inventoryDO = tbInventoryMapper.selectSkuInventory(customerNo, warehouseCode, sku);
                Integer availableInventory = inventoryDO.getAvailableInventory();
                // 判断库存是否足够
                if (qty > availableInventory){
                    System.err.println("库存不足,不能出库");
                    throw new ServiceException(StatusEnum.SERVICE_ERROR, "库存不足,不能出库");
                }

                // 扣减库存
                TbInventoryDO updateInventory = new TbInventoryDO();
                updateInventory.setCustomerNo(customerNo);
                updateInventory.setWarehouseCode(warehouseCode);
                updateInventory.setSku(sku);
                // 库存差值
                updateInventory.setDiffInventory(qty);
                tbInventoryMapper.updateInventory(updateInventory);
            } else {
                log.error("更新库存时发生并发冲突,请重试");
                throw new ServiceException(StatusEnum.SERVICE_ERROR, "更新库存时发生并发冲突,请重试");
            }
        } catch (Exception e) {
            log.error("处理分布式锁时发生错误: {}", e.getMessage());
        }
    }

图片

image.png

Redis作为内存数据库,其操作速度快,setNX的执行时间几乎可以忽略不计,尤其适合高并发场景下的锁请求。Redis作为一个可以独立的服务,可以轻松实现不同进程或服务器之间的互斥锁。而setNX命令是原子操作,能够在Redis这一单线程环境下以原子性的方式实现锁的获取,简单一行命令即可实现锁的争抢。同时可以通过EXPX参数,可以在设置锁时一并设定过期时间,避免因意外情况导致的死锁。

但是单纯使用setNX并不能自动续期,一旦锁过期而又未主动释放,可能出现锁被其他客户端误获取的情况,需要额外实现锁的自动续期机制,例如使用WATCHMULTI命令组合,或者SET命令的新参数如SET key value PX milliseconds NX XX。而setNX在获取不到锁时会立即返回失败,所以我们必须轮询或使用某种延时重试策略来不断尝试获取锁。并且如果多个客户端同时请求锁,Redis并不会保证特定的排队顺序,可能导致“饥饿”现象(即某些客户端始终无法获取锁)。

虽然Redis的setNX命令在实现分布式锁方面提供了便捷性和高性能,但要构建健壮、可靠的分布式锁解决方案,往往还需要结合其他命令(如expirewatchmulti/exec等)以及考虑到各种边缘情况和容错机制。一些成熟的Redis客户端库(如Redisson、Jedis)提供了封装好的分布式锁实现,解决了上述许多问题。

(3)  Redission实现分布式锁

1. Redisson简介

Redisson是一个功能丰富的Java客户端库,它封装了与Redis交互的复杂逻辑,为开发者提供了诸如分布式锁、信号量、阻塞队列等多种分布式数据结构。其中,对Redis分布式锁的支持尤为出色。

2. Redisson分布式锁实现

Redisson利用Redis的SETNXEXPIRE等命令实现锁的获取与释放。当多个客户端同时请求锁时,只有一个客户端能成功设置键值对,其余客户端则等待或失败。

Redisson 锁的加锁机制如上图所示,线程去获取锁,获取成功则执行lua脚本,保存数据到redis数据库。如果获取失败: 一直通过while循环尝试获取锁(可自定义等待时间,超时后返回失败),获取成功后,执行lua脚本,保存数据到redis数据库。Redisson提供的分布式锁是支持锁自动续期的,也就是说,如果线程仍旧没有执行完,那么redisson会自动给redis中的目标key延长超时时间,这在Redisson中称之为 Watch Dog 机制。

3. Redisson RedLock算法

Redisson还支持RedLock算法,通过在多个独立Redis实例上同时获取锁,大幅提升分布式锁的可用性和安全性。

4 . Redisson看门狗:智能续期的守护者

1. 为什么要续期?

  • 避免锁超时:锁持有者可能因网络波动、GC暂停等原因延迟释放锁,若无续期机制,锁可能因超时自动释放,导致其他进程误入。

  • 防止单点故障:即使锁服务端出现故障,通过续期也能延长锁的有效时间,为恢复争取窗口。

2. Redisson看门狗如何续期?

Redisson在获取锁后启动一个后台线程——看门狗(Watchdog),周期性地延长锁的过期时间。这确保了只要持有锁的客户端存活,锁就不会意外释放。

3. 看门狗续期策略详解

  • 后台线程续期:Redisson在内部维护一个定时任务线程,对持有的锁定期发送续期请求。

  • 锁watchdog:Redisson 3.10版本引入了锁watchdog机制,当客户端与Redis断开连接时,watchdog能感知并立即释放锁,防止死锁。

4.1 看门狗默认状态设定

1. 默认续期间隔

Redisson看门狗的默认续期间隔(lockWatchdogTimeout)为30秒。这意味着,每隔30秒,看门狗会对持有的锁进行一次续期操作,确保锁的有效期始终足够长,避免因超时而导致锁被错误地释放。

2. 默认锁超时

Redisson分布式锁的默认超时时间(lockTimeout)为30秒。如果在指定时间内未能成功获取锁,请求线程将不再等待,立即返回获取失败。同时,每隔 10 秒检查一下,持有锁的客户端若在30秒内未完成操作且未进行续期,锁将自动过期并释放。

5. 实战场景:Redisson分布式锁的应用

1. 电商秒杀:在商品库存扣减、订单创建等环节使用分布式锁,确保同一商品仅被一个用户成功下单。 

2. 任务调度:避免同一任务被多个worker同时执行,确保任务执行的唯一性和完整性。 

3. 数据库事务:在分布式事务中,利用分布式锁协调不同节点的事务操作顺序,保证数据一致性。

6. 最佳实践与调优

1. 锁超时时间设置:兼顾锁的竞争激烈程度、业务处理耗时等因素,合理设置锁超时时间,避免过短导致频繁抢锁或过长导致死锁风险。 

2. 续期间隔调整:根据系统负载和网络状况,动态调整续期间隔,确保既不过于频繁地占用Redis资源,又能及时防范锁超时。 

3. 错误处理与重试:对锁获取失败、续期异常等情况做好优雅处理,并设定合理的重试策略。

7. 结语

Redisson如同为Redis分布式锁插上智慧翅膀的魔法师,通过巧妙的看门狗续期机制,确保锁在复杂环境下依然坚韧可靠。理解并熟练运用Redisson,不仅能提升分布式系统的并发处理能力,更能有效保障数据一致性,为您的业务构建稳固的基石。

除了Redis,还有哪些实现分布式锁的方案?

方案一:基于MySQL
1.数据库悲观锁

悲观锁以预防性策略处理并发冲突,它假设并发访问导致的数据冲突是常态。因此,在访问数据之前,它会积极地获取并持有锁,确保在锁未释放时,其他事务无法对同一数据进行访问。通过运用SELECT ... FOR UPDATE SQL语句,能够在查询阶段即锁定相关行,实现数据的独占访问。然而,重要的是要注意,此操作应仅针对唯一键执行,否则可能会大幅增加锁定范围和潜在的锁表风险,从而影响系统的并发性能与效率。

最常见的做法是直接在业务数据上使用SELECT ... FOR UPDATE,例如:

<select id="selectSkuInventoryForUpdate" resultType="com.springboot.mybatis.entity.TbInventoryDO">
    SELECT *
    FROM tb_inventory
    WHERE sku = #{sku}
    AND customer_no = #{customerNo}
    AND warehouse_code = #{warehouseCode}
    AND deleted = 0
    FOR UPDATE
  </select>

在一个事务中,先使用SELECT ... FOR UPDATE后,在执行更新。

/**
     * 使用SELECT... FOR UPDATE 实现分布式锁,扣减库存
     * @param customerNo
     * @param orderNo
     */
    @Transactional(rollbackFor = Exception.class)
    @Override
    public void confirmOrderWithLock(String customerNo, String orderNo) {
        // 查询订单信息
        OutboundOrderDO outboundOrderDO = outboundOrderMapper.selectOrderByOrderNo(customerNo, orderNo);
        String warehouseCode = outboundOrderDO.getWarehouseCode();

        // 查询订单明细  假设我们的出库订单是一单一件
        OutboundOrderDetailDO detailDO = orderDetailMapper.selectDetailByOrderNo(outboundOrderDO.getOrderNo());
        String sku = detailDO.getSku();
        Integer qty = detailDO.getQty();

        // 查询库存
        TbInventoryDO inventoryDO = tbInventoryMapper.selectSkuInventoryForUpdate(customerNo, warehouseCode, sku);
        Integer availableInventory = inventoryDO.getAvailableInventory();
        // 判断库存是否足够
        if (qty > availableInventory){
            System.err.println("库存不足,不能出库");
            throw new ServiceException(StatusEnum.SERVICE_ERROR, "库存不足,不能出库");
        }

        // 扣减库存
        TbInventoryDO updateInventory = new TbInventoryDO();
        updateInventory.setCustomerNo(customerNo);
        updateInventory.setWarehouseCode(warehouseCode);
        updateInventory.setSku(sku);
        // 库存差值
        updateInventory.setDiffInventory(qty);
        tbInventoryMapper.updateInventory(updateInventory);
    }

但是,这种实现方式,很容易造成业务表的锁压力,特别是数据量大,并发量高的时候。所以,还有一种做法是,专门维护一张锁的表,而不是直接在业务数据表上使用SELECT FOR UPDATE。这种方式在某些场景下可以帮助简化锁的管理,并且可以在一定程度上减轻对业务数据表的锁定压力。(其实实现方式,类似Redis实现的分布式锁,只是用数据库实现了而已)。其实现流程,如下:

图片

数据库实现悲观锁流程

  1. 1. 创建锁表:首先,创建一张锁表,例如lock_table,包含lock_key(用于标识需要锁定的业务资源)、lock_holder(持有锁的客户端标识,如用户ID或事务ID)、acquire_time(获取锁的时间)等字段。

CREATE TABLE `tb_lock`
(
    id           BIGINT AUTO_INCREMENT
        PRIMARY KEY,
    lock_key     VARCHAR(255)                               NOT NULL DEFAULT '' COMMENT '锁的业务编码。对应业务表的唯一键',
    lock_holder  VARCHAR(32)                                NOT NULL DEFAULT '' COMMENT '持有锁的客户端标识',
    acquire_time DATETIME                                   NOT NULL COMMENT '获取锁的时间',
    create_time  DATETIME         DEFAULT CURRENT_TIMESTAMP NOT NULL COMMENT '创建时间',
    update_time  DATETIME         DEFAULT CURRENT_TIMESTAMP NOT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
    deleted      TINYINT UNSIGNED DEFAULT '0'               NULL COMMENT '0-未删除 1/null-已删除',
    UNIQUE KEY uk_lock (lock_key, deleted)
) ENGINE = InnoDB
  AUTO_INCREMENT = 1
  CHARACTER SET = utf8mb4 COMMENT = 'Lock表';
  1. 1. 插入锁记录:当客户端想要获取锁时,尝试在lock_table中插入一条记录,其中lock_key对应需要保护的业务资源,例如商品SKU。插入操作通常是通过INSERT INTO ... ON DUPLICATE KEY UPDATE这样的语句实现,以确保在存在相同锁键的情况下更新记录,否则插入新记录,这一步相当于获取锁。

<insert id="insertLock">
    INSERT INTO tb_lock
    (lock_key,lock_holder,acquire_time)
    VALUES
    (#{lockKey},#{lockHolder},#{acquireTime})
  </insert>
  1. 1. 使用 SELECT FOR UPDATE:在插入锁记录时,可以通过SELECT ... FOR UPDATE锁定锁表中的相应记录,确保在当前事务结束前,其他事务无法更新或删除这条锁记录。

 <select id="selectLockByLockKey" resultType="com.springboot.mybatis.entity.TbLockDO">
    SELECT *
    FROM tb_lock
    WHERE lock_key = #{lockKey} AND deleted = 0
    FOR UPDATE
</select>
  1. 1. 检查锁状态:在获取锁时,可以检查锁是否已被持有,比如检查lock_holder字段,如果已有其他事务持有锁,则获取锁失败,需要等待或重试。

// 尝试获取锁
tryLock(lockKey, lockHolder);
// 使用SELECT FOR UPDATE锁定锁表记录
TbLockDO tbLockDO = tbLockMapper.selectLockByLockKey(lockKey);
if (!tbLockDO.getLockHolder().equals(lockHolder)) {
    // 锁已被其他客户端持有,获取锁失败,需要处理此异常情况
    throw new IllegalStateException("Lock is held by another client.");
}
  1. 1. 释放锁:当业务操作完成时,可以通过删除或更新锁表中的对应记录来释放锁。

<delete id="deleteLockByLockKey" parameterType="java.lang.String">
    DELETE FROM tb_lock
    WHERE lock_key = #{lockKey}
    AND lock_holder = #{lockHolder}
    AND deleted = 0
</delete>

基于数据库悲观锁实现,代码如下:

@Transactional(rollbackFor = Exception.class)
    @Override
    public void confirmOrderWithLock(String customerNo, String orderNo) {
        // 查询订单信息
        OutboundOrderDO outboundOrderDO = outboundOrderMapper.selectOrderByOrderNo(customerNo, orderNo);
        String warehouseCode = outboundOrderDO.getWarehouseCode();

        // 查询订单明细  假设我们的出库订单是一单一件
        OutboundOrderDetailDO detailDO = orderDetailMapper.selectDetailByOrderNo(outboundOrderDO.getOrderNo());
        String sku = detailDO.getSku();
        Integer qty = detailDO.getQty();

        String lockKey = String.format("inventory:%s_%s_%s", customerNo, warehouseCode, sku);
        String lockHolder = Thread.currentThread().getName();
        try {
            // 尝试获取锁
            tryLock(lockKey, lockHolder);
            // 使用SELECT FOR UPDATE锁定锁表记录
            TbLockDO tbLockDO = tbLockMapper.selectLockByLockKey(lockKey);
            if (!tbLockDO.getLockHolder().equals(lockHolder)) {
                // 锁已被其他客户端持有,获取锁失败,需要处理此异常情况
                throw new IllegalStateException("Lock is held by another client.");
            }
            // 查询库存
            TbInventoryDO inventoryDO = tbInventoryMapper.selectSkuInventoryForUpdate(customerNo, warehouseCode, sku);
            Integer availableInventory = inventoryDO.getAvailableInventory();
            // 判断库存是否足够
            if (qty > availableInventory){
                System.err.println("库存不足,不能出库");
                throw new ServiceException(StatusEnum.SERVICE_ERROR, "库存不足,不能出库");
            }

            // 扣减库存
            TbInventoryDO updateInventory = new TbInventoryDO();
            updateInventory.setCustomerNo(customerNo);
            updateInventory.setWarehouseCode(warehouseCode);
            updateInventory.setSku(sku);
            // 库存差值
            updateInventory.setDiffInventory(qty);
            tbInventoryMapper.updateInventory(updateInventory);
        }finally {
            unlock(lockKey, lockHolder);
        }
    }


    /**
     * 尝试获取锁
     * @param lockKey 锁的key 业务编码
     * @param lockHolder 锁的持有者
     * @return 是否获取成功
     */
    private void tryLock(String lockKey, String lockHolder) {
        TbLockDO tbLockDO = new TbLockDO();
        tbLockDO.setLockKey(lockKey);
        tbLockDO.setLockHolder(lockHolder);
        tbLockDO.setAcquireTime(LocalDateTime.now());
        //插入一条数据   insert into
        tbLockMapper.insertLock(tbLockDO);
    }

    /**
     * 锁释放
     * @param lockKey 锁的key 业务编码
     */
    private void unlock(String lockKey, String lockHolder){
        tbLockMapper.deleteLockByLockKey(lockKey, lockHolder);
    }

图片

image.png

数据库悲观锁实现分布式锁可以防止并发冲突,确保在事务结束前,这些记录不会被其他并发事务修改。它还可以控制锁的粒度,提供行级别的锁定,减少锁定范围,提高并发性能。这种方式非常适合于处理需要更新的事务场景,特别是银行转账、库存扣减等需要保证数据完整性和一致性的操作。

但是,需要注意的是,过度或不当使用SELECT FOR UPDATE会导致更多的行被锁定,在高并发场景下,如果大量事务都在等待获取锁,可能会导致锁等待和死锁问题,并且当事务持有SELECT FOR UPDATE的锁时,其他事务尝试修改这些锁定的行会陷入等待状态,直至锁释放。这可能导致其他事务的延迟和系统吞吐量下降,长时间持有锁会导致数据库资源(如内存、连接数等)消耗增大,特别是长事务中持有锁时间较长,会影响系统的总体性能。所以我们在使用时要特别注意不要再长事务中使用悲观锁。

2.数据库乐观锁

乐观锁假定并发冲突不太可能发生,因此在读取数据时不锁定资源,而是在更新数据时验证数据是否被其他事务修改过。

在数据库表中添加一个version字段。

ALTER TABLE `tb_inventory` ADD COLUMN `version` INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本' AFTER available_inventory;

每次更新时将version字段加1。在更新数据时,通过UPDATE语句附带WHERE version = oldVersion条件,只有当version值不变时更新操作才会成功。若version已变,则表示数据已被其他事务修改,此次更新失败。

 <update id="updateInventorWithVersion">
    UPDATE tb_inventory
    SET available_inventory = available_inventory - #{diffInventory},
        version = #{version} + 1
    WHERE sku = #{sku}
    AND customer_no = #{customerNo}
    AND warehouse_code = #{warehouseCode}
    AND version = #{version}
    AND deleted = 0
  </update>

基于乐观锁实现的方案:

@Transactional(rollbackFor = Exception.class)
@Override
public void confirmOrderWithVersion(String customerNo, String orderNo) {
    // 查询订单信息
    OutboundOrderDO outboundOrderDO = outboundOrderMapper.selectOrderByOrderNo(customerNo, orderNo);
    String warehouseCode = outboundOrderDO.getWarehouseCode();

    // 查询订单明细  假设我们的出库订单是一单一件
    OutboundOrderDetailDO detailDO = orderDetailMapper.selectDetailByOrderNo(outboundOrderDO.getOrderNo());
    String sku = detailDO.getSku();
    Integer qty = detailDO.getQty();

    // 查询库存
    TbInventoryDO inventoryDO = tbInventoryMapper.selectSkuInventory(customerNo, warehouseCode, sku);
    Integer availableInventory = inventoryDO.getAvailableInventory();
    Integer curVersion = inventoryDO.getVersion();
    // 判断库存是否足够
    if (qty > availableInventory){
        System.err.println("库存不足,不能出库");
        throw new ServiceException(StatusEnum.SERVICE_ERROR, "库存不足,不能出库");
    }

    // 扣减库存
    TbInventoryDO updateInventory = new TbInventoryDO();
    updateInventory.setCustomerNo(customerNo);
    updateInventory.setWarehouseCode(warehouseCode);
    updateInventory.setSku(sku);
    // 设置当前数据版本号
    updateInventory.setVersion(curVersion);
    // 库存差值
    updateInventory.setDiffInventory(qty);
    updateInventory.setVersion(inventoryDO.getVersion());
    int updateRows = tbInventoryMapper.updateInventorWithVersion(updateInventory);
    if (updateRows != 1){
        System.err.println("更新库存时发生并发冲突,请重试");
        throw new ServiceException(StatusEnum.SERVICE_ERROR, "更新库存时发生并发冲突,请重试");
    }
}

图片

image.png

乐观锁假定大多数情况下不会有并发冲突,所以在读取数据时不立即加锁,而是等到更新数据时才去检查是否有其他事务进行了改动,这样可以减少锁的持有时间,提高了系统的并发性能。并且,乐观锁在数据更新时才检查冲突,而不是在获取数据时就加锁,所以大大降低了死锁的风险。并且因为不常加锁,所以减少了数据库级别的锁管理开销,非常适合对于读多写少的场景。

但是,当并发写入较多时,可能出现大量更新冲突,需要不断地重试事务以获得成功的更新。过多的重试可能导致性能下降,特别是在并发度极高时,可能会形成“ABA”问题。并且 在极端并发条件下,如果没有正确的重试机制或超时机制,乐观锁可能无法保证强一致性。尤其是在涉及多个表的复杂事务中,单个乐观锁可能不足以解决所有并发问题。

方案二:基于ZooKeeper或Etcd实现

除了Redis实现分布式锁,相信最多的方案就是基于ZooKeeper或Etcd实现了。ZooKeeper或Etcd实现分布式锁的优势在于:

1、两者就是分布式的,天然具备高可用能力;

2、都有临时节点的能力,能够很好支持锁超时释放等机制的实现;

3、基于顺序节点、Revision 机制等,更方便实现可重入、公平性等特性;

4、基于Watch、Revision 机制更容易避免分布式锁争抢中的「惊群效应」问题(抢占分布式锁时被频繁唤醒和重新休眠,造成浪费)。解决方案为:分布式锁释放后,只唤醒满足条件的下一个节点。

ZK实现分布式锁的基本原理是:以某个资源为目录,然后这个目录下面的节点就是我们需要获取锁的客户端,未获取到锁的客户端注册需要注册Watcher到上一个客户端。如下图所示[6]:

图片

Curator[7]封装了ZooKeeper底层的Api,使我们更加容易方便的对ZooKeeper进行操作,并且它封装了分布式锁的功能。

Etcd实现分布式锁的原理与ZK大致相同,对比大致如下[8][9]:

图片

Etcd实现分布式锁,也可以基于自带的concurrency包[10]实现。

面试官:你觉得哪种分布式锁方案是最优的?

根据上面的分析,考虑网络延迟、GC、时钟等因素后,没有一种分布式锁方案是能完美保证分布式锁的正确性(互斥性)的。

但是,对于分布式锁来说,在上层完成互斥,虽然极端情况下锁会失效,但是它可以最大程度把并发请求阻挡在外,减轻操作资源层的压力。

对于要求数据绝对正确的业务,在资源层一定要做好兜底,比如先拿到标记位,在修改共享资源之前,先校验标记位是否和之前拿到的一致,类似CAS的操作实现:

UPDATE table_name SET data = #{new_data} WHERE id = #{id} AND current_token = #{current_lock_token}

明白了这点,我们再来说下Redis与ZK/Etcd的方案主要异同点:

(1)性能:Redis基于内存,读写性能高,适合高并发。ZK/Etcd相对弱一些。

(2)运维成本:Redis更常用、是基础组件,运维也更简单。ZK/Etcd都是分布式系统,运维相对复杂一些。

(3)易用性:都有较成熟的客户端封装,差别不大。

(4)高可用:均支持,Redis采用Redlock方案,ZK/Etcd本身就是高可用的。

如下图所示:

图片

了解了优缺点后,可根据实际场景灵活选取,脱离场景,没法说有明显的优劣之分。

5.redis 的持久化机制有哪些?优缺点说说

redis 有两种持久化的方式,AOF 和 RDB.

图片

AOF:

  • redis 每次执行一个命令时,都会把这个「命令原本的语句记录到一个.aod的文件当中,然后通过fsync策略,将命令执行后的数据持久化到磁盘中」(不包括读命令),

AOF的优缺点

图片

  • AOF 的「优点」:

    • 1.AOF可以「更好的保护数据不丢失」,一般AOF会以每隔1秒,通过后台的一个线程去执行一次fsync操作,如果redis进程挂掉,最多丢失1秒的数据

    • 2.AOF是将命令直接追加在文件末尾的,「写入性能非常高」

    • 3.AOF日志文件的命令通过非常可读的方式进行记录,这个非常「适合做灾难性的误删除紧急恢复」,如果某人不小心用 flushall 命令清空了所有数据,只要这个时候还没有执行 rewrite,那么就可以将日志文件中的 flushall 删除,进行恢复

图片

  • AOF 的「缺点」:

    • 1.对于同一份数据源来说,一般情况下AOF 文件比 RDB 数据快照要大

    • 2.由于 .aof 的每次命令都会写入,那么相对于 RDB 来说「需要消耗的性能也就更多」,当然也会有 aof 重写将 aof 文件优化。

    • 3.「数据恢复比较慢」,不适合做冷备。


RDB:

  • 某个时间点 redis 内存中的数据以二进制的形式存储的一个.rdb为后缀的文件当中,也就是「周期性的备份redis中的整个数据」,这是redis默认的持久化方式,也就是我们说的快照(snapshot),是采用 fork 子进程的方式来写时同步的。

RDB的优缺点

图片

  • RDB的优点:

    • 1.它是将某一时间点redis内的所有数据保存下来,所以当我们做「大型的数据恢复时,RDB的恢复速度会很快」

    • 2.由于RDB的FROK子进程这种机制,队友给客户端提供读写服务的影响会非常小

图片

  • RDB的缺点:

    • 举个例子假设我们定时5分钟备份一次,在10:00的时候 redis 备份了数据,但是如果在10:04的时候服务挂了,那么我们就会丢失在10:00到10:04的整个数据

    • 1:「有可能会产生长时间的数据丢失」

    • 2:可能会有长时间停顿:我们前面讲了,fork 子进程这个过程是和 redis 的数据量有很大关系的,如果「数据量很大,那么很有可能会使redis暂停几秒」

6. Redis的过期键的删除策略有哪些?

过期策略通常有以下三种:

图片

  • 定时过期每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

  • 惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。

  • 定期过期每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

7. Redis的内存满了怎么办?

实际上Redis定义了「8种内存淘汰策略」用来处理redis内存满的情况:

  • 1.noeviction:直接返回错误,不淘汰任何已经存在的redis键

  • 2.allkeys-lru:所有的键使用lru算法进行淘汰

  • 3.volatile-lru:有过期时间的使用lru算法进行淘汰

  • 4.allkeys-random:随机删除redis键

  • 5.volatile-random:随机删除有过期时间的redis键

  • 6.volatile-ttl:删除快过期的redis键

  • 7.volatile-lfu:根据lfu算法从有过期时间的键删除

  • 8.allkeys-lfu:根据lfu算法从所有键删除

8.Redis 的热 key 问题怎么解决?

热 key  就是说,在某一时刻,有非常多的请求访问某个 key,流量过大,导致该 redi 服务器宕机

图片

解决方案:

  • 可以将结果缓存到本地内存中

  • 将热 key 分散到不同的服务器中

  • 设置永不过期

9.缓存击穿、缓存穿透、缓存雪崩是什么?怎么解决呢?

缓存穿透:

  • 缓存穿透是指用户请求的数据在缓存中不存在并且在数据库中也不存在,导致用户每次请求该数据都要去数据库中查询一遍,然后返回空。

图片

解决方案:

  • 布隆过滤器

  • 返回空对象

缓存击穿:

  • 缓存击穿,是指一个 key 非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个 key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。

图片

解决方案:

  • 互斥锁

  • 永不过期

缓存雪崩:

  • 缓存雪崩是指缓存中不同的数据大批量到过期时间,而查询数据量巨大,请求直接落到数据库上导致宕机。

图片

解决方案:

  • 均匀过期

  • 加互斥锁

  • 缓存永不过期

  • 双层缓存策略

10.Redis 有哪些部署方式?

图片

  • 单机模式:这也是最基本的部署方式,只需要一台机器,负责读写,一般只用于开发人员自己测试

  • 哨兵模式:哨兵模式是一种特殊的模式,首先Redis提供了哨兵的命令,哨兵是一个独立的进程,作为进程,它会独立运行。其原理是哨兵通过发送命令,等待Redis服务器响应,从而监控运行的多个Redis实例。它具备自动故障转移、集群监控、消息通知等功能。

  • cluster集群模式:在redis3.0版本中支持了cluster集群部署的方式,这种集群部署的方式能自动将数据进行分片,每个master上放一部分数据,提供了内置的高可用服务,即使某个master挂了,服务还可以正常地提供。

  • 主从复制:在主从复制这种集群部署模式中,我们会将数据库分为两类,第一种称为主数据库(master),另一种称为从数据库(slave)。主数据库会负责我们整个系统中的读写操作,从数据库会负责我们整个数据库中的读操作。其中在职场开发中的真实情况是,我们会让主数据库只负责写操作,让从数据库只负责读操作,就是为了读写分离,减轻服务器的压力。

11.哨兵有哪些作用?

  • 1.监控整个主数据库和从数据库,观察它们是否正常运行

  • 2.当主数据库发生异常时,自动的将从数据库升级为主数据库,继续保证整个服务的稳定

12.哨兵选举过程是怎么样的?

  • 1.第一个发现该master挂了的哨兵,向每个哨兵发送命令,让对方选举自己成为领头哨兵

  • 2.其他哨兵如果没有选举过他人,就会将这一票投给第一个发现该master挂了的哨兵

  • 3.第一个发现该master挂了的哨兵如果发现由超过一半哨兵投给自己,并且其数量也超过了设定的quoram参数,那么该哨兵就成了领头哨兵

  • 4.如果多个哨兵同时参与这个选举,那么就会重复该过程,知道选出一个领头哨兵

选出领头哨兵后,就开始了故障修复,会从选出一个从数据库作为新的master

13.cluster集群模式是怎么存放数据的?

一个cluster集群中总共有16384个节点,集群会将这16384个节点平均分配给每个节点,当然,我这里的节点指的是每个主节点,就如同下图:

图片

14.cluster的故障恢复是怎么做的?

判断故障的逻辑其实与哨兵模式有点类似,在集群中,每个节点都会定期的向其他节点发送ping命令,通过有没有收到回复来判断其他节点是否已经下线。

如果长时间没有回复,那么发起ping命令的节点就会认为目标节点疑似下线,也可以和哨兵一样称作主观下线,当然也需要集群中一定数量的节点都认为该节点下线才可以,我们来说说具体过程:

图片

  • 1.当A节点发现目标节点疑似下线,就会向集群中的其他节点散播消息,其他节点就会向目标节点发送命令,判断目标节点是否下线

  • 2.如果集群中半数以上的节点都认为目标节点下线,就会对目标节点标记为下线,从而告诉其他节点,让目标节点在整个集群中都下线

15.主从同步原理是怎样的?

  • 1.当一个从数据库启动时,它会向主数据库发送一个SYNC命令,master收到后,在后台保存快照,也就是我们说的RDB持久化,当然保存快照是需要消耗时间的,并且redis是单线程的,在保存快照期间redis受到的命令会缓存起来

  • 2.快照完成后会将缓存的命令以及快照一起打包发给slave节点,从而保证主从数据库的一致性。

  • 3.从数据库接受到快照以及缓存的命令后会将这部分数据写入到硬盘上的临时文件当中,写入完成后会用这份文件去替换掉RDB快照文件,当然,这个操作是不会阻塞的,可以继续接收命令执行,具体原因其实就是fork了一个子进程,用子进程去完成了这些功能。

因为不会阻塞,所以,这部分初始化完成后,当主数据库执行了改变数据的命令后,会异步的给slave,这也就是我们说的复制同步阶段,这个阶段会贯穿在整个中从同步的过程中,直到主从同步结束后,复制同步才会终止。

16.无硬盘复制是什么?

我们刚刚说了主从之间是通过RDB快照来交互的,虽然看来逻辑很简单,但是还是会存在一些问题,但是会存在着一些问题。

  • 1.master禁用了RDB快照时,发生了主从同步(复制初始化)操作,也会生成RDB快照,但是之后如果master发成了重启,就会用RDB快照去恢复数据,这份数据可能已经很久了,中间就会丢失数据

  • 2.在这种一主多从的结构中,master每次和slave同步数据都要进行一次快照,从而在硬盘中生成RDB文件,会影响性能

为了解决这种问题,redis在后续的更新中也加入了无硬盘复制功能,也就是说直接通过网络发送给slave,避免了和硬盘交互,但是也是有io消耗

Redis高并发

1 Redis 为什么可以抗高并发?

首先,Redis使用内存存储数据,避免了磁盘I/O的开销,提高了数据访问的速度。其次,Redis拥有丰富的对象类型,包含八种类型,满足不同的需求。此外,Redis采用了高效的数据结构,减少了内存占用和计算复杂度。Redis还使用单线程模型,避免了多线程之间的上下文切换和竞争条件,提升了CPU利用率。最后,Redis使用非阻塞I/O多路复用机制(多路复用IO模型实际也是传统阻塞型IO模型演化而来的),充分利用CPU和网络资源,提高了并发处理能力。

2 Redis 为什么使用单线程却依旧可以抗高并发?

面试官考察目的分析:

  1. 考察你对于Redis原理的理解程度;

  2. 考察你对于网络连接的理解程度;

首先Redis为什么选择单线程的实现方式:

  • 从Redis自身特性来说,Redis是基于内存的数据库,所以数据处理速度非常快。另外它的底层使用了很多效率很高的数据结构,如哈希表和跳表等。另外Redis从狭义上面来说他是单线程的,网络请求解析与数据读写都是由主线程完成。因此它内部就省去了很多多线程访问共享数据资源的繁琐设计,同时也避免了频繁的线程上下文切换因此减少了多线程的系统开销。

候选者:Redis 为什么可以抗高并发。

  • 其次从IO模型角度来说,Redis使用的是IO多路复用模型,使得它可以在网络IO操作并发处理数十万的客户端网络连接,实现非常高的网络吞吐率。这也是Redis可以实现高并发访问的最主要的原因。

还有一点就是常识问题:我们都知道磁盘的寻址是ms级别的,带宽是G/M(也就是千兆位每秒或兆位每秒)。而内存寻址是ns级别的,并且带宽远比磁盘快得多。从这个角度来看Redis基于内存的数据库快是毋庸置疑的。

3 刚才你提到了IO多路复用模型,其实也就是Redis 线程模型,能详细说下Redis的IO多路复用的原理吗?

候选者:好的。首先我们要明确知道Redis 服务器是一个事件驱动程序, 服务器处理的事件分为文件事件时间事件两类。

  • 文件事件:Redis 主进程中,主要处理客户端的连接请求与响应。

  • 时间事件:fork 出的子进程中,处理如 AOF 持久化任务等。

候选者:之所以 Redis 的文件事件是单进程,单线程模型,但是确保持着优秀的吞吐量,IO 多路复用起到了主要作用。

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

  • IO 多路复用程序负责监听多个套接字并向文件事件分派器传送那些产生了事件的套接字。文件事件分派器接收 IO 多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。示例如图所示:

图片

候选者:而Redis 的 IO 多路复用程序的所有功能都是通过包装常见的 selectpollevport 和 kqueue 这些 IO 多路复用函数库来实现的,每个 IO 多路复用函数库在 Redis 源码中都有对应的一个单独的文件。Redis 为每个 IO 多路复用函数库都实现了相同的 API,所以 IO 多路复用程序的底层实现是可以互换的。如图:

图片

候选者:因此Redis 把所有连接与读写事件、还有提到的时间事件一起集中管理,并对底层 IO 多路复用机制进行了封装,最终实现了单进程能够处理多个连接以及读写事件。这就是 IO 多路复用在 redis 中的应用。

17 Redis内存管理

过期键删除

Redis支持为键设置过期时间(TTL),并且在键过期后会通过两种方式自动删除它们:

  1. 惰性删除(Lazy Expire):在访问某个键时,Redis会检查该键是否已经过期,如果已经过期,则在访问时将其删除。这意味着只有当有客户端尝试访问过期的键时,Redis才会执行删除操作。这种方式的优势在于避免了不必要的操作,只有在需要时才进行删除,但缺点是可能会导致过期键在一段时间内仍然占用内存。

  2. 定期删除(Active Expire):Redis周期性地(默认每秒10次)随机抽取一部分键,并检查它们的过期时间。如果发现某个键已经过期,则立即将其删除。这种方式可以保证过期键在一定时间内被及时删除,避免了过期键长时间占用内存。但定期删除会带来额外的CPU消耗,因为需要在每次抽取时检查键的过期时间。

这两种方式结合起来,可以有效地管理和清理过期键,保证Redis的内存使用在合理范围内。同时,我们在日常开发中可以根据具体业务场景和需求调整过期策略的配置,以达到最佳的性能和内存利用率。

内存淘汰策略

内存淘汰策略是Redis用于释放内存空间的一种机制,当内存空间不足时(达到或超过了配置的maxmemory),Redis会根据预先设置的淘汰策略来选择要删除的键,从而释放内存空间。通过合理选择和配置内存淘汰策略,可以有效地管理内存使用,防止内存溢出,并保证系统的稳定性和性能。

常见的内存淘汰策略:

  1. LRU(最近最少使用)
    LRU策略会删除最近最少被访问的键。Redis会记录每个键最后一次被访问的时间戳,并定期检查这些时间戳,选择最久未被访问的键进行删除。LRU策略适用于缓存场景,通常最久未被访问的键可能是最不常用的,因此删除这些键可以释放更多的内存空间。

  2. LFU(最不经常使用)
    LFU策略会删除最不经常被访问的键。Redis会记录每个键被访问的频率,并定期检查这些频率,选择访问频率最低的键进行删除。LFU策略适用于对访问频率较低的键进行淘汰,从而释放内存空间。

  3. TTL(键的过期时间)
    TTL策略会删除已经过期的键。Redis会定期检查键的过期时间,并删除已经过期的键。通过设置键的过期时间,可以自动清理不再需要的数据,释放内存空间。

  4. 随机删除
    随机删除策略会随机选择一些键进行删除。虽然这种策略不考虑键的使用频率或过期时间,但在某些情况下可能会是一种简单且有效的淘汰方式,尤其是在内存空间不足时。

  5. 淘汰固定数量的键
    淘汰固定数量的键策略会选择要删除的键的数量,然后按照一定的规则(如LRU或LFU)来选择要删除的键。这种策略可以保证每次淘汰都释放固定数量的内存空间。

当Redis的内存使用达到配置的maxmemory限制时,就会触发内存淘汰策略,以释放内存空间。合理选择内存淘汰策略,并根据系统的需求设置maxmemory参数,可以有效地管理内存使用,保证系统的稳定性和性能。通过合理配置内存限制和内存淘汰策略,可以有效地管理Redis的内存使用,保证系统在内存空间不足时能够及时释放内存,避免因内存溢出而导致系统性能下降或者崩溃。

修改内存maxmemory只需要在redis.conf配置文件中配置maxmemory-policy参数即可。

内存碎片管理

内存碎片整理是指对Redis中的内存空间进行重新排列和整理,以减少内存碎片的数量和大小。内存碎片是指已分配但不再使用的内存块,这些内存块虽然被标记为已分配,但实际上并未被有效利用,造成了内存的浪费。

在Redis中,由于数据的增删改查操作不断进行,会导致内存空间中出现大量的内存碎片。这些内存碎片虽然单个很小,但如果积累起来会导致内存碎片化,降低内存利用率,影响系统的性能和稳定性。

为了解决内存碎片化的问题,Redis会定期进行内存碎片整理操作。内存碎片整理过程包括以下几个步骤:

  1. 遍历内存空间:Redis会遍历整个内存空间,检查每个内存块的状态,包括已分配和未分配的内存块。

  2. 合并相邻的空闲内存块:Redis会尝试合并相邻的空闲内存块,将它们合并成一个更大的内存块。这样可以减少内存碎片的数量,提高内存利用率。

  3. 移动数据:如果有必要,Redis可能会将数据从一个内存块移动到另一个内存块,以便更好地组织内存空间。这个过程可能会比较耗时,因为需要将数据从一个位置复制到另一个位置。

  4. 释放不再使用的内存块:最后,Redis会释放那些不再使用的内存块,以便它们可以被重新分配给新的数据。

通过定期进行内存碎片整理操作,Redis可以保持内存空间的连续性,减少内存碎片化的程度,提高内存利用率,从而提高系统的性能和稳定性。但是,内存碎片整理过程可能会消耗一定的系统资源,尤其是在内存碎片较多的情况下。所以,通常情况下,Redis会选择在系统负载较低的时候进行碎片整理操作,以避免对系统性能产生不利影响。

  • 19
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值