Redis 基础

什么是 Redis?

RedisREmote DIctionary Server)是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。

为了满足不同的业务场景,Redis 内置了多种数据类型实现(比如 String、Hash、Sorted Set、Bitmap、HyperLogLog、GEO)。并且,Redis 还支持事务、持久化、Lua 脚本、发布订阅模型、多种开箱即用的集群方案(Redis Sentinel、Redis Cluster)。

⭐️Redis 为什么这么快?

Redis 内部做了非常多的性能优化,比较重要的有下面 4 点:

  1. 纯内存操作 (Memory-Based Storage) :这是最主要的原因。Redis 数据读写操作都发生在内存中,访问速度是纳秒级别,而传统数据库频繁读写磁盘的速度是毫秒级别,两者相差数个数量级。
  2. 高效的 I/O 模型 (I/O Multiplexing & Single-Threaded Event Loop) :Redis 使用单线程事件循环配合 I/O 多路复用技术,让单个线程可以同时处理多个网络连接上的 I/O 事件(如读写),避免了多线程模型中的上下文切换和锁竞争问题。虽然是单线程,但结合内存操作的高效性和 I/O 多路复用,使得 Redis 能轻松处理大量并发请求(Redis 线程模型会在后文中详细介绍到)。
  3. 优化的内部数据结构 (Optimized Data Structures) :Redis 提供多种数据类型(如 String, List, Hash, Set, Sorted Set 等),其内部实现采用高度优化的编码方式(如 ziplist, quicklist, skiplist, hashtable 等)。Redis 会根据数据大小和类型动态选择最合适的内部编码,以在性能和空间效率之间取得最佳平衡。
  4. 简洁高效的通信协议 (Simple Protocol - RESP) :Redis 使用的是自己设计的 RESP (REdis Serialization Protocol) 协议。这个协议实现简单、解析性能好,并且是二进制安全的。客户端和服务端之间通信的序列化/反序列化开销很小,有助于提升整体的交互速度。

除了 Redis,你还知道其他分布式缓存方案吗?

Memcached 是分布式缓存最开始兴起的那会,比较常用的。后来,随着 Redis 的发展,大家慢慢都转而使用更加强大的 Redis 了。

目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):

  • Dragonfly:一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。
  • KeyDB:Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。

说一下 Redis 和 Memcached 的区别和共同点

共同点

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略。
  3. 两者的性能都非常高。

区别

  1. 数据类型:Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list、set、zset、hash 等数据结构的存储;而 Memcached 只支持最简单的 k/v 数据类型。
  2. 数据持久化:Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用;而 Memcached 把数据全部存在内存之中。也就是说,Redis 有灾难恢复机制,而 Memcached 没有。
  3. 集群模式支持:Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;而 Redis 自 3.0 版本起是原生支持集群模式的。
  4. 线程模型:Memcached 是多线程、非阻塞 IO 复用的网络模型;而 Redis 使用单线程的多路 IO 复用模型(Redis 6.0 针对网络数据的读写引入了多线程)。
  5. 特性支持:Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  6. 过期数据删除:Memcached 过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

⭐️为什么要用 Redis?

1、访问速度更快

传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。

2、高并发

一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g),但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数;

由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。

3、功能全面

Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大!

⭐️为什么用 Redis 而不用本地缓存呢?

特性 本地缓存 Redis
数据一致性 多服务器部署时存在数据不一致问题 数据一致
内存限制 受限于单台服务器内存 独立部署,内存空间更大
数据丢失风险 服务器宕机数据丢失 可持久化,数据不易丢失
管理维护 分散,管理不便 集中管理,提供丰富的管理工具
功能丰富性 功能有限,通常只提供简单的键值对存储 功能丰富,支持多种数据结构和功能

什么是 Redis Module?有什么用?

Redis 从 4.0 版本开始,支持通过 Module 来扩展其功能以满足特殊的需求。这些 Module 以动态链接库(so 文件)的形式被加载到 Redis 中,这是一种非常灵活的动态扩展功能的实现方式,值得借鉴学习!

我们每个人都可以基于 Redis 去定制化开发自己的 Module,比如实现搜索引擎功能、自定义分布式锁和分布式限流。

目前,被 Redis 官方推荐的 Module 有:

  • RediSearch:用于实现搜索引擎的模块。
  • RedisJSON:用于处理 JSON 数据的模块。
  • RedisGraph:用于实现图形数据库的模块。
  • RedisTimeSeries:用于处理时间序列数据的模块。
  • RedisBloom:用于实现布隆过滤器的模块。
  • RedisAI:用于执行深度学习/机器学习模型并管理其数据的模块。
  • RedisCell:用于实现分布式限流的模块。

⭐️Redis 应用

Redis 除了做缓存,还能做什么?

  • 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。关于 Redis 实现分布式锁的详细介绍,可以看我写的这篇文章:分布式锁详解
  • 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的 RRateLimiter 来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。
  • 消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
  • 延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。
  • 分布式 Session:利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
  • 复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景,比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜、通过 HyperLogLog 统计网站 UV 和 PV。

本地锁和分布式锁的区别

对于单机多线程来说,在 Java 中,我们通常使用 ReentrantLock 类、synchronized 关键字这类 JDK 自带的 本地锁 来控制一个 JVM 进程内的多个线程对本地共享资源的访问。

下面是我对本地锁画的一张示意图。

本地锁

从图中可以看出,这些线程访问共享资源是互斥的,同一时刻只有一个线程可以获取到本地锁访问共享资源。

分布式系统下,不同的服务/客户端通常运行在独立的 JVM 进程上。如果多个 JVM 进程共享同一份资源的话,使用本地锁就没办法实现资源的互斥访问了。于是,分布式锁 就诞生了。

举个例子:系统的订单服务一共部署了 3 份,都对外提供服务。用户下订单之前需要检查库存,为了防止超卖,这里需要加锁以实现对检查库存操作的同步访问。由于订单服务位于不同的 JVM 进程中,本地锁在这种情况下就没办法正常工作了。我们需要用到分布式锁,这样的话,即使多个线程不在同一个 JVM 进程中也能获取到同一把锁,进而实现共享资源的互斥访问。

下面是我对分布式锁画的一张示意图。

分布式锁

分布式锁应该具备哪些条件?

一个最基本的分布式锁需要满足:

  • 互斥:任意一个时刻,锁只能被一个线程持有。
  • 高可用:锁服务是高可用的,当一个锁服务出现问题,能够自动切换到另外一个锁服务。并且,即使客户端的释放锁的代码逻辑出现问题,锁最终一定还是会被释放,不会影响其他线程对共享资源的访问。这一般是通过超时机制实现的。
  • 可重入:一个节点获取了锁之后,还可以再次获取锁。

除了上面这三个基本条件之外,一个好的分布式锁还需要满足下面这些条件:

  • 高性能:获取和释放锁的操作应该快速完成,并且不应该对整个系统的性能造成过大影响。
  • 非阻塞:如果获取不到锁,不能无限期等待,避免对系统正常运行造成影响。

分布式锁的常见实现方式有哪些?

常见分布式锁实现方案如下:

  • 基于关系型数据库比如 MySQL 实现分布式锁。
  • 基于分布式协调服务 ZooKeeper 实现分布式锁。
  • 基于分布式键值存储系统比如 Redis 、Etcd 实现分布式锁。

关系型数据库的方式一般是通过唯一索引或者排他锁实现。不过,一般不会使用这种方式,问题太多比如性能太差、不具备锁失效机制。

如何基于 Redis 实现一个最简易的分布式锁?

不论是本地锁还是分布式锁,核心都在于“互斥”。

在 Redis 中, SETNX 命令是可以帮助我们实现互斥。SETNXSET if Not eXists (对应 Java 中的 setIfAbsent 方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX 啥也不做

为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。

选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。

// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

Redis 实现简易分布式锁

Redis 可以做消息队列

Redis 2.0 之前,如果想要使用 Redis 来做消息队列的话,只能通过 List 来实现。

通过 RPUSH/LPOP 或者 LPUSH/RPOP 即可实现简易版消息队列:

# 生产者生产消息
RPUSH myList msg1 msg2
(integer) 2
RPUSH myList msg3
(integer) 3
# 消费者消费消息
LPOP myList
"msg1"

不过,通过 RPUSH/LPOP 或者 LPUSH/RPOP 这样的方式存在性能问题,我们需要不断轮询去调用 RPOPLPOP 来消费消息。当 List 为空时,大部分的轮询的请求都是无效请求,这种方式大量浪费了系统资源。

因此,Redis 还提供了 BLPOPBRPOP 这种阻塞式读取的命令(带 B-Blocking 的都是阻塞式),并且还支持一个超时参数。如果 List 为空,Redis 服务端不会立刻返回结果,它会等待 List 中有新数据后再返回或者是等待最多一个超时时间后返回空。如果将超时时间设置为 0 时,即可无限等待,直到弹出消息

List 实现消息队列功能太简单,像消息确认机制等功能还需要我们自己实现,最要命的是没有广播机制,消息也只能被消费一次。

Redis 2.0 引入了发布订阅 (pub/sub) 功能,解决了 List 实现消息队列没有广播机制的问题。

Redis 发布订阅 (pub/sub) 功能

pub/sub 中引入了一个概念叫 channel(频道),发布订阅机制的实现就是基于这个 channel 来做的。

pub/sub 涉及发布者(Publisher)和订阅者(Subscriber,也叫消费者)两个角色:

  • 发布者通过 PUBLISH 投递消息给指定 channel。
  • 订阅者通过SUBSCRIBE订阅它关心的 channel。并且,订阅者可以订阅一个或者多个 channel

pub/sub 既能单播又能广播,还支持 channel 的简单正则匹配。不过,消息丢失(客户端断开连接或者 Redis 宕机都会导致消息丢失)、消息堆积(发布者发布消息的时候不会管消费者的具体消费能力如何)等问题依然没有一个比较好的解决办法。

为此,Redis 5.0 新增加的一个数据结构 Stream 来做消息队列。Stream 支持:

  • 发布 / 订阅模式;
  • 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念);
  • 消息持久化( RDB 和 AOF);
  • ACK 机制(通过确认机制来告知已经成功处理了消息);
  • 阻塞式获取消息。

Stream 的结构如下:

为此,Redis 5.0 新增加的一个数据结构 Stream 来做消息队列。Stream 支持:

  • 发布 / 订阅模式;
  • 按照消费者组进行消费(借鉴了 Kafka 消费者组的概念);
  • 消息持久化( RDB 和 AOF);
  • ACK 机制(通过确认机制来告知已经成功处理了消息);
  • 阻塞式获取消息。

Stream 的结构如下:

这是一个有序的消息链表,每个消息都有一个唯一的 ID 和对应的内容。ID 是一个时间戳和序列号的组合,用来保证消息的唯一性和递增性。内容是一个或多个键值对(类似 Hash 基本数据类型),用来存储消息的数据。

这里再对图中涉及到的一些概念,进行简单解释:

  • Consumer Group:消费者组用于组织和管理多个消费者。消费者组本身不处理消息,而是再将消息分发给消费者,由消费者进行真正的消费。
  • last_delivered_id:标识消费者组当前消费位置的游标,消费者组中任意一个消费者读取了消息都会使 last_delivered_id 往前移动。
  • pending_ids:记录已经被客户端消费但没有 ack 的消息的 ID。

总的来说,Stream 已经可以满足一个消息队列的基本要求了。不过,Stream 在实际使用中依然会有一些小问题不太好解决,比如在 Redis 发生故障恢复后不能保证消息至少被消费一次。

综上,和专业的消息队列相比,使用 Redis 来实现消息队列还是有很多欠缺的地方,比如消息丢失和堆积问题不好解决。因此,我们通常建议不要使用 Redis 来做消息队列,你完全可以选择市面上比较成熟的一些消息队列,比如 RocketMQ、Kafka。不过,如果你就是想要用 Redis 来做消息队列的话,那我建议你优先考虑 Stream,这是目前相对最优的 Redis 消息队列实现。

Redis 可以做搜索引擎么?

Redis 是可以实现全文搜索引擎功能的,需要借助 RediSearch,这是一个基于 Redis 的搜索引擎模块。

RediSearch 支持中文分词、聚合统计、停用词、同义词、拼写检查、标签查询、向量相似度查询、多关键词搜索、分页搜索等功能,算是一个功能比较完善的全文搜索引擎了。

相比较于 Elasticsearch 来说,RediSearch 主要在下面两点上表现更优异一些:

  1. 性能更优秀:依赖 Redis 自身的高性能,基于内存操作(Elasticsearch 基于磁盘)。
  2. 较低内存占用实现快速索引:RediSearch 内部使用压缩的倒排索引,所以可以用较低的内存占用来实现索引的快速构建。

对于小型项目的简单搜索场景来说,使用 RediSearch 来作为搜索引擎还是没有问题的(搭配 RedisJSON 使用)。

对于比较复杂或者数据规模较大的搜索场景,还是不太建议使用 RediSearch 来作为搜索引擎,主要是因为下面这些限制和问题:

  1. 数据量限制:Elasticsearch 可以支持 PB 级别的数据量,可以轻松扩展到多个节点,利用分片机制提高可用性和性能。RedisSearch 是基于 Redis 实现的,其能存储的数据量受限于 Redis 的内存容量,不太适合存储大规模的数据(内存昂贵,扩展能力较差)。
  2. 分布式能力较差:Elasticsearch 是为分布式环境设计的,可以轻松扩展到多个节点。虽然 RedisSearch 支持分布式部署,但在实际应用中可能会面临一些挑战,如数据分片、节点间通信、数据一致性等问题。
  3. 聚合功能较弱:Elasticsearch 提供了丰富的聚合功能,而 RediSearch 的聚合功能相对较弱,只支持简单的聚合操作。
  4. 生态较差:Elasticsearch 可以轻松和常见的一些系统/软件集成比如 Hadoop、Spark、Kibana,而 RedisSearch 则不具备该优势。

Elasticsearch 适用于全文搜索、复杂查询、实时数据分析和聚合的场景,而 RediSearch 适用于快速数据存储、缓存和简单查询的场景。

如何基于 Redis 实现延时任务?

类似的问题:

  • 订单在 10 分钟后未支付就失效,如何用 Redis 实现?
  • 红包 24 小时未被查收自动退还,如何用 Redis 实现?

基于 Redis 实现延时任务的功能无非就下面两种方案:

  1. Redis 过期事件监听。
  2. Redisson 内置的延时队列。

Redis 过期事件监听存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。

Redisson 的延迟队列 RDelayedQueue 是基于 Redis 的 SortedSet 来实现的。SortedSet 是一个有序集合,其中的每个元素都可以设置一个分数,代表该元素的权重。Redisson 利用这一特性,将需要延迟执行的任务插入到 SortedSet 中,并给它们设置相应的过期时间作为分数。

Redisson 定期使用 zrangebyscore 命令扫描 SortedSet 中过期的元素,然后将这些过期元素从 SortedSet 中移除,并将它们加入到就绪消息列表中。就绪消息列表是一个阻塞队列,有消息进入就会被消费者监听到。这样做可以避免消费者对整个 SortedSet 进行轮询,提高了执行效率。

Redisson 内置的延时队列具备下面这些优势:

  1. 减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
  2. 消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。

Redis 过期事件完整实现原理

  1. Redis 有个功能:Key 过期时,会发布一个消息__keyevent@0__:expired

  2. 我们的服务监听这个消息

  3. 收到消息 → 知道某个 Key 过期了 → 执行延时任务

这就是 Redis 延时任务 的本质。

Redisson 延时队列 完整实现原理

1. 添加任务(offer)你往延时队列放一个任务:

delayedQueue.offer("任务A", 30, TimeUnit.SECONDS);

Redisson 做了什么?

  1. 计算 执行时间戳 = 当前时间 + 30 秒

  2. 往 Redis ZSet 里插入一条数据:
    value = 任务A
    score = 1735678000000(执行时间戳)
    

2. 后台线程轮询 ZSet

Redisson 客户端 启动一个后台线程,不断轮询:

取 score ≤ 当前时间 的第一条数据
如果有 → 说明到时间了 → 拿出来消费
如果没有 → 等待一段时间再查

3. 到时间 → 任务转入阻塞队列

任务到期后:

  1. Redisson 把任务从 ZSet 移除

  2. 放入 Redis List 阻塞队列

  3. 消费者从 List 里取任务

4. 消费者 take () 拿到任务

⭐️Redis 数据类型

关于 Redis 5 种基础数据类型和 3 种特殊数据类型

Redis 中比较常见的数据类型有下面这些:

  • 5 种基础数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
  • 3 种特殊数据类型:HyperLogLog(基数统计)、Bitmap (位图)、Geospatial (地理位置)。

除了上面提到的之外,还有一些其他的比如 Bloom filter(布隆过滤器)、Bitfield(位域)。

Redis 共有 5 种基本数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。

这 5 种数据类型是直接提供给用户使用的,是数据的保存形式,其底层实现主要依赖这 8 种数据结构:简单动态字符串(SDS)、LinkedList(双向链表)、Dict(哈希表/字典)、SkipList(跳跃表)、Intset(整数集合)、ZipList(压缩列表)、QuickList(快速列表)。

Redis 5 种基本数据类型对应的底层数据结构实现如下表所示:

String List Hash Set Zset
SDS LinkedList/ZipList/QuickList Dict、ZipList Dict、Intset ZipList、SkipList

Redis 3.2 之前,List 底层实现是 LinkedList 或者 ZipList。 Redis 3.2 之后,引入了 LinkedList 和 ZipList 的结合 QuickList,List 的底层实现变为 QuickList。从 Redis 7.0 开始, ZipList 被 ListPack 取代。

购物车信息用 String 还是 Hash 存储更好呢?

由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:

  • 用户 id 为 key
  • 商品 id 为 field,商品数量为 value

Hash维护简单的购物车信息

那用户购物车信息的维护具体应该怎么操作呢?

  • 用户添加商品就是往 Hash 里面增加新的 field 与 value;
  • 查询购物车信息就是遍历对应的 Hash;
  • 更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可);
  • 删除商品就是删除 Hash 中对应的 field;
  • 清空购物车直接删除对应的 key 即可。

这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的。

使用 Redis 实现一个排行榜怎么做?

Redis 中有一个叫做 Sorted Set(有序集合)的数据类型经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。

相关的一些 Redis 命令:ZRANGE(从小到大排序)、ZREVRANGE(从大到小排序)、ZREVRANK(指定元素排名)。

Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+ 树?

这道面试题很多大厂比较喜欢问,难度还是有点大的。

  • 平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。
  • 红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
  • B+ 树 vs 跳表:B+ 树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+ 树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+ 树那样插入时发现失衡时还需要对节点分裂与合并。

Set 的应用场景是什么?

Redis 中 Set 是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet

Set 的常见应用场景如下:

  • 存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是 HyperLogLog 更适合一些)、文章点赞、动态点赞等等。
  • 需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集)等等。
  • 需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。

使用 Set 实现抽奖系统怎么做?

如果想要使用 Set 实现一个简单的抽奖系统的话,直接使用下面这几个命令就可以了:

  • SADD key member1 member2 ...:向指定集合添加一个或多个元素。
  • SPOP key count:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。
  • SRANDMEMBER key count:随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。

使用 Bitmap 统计活跃用户怎么做?

Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap,只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。

你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。

img

如果想要使用 Bitmap 统计活跃用户的话,可以使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。

初始化数据:

> SETBIT 20210308 1 1
(integer) 0
> SETBIT 20210308 2 1
(integer) 0
> SETBIT 20210309 1 1
(integer) 0

统计 20210308~20210309 总活跃用户数:

> BITOP and desk1 20210308 20210309
(integer) 1
> BITCOUNT desk1
(integer) 1

统计 20210308~20210309 在线活跃用户数:

> BITOP or desk2 20210308 20210309
(integer) 1
> BITCOUNT desk2
(integer) 2

HyperLogLog 适合什么场景?

HyperLogLog (HLL) 是一种非常巧妙的概率性数据结构,它专门解决一类非常棘手的大数据问题:在海量数据中,用极小的内存,估算一个集合中不重复元素的数量,也就是我们常说的基数(Cardinality)

HLL 做的最核心的权衡,就是用一点点精确度的损失,来换取巨大的内存空间节省。它给出的不是一个 100%精确的数字,而是一个带有很小标准误差(Redis 中默认是 0.81%)的近似值。

基于这个核心权衡,HyperLogLog 最适合以下特征的场景:

  1. 数据量巨大,内存敏感: 这是 HLL 的主战场。比如,要统计一个亿级日活 App 的每日独立访客数。如果用传统的 Set 来存储用户 ID,一个 ID 占几十个字节,上亿个 ID 可能需要几个 GB 甚至几十 GB 的内存,这在很多场景下是不可接受的。而 HLL,在 Redis 中只需要固定的 12KB 内存,就能处理天文数字级别的基数,这是一个颠覆性的优势。
  2. 对结果的精确度要求不是 100%: 这是使用 HLL 的前提。比如,产品经理想知道一个热门帖子的 UV(独立访客数)是大约 1000 万还是 1010 万,这个细微的差别通常不影响商业决策。但如果场景是统计一个交易系统的准确交易笔数,那 HLL 就完全不适用,因为金融场景要求 100%的精确。

所以,HyperLogLog 具体的应用场景就非常清晰了:

  • 网站/App 的 UV(Unique Visitor)统计: 比如统计首页每天有多少个不同的 IP 或用户 ID 访问过。
  • 搜索引擎关键词统计: 统计每天有多少个不同的用户搜索了某个关键词。
  • 社交网络互动统计: 比如统计一条微博被多少个不同的用户转发过。

在这些场景下,我们关心的是数量级和趋势,而不是个位数的差异。

最后,Redis 的实现还非常智能,它内部会根据基数的大小,在稀疏矩阵(占用空间更小)和稠密矩阵(固定的 12KB)之间自动切换,进一步优化了内存使用。总而言之,当您需要对海量数据进行去重计数,并且可以接受微小误差时,HyperLogLog 就是不二之选。

使用 HyperLogLog 统计页面 UV 怎么做?

使用 HyperLogLog 统计页面 UV 主要需要用到下面这两个命令:

  • PFADD key element1 element2 ...:添加一个或多个元素到 HyperLogLog 中。
  • PFCOUNT key1 key2:获取一个或者多个 HyperLogLog 的唯一计数。

1、将访问指定页面的每个用户 ID 添加到 HyperLogLog 中。

PFADD PAGE_1:UV USER1 USER2 ...... USERn

2、统计指定页面的 UV。

PFCOUNT PAGE_1:UV

如果我想判断一个元素是否不在海量元素集合中,用什么数据类型?

如果我想判断一个元素是否不在海量元素集合中,用什么数据类型?

这是布隆过滤器的经典应用场景。布隆过滤器可以告诉你一个元素一定不存在或者可能存在,它也有极高的空间效率和一定的误判率,但绝不会漏报。也就是说,布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。

Bloom Filter 的简单原理图如下:

Bloom Filter 的简单原理示意图

当字符串存储要加入到布隆过滤器中时,该字符串首先由多个哈希函数生成不同的哈希值,然后将对应的位数组的下标设置为 1(当位数组初始化时,所有位置均为 0)。当第二次存储相同字符串时,因为先前的对应位置已设置为 1,所以很容易知道此值已经存在(去重非常方便)。

如果我们需要判断某个字符串是否在布隆过滤器中时,只需要对给定字符串再次进行相同的哈希计算,得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。

Redis持久化机制详解

使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。

Redis 支持持久化,而且支持 3 种持久化方式:

  • 快照(snapshotting,RDB)
  • 只追加文件(append-only file, AOF)
  • RDB 和 AOF 的混合持久化(Redis 4.0 新增)
版本 持久化默认方式 重要特性
Redis 4.0 RDB 引入 RDB+AOF 混合持久化
Redis 6.0 RDB AOF 仍需手动开启
Redis 7.0 RDB 引入 Multi-Part AOF
Redis 7.2+ RDB 进一步优化持久化性能

关键行为差异

  • AOF rewrite 内存占用:Redis 7.0 之前重写期间增量数据需在内存中保留,7.0+ 使用 Multi-Part AOF 解决
  • 混合持久化:Redis 4.0-6.x 需手动开启,Redis 7.0+ 默认启用。

下面这张图展示了 Redis 持久化机制的完整流程,包含了本文的核心内容:

Redis 持久化机制完整流程

RDB 持久化

什么是 RDB 持久化?

Redis 可以通过创建快照来获得存储在内存里面的数据在 某个时间点 上的副本。Redis 创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。

快照持久化是 Redis 默认采用的持久化方式,在 redis.conf 配置文件中默认有此下配置:

RDB 创建快照时会阻塞主线程吗?

Redis 提供了两个命令来生成 RDB 快照文件:

  • save : 同步保存操作,会阻塞 Redis 主线程;
  • bgsave : fork 出一个子进程,子进程执行。

这里说 Redis 主线程而不是主进程的主要是因为 Redis 启动之后主要是通过单线程的方式完成主要的工作。如果你想将其描述为 Redis 主进程,也没毛病。

Copy-on-Write (COW) 机制
  • fork 后,子进程共享父进程的内存页(标准页 4KB)
  • 当父进程或子进程修改内存页时,内核复制该页(Copy-on-Write)
  • 大数据集 + 高写负载时,会导致大量页面复制,影响性能
THP(透明大页)导致的内存雪崩问题

Linux 发行版默认开启 THP(Transparent Huge Pages,透明大页),大小为 2MB。THP 会增加大页被 COW 的概率,最坏情况下,如果内存被合并为 2MB 大页,即使客户端仅修改 10 字节的数据,内核也会复制完整的 2MB 内存页,导致 COW 的内存开销放大 512 倍(2MB / 4KB = 512)。

实际行为:内核不会强制所有内存都使用 2MB 大页,而是根据情况动态决定是否合并。只有在 THP 成功合并为大页后,修改才会触发 2MB 的 COW。但在高并发写入场景下,这仍会显著增加内存消耗,可能瞬间吸干宿主机内存,触发 OOM Killer 强杀 Redis 进程

验证方式

cat /sys/kernel/mm/transparent_hugepage/enabled
# 输出 [always] madvise never 表示已开启(危险!)
# 应该输出 always madvise [never]

解决方案:在 Redis 启动脚本中添加 echo never > /sys/kernel/mm/transparent_hugepage/enabled,或使用 redis-server --disable-thp yes(Redis 6.0+ 支持)。

启动警告:Redis 检测到 THP 开启时会在启动日志中打印 WARNING you have Transparent Huge Pages (THP) support enabled in your kernel,必须立即处理。

生产环境建议
# 1. 监控 fork 风险指标
redis-cli INFO memory | grep -E "(used_memory|used_memory_rss)"

# 输出示例:
# used_memory:1073741824
# used_memory_rss:1226833920
# used_memory_rss_human:1.14G

# 计算 RSS/USED 比值,fork 时应 < 2
# 如果接近或超过 2,说明 fork 风险高

# 2. 设置 maxmemory 限制 Redis 内存占用,为 fork 预留空间
# 在 redis.conf 中设置:
# maxmemory 8gb
# maxmemory-policy allkeys-lru

# 3. 避免在高峰期手动触发 BGSAVE
# 让 Redis 根据配置规则自动触发

# 4. 考虑主从复制 + 从节点持久化架构
# 将持久化操作转移到从节点,避免主节点 fork 开销

监控告警

  • rdb_last_bgsave_time_sec:上次 bgsave 耗时,应 < 5s
  • rdb_last_cow_size:上次 fork 的 COW 内存大小,应 < 10% used_memory

AOF 持久化

什么是 AOF 持久化?

与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:

版本说明:Redis 默认使用 RDB 持久化方式。若需使用 AOF,需要手动设置 appendonly yes。Redis 7.0 引入了 Multi-Part AOF 机制优化 AOF 性能,但并未改变默认持久化方式。

appendonly yes

开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf 中,然后再写入到 AOF 文件中(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式( fsync策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。

只有同步到磁盘中才算持久化保存了,否则依然存在数据丢失的风险,比如说:系统内核缓存区的数据还未同步,磁盘机器就宕机了,那这部分数据就算丢失了。

AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof

AOF 工作基本流程是怎样的?

AOF 持久化功能的实现可以简单分为 5 步:

  1. 命令追加(append):所有的写命令会追加到 AOF 缓冲区中。
  2. 文件写入(write):将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用write函数(系统调用),write将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。
  3. 文件同步(fsync):这一步才是持久化的核心!根据你在 redis.conf 文件里 appendfsync 配置的策略,Redis 会在不同的时机,调用 fsync 函数(系统调用)。fsync 针对单个文件操作,对其进行强制硬盘同步(文件在内核缓冲区里的数据写到硬盘),fsync 将阻塞直到写入磁盘完成后返回,保证了数据持久化。
  4. 文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
  5. 重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。

Linux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为 系统调用(syscall)

这里对上面提到的一些 Linux 系统调用再做一遍解释:

  • write:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。同步硬盘操作取决于 Linux 内核的脏页回写策略(Dirty Page Writeback),主要受以下参数影响:
    • /proc/sys/vm/dirty_expire_centisecs:脏页过期时间(默认 30 秒)
    • /proc/sys/vm/dirty_writeback_centisecs:内核回写线程的唤醒间隔(默认 5 秒)
    • 系统内存压力:内存不足时会更积极触发同步
  • 这意味着 appendfsync no 模式下宕机时,可能丢失的数据量是不可控且不可预测的,取决于上次内核同步的时间点。
  • fsyncfsync用于强制刷新系统内核缓冲区(同步到磁盘),确保写磁盘操作结束才会返回。

AOF 工作流程图如下:

AOF 工作基本流程

    AOF 持久化方式有哪些?

    在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync策略),它们分别是:

    1. appendfsync always:主线程调用 write 执行写操作后,会立刻调用 fsync 函数同步 AOF 文件(刷盘)。主线程会阻塞,直到 fsync 将数据完全刷到磁盘后才会返回。这种方式数据最安全,理论上不会有任何数据丢失。但因为每个写操作都会同步阻塞主线程,所以性能极差。
    2. appendfsync everysec:主线程调用 write 执行写操作后立即返回,由后台线程( aof_fsync 线程)每秒钟调用 fsync 函数(系统调用)同步一次 AOF 文件(write+fsyncfsync间隔为 1 秒)。这种方式主线程的性能基本不受影响。在性能和数据安全之间做出了绝佳的平衡。不过,在 Redis 异常宕机时,通常可能丢失最近 1 秒内的数据。
    3. appendfsync no:主线程调用 write 执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write但不fsyncfsync 的时机由操作系统决定)。 这种方式性能最好,因为避免了 fsync 的阻塞。但数据安全性最差,宕机时丢失的数据量不可控,取决于操作系统上一次同步的时间点。

    可以看出:这 3 种持久化方式的主要区别在于 fsync 同步 AOF 文件的时机(刷盘)

    为了兼顾数据和写入性能,可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能受到的影响较小。通常情况下,即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。当硬盘忙于执行写入操作的时候,Redis 还会优雅的放慢自己的速度以便适应硬盘的最大写入速度。

    从 Redis 7.0.0 开始,Redis 使用了 Multi Part AOF 机制。顾名思义,Multi Part AOF 就是将原来的单个 AOF 文件拆分成多个 AOF 文件。在 Multi Part AOF 中,AOF 文件被分为三种类型,分别为:

    • BASE:表示基础 AOF 文件,它一般由子进程通过重写产生,该文件最多只有一个。
    • INCR:表示增量 AOF 文件,它一般会在 AOFRW 开始执行时被创建,该文件可能存在多个。
    • HISTORY:表示历史 AOF 文件,它由 BASE 和 INCR AOF 变化而来,每次 AOFRW 成功完成时,本次 AOFRW 之前对应的 BASE 和 INCR AOF 都将变为 HISTORY,HISTORY 类型的 AOF 会被 Redis 自动删除。

    AOF 为什么是在执行完命令之后记录日志?

    关系型数据库(如 MySQL)通常都是执行命令之前记录日志(方便故障恢复),而 Redis AOF 持久化机制是在执行完命令之后再记录日志。

    AOF 记录日志过程

    为什么是在执行完命令之后记录日志呢?

    • 避免额外的检查开销,AOF 记录日志不会对命令进行语法检查;
    • 在命令执行完之后再记录,不会阻塞当前的命令执行。

    这样也带来了风险(我在前面介绍 AOF 持久化的时候也提到过):

    • 如果刚执行完命令 Redis 就宕机会导致对应的修改丢失;
    • 可能会阻塞后续其他命令的执行(AOF 记录日志是在 Redis 主线程中进行的)。

    AOF 重写了解吗?

    当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。

    AOF 重写

    AOF 重写(rewrite) 是一个有歧义的名字,该功能是通过读取数据库中的键值对来实现的,程序无须对现有 AOF 文件进行任何读入、分析或者写入操作。

    由于 AOF 重写会进行大量的写入操作,为了避免对 Redis 正常处理命令请求造成影响,Redis 将 AOF 重写程序放到子进程里执行。

    AOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。

    开启 AOF 重写功能,可以调用 BGREWRITEAOF 命令手动执行,也可以设置下面两个配置项,让程序自动决定触发时机:

    • auto-aof-rewrite-min-size:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB;
    • auto-aof-rewrite-percentage:执行 AOF 重写时,当前 AOF 大小(aof_current_size)和上一次重写时 AOF 大小(aof_base_size)的比值。如果当前 AOF 文件大小增加了这个百分比值,将触发 AOF 重写。将此值设置为 0 将禁用自动 AOF 重写。默认值为 100。

    AOF rewrite 的失败边界与风险场景

    虽然 AOF rewrite 放在子进程执行,但仍存在以下风险需要了解:

    风险场景 影响 触发条件 应对措施
    fork 失败 无法创建 rewrite 子进程 内存不足、系统限制 监控内存使用率,设置 maxmemory
    磁盘满 新 AOF 文件写入失败 rewrite 期间数据量增长快 监控磁盘使用率(df -h),设置告警阈值 70%
    inode 耗尽 无法创建新文件 小文件过多的系统 监控 inode 使用率(df -i),清理临时文件
    时间戳回拨 Multi-Part AOF 文件管理混乱 虚拟机时钟同步问题 配置 NTP 服务,设置 aof-timestamp-enabled
    SIGTERM 信号 rewrite 被中断 运维人员手动重启 配置优雅关闭(shutdown-timeout

    AOF 文件如何验证数据完整性?

    核心结论:纯 AOF 文件没有校验和机制,仅通过逐条命令解析验证;CRC64 校验和仅存在于混合持久化文件的 RDB 部分

    纯 AOF 模式:无校验和,仅语法解析

    纯 AOF 文件不会对整体或单条命令计算 CRC64 校验和,而是通过逐条解析文件中的命令来验证有效性。

    为什么没有校验和?

    AOF 是高频追加写入的文本日志。如果每次追加命令都要重新计算整个文件的 CRC64 校验和,会对主线程的 CPU 和磁盘 I/O 造成严重拖累。因此 Redis 选择了更轻量的方式:重启加载时逐条读取并解析命令语法。

    如果解析过程中发现语法错误(如命令不完整、格式错误),Redis 会终止加载并报错。

    混合持久化模式:分段校验策略

    在 混合持久化模式(Redis 4.0 引入)下,AOF 文件采用"分段治理"的校验策略:

    ┌─────────────────────────────────────────────────────────┐
    │                    混合持久化文件结构                    │
    ├─────────────────────────────────────────────────────────┤
    │  RDB 快照部分(二进制)   ← CRC64 校验和保护这部分        │
    │  ├── "REDIS" 头部                                       │
    │  ├── 数据库编号、键值对...                               │
    │  ├── EOF 标志                                           │
    │  └── CRC64 校验和(8 字节)  ← 校验边界在这里            │
    ├─────────────────────────────────────────────────────────┤
    │  AOF 增量部分(文本)     ← 无校验和,仅语法解析          │
    │  ├── *3\r\n$3\r\nSET\r\n...                             │
    │  └── ...                                                │
    └─────────────────────────────────────────────────────────┘
    • RDB 快照部分:以固定的 REDIS 字符开头,存储某一时刻的内存数据快照,并在快照数据末尾附带一个 CRC64 校验和。这个校验和严格卡在 RDB 数据块的末尾,仅保障这部分二进制快照的完整性。
    • AOF 增量部分:紧随 RDB 快照之后,记录增量写命令。这部分依然没有校验和,采用与纯 AOF 相同的逐条语法解析验证。

    加载时的校验流程

    1. Redis 首先校验 RDB 快照部分:计算该部分数据的 CRC64 校验和,与存储的校验和值比较。如果不匹配,Redis 拒绝启动。
    2. RDB 部分校验通过后,逐条解析 AOF 增量命令。解析出错则停止加载后续命令(但此时 RDB 快照数据已成功加载)。
    配置项说明
    配置项 作用域 说明
    rdbchecksum RDB 文件、混合持久化的 RDB 部分 控制是否计算 CRC64 校验和,对纯 AOF 增量部分不生效
    aof-load-truncated 纯 AOF 文件、混合持久化的 AOF 增量部分 控制尾部截断时是否自动丢弃并继续启动

    新版本优化

    Redis 4.0 对于持久化机制做了什么优化?

    由于 RDB 和 AOF 各有优势,于是,Redis 4.0 开始支持 RDB 和 AOF 的混合持久化。

    配置说明
    # 开启 AOF
    appendonly yes
    
    # 开启混合持久化(Redis 7.0+ 默认启用)
    aof-use-rdb-preamble yes
    
    # 优化重写触发条件
    auto-aof-rewrite-percentage 100   # AOF 文件大小比上次重写后增长 100% 时触发
    auto-aof-rewrite-min-size 64mb    # AOF 文件至少达到 64MB 才触发重写
    工作原理

    如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。

    混合持久化文件结构

    • ┌───────────────────┐
      │   RDB Header      │ ← 二进制快照(压缩格式)
      │   REDIS0009       │
      │   ...             │
      ├───────────────────┤
      │   AOF Log Entries │ ← 文本格式命令
      │   *3\r\n$3\r\nSET\r\n$5\r\nkey01\r\n...
      │   INCR counter    │
      │   ...             │
      └───────────────────┘

    核心工作流程

    1. 写处理阶段

      • 客户端执行写命令(SET/INCR 等)
      • Redis 立即更新内存数据
      • 将命令追加到 AOF 缓冲区(文本格式)
    2. 持久化触发阶段

      • AOF 文件大小达到阈值(默认 64MB)或增长 100%
      • 触发 AOF 重写(BGREWRITEAOF
    3. 文件构建阶段

      • 子进程将当前内存数据以 RDB 格式写入新 AOF 文件开头
      • 父进程继续处理写命令,增量数据记录到重写缓冲区
      • 重写完成后,将重写缓冲区的增量命令追加到新 AOF 文件末尾
    4. 数据恢复阶段

      • Redis 启动时优先加载 RDB 部分(快速恢复基础数据)
      • 然后顺序重放 AOF 增量命令(恢复最新数据)
    优势对比
    指标 纯 RDB 纯 AOF 混合持久化
    恢复速度 快(秒级) 慢(分钟级) 快(秒级)
    数据丢失窗口 分钟级 ≤2 秒 ≤2 秒
    文件大小 小(压缩) 大(文本日志) 中等
    写入影响 中等
    可读性 差(二进制) 好(文本) 差(RDB 部分)

    基准数据(1GB 数据集,SSD):

    • 纯 AOF 恢复:30-60 秒
    • 混合持久化恢复:2-5 秒(快 5-10 倍

    混合持久化缺点

    • AOF 文件里面的 RDB 部分是压缩格式,不再是 AOF 格式,可读性较差。
    • 需要额外消耗 CPU 进行 RDB 压缩和解压。

    Redis 7.0 对于持久化机制做了什么优化?

    由于 AOF 重写过程中存在内存缓冲增量数据和磁盘双写的问题,于是,Redis 7.0 开始支持 Multi-Part AOF(默认启用,可以通过配置项 appenddirname 指定目录)。

    如果把 Multi-Part AOF 启用,AOF 文件将被拆分为 base 文件(最多一个,初始全量快照,可为 RDB 或 AOF 格式)和多个 incr 文件(增量命令日志),重写期间新增命令直接写入新的 incr 文件,由 manifest 文件跟踪所有部分。这样做的好处是可以消除重写时的内存缓冲开销和双重 I/O 写入,提高性能并减少潜在的 fsync 阻塞。由于文件结构分离,INCR 文件在重写前保持只读,单文件拷贝相对安全;但跨文件的一致性备份仍需暂停重写,整体备份流程比单文件 AOF 更复杂,且在极大数据集下仍可能需监控资源。

    如何选择 RDB 和 AOF?

    关于 RDB 和 AOF 的优缺点,官网上面也给了比较详细的说明Redis persistence,这里结合自己的理解简单总结一下。

    RDB 比 AOF 优秀的地方

    • 文件紧凑,适合备份和灾难恢复:RDB 文件存储的内容是经过压缩的二进制数据,保存着某个时间点的数据集,文件很小,非常适合做数据的备份和灾难恢复。AOF 文件存储的是每一次写命令,类似于 MySQL 的 binlog 日志,通常会比 RDB 文件大很多。当 AOF 变得太大时,Redis 能够在后台自动重写 AOF,新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。不过,Redis 7.0 版本之前,如果在重写期间有写入命令,AOF 可能会使用大量内存,重写期间到达的所有写入命令都会写入磁盘两次。
    • 恢复速度快:使用 RDB 文件恢复数据,直接解析还原数据即可,不需要一条一条地执行命令,速度非常快。而 AOF 则需要依次执行每个写命令,速度非常慢。也就是说,与 AOF 相比,恢复大数据集的时候,RDB 速度更快。
    • 主从复制优势:在副本(replica)上,RDB 支持重启和故障转移后的部分重新同步(Partial Resynchronization)。副本可以使用 RDB 快照快速同步到主节点的某个时间点状态,而不需要全量同步。
    • 性能开销小:RDB 最大化 Redis 性能,因为 Redis 父进程需要做的唯一持久化工作就是 fork 子进程,子进程将完成所有其余工作。父进程永远不会执行磁盘 I/O 或类似操作。

    AOF 比 RDB 优秀的地方

    • 数据安全性更高,支持秒级持久化:RDB 的数据安全性不如 AOF,没有办法实时或者秒级持久化数据。生成 RDB 文件的过程是比较繁重的,虽然 BGSAVE 子进程写入 RDB 文件的工作不会阻塞主线程,但会对机器的 CPU 资源和内存资源产生影响,严重的情况下甚至会直接把 Redis 服务干宕机。AOF 支持秒级数据丢失(取决于 fsync 策略,如果是 everysec,通常最多丢失 1 秒的数据;但磁盘 I/O 繁忙时可能丢失 2 秒且主线程会阻塞),仅仅是追加命令到 AOF 文件,操作轻量。
    • 版本兼容性好:RDB 文件是以特定的二进制格式保存的,并且在 Redis 版本演进中有多个版本的 RDB,所以存在老版本的 Redis 服务不兼容新版本的 RDB 格式的问题。
    • 可读性和可操作性强:AOF 以一种易于理解和解析的格式包含所有操作的日志。你可以轻松地导出 AOF 文件进行分析,也可以直接操作 AOF 文件来解决一些问题。比如,如果执行FLUSHALL命令意外地刷新了所有内容后,只要 AOF 文件没有被重写,删除最新命令并重启即可恢复之前的状态。
    • 追加日志无损坏风险:AOF 日志是追加日志,没有寻道,也没有断电损坏问题。即使日志由于某种原因(磁盘已满或其他原因)以半写入命令结尾,redis-check-aof 工具也能轻松修复。 

    ⭐️Redis 线程模型(重要)

    对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作,Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)。

    既然是单线程,那怎么监听大量的客户端连接呢?

    Redis 通过 IO 多路复用程序 来监听来自客户端的大量连接(或者说是监听多个 socket),它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。

    这样的好处非常明显:I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。

    文件事件处理器(file event handler)主要是包含 4 个部分:

    • 多个 socket(客户端连接)
    • IO 多路复用程序(支持多个客户端连接的关键)
    • 文件事件分派器(将 socket 关联到相应的事件处理器)
    • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

    文件事件处理器(file event handler)

    Redis6.0 之前为什么不使用多线程?

    虽然说 Redis 是单线程模型,但实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。

    不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”,从而减少对主线程的影响。

    为此,Redis 4.0 之后新增了几个异步命令:

    • UNLINK:可以看作是 DEL 命令的异步版本。
    • FLUSHALL ASYNC:用于清空所有数据库的所有键,不限于当前 SELECT 的数据库。
    • FLUSHDB ASYNC:用于清空当前 SELECT 数据库中的所有键。

    总的来说,直到 Redis 6.0 之前,Redis 的主要操作仍然是单线程处理的。

    那 Redis6.0 之前为什么不使用多线程? 我觉得主要原因有 3 点:

    • 单线程编程容易并且更容易维护;
    • Redis 的性能瓶颈不在 CPU,主要在内存和网络;
    • 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

    Redis6.0 之后为何引入了多线程?

    Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。

    虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。

    Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置 IO 线程数 > 1,需要修改 redis 配置文件 redis.conf

    io-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

    另外:

    • io-threads 的个数一旦设置,不能通过 config 动态设置。
    • 当设置 ssl 后,io-threads 将不工作。

    开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端,如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 redis.conf

    Redis 后台线程了解吗?

    我们虽然经常说 Redis 是单线程模型(主要逻辑是单线程完成的),但实际还有一些后台线程用于执行一些比较耗时的操作:

    • 通过 bio_close_file 后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。
    • 通过 bio_aof_fsync 后台线程调用 fsync 函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘(AOF 文件)。
    • 通过 bio_lazy_free 后台线程释放大对象(已删除)占用的内存空间.

    ⭐️Redis 内存管理

    Redis 给缓存数据设置过期时间有什么用?

    一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?

    内存是有限且珍贵的,如果不对缓存数据设置过期时间,那内存占用就会一直增长,最终可能会导致 OOM 问题。通过设置合理的过期时间,Redis 会自动删除暂时不需要的数据,为新的缓存数据腾出空间。

    Redis 自带了给缓存数据设置过期时间的功能,比如:

    127.0.0.1:6379> expire key 60 # 数据在 60s 后过期
    (integer) 1
    127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
    OK
    127.0.0.1:6379> ttl key # 查看数据还有多久过期
    (integer) 56

    注意 ⚠️:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外,persist 命令可以移除一个键的过期时间。

    过期时间除了有助于缓解内存的消耗,还有什么其他用么?

    很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 Token 可能只在 1 天内有效。

    如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。

    Redis 是如何判断数据是否过期的呢?

    Redis 通过一个叫做过期字典(可以看作是 hash 表)来保存数据过期的时间。过期字典的键指向 Redis 数据库中的某个 key(键),过期字典的值是一个 long long 类型的整数,这个整数保存了 key 所指向的数据库键的过期时间(毫秒精度的 UNIX 时间戳)。

    Redis 过期字典

    过期字典是存储在 redisDb 这个结构里的:

    typedef struct redisDb {
        ...
    
        dict *dict;     //数据库键空间,保存着数据库中所有键值对
        dict *expires   // 过期字典,保存着键的过期时间
        ...
    } redisDb;

    在查询一个 key 的时候,Redis 首先检查该 key 是否存在于过期字典中(时间复杂度为 O(1)),如果不在就直接返回,在的话需要判断一下这个 key 是否过期,过期直接删除 key 然后返回 null。

    Redis 过期 key 删除策略了解么?

    如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?

    常用的过期数据的删除策略就下面这几种:

    1. 惰性删除:只会在取出/查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。
    2. 定期删除:周期性地随机从设置了过期时间的 key 中抽查一批,然后逐个检查这些 key 是否过期,过期就删除 key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。
    3. 延迟队列:把设置过期时间的 key 放到一个延迟队列里,到期之后就删除 key。这种方式可以保证每个过期 key 都能被删除,但维护延迟队列太麻烦,队列本身也要占用资源。
    4. 定时删除:每个设置了过期时间的 key 都会在设置的时间到达时立即被删除。这种方法可以确保内存中不会有过期的键,但是它对 CPU 的压力最大,因为它需要为每个键都设置一个定时器。

    Redis 采用的是那种删除策略呢?

    Redis 采用的是 定期删除+惰性/懒汉式删除 结合的策略,这也是大部分缓存框架的选择。定期删除对内存更加友好,惰性删除对 CPU 更加友好。两者各有千秋,结合起来使用既能兼顾 CPU 友好,又能兼顾内存友好。

    Redis 的定期删除过程是随机的(周期性地随机从设置了过期时间的 key 中抽查一批),所以并不保证所有过期键都会被立即删除。这也就解释了为什么有的 key 过期了,并没有被删除。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

    另外,定期删除还会受到执行时间和过期 key 的比例的影响:

    • 执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。
    • 如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。

    每次随机抽查数量是多少?

    expire.c 中定义了每次随机抽查的数量,Redis 7.2 版本为 20,也就是说每次会随机选择 20 个设置了过期时间的 key 判断是否过期。

    如何控制定期删除的执行频率?

    在 Redis 中,定期删除的频率是由 hz 参数控制的。hz 默认为 10,代表每秒执行 10 次,也就是每秒钟进行 10 次尝试来查找并删除过期的 key。

    hz 的取值范围为 1~500。增大 hz 参数的值会提升定期删除的频率。如果你想要更频繁地执行定期删除任务,可以适当增加 hz 的值,但这会增加 CPU 的使用率。根据 Redis 官方建议,hz 的值不建议超过 100,对于大部分用户使用默认的 10 就足够了。

    类似的参数还有一个 dynamic-hz,这个参数开启之后 Redis 就会在 hz 的基础上动态计算一个值。Redis 提供并默认启用了使用自适应 hz 值的能力,

    这两个参数都在 Redis 配置文件 redis.conf 中:

    # 默认为 10
    hz 10
    # 默认开启
    dynamic-hz yes

    多提一嘴,除了定期删除过期 key 这个定期任务之外,还有一些其他定期任务例如关闭超时的客户端连接、更新统计信息,这些定期任务的执行频率也是通过 hz 参数决定。

    为什么定期删除不是把所有过期 key 都删除呢?

    这样会对性能造成太大的影响。如果我们 key 数量非常庞大的话,挨个遍历检查是非常耗时的,会严重影响性能。Redis 设计这种策略的目的是为了平衡内存和性能。

    为什么 key 过期之后不立马把它删掉呢?这样不是会浪费很多内存空间吗?

    因为不太好办到,或者说这种删除方式的成本太高了。假如我们使用延迟队列作为删除策略,这样存在下面这些问题:

    1. 队列本身的开销可能很大:key 多的情况下,一个延迟队列可能无法容纳。
    2. 维护延迟队列太麻烦:修改 key 的过期时间就需要调整其在延迟队列中的位置,并且还需要引入并发控制。

    大量 key 集中过期怎么办?

    当 Redis 中存在大量 key 在同一时间点集中过期时,可能会导致以下问题:

    • 请求延迟增加:Redis 在处理过期 key 时需要消耗 CPU 资源,如果过期 key 数量庞大,会导致 Redis 实例的 CPU 占用率升高,进而影响其他请求的处理速度,造成延迟增加。
    • 内存占用过高:过期的 key 虽然已经失效,但在 Redis 真正删除它们之前,仍然会占用内存空间。如果过期 key 没有及时清理,可能会导致内存占用过高,甚至引发内存溢出。

    为了避免这些问题,可以采取以下方案:

    1. 尽量避免 key 集中过期:在设置键的过期时间时尽量随机一点。
    2. 开启 lazy free 机制:修改 redis.conf 配置文件,将 lazyfree-lazy-expire 参数设置为 yes,即可开启 lazy free 机制。开启 lazy free 机制后,Redis 会在后台异步删除过期的 key,不会阻塞主线程的运行,从而降低对 Redis 性能的影响。

    Redis 内存淘汰策略了解么?

    相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?

    Redis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发,这个阈值是通过 redis.confmaxmemory 参数来定义的。64 位操作系统下,maxmemory 默认为 0,表示不限制内存大小。32 位操作系统下,默认的最大内存值是 3GB。

    你可以使用命令 config get maxmemory 来查看 maxmemory 的值。

    > config get maxmemory
    maxmemory
    0

    Redis 提供了 6 种内存淘汰策略:

    1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。
    2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。
    3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。
    4. allkeys-lru(least recently used):从数据集(server.db[i].dict)中移除最近最少使用的数据淘汰。
    5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰。
    6. no-eviction(默认内存淘汰策略):禁止驱逐数据,当内存不足以容纳新写入数据时,新写入操作会报错。
    1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰。
    2. allkeys-lfu(least frequently used):从数据集(server.db[i].dict)中移除最不经常使用的数据淘汰。

    allkeys-xxx 表示从所有的键值中淘汰数据,而 volatile-xxx 表示从设置了过期时间的键值中淘汰数据。

    Redis 事务

    什么是 Redis 事务?

    你可以将 Redis 中的事务理解为:Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。

    Redis 事务实际开发中使用的非常少,功能比较鸡肋,不要将其和我们平时理解的关系型数据库的事务混淆了。

    除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了,这种操作实在是看不懂。

    因此,Redis 事务是不建议在日常开发中使用的。

    Redis 可以通过 MULTIEXECDISCARDWATCH 等命令来实现事务(Transaction)功能。

    > MULTI
    OK
    > SET PROJECT "JavaGuide"
    QUEUED
    > GET PROJECT
    QUEUED
    > EXEC
    1) OK
    2) "JavaGuide"

    MULTI 命令后可以输入多个命令,Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令后,再执行所有的命令。

    这个过程是这样的:

    1. 开始事务(MULTI);
    2. 命令入队(批量操作 Redis 的命令,先进先出(FIFO)的顺序执行);
    3. 执行事务(EXEC)。

    你也可以通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。

    > MULTI
    OK
    > SET PROJECT "JavaGuide"
    QUEUED
    > GET PROJECT
    QUEUED
    > DISCARD
    OK

    你可以通过WATCH 命令监听指定的 Key,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的 Key 被 其他客户端/Session 修改的话,整个事务都不会被执行。

    # 客户端 1
    > SET PROJECT "RustGuide"
    OK
    > WATCH PROJECT
    OK
    > MULTI
    OK
    > SET PROJECT "JavaGuide"
    QUEUED
    
    # 客户端 2
    # 在客户端 1 执行 EXEC 命令提交事务之前修改 PROJECT 的值
    > SET PROJECT "GoGuide"
    
    # 客户端 1
    # 修改失败,因为 PROJECT 的值被客户端2修改了
    > EXEC
    (nil)
    > GET PROJECT
    "GoGuide"

    Redis 事务支持原子性吗?

    Redis 的事务和我们平时理解的关系型数据库的事务不同。我们知道事务具有四大特性:1. 原子性2. 隔离性3. 持久性4. 一致性

    1. 原子性(Atomicity):事务是最小的执行单位,不允许分割。事务的原子性确保动作要么全部完成,要么完全不起作用;
    2. 隔离性(Isolation):并发访问数据库时,一个用户的事务不被其他事务所干扰,各并发事务之间数据库是独立的;
    3. 持久性(Durability):一个事务被提交之后。它对数据库中数据的改变是持久的,即使数据库发生故障也不应该对其有任何影响;
    4. 一致性(Consistency):执行事务前后,数据保持一致,多个事务对同一个数据读取的结果是相同的。

    Redis 事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。并且,Redis 事务是不支持回滚(roll back)操作的。因此,Redis 事务其实是不满足原子性的。

    Redis 事务支持持久性吗?

    Redis 不同于 Memcached 的很重要一点就是,Redis 支持持久化,而且支持 3 种持久化方式:

    • 快照(snapshotting,RDB);
    • 只追加文件(append-only file,AOF);
    • RDB 和 AOF 的混合持久化(Redis 4.0 新增)。

    与 RDB 持久化相比,AOF 持久化的实时性更好。在 Redis 的配置文件中存在三种不同的 AOF 持久化方式(fsync 策略),它们分别是:

    appendfsync always    #每次有数据修改发生时,都会调用fsync函数同步AOF文件,fsync完成后线程返回,这样会严重降低Redis的速度
    appendfsync everysec  #每秒钟调用fsync函数同步一次AOF文件
    appendfsync no        #让操作系统决定何时进行同步,一般为30秒一次

    AOF 持久化的 fsync 策略为 no、everysec 时都会存在数据丢失的情况。always 下可以基本是可以满足持久性要求的,但性能太差,实际开发过程中不会使用。

    因此,Redis 事务的持久性也是没办法保证的。

    如何解决 Redis 事务的缺陷?

    Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。

    一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。

    不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此,严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。

    如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。

    ⭐️Redis 性能优化(重要)

    使用批量操作减少网络传输

    一个 Redis 命令的执行可以简化为以下 4 步:

    1. 发送命令;
    2. 命令排队;
    3. 命令执行;
    4. 返回结果。

    其中,第 1 步和第 4 步耗费时间之和称为 Round Trip Time(RTT,往返时间),也就是数据在网络上传输的时间。

    使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。

    另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在 read()write() 系统调用),批量操作还可以减少 socket I/O 成本。

    原生批量操作命令

    Redis 中有一些原生支持批量操作的命令,比如:

    • MGET(获取一个或多个指定 key 的值)、MSET(设置一个或多个指定 key 的值)、
    • HMGET(获取指定哈希表中一个或者多个指定字段的值)、HMSET(同时将一个或多个 field-value 对设置到指定哈希表中)、
    • SADD(向指定集合添加一个或多个元素)
    • ……

    不过,在 Redis 官方提供的分片集群解决方案 Redis Cluster 下,使用这些原生批量操作命令可能会存在一些小问题需要解决。就比如说 MGET 无法保证所有的 key 都在同一个 hash slot(哈希槽) 上,MGET可能还是需要多次网络传输,原子操作也无法保证了。不过,相较于非批量操作,还是可以节省不少网络传输次数。

    整个步骤的简化版如下(通常由 Redis 客户端实现,无需我们自己再手动实现):

    1. 找到 key 对应的所有 hash slot;
    2. 分别向对应的 Redis 节点发起 MGET 请求获取数据;
    3. 等待所有请求执行结束,重新组装结果数据,保持跟入参 key 的顺序一致,然后返回结果。

    如果想要解决这个多次网络传输的问题,比较常用的办法是自己维护 key 与 slot 的关系。不过这样不太灵活,虽然带来了性能提升,但同样让系统复杂性提升。

    pipeline

    对于不支持批量操作的命令,我们可以利用 pipeline(流水线) 将一批 Redis 命令封装成一组,这些 Redis 命令会被一次性提交到 Redis 服务器,只需要一次网络传输。不过,需要注意控制一次批量操作的 元素个数(例如 500 以内,实际也和元素字节数有关),避免网络传输的数据量过大。

    MGETMSET 等原生批量操作命令一样,pipeline 同样在 Redis Cluster 上使用会存在一些小问题。原因类似,无法保证所有的 key 都在同一个 hash slot(哈希槽) 上。如果想要使用的话,客户端需要自己维护 key 与 slot 的关系。

    原生批量操作命令和 pipeline 的是有区别的,使用的时候需要注意:

    • 原生批量操作命令是原子操作,pipeline 是非原子操作。
    • pipeline 可以打包不同的命令,原生批量操作命令不可以。
    • 原生批量操作命令是 Redis 服务端支持实现的,而 pipeline 需要服务端和客户端的共同实现。

    顺带补充一下 pipeline 和 Redis 事务的对比:

    • 事务是原子操作,pipeline 是非原子操作。两个不同的事务不会同时运行,而 pipeline 可以同时以交错方式执行。
    • Redis 事务中每个命令都需要发送到服务端,而 Pipeline 只需要发送一次,请求次数更少。

    事务可以看作是一个原子操作,但其实并不满足原子性。当我们提到 Redis 中的原子操作时,主要指的是这个操作(比如事务、Lua 脚本)不会被其他操作(比如其他事务、Lua 脚本)打扰,并不能完全保证这个操作中的所有写命令要么都执行要么都不执行。这主要也是因为 Redis 是不支持回滚操作。

    另外,pipeline 不适用于执行顺序有依赖关系的一批命令。就比如说,你需要将前一个命令的结果给后续的命令使用,pipeline 就没办法满足你的需求了。对于这种需求,我们可以使用 Lua 脚本

    Lua 脚本

    Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。

    并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。

    不过, Lua 脚本依然存在下面这些缺陷:

    • 如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。
    • Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽) 上。

    大量 key 集中过期问题

    我在前面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。

    定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。

    如何解决呢? 下面是两种常见的方法:

    1. 给 key 设置随机过期时间。
    2. 开启 lazy-free(惰性删除/延迟释放)。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

    个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间。

    Redis bigkey(大 Key)

    什么是 bigkey?

    简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:

    • String 类型的 value 超过 1MB
    • 复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。

    bigkey 判定标准

    bigkey 是怎么产生的?有什么危害?

    bigkey 通常是由于下面这些原因产生的:

    • 程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
    • 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
    • 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。

    bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。

    Redis 常见阻塞原因总结 这篇文章中我们提到:大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:

    1. 客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
    2. 网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
    3. 工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。

    大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。

    综上,大 key 带来的潜在问题是非常多的,我们应该尽量避免 Redis 中存在 bigkey。

    如何发现 bigkey?

    1、使用 Redis 自带的 --bigkeys 参数来查找。

    从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan)Redis 中的所有 key,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。

    在线上执行该命令时,为了降低对 Redis 的影响,需要指定 -i 参数控制扫描的频率。redis-cli -p 6379 --bigkeys -i 3 表示扫描过程中每次扫描后休息的时间间隔为 3 秒。

    2、使用 Redis 自带的 SCAN 命令

    SCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLENHLENLLEN 等命令返回其长度或成员数量。

    数据结构 命令 复杂度 结果(对应 key)
    String STRLEN O(1) 字符串值的长度
    Hash HLEN O(1) 哈希表中字段的数量
    List LLEN O(1) 列表元素数量
    Set SCARD O(1) 集合元素数量
    Sorted Set ZCARD O(1) 有序集合的元素数量

    对于集合类型还可以使用 MEMORY USAGE 命令(Redis 4.0+),这个命令会返回键值对占用的内存空间。

    3、借助开源工具分析 RDB 文件。

    通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。

    网上有现成的代码/工具可以直接拿来使用:

    • redis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具。
    • rdb_bigkeys:Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。

    4、借助公有云的 Redis 分析服务。

    如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。

    这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现,文档地址:https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-feature

    阿里云Key分析

    如何处理 bigkey?

    bigkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):

    • 分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。
    • 手动清理:Redis 4.0+ 可以使用 UNLINK 命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用 SCAN 命令结合 DEL 命令来分批次删除。
    • 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。
    • 开启 lazy-free(惰性删除/延迟释放):lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

    Redis hotkey(热 Key)

    什么是 hotkey?

    如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。

    hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。

    hotkey 有什么危害?

    处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。

    因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性。

    如何发现 hotkey?

    1、使用 Redis 自带的 --hotkeys 参数来查找。

    Redis 4.0.3 版本中新增了 hotkeys 参数,该参数能够返回所有 key 的被访问次数。

    使用该方案的前提条件是 Redis Server 的 maxmemory-policy 参数设置为 LFU 算法,不然就会出现如下所示的错误。

    # redis-cli -p 6379 --hotkeys
    
    # Scanning the entire keyspace to find hot keys as well as
    # average sizes per key type.  You can use -i 0.1 to sleep 0.1 sec
    # per 100 SCAN commands (not usually needed).
    
    Error: ERR An LFU maxmemory policy is not selected, access frequency not tracked. Please note that when switching between policies at runtime LRU and LFU data will take some time to adjust.

    Redis 中有两种 LFU 算法:

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

    以下是配置文件 redis.conf 中的示例:

    # 使用 volatile-lfu 策略
    maxmemory-policy volatile-lfu
    
    # 或者使用 allkeys-lfu 策略
    maxmemory-policy allkeys-lfu

    需要注意的是,hotkeys 参数命令也会增加 Redis 实例的 CPU 和内存消耗(全局扫描),因此需要谨慎使用。

    2、使用 MONITOR 命令。

    MONITOR 命令是 Redis 提供的一种实时查看 Redis 的所有操作的方式,可以用于临时监控 Redis 实例的操作情况,包括读写、删除等操作。

    由于该命令对 Redis 性能的影响比较大,因此禁止长时间开启 MONITOR(生产环境中建议谨慎使用该命令)。

    # redis-cli
    127.0.0.1:6379> MONITOR
    OK
    1683638260.637378 [0 172.17.0.1:61516] "ping"
    1683638267.144236 [0 172.17.0.1:61518] "smembers" "mySet"
    1683638268.941863 [0 172.17.0.1:61518] "smembers" "mySet"
    1683638269.551671 [0 172.17.0.1:61518] "smembers" "mySet"
    1683638270.646256 [0 172.17.0.1:61516] "ping"
    1683638270.849551 [0 172.17.0.1:61518] "smembers" "mySet"
    1683638271.926945 [0 172.17.0.1:61518] "smembers" "mySet"
    1683638274.276599 [0 172.17.0.1:61518] "smembers" "mySet2"
    1683638276.327234 [0 172.17.0.1:61518] "smembers" "mySet"

    在发生紧急情况时,我们可以选择在合适的时机短暂执行 MONITOR 命令并将输出重定向至文件,在关闭 MONITOR 命令后通过对文件中请求进行归类分析即可找出这段时间中的 hotkey。

    3、借助开源项目。

    京东零售的 hotkey 这个项目不光支持 hotkey 的发现,还支持 hotkey 的处理。

    京东零售开源的 hotkey

    4、根据业务情况提前预估。

    可以根据业务情况来预估一些 hotkey,比如参与秒杀活动的商品数据等。不过,我们无法预估所有 hotkey 的出现,比如突发的热点新闻事件等。

    5、业务代码中记录分析。

    在业务代码中添加相应的逻辑对 key 的访问情况进行记录分析。不过,这种方式会让业务代码的复杂性增加,一般也不会采用。

    6、借助公有云的 Redis 分析服务。

    如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。

    这里以阿里云 Redis 为例说明,它支持 hotkey 实时分析、发现,文档地址:https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-feature

    阿里云Key分析

    如何解决 hotkey?

    hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):

    • 读写分离:主节点处理写请求,从节点处理读请求。
    • 使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
    • 二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。

    除了这些方法之外,如果你使用的公有云的 Redis 服务话,还可以留意其提供的开箱即用的解决方案。

    这里以阿里云 Redis 为例说明,它支持通过代理查询缓存功能(Proxy Query Cache)优化热点 Key 问题。

    慢查询命令

    为什么会有慢查询命令?

    我们知道一个 Redis 命令的执行可以简化为以下 4 步:

    1. 发送命令;
    2. 命令排队;
    3. 命令执行;
    4. 返回结果。

    Redis 慢查询统计的是命令执行这一步骤的耗时,慢查询命令也就是那些命令执行时间较长的命令。

    Redis 为什么会有慢查询命令呢?

    Redis 中的大部分命令都是 O(1) 时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:

    • KEYS *:会返回所有符合规则的 key。
    • HGETALL:会返回一个 Hash 中所有的键值对。
    • LRANGE:会返回 List 中指定范围内的元素。
    • SMEMBERS:返回 Set 中的所有元素。
    • SINTER/SUNION/SDIFF:计算多个 Set 的交集/并集/差集。
    • ……

    由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCANSSCANZSCAN 代替。

    除了这些 O(n) 时间复杂度的命令可能会导致慢查询之外,还有一些时间复杂度可能在 O(N) 以上的命令,例如:

    • ZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量,m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
    • ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量,m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
    如何找到慢查询命令?

    Redis 提供了一个内置的慢查询日志 (Slow Log) 功能,专门用来记录执行时间超过指定阈值的命令。这对于排查性能瓶颈、找出导致 Redis 阻塞的“慢”操作非常有帮助,原理和 MySQL 的慢查询日志类似。

    redis.conf 文件中,我们可以使用 slowlog-log-slower-than 参数设置耗时命令的阈值,并使用 slowlog-max-len 参数设置耗时命令的最大记录条数。

    当 Redis 服务器检测到执行时间超过 slowlog-log-slower-than 阈值的命令时,就会将该命令记录在慢查询日志(slow log)中,这点和 MySQL 记录慢查询语句类似。当慢查询日志超过设定的最大记录条数之后,Redis 会把最早的执行命令依次舍弃。

    ⚠️ 注意:由于慢查询日志会占用一定内存空间,如果设置最大记录条数过大,可能会导致内存占用过高的问题。

    除了修改配置文件之外,你也可以直接通过 CONFIG 命令直接设置:

    # 命令执行耗时超过 10000 微妙(即10毫秒)就会被记录
    CONFIG SET slowlog-log-slower-than 10000
    # 只保留最近 128 条耗时命令
    CONFIG SET slowlog-max-len 128

    获取慢查询日志的内容很简单,直接使用 SLOWLOG GET 命令即可。

    127.0.0.1:6379> SLOWLOG GET #慢日志查询
     1) 1) (integer) 5
       2) (integer) 1684326682
       3) (integer) 12000
       4) 1) "KEYS"
          2) "*"
       5) "172.17.0.1:61152"
       6) ""
      // ...

    慢查询日志中的每个条目都由以下六个值组成:

    1. 唯一 ID: 日志条目的唯一标识符。
    2. 时间戳 (Timestamp): 命令执行完成时的 Unix 时间戳。
    3. 耗时 (Duration): 命令执行所花费的时间,单位是微秒
    4. 命令及参数 (Command): 执行的具体命令及其参数数组。
    5. 客户端信息 (Client IP:Port): 执行命令的客户端地址和端口。
    6. 客户端名称 (Client Name): 如果客户端设置了名称 (CLIENT SETNAME)。

    SLOWLOG GET 命令默认返回最近 10 条的的慢查询命令,你也自己可以指定返回的慢查询命令的数量 SLOWLOG GET N

    下面是其他比较常用的慢查询相关的命令:

    # 返回慢查询命令的数量
    127.0.0.1:6379> SLOWLOG LEN
    (integer) 128
    # 清空慢查询命令
    127.0.0.1:6379> SLOWLOG RESET
    OK

    Redis 内存碎片

    相关问题

    1. 什么是内存碎片?为什么会有 Redis 内存碎片?
    2. 如何清理 Redis 内存碎片?

    什么是内存碎片?

    你可以将内存碎片简单地理解为那些不可用的空闲内存。

    举个例子:操作系统为你分配了 32 字节的连续内存空间,而你存储数据实际只需要使用 24 字节内存空间,那这多余出来的 8 字节内存空间如果后续没办法再被分配存储其他数据的话,就可以被称为内存碎片。

    内存碎片

    Redis 内存碎片虽然不会影响 Redis 性能,但是会增加内存消耗。

    为什么会有 Redis 内存碎片?

    Redis 内存碎片产生比较常见的 2 个原因:

    1、Redis 存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。

    Redis 使用 zmalloc 方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size 大小的内存之外,还会多分配 PREFIX_SIZE 大小的内存。

    另外,Redis 可以使用多种内存分配器来分配内存( libc、jemalloc、tcmalloc),默认使用 jemalloc,而 jemalloc 按照一系列固定的大小(8 字节、16 字节、32 字节……)来分配内存的。jemalloc 划分的内存单元如下图所示:

    jemalloc 内存单元示意图

    当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间,就比如说程序需要申请 17 字节的内存,jemalloc 会直接给它分配 32 字节的内存,这样会导致有 15 字节内存的浪费。不过,jemalloc 专门针对内存碎片问题做了优化,一般不会存在过度碎片化的问题。

    2、频繁修改 Redis 中的数据也会产生内存碎片。

    当 Redis 中的某个数据删除时,Redis 通常不会轻易释放内存给操作系统。

    如何查看 Redis 内存碎片的信息?

    使用 info memory 命令即可查看 Redis 内存相关的信息。

    Redis 内存碎片率的计算公式:mem_fragmentation_ratio (内存碎片率)= used_memory_rss (操作系统实际分配给 Redis 的物理内存空间大小)/ used_memory(Redis 内存分配器为了存储数据实际申请使用的内存空间大小)

    也就是说,mem_fragmentation_ratio (内存碎片率)的值越大代表内存碎片率越严重。

    一定不要误认为used_memory_rss 减去 used_memory值就是内存碎片的大小!!!这不仅包括内存碎片,还包括其他进程开销,以及共享库、堆栈等的开销。

    很多小伙伴可能要问了:“多大的内存碎片率才是需要清理呢?”。

    通常情况下,我们认为 mem_fragmentation_ratio > 1.5 的话才需要清理内存碎片。 mem_fragmentation_ratio > 1.5 意味着你使用 Redis 存储实际大小 2G 的数据需要使用大于 3G 的内存。

    如果想要快速查看内存碎片率的话,你还可以通过下面这个命令:

    > redis-cli -p 6379 info | grep mem_fragmentation_ratio

    如何清理 Redis 内存碎片?

    Redis4.0-RC3 版本以后自带了内存整理,可以避免内存碎片率过大的问题。

    直接通过 config set 命令将 activedefrag 配置项设置为 yes 即可。

    config set activedefrag yes

    具体什么时候清理需要通过下面两个参数控制:

    # 内存碎片占用空间达到 500mb 的时候开始清理
    config set active-defrag-ignore-bytes 500mb
    # 内存碎片率大于 1.5 的时候开始清理
    config set active-defrag-threshold-lower 50

    通过 Redis 自动内存碎片清理机制可能会对 Redis 的性能产生影响,我们可以通过下面两个参数来减少对 Redis 性能的影响:

    # 内存碎片清理所占用 CPU 时间的比例不低于 20%
    config set active-defrag-cycle-min 20
    # 内存碎片清理所占用 CPU 时间的比例不高于 50%
    config set active-defrag-cycle-max 50

    另外,重启节点可以做到内存碎片重新整理。如果你采用的是高可用架构的 Redis 集群的话,你可以将碎片率过高的主节点转换为从节点,以便进行安全重启。

    ⭐️Redis 生产问题(重要)

    缓存预热

    如何知道redis中应该存储哪些数据?

    也就是怎么获得热点数据。

    这涉及到缓存的两种更新策略:1,定期生成 2,实时生成

    1,定期生成

    将访问的数据 ,以日志的 形式记录下来。接下来就可以针对这些日志进行统计了,统计这一天/一周/一个月,数据出现的频率,然后再按照降序排序,取出前20%的数据数据,这些数据 就是热点数据。

    优点:上述过程,实际上实现起来比较简单,过程更可控,缓存中的数据是比较扶额和预期的,方便排查问题。

    缺点:实时性不够。如果出现一些突发事件,有些本来不是热词的内容成了热词,这就可能会给后面的数据库带来较大的压力。

    2,实时生成
    • 如果在redis中查到了数据,就直接返回。
    • 如果没有查到,就从数据库查,同时把查到的数据写入redis中。

    这里就会有一个问题,如果不停的向redis中写入数据,就会使redis的内存占用越来越高,逐渐达到内存上限。

    此时如果继续向redis中写入数据,就会出现问题,为了解决这个问题,redis就引入了"内存淘汰策略"。 经典面试题:

    • FIFO(First In First Out)先进先出:把缓存中存在时间最久的(也就是先来的数据)淘汰掉。
    • LRU(Least Recently Used)淘汰最近未使用的:记录每个key的最近访问时间,把最近访问时间最老的key淘汰掉
    • LFU(Least Frequently Used)淘汰访问次数最少的:记录每个key最近一段时间的访问次数,把访问次数最少的淘汰掉。
    • Random 随机淘汰:从所有的key中随机抽取一个淘汰掉。

    缓存中的数据,有两种更新策略:1,定期生成 2,实时生成

    1. 如果使定期生成,就不涉及到预热。
    2. 如果是实时生成,在redis服务首次接入之后,服务器里是没有数据的,此时客户端的所有请求就都会打给MySQL,如果请求量太多,可能就会导致MySQL服务挂了。随着时间的推移,reids中的数据越来越多,MySQl承担的压力也就越来越小了。

    缓存预热,就是为了解决上述问题。把定期生成和实时生成相结合,先通过离线的方式,通过一些统计的途径,先找到一批热点数据,导入到redis中。此时导入的这批热点数据就能帮MySQL分担一些压力了。随着时间的推移,使用新的热点数据来淘汰旧的热点数据。

    在刚开始架构演进的时候,没有缓存,此时要加入缓存,就要进行缓存预热。还有当服务器进行重启的时候,我们要保证重启之后缓存中是否有数据以及 这里的数据 是否是热点数据,这也涉及到缓存预热。

    缓存穿透

    什么是缓存穿透?

    缓存穿透说简单点就是大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

    缓存穿透

    举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。

    有哪些解决办法?

    最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。

    缓存穿透

    举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。

    有哪些解决办法?

    最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。

    1)缓存无效 key

    如果缓存和数据库都查不到某个 key 的数据,就写一个到 Redis 中去并设置过期时间,具体命令如下:SET key value EX 10086。这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点,比如 1 分钟。

    另外,这里多说一嘴,一般情况下我们是这样设计 key 的:表名:列名:主键名:主键值

    如果用 Java 代码展示的话,差不多是下面这样的:

    public Object getObjectInclNullById(Integer id) {
        // 从缓存中获取数据
        Object cacheValue = cache.get(id);
        // 缓存为空
        if (cacheValue == null) {
            // 从数据库中获取
            Object storageValue = storage.get(key);
            // 缓存空对象
            cache.set(key, storageValue);
            // 如果存储数据为空,需要设置一个过期时间(300秒)
            if (storageValue == null) {
                // 必须设置过期时间,否则有被攻击的风险
                cache.expire(key, 60 * 5);
            }
            return storageValue;
        }
        return cacheValue;
    }

    2)布隆过滤器

    布隆过滤器是一个非常神奇的数据结构,通过它我们可以非常方便地判断一个给定数据是否存在于海量数据中。我们可以把它看作由二进制向量(或者说位数组)和一系列随机映射函数(哈希函数)两部分组成的数据结构。相比于我们平时常用的 List、Map、Set 等数据结构,它占用空间更少并且效率更高,但是缺点是其返回的结果是概率性的,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。

    Bloom Filter 的简单原理示意图

    Bloom Filter 会使用一个较大的 bit 数组来保存所有的数据,数组中的每个元素都只占用 1 bit ,并且每个元素只能是 0 或者 1(代表 false 或者 true),这也是 Bloom Filter 节省内存的核心所在。这样来算的话,申请一个 100w 个元素的位数组只占用 1000000Bit / 8 = 125000 Byte = 125000/1024 KB ≈ 122KB 的空间。

    位数组

    具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

    加入布隆过滤器之后的缓存处理流程图如下:

    加入布隆过滤器之后的缓存处理流程图

    不同的字符串可能哈希出来的位置相同,这种情况我们可以适当增加位数组大小或者调整我们的哈希函数。

    综上,我们可以得出:布隆过滤器说某个元素存在,小概率会误判。布隆过滤器说某个元素不在,那么这个元素一定不在。

    3)接口限流

    根据用户或者 IP 对接口进行限流,对于异常频繁的访问行为,还可以采取黑名单机制,例如将异常 IP 列入黑名单。

    后面提到的缓存击穿和雪崩都可以配合接口限流来解决,毕竟这些问题的关键都是有很多请求落到了数据库上造成数据库压力过大。

    缓存击穿

    什么是缓存击穿?

    缓存击穿中,请求的 key 对应的是 热点数据,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)。这就可能会导致瞬时大量的请求直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。

    缓存击穿

    举个例子:秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力。

    有哪些解决办法?
    1. 永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。
    2. 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
    3. 加锁(看情况):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。
    缓存穿透和缓存击穿有什么区别?

    缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。

    缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)

    缓存雪崩

    什么是缓存雪崩?

    我发现缓存雪崩这名字起的有点意思,哈哈。

    实际上,缓存雪崩描述的就是这样一个简单的场景:缓存在同一时间大面积的失效,导致大量的请求都直接落到了数据库上,对数据库造成了巨大的压力。 这就好比雪崩一样,摧枯拉朽之势,数据库的压力可想而知,可能直接就被这么多请求弄宕机了。

    另外,缓存服务宕机也会导致缓存雪崩现象,导致所有的请求都落到了数据库上。

    缓存雪崩

    举个例子:缓存中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力。

    针对 Redis 服务不可用的情况

    1. Redis 集群:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案,详细介绍可以参考:Redis 集群详解(付费)
    2. 多级缓存:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。

    针对大量缓存同时失效的情况

    1. 设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。
    2. 提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间,比如秒杀场景下的数据在秒杀结束之前不过期。
    3. 持久缓存策略(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略。
    缓存预热如何实现?

    常见的缓存预热方式有两种:

    1. 使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
    2. 使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。
    缓存雪崩和缓存击穿有什么区别?

    缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在于缓存中(通常是因为缓存中的那份数据已经过期)。

    如何保证缓存和数据库数据的一致性?

    缓存和数据库一致性是个挺常见的技术挑战。引入缓存主要是为了提升性能、减轻数据库压力,但确实会带来数据不一致的风险。绝对的一致性往往意味着更高的系统复杂度和性能开销,所以实践中我们通常会根据业务场景选择合适的策略,在性能和一致性之间找到一个平衡点。

    下面单独对 Cache Aside Pattern(旁路缓存模式) 来聊聊。这是非常常用的一种缓存读写策略,它的读写逻辑是这样的:

    • 读操作
      1. 先尝试从缓存读取数据。
      2. 如果缓存命中,直接返回数据。
      3. 如果缓存未命中,从数据库查询数据,将查到的数据放入缓存并返回数据。
    • 写操作
      1. 先更新数据库。
      2. 再直接删除缓存中对应的数据。

    图解如下:

    如果更新数据库成功,而删除缓存这一步失败的情况的话,简单说有两个解决方案:

    1. 缓存失效时间(TTL - Time To Live)变短(不推荐,治标不治本):我们让缓存数据的过期时间变短,这样的话缓存就会从数据库中加载数据。另外,这种解决办法对于先操作缓存后操作数据库的场景不适用。
    2. 增加缓存更新重试机制(常用):如果缓存服务当前不可用导致缓存删除失败的话,我们就隔一段时间进行重试,重试次数可以自己定。不过,这里更适合引入消息队列实现异步重试,将删除缓存重试的消息投递到消息队列,然后由专门的消费者来重试,直到成功。虽然说多引入了一个消息队列,但其整体带来的收益还是要更高一些。

    哪些情况可能会导致 Redis 阻塞?

    常见的导致 Redis 阻塞原因有:

    • O(n) 复杂度命令执行(如 KEYS *HGETALLLRANGESMEMBERS 等),随着数据量增大导致执行时间过长。
    • 执行 SAVE 命令生成 RDB 快照时同步阻塞主线程,而 BGSAVE 通过 fork 子进程避免阻塞。
    • AOF 记录日志在主线程中进行,可能因命令执行后写日志而阻塞后续命令。
    • AOF 刷盘(fsync)时后台线程同步到磁盘,磁盘压力大导致 fsync 阻塞,进而阻塞主线程 write 操作,尤其在 appendfsync alwayseverysec 配置下明显。
    • AOF 重写过程中将重写缓冲区内容追加到新 AOF 文件时产生阻塞。
    • 操作大 key(string > 1MB 或复合类型元素 > 5000)导致客户端超时、网络阻塞和工作线程阻塞。
    • 使用 flushdbflushall 清空数据库时涉及大量键值对删除和内存释放,造成主线程阻塞。
    • 集群扩容缩容时数据迁移为同步操作,大 key 迁移导致两端节点长时间阻塞,可能触发故障转移
    • 内存不足触发 Swap,操作系统将 Redis 内存换出到硬盘,读写性能急剧下降。
    • 其他进程过度占用 CPU 导致 Redis 吞吐量下降。
    • 网络问题如连接拒绝、延迟高、网卡软中断等导致 Redis 阻塞。

    Redis 集群

    Redis Sentinel

    1. 什么是 Sentinel? 有什么用?
    2. Sentinel 如何检测节点是否下线?主观下线与客观下线的区别?
    3. Sentinel 是如何实现故障转移的?
    4. 为什么建议部署多个 sentinel 节点(哨兵集群)?
    5. Sentinel 如何选择出新的 master(选举机制)?
    6. 如何从 Sentinel 集群中选择出 Leader?
    7. Sentinel 可以防止脑裂吗?

    Redis Cluster

    1. 为什么需要 Redis Cluster?解决了什么问题?有什么优势?
    2. Redis Cluster 是如何分片的?
    3. 为什么 Redis Cluster 的哈希槽是 16384 个?
    4. 如何确定给定 key 的应该分布到哪个哈希槽中?
    5. Redis Cluster 支持重新分配哈希槽吗?
    6. Redis Cluster 扩容缩容期间可以提供服务吗?
    7. Redis Cluster 中的节点是怎么进行通信的?

    Redis 使用规范

    实际使用 Redis 的过程中,我们尽量要准守一些常见的规范,比如:

    1. 使用连接池:避免频繁创建关闭客户端连接。
    2. 尽量不使用 O(n) 指令,使用 O(n) 命令时要关注 n 的数量:像 KEYS *HGETALLLRANGESMEMBERSSINTER/SUNION/SDIFF 等 O(n) 命令并非不能使用,但是需要明确 n 的值。另外,有遍历的需求可以使用 HSCANSSCANZSCAN 代替。
    3. 使用批量操作减少网络传输:原生批量操作命令(比如 MGETMSET 等等)、pipeline、Lua 脚本。
    4. 尽量不使用 Redis 事务:Redis 事务实现的功能比较鸡肋,可以使用 Lua 脚本代替。
    5. 禁止长时间开启 monitor:对性能影响比较大。
    6. 控制 key 的生命周期:避免 Redis 中存放了太多不经常被访问的数据。

    Logo

    AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

    更多推荐