06-Redis(48)
1. 主从的实现原理
- Redis 主从架构图
- 主从架构可以实现读写分离。写操作可以请求主节点,而读操作只请求从节点,这样就能减轻主节点的压力
- 整个主从集群仅主节点可以写入,其它从节点都通过复制来同步数据,这样就能保证数据的一致性。并且对读请求分散到多个节点,提高了 Redis 的吞吐量,从一定程度上也提高了 Redis 的可用性
1. 主从复制原理
Redis 之间主从复制主要有两种数据同步方式,分别是全量同步和增量同步
1. 全量同步
- runid 指的是主服务器的 run ID,从节点第一次同步不知道主节点 ID,于是传递
?
- offset 为复制进度,第一次同步值为 -1
文字版本的流程
- 从节点发送
psync ? -1
,触发同步 - 主节点收到从节点的 psync 命令之后,发现 runid 没值,判断是全量同步,返回 fullresync 并带上主服务器的 runid 和当前复制进度,从服务器会存储这两个值
- 主节点执行 bgsave 生成 RDB 文件,在 RDB 文件生成过程中,主节点新接收到的写入数据的命令会存储到
replication buffer
中 - RDB 文件生成完毕后,主节点将其发送给从节点,从节点清空旧数据,加载 RDB 的数据
- 等到从节点中 RDB 文件加载完成之后,主节点将
replication buffer
缓存的数据发送给从节点,从节点执行命令,保证数据的一致性
待同步完毕后,主从之间会保持一个长连接,主节点会通过这个连接将后续的写操作传递给从节点执行,来保证数据的一致
2. 增量同步
主从之间的网络可能不稳定,如果连接断开,主节点部分写操作未传递给从节点执行,主从数据就不一致了
- 此时有一种选择是再次发起全量同步,但是全量同步数据量比较大,非常耗时
- 因此 Redis 在 2.8 版本引入了增量同步(psync 其实就是 2.8 引入的命令),仅需把连接断开其间的数据同步给从节点就好了
repl_backlog_buffer
是一个环形缓冲区,默认大小为 1m。主节点会将写入命令存到这个缓冲区中,但是大小有限,待写入的命令超过 1m 后,会覆盖之前的数据,因为是环形写入。
增量同步也是 psync 命令,如果主节点判断从节点传递的 runid 和主节点一致,且根据 offset 判断数据还在 repl_backlog_buffer
中,则说明可以进行增量同步
- 去
repl_backlog_buffer
查找对应 offset 之后的命令数据,写入到replication buffer
中,最终将其发送给 slave 节点。slave 节点收到指令之后执行对应的命令,一次增量同步的过程就完成了 - 如果根据 offset 判断数据已经被覆盖了,此时只能触发全量同步!可以调整
repl_backlog_buffer
大小,尽量避免出现全量同步
3. replication,repl_backlog buffer
- replication buffer
- 因为不同的从节点同步速度不一样,主节点会为每个从节点都创建一个
replication buffer
,它用于实时传输写命令,且大小是动态的,因为对于同步速度较慢的从服务器,需要更多的内存来缓存数据 - 虽说
replication buffer
没有明确的大小限制,但是可以通过client-output-buffer-limit
间接控制,该参数可以设置不同类型客户端(普通、从服务器、发布订阅)的输出缓冲区限制。当缓冲区大小超过限制时,Redis 会断开与客户端(从节点其实就是一个客户端)的连接 client-output-buffer-limit slave 256mb 64mb 60
配置表示,如果从服务器的输出缓冲区大小超过 256 MB 且在 60 秒内未恢复到 64 MB 以下,Redis 将断开与从服务器的连接
- 因为不同的从节点同步速度不一样,主节点会为每个从节点都创建一个
- repl_backlog_buffer
repl_backlog_buffer
在主节点上只有一个,存储最近的写命令,用于从服务器重新连接时进行部分重同步
2. 集群的了解
Redis 集群有了解过吗?
- 当单机 Redis 缓存的数据量太大,请求量也高,这个时候可以采用 Redis 集群(Redis Cluster)方案
- Redis 集群会将数据分片存储到多台 Redis 上,多个 Redis 实例都可进行读写操作
- 集群内每个节点都会保存集群的完整拓扑信息,包括每个节点的 ID、IP 地址、端口、负责的哈希槽范围等,它们直接通过 Gossip 协议保持通信,会周期性地发送 PING 和 PONG 消息,交换集群信息,使得集群信息得以同步
简单点说,集群就是通过多台机器分担单台机器上的压力
- 从图中可以看到,每个分片内部还是有主从的结构,这个目的是为了提高集群的可用性
1. 集群分片原理
Redis 集群会将数据分散到 16384 个哈希槽中,集群中的每个节点负责一定范围的哈希槽
每个节点会拥有一部分的槽位,然后对应的键值会根据其本身的 key,映射到一个哈希槽中,其主要流程如下:
- 根据键值的 key,按照 CRC 16 算法计算一个 16 bit 的值,然后将 16 bit 的值对 16384 进行取余运算,最后得到一个对应的哈希槽编号
- 根据每个节点分配的哈希槽区间,对应编号的数据落在对应的区间上,就能找到对应的分片实例
强调:Redis 客户端可以访问集群中任意一台实例,正常情况下这个实例包含这个数据。但如果槽被转移了,客户端还未来得及更新槽的信息,当前实例没有这个数据,则返回 MOVED 响应给客户端,将其重定向到对应的实例(因 Gossip 集群内每个节点都会保存集群的完整拓扑信息)
2. 为什么哈希槽16384
github 上有人向作者提问过:
- 首先是消息大小的考虑。正常的心跳包需要带上节点完整配置数据,心跳还是比较频繁的,所以需要考虑数据包的大小,如果使用 16384 数据包只要 2k,如果用了 65535 则需要 8k
- 实际上槽位信息使用一个长度为 16384 位的数组来表示,节点拥有哪个槽位,就将对应位置的数据信息设置为 1,否则为 0
- 心跳数据包包含槽位信息,如图:
这里看到一个重点,即在消息头中最占空间的是 myslots[CLUSTER_SLOTS/8]
- 当槽位为 65536 时,大小是:
65536 / 8 / 1024 = 8kb
,ping 消息的消息头就太大了,浪费带宽 - 当槽位为 16384 时,大小是:
16384 / 8 / 1024 = 2kb
- 集群规模的考虑。集群不太可能会扩展超过 1000 个节点,16384 够用且使得每个分片下的槽又不会太少
3. 使用场景?
更多的场景可以结合项目去拓展。eg:登录鉴权、限流、会话等
1. Redis缓存
- 因为 Redis 是基于内存的,其读写速度比 MySQL 基于磁盘的方式要快很多,所以其作为热点数据的缓存是非常合适的。使用 Redis 缓存可以极大地提高应用的响应速度和吞吐量
- eg:用户访问 Web 服务时,可以先查询缓存,如果缓存未命中的话,去查询数据库(这里建议去查询数据库的时候加锁,防止多个请求同时打到数据库,导致数据库查询效率不高)
- 如果数据库中有对应的数据,则写入 Redis 缓存中,并返回给用户
- 如果数据库没有对应的数据的话,可以写入 null 值到缓存中,应对缓存穿透问题
2. 分布式锁
- 本地锁(synchronized、lock)很多时候已经满足不了需求,特别是使用了微服务框架,不同实例之间的锁需要依赖外部系统进行一致性锁定,因此就需要用上分布式锁
- Redis 是很好的一个外部系统,基于缓存使得加锁非常高效,天然的过期机制可以很好地避免死锁的发生,且配合 Redission 封装好的类库,使用起来非常简便:
3. 计数器
- Redis 由于其单线程执行命令的特性,实现计数器非常方便,不会有锁的竞争
- 像文章的点赞数量就可以 Redis 实现
- 再如一些海量数据的统计
- eg:大网站的访问统计、日活月活等,适合使用 Redis 提供的高级数据结构 HyperLogLog
HyperLogLog:基于基数估算算法实现。优点就是所需的内存不会随着集合的大小而改变,因此很适合大规模数据集统计的场景,不过它的统计值是不精确的,有一定的误差,但是在海量数据场景,这些误差一般是可以接受的
4. 消息队列
- 在一些简单场景,也可以利用 Redis 来实现消息队列功能
- eg:使用列表的 lpush 实现消息的发布,rpop 实现消息的消费。也可以使用 Redis 5.0 之后引入的 stream 数据结构来实现消息功能
- 注意:用 Redis 来实现消息队列肯定比不上正常的消息队列中间件
- eg:无法保证消息的持久性,即使有 aof 和 rdb 也无法保证消息一定不会丢
5. 实时系统的构建
- 抽奖
- 秒杀
- 排行榜。其可以使用 Redis 的 Zset 数据结构,根据用户的分数、时间等参数构建一个实时的排行榜
4. 为什么这么快
主要有 3 个方面的原因
- 存储方式
- 优秀的线程模型及 I/O 模型
- 高效的数据结构
1. 存储方式
Redis 的存储是基于内存的,直接访问内存的速度是远远大于访问磁盘的速度的
- 一般情况下,计算机访问一次 SSD 磁盘的时间大概是 50 ~ 150 微秒,如果是传统硬盘的话,需要的时间会更长,可能需要 1 ~ 10 毫秒,而你访问一次内存,其需要的时间大概是 120 纳妙。由此可见,其访问的速度差了快一千倍左右
- 除了一些场景,比如说持久化,Redis 很少需要与磁盘进行交互,大多数时候 Redis 的读写是基于内存的,因此其效率较高
2. 优秀的线程、IO模型
- Redis 使用单个主线程来执行命令,不需要进行线程切换,避免了上下文切换带来的性能开销,大大提高了 Redis 的运行效率和响应速度
- Redis 采用了 I/O 多路复用技术,实现了单个线程同时处理多个客户端连接的能力,从而提高 Redis 的并发能力
不过 Redis 并不是一直都是单线程的
- 自 4.0 开始,Redis 就引入了 Unlink 这类命令,用于异步执行删除等操作
- 在 6.0 之后,Redis 为了进一步提升 I/O 的性能,引入了多线程的机制,利用多线程的机制并发处理网络请求,从而减少 Redis 由于网络 I/O 等待造成的影响
3. 高效的数据结构
Redis 本身提供了丰富的数据结构(eg:字符串、哈希、Zset 等),这些数据结构大多操作的时间复杂度为
5. 为什么设计成单线程
Redis 之所以在前期并没有在网络请求模块和数据操作模块中使用多线程模型,其主要原因如下:
- Redis 的操作是基于内存的,其大多数操作的性能瓶颈主要不是 CPU 导致的
- 使用单线程模型,代码简便的同时也减少了线程上下文切换带来的性能开销
- Redis 在单线程的情况下,使用 I/O 多路复用模型就可以提高 Redis 的 I/O 利用率
为什么 Redis 前期不使用多线程的方式,等到 6.0 却又引入呢?
- 我们对 Redis 的性能有了更高的要求,因为随着业务愈加复杂,公司需要的 QPS 就越高了,为了提升 QPS ,最直接的做法就是搭建 Redis 的集群,即提高 Redis 的机器数,但是这种做法的资源消耗是巨大的
- 而 Redis 单线程执行命令的性能瓶颈在网络 I/O ,虽然它采用了多路复用技术,但 I/O 多路复用模型的本质就是同步阻塞型 IO
6.0 不是变成多线程了吗?
- Redis 单线程,主要指的是 Redis 网络 I/O 和键值对读写这些操作是由一个线程完成的(持久化、集群等机制其实是有后台线程执行的)
- 在 4.0 之后就开始引入了多线程指令,6.0 之后便正式引入了多线程的机制,不过 这里的多线程其只是针对网络请求过程使用多线程,其对于数据读写命令的处理依旧是单线程
IO 多路复用,如图:
- I/O 多路复用在处理网络请求时,无论是调用 epoll 还是其他函数,其过程都是阻塞的,即处理网络请求的这个过程都会阻塞线程,如果并发量很高的话,这个过程可能会成为瓶颈
- 综上所示,单线程加上网络 IO 模型的设计并不能很好地解决网络 IO 瓶颈的问题,这时考虑利用 CPU 的多核优势,即利用多线程处理网络请求的方式来提高效率,然后对于读写命令, Redis 依旧采用单线程命令
Redis 引入多线程之后,有没有带来什么线程安全问题呢?
- 没有,因为 Redis 6.0 只有针对网络请求模块采用的是多线程,对于读写部分还是采用单线程,所以所谓的线程安全问题就不存在了
6. 数据类型有哪些
Redis 常见的数据结构主要有五种,这五种类型分别为:
- String(字符串):缓存对象、计数器、分布式锁、分布式 session 等
- List(列表):阻塞队列、消息队列(有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)
- Hash:缓存对象、购物车等
- Set(集合):集合聚合计算(并集、交集、差集)的场景。eg:点赞、共同关注、收藏等
- Zset(有序集合,也叫 sorted set):最典型的就是排行榜,面试重点
随着 Redis 版本的更新,后面又增加了 4 种高级数据类型:
- BitMap(2.2 版新增):主要有 0 和 1 两种状态,可以用于签到统计、用户登录态判断等
- HyperLogLog(2.8 版新增):海量数据基数统计的场景,有一定的误差,可以根据场景选择使用,常用于网页 PV、UV 的统计
- GEO(3.2 版新增):存储地理位置信息的场景。eg:百度地图、高德地图、附近的人等
- Stream(5.0 版新增):消息队列,可以实现一个简单的消息,其相比 list 多了两个特性,分别是自动生成全局唯一消息ID以及支持以消费组形式消费数据
7. Redis跳表的实现
什么是跳表?
跳表:就是一个多层索引的链表。每一层索引的元素在最底层的链表中可以找到的元素(这一点和 B+树
是一样的)
- 如图,这就是一个简单的跳表实现了,每个颜色代表一层,绿色的就是链表的最底层了
通过查询、添加元素来了解其功能流程:
- 查询元素:这里我们与传统的链表进行对比,来了解跳表查询的高效
- 假设要查找 50,如果通过传统链表(看最底层绿色的查询路线),需要查找 4 次,时间复杂度为
- 使用跳表,其只需要从最上面的10开始,首先跳到40,比40大,比70小。前往下一层,50刚好符合目标,直接返回。跳转次数是3次,即 10 -> 40(顶层)-> 40(第二层)-> 50(第二层)。流程如图:
- 跳表的平均时间查询复杂度是 ,最差的时间复杂度是
- 插入元素:插入一条 score 为 48 的数据
- 先需要定位到第一个比 score 大的数据。如图,一下子就可以定位到 50 了,和查询过程一样
- 在定位到对应节点之后,具体是在当前节点创建数据还是增加一个层级这个是随机的
- 定位层级后,再将每一层的链表节点进行补齐,就是在 40 与 50 之间插入一个新的链表节点 48,插入过程与链表插入是一样的
最终实现效果,如图:
Redis 中的跳表?
Redis 的跳表相对于普通的跳表多了一个回退指针,且 score 可以重复
typedef struct zskiplistNode {
// Zset 对象的元素值
sds ele;
// 元素权重值
double score;
// 后退指针
struct zskiplistNode *backward;
// 节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
ele
:用到了 Redis 字符串底层的一个实现 sds,其主要作用是用来存储数据score
:节点的分数,double 即浮点型数据backward
:可以看到其是 zskiplistNode 结构体指针类型,即代表指向前一个跳表节点level
:这个就是 zskiplistNode 的结构体数组了,数组的索引代表层级索引,这里注意与 hashtable 中的结构进行区分,那个使用的是联合体,一个是 forward ,其代表下一个跳转的跳表节点,注意一个点,其是跳转到同一层;span 主要作用代表距离下一个节点的步数
Redis 跳表实现如图(红色箭头就代表回退指针):
补充插入的随机层级
- 一个节点有多少层,Redis 是采用随机的概率函数来决定的
- 在代码中,跳表每一个节点能否新加一层的概率是
25%
,然后最多的层数在 Redis 5.0 中是 64 层,Redis 7.0 中是 32 层
8. Redis的hash详细讲讲
Hash 是 Redis 中的一种数据基础数据结构,类似于数据结构中的哈希表,一个 Hash 可以存储 2^32 - 1
个键值对(约 40 亿)。底层结构需要分成两个情况:
- Redis 6 及之前,Hash 的底层是《压缩列表 + 哈希表》(ziplist + hashtable)
- Redis 7 之后,Hash 的底层是《紧凑列表 + 哈希表》(listpack + hashtable)
ziplist 和 listpack 查找 key 的效率是类似的,时间复杂度都是 ,其主要区别就在于 listpack 解决了 ziplist 的级联更新问题
hash 在什么时候使用 ziplist 和 listpack,什么时候使用 Hashtable 呢?
hash-max-ziplist-entries
(hash-max-listpack-entries
):Hash 类型键的字段个数(默认512)hash-max-ziplist-value
(hash-max-listpack-value
):每个字段名和字段值的长度(默认64)
Redis 7.0 为了兼容早期的版本,并没有把 ziplist 相关的值删掉。
127.0.0.1: 6379># config get hash*
1) "hash-max-listpack-entries"
2) "512"
3) "hash-max-ziplist-entries"
4) "512"
5) "hash-max-listpack-value"
6) "64"
7) "hash-max-ziplist-value"
8) "64"
- 当 hash 小于这两个值时,会使用 listpack 或者 ziplist 进行存储
- 当大于这两个值时,会使用 hashtable 进行存储
- 注意:在使用 hashtable 结构之后,就不会再退化成 ziplist 或 listpack,之后都是使用 hashtable 进行存储
- 注意:这两个值是可以修改的
127.0.0.1:6379># config get hash*
1) "hash-max-ziplist-entries"
2) "512"
3) "hash-max-ziplist-value"
4) "64"
127.0.0.1:6379># config set hash-max-ziplist-entries 4399
OK
127.0.0.1:6379># config set hash-max-ziplist-value 2024
OK
127.0.0.1:6379># config get hash*
1) "hash-max-ziplist-entries"
2) "4399"
3) "hash-max-ziplist-value"
4) "2024"
1. 聊聊Hashtable
- Hashtable 就是哈希表实现,查询时间复杂度为 ,效率非常快
- 看下 HashTable 的结构:
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
unsigned long sizemask;
// 该哈希表已有的节点数量
unsigned long used;
} dictht;
dictht 一共有 4 个字段:
table
:哈希表实现存储元素的结构,可以看成是哈希节点(dictEntry)组成的数组size
:表示哈希表的大小sizemask
:指哈希表大小的掩码,它的值永远等于size-1
,这个属性和哈希值一起约定了哈希节点所处的哈希表的位置,索引值:index = hash(哈希值) & sizemask
used
:表示已经使用的节点数量
哈希节点(dictEntry)的组成,它主要由三个部分组成,分别为 key,value 以及指向下一个哈希节点的指针,源码如下:
typedef struct dictEntry {
// 键值对中的键
void *key;
// 键值对中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
- 哈希节点中的 value 值是由一个联合体组成的。因此,这个值可以是指向实际值的指针、64位无符号整数、64 为整数以及 double 的值
- 这样设计目的:节省 Redis 的内存空间,当值是整数或浮点数时,可以将值的数据存在哈希节点中,不需要使用一个指针指向实际的值,一定程度上节省了空间
2. 渐进式rehash
从字面意思上来说,就是一点点地扩容,而不是直接一次性完成扩容。
dict 有两个 dictht 组成,为什么需要 2 个哈希表呢?主要原因就是为了实现渐进式
在平时,插入数据时,所有的数据都会写入 ht[0] 即哈希表1
,ht [1] 哈希表2
此时就是一张没有分配空间的空表
但是随着数据越来越多,当 dict 的空间不够的时候,就会触发扩容条件,其扩容流程主要分为三步:
- 首先,为
哈希表2
分配空间。新表的大小是第一个大于等于原表 2 倍 used 的 2 次方幂- eg:如果原表即哈希表 1 的值是 1024,那个其扩容之后的新表大小就是 2048
- 分配好空间之后,此时 dict 就有了两个哈希表了,然后此时字典的 rehashidx 即 rehash 索引的值从 -1 暂时变成 0 ,然后便开始数据转移操作
- 数据开始实现转移。每次对 hash 进行crud操作,都会将当前 rehashidx 的数据从在 1 迁移到 2 上,然后
rehashidx + 1
,所以迁移的过程是分多次、渐进式地完成- 注意:插入数据会直接插入到 2 表中
- 随着操作不断执行,最终哈希表 1 的数据都会被迁移到 2 中,这时候进行指针对象进行互换,即哈希表 2 变成新的哈希表 1,而原先的哈希表 1 变成哈希表 2并且设置为空表,最后将 rehashidx 的值设置为 1
3. 扩容、缩容的条件
负载因子,Redis 中 hash 的负载因子计算有一条公式:
负载因子 = 哈希表已保存节点的数量 / 哈希表的大小
- 扩容负载因子
- 负载因子>= 1,说明空间非常紧张,新数据在哈希节点的链表上找到的,这时如果服务器没有执行 RDB 快照或 AOF 重写这两个持久化机制时,就会进行 rehash 操作
- 当负载因子>= 5,这时说明哈希冲突非常严重了,无论有没有进行 AOF 重写或 RDB 快照,都会强制执行 rehash 操作
- 缩容也和负载因子有关
- 当负载因子小于 0.1 时,就会进行缩容操作。这时新表大小是老表的 used 的最近的一个 2 次方幂
- eg:老表的 used = 1000,那么新表的大小就是 1024。如果没有执行 RDB 快照和 AOF 重写的时候就会进行缩容,反之不会进行
9. Redis、Memcached区别
Redis 和 Memcached 都是常见的缓存中间件,其之间有一些
共同点:
- 都是基于内存的数据库,所以操作速度非常快,性能很高
- 都有对应的缓存过期策略
不同点:
- Redis 的数据类型更加丰富。eg:String、Hash、List、Zset等,还支持 HyperLogLog、Geo 等高级数据结构
- 而 Memcache 只支持简单的 Key-Value 数据类型
- Redis 支持发布订阅、Lua 脚本等特性
- Memcached 特性比较少
- Redis 支持缓存数据持久化到磁盘当中(RDB、AOF)
- Memcached 并不支持数据持久化
- 如果发生故障进行服务重启时,Memcached 中的数据会直接丢失,而 Redis 可以通过持久化机制进行数据的恢复
- 线程模型不一样,Redis 使用单线程进行数据请求的处理
- 而 Memcached 使用多线程进行数据处理
- 分布式支持不一样,Redis 原生支持集群(Redis Cluster),可以实现数据的自动分片和负载均衡功能
- 而 Mamcached 本身不支持分布式,需要客户端自己实现分布式功能。eg:手动进行集群分片
10. Redis可以实现事务吗
- Redis 支持事务,但它的事务与 MySQL 中的事务有所不同。MySQL 中的事务主要支持 ACID 的特性,而 Redis 中的事务主要保证多个命令执行的原子性,即所有的命令在一个原子操作中执行,不会被打断
- MySQL 中的事务是支持回滚的,而 Redis 中的事务是不支持回滚的
Redis 官网描述:
- 从 Redis 2.6.5 开始,服务器会在累计命令的过程中检测到错误,此时执行 EXEC 会拒绝执行事务,并且返回一个错误,同时丢弃该事务
- 但如果事务执行的过程中发生了错误,Redis 会继续执行剩下的命令,而不会对事务进行回滚,这个是 Redis 和 MySQL 最不一样的地方,并且也不支持多种隔离级别的设置,因为 Redis 是单线程执行,只能是串行隔离级别
- 可以认为 Redis 的事务是一个残血的事务,更多只是一个噱头,不是平时理解的事务
1. Redis事务简单知识
Redis 的事务机制主要通过以下几个命令实现:
MULTI
:开始一个事务EXEC
:执行所有在事务中排队的命令DISCARD
:放弃事务中排队的所有命令WATCH
:监视一个或多个键,如果这些键在事务执行之前发生变化,事务将被中止
11. Redis过期策略
- Redis 数据过期主要有三种删除策略,分别为定期删除、惰性删除两种
- Redis 在正常情况下对过期键的处理就是
惰性删除 + 定期删除
一起使用,主动删除(内存回收)其实属于异常的兜底处理了
1. 定期删除
定期删除策略是 Redis 内部的一个定时任务,周期性(每 100ms)地扫描一些设置了过期时间的键,然后删除那些已经过期的键
- 要注意,Redis 并不会一次性扫描所有设置了过期时间的键,因为这样会消耗大量的 CPU 资源。它会在每次扫描时限制扫描的时间和数量,以避免对性能产生过大的影响,因此可能会有部分键过期了没有被即使删除
- 每次获取 20 个 key 判断是否过期,如果发现过期的 key 占比超过 25% 则继续再拉 20 个,如果小于 25% 则停止。这里还有一个时间限制,即一次删除时间不超过 25ms,即如果发现占比超过 25% 的时候,需要判断目前是否已经花了 25ms,如果已经用了这么多时长也会结束
2. 惰性删除
客户端访问键的时候触发的,每次客户端访问键的时候,Redis 会主动检查这个键是否过期,如果过期了则删除并且返回 null,如果没有过期,则直接返回数据
- 优点:可以减少 CPU 的占用,因为只有查询到了相关的数据才执行删除操作,不需要主动去定时删除
- 缺点:如果一直没有查询一个 Key,就有可能不会被删除,这样就容易造成内存泄漏问题
3. 内存回收机制
当 Redis 内存使用达到设置的 maxmemory 限制时,会触发内存回收机制。会主动删除一些过期键和其他不需要的键,以释放内存。具体的删除策略有以下几种:
volatile-lru
:从设置了过期时间的键中使用 LRU(Least Recently Used,最近最少使用)算法删除键allkeys-lru
:从所有键中使用 LRU 算法删除键volatile-lfu
:从设置了过期时间的键中使用 LFU(Least Frequently Used,最少使用频率)算法删除键allkeys-lfu
:从所有键中使用 LFU 算法删除键volatile-random
:从设置了过期时间的键中随机删除键allkeys-random
:从所有键中随机删除键volatile-ttl
:从设置了过期时间的键中根据 TTL(Time to Live,存活时间)删除键,优先删除存活时间短的键noeviction
:不删除键,只是拒绝写入新的数据
12. Redis内存淘汰策略
Redis 的内存淘汰策略一共有 8 种。可以细分为两大类
- 开启数据淘汰
- 基于过期时间的淘汰策略
- 全部数据的淘汰策略
- 不开启数据淘汰
1. 不进行数据淘汰
noeviction
(Redis 3.0 之后,默认使用的内存淘汰策略):当运行内存超过最大设置内存时,这时不进行数据淘汰,直接报错,禁止写入
- 通过
config set maxmeory
先将其内存大小修改至 2 字节,然后设置一个超过 2 字节的字符串值 99999
127.0.0.1:6379># config get maxmemory
1) "maxmemory"
2)"@"127.0.0.1:6379># config set maxmemory 2
OK
127.0.0.1:6379># config get maxmemory
1)"maxmemory"
2)"2"
127.0.0.1:6379># setname 999999
error 00M commandnot allowed when used memory > 'maxmemory' .
- 注意:虽然其不支持写入数据,但已有数据并不会删除,其还是可以进行查询和删除操作
- 如果不设置最大内存大小或设置最大内存大小为 0,在 64 位操作系统下不限制内存大小,在 32 位操作系统下最多使用 3 GB 内存
2. 进行数据淘汰
设置了过期时间的数据淘汰
volatile-random
:随机淘汰掉设置了过期时间的 keyvolatile-ttl
:优先淘汰掉较早过期的 keyvolatile-lru
(Redis 3.0 之前默认策略):淘汰掉所有设置了过期时间的,然后最久未使用的 keyvolatile-lfu
(Redis 4.0 后新增):与上面类似,不过是淘汰掉最少使用的 key
所有数据进行淘汰
allkeys-random
:随机淘汰掉任意的 keyallkeys-lru
:淘汰掉缓存中最久没有使用的 keyallkeys-lfu
(Redis4.0 后新增):淘汰掉缓存中最少使用的 key
3. 配置
- 通过
config set maxmemory-policy
设置内存淘汰策略,立即生效,不需要重启,但是重启后就失效了 - 通过配置文件设置
maxmemory-policy
,配置后需要重启 Redis,且后续重启后配置不会丢失
13. Redis的lua脚本用过吗
lua 本身是不具备原子性的,但由于 Redis 的命令是单线程执行的,它会把整个 lua 脚本作为一个命令执行,会阻塞其间接受到的其他命令,这就保证了 lua 脚本的原子性。
- eg:常见基于 Redis 实现分布式锁就需要结合 lua 脚本来实现
1. lua脚本的好处
- 原子性:Lua 脚本的所有命令在执行过程中是原子的,避免了并发修改带来的问题
- 减少网络往返次数:通过在服务器端执行脚本,减少了客户端和服务器之间的网络往返次数,提高了性能
- 复杂操作:可以在 Lua 脚本中执行复杂的逻辑,超过了单个 Redis 命令的能力
2. lua脚本使用注意点
- 由于 Redis 执行 lua 脚本其间,无法处理其他命令,因此如果 lua 脚本的业务过于复杂,则会产生长时间的阻塞,因此编写 Lua 脚本时应尽量保持简短和高效
- Redis 默认限制 lua 执行脚本时间为 5s,如果超过这个时间则会终止且抛错,可以通过
lua-time-limit
调整时长
3. lua知识
Lua 语言是一门轻量级的脚本语言,使用 C 语言编写,具有简洁的语法,易于学习和使用,广泛应用于游戏开发、嵌入式系统等领域。它设计的主要目的就是为了嵌入其他程序,实现灵活的扩展和定制功能,并且具备快速的执行速度,能够满足各种开发需求
优点:
- 轻量级:占用资源少,易于嵌入其他程序
- 简洁高效:语法简单,执行效率高
- 跨平台:可在多种操作系统上运行
- 扩展性强:方便与其他语言集成
- 快速开发:减少开发时间和成本
- 灵活性高:适应各种不同的项目需求
- 易于学习:入门难度低,容易掌握
- 解释执行:无需编译,便于调试
- 资源占用少:对系统资源的需求相对较低
- 社区活跃:有丰富的文档和工具支持
14. Redis pipeline了解
正常情况下,如果要执行多条命令,那么操作如下:
而 Redis pipeline(管道)使得客户端可以一次性将要执行的多条命令封装成块一起发送给服务端
1. 节省了RTT
RTT(Round Trip Time)即往返时间。Redis 客户端将要执行的多条指令一次性给客户端,显然减少了往返时间
2. 减少上下文切换的开销
当服务端需要从网络中读写数据时,都会产生一次系统调用,系统调用是非常耗时的操作。其中涉及到程序由用户态切换到内核态,再从内核态切换回用户态的过程
- 当我们执行 100 条 Redis 指令的时候,就发生 100 次用户态到内核态之间上下文的切换
- 如果使用管道的话,其将多条命令一同发送给服务端,就只需要进行一次上下文切换就好了,这样就可以节约性能
非pipeline操作10000次字符串数据类型set写入,耗时:1073毫秒
pipeline操作10000次字符串数据类型set写入,耗时:10毫秋
注意:
- pipeline 不宜包装过多的命令,因为会导致客户端长时间的等待,且服务器需要使用内存存储响应,所以官方推荐最多一次 10k 命令
- pipeline 命令执行的原子性不能保证,如果要保证原子性则使用 lua 脚本或者事务
15. big_key怎么解决
1. 什么是big_key
Redis 中的 "big Key" 顾名思义,指一个内存空间占用比较大的键(Key)
危害:
- 内存分布不均。在集群模式下,不同 slot 分配到不同实例中,如果大 key 都映射到一个实例,则分布不均,查询效率也会受到影响
- 由于 Redis 单线程执行命令,操作大 Key 时耗时较长,从而导致 Redis 出现其它命令阻塞的问题
- 大 Key 对资源的占用巨大,在进行网络 I/O 传输的时候,导致你获取过程中产生的网络流量较大,从而产生网络传输时间延长甚至网络传输发现阻塞的现象
- eg:一个 key 2MB,请求个 1000 次 2000 MB
- 客户端超时。因为操作大 Key 时耗时较长,可能导致客户端等待超时
2. 怎样算big key
大 Key 可能出现在不同的场景中,不过其本质都是相同的,就是对应的值其所占内存非常大,例如:
- 大型字符串
set key "面试鸭xxxxxx······(此处省略无数个字符)"
- 大型哈希表
HMSET student:8927 name "yupi" nickname "面试鸭"
参考阿里云 Redis 文档:(当然,这些标准并不是绝对的,具体的情况还是需要根据应用场景和实际情况来进行调整)
名词 | 解释 | 举例 |
---|---|---|
大Key | 通常以Key的大小和Key中成员的数量来综合判定 | - Key本身的数据量过大。eg:一个String类型的Key,它的值为5 MB - Key中的成员数过多。eg:一个ZSET类型的Key,它的成员数量为 10,000 个 - Key中成员的数据量过大。eg:一个Hash类型的Key,它的成员数量虽然只有 2,000 个但这些成员的Value(值)总大小为 100MB |
3. bigkeys命令查询
Redis 内置的指令,直接在 Redis 的客户端 Redis-cli 中就可以调用使用,该命令可以获取 Redis 的整体信息,并且显示每种类型数据中最大的 Key
redis-cli --bigkeys
便可以查看(docker版本可能会出现查找失败的情况)- 原理:Redis 内部执行 scan 命令,遍历整个实例所有的 key,针对 key 的类型执行 strlen、hlen、scard 等命令获取字符串长度或集合的元素个数
- 在线上执行这个命令的需要,需要关注性能问题,可以通过 -i 命令控制扫描的休息间隔,时间单位是秒
4. 如何解决big_key问题
1. 开发方面
- 对要存储的数据进行压缩,压缩之后再进行存储
- 大化小,即把大对象拆分成小对象,即将一个大 Key 拆分成若干个小 Key,降低单个 Key 的内存大小
- 使用合适的数据结构进行存储
- eg:一些用 String 存储的场景,可以考虑使用 Hash、Set 等结构进行优化
2. 业务层面
- 可以根据实际情况,调整存储策略,只存一些必要的数据
- 优化业务逻辑,从根源上避免大 Key 的产生
3. 数据分布方面
- 采用 Redis 集群方式进行 Redis 的部署,然后将大 Key 散落到不同的服务器上面,加快响应速度
16. 热点key
hotkey 即热点 Key,它与 big key 一样,没有一个很明确的定义来约定什么样的 key 叫做热点 key
- 一个 key 的访问频率占比过大,或带宽占比过大,都属于热点 key
参考阿里云 Redis 对热 key 的定义:
通常以其接收到的 Key 被请求频率来判定
- QPS 集中在特定的 Key
- eg:Redis 实例的总 QPS(每秒查询率)为 10,000,而其中一个Key的每秒访问量达到了 7,000
- 带宽使用率集中在特定的 Key
- eg:对一个拥有上千个成员且总大小为 1MB 的 HASH Key 每秒发送大量的 HGETALL 操作请求
- CPU 使用时间占比集中在特定的 Key
- eg:对一个拥有数万个成员的 Key(ZSET 类型)每秒发送大量的 ZRANGE 操作请求
由于 Redis 的读写是单线程执行的,所以热点 key 可能会影响 Redis 的整体效率,消耗大量的 CPU 资源,从而降低 Redis 的整体吞吐量。集群环境下会使得流量不均衡,从而导致读写热点倾斜问题的发生
1. 如何发现热key
1. 业务经验进行分析
依据业务场景进行分析,通过经验判断哪些 key 可能成为热门 key。eg:某明星的花边新闻、秒杀活动,演唱会门票等
- 优点:这个方案实现起来简单直接,只有直接进行判断就可以了,基本没有什么成本
- 缺点:这个主要依据业务能力,对于业务能力有一定的要求,并且不是所有的业务都能判断出来是否是热 key 的。且有些突发事情是无法预测的
2. Redis集群监控
Redis 集群,只需要查看集群中哪个 Redis 出现 QPS 倾斜,而出现 QPS 倾斜的实例有极大的概率存在热点 Key
- 优点:由于企业的 Redis 大多数是集群部署,所以使用起来非常简单
- 缺点:每次发生状况都需要排查,因为不一定所有的 QPS 倾斜都是热 Key 导致的
3. hotkey监控
Redis 4.0 版本之后引入的一个新的指令,只需要在命令行执行 redis-cli 的时候加上 --hotkeys 的选项就可以了。它是通过 scan + object freq 实现的
- 优点:因为这个命令是 Redis 自带的,使用起来简单快捷
- 缺点:需要扫描整个 keyspace,如果 Redis 中的 key 数量比较多的话,可能导致执行时间非常长,且实时性不好
4. monitor命令
Redis 自 1.0 起就支持的功能
当通过 MONITOR 命令开启监视器之后,Redis 只需要在执行之后结合一些日志和相关的分析工具就可以进行统计
- 优点:Redis 原生支持的功能,使用起来简单快捷
- 缺点:monitor 非常消耗性能,单个客户端执行 monitor 就会损耗 50% 的性能!不推荐
Redis 官网的 benchmark:
5. 客户端收集
操作 Redis 之前,加上统计 Redis 键值查询频次的逻辑,将统计数据发送给一个聚合计算平台进行计算,计算之后查看相对应的结果
- 优点:对性能损耗较低
- 缺点:成本较大,没有聚合计算平台还需要引入
6. 代理层收集
在代理层进行统一的收集,因为有些服务在请求 Redis 之前都会请求一个代理服务,这种场景可以使用在代理层收集 Redis 热 Key 数据,和在客户端收集比较类似
- 优点:客户端使用方便,不需要考虑 SDK 多语言异构差异和升级成本高的问题
- 缺点:需要给 Redis 定制一个代理层,进行转发等操作,构建代理成本不低,且转发有性能损耗
2. 解决热点key
1. 多级缓存
既然 Redis 这种三方缓存系统压力太大,需要给它减点重,可以上多级缓存来分担压力
- 使用本地缓存,在应用层就返回数据,避免请求再打到 Redis
- 前端缓存。eg:将数据存储到浏览器中,这样一段时间内请求都不需要打到后端
- CDN 缓存之类的,总之设置多级缓存,设定好业务允许的过期时间,分流请求,均分压力
2. 热点key拆分
Redis 有集群,一个 key 只能分布到集群的一台实例上。此时可以将 key 进行拆分
- eg:mianshiya 这个 key,拆分成 mianshiya_1、mianshiya_2、mianshiya_3 依次类推,这样它就能分配到不同的实例上,不同用户可以进行 hash,将用户 id 哈希之后取余得到后缀,拼上 mianshiya_ 即可组成一个 key
- 可以按照不同场景做不同的“拆分“。有些场景可以全量拷贝,即 mianshiya_1、mianshiya_2、mianshiya_3 它们之间的数据是一致的,这样不同用户都能得到全量的数据
- 有些场景直接进行 key 的拆分,mianshiya_1、mianshiya_2、mianshiya_3 各存一部分的数据,不同用户仅需访问不同数据即可
- eg:一些推流信息,因为一个热点往往有很多发布者,大家看一部分,后续热度稍微降低下来,可以替换数据
17. Redis持久化机制
1. 持久化方式有几种
- 快照(snapshotting,RDB)一般用于全量同步
- 只追加文件(append-only file,AOF)一般用于增量同步
- RDB 和 AOF 的混合持久化(Redis 4.0 新增)
2. RDB持久化是什么
- 通过创建快照来获取内存某个时间点上的副本,利用快照可以进行方便地进行主从复制
- 默认持久化方式是 RDB,默认文件
dump.rdb
redis.conf
文件可以配置,在 x 秒内如果至少有 y 个 key 发生变化就会触发命令,进行持久化操作
3. RDB持久化命令
save
:在主线程生成 RDB 文件,因此生成其间,主进程无法执行正常的读写命令,需要等待 RDB 结束bgsave
:利用 Fork 操作得到子进程,在子进程执行 RDB 生成,不会阻塞主进程,默认使用 bgsave
4. bgsave流程(重点)
- 检查子进程(检查是否存在 AOF/RDB 的子进程正在进行),如果有返回错误
- 触发持久化,调用 rdbsaveBackGroud
- 开始 fork,子进程执行 rdb 操作,同时主进程响应其他操作
- RDB 完成后,替换原来的旧 RDB 文件,子进程退出
5. 注意事项(重点)
- Fork 操作会产生短暂的阻塞,微秒级别操作过后,不会阻塞主进程,整个过程不是完全的非阻塞
- RDB 由于是快照备份所有数据,而不是像 AOF 一样存写命令,因为 Redis 实例重启后恢复数据的速度可以得到保证,大数据量下比AOF会快很多
- Fork 操作利用了写时复制,类似 CopyOnWriteArrayList
1. 写时复制
- 在子线程创建时,它不会直接将主进程地址空间全部复制,而是共享同一个内存
- 之后如果任意一个进程需要对内存进行修改操作,内存会重新复制一份提供给修改的进程单独使用
6. AOF持久化机制
将 Redis 写命令以追加的形式写入到磁盘中的 AOF 文件,AOF 文件记录了 Redis 在内存中的操作过程,只要在 Redis 重启后重新执行 AOF 文件中的写命令即可将数据恢复到内存中
- 优点:
- AOF 机制比 RDB 机制更加可靠,因为 AOF 文件记录了 Redis 执行的所有写命令,可以在每次写操作命令执行完毕后都落盘存储
- 缺点:
- AOF 机制生成的 AOF 文件比 RDB 文件更大,当数据集比较大时,AOF 文件会比 RDB 文件占用更多的磁盘空间
- 对于数据恢复的时间比 RDB 机制更加耗时,因为要重新执行 AOF 文件中的所有操作命令
7. 混合持久化
- RDB 备份的频率低,那么丢的数据多。备份的频率高,性能影响大
- AOF 文件虽然丢数据比较少,但是恢复起来又比较耗时
因此 Redis 4.0 以后引入了混合持久化,通过 aof‐use‐rdb‐preamble
配置开启混合持久化
- 当 AOF 重写时(注意混合持久化是在 aof 重写时触发的)。它会先生成当前时间的 RDB 快照,将其写入新的 AOF 文件头部位置
- 这段时间主线程处理的操作命令会记录在重写缓冲区中,RDB 写入完毕后将这里的增量数据追加到这个新 AOF 文件中,最后再用新 AOF 文件替换旧 AOF 文件
如此一来,当 Redis 通过 AOF 文件恢复数据时,会先加载 RDB,然后再重新执行指令恢复后半部分的增量数据,这样就大幅度提高数据恢复的速度!
8. 扩展AOF写回策略
AOF 提供了三种写回策略,决定何时将数据同步到磁盘中:
always
:每次写操作后立即调用 fsync,将数据同步到磁盘。这种策略保证了最高的数据安全性,但也会显著降低性能,因为每个写操作都要等待磁盘写入完成everysec
:每秒调用一次 fsync,将数据同步到磁盘。这种策略在性能和数据安全性之间做了折中,默认情况下,Redis 使用这种策略。最多会丢失 1 秒的数据no
:由操作系统决定何时将数据写入磁盘。通常,操作系统会在一定时间后或缓冲区满时同步数据到磁盘。这种策略具有最高的性能,但数据安全性较低,因为在 Redis 崩溃时可能会丢失较多的数据
设置 always 能一定保证数据不丢失吗?
不能!因为 Redis 是先执行命令再写入 aof,所以如果执行命令写入 aof 这段时间 Redis 宕机了,重启后也无法利用 aof 恢复!
9. 扩展AOF重写机制
AOF 文件随着写操作的增加会不断变大,过大的 AOF 文件会导致恢复速度变慢,并消耗大量磁盘空间。所以,Redis 提供了 AOF 重写机制,即对 AOF 文件进行压缩,通过最少的命令来重新生成一个等效的 AOF 文件
- 手动触发:使用 BGREWRITEAOF 命令可以手动触发 AOF 重写
- 自动触发:通过配置文件中的参数控制自动触发条件,参数解析如下:
auto-aof-rewrite-min-size
:AOF 文件达到该大小时允许重写(默认 64 MB)auto-aof-rewrite-percentage
:当前 AOF 文件大小相对于上次重写后的增长百分比达到该值时触发重写
10. 扩展AOF文件修复
如果 AOF 文件因系统崩溃等原因损坏,可以使用 redis-check-aof
工具修复。该工具会截断文件中的不完整命令,使其恢复到一致状态
18. RDB时如何处理请求
- 默认情况下 Redis 生成 RDB 的过程是异步的(采用 bgsave),主线程会调用
fork
创建一个子线程,由子线程负责将内存的数据写入磁盘,生成 RDB 文件 - 当父进程
fork
出一个子进程后,并不会把父进程的所有内存数据重新复制一份给子进程,而是让主进程和子进程共享相同的内存页面 - 底层的实现仅仅复制了页表,但映射的物理内存还是同一个。这样做可以加快 fork 的速度,减少性能损耗(fork会阻塞主线程)
运用了写时复制的技术
- 父进程收到写命令,需要修改数据,那么父进程会将对应数据所在的页复制一份,对复制的副本进行修改。此时子进程指向的还是老的页,因此数据没有变化,符合快照的概念
- 通过在写时才触发内存的复制,可以显著地降低 Redis 实例的性能压力,最大限度的减少 RDB 对服务正常运行的影响
1. 注意
如果 RDB 时间长,且写并发高,因为写时复制机制,如果共享的每一页内存都被修改,会使得内存极速膨胀,最大内存可以膨胀两倍,所以要注意内存的使用量,防止内存过载
- RDB 会产生大量的磁盘 I/O,要注意磁盘性能导致的影响
- 还需要注意 CPU 负载,毕竟有大量的数据需要写入
- 因此如果 RDB 在高峰期可能会影响到正常业务,需要合理安排生成 RDB 的时机
19. Redis哨兵机制
- 主从架构中,如果采用读写分离的模式,即主节点负责写请求,从节点负责读请求。假设这个时候主节点宕机了,没有新的主节点顶替上来的话,就会出现很长一段时间写请求没响应的情况
- 针对这个情况,便出现了哨兵这个机制。它主要进行监控作用,如果主节点挂了,将从节点切换成主节点,从而最大限度地减少停机时间和数据丢失
哨兵机制对应的架构图:
- 哨兵节点(Sentinel): 主要作用是对 Redis 的主从服务节点进行监控,当主节点发生故障时,哨兵节点会选择一个合适的从节点升级为主节点,并通知其他从节点和客户端进行更新操作
- Redis 节点:主要包括 master 以及 slave 节点,就是 Redis 提供服务的实例
一般哨兵需要集群部署,至少三台哨兵组成哨兵集群
哨兵是如何判断 Redis 中主节点挂了的呢?
- 主观下线
- Sentinel 每隔 1s 会发送 ping 命令给所有的节点。如果 Sentinel 超过一段时间还未收到对应节点的 pong 回复,就会认为这个节点主观下线
- “一段时间“是配置项
down-after-milliseconds
设定的
- 客观下线(注意:只有主节点才有客观下线,从节点没有)
- 假设目前有个主节点被一个 sentinel 的判断主观下线了,但可能主节点并没问题,只是因为网络抖动导致了一台哨兵的误判。所以此时哨兵需要问问它的队友,来确定这个主节点是不是真的出了问题!
- 因此,它会向其他哨兵发起投票,其他哨兵会判断主节点的状态进行投票,可以投赞成或反对
- 如果认为下线的总投票数大于 quorum(一般为
集群总数/2 + 1
),则判定该主节点客观下线,此时就需要进行主从切换,而只有哨兵的 leader 才能操作主从切换
1. Sentinel_leader如何选举
Sentinel leader 节点的选举实际上涉及到分布式算法 raft(sentinel 选举的时候尽量避免出现平票的情况,sentinel 的节点个数一般都会是奇数)
- 判断主节点主观下线的 sentinel 就是候选者,此时它想成为 leader。如果同时有两个 sentinel 判断主观下线,那么它们都是候选人,一起竞争leader
- 候选者们会先投自己一票,然后向其他 sentinel 发送命令让它们给自己投票。每个哨兵手里只有一票,投了一个之后就不能投别人了
- 最后,如果某个候选者拿到哨兵集群半数及以上的赞成票,就会成为 leader
2. Redis主节点选举
首先把一些已经下线的节点全部剔除,然后从正常的从节点中选择主节点
- 根据从节点的优先级进行选择,优先选择优先级值比较小的节点(优先级值越小优先级越高,优先级可通过
slave-priority
配置) - 如果节点的优先级相同,则查看进行主从复制的 offset 的值,即复制的偏移量,偏移量越大则表示其同步的数据越多,优先级越高
- 如果 offset 也相同了,那只能比较 ID 号,选择 ID 号比较小的那个作为主节点(每个实例 ID 不同)
- 选好主节点之后,哨兵 leader 会让其他从节点全部成为新 master 节点的 slave 节点
- 最后利用 Redis 的发布/订阅机制,把新主节点的 IP 和端口信息推送给客户端,此时主从切换就结束了
- 旧主节点恢复了怎么办?
- 实际上哨兵会继续监视旧的主节点,如果它上线了,哨兵集群会向它发送 slaveof 命令,让它成为新主节点的从节点
20. Redis集群会脑裂吗
会出现脑裂
1. 什么是脑裂
- 脑裂是指分布式系统中节点之间失去正常联系,导致集群分成多个孤立的团体,每个团体都认为自己是”完整的集群”,从而造成数据一致性和可用性的严重问题
- 导致脑裂出现原因主要是网络分区
2. Redis中的脑裂
eg:发生了网络分区,主节点与哨兵、从节点分区了
此时哨兵发现联系不上子节点,于是发起选举,选了新的主节点,此时 Redis 就出现了两个主节点:
这就发生了脑裂,此时客户端写数据应该写到哪台上呢?写哪都会导致数据不一致
3. 如何避免脑裂发生
min-slaves-to-write
:设置主节点在至少有指定数量的从节点确认写操作的情况下才执行写操作min-salves-max-lag
:设置从节点的最大延迟(以秒为单位),如果从节点的延迟超过这个值,则该从节点不会被计入min-slaves-to-write
的计数中- eg:当
min-slaves-to-write
设置为2,min-slaves-max-lag
设置为10秒,主节点只有在至少有2个从节点延迟不超过10秒的情况下才会接受写操作
- eg:当
这两个参数就使得发生脑裂时,如果某个主节点跟随的从节点数量不够或延迟较大,就无法被写入,这样就能避免脑裂导致的数据不一致
4. 脑裂能完全避免吗
并不能。即使配置了以上两个参数也可能会因为脑裂导致数据不一致
- 举个例子,假设某个主节点临时出了问题,哨兵判断它主观下线,然后开始发起选举
- 在选举进行时,主节点恢复了,此时它还是跟着很多从节点,假设
min-slaves-max-lag
配置了 10s,可能此时从节点和主节点延迟的时间才 6s,因此此时主节点还是可以被写入 - 而等选举完毕了,选出新的主节点,旧的主节点被哨兵操作需要 salveof 新主,此时选举时间内写入的数据会被覆盖,因此就导致了数据不一致
21. 订阅/发布的了解
1. what
发布/订阅其实属于消息队列中的一种消息通信模型,生产者(pub)发送消息,订阅者(sub)接收消息
2. 实现原理
创建并发布一个 message 到 Redis 频道,用三条信息进行对应的测试,如图:
># publish channel "hello1"
(integer) 1
># publish channel "hello2"
(integer) 1
># publish channel "hello3"
(integer) 1
然后实现对应的频道订阅
subscribe channel
首先是 subscribe 以及 channel,其分别表示执行订阅及订阅的频道
integer 1
:表示订阅成功message
:表示接收到的消息,之后出现的 channel 表示订阅频道,后面就是消息数据
它支持消费者阻塞式拉取消息,另外还提供了匹配订阅,消费者可以根据一定的规则,订阅多个队列
- eg:
psubscribe mianshiya.*
,此时如果生产者发布了mianshiya.1
、mianshiya.2
、mianshiya.3
等队列的消息,消费者都可以接收到
3. 原理
消费者订阅队列,实际上就是在 Redis 中保存了一个映射关系,即队列 x -> 消费者 1
- 如果生产者往这个队列 x 发送一条消息,Redis 不会做任何的存储动作,而是查找映射关系,然后立马转发给消费者1,所以 Redis 实际上就是提供了一个“转发通道”
- 如果找到多个映射关系,那么就都转发,所以支持多消费者的实现
因此,不论是 rdb 还是 aof 都不会存储消息
4. 缺点
会丢数据!
- 原理得知,Redis 并不会存储消息,那么如果生产者发布消息时,消费者宕机了,当服务恢复时,其间生产者发送的消息就丢失了
- 不仅是宕机会导致消息丢失,如果消费者消费过慢也有可能导致消息丢失
client-output-buffer-limit
参数,默认 pubsub 配置的是 32mb 8mb 60,即缓冲区一旦超过 32 MB 直接把消费者下线,如果持续 60s 超过 8M 也直接下线- Redis 虽然不会存储消息,但是消息会先写入这个缓冲区,供消费者拉取消费。如果消费者处理太慢,导致这个缓冲区溢出,那么消费者被强行下线,消息也就丢了
5. 应用场景
- Redis 的哨兵集群和 Redis 实例通信用的就是发布/订阅模型
- 一般业务上的即使通信场景也可以使用,但是由于消息会丢失,大部分情况下还是会使用消息队列中间件来实现发布/订阅
22. 如何实现分布式锁
基于 Redis 来实现分布锁,需要利用 set ex nx + lua
- 加锁:
SET lock_key uniqueValue EX expire_time NX
- 解锁:使用 lua 脚本,先通过 get 获取 key 的 value 判断锁是否是自己加的,如果是则 del
-- 解锁
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end
1. 过期机制
首先锁需要有过期机制。假设某个客户端加了锁之后宕机了,锁没有设置过期机制,会使得其他客户端都无法抢到锁
EX expire_time
就是设置了锁的过期,单位是秒。还一个 PX
, 也是过期时间,单位是毫秒
- 2.6.12 版本之前只有
SETNX
即 SET if Not Exists,它表示如果 key 已存在,则什么都不会做,返回 0,如果不存在则会设置它的值,返回 1。SETNX
和过期时间的设置就无法保证原子性,如果客户端在发送完SETNX
之后就宕机了,还没来得及设置过期时间,会导致锁不会被释放 - 2.6.12 版本之后,优化了 SET 命令,使得可以执行
set ex|px
2. uniqueValue
设置唯一值(UUID)是为了防止被别的客户端给释放了
Client1
加锁成功,然后执行业务逻辑,但执行的时间超过了锁的过期时间- 此时锁已经过期被释放了,
Client2
加锁成功 Client2
执行业务逻辑Client1
执行完了,执行释放锁的逻辑,即删除锁Client2
一脸懵,我还在执行着呢,怎么锁被人释放了??
先判断锁的值和唯一标识是否一致,一致后再删除释放锁。两步操作,只有使用了 lua 脚本才能保证原子性
23. 分布式锁过期了
可能会产生数据不一致的问题。等于锁失效了,同时有两个竞争者在临界区进行业务操作
1. 看门狗机制
业界出了一个看门狗机制来防止这种情况的产生
- 理论很简单,在抢到锁之后,后台会有一个任务,定时向 Redis 进行锁的续期
- eg:锁的过期时间是 30s,可以每过三分之一时长(30/3)10s 后就去 Redis 重新设置过期时间为 30s
- 在锁被释放的时候,就移除这个定时任务
2. redission
redission 是一个类库,封装了很多 Redis 操作,便于使用
- 其实现的分布式锁就引入了看门狗机制,具体原理和上面所述的一致,基于 Netty 的时间轮实现的定时任务
- 并且 redisson 支持可重入锁,即同一个线程可以多次获取同一个分布式锁,而不会导致死锁
- 在获取锁时,检查当前锁的唯一标识是否已经属于当前线程
- 如果是,则增加一个重入计数器
- 释放锁时,减少重入计数器,只有当计数器为 0 时才真正释放锁
24. red_lock的了解
Red Lock,又称为红锁,其主要是为了保证分布式锁的可靠性提供的一种实现
一般情况下,在生产环境会使用《主从 + 哨兵》方式来部署 Redis
- 如果正在使用 Redis 分布式锁,此时发生了主从切换,但从节点上不一定已经同步了主节点的锁信息
- 所以新的主节点上可能没有锁的信息。此时另一个业务去加锁,一看锁还没被占,于是抢到了锁开始执行业务逻辑
- 此时就发生了两个竞争者同时进入临界区操作临界资源的情况,可能会发生数据不一致的问题
1. 红锁实现原理
首先要使用红锁需要集群部署 Redis,官方推荐至少 5 个实例,不需要部署从库和哨兵,仅需主库。这 5 个实例之间没有任何关系(不同于 Redis cluster),它们之间不需要任何信息交互
- 客户端会对这 5 个实例依次申请锁,如果最终申请成功的数量超过半数(>=3),则表明红锁申请成功,反之失败
- 再来看下异常情况。假设有一台实例宕机了怎么办?实际上没任何影响,因为理论上能申请成功的数量可以达到 4,超过了半数
- 也因为没有主从机制,不会有同步丢失锁的问题
具体加锁流程:
- 客户端获取当前时间(t1)
- 客户端按照顺序依次对 N 个 Redis 节点利用 set 命令进行加锁操作,对每个节点加锁都会设置超时时间(远小于锁的总过期时间),如果当前节点请求超时,立马向下一个节点申请锁
- 当客户端成功从半数的 Redis 节点获取到了锁,这个时候获取一下当前时间 t2,然后计算加锁过程的总耗时 t(t2 - t1)。如果 t < 锁的过期时间,这个时候就可以判断加锁成功,反之加锁失败
- 加锁成功则执行业务逻辑,加锁失败则依次向全部节点发起释放锁的流程
2. 红锁一定安全吗
不一定
- 如果 Client1 抢到了红锁,但此时发生了 gc,暂停了很久,与此同时 Redis 中的锁过期了
- 锁过期的同一时刻 gc 结束了,Client1 认为自己还持有锁,正常执行后续逻辑,而 Client2 也在此时拿到了锁,开始执行后续逻辑
这不就有问题了吗?
- 除了 gc,如果出现时钟漂移。eg:几个 Redis 实例时间跳跃,导致锁提前过期了,也可能会造成别的 Client 抢到锁
- 所以,从理论上看红锁并不是无懈可击,还是有概率出现问题,只不过这个概率非常小
3. 业务上
- 红锁的实现成本其实不低,需要有至少 5 个实例,而且因为要依次加锁,所以性能来说也比不上单实例的 Redis 加锁,且极端环境下还是有问题
- 所以一般业务上还是使用《主从 + 哨兵》来实现分布式锁
25. Redis分布式锁问题
1. 锁提前到期
- 设置一种续约机制(Redisson 中的看门狗机制),线程 a 在执行时,设置一个超时时间,并且启动一个守护线程,守护线程每隔一段时间就去判断线程 a 的执行情况,如果 a 还没有执行完毕并且 a 的时间快过期了,就重新设置一下超时时间,即继续续约
- 过期时间需要好好评估一下。使得在大多数情况下任务能够在锁过期之前完成
- 太长,业务结束了还在阻塞的话,影响 Redis 性能
- 太短,需要看门狗
2. 单点故障问题
如果 Redis 单机部署,当实例宕机或不可用,整个分布式锁服务将无法正常工作,阻塞业务的正常执行
3. 主从问题
Redis 是《主从 + 哨兵》部署的,则分布式锁可能会有问题
- Redis 的主从复制过程是异步实现的,如果主节点获取到锁之后,还没同步到其他的从节点,此时主节点发生宕机了,新主节点上没锁,因此其他 Client 可以获取锁,就会导致多个应用服务同时获取锁
4. 时钟漂移
因为 Redis 分布式锁依赖于实例的时间来判断是否过期,如果时钟出现漂移,很可能导致锁直接失效
- 可以让所有节点的系统时钟通过 NTP 服务进行同步,减少时钟漂移的影响
26. 击穿、穿透、雪崩
1. 缓存击穿
- 指某一热点数据缓存失效,使得大量请求直接打到了DB,增加DB负载
- 加互斥锁:保证同一时间只有一个请求来构建缓存,跟缓存雪崩相同
- 热点数据永不过期:不要给热点数据设置过期时间,在后台异步更新缓存
2. 缓存穿透
- 查询一个不存在的数据,由于缓存中肯定不存在,导致每次请求都直接访问DB,增加DB负载
- 防止非法请求:检查非法请求,封禁其 IP 以及账号
- 缓存空值:将DB中不存在的结果(eg:空值)也缓存起来,并设置一个较短的过期时间,避免频繁查询DB
- 使用布隆过滤器:使用布隆过滤器来快速判断一个请求的数据是否存在,如果布隆过滤器判断数据不存在,则直接返回,避免查询DB
3. 缓存雪崩
- 在某个时间点,大量缓存同时失效或被清空,导致大量请求直接打到DB或后端系统,造成系统负载激增,甚至引发系统崩溃
- 过期时间随机化:设置缓存的过期时间,加上一个随机值,避免同一时间大量缓存失效
- 使用多级缓存:引入多级缓存机制。eg:本地缓存和分布式缓存相结合,减少单点故障风险
- 缓存预热:系统启动时提前加载缓存数据,避免大量请求落到冷启动状态下的DB
- 加互斥锁:使得没缓存或缓存失效的情况下,同一时间只有一个请求来构建缓存,防止数据库压力过大
缓存中间件故障:
- 服务熔断:暂停业务的返回数据,直接返回错误
- 构建集群:构建多个 Redis 集群保证其高可用
27. 保证缓存与DB一致性
以 MySQL 和 Redis 为主要实现的案例,一共有 6 种方式,如图:
1. 先写缓存,再写DB
- 由于网络原因,请求顺序无法保证,可能出现先更新缓存的请求,后更新数据库,而后更新缓存的请求反而先更新了数据库,这样就出现了缓存数据为 20,数据库数据为 10,即数据不一致的情况
2. 先写DB,再写缓存
- 并发和网络问题导致的数据库与缓存不一致
3. 先删缓存,再写DB
- 读获取到的数据是过时的数据,虽然写已经完成了,但是因为缓存被删除了,读就必须从DB中读取到旧值,并不是最新的数据
- 请求 A 先对缓存中的数据进行删除操作
- 请求 B 这个时候来执行查询,发现缓存中数据为空,就去DB进行查询并回写缓存
- 这个时候请求 A 删除缓存中的数据之后,进行DB数据的更新
- 但此时请求 B 已经把从DB查询到的原始数据回写缓存了,如上图,DB中查询的值是 20,而缓存中的数据是 10
4. 先写DB,再删缓存
- 先写DB,再删除缓存,然后在修改DB期间,可以允许一定时间的缓存不一致,保证缓存的最终一致性
- 问题发生的概率比较低,一般而言业务上都会使用这个方案
模型问题,如图:
一个写操作,此时刚好缓存失效,又在同一时刻刚好有一个并发读请求过来,且回写缓存的请求晚于缓存删除,导致数据库与缓存的不一致
5. 缓存双删
- 先删除缓存,再写DB,然后过一段时间再删除缓存
- 这个方案为了避免旧数据被回种,等待一段时间后再延迟删除缓存
- 也可以使用消息队列、定时任务或延迟任务等方式去实现延迟删除
6. 先写DB,Binlog异步更新缓存
- 先修改DB,然后通过 Canal 监听DB的 binglog 日志,记录DB的修改信息,然后通过消息队列异步修改缓存的数据
注意:需要保证顺序消费,保证缓存中数据按顺序更新,然后再加上重试机制,避免因为网络问题导致更新失败
28. Redis不复用c语言的字符串
在 Redis 中,并没有使用 C 标准库提供的字符串,而是实现了一种动态字符串,即 SDS (Simple Dynamic String),通过这种数据结构来表示字符串
1. C语言字符串缺陷
因为 C 语言的字符串本质上就是 char*
的字符数组,存在一定缺陷:
- C 语言字符数组的结尾位置用
\0
表示,是指字符串的结束 - C 语言字符数组获取长度只能通过遍历获得,时间复杂度是
- 字符串操作函数不高效且不安全。eg:缓冲区溢出,其可能导致程序异常终止
针对以上问题,SDS 做了一些改造
len
(长度):记录了 SDS 字符串数组的长度,当需要获取字符串长度时,只需要返回这个成员变量的值就可以了,时间复杂度是allloc
(分配空间长度):指分配给字符数组的存储的空间大小,当需要计算剩余空间大小时,alloc - len
就可以直接进行计算,然后判断空间大小是否符合修改需求,如果不满足需求,就执行相应的修改操作,很好地解决上面所说的缓冲区溢出问题flags
(SDS 的类型):一共设计了五种类型的 SDS,分别是sdshdr 5
、sdshdr 8
、sdshdr 16
、sdshdr 32
、sdshdr 64
(这个的记忆页很简单,就是 32 开始,128,即 2 的多少次方去记忆就可以了),通过使用不同存储类型的结构题,灵活保存不同大小的字符串,从而节省内存空间buf
(存储数据的字符数组):起到保存数据的作用。eg:字符串、二进制数据(二进制安全就是一个重要原因)等
29. 实现一个排行榜
1. DB实现
- SQL 的
Order by
函数,每次根据DB中的数据进行排序,返回给前端。如果需要前多少名的数据,可以在排序之后进行limit
,实现起来非常简单 - 性能不好,每次玩家数据变更都需要重排,DB操作比较重,效率比较低
2. Redis实现
- Zset 天然支持排行榜实现,其中 score 就是排行的分数依据
- 通过 ZRANGE 就可以获取排行榜(分数从低到高,
ZREVRANGE
从高到底) - 增加、删除元素的时间复杂度都是 ,获取排行的复杂度平均也是
import redis.clients.jedis.Jedis;
public class RedisLeaderboard {
private static final String LEADERBOARD_KEY = "leaderboard";
private Jedis jedis;
public RedisLeaderboard() {
// 连接到 Redis
this.jedis = new Jedis("localhost", 6379);
}
// 添加或更新用户分数
public void addOrUpdateUserScore(String user, double score) {
jedis.zadd(LEADERBOARD_KEY, score, user);
}
// 获取排行榜前 N 名用户
public Set<String> getTopUsers(int topN) {
return jedis.zrevrange(LEADERBOARD_KEY, 0, topN - 1);
}
// 获取用户的排名
public Long getUserRank(String user) {
return jedis.zrevrank(LEADERBOARD_KEY, user);
}
// 获取用户的分数
public Double getUserScore(String user) {
return jedis.zscore(LEADERBOARD_KEY, user);
}
// 删除用户
public void removeUser(String user) {
jedis.zrem(LEADERBOARD_KEY, user);
}
// 获取排行榜分页查询
public Set<String> getUsersByPage(int page, int pageSize) {
int start = (page - 1) * pageSize;
int end = start + pageSize - 1;
return jedis.zrevrange(LEADERBOARD_KEY, start, end);
}
// 关闭连接
public void close() {
jedis.close();
}
public static void main(String[] args) {
RedisLeaderboard leaderboard = new RedisLeaderboard();
// 添加用户及其分数
leaderboard.addOrUpdateUserScore("user1", 100);
leaderboard.addOrUpdateUserScore("user2", 200);
leaderboard.addOrUpdateUserScore("user3", 150);
// 更新用户分数
leaderboard.addOrUpdateUserScore("user1", 250);
// 获取排行榜前 3 名用户
Set<String> topUsers = leaderboard.getTopUsers(3);
System.out.println("Top 3 users: " + topUsers);
// 获取用户的排名
Long rank = leaderboard.getUserRank("user1");
System.out.println("User1 rank: " + rank);
// 获取用户的分数
Double score = leaderboard.getUserScore("user1");
System.out.println("User1 score: " + score);
// 获取排行榜第 2 页,每页 2 个用户
Set<String> pageUsers = leaderboard.getUsersByPage(2, 2);
System.out.println("Page 2 users: " + pageUsers);
// 删除用户
leaderboard.removeUser("user1");
// 关闭连接
leaderboard.close();
}
}
30. 实现一个布隆过滤器
用来快速检测一个元素是否存在一个集合中,原理:利用多个哈希函数将元素映射到固定的点位上(数组中),面对海量数据它占据的空间也非常小
- 如果判断一个元素不存在集合中,那么这个元素一定不在集合中,如果判断元素存在集合中则不一定是真的,因为哈希可能会存在冲突。有误判的概率
- 不好删除元素,只能新增,如果想要删除,只能重建
用 Redis 的 bitmap 可以实现布隆过滤器,在 java 中使用 Redisson 的 bloomFilter 可以直接使用布隆过滤器
bitmap 基本操作:
SETBIT key offset value
:将 key 的值在 offset 位置上的位设置为 value(0 或 1)GETBIT key offset
:获取 key 的值在 offset 位置上的位的值(0 或 1)BITCOUNT key [start end]
:计算字符串中设置为 1 的位的数量BITOP operation destkey key [key ...]
:对一个或多个 key 进行位运算,并将结果存储在 destkey 中。支持的操作包括AND, OR, XOR, NOT
import redis.clients.jedis.Jedis;
import java.nio.charset.StandardCharsets;
import java.util.BitSet;
public class RedisBloomFilter {
private static final String BLOOM_FILTER_KEY = "bloom_filter";
private static final int BITMAP_SIZE = 1_000_000; // 位图大小
private static final int[] HASH_SEEDS = {3, 5, 7, 11, 13, 17}; // 多个哈希函数的种子
private Jedis jedis;
private List<SimpleHash> hashFunctions;
public RedisBloomFilter() {
this.jedis = new Jedis("localhost", 6379);
this.hashFunctions = new ArrayList<>();
for (int seed : HASH_SEEDS) {
hashFunctions.add(new SimpleHash(BITMAP_SIZE, seed));
}
}
// 添加元素到布隆过滤器
public void add(String value) {
for (SimpleHash hashFunction : hashFunctions) {
jedis.setbit(BLOOM_FILTER_KEY, hashFunction.hash(value), true);
}
}
// 检查元素是否可能存在于布隆过滤器中
public boolean mightContain(String value) {
for (SimpleHash hashFunction : hashFunctions) {
if (!jedis.getbit(BLOOM_FILTER_KEY, hashFunction.hash(value))) {
return false;
}
}
return true;
}
// 关闭连接
public void close() {
jedis.close();
}
// 简单哈希函数
public static class SimpleHash {
private int cap;
private int seed;
public SimpleHash(int cap, int seed) {
this.cap = cap;
this.seed = seed;
}
public int hash(String value) {
int result = 0;
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
for (byte b : bytes) {
result = seed * result + b;
}
return (cap - 1) & result;
}
}
public static void main(String[] args) {
RedisBloomFilter bloomFilter = new RedisBloomFilter();
// 添加元素到布隆过滤器
bloomFilter.add("user1");
bloomFilter.add("user2");
bloomFilter.add("user3");
// 检查元素是否可能存在
System.out.println("Does user1 exist? " + bloomFilter.mightContain("user1")); // true
System.out.println("Does user4 exist? " + bloomFilter.mightContain("user4")); // false
// 关闭连接
bloomFilter.close();
}
}
31. Redis统计海量UV
- 在 Redis 中,有一种可以快速实现网页 UV 、PV 等值统计的数据结构,即 HyperLogLog
- HyperLogLog 是一种基数估算算法,可以用于快速计算一个集合中的不同元素数量的近似值
- HyperLogLog 具有极小的内存占用(每个 HyperLogLog 结构约 12 KB),而允许一定的误差(通常在 0.81% 以内)
HyperLogLog 的基本操作:
PFADD key element [element ...]
:将一个或多个元素添加到 HyperLogLog 数据结构中PFCOUNT key [key ...]
:返回 HyperLogLog 结构中不重复元素的近似数量PFMERGE destkey sourcekey [sourcekey ...]
:将多个 HyperLogLog 合并为一个
eg:如何使用 Redis 的 HyperLogLog 实现页面 UV 统计:
import redis.clients.jedis.Jedis;
public class RedisHyperLogLogUV {
private static final String UV_KEY = "uv";
private Jedis jedis;
public RedisHyperLogLogUV() {
// 连接到 Redis
this.jedis = new Jedis("localhost", 6379);
}
// 添加用户访问记录
public void addUserVisit(String userId) {
jedis.pfadd(UV_KEY, userId);
}
// 获取独立用户访问量
public long getUniqueVisitorCount() {
return jedis.pfcount(UV_KEY);
}
// 关闭连接
public void close() {
jedis.close();
}
public static void main(String[] args) {
RedisHyperLogLogUV uvCounter = new RedisHyperLogLogUV();
// 模拟用户访问
uvCounter.addUserVisit("user1");
uvCounter.addUserVisit("user2");
uvCounter.addUserVisit("user3");
uvCounter.addUserVisit("user1"); // 重复访问
uvCounter.addUserVisit("user4");
// 获取独立用户访问量
long uniqueVisitors = uvCounter.getUniqueVisitorCount();
System.out.println("Unique Visitors: " + uniqueVisitors); // 输出: 4
// 关闭连接
uvCounter.close();
}
}
32. Geo结构了解吗
- Geo 就是 Geolocation 的简写形式,代表地理坐标
- Redis 2.2 版本后新增的数据类型,Redis GEO 主要用于地理位置信息的存储,提供了丰富的指令来帮助实现地理位置的查找以及计算
1. 常用命令
# 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中
GEOADD key longitude latitude member [longitude latitude member ...]
# 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil
GEOPOS key member [member ...]
# 返回两个给定位置之间的距离
GEODIST key member1 member2 [m|km|ft|mi]
# 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
2. 底层实现
- GEO 底层并没有使用新的数据结构,直接使用了 Sorted Set 集合类型
- GEO 类型通过使用 GeoHash 编码的方法实现了经纬度到 Sorted Set 中元素权重分数的转换
1. 扩展Geohash
- Geohash 是一种将二维地理坐标(经度和纬度)编码为一维字符串的方法
- 它将地理区域递归地划分成更小的网格,并为每个网格分配一个唯一的标识符
Geohash 的基本思想如下:
- 区域划分:将整个地球分成一个矩形,并不断地将每个矩形划分成更小的矩形
- 编码:根据划分区域的递归顺序,为每个区域分配一个唯一的二进制编码。编码的长度越长,表示的位置越精确
- 当拿到一个坐标时,Geo 会先根据这组坐标转换为 Geohash 编码,这个编码其实就代表了某块区域,将这个编码值作为 Sorted Set 中相应元素的权重分数
- 这样一来,就可以实现经纬度信息存储到 Sorted Set 中这个需求,并且利用 Sorted Set 提供的 “按分数进行有序范围查找的特性”,实现坐标等地理位置信息的搜索以及查询
33. Redis客户端
官方主要推荐的客户端有三种:
- Jedis:适用于简单的同步操作和单线程环境
- Lettuce:适用于高并发、高性能和多线程环境,尤其是需要异步和响应式编程的场景
- Redisson:适用于复杂的分布式系统,提供丰富的分布式对象和服务,简化开发
1. Jedis
Jedis 是一款比较经典的Java 实现的 Redis 客户端,里面提供了比较全面的 Redis 命令,也是使用最为广泛的 Redis 客户端
优点:
- 简单易用:提供了直观的 API,使得开发者能够方便地与 Redis 进行交互
- 使用广泛:在 Java 社区中被广泛采用,有丰富的文档和示例可供参考
- 性能良好:在大多数情况下能够提供高效的 Redis 操作
- 功能丰富:支持常见的 Redis 操作,如字符串、列表、哈希、集合等数据结构的操作
缺点:
- 线程安全问题:线程不安全,每个线程需独立使用 Jedis 实例
- 不支持自动重连:在网络异常或 Redis 服务器重启时,需要手动处理重连
- 阻塞操作:同步的 API,因此高并发下可能会发生阻塞
2. Lettuce
Lettuce 其相对于 Jedis,其最突出的点就是线程安全,且其扩展性较高,从 SpringBoot 2.X 开始,Lettuce 逐渐取代 Jedis 成为 SpringBoot 默认的 Redis 客户端。它支持异步和响应式 API,底层基于 Netty 实现
优点:
- 多线程安全:在多线程环境中可以安全使用
- 高性能:提供了高效的 Redis API 操作性能
- 自动重连:当网络连接出现问题时,能够自动重新连接
- 支持多种编程模型:同步、异步、响应式,适应不同的应用场景
缺点:
- 学习曲线较陡:API 相对复杂,学习曲线较高
- 资源消耗:异步和响应式 API 可能会消耗更多的资源,需要仔细调优
3. Redisson
Redisson 是一个高级的 Redis 客户端,提供分布式和并行编程的支持,提供了丰富的分布式对象和服务,底层也是基于 Netty 实现通信
优点:
- 易用性:简化了 Redis 操作,提供了简洁的 API
- 高级特性:支持分布式锁、缓存、队列等常见场景
- 支持集群:支持 Redis 集群模式,适应大规模分布式应用
- 线程安全:无需手动处理多线程问题
- 高性能:优化的底层实现,提高性能
- 稳定性:经过广泛使用和验证
缺点:
- 学习成本:需要一定时间来熟悉其 API 和特性
- 可能的依赖问题:与其他库的兼容性可能需要注意
34. Redis最大字符串
Redis 字符串能存储的最大容量是 512 MB。官方文档:
- 无论是网络传输、内存分配还是字符串操作,大字符串都会增加 Redis 服务器的负载
- 且过大的字符串在
GET、SET、APPEND、STRLEN
等操作都会导致性能瓶颈 - 所以官方给字符串的大小做了限制,防止单个键值对占用过多的内存,影响整体性能和稳定性
35. Redis扛不住了怎么办
- 扩容。eg:增加 Redis 的配置,容纳更多的内存等
- 超过单机配置了,Redis 主从,通过从服务分担读取数据的压力,利用哨兵自动进行故障转移
- 利用 Redis 集群进行数据分片。eg:Redis Cluster
- 增加本地内存,通过多级缓存分担 Redis 的压力
36. EMBSTR阈值是44?曾经39?
- Redis 使用 jemalloc 内存分配器,jemalloc 以 64 字节作为阈值区分大小字符串 raw 和 EMBSTR
- 然后 redisObject 固定占用 16 个字节,然后 sdshdr 中
已分配、已申请、标记
这 3 个字段各自占用 1 个字节,\0
占用 1 个字节,最终剩余 44 个字节 - 因为 3.2 前后 Redis 关于 sdshdr 结构的差异,3.2 之后的版本 EMBSTR 使用 sdshdr8 这个结构,总容量和已使用容量字段减少了 6 个字节,但是 3.2 之后的版本增加了一个 flags 字段,所以最终 3.2 版本之前的 EMBSTR 结构少了 5 个字节
1. EMBSTR阈值为44字节
- Redis 使用的是 jemalloc 作为内存分配器
- jemalloc 是以 64 字节作为内存单位进行内存分配的,如果超过了 64 字节,即超过了一个内存单元,使用的就是 raw 编码,反之使用的就是 EMBSTR 编码
- 核心就是这个 64 字节,围绕 64 字节这个关键点来分析。Redis 的字符串对象是 redisObject 和 sdshdr 这两个部分组成的,redisObject 大小为
4 + 4 + 24 + 32 + 64 = 128bits = 16 bytes(16 字节)
这个是一直没有改变的,计算来源如下:
// from Redis 3.9.5
#define LRU_BITS 24
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
/* LRU time or LFU data */
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;
sdshdr 结构:
// from Redis 3.9.5
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
sdshdr 占用的内存大小:3 byte + 字符数组的大小
,由于字符数组内部保留的一个\0
的占位符,所以剩下的空间只有 44 个字节了
2. 之前版本阈值39?
其主要还是因为 sds 结构的版本差异,在 3.2 以前 sdshdr 的版本结构如下:
struct SDS {
unsigned int capacity; // 4byte
unsigned int len; // 4byte
byte[] content; // 内联数组,长度为 capacity
}
- 非数据字段就占用了 8 个字节,为了节约内存,3.2 版本之后的sds不再使用 sdshdr5 这个结构了,就剩下 sdshdr8、sdshdr16、sdshdr32、sdshdr64 这 4 个结构
- 然后 EMBSTR 使用 sdsjdr8 节约了 6 个字节,然后多引入一个 flags 字段占用 1 字节,所以现版本的 EMBSTR 相比 3.2 版本之前的 sds 多了 5 个字节
37. mset,mget,Pipeline区别
原生批处理命令与管道 Pipeline 区别:
- 原生批处理命令是原子性的而 Pipeline 是非原子性的
- Pipeline 更灵活,因为它可以组合多种命令。eg:又读又写,而原生批处理则不提供这样的功能
场景:
- 原生批处理命令适用于一次性处理多个键值对或获取多个键的值的场景,通过减少单独请求的次数来提高效率
- Pipeline 更适用于需要一次性处理多个命令的场景。通过将多个命令打包发送,可以减少网络通信开销,并在一次调用中获取多个命令的结果
38. 主从几种常见的拓扑结构
忽略哨兵
1. 一主多从
- 最基本的拓扑结构,包含一个主节点、多个从节点。所有写操作都在主节点上执行,而读操作可以在从节点上进行,以提高读取速度和负载均衡
2. 树状主从结构(级联)
- 从节点也可以作为其他从节点的主节点。这样形成了一个层次结构,主节点负责写操作,而从节点负责读操作,并将数据再次复制到更下一级的从节点
- 因为主从复制对主节点有压力,所以这样的结构可以减轻主节点的压力
3. 主主结构(双主或多主)
- 在这种拓扑中,有两个或多个主节点,它们之间相互复制数据。这种结构提高了系统的写能力和容错性
- 但需要处理多主节点之间的数据同步和冲突解决,管理复杂度高
39. List常见命令
Redis 中的 List 类型是一个字符串列表
lpush
:将一个或多个值插入到列表头部。列表不存在,一个新的列表会被创建rpush
:将一个或多个值插入到列表尾部lpop
:移除并返回列表头部的元素rpop
: 移除并返回列表尾部的元素lrange
:获取列表指定范围内的元素lindex
: 通过索引获取列表中的元素llen
: 获取列表长度lset
: 将列表中指定索引的元素设置为另一个值lrem
: 移除列表中与参数匹配的元素ltrim
: 修剪(裁剪)一个已存在的 list,使其只包含指定范围的元素
lpush mylist a # 在列表 'mylist' 的头部插入元素 'a'
rpush mylist b # 在列表 'mylist' 的尾部插入元素 'b'
lpop mylist # 移除并返回 'mylist' 的第一个元素
rpop mylist # 移除并返回 'mylist' 的最后一个元素
lrange mylist 0 -1 # 返回 'mylist' 中的所有元素
lindex mylist 0 # 获取 'mylist' 中索引为 0 的元素
llen mylist # 返回 'mylist' 的长度
lset mylist 0 x # 将 'mylist' 中索引为 0 的元素设置为 'x'
lrem mylist 1 a # 从 'mylist' 中移除第一个 'a'
ltrim mylist 1 2 # 保留 'mylist' 中索引从 1 到 2 的元素,其他的删除
40. 如何实现队列和栈
- 队列是先进先出(FIFO)
lpush myqueue value # 在队列头部插入值
rpop myqueue # 从队列尾部移除并获取值
- 栈是后进先出(LIFO)
lpush mystack value # 在栈顶插入值
lpop mystack # 从栈顶移除并获取值
注意: list 内没数据,则执行 rpop/lpop
操作,返回 null
// 业务代码消费逻辑只能使用死循环来消费队列
while(true) {
msg = redis.rpop("queue");
if (msg == null) {
continue;
}
process(msg);
}
- 如果队列里长时间没有消息,这样会导致应用 cpu 空转,疯狂消耗 cpu 资源,还会频繁请求 Redis,给缓存上上压力
- Redis 提供了阻塞式消费 list 接口,即
brpop/blpop
,b 指的是 block,并且还支持超时时间,即等待一段时间还未接收到消息,先返回 null
while(true) {
msg = redis.brpop("queue", 5);
if (msg == null) {
sout("写一些日志,或者其他什么动作")
continue;
}
process(msg);
}
41. Ziplist、Quicklist
1. Ziplist
- Ziplist(压缩列表)是一种紧凑的数据结构,它将所有元素紧密排列存储在单个连续内存块中,十分节省空间
- 这些元素可以是字符串或整数,且每个元素的存储都紧凑地排列在一起
zlbytes
:记录整个 ziplist 所占用的字节数zltail
:记录 ziplist 中最后一个节点距离 ziplist 起始地址的偏移量zllen
:记录 ziplist 中节点的个数entry
:各个节点的数据zlend
:特殊值 0xFF,用于标记 ziplist 的结束
entry 更详细的结构,会记录前一个节点的长度和编码:
- 也因为 entry 需要记录前一个元素的大小,如果前面插入的元素很大,则已经存在的 entry 的
pre_entry_length
字段需要变大,它又变大后续的节点也需要变,所以可能导致连锁更新的情况,影响性能 - 查询需按顺序遍历所有元素,逐个检查是否匹配查询条件
特点:
- 紧凑性:所有元素紧密排列在一起,没有额外的内存开销,适合存储少量数据
- 顺序访问:由于元素是按顺序存储的,顺序访问性能较好,但随机访问性能较差
应用:
- 列表(List):当列表长度小于一定阈值(默认 512 个元素)且每个元素的长度小于 64 字节时,Redis 会使用 Ziplist 存储列表
- 哈希表(Hash):当哈希表中键值对的数量少于一定阈值(默认 512 对)且每个键和值的长度都小于 64 字节时,Redis 会使用 Ziplist 存储哈希表
2. Quicklist
Quicklist 结合了 Ziplist 和双端链表的优点,每个 Quicklist 节点都是一个 Ziplist,它限制了单个 Ziplist 的大小,降低级联更新产生的影响
特点:
- 高效内存利用:结合了 Ziplist 的内存紧凑性和双端链表的快速插入、删除操作
- 降低级联更新产生的影响
应用:
- 列表(List):当列表长度超过 Ziplist 的阈值时,Redis 会使用 Quicklist 存储列表
42. 复制延迟原因
指从节点同步主节点数据时可能出现时间延迟。在读写分离场景,这个延迟会导致明明写入了数据,但是去从节点查的时候没查到
- 网络原因
- 可能是带宽不足,或者网络抖动导致同步的延迟
- 不过一般内网情况下不会产生这个问题
- 大量写操作
- 主节点接收到大量的写操作,在处理客户端请求的同时,还需向从节点发送复制数据。如果主节点负载较高时,来不及处理从服务的复制请求
- 大量写操作无法避免。但是可优化下写入的结构,精简数据,降低单条数据的大小
- 复制缓存区溢出
- 复制缓存区暂存当前主节点接收到的写命令。如果从节点处理过慢,写入的命令又过多,则会导致复制缓冲区溢出,此时主节点会断开与从节点的连接
- 可通过
client-output-buffer-limit
间接控制缓冲区大小
- 主节点持久化,无法及时响应复制请求
- 生成 RDB 快照或 AOF 文件重写都会占用大量的 CPU 和 I/O 资源,可能会影响复制的速度
- 避免在高峰期触发持久化动作
- 从节点配置太差
- 因为从节点需要接收、处理和存储主节点发送的数据。如果从节点性能较低,处理数据的速度会慢,从而导致延迟
- 此时需要升配
43. Redis、DB事务区别
DB事务 ACID 的定义:
- 原子性(Atomicity):事务中的所有操作要么全部成功,要么全部失败,且可以回滚到事务开始前的状态
- 一致性(Consistency):事务执行前后,DB必须保持一致的状态
- 隔离性(Isolation):事务的执行是隔离的,事务之间不会相互干扰。支持不同的隔离级别(eg:读未提交、读已提交、可重复读、序列化)
- 持久性(Durability):一旦事务提交,数据变更就会永久保存在数据库中
一一对比下 Redis 中的事务是否符合 ACID:
- 原子性:提供部分原子性,但不是完整的ACID事务。如果事务中的一个命令失败,其他命令仍然会继续执行,事务不会自动回滚
- 一致性:无法回滚,无法保证一致性
- 隔离性:单线程模型,在执行事务的过程中,其他命令不会插入到事务执行的过程中,但无法设置不同隔离级别
- 持久性:依赖于配置(eg:RDB、AOF),但理论上还是会丢数据
Redis 事务: 根本就不是我们理解的传统事务
- Redis 的事务是通过
MULTI, EXEC, DISCARD, WATCH
命令来控制- 事务开始时用 MULTI 命令,此后的所有命令都不会被立即执行,而是被放入一个队列。要执行 exec 命令才会原子性连续执行,其间不会被插入其他命令
- 事务的执行不支持回滚,如果中间命令出错,后续的命令还是会继续执行,且不会回滚之前的执行
- Redis 使用 WATCH 命令实现乐观锁机制。如果被 WATCH 监控的键改变,则直接中断事务,并不会回滚
44. Cluster,Sentinel模式区别
Redis 的 Cluster 模式和 Sentinel 模式提供了不同的机制来处理高可用性和数据分片,各自适用于不同的场景
- Redis Cluster 是 Redis 集群,提供自动分片功能,将数据自动分布在多个节点上,支持自动故障转移。如果一个节点失败,集群会自动重新配置和平衡,不需要外部介入,因为它内置了哨兵逻辑
- Sentinel 是哨兵,主要用于管理多个 Redis 服务器实例来提高数据的高可用性。当主节点宕机,哨兵会将从节点提升为主节点,它并不提供数据分片功能
- 需要处理大量数据并进行数据分片,应选择 Redis Cluster,它支持水平扩展,适用于大规模数据、高吞吐量场景
- 只是为了提高 Redis 可用性,并不需要数据分片,应选择《主从 + Sentinel》,它主要关注故障转移和实例高可用,适用于高可用性、读写分离场景
45. Redis的ListPack
很多人对 ListPack 比较陌生,它其实是用来替代 ziplist 的
- 在 listpack 中,Redis 作者描述了下面这段话:
- 因为发现了一个 bug,怀疑了 ziplist 连锁更新导致的,所以就设计了 listPack
它采用一种紧凑的格式来存储多个元素(本质上仍是一个字节数组),并且使用多种编码方式来表示不同长度的数据
header
:整个 listpack 的元数据,包括总长度和总元素个数elements
:实际存储的元素,每个元素包括长度和数据部分end
:标识 listpack 结束的特殊字节
element 内部结构:
encoding-type
:元素的编码类型element-data
:实际存放的数据element-tot-len
:encoding-type + element-data
的总长度,不包含自己的长度
- 之所以设计它来替换 ziplist 就是因为 ziplist 连锁更新的问题,因为 ziplist 的每个 entry 会记录之前的 entry 长度
- 而 listpack 的每个元素,仅记录自己的长度,这样一来修改会新增不会影响后面的长度变大,也就避免了连锁更新的问题
46. 内存碎片化如何解决
指内存使用中出现小块空间被闲置,无法被有效利用的现象
- Redis 默认使用 jemalloc 作为内存分配器,它是按照固定大小来分配内存的,eg:实际需要 8kb 的内存,分配器给了 12kb
- 那么多余的 4kb 其实就无法被利用上了,就叫内存碎片
且频繁创建和删除大量数据时,会导致内存块大小和位置不连续,内存碎片会变多
可以通过 INFO memory
命令查看内存碎片率(mem_fragmentation_ratio
):
# Memory
used_memory:1000000
# 实际申请的内存空间
used_memory_human:977.54K
# 表示实际占用的物理内存空间(含内存碎片)
used_memory_rss:1200000
used_memory_rss_human:1.14M
mem_fragmentation_ratio:1.20
used_memory
:Redis 实际使用的内存,单位是字节used_memory_rss
:从操作系统的角度来看,Redis 占用的总内存量(含内存碎片),单位是字节mem_fragmentation_ratio = used_memory_rss / used_memory
:大于 1 就代表有内存碎片- 如果值较大,就需要考虑内存碎片的清理了
- 如果小于 1 问题也很大,说明 Redis 已经使用了 swap 用上磁盘空间了,性能会变得很差
解决内存碎片的方式:
- 最简单的解决方法是定期重启 Redis 服务,这样可以消除内存碎片并优化内存的布局,但是会导致服务不可用
- Redis 4.0 及以上版本引入了内存碎片整理功能。通过配置
activedefrag
选项,Redis 可以在运行时尝试整理内存碎片,将小的内存块合并为更大的块 - 通过优化数据存储结构和类型。eg:使用 ListPack 替代 ziplist
- 利用
MEMORY PURGE
命令手动清理碎片,但是这个命令会阻塞主线程
47. VM机制是什么
- Redis 的 VM 机制(Virtual Memory)曾经是 Redis 早期版本(2.0 之前)的一部分,用于将部分数据存储在磁盘上,以扩展内存数据库的容量。当内存不足时,Redis 会将冷数据(不经常访问的数据)移到磁盘,并将热数据(经常访问的数据)保留在内存中
- 虽然能使用的数据变多了,但是数据存到磁盘在获取会显著的使得性能下降!满足不了高并发的场景,因此 Redis 在 2.0 版本之后放弃了 VM 机制,转而推荐使用更高效的内存淘汰策略来管理内存
抛弃 VM 具体原因如下:
- 性能考虑:Redis 的设计初衷是作为一个高性能的内存数据库,频繁的磁盘 I/O 操作违背了这一目标
- 简化架构:去掉 VM 机制后,Redis 的架构变得更为简单,易于维护和优化
- 现代硬件的发展:随着内存价格的下降和容量的增加,服务器可以上更大的内存
了解即可
- 分页:Redis 将数据分成多个页面,每个页面的大小固定(默认为 4 KB)
- 冷热数据区分:Redis 维护一个 LRU(Least Recently Used)列表,用于跟踪数据的访问频率。冷数据会被移动到磁盘,而热数据会保留在内存中
- 交换数据:当 Redis 需要更多内存时,它会将冷数据页面写入磁盘,并在内存中释放这些页面。当需要访问这些数据时,Redis 会从磁盘读取相应页面并加载到内存中
48. 集群,key如何定位节点
Redis 集群之间初始化时,节点之只知道自己的槽位,不知道其他节点槽位的,他们之间会通过 Gossip 协议使用 ping 和 pong 响应进行通信,彼此之间交换槽位的信息
Client可以将请求打在 Redis 集群中的任意一个节点,流程进行分析:
- Client会使用 CRC16 算法计算出 Key 的哈希值,然后将哈希值对 16384 取模,从而计算 key 最终要落到的槽位
- 一般客户端在启动时会从集群中获取哈希槽到节点的映射关系,选择对应的节点
- 如果节点上有数据则直接返回,如果访问的 key 不在连接的节点上时,返回一个重定向命令
MOVED
或ASK
)- Client 收到
MOVED
响应时,表示 key 所在的哈希槽已经被移动到另一个节点,客户端需要更新哈希槽映射并重试操作 - Client 收到
ASK
响应时,表明 Redis 集群正在进行伸缩(扩容 / 缩容)
- Client 收到
- Redis 客户端根据
MOVED/ASK
指令重定向到正确的 Redis 节点
演示 MOVED
情况:
1. ASK重定向工作原理
- Client 请求:Client 发送一个命令来访问某个 key
- 哈希槽迁移中的源节点:如果该 key 所在的哈希槽正在从源节点迁移到目标节点,源节点会返回一个 ASK 重定向指令
- Client 处理 ASK 重定向:Client 收到 ASK 重定向后,首先发送一个 ASKING 命令到目标节点,随后重新发送原始命令到目标节点
为什么Client 需要先发送一个 ASKING 命令到目标节点,然后再发送实际的请求?
- 因为集群扩容还未完成,所以理论新的节点还未完全拥有这个槽,而
ASKING
命令其实是一个临时授权,告诉目标节点即使该节点还没有正式拥有该哈希槽,也要暂时处理来自该哈希槽的请求 - 如果没有先发送 ASKING 命令,目标节点可能会因为还没有正式接管哈希槽而拒绝处理请求