一、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 文件时,服务器会执行以下操作:
-
Redis 调用 fork(),同时拥有父进程和子进程。
-
子进程将数据集写入到一个临时 RDB 文件中。
-
当子进程完成对新 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 持久化的过程分为三步:命令追加、文件写入、文件同步。
-
命令追加:当 AOF 持久化功能打开时,Redis 在执行完一个写命令之后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf 缓冲区的末尾。
-
文件写入和同步: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,以及要检查多少个数据库,则由算法决定。
定期删除策略是前两种策略的一种整合和折中:
-
定期删除策略每隔一段时间执行一次删除过期 key 操作,并通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
-
除此之外,通过定期删除过期 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 秒)
-
再次删除缓存
这样做的目的是:在休眠的这段时间里,那些在删除缓存和更新数据库之间读到旧数据并写入缓存的请求,会在第二次删除缓存的时候被清掉。
延时双删的问题:
延时时间不好确定,需要根据业务的读数据耗时来估算,一般设置为几百毫秒到 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 高可用的基础。
全量同步:
全量同步一般发生在从节点初始化阶段,这时候从节点需要把主节点上的所有数据都复制一份。
全量同步的流程:
-
从节点连接主节点,发送 SYNC 命令。
-
主节点收到 SYNC 命令后,开始执行 BGSAVE 命令生成 RDB 文件,同时将新收到的写命令记录在缓冲区中。
-
主节点的 BGSAVE 执行完成后,向所有从节点发送 RDB 文件。
-
从节点收到 RDB 文件后,丢弃所有旧数据,载入 RDB 文件。
-
主节点的 RDB 文件发送完毕后,将缓冲区中的写命令发送给从节点。
-
从节点完成 RDB 文件载入后,开始接收并执行缓冲区中的写命令,完成数据同步。
增量同步:
增量同步是指从节点在初始化完成后,主节点有新的写命令时,将写命令同步给从节点的过程。
主节点每执行一个写命令,就会将相同的写命令发送给从节点,从节点接收并执行,从而保证主从数据的一致性。
Redis 2.8 之后引入了部分重同步的功能,当从节点断线重连时,如果条件允许,主节点可以只把断线期间的写命令同步给从节点,而不需要全量同步。
部分重同步依赖于复制偏移量(offset)和复制积压缓冲区(replication backlog)。主节点和从节点都会维护一个复制偏移量,主节点每次向从节点传播 N 个字节的数据时,就将自己的复制偏移量加上 N;从节点每次收到主节点传来的 N 个字节的数据时,也将自己的复制偏移量加上 N。通过对比主从的复制偏移量,就可以判断主从数据是否一致。
优缺点:
优点:实现简单,配置方便,能够实现读写分离,提高读性能。
缺点:不具备自动故障转移能力,主节点宕机后需要手动切换,恢复时间长。
8.2 哨兵模式(Sentinel)
哨兵模式是在主从复制的基础上,增加了哨兵节点来实现自动故障转移。哨兵(Sentinel)是一个独立的进程,它会持续监控主节点和从节点的健康状态。当主节点出现故障时,哨兵会自动从从节点中选举出一个新的主节点,并将其他从节点指向新的主节点。
哨兵的功能:
**监控。**哨兵会不断地检查你的主服务器和从服务器是否运作正常。
**通知。**当被监控的某个 Redis 服务器出现问题时,哨兵可以通过 API 向管理员或者其他应用程序发送通知。
**自动故障转移。**当一个主服务器不能正常工作时,哨兵会开始一次自动故障转移操作,它会将失效主服务器的其中一个从服务器升级为新的主服务器,并让失效主服务器的其他从服务器改为复制新的主服务器。
故障转移流程:
-
每个哨兵每秒向主节点、从节点和其他哨兵发送 PING 命令。
-
如果一个实例距离最后一次有效回复 PING 命令的时间超过 down-after-milliseconds 选项所指定的值,那么这个实例会被哨兵标记为主观下线。
-
如果一个主节点被标记为主观下线,那么所有正在监控这个主节点的哨兵,会以每秒一次的频率确认主节点的确进入了主观下线状态。
-
当有足够数量的哨兵(大于等于配置文件指定的值)在指定的时间范围内确认主节点进入了主观下线状态,那么主节点会被标记为客观下线。
-
哨兵之间会进行投票,选举出一个领头哨兵,由领头哨兵来执行故障转移操作。
-
领头哨兵从从节点中选举出一个新的主节点。选举规则:优先选择优先级最高的从节点;如果优先级相同,选择复制偏移量最大的从节点(复制的数据最多);如果偏移量也相同,选择 run_id 最小的从节点。
-
领头哨兵向新的主节点发送 SLAVEOF NO ONE 命令,将其提升为主节点。
-
领头哨兵向其他所有从节点发送 SLAVEOF 命令,让它们复制新的主节点。
-
领头哨兵将原来的主节点设置为新的主节点的从节点,当它恢复后,自动成为新主节点的从节点。
优缺点:
优点:具备自动故障转移能力,提高了可用性;实现了读写分离,提高了读性能。
缺点:写操作还是只能在主节点上,写性能和存储容量受限于单台主节点;扩容困难,不能水平扩展。
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 会导致很多问题:
-
读取大 key 会消耗大量网络带宽,导致响应变慢。
-
删除大 key 会阻塞主线程,因为删除操作是在主线程中执行的。
-
大 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 为什么这么快?
答:主要有四个原因:
-
完全基于内存,数据都存在内存中,读写速度非常快。
-
用 C 语言实现,执行效率高。
-
单线程模型,避免了多线程的上下文切换和锁竞争的开销。
-
使用 IO 多路复用模型,可以处理大量并发连接。
2. Redis 是单线程的吗?
答:Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。所以严格来说 Redis 不是单线程的,只是说它的核心网络模型是单线程的。
3. Redis 和 Memcached 有什么区别?
答:主要有以下几点区别:
-
数据结构:Redis 支持丰富的数据结构,比如 String、Hash、List、Set、ZSet 等;Memcached 只支持简单的 key-value。
-
持久化:Redis 支持 RDB 和 AOF 两种持久化方式;Memcached 不支持持久化,重启后数据就没了。
-
线程模型:Redis 是单线程模型;Memcached 是多线程模型。
-
内存管理:Redis 有自己的内存管理机制,支持多种内存淘汰策略;Memcached 使用 Slab Allocation 内存管理。
-
集群支持:Redis 原生支持集群;Memcached 本身不支持集群,需要客户端来实现分布式。
4. Redis 有哪些数据结构?分别有什么应用场景?
答:Redis 有 5 种基础数据结构:
-
String:最基础的类型,二进制安全。可以用来缓存对象、计数器、分布式锁、共享 Session 等。
-
Hash:键值对集合,适合存储对象。可以用来存储用户信息、商品信息、购物车等。
-
List:有序列表,可以从两端操作。可以用来做消息队列、文章列表、评论列表等。
-
Set:无序集合,元素不重复。可以用来做去重、共同好友、抽奖、标签系统等。
-
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 来攻击,就会给数据库造成很大压力。
解决方案:
-
**布隆过滤器:**将所有可能存在的数据哈希到一个足够大的 bitmap 中,查询的时候先过布隆过滤器,如果不存在就直接返回,不用查数据库。
-
**缓存空值:**如果数据库查询不到,也把这个空结果缓存起来,设置一个较短的过期时间。这样下次再查这个 key 的时候就直接返回空,不会打到数据库上。
8. 什么是缓存击穿?怎么解决?
答:缓存击穿是指一个热点 key,在缓存过期的瞬间,有大量并发请求过来,这些请求都打到数据库上,导致数据库压力骤增。
解决方案:
-
**互斥锁:**缓存失效的时候,不是所有人都去查数据库,而是先拿锁,拿到锁的线程去查数据库并更新缓存,其他线程等待。这样可以保证只有一个请求打到数据库上。
-
**逻辑过期:**不设置真正的过期时间,而是把过期时间存在 value 里。当发现过期了,就异步去更新缓存,当前请求还是返回旧数据。这样不会有线程等待,性能更好,但是数据一致性差一点。
9. 什么是缓存雪崩?怎么解决?
答:缓存雪崩是指大量的 key 在同一时间过期,或者 Redis 宕机了,导致所有请求都打到数据库上,给数据库造成巨大压力,甚至把数据库打挂。
解决方案:
-
**过期时间随机化:**给 key 的过期时间加上一个随机值,避免大量 key 同时过期。
-
**多级缓存:**本地缓存 + Redis 缓存,本地缓存也能挡一部分请求。
-
服务熔断与降级:如果数据库压力太大,可以触发熔断,直接返回降级结果,保护数据库。
-
Redis 高可用:搭建 Redis 集群,保证 Redis 不会轻易挂掉。
10. 缓存和数据库双写一致性问题怎么解决?
答:最常用的方案是 Cache Aside Pattern,也就是先更新数据库,再删除缓存。这种方案出现不一致的概率很低,而且实现简单。
如果想要更可靠一点,可以用延时双删:先删缓存,再更新数据库,然后延时一段时间再删一次缓存。但是延时时间不好确定。
如果想要强一致性,可以用读写锁,读的时候加共享锁,写的时候加排他锁。但是这样会影响性能。
如果对一致性要求很高,又有大量的读写操作,可以用 Canal 监听 MySQL 的 binlog,异步更新缓存。这样可以保证最终一致性。
10.4 分布式锁篇
11. Redis 分布式锁怎么实现?有什么问题?
答:最基础的实现是用 SETNX 命令,加锁的时候设置一个唯一的 value,解锁的时候用 Lua 脚本判断 value 是不是自己的,是就删除。还要设置过期时间,防止死锁。
存在的问题:
-
锁过期时间不好设置:设置短了业务还没执行完锁就过期了;设置长了如果客户端挂了,锁要等很久才释放。
-
主从一致性问题:如果 master 上加锁成功了,还没同步给 slave,master 就挂了,slave 变成新的 master,这时候另一个客户端也能加锁成功,就出现了两把锁。
-
不可重入:同一个线程不能多次加锁。
这些问题 Redisson 都解决了,它有看门狗机制自动续期,支持可重入,还有自旋等待等功能。生产环境推荐用 Redisson。
12. 什么是红锁?有什么问题?
答:红锁是为了解决主从一致性问题提出的算法。它假设有 N 个独立的 Redis 节点,加锁的时候向所有节点发送加锁请求,如果超过半数加锁成功,并且总耗时小于锁的有效时间,就算加锁成功。
红锁的问题:性能低,运维成本高,而且算法本身也有争议,比如时钟漂移的问题。现在 Redis 官方也不推荐用红锁了,Redisson 也把红锁标记为废弃了。
10.5 集群篇
13. Redis 有哪些集群方案?
答:主要有三种:
-
**主从复制:**一主多从,主负责写,从负责读。优点是简单,缺点是没有自动故障转移,容量受限于单节点。
-
**哨兵模式:**在主从的基础上加了哨兵节点,监控主从的健康状态,主节点挂了自动选举新的主节点。优点是高可用,缺点是容量还是受限于单节点。
-
**分片集群:**数据分散存储在多个主节点上,每个主节点有自己的从节点。优点是可以水平扩展,容量和性能都可以线性提升;缺点是实现复杂,不支持多键操作和事务。
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 有哪些性能优化的方法?
答:可以从几个方面来优化:
-
键值设计:避免大 key,合理设置过期时间,key 命名简洁。
-
命令使用:避免 KEYS * 这种慢命令,使用批量操作和 Pipeline 减少网络往返,避免复杂度过高的命令。
-
内存优化:选择合适的数据结构,利用压缩列表节省内存,合理设置淘汰策略。
-
持久化优化:合理选择持久化方式,避免在高峰期做持久化,不需要的话就关闭持久化。
-
网络优化:使用连接池和长连接,客户端和 Redis 部署在同一机房。
-
架构优化:读写分离分担读压力,分片集群水平扩展,本地缓存挡热点数据。
17. 怎么排查 Redis 的性能问题?
答:常用的方法有:
-
用 INFO 命令查看 Redis 的整体状态,比如内存、连接数、QPS、命中率等。
-
用 SLOWLOG 查看慢查询,找出执行慢的命令。
-
用 MONITOR 命令实时查看执行的命令,排查问题。
-
用 redis-benchmark 做压测,评估性能。