X Tutup
Skip to content

Latest commit

 

History

History
2418 lines (1465 loc) · 151 KB

File metadata and controls

2418 lines (1465 loc) · 151 KB
title Redis 面试专场
date 2024-05-31
tags
Redis
Interview
categories Interview

**导读:**不管哪个模板的面试题,其实都是分原理和实践两部分。所以两方面都要准备。

比如你们项目是怎么用缓存的,服务是怎么部署的,不要像有些同学自己项目中的 Redis 是集群部署还是哨兵都不清楚。

面试是需要准备的~

一、Redis 基础问题

Redis是什么?

Redis:REmote DIctionary Server(远程字典服务器)。

Redis 是一个全开源免费(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件

和 Memcached 类似,它支持存储的 value 类型相对更多,包括string(字符串)、list(链表)、set(集合)、zset(sorted set --有序集合)和hash(哈希类型)、bitmap、hyperloglog、GeoHash、streams。这些数据类型都支持push/pop、add/remove及取交集并集和差集及更丰富的操作,而且这些操作都是原子性的。

Redis 内置了复制(Replication),LUA脚本(Lua scripting), LRU驱动事件(LRU eviction),事务(Transactions) 和不同级别的磁盘持久化(Persistence),并通过 Redis 哨兵(Sentinel)和自动分区(Cluster)提供高可用性(High Availability)。

  • 性能优秀,数据在内存中,读写速度非常快,支持并发10W QPS
  • 单进程单线程,是线程安全的,采用IO多路复用机制
  • Redis 数据库完全在内存中,使用磁盘仅用于持久性
  • 相比许多键值数据存储,Redis 拥有一套较为丰富的数据类型
  • 操作都是原子性:所有 Redis 操作是原子的,这保证了如果两个客户端同时访问的Redis服务器将获得更新后的值
  • Redis 可以将数据复制到任意数量的从服务器(主从复制,哨兵,高可用)

为什么要用缓存?为什么使用 Redis?

提一下现在 Web 应用的现状

在日常的 Web 应用对数据库的访问中,读操作的次数远超写操作,比例大概在 1:93:7,所以需要读的可能性是比写的可能大得多的。当我们使用 SQL 语句去数据库进行读写操作时,数据库就会 去磁盘把对应的数据索引取回来,这是一个相对较慢的过程。

使用 Redis or 使用缓存带来的优势

如果我们把数据放在 Redis 中,也就是直接放在内存之中,让服务端直接去读取内存中的数据,那么这样 速度 明显就会快上不少 (高性能),并且会 极大减小数据库的压力 (特别是在高并发情况下)

也要提一下使用缓存的考虑

但是使用内存进行数据存储开销也是比较大的,限于成本 的原因,一般我们只是使用 Redis 存储一些 常用和主要的数据,比如用户登录的信息等。

一般而言在使用 Redis 进行存储的时候,我们需要从以下几个方面来考虑:

  • 业务数据常用吗?命中率如何? 如果命中率很低,就没有必要写入缓存;
  • 该业务数据是读操作多,还是写操作多? 如果写操作多,频繁需要写入数据库,也没有必要使用缓存;
  • 业务数据大小如何? 如果要存储几百兆字节的文件,会给缓存带来很大的压力,这样也没有必要;

在考虑了这些问题之后,如果觉得有必要使用缓存,那么就使用它!

用缓存,肯定是因为他快,那 Redis 为什么这么快?

  • 纯内存操作:读取不需要进行磁盘 I/O,所以比传统数据库要快上不少;(但不要有误区说磁盘就一定慢,例如 Kafka 就是使用磁盘顺序读取但仍然较快)

  • 用 hash table 作为键空间,查找任意的 key 只需 $O(1)$

  • 单线程,无锁竞争:天生的队列模式,避免了因多线程竞争而导致的上下文切换和抢锁的开销

  • 事件机制,Redis服务器将所有处理的任务分为两类事件,一类是采用 I/O 多路复用处理客户端请求的网络事件;一类是处理定时任务的时间事件,包括更新统计信息、清理过期键、持久化、主从同步等;

    • 多路 I/O 复用模型,非阻塞 I/O:采用多路 I/O 复用技术可以让单个线程高效的处理多个网络连接请求(尽量减少网络 IO 的时间消耗);

    • Redis 基于 Reactor 模式开发了自己的网络事件处理器,这个处理器被称为文件事件处理器 file event handler。由于这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型,但是它采用 IO 多路复用机制同时监听多个Socket,并根据Socket上的事件来选择对应的事件处理器进行处理。

    多个 Socket 可能会产生不同的操作,每个操作对应不同的文件事件,但是IO多路复用程序会监听多个Socket,将Socket产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

    Redis客户端对服务端的每次调用都经历了发送命令,执行命令,返回结果三个过程。其中执行命令阶段,由于Redis是单线程来处理命令的,所有每一条到达服务端的命令不会立刻执行,所有的命令都会进入一个队列中,然后逐个被执行。并且多个客户端发送的命令的执行顺序是不确定的。但是可以确定的是不会有两条命令被同时执行,不会产生并发问题,这就是Redis的单线程基本模型。

I/O多路复用,I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。串起来理解就是很多个网络I/O复用一个或少量的线程来处理这些连接。

IO 多路复用是 Redis 快速响应众多客户端请求的核心技术之一。IO 多路复用允许 Redis 同时监听多个文件描述符(通常是网络套接字),当其中任何一个描述符就绪(可读或可写)时,程序可以进行相应的处理。这样可以在单线程中高效处理大量并发连接。

常见的 IO 多路复用技术包括:

  • select:最古老的 IO 多路复用技术,支持的文件描述符数量有限。
  • poll:与 select 类似,但没有文件描述符数量的限制。
  • epoll:Linux 上的高效 IO 多路复用机制,适合大量并发连接。

Redis 在不同平台上会选择不同的 IO 多路复用实现。例如,在 Linux 上使用 epoll,在 MacOS 上使用 kqueue,在 Windows 上使用 IOCP。

当然这种单线程事件机制也是有缺陷的,由于所有的事件都是串行执行,一旦某个事件比较重就会阻塞其它事件,从而导致整个系统的吞吐率下降。比如某个客户端执行了一个比较重的lua函数、或者使用了诸如keys*、zrange(0,-1)、hgetall等全集合扫描的操作,又或者删除的过期键是个big key,又或者使用了较多内存的redis实例进行bgsave时,都会导致服务器一定程度的阻塞,一般伴随会有相应的慢日志。所以我们在实际使用redis的过程中,必须要给每一次的操作分配合理的时间片。

  • Redis直接自己构建了VM 机制 ,避免调用系统函数的时候,浪费时间去移动和请求
  • 高效的数据结构,加上底层做了大量优化:Redis 对于底层的数据结构和内存占用做了大量的优化,例如不同长度的字符串使用不同的结构体表示,HyperLogLog 的密集型存储结构等等..

Redis 属于单线程还是多线程?

这道题其实就在考察 Redis 的线程模型(这几乎是 Redis 必问的问题之一)。

很多初级研发工程师基本都知道 Redis 是单线程的,并且能说出 Redis 单线程的一些优缺点,比如,实现简单,可以在无锁的情况下完成所有操作,不存在死锁和线程切换带来的性能和时间上的开销,但同时单线程也不能发挥多核 CPU 的性能。

很明显,如果你停留在上面的回答思路上,只能勉强及格,因为对于这样一道经典的面试题,你回答得没有亮点,几乎丧失了机会。一个相对完整的思路应该基于 Redis 单线程,补充相关的知识点,比如:

如:

Redis 只有单线程吗?

业界说 Redis 是单线程的,是指它在处理命令的时候,是单线程的。在 Redis 6.0 之 前,Redis 的 IO 也是单线程的,但是在 6.0 之后也改成了多线程。

但 Redis 的持久化、集群同步等操作,则是由另外的线程来执行的。

Redis 采用单线程为什么还这么快?

一般来说,单线程的处理能力应该要比多线程差才对,但为什么 Redis 还能达到每秒数万级的处理能力呢?主要有如下几个原因。

  • 首先,一个重要的原因是,Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,比如哈希表和跳表。

  • 其次,因为是单线程模型避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。

  • 最后,也是最重要的一点, Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,这让 Redis 可以高效地进行网络通信,因为基于非阻塞的 I/O 模型,就意味着 I/O 的读写流程不再阻塞。

但是因为 Redis 不同版本的特殊性,所以对于 Redis 的线程模型要分版本来看。

Redis 4.0 版本之前,使用单线程速度快的原因就是上述的几个原因;

Redis 4.0 版本之后,Redis 添加了多线程的支持,但这时的多线程主要体现在大数据的异步删除功能上,例如 unlink key、flushdb async、flushall async 等。

Redis 6.0 版本之后,为了更好地提高 Redis 的性能,新增了多线程 I/O 的读写并发能力,但是在面试中,能把 Redis 6.0 中的多线程模型回答上来的人很少,如果你能在面试中补充 Redis 6.0 多线程的原理,势必会增加面试官对你的认可。

你可以在面试中这样补充:

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

Redis 使用了 Reactor 设计模式来实现 I/O 多路复用,它通过一个或多个 Reactor 线程来监听文件描述符的状态变化,当有事件发生时,将事件分发给相应的处理程序进行处理 。Redis 6.0 之后的版本采用了多线程模式进行网络处理,但是 I/O 多路复用模型仍然存在,变化不大。

Redis 的 I/O 多路复用模型使用的函数可以是 select(在一些其他系统上)、poll(在 Linux 上)、epollkqueue(在 BSD 系统上)。其中,select 作为备选方案,由于其限制和性能问题,通常不是首选。而 epoll 是 Linux 系统上的高性能选择,特别是在处理大量并发连接时。

以下是 Redis 多路复用的工作原理:

1. 多路复用简介

多路复用是一种 I/O 复用技术,可以让一个线程监视多个文件描述符(FD),一旦某个描述符准备好进行 I/O 操作(如读或写),程序就可以对其进行相应的处理。这样可以有效地提高系统资源利用率和并发性能。

2. Redis 使用的多路复用机制

Redis 根据不同操作系统,选择最合适的多路复用机制:

  • 在 Linux 系统上,使用 epoll
  • 在 BSD 系统上,使用 kqueue
  • 在 Solaris 上,使用 evport
  • 在其他一些系统上,使用 select

3. 工作流程

以下是 Redis 使用多路复用的工作流程:

  1. 初始化事件循环: Redis 在启动时会初始化一个事件循环,其中包含一个事件表,用于记录所有需要监视的文件描述符及其相关的事件(如可读、可写)。
  2. 注册事件: 当 Redis 需要监视某个文件描述符(如新客户端连接或现有客户端的读写操作)时,会将该描述符及其事件类型(如读、写)注册到事件表中。
  3. 等待事件触发: Redis 使用多路复用函数(如 epoll_waitkqueueselect)等待注册的文件描述符上有事件发生。这个函数会阻塞,直到至少有一个文件描述符准备好进行 I/O 操作。
  4. 处理事件: 一旦多路复用函数返回,Redis 会遍历触发事件的文件描述符,并对其进行相应的处理。这可能包括读取客户端请求、向客户端发送响应、接受新的连接等。
  5. 事件处理完毕后继续等待: 处理完所有触发的事件后,Redis 会再次调用多路复用函数,继续等待新的事件发生。

4. 优点

Redis 使用多路复用的优点包括:

  • 高效性:可以高效地监视大量的文件描述符,并且只在有事件发生时才进行处理,避免了轮询方式的高开销。
  • 单线程模型:通过多路复用,Redis 能够在单线程中高效地处理大量的并发连接,简化了编程模型和线程间同步问题。
  • 跨平台性:Redis 封装了多种操作系统的多路复用机制,使其可以在不同平台上高效运行。

为什么早期版本的 Redis 选择单线程?

我们首先要明白,上边的种种分析,都是为了营造一个 Redis 很快的氛围!官方FAQ表示,因为Redis是基于内存的操作,CPU不是Redis的瓶颈,Redis 的瓶颈最有可能是机器内存的大小或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了(毕竟采用多线程会有很多麻烦!)。

看到这里,你可能会气哭!本以为会有什么重大的技术要点才使得Redis使用单线程就可以这么快,没想到就是一句官方看似糊弄我们的回答!但是,我们已经可以很清楚的解释了为什么 Redis 这么快,并且正是由于在单线程模式的情况下已经很快了,就没有必要在使用多线程了!

简单总结一下

  1. 使用单线程模型能带来更好的可维护性,方便开发和调试;
  2. 使用单线程模型也能并发的处理客户端的请求;
  3. Redis 服务中运行的绝大多数操作的性能瓶颈都不是 CPU;

这里我们一直在强调的单线程,只是在处理我们的网络请求的时候只有一个线程来处理,一个正式的 Redis Server 运行的时候肯定是不止一个线程的,这里需要大家明确的注意一下!例如 Redis 进行持久化的时候会以子进程或者子线程的方式执行;

Redis 选择使用单线程模型处理客户端的请求主要还是因为 CPU 不是 Redis 服务器的瓶颈,所以使用多线程模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;

而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率。

在高并发场景下可以利用多个线程 并发处理 IO 任务、命令解析和数据回写。这些线程也被叫做 IO 线程。默认情况下,多线程模式是被禁用了的,需要显式地开启。

推荐阅读:https://draveness.me/whys-the-design-redis-single-thread/

Redis 和 Memcached 的区别

  1. 存储方式上:memcache 会把数据全部存在内存之中,断电后会挂掉,数据不能超过内存大小。redis 有部分数据存在硬盘上,这样能保证数据的持久性。
  2. 数据支持类型上:memcache 对数据类型的支持简单,只支持简单的 key-value,而 redis 支持五种数据类型。
  3. 使用底层模型不同:它们之间底层实现方式以及与客户端之间通信的应用协议不一样。redis 直接自己构建了 VM 机制,因为一般的系统调用系统函数的话,会浪费一定的时间去移动和请求。
  4. value 的大小:redis 可以达到 512M,而 memcache 只有 1M。

key 最大是多少 ,单个实例最多支持多少个key

Redis 对键(key)的最大长度和单个实例最多支持的键数量有明确的限制。下面详细解释这些限制:

  1. 键(Key)的最大长度

​ Redis 对每个键的最大长度有严格的限制。根据 Redis 的官方文档:键的最大长度为 512 MB(512 * 1024 * 1024 bytes)。

​ 虽然 Redis 允许非常长的键,但在实际应用中建议避免使用过长的键,以提高内存利用效率和操作性能。

  1. 单个实例最多支持的键数量

    Redis 是一个内存数据库,理论上它可以存储的键数量没有严格的上限,取决于可用内存和系统的限制。然而,实际应用中单个实例的键数量会受到以下因素的限制:

    • 内存限制

      Redis 存储的数据都在内存中,因此可用内存是决定单个实例能存储多少键的主要因素。Redis 可以使用的最大内存量取决于机器的物理内存和 Redis 的配置。

      配置最大内存:通过配置 maxmemory 参数,可以限制 Redis 使用的最大内存量。例如,在 redis.conf 文件中设置:maxmemory 4GB

      这将限制 Redis 使用的最大内存为 4 GB。当达到这个限制时,可以通过 maxmemory-policy 参数配置内存淘汰策略,如 LRU(Least Recently Used)、LFU(Least Frequently Used)、ALLKEYS-RANDOM 等。

    • 数据结构的内存开销

      不同的数据结构(如字符串、哈希、列表、集合、有序集合)在 Redis 中的内存开销不同。每个键值对的存储不仅包括键和值的实际内容,还包括 Redis 内部维护的数据结构(如哈希表节点、指针等)的开销。

Redis 内部实现限制

虽然 Redis 没有明确限制单个实例的最大键数量,但 Redis 的哈希表实现有默认的初始大小和扩展策略:

  • 初始哈希表大小:Redis 默认初始化的哈希表大小较小,但会根据键数量自动扩展。

  • 扩展策略:Redis 使用渐进式 rehashing 来扩展哈希表,以减少扩展过程中对性能的影响。

为什么 Redis 不建议 key 太长,原理?

Redis 的键(key)设计不建议太长,这主要是出于以下几个方面的考虑:

  1. 内存使用效率
    • Redis 的键和值在内存中是成对存储的。键过长意味着每个键值对占用的内存空间会增加,这会降低内存的使用效率。
    • 较长的键名会增加内存占用,因为 Redis 需要为每个键分配额外的内存空间来存储键名。
  2. 性能影响
    • 键的查找、存储和删除操作都需要对键名进行处理。键名较长会增加这些操作的执行时间,从而影响性能。
    • 特别是当使用具有前缀或模式匹配的键进行操作时,如 keys 命令或 SCAN 命令,长键名会增加处理时间,影响性能。
  3. 网络传输效率
    • 在客户端与 Redis 服务器之间传输数据时,键名也是需要传输的一部分。键名较长会增加网络传输的数据量,降低传输效率。
  4. 可读性和维护性
    • 较短的键名通常更易于理解和维护。长键名可能会使得代码更难阅读,也更容易出错。
    • 使用有意义的短键名可以提高代码的可读性和可维护性。
  5. 散列算法的影响
    • Redis 使用哈希表来存储键值对,长键名在哈希算法中可能会产生更多的冲突,这会增加处理哈希冲突的复杂性。
  6. 限制和配置
    • Redis 并没有严格的键名长度限制,但是过长的键名可能会受到特定 Redis 配置或客户端库的限制。
  7. 命令行和脚本处理
    • 在使用命令行工具或编写自动化脚本时,长键名可能会使得命令行变得复杂,增加出错的风险。

因此,为了优化内存使用、提高性能、简化开发和维护,通常建议设计键名时尽量简短且具有描述性。在实际应用中,可以根据业务逻辑和需求来设计合适的键名长度,以平衡可读性和性能。

最后总结下 Redis 优缺点

优点

  • 读写性能优异, Redis能读的速度是 110000 次/s,写的速度是 81000 次/s。
  • 支持数据持久化,支持 AOF 和 RDB 两种持久化方式。
  • 支持事务,Redis 的所有操作都是原子性的,同时 Redis 还支持对几个操作合并后的原子性执行。
  • 数据结构丰富,除了支持 string 类型的 value 外还支持 hash、set、zset、list 等数据结构。
  • 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。

缺点

  • 数据库 容量受到物理内存的限制,不能用作海量数据的高性能读写,因此 Redis 适合的场景主要局限在较小数据量的高性能操作和运算上。
  • Redis 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的 IP 才能恢复。
  • 主机宕机,宕机前有部分数据未能及时同步到从机,切换 IP 后还会引入数据不一致的问题,降低了 系统的可用性
  • Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。

二、Redis 数据结构问题

Redis 都支持哪些数据类型

首先在 Redis 内部会使用一个 RedisObject 对象来表示所有的 keyvalue

Redis 提供了五种基本数据类型,String、Hash、List、Set、Zset(sorted set:有序集合)

Redis 不是简单的键值存储,它实际上是一个数据结构服务器,支持不同类型的值。

  • String(字符串):二进制安全字符串
  • List(列表):根据插入顺序排序的字符串元素的集合。它们基本上是链表
  • Hash(字典):是一个键值对集合。KV模式不变,但V是一个键值对
  • Set(集合):唯一,未排序的字符串元素的集合
  • zset(sorted set:有序集合):相当于有序的 Set集合,每个字符串元素都与一个称为 score 的浮点值相关联。元素总是按它们的分数排序(eg,找出前10名或后10名)

除了支持最 基础的五种数据类型 外,还支持一些 高级数据类型

  • Bit arrays (位数组,简称位图 bitMap):
  • HyperLogLog():这是一个概率数据结构,用于估计集合的基数
  • Geo
  • Stream:

由于 Redis 是基于标准 C 写的,只有最基础的数据类型,因此 Redis 为了满足对外使用的 5 种数据类型,开发了属于自己独有的一套基础数据结构,使用这些数据结构来实现5种数据类型。

Redis底层的数据结构包括:简单动态数组SDS、链表、字典、跳跃链表、整数集合、压缩列表、对象。

Redis 为了平衡空间和时间效率,针对 value 的具体类型在底层会采用不同的数据结构来实现,其中哈希表和压缩列表是复用比较多的数据结构,如下图展示了对外数据类型和底层数据结构之间的映射关系:

源码中,redisObject(或 robj)是 Redis 用于表示数据对象的核心结构。每一个 Redis 数据对象,无论是字符串、列表、集合、哈希还是有序集合,都会被封装在一个 redisObject 结构体中

//简化版
typedef struct redisObject {
    unsigned type:4;          // 数据类型
    unsigned encoding:4;      // 对象的编码方式
    unsigned lru:LRU_BITS;    // LRU 时间,用于内存淘汰策略
    int refcount;             // 引用计数
    void *ptr;                // 指向实际存储数据的指针
} robj;

redisObject 各字段解析

  • type:用来标识对象的数据类型(如字符串、列表、集合等)。Redis 支持的几种核心数据类型在源码中定义为常量,例如:

    #define OBJ_STRING 0  // 字符串
    #define OBJ_LIST 1    // 列表
    #define OBJ_SET 2     // 集合
    #define OBJ_ZSET 3    // 有序集合
    #define OBJ_HASH 4    // 哈希表
  • encoding:用来标识对象的具体编码方式,也就是该对象的底层数据结构(如 intset, ziplist, hashtable 等)。常见的编码方式如下:

    #define OBJ_ENCODING_RAW 0         // 普通字符串编码
    #define OBJ_ENCODING_INT 1         // 整数编码
    #define OBJ_ENCODING_HT 2          // 哈希表编码
    #define OBJ_ENCODING_ZIPMAP 3      // 压缩地图编码(老版本 Redis)
    #define OBJ_ENCODING_LINKEDLIST 4  // 链表编码(旧的列表编码)
    #define OBJ_ENCODING_ZIPLIST 5     // 压缩列表编码
    #define OBJ_ENCODING_INTSET 6      // 整数集合编码
    #define OBJ_ENCODING_SKIPLIST 7    // 跳表编码(用于有序集合)
    #define OBJ_ENCODING_QUICKLIST 8   // 快速列表编码(新的列表编码)
  • lru:用于记录该对象的最后访问时间,Redis 使用这个字段来实现内存淘汰策略(LRU,最近最少使用算法)。在新的 Redis 版本中,它可能改为 LRU 或 LFU(频率淘汰)。

  • refcount:对象的引用计数。Redis 使用引用计数机制来管理对象的内存。如果一个对象的引用计数为 0,则该对象可以被释放。

  • ptr:这是一个通用指针,指向该对象实际存储的数据。根据 typeencoding 的不同,ptr 指向的结构也不同。例如:

    • 对于字符串对象,ptr 可能指向的是一个 sds(简单动态字符串)。
    • 对于列表对象,ptr 可能指向的是 quicklist
    • 对于有序集合对象,ptr 可能指向的是 skiplistziplist

那你能说说这些数据类型的使用指令吗?

String:就是基本的 SET、GET、MSET、MGET、INCR、DECR

List:LPUSH、RPUSH、LRANGE、LINDEX

Hash:HSET、HMSET、HSETNX、HKEYS、HVALS

Set:SADD、SCARD、SDIFF、SREM

SortSet:ZADD、ZCARD、ZCOUNT、ZRANGE

Redis的数据结构, 字符串用什么实现?

Redis 中的字符串数据类型并不是直接通过 C 字符串(以 NULL 结尾的字符数组)来实现的,而是使用了一种叫做 SDS (Simple Dynamic String) 的数据结构来存储字符串。SDS 的主要优势在于支持快速的字符串操作和高效的内存管理。

SDS 结构一般包含以下几个部分:

  • len:当前字符串的长度(不包括末尾的 null 字符)。这使得 Redis 不需要在每次操作时都遍历整个字符串来获取其长度。
  • alloc:当前为字符串分配的总空间(包括字符串数据和额外的内存空间)。由于 Redis 使用的是动态分配内存,因此可以避免频繁的内存分配和释放。
  • buf:实际的字符串数据部分,存储字符串的字符数组。Redis 通过这个区域存储字符串的内容。

Redis 中的 String二进制安全的,这意味着 Redis 的 String 可以存储任何形式的数据,不仅仅是文本(如 JSON、二进制文件、图像数据、序列化数据等)。这一点非常重要,因为 Redis 的 String 类型没有对数据内容的限制,可以安全地存储二进制数据。

SDS 如何保证二进制安全:

二进制安全是一种主要用于字符串操作函数相关的计算机编程术语。其描述的是:将输入作为原始的、无任何特殊格式意义的数据流。对于每个字符都公平对待,不特殊处理某一个字符

  • 不依赖于 null 字符:传统的 C 字符串依赖于 null 字符('\0')来表示字符串的结束,但 Redis 的 SDS 不依赖于 null 字符来确定字符串的结束位置。SDS 存储了字符串的实际长度(len 字段),因此可以正确处理包含 null 字符的二进制数据。
  • 动态扩展:SDS 会根据需要动态地扩展其内部缓冲区。Redis 会使用 alloc 字段来记录已分配的内存大小。当你向 SDS 中追加数据时,Redis 会确保分配足够的内存,而不需要担心数据的终止符。
  • 不需要二次编码:二进制数据直接存储在 SDS 的 buf 区域内,不需要进行任何编码或转换。因此,Redis 可以原样存储任意二进制数据。

在早期版本的 Redis(特别是在 2.x 版本中),SDS 数据结构包含了一个 free 字段,它用于表示当前字符串缓冲区中未使用的内存量。

struct sdshdr {
    int len;    // 当前字符串的长度
    int free;   // buf数组中未使用的字节的数量(即额外的分配空间)
    char buf[]; // 字符串内容
};

在现代 Redis(即 Redis 3.x 及之后的版本)中,SDS 的实现发生了一些变化,尤其是去除了 free 字段。

struct sdshdr {
    int len;    // 当前字符串的长度
    int alloc;  // 分配的内存空间大小
    unsigned char buf[];  // 字符串数据
};

现在 Redis 使用 alloc 字段来表示已分配的内存空间,而不再使用 freealloc 存储的是当前为字符串分配的内存的总大小,而 len 表示已用的部分。

为什么移除 free 字段?

  • 更简洁的内存管理alloc 字段可以更直接地表示当前分配的内存大小,简化了内存管理。
  • 惰性空间回收:Redis 采用了懒加载和空间回收机制,通过这种方式避免了频繁的内存管理操作。

Redis 的 SDS 和 C 中字符串相比有什么优势?

C 语言使用了一个长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组最后一个元素总是 \0,这种简单的字符串表示方式 不符合 Redis 对字符串在安全性、效率以及功能方面的要求

再来说 C 语言字符串的问题

这样简单的数据结构可能会造成以下一些问题:

  • 获取字符串长度为 O(N) 级别的操作 → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
  • 不能很好的杜绝 缓冲区溢出/内存泄漏 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
  • C 字符串 只能保存文本数据 → 因为 C 语言中的字符串必须符合某种编码(比如 ASCII),例如中间出现的 '\0' 可能会被判定为提前结束的字符串而识别不了;

Redis 如何解决的 | SDS 的优势

如果去看 Redis 的源码 sds.h/sdshdr 文件,你会看到 SDS 完整的实现细节,这里简单来说一下 Redis 如何解决的:

  1. 多增加 len 表示当前字符串的长度:这样就可以直接获取长度了,复杂度 $O(1)$
  2. 自动扩展空间:当 SDS 需要对字符串进行修改时,首先借助于 lenalloc 检查空间是否满足修改所需的要求,如果空间不够的话,SDS 会自动扩展空间,避免了像 C 字符串操作中的覆盖情况;
  3. 有效降低内存分配次数:C 字符串在涉及增加或者清除操作时会改变底层数组的大小造成重新分配,SDS 使用了 空间预分配惰性空间释放 机制,简单理解就是每次在扩展时是成倍的多分配的,在缩容是也是先留着并不正式归还给 OS;
  4. 二进制安全:C 语言字符串只能保存 ascii 码,对于图片、音频等信息无法保存,SDS 是二进制安全的,写入什么读取就是什么,不做任何过滤和限制;

SDS,数字类型的自增自减如何实现?

在 Redis 中,SDS(Simple Dynamic String)是一种用于存储字符串的动态数据结构,它被设计为二进制安全且能够高效地执行字符串操作。然而,SDS 主要用于存储字符串数据,并不直接用于存储整数值。Redis 使用一个专门的数据结构来存储整数类型的数据,这通常是一个长整型(long long)变量。

对于数字类型的自增(INCR)和自减(DECR)操作,Redis 并不是通过 SDS 实现的,而是通过以下方式:

  1. 内存中的整数值:Redis 的字符串对象内部可能包含一个长整型(long long)的值,如果该字符串对象被用作计数器(即通过 INCR 或 DECR 命令操作)。
  2. 自增操作(INCR):当执行 INCR 命令时,Redis 会获取当前键对应的整数值,将其加一,然后将新的整数值更新到内存中的字符串对象。
  3. 自减操作(DECR):类似地,DECR 命令会获取当前键对应的整数值,将其减一,并更新。
  4. 范围检查:在执行自增或自减操作时,如果整数值超出了 long long 类型的范围,Redis 会将值回绕到该类型的最小值或最大值。
  5. 持久化:如果启用了持久化,Redis 还会将自增或自减操作的结果同步到磁盘上的 RDB 文件或 AOF 日志中。
  6. 事务和原子性:自增和自减操作是原子性的,即使在多客户端并发访问的情况下,每个操作都能保证正确地执行。
  7. 内存优化:当字符串对象仅用作计数器时,Redis 会使用内存效率更高的内部表示来存储整数值。
  8. 编码转换:在某些情况下,如果字符串对象同时包含文本和数字,或者执行了某些操作导致字符串对象不再适合作为整数存储,Redis 可能会在 SDS 和整数之间转换编码。
  9. 使用场景:自增和自减操作通常用于实现计数器、限制速率、生成唯一序列号等场景。

在 Redis 中,数字类型的自增自减操作是直接针对内存中的整数值进行的,而不是通过 SDS 来实现。Redis 的设计确保了这些操作的效率和原子性,使其成为执行这类操作的理想选择。

说说 List

Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。

Redis 的列表相当于 Java 语言里面的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。

Redis 的列表结构常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。

在版本3.2之前,Redis 列表list使用两种数据结构作为底层实现:

  • 压缩列表ziplist
  • 双向链表linkedlist

因为双向链表占用的内存比压缩列表要多, 所以当创建新的列表键时, 列表会优先考虑使用压缩列表, 并且在有需要的时候, 才从压缩列表实现转换到双向链表实现。

创建新列表时 redis 默认使用 redis_encoding_ziplist 编码, 当以下任意一个条件被满足时, 列表会被转换成 redis_encoding_linkedlist 编码:

  • 试图往列表新添加一个字符串值,且这个字符串的长度超过 server.list_max_ziplist_value (默认值为 64 )。
  • ziplist 包含的节点超过 server.list_max_ziplist_entries (默认值为 512 )。

注意:这两个条件是可以修改的,在 redis.conf 中:

list-max-ziplist-value 64 
list-max-ziplist-entries 512 
双向链表linkedlist

当链表entry数据超过512、或单个value 长度超过64,底层就会转化成linkedlist编码; linkedlist是标准的双向链表,Node节点包含prev和next指针,可以进行双向遍历; 还保存了 head 和 tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为 (1) —— 这是高效实现 LPUSH 、 RPOP、 RPOPLPUSH 等命令的关键。 linkedlist比较简单,我们重点来分析ziplist。

压缩列表ziplist

压缩列表 ziplist 是为 Redis 节约内存而开发的。

Redis官方对于ziplist的定义是(出自ziplist.c的文件头部注释):

/* The ziplist is a specially encoded dually linked list that is designed
 * to be very memory efficient. It stores both strings and integer values,
 * where integers are encoded as actual integers instead of a series of
 * characters. It allows push and pop operations on either side of the list
 * in O(1) time. However, because every operation requires a reallocation of
 * the memory used by the ziplist, the actual complexity is related to the
 * amount of memory used by the ziplist.
 *

ziplist 是由一系列特殊编码的内存块构成的列表(像内存连续的数组,但每个元素长度不同), 一个 ziplist 可以包含多个节点(entry)。 ziplist 将表中每一项存放在前后连续的地址空间内,每一项因占用的空间不同,而采用变长编码。

ziplist 是一个特殊的双向链表 特殊之处在于:没有维护双向指针:prev next;而是存储上一个 entry的长度和当前entry的长度,通过长度推算下一个元素在什么地方。 牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储entry长度 更费内存。这是典型的“时间换空间”。

Redis3.2+ list的新实现quickList

可以认为quickList,是 ziplist 和 linkedlist 二者的结合;quickList 将二者的优点结合起来。

  • quickList 就是一个标准的双向链表的配置,有head 有tail;

  • 每一个节点是一个quicklistNode,包含prev和next指针。

  • 每一个quicklistNode 包含 一个ziplist,*zp 压缩链表里存储键值。

    所以quicklist是对ziplist进行一次封装,使用小块的ziplist来既保证了少使用内存,也保证了性能。

字典Hash是如何实现的?Rehash 了解吗?

Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 “数组 + 链表”链地址法 来解决部分哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点。

字典结构内部包含 两个 hashtable,通常情况下只有一个 hashtable 有值,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式搬迁 (rehash),这时候两个 hashtable 分别存储旧的和新的 hashtable,待搬迁结束后,旧的将被删除,新的 hashtable 取而代之。

扩缩容的条件

正常情况下,当 hash 表中 元素的个数等于第一维数组(第一个hashtable)的长度时,就会开始扩容,扩容的新数组是 原数组大小的 2 倍。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会 强制扩容

当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是 元素个数低于数组长度的 10%,缩容不会考虑 Redis 是否在做 bgsave

说说 Zset 吧

它类似于 Java 的 SortedSet 和 HashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以给每个 value 赋予一个 score,代表这个 value 的排序权重。它的内部实现用的是一种叫做「跳跃列表」的数据结构。

Redis 正是通过 score 来为集合中的成员进行从小到大的排序。Zset 的成员是唯一的,但 score 却可以重复。

跳跃表是如何实现的?原理?

从图中可以看到, 跳跃表主要由以下部分构成:

  • 表头(head):负责维护跳跃表的节点指针。
  • 跳跃表节点:保存着元素值,以及多个层。
  • 层:保存着指向其他元素的指针。高层的指针越过的元素数量大于等于低层的指针,为了提高查找的效率,程序总是从高层先开始访问,然后随着元素值范围的缩小,慢慢降低层次。
  • 表尾:全部由 NULL 组成,表示跳跃表的末尾。

除了5种基本数据类型,还知道其他数据结构不?

Bitmaps(位图)

位图不是实际的数据类型,而是在 String 类型上定义的一组面向位的操作。可以看作是 byte 数组。我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit 等将 byte 数组看成「位数组」来处理。

一般用于:各种实时分析;存储与对象 ID 相关的布尔信息

HyperLogLog

HyperLogLog是一种概率数据结构,用于对唯一事物进行计数(从技术上讲,这是指估计集合的基数)

https://www.wmyskxz.com/2020/03/02/reids-4-shen-qi-de-hyperloglog-jie-jue-tong-ji-wen-ti/

怎么统计一亿用户的日活,hyperloglog有什么缺点,bitmap不行么?

统计一亿用户的日活(Daily Active Users, DAU)时,需要一个能够高效处理和存储大量数据的方案。以下是几种适用于此类场景的数据结构及其优缺点:

HyperLogLog 是一种用于基数统计的数据结构,它提供了一个近似的、不精确的解决方案来估算集合中唯一元素的数量。

优点

  • 内存效率:HyperLogLog 消耗的内存固定,与集合中元素的数量无关,通常每个 HyperLogLog 实例只需要 12.4KB 左右,无论集合中有多少元素。
  • 性能:HyperLogLog 可以快速处理数据,因为它只存储元素的哈希值的一些位信息。

缺点

  • 近似值:HyperLogLog 提供的是近似值,标准误差大约为 0.81%,这意味着实际基数可能与估算值有所偏差。
  • 更新频率:如果数据更新非常频繁,HyperLogLog 可能需要频繁地调整其内部数据结构,这可能会影响性能。

Bitmap 是另一种数据结构,它使用位数组来表示数据,每个位对应一个元素的状态(例如,用户是否活跃)。

优点

  • 精确计数:Bitmap 提供精确的计数,没有 HyperLogLog 的近似误差。
  • 简单直观:Bitmap 的概念简单,易于理解和实现。

缺点

  • 内存消耗:对于一亿用户,Bitmap 需要大约 100MB(1亿位 / 8位/字节 ÷ 1024KB/MB)的内存,这比 HyperLogLog 高得多。
  • 扩展性问题:随着用户数量的增加,Bitmap 所需的内存也会线性增长,这可能在大规模数据集上造成问题。

综合考虑

  • 如果对日活用户数的精确度要求不高,并且希望最小化内存使用,HyperLogLog 是一个很好的选择。
  • 如果需要精确的日活用户数,并且可以承受较高的内存消耗,可以使用 Bitmap。
  • 在实际应用中,可能还会考虑其他因素,如数据的更新频率、系统的扩展性、维护成本等。

对于一亿用户的日活统计,如果对精度要求不是特别高,HyperLogLog 是一个更合适的选择,因为它在内存使用和性能方面具有优势。Bitmap 虽然可以提供精确的统计,但其内存消耗较高,可能不适合大规模的用户统计。

这些都会,那你能说说 Redis 使用场景不,你们项目中是怎么用的?

在 Redis 中,常用的 5 种数据结构和应用场景如下:

  • String:缓存、计数器、分布式锁等。

    • 什么是计数器,如电商网站商品的浏览量、视频网站视频的播放数等。为了保证数据实时效,每次浏览都得+1,并发量高时如果每次都请求数据库操作无疑会对数据库提出挑战。Redis提供的incr命令来实现计数器功能,内存操作,性能非常好,非常适用于这些计数场景。
    • 在很多互联网公司中都使用了分布式技术,分布式技术带来的技术挑战是对同一个资源的并发访问,如全局ID、减库存、秒杀等场景,并发量不大的场景可以使用数据库的悲观锁、乐观锁来实现,但在并发量高的场合中,利用数据库锁来控制资源的并发访问是不太理想的,大大影响了数据库的性能。可以利用Redis的setnx功能来编写分布式的锁,如果设置返回1说明获取锁成功,否则获取锁失败,实际应用中要考虑的细节要更多。
  • List:链表、队列、微博关注人时间轴列表等。

    • Redis列表结构,LPUSH可以在列表头部插入一个内容ID作为关键字,LTRIM可用来限制列表的数量,这样列表永远为N个ID,无需查询最新的列表,直接根据ID去到对应的内容页即可。
  • Hash:用户信息、Hash 表等。

  • Set社交网络

    • 点赞、踩、关注/被关注、共同好友等是社交网站的基本功能,社交网站的访问量通常来说比较大,而且传统的关系数据库类型不适合存储这种类型的数据,Redis提供的哈希、集合等数据结构能很方便的的实现这些功能。
  • Zset:访问量排行榜、点击量排行榜等

还有一些,比如:

  • 取最新N个数据的操作

  • 需要精确设定过期时间的应用

  • Uniq操作,获取某段时间所有数据排重值

  • 实时系统,反垃圾系统

  • Pub/Sub构建实时消息系统

  • 构建队列系统

  • 分布式会话

集群模式下,在应用不多的情况下一般使用容器自带的session复制功能就能满足,当应用增多相对复杂的系统中,一般都会搭建以Redis等内存数据库为中心的session服务,session不再由容器管理,而是由session服务及内存数据库管理。


三、Redis持久化问题

你对 Redis 的持久化机制了解吗?能讲一下吗?

或者不会这么直白的问,而是问 Redis 是如何实现数据不丢失的?

Redis 的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此必须有一种机制来保证 Redis 的数据不会因为故障而丢失,这种机制就是 Redis 的持久化机制,它会将内存中的数据库状态 保存到磁盘 中。

回答思路:先说明 Redis 有几种持久化的方式,然后分析 AOF 和 RDB 的原理以及存在的问题,最后分析一下 Redis 4.0 版本之后的持久化机制。

Redis 持久化的方式有哪写

Redis有两种持久化的方式:快照(RDB文件)和追加式文件(AOF文件)

RDB:在不同的时间点将 redis 的数据生成的快照同步到磁盘等介质上,内存到硬盘的快照,定期更新。缺点:耗时,耗性能(fork+io 操作),易丢失数据。

AOF:将redis所执行过的所有指令都记录下来,在下次redis重启时,只需要执行指令就可以了,写日志。缺点:体积大,恢复速度慢。

RDB(Redis DataBase)

在指定的时间间隔内将内存中的数据集快照写入磁盘,也就是行话讲的 Snapshot 快照,它恢复时是将快照文件直接读到内存里。

Redis会单独创建(fork)一个子进程来进行持久化,会先将数据写入到一个临时文件中,待持久化过程都结束了,再用这个临时文件替换上次持久化好的文件。整个过程中,主进程是不进行任何 IO 操作的,这就确保了极高的性能,如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那 RDB 方式要比 AOF 方式更加的高效。RDB的缺点是最后一次持久化后的数据可能丢失。

What ? Redis 不是单进程的吗?

Redis 使用操作系统的多进程 COW(Copy On Write) 机制来实现快照持久化, fork 是类Unix操作系统上创建进程的主要方法。COW(Copy On Write)是计算机编程中使用的一种优化策略。

fork 的作用是复制一个与当前进程一样的进程。新进程的所有数据(变量、环境变量、程序计数器等)数值都和原进程一致,但是是一个全新的进程,并作为原进程的子进程。 子进程读取数据,然后序列化写到磁盘中。

rdb 默认保存的是 dump.rdb 文件

你可以对 Redis 进行设置, 让它在“ N 秒内数据集至少有 M 个改动”这一条件被满足时, 自动保存一次数据集。

你也可以通过调用 SAVE 或者 BGSAVE , 手动让 Redis 进行数据集保存操作。

比如说, 以下设置会让 Redis 在满足“ 60 秒内有至少有 1000 个键被改动”这一条件时, 自动保存一次数据集:

save 60 1000

RDB 做快照时会阻塞线程吗?

因为 Redis 的单线程模型决定了它所有操作都要尽量避免阻塞主线程,所以对于 RDB 快照也不例外,这关系到是否会降低 Redis 的性能。

为了解决这个问题,Redis 提供了两个命令来生成 RDB 快照文件,分别是 save 和 bgsave。save 命令在主线程中执行,会导致阻塞。而 bgsave 命令则会创建一个子进程,用于写入 RDB 文件的操作,避免了对主线程的阻塞,这也是 Redis RDB 的默认配置。

RDB 做快照的时候数据能修改吗?

它利用了 bgsave 的子进程,具体操作如下:

如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响;

如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

AOF 日志是如何实现的?

通常情况下,关系型数据库(如 MySQL)的日志都是“写前日志”(Write Ahead Log, WAL),也就是说,在实际写数据之前,先把修改的数据记到日志文件中,以便当出现故障时进行恢复,比如 MySQL 的 redo log(重做日志),记录的就是修改后的数据。

而 AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的,不同的是,Redis 的 AOF 日志的记录顺序与传统关系型数据库正好相反,它是写后日志,“写后”是指 Redis 要先执行命令,把数据写入内存,然后再记录日志到文件。

那么面试的考察点来了:Reids 为什么先执行命令,在把数据写入日志呢?为了方便你理解,我整理了关键的记忆点:

  • 因为 ,Redis 在写入日志之前,不对命令进行语法检查;

  • 所以,只记录执行成功的命令,避免了出现记录错误命令的情况;

  • 并且,在命令执行完之后再记录,不会阻塞当前的写操作。

当然,这样做也会带来风险(这一点你也要在面试中给出解释)。

  • 数据可能会丢失: 如果 Redis 刚执行完命令,此时发生故障宕机,会导致这条命令存在丢失的风险。

  • 可能阻塞其他操作: 虽然 AOF 是写后日志,避免阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。

RDB 和 AOF 各自有什么优缺点?

RDB | 优点

  1. 只有一个文件 dump.rdb方便持久化
  2. 容灾性好,一个文件可以保存到安全的磁盘。
  3. 性能最大化fork 子进程来完成写操作,让主进程继续处理命令,所以使 IO 最大化。使用单独子进程来进行持久化,主进程不会进行任何 IO 操作,保证了 Redis 的高性能
  4. 相对于数据集大时,比 AOF 的 启动效率 更高。

RDB | 缺点

  1. 数据安全性低。RDB 是间隔一段时间进行持久化,如果持久化之间 Redis 发生故障,会发生数据丢失。所以这种方式更适合数据要求不严谨的时候;

AOF | 优点

  1. 数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次命令操作就记录到 aof 文件中一次。
  2. 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。
  3. AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令 进行合并重写),可以删除其中的某些命令(比如误操作的 flushall)

AOF | 缺点

  1. AOF 文件比 RDB 文件大,且 恢复速度慢
  2. 数据集大 的时候,比 rdb 启动效率低

AOF 如果文件越来愈大 怎么办?

rewrite(AOF 重写)

  • 是什么:AOF采用文件追加方式,文件会越来越大为避免出现此种情况,新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集,可以使用命令bgrewriteaof,这个操作相当于对AOF文件“瘦身”。
  • 重写原理:AOF 文件持续增长而过大时,会 fork 出一条新进程来将文件重写(也是先写临时文件最后再rename),遍历新进程的内存中数据,每条记录有一条的 Set 语句。重写 aof 文件的操作,并没有读取旧的aof文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的 aof 文件,这点和快照有点类似
  • 触发机制:Redis 会记录上次重写时的 AOF 大小,默认配置是当 AOF 文件大小是上次 rewrite 后大小的一倍且文件大于64M 时触发

fork 耗时问题定位

Fork操作

当Redis做RDB或AOF重写时,一个必不可少的操作就是执行fork操作创建子进程,对于大多数操作系统来说fork是个重量级操作

虽然fork创建的子进程不需要拷贝父进程的物理内存空间,但是会复制父进程的空间内存页表。例如对于10GB的Redis进程,需要复制大约20MB的内存页表,因此fork 操作耗时跟进程总内存量息息相关,如果使用虚拟化技术,特别是Xen虚拟 机,fork操作会更耗时

  • 在做 RDB 或 AOF 重写时, fork 是必不可少的
  • 对于大多数操作系统来说, fork 是个重量级错误
  • fork 会复制符进程的空间内存页表
  • 如果使用虚拟化技术, 特别是 Xen 虚拟机, fork 操作会更耗时

fork 耗时问题定位:

  • 高流量的 Redis 实例 ops 可达5万以上
  • 正常情况 fork 耗时应该是每 GB 消耗 20ms 左右
  • 可以用 info stats 命令查看 latest_fork_usec 指标, 获取最近一次 fork 操作耗时, 单位微秒

如何改善 fork 操作的耗时:

  • 优先使用物理机或者高效支持 fork 操作的虚拟化技术, 避免使用 Xen
  • 控制 Redis 实例最大可用内存, fork 耗时跟内存量成正比, 线上建议每个 Redis 实例内存控制在 10GB 以内
  • 合理配置 Linux 内存分配策略, 避免物理内存不足导致 fork 失败, 具体细节见12.1节 “Linux 配置优化”
  • 降低 fork 操作的频率, 如适度放宽 AOF 自动触发时机, 避免不必要的全量复制等

两种持久化方式如何选择?

  • RDB 持久化方式能够在指定的时间间隔能对你的数据进行快照存储
  • AOF 持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF命令以 redis 协议追加保存每次写的操作到文件末尾。Redis还能对AOF文件进行后台重写(bgrewriteaof),使得 AOF 文件的体积不至于过大
  • 只做缓存:如果你只希望你的数据在服务器运行的时候存在,你也可以不使用任何持久化方式。
  • 同时开启两种持久化方式
    • 在这种情况下,当 redis 重启的时候会优先载入 AOF 文件来恢复原始的数据,因为在通常情况下 AOF 文件保存的数据集要比 RDB 文件保存的数据集要完整。
    • RDB 的数据不实时,同时使用两者时服务器重启也只会找 AOF 文件。那要不要只使用AOF 呢?建议不要,因为 RDB 更适合用于备份数据库(AOF 在不断变化不好备份),快速重启,而且不会有 AOF 可能潜在的bug,留着作为一个万一的手段。

Redis4.0 之后有了混合持久化的功能,将 bgsave 的全量 和 aof 的增量做了融合处理,这样既保证了恢复的效率又兼顾了数据的安全性。bgsave 的 原理,fork 和 cow, fork 是指 redis 通过创建子进程来进行 bgsave 操作,cow 指的是 copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据 会逐渐和子进程分离开来。


四、Redis事务问题

Redis事务的概念?

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

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

MULTI 命令用于开启一个事务,它总是返回 OK 。

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

另一方面, 通过调用 DISCARD , 客户端可以清空事务队列, 并放弃执行事务。

WATCH 使得 EXEC 命令需要有条件地执行: 事务只能在所有被监视键都没有被修改的前提下执行, 如果这个前提不能满足的话,事务就不会被执行。

Redis事务的三个阶段、三特性

三阶段

  1. 开启:以MULTI开始一个事务

  2. 入队:将多个命令入队到事务中,接到这些命令并不会立即执行,而是放到等待执行的事务队列里面

  3. 执行:由EXEC命令触发事务

三特性

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

  2. 没有隔离级别的概念:队列中的命令没有提交之前都不会实际的被执行,因为事务提交前任何指令都不会被实际执行,也就不存在”事务内的查询要看到事务里的更新,在事务外查询不能看到”这个让人万分头痛的问题

  3. 不保证原子性:redis同一个事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚

Redis事务支持隔离性吗?

Redis 是单进程程序,并且它保证在执行事务时,不会对事务进行中断,事务可以运行直到执行完所有事务队列中的命令为止。因此,Redis 的事务是总是带有隔离性的

Redis事务保证原子性吗,支持回滚吗?

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

  1. 如果在一个事务中的命令出现错误,那么所有的命令都不会执行
  2. 如果在一个事务中出现运行错误,那么正确的命令会被执行

五、Redis 集群问题

redis单节点存在单点故障问题,为了解决单点问题,一般都需要对redis配置从节点,然后使用哨兵来监听主节点的存活状态,如果主节点挂掉,从节点能继续提供缓存功能

主从同步了解吗?

主从复制,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称为 主节点(master),后者称为 从节点(slave)。且数据的复制是 单向 的,只能由主节点到从节点。Redis 主从复制支持 主从同步从从同步 两种,后者是 Redis 后续版本新增的功能,以减轻主节点的同步负担。

主从复制主要的作用

  • 数据冗余: 主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
  • 故障恢复: 当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复 (实际上是一种服务的冗余)
  • 负载均衡: 在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务 (即写 Redis 数据时应用连接主节点,读 Redis 数据时应用连接从节点),分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。
  • 高可用基石: 除了上述作用以外,主从复制还是哨兵和集群能够实施的 基础,因此说主从复制是 Redis 高可用的基础。

实现原理

redis-replicaof

为了节省篇幅,主要的步骤都 浓缩 在了上图中,其实也可以 简化成三个阶段:准备阶段-数据同步阶段-命令传播阶段

redis2.8 之前使用sync[runId][offset]同步命令,redis2.8 之后使用psync[runId][offset]命令。两者不同在于,sync 命令仅支持全量复制过程,psync 支持全量和部分复制

主从复制实现:主节点将自己内存中的数据做一份快照,将快照发给从节点,从节点将数据恢复到内存中。之后再每次增加新数据的时候,主节点以类似于 mysql 的二进制日志方式将语句发送给从节点,从节点拿到主节点发送过来的语句进行重放。

那主从复制会存在哪些问题呢?

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

那比较主流的解决方案是什么呢?哨兵

Redis读写分离的场景下,怎么保证从数据库读到最新的数据?

  1. Redis 4.0 引入了无磁盘化复制(diskless replication),在这种模式下,从节点不需要将主节点的数据写入磁盘,从而减少了数据同步的延迟。

  2. 强制从主数据库读取

  3. 延时读(在一些特定场景下,可以配置从节点的 slave-read-delay 选项,以延迟从节点的读操作,确保数据的一致性。)

什么是哨兵

上图 展示了一个典型的哨兵架构图,它由两部分组成,哨兵节点和数据节点:

  • 哨兵节点: 哨兵系统由一个或多个哨兵节点组成,哨兵节点是特殊的 Redis 节点,不存储数据
  • 数据节点: 主节点和从节点都是数据节点;

哨兵的介绍

sentinel,中文名是哨兵。哨兵是 redis 集群机构中非常重要的一个组件,主要有以下功能:

  1. 集群监控:负责监控 redis master 和 slave 进程是否正常工作。
  2. 消息通知:如果某个 redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
  3. 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
  4. 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。

哨兵用于实现 redis 集群的高可用,本身也是分布式的,作为一个哨兵集群去运行,互相协同工作。

哨兵的核心知识

  1. 哨兵至少需要 3 个实例,来保证自己的健壮性。
  2. 哨兵 + redis 主从的部署架构,是不保证数据零丢失的,只能保证 redis 集群的高可用性。
  3. 对于哨兵 + redis 主从这种复杂的部署架构,尽量在测试环境和生产环境,都进行充足的测试和演练。

说下哨兵的工作原理?

  1. 监控机制
    • 哨兵会周期性地向主服务器和从服务器发送 PING 命令,根据响应来判断服务器的健康状态。
    • 如果某个哨兵在指定时间内(down-after-milliseconds)没有收到某个服务器的响应,它会将该服务器标记为主观下线(Subjectively Down,简称 SDOWN)。
    • 当多个哨兵都认为某个主服务器不可用时,达成共识后会将其标记为客观下线(Objectively Down,简称 ODOWN)。
  2. 选举机制
    • 如果主服务器被标记为 ODOWN,哨兵会通过 Raft 共识算法进行选举,选出一个哨兵来执行故障转移。
    • 当选举出的哨兵确认自己是领导者后,会进行主从切换操作。
  3. 故障转移
    • 领导者哨兵会从从服务器中挑选一个最合适的服务器提升为新的主服务器。
    • 新的主服务器被选定后,哨兵会通知所有其他从服务器进行重新配置,使它们成为新主服务器的从服务器。
    • 同时,哨兵会更新配置,以便客户端能够连接到新的主服务器。
  4. 通知机制
    • 故障转移完成后,哨兵会通过发布订阅机制通知其他哨兵和客户端新主服务器的地址。

新的主服务器是怎样被挑选出来的?

故障转移操作的第一步 要做的就是在已下线主服务器属下的所有从服务器中,挑选出一个状态良好、数据完整的从服务器,然后向这个从服务器发送 slaveof no one 命令,将这个从服务器转换为主服务器。但是这个从服务器是怎么样被挑选出来的呢?

简单来说 Sentinel 使用以下规则来选择新的主服务器:

  1. 在失效主服务器属下的从服务器当中, 那些被标记为主观下线、已断线、或者最后一次回复 PING 命令的时间大于五秒钟的从服务器都会被 淘汰
  2. 在失效主服务器属下的从服务器当中, 那些与失效主服务器连接断开的时长超过 down-after 选项指定的时长十倍的从服务器都会被 淘汰
  3. 经历了以上两轮淘汰之后 剩下来的从服务器中, 我们选出 复制偏移量(replication offset)最大 的那个 从服务器 作为新的主服务器;如果复制偏移量不可用,或者从服务器的复制偏移量相同,那么 带有最小运行 ID 的那个从服务器成为新的主服务器。

Redis Sentinel 着眼于高可用,在 master 宕机时会自动将 slave 提升为 master,继续提供服务。

Redis Cluster 着眼于扩展性,在单个 redis 内存不足时,使用 Cluster 进行分片存储。

Redis 集群使用过吗?原理?

Redis Cluster Architecture

上图 展示了 Redis Cluster 典型的架构图,集群中的每一个 Redis 节点都 互相两两相连,客户端任意 直连 到集群中的 任意一台,就可以对其他 Redis 节点进行 读写 的操作。

基本原理

Redis 集群中内置了 16384 个哈希槽。当客户端连接到 Redis 集群之后,会同时得到一份关于这个 集群的配置信息,当客户端具体对某一个 key 值进行操作时,会计算出它的一个 Hash 值,然后把结果对 16384 求余数,这样每个 key 都会对应一个编号在 0-16383 之间的哈希槽,Redis 会根据节点数量 大致均等 的将哈希槽映射到不同的节点。

再结合集群的配置信息就能够知道这个 key 值应该存储在哪一个具体的 Redis 节点中,如果不属于自己管,那么就会使用一个特殊的 MOVED 命令来进行一个跳转,告诉客户端去连接这个节点以获取数据:

GET x
-MOVED 3999 127.0.0.1:6381

MOVED 指令第一个参数 3999key 对应的槽位编号,后面是目标节点地址,MOVED 命令前面有一个减号,表示这是一个错误的消息。客户端在收到 MOVED 指令后,就立即纠正本地的 槽位映射表,那么下一次再访问 key 时就能够到正确的地方去获取了。

redis-cluster 分片原理:Cluster 中有一个 16384 长度的槽(虚拟槽),编号分别为 0-16383。 每个 Master 节点都会负责一部分的槽,当有某个 key 被映射到某个 Master 负责的槽,那么这个 Master 负责为这个 key 提供服务,至于哪个 Master 节点负责哪个槽,可以由用户指定,也可以在初始化的时候自动生成,只有 Master 才拥有槽的所有权。Master 节点维护着一个 16384/8 字节的位序列,Master 节点用 bit 来标识对于某个槽自己是否拥有。比如对于编号为 1 的槽,Master 只要判断序列的第二位(索引从 0 开始)是不是为 1 即可。 这种结构很容易添加或者删除节点。比如如果我想新添加个节点 D, 我需要从节点 A、B、 C 中的部分槽到 D 上。

集群的主要作用

  1. 数据分区: 数据分区 (或称数据分片) 是集群最核心的功能。集群将数据分散到多个节点,一方面 突破了 Redis 单机内存大小的限制,存储容量大大增加另一方面 每个主节点都可以对外提供读服务和写服务,大大提高了集群的响应能力。Redis 单机内存大小受限问题,如果单机内存太大,bgsavebgrewriteaoffork 操作可能导致主进程阻塞,主从环境下主机切换时可能导致从节点长时间无法提供服务,全量复制阶段主节点的复制缓冲区可能溢出……
  2. 高可用: 集群支持主从复制和主节点的 自动故障转移 (与哨兵类似),当任一节点发生故障时,集群仍然可以对外提供服务。

集群中数据如何分区?

Redis 采用方案三。

方案一:哈希值 % 节点数

哈希取余分区思路非常简单:计算 key 的 hash 值,然后对节点数量进行取余,从而决定数据映射到哪个节点上。

不过该方案最大的问题是,当新增或删减节点时,节点数量发生变化,系统中所有的数据都需要 重新计算映射关系,引发大规模数据迁移。

方案二:一致性哈希分区

一致性哈希算法将 整个哈希值空间 组织成一个虚拟的圆环,范围是 [0 - 2^32 - 1],对于每一个数据,根据 key 计算 hash 值,确数据在环上的位置,然后从此位置沿顺时针行走,找到的第一台服务器就是其应该映射到的服务器:

与哈希取余分区相比,一致性哈希分区将 增减节点的影响限制在相邻节点。以上图为例,如果在 node1node2 之间增加 node5,则只有 node2 中的一部分数据会迁移到 node5;如果去掉 node2,则原 node2 中的数据只会迁移到 node4 中,只有 node4 会受影响。

一致性哈希分区的主要问题在于,当 节点数量较少 时,增加或删减节点,对单个节点的影响可能很大,造成数据的严重不平衡。还是以上图为例,如果去掉 node2node4 中的数据由总数据的 1/4 左右变为 1/2 左右,与其他节点相比负载过高。

方案三:带有虚拟节点的一致性哈希分区

该方案在 一致性哈希分区的基础上,引入了 虚拟节点 的概念。Redis 集群使用的便是该方案,其中的虚拟节点称为 槽(slot)。槽是介于数据和实际节点之间的虚拟概念,每个实际节点包含一定数量的槽,每个槽包含哈希值在一定范围内的数据。

在使用了槽的一致性哈希分区中,槽是数据管理和迁移的基本单位。槽 解耦数据和实际节点 之间的关系,增加或删除节点对系统的影响很小。仍以上图为例,系统中有 4 个实际节点,假设为其分配 16 个槽(0-15);

  • 槽 0-3 位于 node1;4-7 位于 node2;以此类推….

如果此时删除 node2,只需要将槽 4-7 重新分配即可,例如槽 4-5 分配给 node1,槽 6 分配给 node3,槽 7 分配给 node4;可以看出删除 node2 后,数据在其他节点的分布仍然较为均衡。

节点之间的通信机制了解吗?

集群的建立离不开节点之间的通信,假如我们启动六个集群节点之后通过 redis-cli 命令帮助我们搭建起来了集群,实际上背后每个集群之间的两两连接是通过了 CLUSTER MEET 命令发送 MEET 消息完成的,下面我们展开详细说说。

两个端口

哨兵系统 中,节点分为 数据节点哨兵节点:前者存储数据,后者实现额外的控制功能。在 集群 中,没有数据节点与非数据节点之分:所有的节点都存储数据,也都参与集群状态的维护。为此,集群中的每个节点,都提供了两个 TCP 端口:

  • 普通端口: 即我们在前面指定的端口 (7000等)。普通端口主要用于为客户端提供服务 (与单机节点类似);但在节点间数据迁移时也会使用。
  • 集群端口: 端口号是普通端口 + 10000 (10000是固定值,无法改变),如 7000 节点的集群端口为 17000集群端口只用于节点之间的通信,如搭建集群、增减节点、故障转移等操作时节点间的通信;不要使用客户端连接集群接口。为了保证集群可以正常工作,在配置防火墙时,要同时开启普通端口和集群端口。

Gossip 协议

节点间通信,按照通信协议可以分为几种类型:单对单、广播、Gossip 协议等。重点是广播和 Gossip 的对比。

  • 广播是指向集群内所有节点发送消息。优点 是集群的收敛速度快(集群收敛是指集群内所有节点获得的集群信息是一致的),缺点 是每条消息都要发送给所有节点,CPU、带宽等消耗较大。
  • Gossip 协议的特点是:在节点数量有限的网络中,每个节点都 “随机” 的与部分节点通信 (并不是真正的随机,而是根据特定的规则选择通信的节点),经过一番杂乱无章的通信,每个节点的状态很快会达到一致。Gossip 协议的 优点有负载 (比广播) 低、去中心化、容错性高 (因为通信有冗余) 等;缺点 主要是集群的收敛速度慢。

消息类型

集群中的节点采用 固定频率(每秒10次)定时任务 进行通信相关的工作:判断是否需要发送消息及消息类型、确定接收节点、发送消息等。如果集群状态发生了变化,如增减节点、槽状态变更,通过节点间的通信,所有节点会很快得知整个集群的状态,使集群收敛。

节点间发送的消息主要分为 5 种:meet 消息ping 消息pong 消息fail 消息publish 消息。不同的消息类型,通信协议、发送的频率和时机、接收节点的选择等是不同的:

  • MEET 消息: 在节点握手阶段,当节点收到客户端的 CLUSTER MEET 命令时,会向新加入的节点发送 MEET 消息,请求新节点加入到当前集群;新节点收到 MEET 消息后会回复一个 PONG 消息。
  • PING 消息: 集群里每个节点每秒钟会选择部分节点发送 PING 消息,接收者收到消息后会回复一个 PONG 消息。PING 消息的内容是自身节点和部分其他节点的状态信息,作用是彼此交换信息,以及检测节点是否在线。PING 消息使用 Gossip 协议发送,接收节点的选择兼顾了收敛速度和带宽成本,具体规则如下:(1)随机找 5 个节点,在其中选择最久没有通信的 1 个节点;(2)扫描节点列表,选择最近一次收到 PONG 消息时间大于 cluster_node_timeout / 2 的所有节点,防止这些节点长时间未更新。
  • PONG消息: PONG 消息封装了自身状态数据。可以分为两种:第一种 是在接到 MEET/PING 消息后回复的 PONG 消息;第二种 是指节点向集群广播 PONG 消息,这样其他节点可以获知该节点的最新信息,例如故障恢复后新的主节点会广播 PONG 消息。
  • FAIL 消息: 当一个主节点判断另一个主节点进入 FAIL 状态时,会向集群广播这一 FAIL 消息;接收节点会将这一 FAIL 消息保存起来,便于后续的判断。
  • PUBLISH 消息: 节点收到 PUBLISH 命令后,会先执行该命令,然后向集群广播这一消息,接收节点也会执行该 PUBLISH 命令。

集群数据如何存储的有了解吗?

节点需要专门的数据结构来存储集群的状态。所谓集群的状态,是一个比较大的概念,包括:集群是否处于上线状态、集群中有哪些节点、节点是否可达、节点的主从状态、槽的分布……

节点为了存储集群状态而提供的数据结构中,最关键的是 clusterNodeclusterState 结构:前者记录了一个节点的状态,后者记录了集群作为一个整体的状态。

clusterNode 结构

clusterNode 结构保存了 一个节点的当前状态,包括创建时间、节点 id、ip 和端口号等。每个节点都会用一个 clusterNode 结构记录自己的状态,并为集群内所有其他节点都创建一个 clusterNode 结构来记录节点状态。

下面列举了 clusterNode 的部分字段,并说明了字段的含义和作用:

typedef struct clusterNode {
    //节点创建时间
    mstime_t ctime;
    //节点id
    char name[REDIS_CLUSTER_NAMELEN];
    //节点的ip和端口号
    char ip[REDIS_IP_STR_LEN];
    int port;
    //节点标识:整型,每个bit都代表了不同状态,如节点的主从状态、是否在线、是否在握手等
    int flags;
    //配置纪元:故障转移时起作用,类似于哨兵的配置纪元
    uint64_t configEpoch;
    //槽在该节点中的分布:占用16384/8个字节,16384个比特;每个比特对应一个槽:比特值为1,则该比特对应的槽在节点中;比特值为0,则该比特对应的槽不在节点中
    unsigned char slots[16384/8];
    //节点中槽的数量
    int numslots;
    …………
} clusterNode;

除了上述字段,clusterNode 还包含节点连接、主从复制、故障发现和转移需要的信息等。

clusterState 结构

clusterState 结构保存了在当前节点视角下,集群所处的状态。主要字段包括:

typedef struct clusterState {
    //自身节点
    clusterNode *myself;
    //配置纪元
    uint64_t currentEpoch;
    //集群状态:在线还是下线
    int state;
    //集群中至少包含一个槽的节点数量
    int size;
    //哈希表,节点名称->clusterNode节点指针
    dict *nodes;
    //槽分布信息:数组的每个元素都是一个指向clusterNode结构的指针;如果槽还没有分配给任何节点,则为NULL
    clusterNode *slots[16384];
    …………
} clusterState;

除此之外,clusterState 还包括故障转移、槽迁移等需要的信息。

Redis集群最大节点个数是多少?

16384

Redis集群会有写操作丢失吗?为什么?

Redis并不能保证数据的强一致性,这意味这在实际中集群在特定的条件下可能会丢失写操作。

Redis集群之间是如何复制的?

Redis集群使用异步复制机制在主从节点之间进行数据复制。以下是Redis集群复制的关键点和工作原理:

主从复制

  1. 主节点(Master)和从节点(Slave)
    • 每个主节点负责处理特定的槽(slots)范围,并可以有多个从节点。
    • 从节点通过复制主节点的数据来保持同步,并在主节点不可用时自动提升为新的主节点。
  2. 异步复制
    • 主节点会将写操作命令异步发送给从节点,从节点异步接收并执行这些命令。
    • 由于是异步复制,主节点不会等待从节点确认写操作已经完成,这提高了性能,但也可能导致数据在短时间内不一致。

复制过程

  1. 初次同步(Initial Synchronization)
    • 当一个从节点第一次连接到主节点时,会进行全量复制。
    • 主节点会生成一个 RDB 快照文件,并将其发送给从节点。
    • 在 RDB 文件传输过程中,主节点会将新的写操作命令存储在缓冲区中。
    • RDB 文件传输完成后,主节点会将缓冲区中的写操作命令发送给从节点,从节点执行这些命令以完成数据同步。
  2. 增量同步(Incremental Synchronization)
    • 在初次同步之后,主节点只会将新的写操作命令发送给从节点,从节点接收并执行这些命令。

故障转移

  1. 故障检测
    • 当主节点不可用时,从节点可以通过选举算法选举一个新的主节点。
    • 哨兵(Sentinel)或 Redis Cluster 本身的机制可以实现自动故障转移。
  2. 数据一致性
    • Redis 使用“最终一致性”模型,虽然复制是异步的,但最终所有节点的数据会一致。

集群通信协议

  1. Gossip 协议
    • Redis Cluster 节点之间使用 Gossip 协议进行通信,以传播节点状态和槽分配信息。
    • 每个节点会定期向其他节点发送消息,以分享自身的状态和接收到的其他节点的状态信息。
  2. 复制偏移量和 ACK
    • 主节点会维护一个全局复制偏移量,并将其发送给从节点。
    • 从节点会定期向主节点发送 ACK 消息,告知主节点它们已经接收到的数据偏移量。

Redis是单线程的,如何提高多核CPU的利用率?

Redis 是单线程的,意味着其核心功能(如处理命令请求、数据存储和检索等)主要在单个线程中执行。尽管如此,Redis 还是有一些方法可以提高在多核 CPU 系统上的利用率:

  1. 使用多个 Redis 实例
    • 可以在同一个服务器上运行多个 Redis 实例,每个实例绑定到不同的 CPU 核心上。这种方法可以使得每个核心运行一个 Redis 实例,从而提高 CPU 的利用率。
  2. 分片(Sharding)
    • 通过分片将数据分布到多个 Redis 实例,每个实例可以运行在不同的服务器或同一服务器的不同核心上。
  3. Redis 集群
    • 使用 Redis 集群可以将数据自动分区到多个节点上。每个节点可以独立运行在不同的 CPU 核心上。
  4. 后台任务
    • Redis 的一些任务,如持久化(RDB 快照和 AOF 日志写入)、过期键的清理等,可以在后台线程中执行,不阻塞主线程。
  5. 使用多线程 I/O
    • 从 Redis 6.0 开始,引入了多线程 I/O 来提高网络通信的效率。虽然核心命令处理仍然是单线程的,但多线程 I/O 可以显著提高网络数据的读写速度。

为什么要做Redis分区?

分区可以让Redis管理更大的内存,Redis将可以使用所有机器的内存。如果没有分区,你最多只能使用一台机器的内存。分区使Redis的计算能力通过简单地增加计算机得到成倍提升,Redis的网络带宽也会随着计算机和网卡的增加而成倍增长。

有哪些Redis分区实现方案?

  1. 客户端分区就是在客户端就已经决定数据会被存储到哪个redis节点或者从哪个redis节点读取。大多数客户端已经实现了客户端分区。

  2. 代理分区 意味着客户端将请求发送给代理,然后代理决定去哪个节点写数据或者读数据。代理根据分区规则决定请求哪些Redis实例,然后根据Redis的响应结果返回给客户端。redis和memcached的一种代理实现就是Twemproxy

  3. 查询路由(Query routing) 的意思是客户端随机地请求任意一个redis实例,然后由Redis将请求转发给正确的Redis节点。Redis Cluster实现了一种混合形式的查询路由,但并不是直接将请求从一个redis节点转发到另一个redis节点,而是在客户端的帮助下直接redirected到正确的redis节点。

Redis分区有什么缺点?

  1. 涉及多个key的操作通常不会被支持。例如你不能对两个集合求交集,因为他们可能被存储到不同的Redis实例(实际上这种情况也有办法,但是不能直接使用交集指令)。

  2. 同时操作多个key,则不能使用Redis事务.

  3. 分区使用的粒度是key,不能使用一个非常长的排序key存储一个数据集

  4. 当使用分区的时候,数据处理会非常复杂,例如为了备份你必须从不同的Redis实例和主机同时收集RDB / AOF文件。

  5. 分区时动态扩容或缩容可能非常复杂。Redis集群在运行时增加或者删除Redis节点,能做到最大程度对用户透明地数据再平衡,但其他一些客户端分区或者代理分区方法则不支持这种特性。然而,有一种预分片的技术也可以较好的解决这个问题。

Redis 的高可用

Redis的高可用,主要通过主从复制机制以及Sentinel集群来实现。

  1. 主从复制 分为两个阶段,首先,当从服务器发起SYNC命令后,主服务器会生成最新的RDB文件发送给从服务器,并使用一个缓冲区来记录从此刻开始主服务器执行的所有写命令;待RDB文件传输完之后,再将该缓冲区的数据再发送给从服务器,这样就完成了复制。旧的Redis版本有个缺陷是,如果在第二个阶段发生失败,需要从第一个阶段重新开始同步,而这个阶段的操作会消耗大量的CPU、内存和磁盘I/O以及网络带宽资源,太过耗费资源。所以从2.8版本开始,实现了部分重同步,通过主从服务器各维护一个复制偏移量来实现。
  2. Sentinel 由一个或多个Sentinel实例组成的哨兵系统,可以监视任意多个主从服务器,并完成Failover的操作。Sentinal其实是一个运行在特殊模式下的Redis服务器,运行期间,会与各服务器建立网络连接,以检测服务器的状态;同时会与其它Sentinel服务器创建连接,完成信息交换,比如发现某个主服务器心跳异常时,会互相询问心跳结果,当超过一定数量时即可判定为客观下线;一旦主服务器被判定为客观下线状态,那么Sentinel集群会通过raft协议选举,选出一个Leader来执行Failover。
  3. Failover 一般来说,会先选出优先级最高的从服务器,然后再从中选出复制偏移量最大的实例,作为新的主服务器;最后将其它从和旧的主都切换为新主的从。

当从服务器有2个或者多个时,Redis的主从架构可以有两种形式。一种是,所有的从服务器直接挂在主服务器上,这种模式的优点是,所有从服务器复制的延迟相对较低,而缺点在于加大了主服务器的复制压力;另一种形式,是采用级联的方式,S1从M复制,S2从S1复制,以此类推,这种模式的优点是,将主服务器的复制压力分摊到多个服务器上,而缺点在于越处于级联下游的从实例,复制延迟就越大。

从主从复制模式可以看出,Redis的数据只能保证最终一致,不能保证强一致性。

Redis的扩展性

读扩展,基于主从架构,可以很好的平行扩展读的能力。写扩展,主要受限于主服务器的硬件资源的限制,一是单个实例内存容量受限,二是一个实例只使用到CPU一个核。下面讨论基于多套主从架构Redis实例的集群实现,目前主要有以下几种方案:

  1. 客户端分片 实现方案,业务进程通过对key进行hash来分片,用Sentinel做failover。优点:运维简单,每个实例独立部署;可使用lua脚本,业务进程执行的key均hash到同一个分片即可;缺点:一旦重新分片,由于数据无法自动迁移,部分数据需要回源;
  2. Redis集群 是官方提供的分布式数据库方案,通过分片实现数据共享,并提供复制和failover。按照16384个槽位进行分片,且实例之间共享分片视图。优点:当发生重新分片时,数据可以自动迁移;缺点:客户端需要升级到支持集群协议的版本;客户端需要感知分片实例,最坏的情况,每个key需要一次重定向;不支持lua脚本;不支持pipeline;
  3. Codis 是由豌豆荚团队开源的一款分布式组件,它将分布式的逻辑从Redis集群剥离出来,交由几个组件来完成,与数据的读写解耦。Codis proxy负责分片和聚合,dashboard作为管理后台,zookeeper做配置管理,Sentinel做failover。优点:底层透明,客户端兼容性好;重新分片时,数据可自动迁移;支持pipeline;支持lua脚本,业务进程保证执行的key均hash到同一个分片即可;缺点:运维较为复杂;引入了中间层;

六、消息队列

什么是 Redis 消息队列?有哪些实现方式?

Redis 消息队列利用 Redis 的数据结构(如 list、stream)实现生产者-消费者模型。实现方式包括:

  • 基于 listLPUSHRPOP(简单队列)。
  • 基于 pub/sub 的发布订阅模式(实时通知)。
  • 基于 stream 的消息流功能(高效、可靠的队列)。

Redis 的 pub/sub 机制的原理是什么?优缺点是什么?

原理:pub/sub 是一种广播机制,发布者将消息发送到指定的频道,订阅者接收频道中的消息。

优点:实时性强,轻量级实现简单。

缺点:

  • 不保证消息持久化。
  • 无法保证订阅者一定能收到消息(离线订阅无效)。
  • 不能实现复杂的消费分组需求。

Redis Stream 是什么?与传统队列有什么区别?

Redis Stream 是 Redis 5.0 引入的日志型数据结构,支持消费分组和持久化。

优势:

  • 消息持久化,保证消息可靠性。
  • 支持消费分组(类似 Kafka 的消费模型)。
  • 可记录消费偏移量,适用于复杂的消息队列场景。

如何用 Redis 实现一个延时队列?

  • 使用 zset(有序集合):
    • 将任务的执行时间作为分值 score,任务内容作为成员 member
    • 定期扫描 zset,取出分值小于当前时间的任务执行。

示例伪代码:

ZADD delay_queue <timestamp> <task>
ZREMRANGEBYSCORE delay_queue -inf <current_time> -> 执行并删除到期任务

Redis 消息队列的瓶颈在哪?如何优化?

  • 瓶颈:
    • 单线程处理模型下,队列写入和读取的高并发可能导致性能瓶颈。
    • 数据量大时,内存消耗过高。
  • 优化:
    • 使用 Redis Cluster 分片存储队列。
    • 调整内存策略或淘汰策略(如 noeviction)。

七、Redis 内存相关问题

Redis 过期键的删除策略?

先抛开 Redis 想一下几种可能的删除策略:

  1. 定时删除:在设置键的过期时间的同时,创建一个定时器 timer. 让定时器在键的过期时间来临时,立即执行对键的删除操作。
  2. 惰性删除:放任键过期不管,但是每次从键空间中获取键时,都检查取得的键是否过期,如果过期的话,就删除该键;如果没有过期,就返回该键。
  3. 定期删除:每隔一段时间程序就对数据库进行一次检查,删除里面的过期键。至于要删除多少过期键,以及要检查多少个数据库,则由算法决定。
  4. 延迟队列:也就是把对象放到一个延迟队列里面。当从队列里取出这个对象的时候,就说明它已经过期了,这时候就可以删除。

在上述的几种策略中定时删除和定期删除属于不同时间粒度的 主动删除,惰性删除属于 被动删除

四种策略都有各自的优缺点

  1. 定时删除对内存使用率有优势,但是对 CPU 不友好;
  2. 惰性删除对内存不友好,如果某些键值对一直不被使用,那么会造成一定量的内存浪费;
  3. 延迟删除,队列本省就有开销
  4. 定期删除,时间不准确,性能损耗不可控,如果触发删除的时候,很多已经过期了,那么当期定时删除就很耗时;

Redis 中的实现

Redis 过期键的删除策略有三种主要方法:定期删除(Scheduled Deletion)、惰性删除(Lazy Deletion)和主动删除(Active Deletion)。这些策略结合使用,以确保在性能和内存之间取得平衡。

1. 定期删除(Scheduled Deletion)

Redis 定期检查键的过期时间并删除过期键。这个过程是通过 Redis 的后台任务进行的,每隔一段时间会随机检查一部分键,删除其中已过期的键。

  • 实现方式:默认情况下,Redis 每隔 100 毫秒运行一次过期扫描任务,检查设置了过期时间的键,并删除过期的键。
  • 优点:分摊了删除操作的开销,避免了集中删除大量键导致的性能问题。
  • 缺点:不能保证过期键在过期后立即被删除,可能会存在一段时间的滞留。

2. 惰性删除(Lazy Deletion)

当客户端访问某个键时,Redis 会检查该键是否过期,如果过期则立即删除。

expireIfNeeded 的作用是, 如果输入键已经过期的话, 那么将键、键的值、键保存在 expires 字典中的过期时间都删除掉。

  • 实现方式:每次访问键时,都会进行过期时间检查,若键已过期则删除该键并返回空结果。
  • 优点:确保访问时一定不会返回已过期的键,删除操作与键的访问相结合,不额外消耗 CPU 资源。
  • 缺点:如果某些过期键长时间不被访问,它们将继续占用内存,直到被定期删除任务或其他方式删除。

3. 主动删除(Active Deletion)

当 Redis 内存不足时,会主动扫描并删除过期键,以释放内存。

  • 实现方式:Redis 配置了内存淘汰策略(如 volatile-lruallkeys-lru 等),当内存达到限制时,Redis 会通过删除过期键来释放内存。
  • 优点:确保在内存不足时能够及时释放内存,避免系统因内存不足崩溃。
  • 缺点:这种方式通常作为内存淘汰策略的一部分,不单独使用。

在 Redis 的 3.2 版本之前,如果读从库的话,是有可能读取到已经过期的key。后来在 3.2 版本之后这个 Bug 就被修复了。不过从库上的懒惰删除特性和主库不一样。主库上的懒惰删除是在发现 key 已经过期之后,就直接删除了。但是在从库上,即便 key 已经过期了,它也不会删除,只是会给你返回一个 NULL 值。

Redis 的淘汰策略有哪些?

Redis 有八种淘汰策略

为了保证 Redis 的安全稳定运行,设置了一个 max-memory 的阈值,那么当内存用量到达阈值,新写入的键值对无法写入,此时就需要内存淘汰机制,在 Redis 的配置中有几种淘汰策略可以选择,详细如下:

策略 描述
volatile-lru 从已设置过期时间的 KV 集中优先对最近最少使用(less recently used)的数据淘汰
volitile-ttl 从已设置过期时间的 KV 集中优先对剩余时间短(time to live)的数据淘汰
volitile-random 从已设置过期时间的 KV 集中随机选择数据淘汰
allkeys-lru 从所有 KV 集中优先对最近最少使用(less recently used)的数据淘汰
allKeys-random 从所有 KV 集中随机选择数据淘汰
noeviction 不淘汰策略,若超过最大内存,返回错误信息

4.0 版本后增加以下两种

  • volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  • allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

一组曾经是热点数据,后面不是了,对于lru和lfu处理时会有什么区别?

LRU(Least Recently Used):基于时间维度,淘汰最久未被访问的数据。

LFU(Least Frequently Used):基于频率维度,淘汰一定时期内访问次数最少的数据。

曾经是热点数据,后来不再是热点,对于 LRU 和 LFU 的处理区别

  • 对于 LRU 策略
    • 行为:当一组数据曾经是热点,被频繁访问,其最近访问时间会被更新为最新值。然而,当它们不再被访问时,随着时间推移,其时间戳会逐渐变旧。
    • 淘汰机制:当需要淘汰数据时,LRU 会选择那些最近最少被访问的键,即时间戳最早的键。因此,这些曾经的热点数据会因为长时间未被访问而被优先淘汰。
    • 效果:LRU 能够较快地清除不再被使用的旧热点数据,为新的数据腾出空间。
  • 对于 LFU 策略
    • 行为:曾经是热点的数据,其访问计数器较高,即使之后不再被访问,其计数值仍然保持在高位。
    • 淘汰机制:LFU 会选择访问频率最低的键进行淘汰。由于旧热点数据的计数器较高,它们不会被立即淘汰。
    • 衰减机制:Redis 为了避免旧热点数据长期占用内存,引入了计数器衰减机制。计数器会随着时间逐渐降低,如果键长时间未被访问,计数器会减小,最终可能被淘汰。
    • 效果:LFU 会更长时间地保留曾经的热点数据,即使它们近期未被访问。这在一定程度上保护了历史上重要的数据,但也可能导致旧数据占用内存空间。

Redis 内存满了怎么办

  1. 增加内存;
  2. 使用内存淘汰策略(redis设置配置文件的*maxmemory参数,可以控制其最大可用内存大小,可以通过配置 maxmemory-policy 设置淘汰策略)
  3. 压缩数据
  4. 集群

Redis 线程模型

Redis 的线程模型是单线程事件驱动模型,这意味着它在处理客户端请求时使用单个线程。然而,Redis 使用 I/O 多路复用技术来高效地管理多个客户端连接。以下是 Redis 线程模型的主要特点和工作原理:

单线程模型

  1. 单线程架构
    • Redis 主要使用单个线程来处理所有客户端请求,包括读写操作和命令执行。这使得 Redis 的实现相对简单且高效,因为不需要处理多线程并发问题,如锁竞争和线程同步。
  2. I/O 多路复用
    • Redis 使用 I/O 多路复用(I/O multiplexing)技术,通过 epoll(Linux)、kqueue(BSD)或 select 等系统调用来同时处理多个客户端连接。I/O 多路复用允许 Redis 在一个线程内同时处理大量连接而不会阻塞。
    • I/O 多路复用机制使得 Redis 可以在一个事件循环中处理多个套接字的 I/O 事件,从而提高并发处理能力。

事件驱动模型

Redis 使用事件驱动模型(Event-Driven Model),在主线程的事件循环中执行各种事件,包括网络事件和定时事件。事件驱动模型确保 Redis 可以高效地处理大量并发请求。

  1. 事件循环
    • Redis 的事件循环不断地检查网络事件(如客户端连接和数据传输)和定时事件(如键过期检查)。当事件发生时,调用相应的事件处理函数进行处理。
  2. 事件处理器
    • Redis 将网络 I/O 操作和命令执行分成不同的事件处理器。网络 I/O 处理器负责接受客户端连接、读取请求和发送响应,命令处理器负责执行具体的 Redis 命令。

多线程 I/O 模式

虽然 Redis 本质上是单线程模型,但在 6.0 版本开始引入了有限的多线程支持,用于处理 I/O 操作。通过启用 I/O 线程,可以在处理网络 I/O 时利用多核 CPU,从而提高性能。

  1. I/O 线程

    • Redis 6.0 及更高版本可以配置多个 I/O 线程用于读取和处理客户端请求,但命令执行仍然在主线程中进行。这样可以减少 I/O 操作对主线程的阻塞,提高整体吞吐量。
  2. 配置示例: 在 redis.conf中启用和配置 I/O 线程:

    io-threads-do-reads yes
    io-threads 4  # 配置 I/O 线程数
    

Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器(file event handler)。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器队列的消费是单线程的,所以 Redis 才叫单线程模型。

文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。

当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关闭(close)等操作时, 与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行, 但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型, 又可以很好地与 redis 服务器中其他同样以单线程方式运行的模块进行对接, 这保持了 Redis 内部单线程设计的简单性。

什么是 Reactor 模式?

Reactor 模式是一种基于事件驱动的设计模式,广泛应用于高性能网络服务开发中,例如 Redis、Netty 和 Java 的 NIO 编程中。它通过非阻塞 IO 和事件循环的方式高效地处理并发请求,避免了线程阻塞和资源浪费问题。Redis 的高性能在很大程度上依赖于这种模式。

Reactor 模式的基本原理

Reactor 模式将应用程序的请求处理分为以下几个关键角色:

  1. Reactor (事件分发器)
    • 负责监听事件并将其分发给相应的事件处理器。
    • Reactor 运行在单线程或多线程上,集中管理 IO 操作。
  2. 事件源 (资源提供者):事件源是产生 IO 事件的实体,例如网络连接、文件描述符等。
  3. 事件处理器 (Handlers):负责具体的事件处理逻辑,例如读取数据、处理业务逻辑、写回数据等。
  4. Demultiplexer (IO 多路复用器):使用操作系统提供的 IO 多路复用机制(如 selectpollepoll)监听多个 IO 通道,将准备好的事件传递给 Reactor。

Reactor 模式的工作流程

  1. 事件监听:Reactor 使用多路复用器不断监听 IO 事件,如读事件、写事件或连接事件。
  2. 事件分发:当有事件发生时,Reactor 从多路复用器获取事件,并将其分发给对应的事件处理器。
  3. 事件处理:事件处理器根据事件类型执行相应的操作(如读取数据、业务处理、写回数据等)。

Redis 中的 Reactor 模式

Redis 是一个基于事件驱动模型的高性能内存数据库,单线程模型的高效运转很大程度上归功于 Reactor 模式。

Redis 的工作流程:

  1. 单线程事件循环:Redis 使用单线程来处理所有客户端的请求,通过 IO 多路复用机制(如 epoll)监听多个连接上的 IO 事件。
  2. 事件分发:当某个连接上有事件发生(如可读、可写),事件被分发给对应的处理器。
  3. 事件处理:Redis 的事件处理器包括:
    • 文件事件处理器:负责处理客户端请求、网络 IO 操作。
    • 时间事件处理器:处理定时任务,例如持久化、清理过期键等。

Redis 的事件模型细节:

  • Redis 的事件模型实现基于 ae.c 文件(Async Event)。
  • 它通过 文件事件处理器(处理网络 IO)和 时间事件处理器(处理定时任务)协同工作。
  • 文件事件处理器利用操作系统的 IO 多路复用机制监听客户端的请求,单线程模型避免了多线程带来的复杂性(如线程同步问题)。

Redis 内存模型

Redis 内存主要可以分为:数据部分、Redis进程本身、缓冲区内存、内存碎片这四个部分。Redis 默认通过jemalloc 来分配内存。

  • 数据内存:数据内存用来存储 Redis 的键值对、慢查询日志等,是主要占用内存的部分,这部分内存会统计在used_memory中

  • Redis进程内存:Redis进程本身也会占用一部分内存,这部分内存不是jemalloc分配,不会统计在used_memory中。执行RDB和AOF时创建的子进程也会占用内存,但也不会统计在used_memory中。

  • 缓冲内存(动态管理):

    • 客户端缓冲区:存储客户端连接的输入/输出数据,通过 client-output-buffer-limit 限制大小,防止溢出。
    • 复制积压缓冲区:用于主从复制的增量同步(PSYNC),大小由 repl-backlog-size 配置,默认 1MB。
    • AOF缓冲区:暂存最近写入的命令,持久化时同步到磁盘。

    分配方式:由 jemalloc 分配,计入 used_memory

  • 内存碎片

    • 来源:频繁的键值增删及 jemalloc 内存块分配策略导致未完全利用的内存空间。
    • 监控指标:
      • 碎片率:mem_fragmentation_ratio = used_memory_rss / used_memory,健康值约 1.03(jemalloc)。
      • 若碎片率 >1.5,需考虑优化;>2 可能触发性能问题。
    • 优化手段:
      • 重启重排:安全重启后通过 RDB/AOF 恢复数据,内存重新分配以减少碎片。
      • 自动碎片整理(Redis 4.0+):开启 activedefrag,根据阈值动态整理碎片

八、Redis 缓存异常问题

Redis常见性能问题和解决方案?

Redis 的常见性能问题和解决方案包括但不限于以下几点:

  1. 内存问题
    • 问题:内存不足或内存碎片导致性能下降。
    • 解决方案:使用 MEMORY USAGE 命令监控内存使用情况,优化数据结构,合理配置 maxmemory 并选择合适的内存淘汰策略。
  2. 高并发下的响应延迟
    • 问题:在高并发访问时,响应时间变长。
    • 解决方案:优化命令使用,避免使用耗时的命令,使用 Pipelining 技术批量执行命令,考虑使用 Redis 集群进行负载均衡。
  3. 慢查询
    • 问题:执行慢查询导致阻塞。
    • 解决方案:使用 slowlog 命令找出并优化慢查询,确保单次操作尽可能快速。
  4. 主从复制延迟
    • 问题:主从复制延迟影响数据的实时性。
    • 解决方案:优化网络条件,升级硬件,使用更高效的复制协议如 PSYNC,考虑使用无磁盘化复制减少延迟。
  5. 持久化性能问题
    • 问题:RDB 和 AOF 持久化影响性能。
    • 解决方案:合理配置持久化策略,如 AOF 刷盘策略 everysec,或使用 RDB-AOF 混合持久化。
  6. 热 Key 问题
    • 问题:某些键被频繁访问,成为热点,可能导致单个实例负载过高。
    • 解决方案:使用本地缓存减轻 Redis 负担,或在多个实例间分散热点数据。
  7. 数据结构选择
    • 问题:使用不合适的数据结构导致性能问题。
    • 解决方案:根据数据类型和操作需求选择合适的数据结构,例如使用 intset 代替普通列表存储整数列表。
  8. 连接数过多
    • 问题:大量客户端连接可能会导致资源耗尽。
    • 解决方案:使用连接池、限制最大客户端连接数、优化客户端连接管理。
  9. 单线程阻塞
    • 问题:由于单线程模型,阻塞操作会影响性能。
    • 解决方案:避免使用耗时的单个命令,如 KEYSFLUSHALLFLUSHDB 等,使用 SCAN 替代。
  10. 版本问题
    • 问题:使用过时的 Redis 版本可能会导致性能问题。
    • 解决方案:升级到最新稳定版本的 Redis,以利用性能改进和新特性。
  11. 监控和告警
    • 问题:缺乏监控和告警可能导致性能问题被忽视。
    • 解决方案:实施 Redis 监控策略,使用工具如 Redis INFO 命令、redis-cli 工具等进行性能监控和调优。

针对 Redis 的性能问题,解决方案通常需要根据具体的业务场景和需求来定制。在设计和构建应用时,应考虑到 Redis 的特点,合理使用其提供的各种功能和命令,以确保系统的高性能和稳定性

如何保证缓存与数据库双写时的数据一致性?

你的业务中使用了缓存之后,你是如何更新缓存和数据库中的数据的?有没有一致性问题?

你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?

一般来说,就是如果你的系统不是严格要求缓存+数据库必须一致性的话,缓存可以稍微的跟数据库偶尔有不一致的情况,最好不要做这个方案,读请求和写请求串行化,串到一个内存队列里去,这样就可以保证一定不会出现不一致的情况。

串行化之后,就会导致系统的吞吐量会大幅度的降低,用比正常情况下多几倍的机器去支撑线上的一个请求。

操作缓存的时候我们都是采取删除缓存策略的,原因如下:

  1. 高并发环境下,无论是先操作数据库还是后操作数据库而言,如果加上更新缓存,那就更加容易导致数据库与缓存数据不一致问题。(删除缓存直接和简单很多)
  2. 如果每次更新了数据库,都要更新缓存【这里指的是频繁更新的场景,这会耗费一定的性能】,倒不如直接删除掉。等再次读取时,缓存里没有,那我到数据库找,在数据库找到再写到缓存里边(体现懒加载)

这里就又有个问题:是先更新数据库,再删除缓存,还是先删除缓存,再更新数据库呢

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

正常的情况是这样的:

  • 先操作数据库,成功;
  • 再删除缓存,也成功;

如果原子性被破坏了:

  • 第一步成功(操作数据库),第二步失败(删除缓存),会导致数据库里是新数据,而缓存里是旧数据
  • 如果第一步(操作数据库)就失败了,我们可以直接返回错误(Exception),不会出现数据不一致。

如果在高并发的场景下,出现数据库与缓存数据不一致的概率特别低,也不是没有:

  • 缓存刚好失效
  • 线程A查询数据库,得一个旧值
  • 线程B将新值写入数据库
  • 线程B删除缓存
  • 线程A将查到的旧值写入缓存

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

正常情况是这样的:

  • 先删除缓存,成功;
  • 再更新数据库,也成功;

如果原子性被破坏了:

  • 第一步成功(删除缓存),第二步失败(更新数据库),数据库和缓存的数据还是一致的。
  • 如果第一步(删除缓存)就失败了,我们可以直接返回错误(Exception),数据库和缓存的数据还是一致的。

看起来是很美好,但是我们在并发场景下分析一下,就知道还是有问题的了:

  • 线程A删除了缓存
  • 线程B查询,发现缓存已不存在
  • 线程B去数据库查询得到旧值
  • 线程B将旧值写入缓存
  • 线程A将新值写入数据库

所以也会导致数据库和缓存不一致的问题。但是我们一般选择这种

延迟双删

延迟双删类似于删除缓存的做法,它在第一次删除操作之后设定一个定时器,在一段时间之后再次执行删除。

第二次删除就是为了避开删除缓存中的读写导致数据不一致的场景。

  • 增加了系统的复杂度,需要合适的机制指定第二次删除操作
  • 第二次删除延迟时间不好确定,太短可能无效,太长可能导致长时间的数据不一致

推荐阅读:

https://mp.weixin.qq.com/s/3Fmv7h5p2QDtLxc9n1dp5A

https://zhuanlan.zhihu.com/p/48334686

使用缓存会出现什么问题?

缓存雪崩

缓存雪崩是指缓存同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案

  1. 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。

    这个随机时间也是有讲究的,我们假设过期时间是 10 分钟,那要在这个基础上加一个 0-210左右秒的“偏移量”都可以的,这个偏移量要跟过期时间成正比,不能过低或者过高

  2. 一般并发量不是特别多的时候,使用最多的解决方案是加锁排队(key上锁,其他线程不能访问,假设在高并发下,缓存重建期间key是锁着的,这是过来1000个请求999个都在阻塞的。同样会导致用户等待超时,这是个治标不治本的方法!)。

  3. 给每一个缓存数据增加相应的缓存标记,记录缓存的是否失效,如果缓存标记失效,则更新数据缓存。

  4. 设置热点数据静态化,把访问量较大的数据做静态化处理,减少数据库的访问。

缓存穿透

缓存穿透是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案

  1. 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0 的直接拦截;

  2. 回写特殊值:从缓存取不到的数据,在数据库中也没有取到,这时也可以将 key-value 对写为 key-null,缓存有效时间可以设置短点,如 30 秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击

    如果攻击者每次都用不同的且都不存在的 key 来请求数据,那么这种措施毫无 效果。并且,因为要回写特殊值,那么这些不存在的 key 都会有特殊值,浪费 了 Redis 的内存。这可能会进一步引起另外一个问题,就是 Redis 在内存不 足,执行淘汰的时候,把其他有用的数据淘汰掉。

  3. 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。

    但是布隆过滤器本身存在假阳性的问题,所以当攻击者请求一个不存在的 key 的时候,布隆过滤器可能会返回数据存在的假阳性响应。在这种情况下,业务 代码依旧会去查询缓存和数据库。不过这个不需要担心,因为假阳性的概率是 很低的。假如说假阳性概率是万分之一,那么就算攻击的并发有百万,也只有 100 个查询请求会落到数据库上,这一点查询请求就是毛毛雨了。

缓存击穿

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。

缓存击穿是指一个 Key 非常热点,在不停地扛着大量的请求,大并发集中对这一个点进行访问,当这个 Key 在失效的瞬间,持续的大并发直接落到了数据库上,就在这个 Key 的点上击穿了缓存

和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决方案

  1. 热点数据永远不过期:物理不过期,但逻辑过期(后台异步线程去刷新)

  2. 使用互斥锁:当缓存失效时,不立即去 load db,先使用如 Redis 的 setnx 去设置一个互斥锁,当操作成功返回时再进行 load db 的操作并回设缓存,否则重试 get 缓存的方法

缓存预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题!用户直接查询事先被预热的缓存数据!

解决方案

  1. 直接写个缓存刷新页面,上线时手工操作一下;

  2. 数据量不大,可以在项目启动的时候自动进行加载;

  3. 定时刷新缓存;

缓存降级

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,仍然需要保证服务还是可用的,即使是有损服务。系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。

缓存降级的最终目的是保证核心服务可用,即使是有损的。而且有些服务是无法降级的(如加入购物车、结算)。

在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:

  1. 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;

  2. 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;

  3. 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级;

  4. 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

服务降级的目的,是为了防止 Redis 服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。

缓存热点 key

缓存中的一个 Key(比如一个促销商品),在某个时间点过期的时候,恰好在这个时间点对这个 Key 有大量的并发请求过来,这些请求发现缓存过期一般都会从后端 DB 加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端 DB 压垮。

解决方案

  1. 对缓存查询加锁,如果 KEY 不存在,就加锁,然后查 DB 入缓存,然后解锁;
  2. 其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入 DB 查询

Redis 大 key 和 热 Key 问题

https://help.aliyun.com/document_detail/353223.html

Redis 的过程中,如果未能及时发现并处理 Big keys(下文称为“大Key”)与 Hotkeys(下文称为“热Key”),可能会导致服务性能下降、用户体验变差,甚至引发大面积故障

大Key

通常以Key的大小和Key中成员的数量来综合判定,例如:

  • Key本身的数据量过大:一个String类型的Key,它的值为5 MB。
  • Key中的成员数过多:一个ZSET类型的Key,它的成员数量为10,000个。
  • Key中成员的数据量过大:一个Hash类型的Key,它的成员数量虽然只有1,000个但这些成员的Value(值)总大小为100 MB。
引发的问题
  • 客户端执行命令的时长变慢。
  • Redis内存达到maxmemory参数定义的上限引发操作阻塞或重要的Key被逐出,甚至引发内存溢出(Out Of Memory)。
  • 集群架构下,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。
  • 对大Key执行读请求,会使Redis实例的带宽使用率被占满,导致自身服务变慢,同时易波及相关的服务。
  • 对大Key执行删除操作,易造成主库较长时间的阻塞,进而可能引发同步中断或主从切换。
原因
  • 在不适用的场景下使用Redis,易造成Key的value过大,如使用String类型的Key存放大体积二进制文件型数据;
  • 业务上线前规划设计不足,没有对Key中的成员进行合理的拆分,造成个别Key中的成员数量过多;
  • 未定期清理无效数据,造成如HASH类型Key中的成员持续不断地增加;
  • 使用LIST类型Key的业务消费侧发生代码故障,造成对应Key的成员只增不减。

热Key

通常以其接收到的Key被请求频率来判定,例如:

  • QPS集中在特定的Key:Redis实例的总QPS(每秒查询率)为10,000,而其中一个Key的每秒访问量达到了7,000。
  • 带宽使用率集中在特定的Key:对一个拥有上千个成员且总大小为1 MB的HASH Key每秒发送大量的HGETALL操作请求。
  • CPU使用时间占比集中在特定的Key:对一个拥有数万个成员的Key(ZSET类型)每秒发送大量的ZRANGE操作请求。
引发的问题
  • 占用大量的CPU资源,影响其他请求并导致整体性能降低。
  • 集群架构下,产生访问倾斜,即某个数据分片被大量访问,而其他数据分片处于空闲状态,可能引起该数据分片的连接数被耗尽,新的连接建立请求被拒绝等问题。
  • 在抢购或秒杀场景下,可能因商品对应库存Key的请求量过大,超出Redis处理能力造成超卖。
  • 热Key的请求压力数量超出Redis的承受能力易造成缓存击穿,即大量请求将被直接指向后端的存储层,导致存储访问量激增甚至宕机,从而影响其他业务。
原因
  • 预期外的访问量陡增,如突然出现的爆款商品、访问量暴涨的热点新闻、直播间某主播搞活动带来的大量刷屏点赞、游戏中某区域发生多个工会之间的战斗涉及大量玩家等。

找出大 Key 和 热 Key 并解决

Redis提供多种方案帮助您轻松找出大Key与热Key。

  • 实时 Top Key 统计
  • 通过 redis-cli 的 bigkeys 和 hotkeys 参数查找
  • 通过内置命令对目标 key 分析(比如 String 类型,通过 STRLEN 查看字节数)
  • 业务层定位 key (对业务层加访问记录并异步汇总分析)
  • 通过 MONITOR 命令找出热 Key

优化大 Key 和 热 Key

大 Key

  • 对大Key进行拆分

    例如将含有数万成员的一个HASH Key拆分为多个HASH Key,并确保每个Key的成员数量在合理范围。在Redis集群架构中,拆分大Key能对数据分片间的内存平衡起到显著作用

  • 对大Key进行清理

  • 监控Redis的内存水位

  • 对过期数据进行定期清理

热 Key

  • 在Redis集群架构中对热Key进行复制
  • 在Redis集群架构中对热Key进行复制

九、分布式锁相关问题

Redis 实现分布式锁

默认指定大家用的是 Redis 2.6.12 及更高的版本,就不再去讲 setnxexpire 这种了,直接 set 命令加锁

set key value[expiration EX seconds|PX milliseconds] [NX|XX]

SET 命令的行为可以通过一系列参数来修改

  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value
  • XX :只在键已经存在时,才对键进行设置操作。
SET resource_name my_random_value NX PX 30000

这条指令的意思:当 key——resource_name 不存在时创建这样的 key,设值为 my_random_value,并设置过期时间 30000 毫秒。

别看这干了两件事,因为 Redis 是单线程的,这一条指令不会被打断,所以是原子性的操作。

Redis 实现分布式锁的主要步骤:

  1. 指定一个 key 作为锁标记,存入 Redis 中,指定一个 唯一的标识 作为 value。
  2. 当 key 不存在时才能设置值,确保同一时间只有一个客户端进程获得锁,满足 互斥性 特性。
  3. 设置一个过期时间,防止因系统异常导致没能删除这个 key,满足 防死锁 特性。
  4. 当处理完业务之后需要清除这个 key 来释放锁,清除 key 时需要校验 value 值,需要满足 解铃还须系铃人

设置一个随机值的意思是在解锁时候判断 key 的值和我们存储的随机数是不是一样,一样的话,才是自己的锁,直接 del 解锁就行。

当然这个两个操作要保证原子性,所以 Redis 给出了一段 lua 脚本(Redis 服务器会单线程原子性执行 lua 脚本,保证 lua 脚本在处理的过程中不会被任意其它请求打断。):

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

上述 Redis 分布式锁的缺点

  1. 单点故障:如果 Redis 服务器出现故障,整个分布式锁服务将不可用。尽管可以通过 Redis 集群或哨兵模式提高可用性,但这些方法会增加系统的复杂性。

  2. 锁失效问题:由于网络延迟或 Redis 服务器负载高等原因,设置的锁可能会在预期的时间之前失效。如果锁失效时间过短,业务逻辑可能还未完成就会失去锁,导致数据不一致。

  3. 时钟漂移:Redis 分布式锁依赖于系统时间。如果多个节点的系统时间不同步,可能会导致锁的时间计算错误,进而引发锁的竞争和数据一致性问题。

  4. 不可重入性:Redis 分布式锁通常是不可重入的,这意味着一个持有锁的线程不能再次获得同一个锁。如果业务逻辑中存在重入需求,需要额外的设计来处理。 【可以考虑使用 Redisson 等工具,它们提供了可重入锁的封装】

  5. 原子性和一致性

    尽管使用 SET NX PX 命令可以实现锁的基本原子性,但在处理锁的释放、续租等复杂场景时,需要小心处理原子性和一致性。例如,在释放锁时,如果释放锁的客户端在删除锁之前崩溃,可能会导致锁无法正确释放。

  6. 锁超时和业务时间不匹配

    设置的锁超时时间可能和业务实际执行时间不匹配,特别是在业务执行时间不可预期的情况下。过短的锁超时时间可能导致锁在业务未完成时被其他节点获取,过长的锁超时时间则可能降低系统并发性。

  7. 主从复制问题:在 Redis 主从复制模式下,如果主节点宕机,从节点被提升为新的主节点,可能会导致锁丢失,从而产生并发问题

  8. 客户端实现复杂:实现一个健壮的分布式锁需要处理很多细节问题,如锁的续租、锁的过期等,这些会增加客户端的实现复杂度。尽管有一些成熟的库(如 Redisson)可以帮助简化这些操作,但依然需要谨慎使用和配置。

  9. 性能开销:虽然 Redis 的性能很高,但频繁的锁操作(获取、续租、释放等)会对 Redis 服务器造成一定的压力,特别是在高并发环境下。

zk 实现分布式锁的思路

  1. 客户端对某个方法加锁时,在 zk 上的与该方法对应的指定节点的目录下,生成一个唯一 的瞬时有序节点 node1;
  2. 客户端获取该路径下所有已经创建的子节点,如果发现自己创建的 node1 的序号是最小 的,就认为这个客户端获得了锁。
  3. 如果发现 node1 不是最小的,则监听比自己创建节点序号小的最大的节点,进入等待。
  4. 获取锁后,处理完逻辑,删除自己创建的 node1 即可。

如何解决 Redis 的并发竞争 Key 问题

所谓 Redis 的并发竞争 Key 的问题也就是多个系统同时对一个 key 进行操作,但是最后执行的顺序和我们期望的顺序不同,这样也就导致了结果的不同!

推荐一种方案:分布式锁(zookeeper 和 redis 都可以实现分布式锁)。(如果不存在 Redis 的并发竞争 Key 问题,不要使用分布式锁,这样会影响性能)

基于zookeeper临时有序节点可以实现的分布式锁。大致思想为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。完成业务流程后,删除对应的子节点释放锁。

在实践中,当然是从以可靠性为主。所以首推Zookeeper。

参考:https://www.jianshu.com/p/8bddd381de06

分布式Redis是前期做还是后期规模上来了再做好?为什么?

既然Redis是如此的轻量(单实例只使用1M内存),为防止以后的扩容,最好的办法就是一开始就启动较多实例。即便你只有一台服务器,你也可以一开始就让Redis以分布式的方式运行,使用分区,在同一台服务器上启动多个实例。

一开始就多设置几个Redis实例,例如32或者64个实例,对大多数用户来说这操作起来可能比较麻烦,但是从长久来看做这点牺牲是值得的。

这样的话,当你的数据不断增长,需要更多的Redis服务器时,你需要做的就是仅仅将Redis实例从一台服务迁移到另外一台服务器而已(而不用考虑重新分区的问题)。一旦你添加了另一台服务器,你需要将你一半的Redis实例从第一台机器迁移到第二台机器。

redis分布式锁,过期时间怎么定的,如果一个业务执行时间比较长,锁过期了怎么办,怎么保证释放锁的一个原子性?

  1. 设定锁的过期时间

锁的过期时间应该稍长于业务的预期执行时间,但不能太长,以免资源长期被占用。一般可以按照以下步骤设定锁的过期时间:

  • 估算业务执行时间:根据业务逻辑和历史执行数据,估算业务的最长执行时间。

  • 加上缓冲时间:在估算的执行时间基础上加上一定的缓冲时间,确保大多数情况下锁不会在业务完成前过期。

例如,如果某个业务通常在 5 秒内完成,可以设定锁的过期时间为 7 秒。

2. 处理业务执行时间较长的情况

对于可能执行时间较长的业务,确保锁在业务完成前不会过期是关键。可以使用“锁续期”机制,定期延长锁的过期时间。

续期机制实现

  1. 开启一个定时任务:在获取锁后,开启一个定时任务,每隔一定时间(如过期时间的一半)延长锁的过期时间。

  2. 判断锁的持有者:在续期时,确保当前续期操作仍然是由锁的持有者执行,以防止锁误续期。

    public class RedisLockWithRenewal {
    
        private static final String LOCK_KEY = "my_lock";
        private static final String LOCK_VALUE = "unique_value";
        private static final int EXPIRE_TIME = 7; // 过期时间为7秒
    
        public boolean acquireLock(Jedis jedis) {
            String result = jedis.set(LOCK_KEY, LOCK_VALUE, "NX", "EX", EXPIRE_TIME);
            return "OK".equals(result);
        }
    
        public void releaseLock(Jedis jedis) {
            if (LOCK_VALUE.equals(jedis.get(LOCK_KEY))) {
                jedis.del(LOCK_KEY);
            }
        }
    
        public void renewLock(Jedis jedis) {
            Timer timer = new Timer();
            timer.schedule(new TimerTask() {
                @Override
                public void run() {
                    if (LOCK_VALUE.equals(jedis.get(LOCK_KEY))) {
                        jedis.expire(LOCK_KEY, EXPIRE_TIME);
                        System.out.println("Lock renewed.");
                    } else {
                        timer.cancel();
                    }
                }
            }, EXPIRE_TIME * 500, EXPIRE_TIME * 500); // 每 3.5 秒续期一次
        }
    
        public static void main(String[] args) {
            Jedis jedis = new Jedis("localhost", 6379);
            RedisLockWithRenewal lock = new RedisLockWithRenewal();
    
            if (lock.acquireLock(jedis)) {
                lock.renewLock(jedis);
                try {
                    // 业务逻辑
                    System.out.println("Lock acquired, performing business logic...");
                    Thread.sleep(15000); // 模拟长时间业务执行
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    lock.releaseLock(jedis);
                    System.out.println("Lock released.");
                }
            } else {
                System.out.println("Failed to acquire lock.");
            }
        }
    }

注意事项

  • 锁的唯一性:确保锁的值是唯一的,可以使用 UUID 或业务唯一标识符。
  • 原子性操作:使用 Redis 的 Lua 脚本保证锁的获取和续期操作的原子性,以防止竞态条件。

你们Redis是集群的么,讲讲RedLock算法

Redlock 是 Redis 的一种分布式锁算法,用于确保分布式环境中的锁定机制的安全性和可靠性。它是由 Redis 的作者 Salvatore Sanfilippo(antirez)提出的,旨在解决单个 Redis 实例锁在分布式系统中存在的可靠性问题。以下是 Redlock 算法的工作原理和实现步骤:

Redlock 算法原理

Redlock 算法依赖于以下假设:

  1. 有多个 Redis 实例(通常是 5 个),它们分别运行在不同的节点上。
  2. 客户端会在这些 Redis 实例上请求锁,并使用多数(quorum)机制来决定锁的成功与否。

实现步骤

  1. 获取当前时间
    • 记录当前的精确时间(毫秒级)。
  2. 尝试在每个 Redis 实例上创建锁
    • 使用相同的 key 和随机生成的 value 尝试在每个 Redis 实例上创建锁。创建锁的命令是 SET resource_name my_random_value NX PX 30000,其中 NX 表示仅在 key 不存在时设置,PX 30000 表示锁的过期时间为 30 秒。
    • 设置一个较短的连接和响应超时时间(如 10 毫秒),确保在网络分区或 Redis 实例故障时不会阻塞太久。
  3. 计算获取锁的总时间
    • 计算从开始到成功获取锁所花费的总时间。假设这个时间为 T。
  4. 验证锁的数量和超时
    • 如果客户端在大多数 Redis 实例(例如 3/5 个实例)上成功创建锁,并且 T 小于锁的有效时间(例如 30 秒),则认为锁创建成功。
    • 否则,尝试在每个实例上删除该锁(释放锁)。
  5. 使用和释放锁
    • 客户端使用锁完成相关的业务逻辑。
    • 业务逻辑完成后,客户端需要在每个实例上删除该锁。删除锁的命令是 EVAL "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 resource_name my_random_value,确保只删除自己创建的锁。

Redlock 算法的注意事项:

  • 多数节点:客户端必须至少在大多数 Redis 实例上成功获取锁,才能认为获得了分布式锁。
  • 时钟同步:所有 Redis 服务器的时钟必须同步,以避免由于时钟漂移导致的问题。
  • 网络分区:在网络分区的情况下,Redlock 算法可能无法保证锁的安全。
  • 锁超时:客户端必须设置合理的锁超时时间,以避免死锁。
  • 重试机制:客户端需要实现重试机制,并在重试时等待随机时间,以避免多个客户端同时重试导致的竞争条件。

十、实践

Redis乐观锁的应用场景,举例说明?

Redis 乐观锁是一种用于并发控制的机制,主要用于在高并发场景下确保数据一致性。与悲观锁不同,乐观锁假设大部分情况下数据竞争不会发生,因此不会像悲观锁那样阻塞其他操作。乐观锁通过检查数据的版本号或其他标识符,在更新数据时确保数据没有被其他事务修改。

乐观锁应用场景

乐观锁在需要高并发、高性能和数据一致性的应用场景中非常有用。以下是几个典型的应用场景:

  1. 库存管理
    • 在电商平台中,商品库存数量的更新就是一个典型的乐观锁应用场景。当用户下单时,系统首先读取库存数量,然后尝试减去相应的数量。这个过程可以通过WATCH命令和事务中的MULTI/EXEC命令来实现。如果库存数量在读取和更新之间被其他事务修改了,事务将失败,用户会被提示库存不足。
  2. 订单号生成
    • 在需要生成唯一订单号的系统中,可以使用Redis的原子自增操作INCRINCRBY。乐观锁假设在生成订单号的过程中不太可能出现冲突,即使出现,也可以通过重试机制解决。
  3. 秒杀活动
    • 秒杀活动通常在极短的时间内有大量用户尝试购买同一商品。使用乐观锁,系统可以先检查库存数量,然后尝试更新。如果库存不足,可以快速返回失败信息,而不需要锁住库存资源。
  4. 分布式序列号生成
    • 在分布式系统中生成全局唯一的序列号时,可以使用Redis的乐观锁特性。通过INCR命令,不同的服务实例可以并发地生成唯一的序列号,而不需要复杂的协调机制。
  5. 投票或点赞功能
    • 在社交媒体应用中,用户的点赞操作可以通过Redis的乐观锁来实现。系统首先读取当前的点赞数,然后将其加一。如果在这个过程中点赞数被其他用户更新了,当前操作可以重试或忽略,因为点赞数的最终一致性通常比实时一致性更重要。
  6. 缓存数据的并发更新
    • 当多个服务实例需要更新同一个缓存数据时,可以使用Redis乐观锁来避免数据不一致的问题。每个实例在更新前先读取当前版本号,然后尝试更新数据和版本号。如果版本号在更新过程中发生变化,说明有其他实例已经更新了数据,当前操作可以放弃或重试。
  7. 分布式锁
    • 虽然Redis也常用于实现分布式锁,但在某些情况下,可以使用乐观锁的方式来实现一种更轻量级的分布式锁。例如,使用SETNX命令设置一个键,如果操作成功,则获得锁;如果失败,则表示锁被其他进程持有。

Redis 过期时间优化?如何确定过期时间?

期时间优化的原则:

  • 数据更新频率
  • 数据访问频率
  • 数据的重要性
  • 内存占用

优化过期时间有两个方向。第一个是调大过期时间,提高缓存命中率,并提高性能。又或者是减少过期时间,从而减少 Redis 的消耗

一般我们是根据缓存容量和缓存命中率确定过期时间的。正常来说,越高缓存命中率,需要越多的缓存容量,越长的过期时间。所以最佳的做法还是通过模拟线上流量来做测试,不断延长过期时间,直到满足命中率的要求。当然,也可以从业务场景出发。比如说,当某个数据被查询出来以后,用户大概率在接下来的三十分钟内再次使用这个对象,那么就可以把过期时间设置成 30 分钟。

也可以考虑根据数据是否是热点来确定过期时间。

还有一种情况是预加载很短的过期时间,我们系统中有一个新建模版的功能,好多步骤新建完之后,建好后用户大概率都会去查看新建的模版长什么样,所以新建时候就会把数据缓存一份。

Redis怎么确认命中率?

要确认 Redis 的缓存命中率,可以使用 Redis 提供的统计信息来进行分析。Redis 提供了一个命令 INFO,该命令会返回 Redis 服务器的各种统计和状态信息,其中包括与缓存命中率相关的两个关键指标:keyspace_hitskeyspace_misses

使用 INFO stats 命令获取统计信息,可以看到这两个指标

> info stats
# Stats
total_connections_received:6119693
total_commands_processed:346700954
instantaneous_ops_per_sec:84
total_net_input_bytes:95242250343
total_net_output_bytes:74348467113
...
keyspace_hits:8337999
keyspace_misses:910002

$ \text{命中率} = \frac{\text{keyspace_hits}}{\text{keyspace_hits} + \text{keyspace_misses}} $

其中:

  • keyspace_hits:缓存命中的次数
  • keyspace_misses:缓存未命中的次数

Redis常见性能问题和解决方案?

内存问题

  • 内存不足:Redis 使用内存作为存储介质,内存不足会导致性能下降。解决方案包括优化数据结构、使用内存友好的数据类型、设置合理的内存淘汰策略、过期时间、分片等。
  • 内存碎片:内存碎片率高会增加内存的使用,解决方案包括定期重启 Redis 实例以整理内存碎片,或者使用 Redis 4.0 以上版本的自动内存碎片整理功能

高并发问题

  • 命令队列积压:Redis 是单线程模型,高并发下命令处理可能会排队等待执行。解决方案包括优化命令的使用,避免使用耗时的命令,使用 Pipelining 技术批量执行命令、分片集群等。
  • 连接数过多:大量客户端连接可能会导致资源耗尽。解决方案包括使用连接池、限制最大客户端连接数、优化客户端连接管理等

数据倾斜问题:

在分布式环境中,某些Redis节点负载过高,而其他节点负载较低,导致数据和流量分布不均。

  • 一致性哈希:使用一致性哈希算法分布数据,减少数据倾斜。
  • 重新分片:定期监控和调整数据分布,必要时进行数据迁移和重新分片。
  • 热键问题:监控和识别热键(访问频率非常高的键),某些键被频繁访问,成为热点,可能导致单个实例负载过高。解决方案包括使用本地缓存减轻 Redis 负担、在多个实例间分散热点数据等

大键(Big Key)问题:

单个键值数据量过大,可能导致单次操作耗时过长,影响Redis的整体性能。解决方案包括拆分大键(将大键的数据拆分成多个小键)、分段处理(对大键进行分段处理,每次只处理一部分数据,避免长时间阻塞)、使用Streams(对于需要处理大量数据的应用,可以考虑使用Redis Streams来替代传统的大键数据结构)

慢查询问题

  • 慢查询会影响整体性能。解决方案包括使用 SLOWLOG 命令分析慢查询、优化查询逻辑、使用合适的数据类型和命令等

持久化问题

  • RDB 和 AOF 持久化可能会影响性能。解决方案包括合理配置持久化策略、使用 AOF 刷盘策略 everysec 减少磁盘 IO(而非使用 always)、在低峰时段进行持久化操作等。

主从复制延迟

  • 主从复制延迟会影响数据的实时性。解决方案包括优化网络条件、升级硬件、使用更高效的复制协议如 PSYNC 等

使用Redis做过异步队列吗,是如何实现的?

使用 list 类型保存数据信息,rpush 生产消息,lpop 消费消息,当 lpop 没有消息时,可以 sleep 一段时间,然后再检查有没有信息,如果不想 sleep 的话,可以使用 blpop,在没有信息的时候,会一直阻塞,直到信息的到来。redis 可以通过 pub/sub 主题订阅模式实现一个生产者,多个消费者,当然也存在一定的缺点,当消费者下线时,生产的消息会丢失。

Redis如何实现延时队列

使用 Redis 作为延时队列的实现方法有很多,其中一种常见的方式是使用 Redis 的有序集合(Sorted Set)。有序集合通过成员的分数进行排序,非常适合实现延时队列功能。

实现步骤

  1. 添加任务到延时队列: 将任务添加到 Redis 有序集合中,使用任务的执行时间作为分数。执行时间可以使用 Unix 时间戳表示。
  2. 轮询检查和执行任务: 使用一个定时任务(如每秒运行一次)来轮询检查有序集合中是否有需要执行的任务。当任务的执行时间小于等于当前时间时,执行该任务并将其从集合中移除。

使用 sortedset,使用时间戳做 score,消息内容作为 key,调用 zadd 来生产消息,消费者使用 zrangbyscore 获取 n 秒之前的数据做轮询处理。

Redis如何做内存优化?

尽可能使用散列表(hashes),散列表(是说散列表里面存储的数少)使用的内存非常小,所以你应该尽可能的将你的数据模型抽象到一个散列表里面。比如你的web系统中有一个用户对象,不要为这个用户的名称,姓氏,邮箱,密码设置单独的key,而是应该把这个用户的所有信息存储到一张散列表里面。

Redis 使用误区

键过大

Redis的key是string类型,最大可以是512MB,那么实际中是不是也可以这样用呢?答案是否定的,redis将key保存在一个全局的hashtable,如果key过大,一是占用过多的内存,二是计算hash和字符串比较都会更耗时;一般建议key的大小不超过2kB。

Big key

或者说是big value,这会导致删除key的操作比较耗时,会阻塞主线程。比如有些同学喜欢用集合类的对象,动辄上百万的元素。对于这类超大集合,一般有两种优化方案,一是采取分片的方式,将每个集合分片控制在较小的范围内,比如小于1000个元素;二是起一个异步任务,对集合中的元素分批进行老化。

全集合扫描

比如在业务代码使用了keys*,hgetall,zrange(0, -1)等返回集合中所有元素,这些都属于阻塞操作,一般考虑用scan,hscan等迭代操作代替。

单个实例内存过大

内存过大有什么问题呢?上文中在讲到持久化的时候其实有说到,无论是生成RDB文件,还是AOF重写,都是要对整个实例的内存数据进行扫描,非常消耗CPU和磁盘资源;当使用Backgroud方式创建子进程时也会涉及到内存空间的拷贝,即便使用了COW机制,也会占用相当的内存开销。另外,在主从复制的第一阶段,save、传输和加载RDB文件的开销,也会随着RDB文件的变大而变大。当单个实例达到瓶颈时,更好的解决方案应该是采用集群方案。

大量key同时过期

redis删除过期键采用了惰性删除和定期删除相结合的策略,惰性删除则是在每次GET/SET操作时去删,定期删除,则是在时间事件中,从整个key空间随机取样,直到过期键比率小于25%,如果同时有大量key过期的话,极可能导致主线程阻塞。一般可以通过做散列来优化处理。

Redis 中的管道有什么用?

Redis 中的管道(Pipelining)是一种优化技术,允许客户端在一次网络往返中发送多个命令,而不是每个命令发送一次。这种方法减少了网络延迟,提高了吞吐量和性能。

管道的工作原理

在使用管道时,客户端会将一系列命令打包,然后一次性发送给 Redis 服务器。服务器执行这些命令后,将结果一次性返回给客户端。这样做的好处是减少了客户端和服务器之间的网络往返次数,从而提高了性能。

import redis

# 创建 Redis 连接
r = redis.Redis(host='localhost', port=6379, db=0)

# 创建管道对象
pipe = r.pipeline()

# 批量添加命令到管道
pipe.set('key1', 'value1')
pipe.set('key2', 'value2')
pipe.get('key1')
pipe.get('key2')

# 执行管道中的所有命令
results = pipe.execute()

# 打印结果
for result in results:
    print(result)

管道的优点

  1. 减少网络延迟:通过一次性发送多个命令,减少了客户端和服务器之间的网络往返次数,从而减少了网络延迟。
  2. 提高吞吐量:由于减少了每个命令的网络开销,服务器可以更快地处理更多的命令,从而提高了系统的吞吐量。
  3. 原子性操作:管道中的所有命令是按顺序执行的,但它们之间不是原子操作。如果需要原子性,可以使用事务(MULTI/EXEC)。

管道的限制

  1. 非原子性:管道中的命令不是原子操作,如果需要原子性,需要使用事务。
  2. 错误处理:管道执行中,如果某个命令出错,Redis 服务器不会立即返回错误,而是继续执行剩余的命令。客户端在接收到结果时,需要检查每个命令的执行结果。

高级用法

  1. 事务中的管道: 管道可以与事务一起使用,确保一组命令在执行过程中不被其他命令打断。

    pipe = r.pipeline(transaction=True)
  2. 批量操作: 对于需要批量操作的大量数据,管道非常适用。例如,批量插入数据或批量获取数据。

使用Redis统计网站的UV,应该怎么做?

使用 Redis 统计网站的 UV(Unique Visitors,独立访客)可以通过 HyperLogLog 或 Set 数据结构来实现。以下是两种方法的具体实现方式:

方法一:使用 HyperLogLog 统计 UV

HyperLogLog 是一种基于概率的数据结构,适用于大规模去重计数。它使用少量内存就能提供高准确率的去重计数。

实现步骤

  1. 记录访问: 每当有用户访问网站时,将用户的唯一标识(例如 IP 地址或用户 ID)添加到 HyperLogLog 中。
  2. 获取 UV: 使用 PFCOUNT 命令获取 HyperLogLog 的基数(即独立访客数)。

示例代码

import redis

# 创建 Redis 连接
r = redis.Redis(host='localhost', port=6379, db=0)

def record_visit(user_id):
    r.pfadd('site_uv', user_id)

def get_uv():
    return r.pfcount('site_uv')

# 示例:记录用户访问
record_visit('user_123')
record_visit('user_456')

# 获取 UV
print(f"Unique Visitors: {get_uv()}")

方法二:使用 Set 统计 UV

Set 是一种集合数据结构,适用于精确去重计数。虽然 Set 的内存占用比 HyperLogLog 大,但它能精确统计独立访客数。

实现步骤

  1. 记录访问: 每当有用户访问网站时,将用户的唯一标识添加到 Set 中。
  2. 获取 UV: 使用 SCARD 命令获取 Set 的基数。

示例代码

import redis

# 创建 Redis 连接
r = redis.Redis(host='localhost', port=6379, db=0)

def record_visit(user_id):
    r.sadd('site_uv_set', user_id)

def get_uv():
    return r.scard('site_uv_set')

# 示例:记录用户访问
record_visit('user_123')
record_visit('user_456')

# 获取 UV
print(f"Unique Visitors: {get_uv()}")

方法选择

  • HyperLogLog:适用于大量数据且对内存使用敏感的场景。它使用固定大小的内存(约 12 KB),但统计结果有一定误差(误差率约 0.81%)。
  • Set:适用于需要精确统计结果的场景。它能精确去重计数,但内存使用随着数据量增加而增加。

假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如 果将它们全部找出来?

使用 keys 指令可以扫出指定模式的 key 列表。

对方接着追问:如果这个 redis 正在给线上的业务提供服务,那使用 keys 指令会有什么问题?

这个时候你要回答 redis 关键的一个特性:redis 的单线程的。keys 指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用 scan 指 令,scan 指令可以无阻塞的提取出指定模式的 key 列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用 keys 指令长。

References

https://www.notion.so/1dd902102ef9801e94a5c0ceb6be2ba7?v=1dd902102ef980f6a532000c7908a988&pvs=4

X Tutup