Redis 深度复习:从入门到面试通关

一、Redis 概述与基础

1.1 什么是 Redis

Redis(Remote Dictionary Server)是一个开源的、基于内存的高性能键值存储系统,由意大利开发者 Salvatore Sanfilippo 于 2009 年发布。它常被用作数据库、缓存和消息中间件,是目前互联网架构中最核心的基础组件之一。

与传统关系型数据库不同,Redis 将数据存储在内存 中,因此读写速度极快。同时它支持多种数据结构,包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等,这使得它的应用场景远超简单的键值缓存。

1.2 Redis 的核心特性

Redis 之所以能成为业界标配,得益于其独特的设计理念和丰富的功能特性。

内存存储与持久化。 Redis 将所有数据保存在内存中,这是它高性能的根本保证。但纯内存存储意味着宕机数据会丢失,因此 Redis 提供了RDB 和 AOF两种持久化机制,可以将内存数据异步写入磁盘,在重启时恢复数据。

**丰富的数据结构。**Redis 不仅仅是简单的 key-value 存储,它支持 String、Hash、List、Set、ZSet、Bitmap、HyperLogLog、Geo、Stream 等多种数据结构,几乎可以满足所有常见的业务场景需求。

**单线程模型。**Redis 采用单线程模型处理命令请求,避免了多线程的上下文切换和锁竞争开销。这里的单线程指的是命令执行是单线程的,而 Redis 的其他模块如持久化、集群同步等是有多线程参与的。Redis 6.0 之后,网络 IO 也引入了多线程来进一步提升性能。

高可用与集群。 Redis 提供了主从复制、哨兵模式、分片集群三种集群方案,可以根据业务规模灵活选择,实现高可用和水平扩展。

1.3 Redis 为什么这么快

Redis 单节点 QPS 可以达到 10 万+,这在数据库领域是非常惊人的数字。其高性能主要源于以下几个方面:

**完全基于内存。**Redis 的所有数据都存储在内存中,读写操作都是内存级别的,速度远快于磁盘 IO。这是 Redis 高性能最根本的原因。

**C 语言实现。**Redis 由 C 语言编写,执行效率高,且对内存的管理更加精细。

**单线程模型。**采用单线程避免了多线程的上下文切换和锁竞争开销。对于内存数据库来说,CPU 通常不是瓶颈,单线程足以处理大量请求。

**IO 多路复用。**Redis 使用 IO 多路复用技术(epoll)来处理网络连接,单个线程可以同时监听大量 Socket 连接,在连接就绪时才进行处理,避免了阻塞等待,充分利用了 CPU 资源。

**💡 关于单线程的理解:**Redis 的单线程指的是命令执行是单线程的,即同一时刻只有一个命令在执行。但 Redis 的其他功能如持久化(bgsave、bgrewriteaof)、集群数据同步等都是由后台子进程或子线程完成的,不会阻塞主线程。Redis 6.0 引入了多线程 IO,将网络数据的读写和协议解析放到多线程中处理,但命令执行仍然是单线程的,这保证了原子性。

1.4 Redis 典型应用场景

缓存。 最常见的用法,缓存热点数据,减轻数据库压力。

**分布式锁。**利用 SETNX 实现分布式环境下的互斥锁。

**计数器。**利用 INCR/DECR 实现文章点赞、访问计数等。

**排行榜。**使用 ZSet 实现各种排行榜功能。

**消息队列。**使用 List 或 Stream 实现简单的消息队列。

**分布式会话。**将 Session 存在 Redis 中,实现多服务共享。

**去重。**使用 Set 或 Bitmap 实现数据去重。

**限流。**结合计数器或滑动窗口实现接口限流。

1.5 Redis vs Memcached

同样作为内存缓存,Redis 和 Memcached 经常被拿来比较。两者各有优势,但 Redis 的功能更加丰富,适用场景更广。

**数据结构方面:**Redis 支持多种复杂数据结构(String、Hash、List、Set、ZSet 等),而 Memcached 仅支持简单的 key-value 字符串。

**持久化方面:**Redis 支持 RDB 和 AOF 两种持久化方式,宕机后可以恢复数据;Memcached 不支持持久化,重启后数据全部丢失。

**线程模型方面:**Redis 命令执行是单线程的,避免了锁竞争;Memcached 是多线程的,可以利用多核 CPU。

**集群支持方面:**Redis 原生支持主从、哨兵、分片集群;Memcached 需要客户端实现分片。

**适用场景:**Redis 适用于复杂业务场景、有持久化需求的场景;Memcached 适用于简单缓存、纯 KV 场景。

二、Redis 数据结构详解

2.1 String(字符串)

String 是 Redis 最基础的数据结构,一个 key 对应一个 value。String 类型是二进制安全的,也就是说 Redis 的 string 可以包含任何数据,比如图片、序列化对象等。一个字符串 value 最大可以是 512MB。

常用命令:

SET key value --- 设置指定 key 的值

GET key --- 获取指定 key 的值

SETEX key seconds value --- 设置 key 的值和过期时间

SETNX key value --- 只有 key 不存在时才设置(分布式锁核心)

INCR key --- 将 key 中储存的数字值增一

DECR key --- 将 key 中储存的数字值减一

INCRBY key increment --- 将 key 所储存的值加上给定的增量

MSET key1 value1 key2 value2 --- 批量设置

MGET key1 key2 --- 批量获取

应用场景:

**缓存对象。**将用户信息、商品信息等序列化成 JSON 字符串存在 Redis 中。

**计数器。**利用 INCR/DECR 实现文章阅读数、点赞数、访问量等计数功能。

**分布式锁。**使用 SETNX 命令实现分布式锁。

**共享 Session。**将用户登录信息存在 Redis 中,实现多服务共享登录状态。

**底层实现:**String 的底层实现是简单动态字符串(SDS)。与 C 语言的字符串不同,SDS 记录了字符串长度,获取长度的时间复杂度是 O(1);同时 SDS 会预分配空间,减少内存分配次数。

2.2 Hash(哈希)

Hash 是一个键值对集合,特别适合用于存储对象。Hash 可以看作是一个微型的 Redis,每个 Hash 可以存储 2^32 - 1 个键值对。

常用命令:

HSET key field value --- 将哈希表 key 中的字段 field 的值设为 value

HGET key field --- 获取存储在哈希表中指定字段的值

HGETALL key --- 获取在哈希表中指定 key 的所有字段和值

HDEL key field --- 删除一个或多个哈希表字段

HLEN key --- 获取哈希表中字段的数量

HINCRBY key field increment --- 为哈希表 key 中的指定字段的整数值加上增量

HMSET key field1 value1 field2 value2 --- 批量设置

HMGET key field1 field2 --- 批量获取

应用场景:

**存储对象。**相比 String 存储 JSON,Hash 可以对单个字段进行读写,不需要序列化整个对象,更加灵活高效。比如用户信息、商品详情等。

**购物车。**可以用用户 ID 作为 key,商品 ID 作为 field,商品数量作为 value,实现购物车功能。

**底层实现:**Hash 的底层实现有两种,当元素较少时使用压缩列表(ziplist),当元素数量超过阈值(默认 512 个)或单个 value 超过 64 字节时,会转换为哈希表(hashtable)。

2.3 List(列表)

List 是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。一个列表最多可以包含 2^32 - 1 个元素。

常用命令:

LPUSH key value1 value2 --- 将一个或多个值插入到列表头部

RPUSH key value1 value2 --- 将一个或多个值插入到列表尾部

LPOP key --- 移出并获取列表的第一个元素

RPOP key --- 移出并获取列表的最后一个元素

LRANGE key start stop --- 获取列表指定范围内的元素

LLEN key --- 获取列表长度

LINDEX key index --- 通过索引获取列表中的元素

BLPOP key1 key2 timeout --- 移出并获取列表的第一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止

应用场景:

**消息队列。**使用 LPUSH + RPOP 可以实现简单的队列;使用 LPUSH + LPOP 可以实现栈。

**文章列表/评论列表。**比如朋友圈的动态列表、文章的评论列表等。

**排行榜。**可以用 List 存储排行榜数据,但更推荐用 ZSet。

**底层实现:**List 在 Redis 3.2 之前使用压缩列表(ziplist)和双向链表(linkedlist),3.2 之后统一使用快速列表(quicklist)。quicklist 是 ziplist 和 linkedlist 的结合,将多个 ziplist 用双向链表串起来,兼顾了空间效率和时间效率。

2.4 Set(集合)

Set 是 String 类型的无序集合,集合中的元素是唯一的,不重复的。Set 是通过哈希表实现的,所以添加、删除、查找的复杂度都是 O(1)。

常用命令:

SADD key member1 member2 --- 向集合添加一个或多个成员

SMEMBERS key --- 返回集合中的所有成员

SISMEMBER key member --- 判断 member 元素是否是集合 key 的成员

SCARD key --- 获取集合的成员数

SREM key member1 member2 --- 移除集合中一个或多个成员

SINTER key1 key2 --- 返回给定所有集合的交集

SUNION key1 key2 --- 返回所有给定集合的并集

SDIFF key1 key2 --- 返回给定所有集合的差集

SPOP key --- 移除并返回集合中的一个随机元素

应用场景:

**去重。**利用 Set 的元素唯一性,可以实现数据去重功能。

**共同好友。**利用交集运算,可以计算两个用户的共同好友。

**抽奖。**利用 SPOP 或 SRANDMEMBER 可以实现随机抽奖功能。

**标签系统。**可以用 Set 存储用户的标签,实现标签的增删改查和交集并集运算。

**底层实现:**Set 的底层实现有两种,当元素都是整数且数量较少时使用整数集合(intset),否则使用哈希表(hashtable)。

2.5 ZSet(有序集合)

ZSet 和 Set 一样也是 String 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数,Redis 通过分数来为集合中的成员进行从小到大的排序。ZSet 的成员是唯一的,但分数(score)却可以重复。

常用命令:

ZADD key score1 member1 score2 member2 --- 向有序集合添加一个或多个成员,或者更新已存在成员的分数

ZRANGE key start stop --- 通过索引区间返回有序集合指定区间内的成员(从小到大)

ZREVRANGE key start stop --- 返回有序集中指定区间内的成员(从大到小)

ZSCORE key member --- 返回有序集中,成员的分数值

ZRANK key member --- 返回有序集合中指定成员的索引

ZREM key member --- 移除有序集合中的一个或多个成员

ZCARD key --- 获取有序集合的成员数

ZCOUNT key min max --- 计算在有序集合中指定区间分数的成员数

ZINCRBY key increment member --- 有序集合中对指定成员的分数加上增量 increment

应用场景:

**排行榜。**这是 ZSet 最经典的应用场景,比如游戏排行榜、热搜榜、积分排行等。

**带权重的任务队列。**可以用 score 表示优先级,实现优先级队列。

**范围查找。**可以按分数范围查找元素,比如查找积分在某个区间的用户。

**底层实现:**ZSet 的底层实现有两种,当元素较少时使用压缩列表(ziplist),当元素数量超过阈值时使用跳表(skiplist)+ 哈希表。跳表是一种有序的数据结构,可以快速查找、插入、删除元素,时间复杂度是 O(log n)。

三、Redis 持久化机制

3.1 RDB 持久化

RDB(Redis Database)是 Redis 默认的持久化方式。它的原理是在指定的时间间隔内,将内存中的数据集快照写入磁盘,也就是 Snapshot 快照。恢复时是将快照文件直接读到内存里。

工作原理:

当 Redis 需要保存 dump.rdb 文件时,服务器会执行以下操作:

  1. Redis 调用 fork(),同时拥有父进程和子进程。

  2. 子进程将数据集写入到一个临时 RDB 文件中。

  3. 当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文件,并删除旧的 RDB 文件。

这种方式使得 Redis 可以从写时复制(copy-on-write)机制中获益,因为父进程继续处理客户端请求,而子进程负责将数据写入磁盘。

触发方式:

**自动触发。**可以通过配置文件 redis.conf 来设置自动触发的条件,比如:

save 900 1 --- 900 秒内至少有 1 个 key 被修改

save 300 10 --- 300 秒内至少有 10 个 key 被修改

save 60 10000 --- 60 秒内至少有 10000 个 key 被修改

只要满足其中一个条件,就会自动触发 RDB 持久化。

**手动触发。**可以通过执行 SAVE 或 BGSAVE 命令来手动触发 RDB 持久化。

SAVE --- 阻塞式保存,会阻塞 Redis 主线程,直到 RDB 文件创建完毕。生产环境不建议使用。

BGSAVE --- 后台异步保存,Redis 会在后台异步执行快照操作,同时还可以响应客户端请求。

优点:

RDB 是一个非常紧凑的文件,它保存了 Redis 在某个时间点上的数据集,非常适合用于备份和灾难恢复。

恢复速度快,RDB 文件是二进制格式,加载速度比 AOF 快很多。

对性能影响小,使用子进程进行持久化,父进程不需要做磁盘 IO 操作。

缺点:

数据丢失风险高,RDB 是间隔一段时间持久化一次,如果 Redis 宕机,可能会丢失最后一次快照之后的数据。

每次保存 RDB 都需要 fork() 子进程,在数据量大的时候,fork() 可能会很耗时,甚至导致 Redis 暂停服务。

3.2 AOF 持久化

AOF(Append Only File)是 Redis 的另一种持久化方式。它的原理是将 Redis 执行过的所有写命令都记录下来,追加到 AOF 文件的末尾。当 Redis 重启时,会重新执行 AOF 文件中的所有命令来恢复数据。

工作原理:

AOF 持久化的过程分为三步:命令追加、文件写入、文件同步。

  1. 命令追加:当 AOF 持久化功能打开时,Redis 在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾。

  2. 文件写入和同步:Redis 每次结束一个事件循环之前,都会调用 flushAppendOnlyFile 函数,考虑是否需要将 aof_buf 缓冲区中的内容写入和保存到 AOF 文件里面。

刷盘策略:

AOF 提供了三种刷盘策略,可以通过 appendfsync 参数配置:

**always。**每次写命令都立即同步到 AOF 文件。最安全,但是性能最差,因为每次写都要做磁盘 IO。

**everysec。**每秒同步一次。这是默认配置,在性能和安全性之间做了平衡。最多只会丢失 1 秒的数据。

**no。**由操作系统决定何时同步。性能最好,但是数据丢失风险最高。

AOF 重写:

随着 Redis 运行时间越来越长,AOF 文件会越来越大。为了解决这个问题,Redis 提供了 AOF 重写机制,可以在不中断服务的情况下,对 AOF 文件进行重写,生成一个更紧凑的新 AOF 文件。

AOF 重写的原理是:读取当前数据库中的所有键值对,然后用一条命令去记录这个键值对,代替之前记录这个键值对的多条命令。比如对同一个 key 执行了 100 次 INCR,AOF 文件里会有 100 条命令,但重写之后只需要一条 SET 命令就够了。

AOF 重写也是通过 fork() 子进程来完成的,不会阻塞主线程。重写过程中,新的写命令会同时写入旧的 AOF 文件和重写缓冲区,重写完成后将重写缓冲区的内容追加到新的 AOF 文件中,然后替换旧文件。

优点:

数据安全性高,使用 everysec 策略最多只丢失 1 秒的数据。

AOF 文件是文本格式,可读性好,可以手动修改和修复。

AOF 重写机制可以自动压缩文件大小。

缺点:

AOF 文件通常比 RDB 文件大,因为它记录的是命令而不是数据。

恢复速度比 RDB 慢,因为需要逐条执行命令。

存在重写期间的额外性能开销。

3.3 RDB vs AOF 对比

**数据安全性:**AOF 比 RDB 更安全,RDB 可能会丢失最后一次快照之后的所有数据,而 AOF 最多丢失 1 秒的数据。

**恢复速度:**RDB 恢复速度比 AOF 快,因为 RDB 是二进制格式,直接加载到内存即可,而 AOF 需要逐条执行命令。

**文件大小:**RDB 文件比 AOF 文件小,因为 RDB 是压缩的二进制格式,而 AOF 是文本格式。

**性能影响:**RDB 使用子进程进行快照,对性能影响较小;AOF 每次写命令都要追加到缓冲区,也会有一定的性能开销。

**使用场景:**如果能容忍少量数据丢失,追求恢复速度,选择 RDB;如果对数据安全性要求高,选择 AOF。

3.4 混合持久化

Redis 4.0 之后引入了混合持久化模式,结合了 RDB 和 AOF 的优点。混合持久化是 AOF 重写的一个优化,开启后,AOF 重写时会把重写这一刻之前的内存数据做 RDB 快照处理,并且将 RDB 快照内容和增量的 AOF 修改命令存在一起,都写入新的 AOF 文件。

这样的好处是:重启时可以先加载 RDB 部分,快速恢复大部分数据,然后再执行增量的 AOF 命令,既保证了恢复速度,又减少了数据丢失。

混合持久化可以通过 aof-use-rdb-preamble yes 配置开启。

四、过期策略与内存淘汰

4.1 过期键删除策略

Redis 中可以通过 EXPIRE 命令给 key 设置过期时间,当 key 过期后,需要及时删除以释放内存。Redis 采用了惰性删除和定期删除两种策略相结合的方式来处理过期键。

定时删除:

在设置 key 的过期时间的同时,创建一个定时器,让定时器在 key 的过期时间来临时,立即执行对 key 的删除操作。

优点:对内存最友好,能够保证过期的 key 会被尽快删除,释放内存。

缺点:对 CPU 最不友好。在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,会影响服务器的响应时间和吞吐量。

惰性删除:

放任 key 过期不管,但是每次从键空间中获取 key 时,都检查取得的 key 是否过期,如果过期的话,就删除该 key;如果没有过期的话,就返回该 key。

优点:对 CPU 最友好,只有在取出 key 的时候才会对过期 key 进行检查,这样删除操作只会在非做不可的情况下进行,而且删除的目标仅限于当前处理的 key,不会在删除其他无关的过期 key 上花费任何 CPU 时间。

缺点:对内存最不友好。如果一个 key 已经过期,而之后又一直没有被访问到的话,那么这个过期的 key 将会一直保留在数据库中,浪费内存空间。如果有大量的过期 key 存在,而这些 key 又没有被访问到的话,那么它们可能永远不会被删除,造成内存泄漏。

定期删除:

每隔一段时间,程序就对数据库进行一次检查,删除里面的过期 key。至于要删除多少过期 key,以及要检查多少个数据库,则由算法决定。

定期删除策略是前两种策略的一种整合和折中:

  1. 定期删除策略每隔一段时间执行一次删除过期 key 操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。

  2. 除此之外,通过定期删除过期 key,定期删除策略有效地减少了因为过期 key 而带来的内存浪费。

Redis 的实际策略:

Redis 实际使用的是惰性删除和定期删除两种策略的配合使用,通过这两种策略的配合,可以很好地平衡 CPU 时间和内存空间。

惰性删除由 db.c/expireIfNeeded 函数实现,所有读写数据库的 Redis 命令在执行之前都会调用 expireIfNeeded 函数对输入 key 进行检查:如果输入 key 已经过期,那么将输入 key 从数据库中删除;如果输入 key 没有过期,那么不做动作。

定期删除由 redis.c/activeExpireCycle 函数实现,每当 Redis 的服务器周期性操作 redis.c/serverCron 函数执行时,activeExpireCycle 函数就会被调用,它在规定的时间内,分多次遍历服务器中的各个数据库,从数据库的 expires 字典中随机检查一部分 key 的过期时间,并删除其中的过期 key。

定期删除有两种模式:

**SLOW 模式。**是定时任务,执行频率默认为 10hz,每次执行时间不超过 25ms。可以通过修改配置文件 redis.conf 的 hz 选项来调整这个次数。

**FAST 模式。**执行频率不固定,每次事件循环会尝试执行,但两次间隔不低于 2ms,每次耗时不超过 1ms。

4.2 内存淘汰策略

当 Redis 的内存使用达到 maxmemory 限制时,Redis 会根据配置的淘汰策略来删除一些 key,以释放内存。Redis 提供了 8 种内存淘汰策略:

**noeviction:**默认策略。不删除任何 key,当内存不足时,所有申请内存的命令都会报错。这是最保守的策略,也是默认策略。

**allkeys-lru:**从所有 key 中,使用 LRU(Least Recently Used,最近最少使用)算法淘汰最近最少使用的 key。这是最常用的策略之一。

**allkeys-lfu:**从所有 key 中,使用 LFU(Least Frequently Used,最不经常使用)算法淘汰使用频率最低的 key。Redis 4.0 新增。

**allkeys-random:**从所有 key 中,随机淘汰一些 key。

**volatile-lru:**从设置了过期时间的 key 中,使用 LRU 算法淘汰最近最少使用的 key。

**volatile-lfu:**从设置了过期时间的 key 中,使用 LFU 算法淘汰使用频率最低的 key。Redis 4.0 新增。

**volatile-random:**从设置了过期时间的 key 中,随机淘汰一些 key。

**volatile-ttl:**从设置了过期时间的 key 中,淘汰最早过期的 key。

策略选择建议:

如果数据都有过期时间,选择 volatile-* 系列策略。

如果数据都没有过期时间,选择 allkeys-* 系列策略。

如果不确定,推荐使用 allkeys-lru,这是最通用的策略,可以保证热点数据留在内存中。

**热点数据问题:**如果数据库中有 1000 万数据,而 Redis 只能缓存 20 万数据,如何保证 Redis 中的数据都是热点数据?答案就是使用 allkeys-lru 淘汰策略,这样最近最少使用的数据会被淘汰,经常访问的热点数据会留在 Redis 中。

4.3 LRU vs LFU

LRU(Least Recently Used):

LRU 的意思是最近最少使用,它是根据 key 的最后一次访问时间来判断的。用当前时间减去最后一次访问时间,这个值越大,则淘汰优先级越高。

LRU 的核心思想是:如果数据最近被访问过,那么将来被访问的几率也更高。

Redis 的 LRU 不是严格的 LRU,而是近似 LRU。Redis 会随机抽取几个 key(默认 5 个),然后从这几个 key 中淘汰最久未使用的那个。这样做是为了节省内存,因为严格的 LRU 需要维护一个链表,每次访问都要更新,开销比较大。

LFU(Least Frequently Used):

LFU 的意思是最不经常使用,它是根据 key 的访问频率来判断的。会统计每个 key 的访问频率,值越小淘汰优先级越高。

LFU 的核心思想是:如果数据被访问的频率越高,那么将来被访问的几率也更高。

LFU 可以解决 LRU 的一些问题,比如某个 key 很久没访问,但偶然访问了一次,LRU 就会认为它是热点数据,而实际上它可能之后再也不会被访问了。LFU 因为统计的是频率,所以不会出现这个问题。

LFU 也有自己的问题,比如新加入的 key 因为访问次数少,很容易被淘汰。所以 LFU 有一个计数器衰减机制,随着时间的推移,计数器会逐渐衰减,避免历史访问次数高的 key 一直留在内存中。

如何选择:

如果访问模式相对稳定,热点数据比较固定,LRU 就够用了。

如果访问频率变化大,有些 key 偶尔访问一次但之后不再访问,LFU 可能更合适。

可以通过 maxmemory-policy 参数配置淘汰策略,通过 maxmemory-samples 参数配置采样数量。

五、缓存三大经典问题

5.1 缓存穿透

什么是缓存穿透:

缓存穿透是指查询一个一定不存在的数据,由于缓存中没有,请求将直接打到数据库,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,失去了缓存的意义。在流量大时,可能会导致数据库挂掉。

这种情况大概率是遭到了攻击,比如攻击者用不存在的 key 来大量请求,故意穿透缓存打到数据库。

解决方案:

方案一:布隆过滤器(Bloom Filter)

布隆过滤器是一种空间效率很高的随机数据结构,它可以用来判断一个元素是否在一个集合中。它的特点是能判断一定不在和可能在。

布隆过滤器的底层原理是:先初始化一个比较大的位数组,里面存放的是二进制 0 或 1,一开始都是 0。当一个 key 来了之后,经过多次 hash 计算,模于数组长度找到数据的下标,然后把数组中原来的 0 改为 1。这样的话,多个数组的位置就能标明一个 key 的存在。查找的过程也是一样的,只要有一个位置是 0,就说明这个 key 一定不存在。

布隆过滤器有一个缺点:有可能会产生误判,就是说某个 key 实际上不存在,但布隆过滤器判断它可能存在。误判率可以通过调整数组长度和 hash 次数来控制,一般可以设置在 5% 以内。虽然有误判,但对于缓存穿透的场景来说已经足够了,因为误判的请求会打到数据库,但数量不会太多,不至于压垮数据库。

在 Java 中,可以使用 Redisson 提供的布隆过滤器实现,使用非常方便。

方案二:缓存空值

当数据库中也查不到数据时,仍然将这个 key 对应的空值缓存起来,设置一个较短的过期时间。这样后续的请求就会直接命中缓存,不会再打到数据库。

这种方案的优点是实现简单,缺点是会占用额外的内存空间,而且如果有大量的不存在的 key,会浪费很多内存。另外,如果之后这个 key 对应的数据被添加了,可能会出现缓存和数据库不一致的问题,所以过期时间不能设置太长。

两种方案对比:

布隆过滤器适合于数据量比较大、数据相对固定、实时性要求不高的场景,比如用户权限校验、黑名单等。

缓存空值适合于数据量比较小、数据变化比较频繁、实时性要求比较高的场景。

5.2 缓存击穿

什么是缓存击穿:

缓存击穿是指对于设置了过期时间的 key,缓存在某个时间点过期的时候,恰好在这个时间点对这个 key 有大量的并发请求过来,这些请求发现缓存过期,一般都会从后端数据库加载数据并回设到缓存。这个时候大并发的请求可能会瞬间把数据库压垮。

缓存击穿和缓存穿透的区别:穿透是查询不存在的数据,击穿是查询存在的数据,只是缓存刚好过期了。

解决方案:

方案一:互斥锁

当缓存失效时,不立即去 load 数据库,而是先使用 Redis 的 SETNX 去设置一个互斥锁。当操作成功返回时,再进行 load 数据库的操作并回设缓存;否则,就重试整个 get 缓存的方法。

这样可以保证同一时刻只有一个请求去查询数据库,其他请求都在等待,避免了大量请求同时打到数据库。

互斥锁方案的优点是数据一致性好,因为只有一个线程去更新缓存,其他线程拿到的都是最新的数据。缺点是性能上会有一些损失,因为其他线程需要等待锁释放。

需要注意的是,锁一定要设置过期时间,防止因为业务异常导致锁没有释放,造成死锁。

方案二:逻辑过期

逻辑过期的思路是:在设置 key 的时候,不设置 Redis 的物理过期时间,而是把过期时间存在 value 里面。当查询的时候,从 Redis 取出数据后判断时间是否过期。

如果没有过期,直接返回数据;如果过期了,则开启一个新的线程去异步更新缓存,当前线程直接返回旧的数据。

逻辑过期方案的优点是性能高,因为不需要等待锁,直接返回数据。缺点是数据一致性差,因为在缓存更新的过程中,其他线程拿到的可能是旧的数据。

两种方案对比:

如果选择数据的强一致性,建议使用互斥锁的方案,性能上可能没那么高,锁需要等,也有可能产生死锁的问题。

如果选择高可用性,性能比较高,建议使用逻辑过期的方案,但是数据一致性差一些。

具体选择哪种方案,要根据业务场景来决定。

5.3 缓存雪崩

什么是缓存雪崩:

缓存雪崩是指设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到数据库,数据库瞬时压力过重雪崩。

缓存雪崩和缓存击穿的区别:击穿是某一个热点 key 缓存过期,雪崩是大量 key 同时过期。

还有一种情况是 Redis 实例宕机了,所有缓存都失效了,请求全部打到数据库,这也属于缓存雪崩的范畴。

解决方案:

方案一:过期时间随机化

将缓存失效时间分散开,比如可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。

这是最简单也是最常用的方案,实现成本很低,效果也不错。

方案二:多级缓存

使用多级缓存架构,比如本地缓存 + Redis 缓存。当 Redis 缓存失效时,还有本地缓存作为兜底,不会直接打到数据库。

多级缓存的优点是可靠性高,即使 Redis 挂了,本地缓存还能顶一阵。缺点是实现复杂,需要维护多级缓存的一致性。

方案三:服务熔断与降级

当数据库的请求量达到一定阈值时,直接熔断,后续请求不再访问数据库,直接返回降级数据。这样可以保护数据库不被打垮。

熔断降级一般是通过 Hystrix、Sentinel 等框架来实现的。

方案四:Redis 高可用集群

搭建 Redis 高可用集群,比如哨兵模式或者分片集群,保证 Redis 不会轻易宕机。即使某个节点挂了,还有其他节点可以继续提供服务。

这是从基础设施层面来解决缓存雪崩问题,是最根本的解决方案。

总结:

缓存穿透、缓存击穿、缓存雪崩是 Redis 缓存最经典的三个问题,也是面试中最常问的问题。一定要理解清楚它们的区别和各自的解决方案。

简单记忆:穿透是查不存在的数据,击穿是一个热点 key 过期,雪崩是大量 key 同时过期或者 Redis 挂了。

六、缓存与数据库一致性

6.1 缓存一致性问题概述

在使用 Redis 作为缓存的架构中,数据存储在数据库中,缓存是数据库的一份副本。当数据发生变化时,需要同时更新缓存和数据库,这就涉及到缓存与数据库的一致性问题。

一致性分为两种:强一致性和最终一致性。强一致性要求任何时刻缓存和数据库的数据都是一致的;最终一致性允许短时间内数据不一致,但最终会达到一致状态。

在实际项目中,大部分业务场景都能接受最终一致性,只有少数对一致性要求极高的场景才需要强一致性。

6.2 常见的缓存更新策略

策略一:先更新数据库,再更新缓存

这种方式的问题是:如果更新数据库成功了,但更新缓存失败了,就会导致缓存和数据库不一致。而且如果有多个线程同时更新,可能会出现线程安全问题,比如线程 A 更新了数据库,线程 B 也更新了数据库,然后线程 B 更新了缓存,线程 A 又更新了缓存,这时候缓存里的数据就是旧的。

另外,更新缓存的成本也比较高,如果一个 key 被频繁更新,缓存也会被频繁更新,但这些更新后的缓存可能根本没有被读取过,造成浪费。所以一般不推荐这种方式。

策略二:先删除缓存,再更新数据库

这种方式的问题是:如果删除缓存成功了,但更新数据库失败了,那么缓存里就没有数据,下次查询的时候会直接打到数据库,然后把旧的数据重新加载到缓存中,导致数据不一致。

还有一个更严重的问题:线程 A 删除了缓存,然后去更新数据库,这时候线程 B 来查询数据,发现缓存没有,就去数据库查询,查到的是旧数据,然后把旧数据写入缓存。之后线程 A 更新完数据库了,这时候缓存里的数据就是旧的,出现了不一致。

这个问题可以通过延时双删来解决。

策略三:先更新数据库,再删除缓存(推荐)

这是最常用的策略,也是 Cache Aside Pattern(旁路缓存模式)推荐的方式。

读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据放入缓存,同时返回响应。

写的时候,先更新数据库,然后再删除缓存。

为什么是删除缓存,而不是更新缓存?因为删除缓存更简单,而且下次读取的时候会自动从数据库加载最新的数据。更新缓存的话,如果更新的是一个复杂的对象,可能需要计算很多东西,而且如果这个缓存之后根本不会被读取,就浪费了。

这种方式也存在不一致的问题:线程 A 读数据的时候,缓存刚好失效了,于是线程 A 去数据库查询,查到了旧数据。这时候线程 B 来写数据,更新了数据库,然后删除了缓存。之后线程 A 把查到的旧数据写入缓存,这时候缓存里的数据就是旧的。

不过这种情况发生的概率比较低,因为读数据库的速度一般比写数据库快,所以线程 A 一般会在线程 B 之前完成。而且缓存有过期时间,即使出现了不一致,过一段时间缓存过期了,下次读取的时候又会从数据库加载最新的数据,最终还是一致的。

6.3 延时双删

延时双删是针对先删除缓存,再更新数据库这种策略的优化方案,用来解决并发读写导致的不一致问题。

延时双删的步骤:

  1. 先删除缓存

  2. 再更新数据库

  3. 休眠一段时间(比如 1 秒)

  4. 再次删除缓存

这样做的目的是:在休眠的这段时间里,那些在删除缓存和更新数据库之间读到旧数据并写入缓存的请求,会在第二次删除缓存的时候被清掉。

延时双删的问题:

延时时间不好确定,需要根据业务的读数据耗时来估算,一般设置为几百毫秒到 1 秒。如果延时时间太短,可能还没等读请求写完缓存就删除了,起不到作用;如果延时时间太长,又会导致不一致的时间变长。

而且延时双删也不能 100% 保证一致性,只是降低了不一致的概率。如果对一致性要求很高,还是需要其他方案。

6.4 读写锁方案(强一致性)

如果业务要求强一致性,可以使用读写锁的方案。读写锁分为读锁和写锁:

**读锁(共享锁):**多个线程可以同时持有读锁,读读不互斥,但读写互斥。

**写锁(排他锁):**同一时刻只能有一个线程持有写锁,读写、写写都互斥。

读数据的时候加读锁,写数据的时候加写锁。这样就能保证在写数据的时候,不会有其他线程读数据,避免了脏读。

在 Java 中,可以使用 Redisson 提供的读写锁(RReadWriteLock)来实现。Redisson 的读写锁底层也是基于 Redis 实现的,使用起来非常方便。

读写锁方案的优点是可以保证强一致性,缺点是性能会有一定的损失,因为读和写是互斥的,高并发下会影响吞吐量。

6.5 Canal 异步同步方案(最终一致性)

Canal 是阿里开源的一个项目,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费。

工作原理:

Canal 把自己伪装成 MySQL 的一个从节点,向 MySQL 发送 dump 协议,请求 binlog。MySQL 收到请求后,会把 binlog 推送给 Canal。Canal 解析 binlog 之后,就可以获取到数据的变更,然后通过 Canal 客户端消费这些变更,更新缓存。

优点:

不需要修改业务代码,对业务代码无侵入。

性能好,异步更新缓存,不影响主流程。

可以保证最终一致性,因为 binlog 是有序的,Canal 会按照顺序消费。

缺点:

需要额外部署 Canal 服务,增加了运维成本。

是异步的,有一定的延迟,不能保证强一致性。

这种方案适合数据同步可以有一定延时、业务量大、对一致性要求不是特别高的场景,是目前大厂比较常用的方案。

6.6 方案对比与选择

**强一致性场景:**如果业务要求数据必须强一致,比如金融、支付等场景,可以使用读写锁方案。但要注意性能影响,评估是否能接受。

**最终一致性场景:**大部分业务场景都属于这种,可以使用先更新数据库,再删除缓存的方案,配合缓存过期时间,基本能满足需求。如果对一致性要求再高一些,可以加上消息队列或者 Canal 来做异步补偿。

**高并发大流量场景:**推荐使用 Canal 异步同步方案,对业务代码无侵入,性能好,运维成本也可控。

总之,没有完美的方案,只有最适合业务场景的方案。在实际项目中,需要根据业务的一致性要求、性能要求、运维成本等因素综合考虑,选择最合适的方案。

七、Redis 分布式锁

7.1 分布式锁概述

在分布式系统中,不同的服务或者同一个服务的不同实例部署在不同的机器上,这时候 Java 自带的 synchronized 和 ReentrantLock 就失效了,因为它们只能保证同一个 JVM 内的线程安全。这时候就需要使用分布式锁来保证多个服务实例之间的互斥访问。

分布式锁需要满足以下几个条件:

**互斥性。**同一时刻只能有一个客户端持有锁。

**不会发生死锁。**即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。

**解铃还须系铃人。**加锁和解锁必须是同一个客户端。

**容错性。**只要大部分 Redis 节点正常运行,客户端就可以加锁和解锁。

7.2 SETNX 实现分布式锁

Redis 实现分布式锁最基础的方式是使用 SETNX 命令。SETNX 是 SET if Not eXists 的缩写,意思是如果 key 不存在才设置,存在的话就不做任何操作。

基本实现:

加锁:SETNX lock_key unique_value,如果返回 1 表示加锁成功,返回 0 表示加锁失败。

解锁:DEL lock_key,删除锁。

存在的问题:

**问题一:没有过期时间。**如果加锁的客户端崩溃了,锁就永远不会被释放,造成死锁。

解决方案:给锁设置一个过期时间,比如 EXPIRE lock_key 30,这样即使客户端崩溃了,过了 30 秒锁也会自动释放。

但是 SETNX 和 EXPIRE 是两个命令,不是原子操作。如果 SETNX 执行成功了,还没来得及执行 EXPIRE,客户端就崩溃了,还是会造成死锁。

Redis 2.6.12 之后,SET 命令增加了 NX 和 EX 参数,可以原子地执行 SETNX + EXPIRE 的操作:

SET lock_key unique_value NX EX 30

这样就解决了原子性的问题。

**问题二:锁被其他客户端误删。**如果客户端 A 加了锁,然后因为业务执行时间太长,锁过期了,这时候客户端 B 加了锁。然后客户端 A 业务执行完了,执行 DEL 命令删除锁,这时候就把客户端 B 的锁给删掉了。

解决方案:加锁的时候设置一个唯一的 value,比如 UUID + 线程 ID。解锁的时候先判断 value 是不是自己的,如果是再删除。

但是 GET 和 DEL 也是两个命令,不是原子操作。可以使用 Lua 脚本来保证原子性:

if redis.call(get,KEYS1) == ARGV1 then return redis.call(del,KEYS1)else return 0end

这样就能保证只有锁的持有者才能删除锁。

7.3 Redisson 分布式锁

Redisson 是一个基于 Redis 的 Java 驻内存数据网格(In-Memory Data Grid),它提供了很多分布式工具,其中就包括分布式锁的实现。Redisson 的分布式锁比我们自己实现的要完善得多,解决了很多问题。

看门狗机制:

Redisson 提供了看门狗(Watchdog)机制,用来解决锁过期时间不好设置的问题。

如果加锁的业务执行时间比锁的过期时间长,就会出现锁提前过期的问题。如果把过期时间设置得很长,又会影响性能。

看门狗机制的原理是:如果客户端加锁成功,会启动一个后台线程,每隔一段时间(默认是锁过期时间的 1/3)就去检查一下,如果客户端还持有锁,就自动延长锁的过期时间。这样就不用担心锁提前过期了。

默认情况下,看门狗的锁过期时间是 30 秒,每 10 秒续期一次。

可重入性:

Redisson 的分布式锁是可重入的,也就是说同一个线程可以多次加锁。

它的底层是用 Hash 结构实现的,Hash 的 key 是锁的名称,field 是客户端 ID + 线程 ID,value 是重入次数。

每次加锁的时候,重入次数加 1;每次解锁的时候,重入次数减 1。当重入次数减到 0 的时候,才真正删除锁。

自旋等待:

Redisson 的锁还支持自旋等待,如果加锁失败,会等待一段时间后重试,而不是直接返回失败。等待时间可以自己设置。

自旋等待的底层是基于 Redis 的发布订阅机制实现的,当锁被释放的时候,会发布一个消息,等待的客户端收到消息后就会去尝试加锁。这样比单纯的轮询效率更高。

7.4 主从一致性问题

上面说的分布式锁都是基于单节点 Redis 的,如果 Redis 是主从架构,就会出现主从一致性问题。

比如客户端 A 在 master 上加锁成功了,这时候 master 还没来得及把锁同步给 slave,master 就宕机了。然后 slave 被提升为新的 master,这时候新的 master 上没有锁的数据,客户端 B 就可以加锁成功。这样就有两个客户端同时持有锁了,违反了互斥性。

这个问题的本质是 Redis 的主从复制是异步的,所以会有数据不一致的窗口。

7.5 红锁(RedLock)

为了解决主从一致性问题,Redis 的作者 Antirez 提出了红锁(RedLock)算法。

红锁的原理:

假设有 N 个独立的 Redis 节点,这些节点之间完全独立,没有主从关系。加锁的时候,客户端会向所有 N 个节点发送加锁请求。如果在超过半数(N/2 + 1)的节点上加锁成功,并且总耗时小于锁的有效时间,就认为加锁成功;否则认为加锁失败,需要向所有节点发送解锁请求。

解锁的时候,需要向所有 N 个节点发送解锁请求。

红锁的设计思路是:即使有少数节点宕机了,只要大部分节点还在正常工作,锁就不会出问题。因为需要超过半数的节点加锁成功才算成功,所以即使有少数节点挂了,也不会影响。

红锁的问题:

红锁虽然理论上解决了主从一致性问题,但在实际中用得并不多,主要有以下几个原因:

**性能低。**需要向多个节点发送请求,加锁和解锁的开销都比较大。

**运维成本高。**需要维护多个独立的 Redis 节点,运维成本比主从架构高很多。

**存在争议。**红锁算法本身也存在一些争议,比如分布式系统中的时钟漂移问题,可能会导致锁的过期时间不准确。

而且 Redis 官方现在也不推荐使用红锁了,Redisson 也把 RedissonRedLock 标记为废弃了。

7.6 分布式锁方案对比

除了 Redis,还有其他几种常见的分布式锁实现方式:

**基于数据库的分布式锁:**可以使用数据库的悲观锁(SELECT ... FOR UPDATE)或者乐观锁(版本号)来实现。优点是实现简单,缺点是性能差,不适合高并发场景。

**基于 ZooKeeper 的分布式锁:**利用 ZooKeeper 的临时顺序节点来实现。优点是可以保证强一致性,支持锁的自动释放和等待队列;缺点是性能不如 Redis,需要额外维护 ZooKeeper 集群。

**基于 Redis 的分布式锁:**优点是性能高,实现简单;缺点是可能存在一致性问题,需要自己处理很多异常情况。

如何选择:

如果对一致性要求极高,不能容忍任何错误,建议使用 ZooKeeper 的分布式锁。

如果追求高性能,能容忍极少数情况下的不一致,建议使用 Redis 的分布式锁。大部分业务场景下,Redis 分布式锁已经足够用了。

在实际项目中,推荐使用 Redisson 提供的分布式锁,它已经帮我们处理了很多细节问题,比如看门狗、可重入、自旋等待等,使用起来非常方便。

八、Redis 高可用集群方案

8.1 主从复制

主从复制是 Redis 最基础的集群方案,它将一台 Redis 服务器的数据复制到其他 Redis 服务器上。前者称为主节点(master),后者称为从节点(slave)。数据的复制是单向的,只能由主节点到从节点。

主从复制的作用:

**数据冗余。**主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。

**故障恢复。**当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复。

**负载均衡。**在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务,分担服务器负载。尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高 Redis 服务器的并发量。

**高可用基石。**主从复制是哨兵模式和集群能够实施的基础,因此主从复制是 Redis 高可用的基础。

全量同步:

全量同步一般发生在从节点初始化阶段,这时候从节点需要把主节点上的所有数据都复制一份。

全量同步的流程:

  1. 从节点连接主节点,发送 SYNC 命令。

  2. 主节点收到 SYNC 命令后,开始执行 BGSAVE 命令生成 RDB 文件,同时将新收到的写命令记录在缓冲区中。

  3. 主节点的 BGSAVE 执行完成后,向所有从节点发送 RDB 文件。

  4. 从节点收到 RDB 文件后,丢弃所有旧数据,载入 RDB 文件。

  5. 主节点的 RDB 文件发送完毕后,将缓冲区中的写命令发送给从节点。

  6. 从节点完成 RDB 文件载入后,开始接收并执行缓冲区中的写命令,完成数据同步。

增量同步:

增量同步是指从节点在初始化完成后,主节点有新的写命令时,将写命令同步给从节点的过程。

主节点每执行一个写命令,就会将相同的写命令发送给从节点,从节点接收并执行,从而保证主从数据的一致性。

Redis 2.8 之后引入了部分重同步的功能,当从节点断线重连时,如果条件允许,主节点可以只把断线期间的写命令同步给从节点,而不需要全量同步。

部分重同步依赖于复制偏移量(offset)和复制积压缓冲区(replication backlog)。主节点和从节点都会维护一个复制偏移量,主节点每次向从节点传播 N 个字节的数据时,就将自己的复制偏移量加上 N;从节点每次收到主节点传来的 N 个字节的数据时,也将自己的复制偏移量加上 N。通过对比主从的复制偏移量,就可以判断主从数据是否一致。

优缺点:

优点:实现简单,配置方便,能够实现读写分离,提高读性能。

缺点:不具备自动故障转移能力,主节点宕机后需要手动切换,恢复时间长。

8.2 哨兵模式(Sentinel)

哨兵模式是在主从复制的基础上,增加了哨兵节点来实现自动故障转移。哨兵(Sentinel)是一个独立的进程,它会持续监控主节点和从节点的健康状态。当主节点出现故障时,哨兵会自动从从节点中选举出一个新的主节点,并将其他从节点指向新的主节点。

哨兵的功能:

**监控。**哨兵会不断地检查你的主服务器和从服务器是否运作正常。

**通知。**当被监控的某个 Redis 服务器出现问题时,哨兵可以通过 API 向管理员或者其他应用程序发送通知。

**自动故障转移。**当一个主服务器不能正常工作时,哨兵会开始一次自动故障转移操作,它会将失效主服务器的其中一个从服务器升级为新的主服务器,并让失效主服务器的其他从服务器改为复制新的主服务器。

故障转移流程:

  1. 每个哨兵每秒向主节点、从节点和其他哨兵发送 PING 命令。

  2. 如果一个实例距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值,那么这个实例会被哨兵标记为主观下线。

  3. 如果一个主节点被标记为主观下线,那么所有正在监控这个主节点的哨兵,会以每秒一次的频率确认主节点的确进入了主观下线状态。

  4. 当有足够数量的哨兵(大于等于配置文件指定的值)在指定的时间范围内确认主节点进入了主观下线状态,那么主节点会被标记为客观下线。

  5. 哨兵之间会进行投票,选举出一个领头哨兵,由领头哨兵来执行故障转移操作。

  6. 领头哨兵从从节点中选举出一个新的主节点。选举规则:优先选择优先级最高的从节点;如果优先级相同,选择复制偏移量最大的从节点(复制的数据最多);如果偏移量也相同,选择 run_id 最小的从节点。

  7. 领头哨兵向新的主节点发送 SLAVEOF NO ONE 命令,将其提升为主节点。

  8. 领头哨兵向其他所有从节点发送 SLAVEOF 命令,让它们复制新的主节点。

  9. 领头哨兵将原来的主节点设置为新的主节点的从节点,当它恢复后,自动成为新主节点的从节点。

优缺点:

优点:具备自动故障转移能力,提高了可用性;实现了读写分离,提高了读性能。

缺点:写操作还是只能在主节点上,写性能和存储容量受限于单台主节点;扩容困难,不能水平扩展。

8.3 分片集群(Cluster)

Redis Cluster 是 Redis 3.0 推出的分布式集群方案,它将数据分散存储在多个主节点上,解决了单节点存储容量和写性能的瓶颈。

哈希槽(Hash Slot):

Redis Cluster 中有 16384 个哈希槽,每个 key 通过 CRC16 算法计算后对 16384 取模,得到对应的哈希槽,然后这个 key 就存储在负责这个哈希槽的节点上。

哈希槽的数量是固定的 16384 个,集群中的每个主节点负责一部分哈希槽。比如有 3 个主节点,那么节点 A 负责 0-5460 号槽,节点 B 负责 5461-10922 号槽,节点 C 负责 10923-16383 号槽。

使用哈希槽而不是直接用 key 取模的好处是:当增加或减少节点时,只需要移动部分哈希槽的数据,不需要重新计算所有 key 的位置,扩容和缩容更加方便。

集群的节点:

Redis Cluster 中有多个主节点,每个主节点可以有多个从节点。主节点负责处理客户端的请求和存储数据,从节点负责复制主节点的数据,在主节点宕机时顶替主节点。

集群中的每个节点都会定期向其他节点发送 PING 消息,检测对方是否存活。如果一个节点在指定时间内没有回复 PONG 消息,就会被标记为疑似下线(PFAIL)。

如果集群中超过半数的主节点都将某个主节点标记为疑似下线,那么这个主节点就会被标记为已下线(FAIL),然后集群会进行故障转移,从它的从节点中选举出一个新的主节点。

客户端访问:

客户端可以连接集群中的任意一个节点,发送命令。如果命令对应的 key 所在的哈希槽正好是这个节点负责的,那么节点直接处理命令并返回结果。

如果 key 所在的哈希槽不是这个节点负责的,节点会返回一个 MOVED 重定向响应,告诉客户端这个 key 应该去哪个节点访问。客户端收到 MOVED 响应后,会自动连接到正确的节点重新发送命令。

智能客户端(比如 Jedis Cluster)会在本地缓存哈希槽和节点的映射关系,这样大部分请求都可以直接发送到正确的节点,不需要重定向,提高了效率。

优缺点:

优点:可以水平扩展,存储容量和读写性能都可以通过增加节点来提升;具备自动故障转移能力,高可用性好。

缺点:实现复杂,运维成本高;不支持多键操作(比如 MSET、MGET),因为多个 key 可能在不同的节点上;不支持事务。

8.4 三种方案对比

**主从复制:**适合数据量不大、读多写少、对可用性要求不高的场景。优点是简单易用,缺点是没有自动故障转移,写性能和容量受限于单节点。

**哨兵模式:**适合数据量不大、读多写少、对可用性要求较高的场景。优点是有自动故障转移,可用性高;缺点是写性能和容量还是受限于单节点,不能水平扩展。

**分片集群:**适合数据量大、写操作多、需要水平扩展的场景。优点是可以水平扩展,容量和性能都可以线性提升;缺点是实现复杂,运维成本高,有一些功能限制。

8.5 脑裂问题

什么是脑裂:

脑裂是指在集群中,因为网络问题,导致节点之间无法通信,集群分裂成两个独立的子集群。两个子集群都认为自己是正常的,都在对外提供服务,这时候就会出现数据不一致的问题。

在 Redis 哨兵模式中,脑裂问题是这样的:假设原来有一个 master 和一个 slave,还有三个哨兵。因为网络分区,master 和哨兵、slave 不在同一个网络分区里。哨兵那边检测不到 master,就会选举 slave 为新的 master。这时候就有两个 master 了,客户端如果还能连接到旧的 master,就会继续往旧的 master 写数据。等网络恢复后,旧的 master 会变成新的 master 的 slave,之前写的数据就会丢失。

解决方案:

Redis 提供了两个配置参数来解决脑裂问题:

**min-slaves-to-write:**主节点必须至少有 N 个从节点连接着,才能执行写操作。如果从节点数量少于 N,主节点就会拒绝写操作。

**min-slaves-max-lag:**主节点和从节点之间的复制延迟不能超过 N 秒。如果超过了,主节点就会拒绝写操作。

这两个参数配合使用,可以保证主节点在从节点数量不足或者复制延迟太大的时候,停止接收写请求,这样即使发生脑裂,旧的 master 也不会继续接收写请求,从而避免数据丢失。

一般建议 min-slaves-to-write 设置为 1,min-slaves-max-lag 设置为 10 秒。这样的话,主节点至少要有一个从节点,并且复制延迟不超过 10 秒,才能写数据。

九、Redis 性能优化

9.1 键值设计优化

**避免大 key。**大 key 是指 value 很大的 key,比如一个 String 类型的 value 有几 MB,或者一个 Hash/List/Set/ZSet 有几十万、上百万个元素。大 key 会导致很多问题:

  1. 读取大 key 会消耗大量网络带宽,导致响应变慢。

  2. 删除大 key 会阻塞主线程,因为删除操作是在主线程中执行的。

  3. 大 key 在内存淘汰时,也会因为删除慢而影响性能。

解决方法:将大 key 拆分成多个小 key。比如一个大的 Hash 可以拆分成多个小的 Hash,或者用 Hash Tag 保证它们在同一个分片上。

**合理设置过期时间。**不要把所有 key 的过期时间都设置成一样的,这样容易导致缓存雪崩。可以在过期时间基础上增加一个随机值,分散过期时间。

**key 命名规范。**key 的名字要简洁明了,不要太长,因为 key 本身也会占用内存。一般建议用业务名:模块名:id 的格式,比如 user:info:123。

9.2 命令使用优化

**避免使用 KEYS * 命令。**KEYS 命令会遍历所有 key,时间复杂度是 O(n),当 key 数量很多的时候,会严重影响性能。生产环境禁止使用 KEYS 命令。如果需要查找 key,可以使用 SCAN 命令,它是渐进式遍历的,不会阻塞主线程。

**使用批量操作。**批量操作可以减少网络往返次数,提高效率。比如 MSET、MGET 代替多次 SET、GET;HMSET、HMGET 代替多次 HSET、HGET。但要注意批量操作的数量不要太多,一般建议每次不超过 100 个,否则也会因为一次处理太多数据而阻塞主线程。

**合理使用 Pipeline。**Pipeline 可以将多个命令打包一次性发送给 Redis,然后一次性接收所有结果,大大减少了网络往返次数。对于那些没有依赖关系的命令,可以用 Pipeline 来提高性能。

**避免使用复杂度过高的命令。**比如 SORT、SUNION、ZUNIONSTORE 等,这些命令的时间复杂度比较高,会消耗大量 CPU。如果需要使用,可以考虑在客户端做,或者用其他方式优化。

9.3 内存优化

**选择合适的数据结构。**不同的数据结构占用的内存不一样,要根据业务场景选择最合适的数据结构。比如能用 Hash 就不用 String,能用 ZipList 就不用 HashTable。

**利用压缩列表。**当数据量比较小的时候,Redis 会使用压缩列表(ziplist)来存储数据,压缩列表占用的内存比哈希表少很多。可以通过调整配置参数来控制压缩列表的使用条件,比如 hash-max-ziplist-entries、hash-max-ziplist-value 等。

**使用共享对象。**Redis 会对一些小的整数对象进行共享,比如 0-9999 的整数。如果你的业务中有很多相同的整数值,可以利用这个特性来节省内存。

**合理设置内存淘汰策略。**根据业务场景选择合适的内存淘汰策略,比如大部分场景推荐使用 allkeys-lru,保证热点数据留在内存中。

9.4 持久化优化

**合理选择持久化方式。**如果对数据安全性要求不高,可以只使用 RDB;如果对数据安全性要求高,推荐使用 AOF + 混合持久化。

**配置合适的 AOF 刷盘策略。**一般推荐使用 everysec,每秒刷盘一次,在性能和安全性之间做平衡。

**避免在高峰期做持久化。**RDB 的 fork 和 AOF 重写都会消耗大量 CPU 和内存,尽量在业务低峰期执行。

**关闭不需要的持久化。**如果 Redis 只是用来做缓存,数据丢失了也没关系,可以关闭持久化,提高性能。

9.5 网络优化

**使用连接池。**避免频繁创建和销毁连接,使用连接池复用连接,减少连接建立的开销。

**使用长连接。**Redis 是 TCP 协议,建立连接需要三次握手,断开需要四次挥手,开销比较大。尽量使用长连接,不要每次操作都新建连接。

**减少网络往返。**使用批量操作和 Pipeline 减少网络往返次数,提高效率。

**客户端和 Redis 部署在同一机房。**减少网络延迟,提高响应速度。

9.6 架构优化

**读写分离。**对于读多写少的场景,可以使用主从架构,主节点负责写,从节点负责读,分担读压力。

**分片集群。**当数据量很大,单节点存不下的时候,使用分片集群,将数据分散到多个节点上,提高存储容量和读写性能。

**本地缓存。**对于热点数据,可以在应用层加一层本地缓存(比如 Caffeine、Guava Cache),先查本地缓存,没有再查 Redis,减少 Redis 的压力。

**连接数优化。**合理设置 Redis 的最大连接数,避免连接数过多导致 Redis 性能下降。同时客户端也要设置合理的连接池大小。

9.7 常见性能问题排查

**使用 INFO 命令查看 Redis 状态。**INFO 命令可以查看 Redis 的各种统计信息,比如内存使用、连接数、QPS、命中 率等,是排查性能问题的首选命令。

**使用 SLOWLOG 查看慢查询。**Redis 会记录执行时间超过阈值的命令,通过 SLOWLOG 可以查看哪些命令执行慢,然后针对性优化。

**使用 MONITOR 命令查看命令执行情况。**MONITOR 命令可以实时打印 Redis 执行的所有命令,帮助排查问题。但注意不要在生产环境长时间使用,因为 MONITOR 本身也会影响性能。

**使用 redis-benchmark 做压测。**redis-benchmark 是 Redis 自带的压测工具,可以用来测试 Redis 的性能,评估系统的承载能力。

十、高频面试题汇总

10.1 基础篇

1. Redis 为什么这么快?

答:主要有四个原因:

  1. 完全基于内存,数据都存在内存中,读写速度非常快。

  2. 用 C 语言实现,执行效率高。

  3. 单线程模型,避免了多线程的上下文切换和锁竞争的开销。

  4. 使用 IO 多路复用模型,可以处理大量并发连接。

2. Redis 是单线程的吗?

答:Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。所以严格来说 Redis 不是单线程的,只是说它的核心网络模型是单线程的。

3. Redis 和 Memcached 有什么区别?

答:主要有以下几点区别:

  1. 数据结构:Redis 支持丰富的数据结构,比如 String、Hash、List、Set、ZSet 等;Memcached 只支持简单的 key-value。

  2. 持久化:Redis 支持 RDB 和 AOF 两种持久化方式;Memcached 不支持持久化,重启后数据就没了。

  3. 线程模型:Redis 是单线程模型;Memcached 是多线程模型。

  4. 内存管理:Redis 有自己的内存管理机制,支持多种内存淘汰策略;Memcached 使用 Slab Allocation 内存管理。

  5. 集群支持:Redis 原生支持集群;Memcached 本身不支持集群,需要客户端来实现分布式。

4. Redis 有哪些数据结构?分别有什么应用场景?

答:Redis 有 5 种基础数据结构:

  1. String:最基础的类型,二进制安全。可以用来缓存对象、计数器、分布式锁、共享 Session 等。

  2. Hash:键值对集合,适合存储对象。可以用来存储用户信息、商品信息、购物车等。

  3. List:有序列表,可以从两端操作。可以用来做消息队列、文章列表、评论列表等。

  4. Set:无序集合,元素不重复。可以用来做去重、共同好友、抽奖、标签系统等。

  5. ZSet:有序集合,每个元素有一个 score。可以用来做排行榜、带权重的任务队列、范围查找等。

10.2 持久化篇

5. Redis 的持久化方式有哪些?有什么区别?

答:Redis 有两种持久化方式:RDB 和 AOF。

RDB 是快照持久化,它会在某个时间点将内存中的数据生成一个快照保存到磁盘上。优点是文件紧凑,恢复速度快,对性能影响小;缺点是数据安全性差,可能会丢失最后一次快照之后的数据,而且 fork 子进程的时候会消耗大量内存。

AOF 是追加文件持久化,它会将每一个写命令都记录到 AOF 文件中。优点是数据安全性高,最多只会丢失 1 秒的数据;缺点是文件体积大,恢复速度慢,AOF 重写的时候会有性能开销。

Redis 4.0 之后引入了混合持久化,结合了 RDB 和 AOF 的优点,AOF 重写的时候会先把数据以 RDB 的格式写入 AOF 文件开头,后面再跟增量的 AOF 命令。这样既保证了恢复速度,又保证了数据安全性。

6. RDB 和 AOF 怎么选?

答:如果对数据安全性要求不高,能容忍少量数据丢失,可以只使用 RDB。

如果对数据安全性要求高,推荐使用 AOF + 混合持久化。

一般生产环境建议两种都开,这样即使 RDB 出问题了,还有 AOF 可以恢复;而且 RDB 也可以用来做冷备份。

10.3 缓存问题篇

7. 什么是缓存穿透?怎么解决?

答:缓存穿透是指查询一个数据库中不存在的数据,导致每次请求都打到数据库上,失去了缓存的意义。如果有人恶意用不存在的 key 来攻击,就会给数据库造成很大压力。

解决方案:

  1. **布隆过滤器:**将所有可能存在的数据哈希到一个足够大的 bitmap 中,查询的时候先过布隆过滤器,如果不存在就直接返回,不用查数据库。

  2. **缓存空值:**如果数据库查询不到,也把这个空结果缓存起来,设置一个较短的过期时间。这样下次再查这个 key 的时候就直接返回空,不会打到数据库上。

8. 什么是缓存击穿?怎么解决?

答:缓存击穿是指一个热点 key,在缓存过期的瞬间,有大量并发请求过来,这些请求都打到数据库上,导致数据库压力骤增。

解决方案:

  1. **互斥锁:**缓存失效的时候,不是所有人都去查数据库,而是先拿锁,拿到锁的线程去查数据库并更新缓存,其他线程等待。这样可以保证只有一个请求打到数据库上。

  2. **逻辑过期:**不设置真正的过期时间,而是把过期时间存在 value 里。当发现过期了,就异步去更新缓存,当前请求还是返回旧数据。这样不会有线程等待,性能更好,但是数据一致性差一点。

9. 什么是缓存雪崩?怎么解决?

答:缓存雪崩是指大量的 key 在同一时间过期,或者 Redis 宕机了,导致所有请求都打到数据库上,给数据库造成巨大压力,甚至把数据库打挂。

解决方案:

  1. **过期时间随机化:**给 key 的过期时间加上一个随机值,避免大量 key 同时过期。

  2. **多级缓存:**本地缓存 + Redis 缓存,本地缓存也能挡一部分请求。

  3. 服务熔断与降级:如果数据库压力太大,可以触发熔断,直接返回降级结果,保护数据库。

  4. Redis 高可用:搭建 Redis 集群,保证 Redis 不会轻易挂掉。

10. 缓存和数据库双写一致性问题怎么解决?

答:最常用的方案是 Cache Aside Pattern,也就是先更新数据库,再删除缓存。这种方案出现不一致的概率很低,而且实现简单。

如果想要更可靠一点,可以用延时双删:先删缓存,再更新数据库,然后延时一段时间再删一次缓存。但是延时时间不好确定。

如果想要强一致性,可以用读写锁,读的时候加共享锁,写的时候加排他锁。但是这样会影响性能。

如果对一致性要求很高,又有大量的读写操作,可以用 Canal 监听 MySQL 的 binlog,异步更新缓存。这样可以保证最终一致性。

10.4 分布式锁篇

11. Redis 分布式锁怎么实现?有什么问题?

答:最基础的实现是用 SETNX 命令,加锁的时候设置一个唯一的 value,解锁的时候用 Lua 脚本判断 value 是不是自己的,是就删除。还要设置过期时间,防止死锁。

存在的问题:

  1. 锁过期时间不好设置:设置短了业务还没执行完锁就过期了;设置长了如果客户端挂了,锁要等很久才释放。

  2. 主从一致性问题:如果 master 上加锁成功了,还没同步给 slave,master 就挂了,slave 变成新的 master,这时候另一个客户端也能加锁成功,就出现了两把锁。

  3. 不可重入:同一个线程不能多次加锁。

这些问题 Redisson 都解决了,它有看门狗机制自动续期,支持可重入,还有自旋等待等功能。生产环境推荐用 Redisson。

12. 什么是红锁?有什么问题?

答:红锁是为了解决主从一致性问题提出的算法。它假设有 N 个独立的 Redis 节点,加锁的时候向所有节点发送加锁请求,如果超过半数加锁成功,并且总耗时小于锁的有效时间,就算加锁成功。

红锁的问题:性能低,运维成本高,而且算法本身也有争议,比如时钟漂移的问题。现在 Redis 官方也不推荐用红锁了,Redisson 也把红锁标记为废弃了。

10.5 集群篇

13. Redis 有哪些集群方案?

答:主要有三种:

  1. **主从复制:**一主多从,主负责写,从负责读。优点是简单,缺点是没有自动故障转移,容量受限于单节点。

  2. **哨兵模式:**在主从的基础上加了哨兵节点,监控主从的健康状态,主节点挂了自动选举新的主节点。优点是高可用,缺点是容量还是受限于单节点。

  3. **分片集群:**数据分散存储在多个主节点上,每个主节点有自己的从节点。优点是可以水平扩展,容量和性能都可以线性提升;缺点是实现复杂,不支持多键操作和事务。

14. Redis Cluster 的哈希槽是怎么回事?

答:Redis Cluster 中有 16384 个哈希槽,每个 key 通过 CRC16 算法计算后对 16384 取模,得到对应的哈希槽,然后这个 key 就存在负责这个哈希槽的节点上。

用哈希槽而不是直接用 key 取模的好处是,扩容缩容的时候只需要移动部分哈希槽的数据,不需要重新计算所有 key 的位置,更加灵活。

15. 什么是脑裂?怎么解决?

答:脑裂是指因为网络分区,集群分裂成两个独立的子集群,两边都认为自己是正常的,都在对外提供服务,导致数据不一致。

在 Redis 中,可以用 min-slaves-to-write 和 min-slaves-max-lag 这两个配置来解决。min-slaves-to-write 要求主节点至少有 N 个从节点连接着才能写;min-slaves-max-lag 要求复制延迟不能超过 N 秒。这样即使发生脑裂,旧的主节点因为没有从节点连接,就会停止接收写请求,避免数据丢失。

10.6 性能优化篇

16. Redis 有哪些性能优化的方法?

答:可以从几个方面来优化:

  1. 键值设计:避免大 key,合理设置过期时间,key 命名简洁。

  2. 命令使用:避免 KEYS * 这种慢命令,使用批量操作和 Pipeline 减少网络往返,避免复杂度过高的命令。

  3. 内存优化:选择合适的数据结构,利用压缩列表节省内存,合理设置淘汰策略。

  4. 持久化优化:合理选择持久化方式,避免在高峰期做持久化,不需要的话就关闭持久化。

  5. 网络优化:使用连接池和长连接,客户端和 Redis 部署在同一机房。

  6. 架构优化:读写分离分担读压力,分片集群水平扩展,本地缓存挡热点数据。

17. 怎么排查 Redis 的性能问题?

答:常用的方法有:

  1. 用 INFO 命令查看 Redis 的整体状态,比如内存、连接数、QPS、命中率等。

  2. 用 SLOWLOG 查看慢查询,找出执行慢的命令。

  3. 用 MONITOR 命令实时查看执行的命令,排查问题。

  4. 用 redis-benchmark 做压测,评估性能。

相关推荐
Ming_studying1 小时前
Python + SQLite FTS5 构建本地文档全文搜索器:增量索引、中文检索与高亮
jvm·数据库·python·sqlite
云和恩墨2 小时前
从静态备份到极速恢复:zData S重构多元数据库时代的“第二存储”底座
数据库·重构
吴声子夜歌2 小时前
MongoDB 8.0——存储
数据库·mongodb
网教盟人才服务平台2 小时前
Redis Key集中过期引发的流量雪崩实战解析
数据库·redis·缓存
AI大模型-小华2 小时前
ChatGPT充值后Codex误改数据库怎么办?用迁移审查避免数据丢失
数据库·chatgpt·codex·chatgpt plus·chatgpt pro·chatgpt充值
吴声子夜歌3 小时前
MongoDB 8.0——索引
数据库·mongodb
KaifuZeng3 小时前
电源面试问题汇总三
单片机·嵌入式硬件·面试·电路
影寂ldy3 小时前
WinForm 完整版分页查询(多条件搜索+页码切换+每页条数切换)
数据库
Linux运维技术栈3 小时前
业务不停机、数据零丢失:Redis / RabbitMQ / Elasticsearch 三大核心中间件升级改造集群无缝平滑迁移
redis·elasticsearch·rabbitmq