为什么要用缓存?
用缓存,主要有两个用途:高性能、高并发。
高性能
直接查询Redis缓存比查询数据库要快得多
高并发
高并发的时候,可能数据库顶不住会宕机,但Redis承载并发量是 mysql 单机的几十倍。
缓存是走内存的,内存天然就支撑高并发。
数据类型
目前支持 6 种数据类型:string、hash、list、set、zset、HyperLogLog。
string 类型
常规 KV 结构,其中 V 可以是 string 或 number 类型。
容量:string 的一个 key 最大能存 512MB。
应用场景:
- 简单值缓存:token,验证码。
- 计数器:登录错误次数计数,产品库存。
底层:简单动态字符串(SDS)。
简单动态字符串(SDS)
- O(1)复杂度获取字符串长度: 结构中定义len,记录了字符串长度。O(1)复杂度获取字符串长度。
- 减少内存分配次数: 结构中定义alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,还会给 SDS 分配额外的「未使用空间」。这样的好处是,下次在操作 SDS 时,如果 SDS 空间够的话,API 就会直接使用「未使用空间」,而无须执行内存分配,有效的减少内存分配次数。
- 节省内存空间: 结构中定义flags 成员变量,表示的是 SDS 类型。根据不同大小的字符串保存对应的SDS类型,从而有效节省内存空间。
- 二进制安全: C语言用 “\0” 字符来标识字符串结尾。SDS有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据。
hash 类型
hash 是一个的键值对集合,其中 V 相当于一个 HashMap,适用于存储对象数据。
容量:hash 的一个 key 最大可以存储 2^32 -1 个键值对(40 多亿)。
应用场景:对象信息,如用户信息,产品信息。
底层:哈希表或压缩列表,包含的元素数量较少,或者元素值不大的情况才会使用压缩列表,否则用哈希表。
压缩列表
压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。不能保存过多的元素,否则查询效率就会降低。
哈希表
- 查询数据 O(1) 的复杂度:哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。
- 链式哈希来解决哈希冲突:每个哈希表节点都有一个 next 指针,用于指向下一个哈希表节点,因此多个哈希表节点可以用 next 指针构成一个单项链表,被分配到同一个哈希桶上的多个节点可以用这个单项链表连接起来,这样就解决了哈希冲突。
- rehash:如果哈希冲突严重,随着链表长度的增加,在查询这一位置上的数据的耗时就会增加,毕竟链表的查询的时间复杂度是 O(n)。通过rehash解决,对哈希表的大小进行扩展。结构定义了 2 个哈希表,是因为进行 rehash 的时候,需要用上 2 个哈希表了。在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。执行rehash的时候,给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;将「哈希表 1」的数据迁移到「哈希表 2」 中。
- 渐进式 rehash:如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。渐进式 rehash 步骤:给「哈希表 2」 分配空间;在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上;随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。
list 类型
list 是有序列表,它可以压入头部和尾部,也可以弹出头部和尾部。
容量:list最多可存储 2^32 - 1 个元素元素(40 多亿)。
应用场景:微博的关注列表,粉丝列表,消息列表。
底层:快速列表(QuickList)。
- quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。
- 在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的 quicklistNode 结构。
set 类型
set 是无序集合,其中 value 相当于 HashSet。适用于数据去重、排重、交集,并集,差集等场景。
容量:集合中最多可以存储 2^32 - 1 个元素元素(40 多亿)。
应用场景:共同爱好等。
底层:底层是整数集合或哈希表。如果集合对象的所有元素都是整数值,并且保存元素小于 512 个时,底层将使用整数集合。否则用哈希表。
整数集合
- 当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现。
- 整数集合升级的好处是节省内存资源
- 整数集合会有一个升级规则,就是当我们将一个新元素加入到整数集合里面,如果新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进行升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性。
- 整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。
zset 类型
zset 是排序集合,其中 value 相当于 TreeSet。适用场景和 set 相似,但数据是有序的。增加了一个权重参数score,使得集合中的元素能够按score进行有序排列。
容量:集合中最多可以存储 2^32 - 1 个元素元素(40 多亿)。
应用场景:金额最多的前几位。
底层:底层是压缩列表或跳表+哈希表,如果元素少于 128 个,且每个元素长度小于 64 字节,使用压缩列表。否则用跳表+哈希表。
跳表
- 支持平均 O(logN) 复杂度的节点查找
- zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。
- Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。
- Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。
- 哈希表只是用于以常数复杂度获取元素权重,大部分操作都是跳表实现的。
- 查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是 O(logN)。
- 查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断,共有两个判断条件:- 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。- 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。

如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:
- 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
- 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
- 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
- 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。
Redis 的线程模型(重要,灵魂之处)
线程模型
Redis内部使用文件事件处理器,这个文件事件处理器是单线程的,所以 Redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 Socket,将产生文件事件的 Socket 压入内存队列中,同时文件事件分派器每次从队列中取出一个 Socket,根据 Socket 上的文件事件类型来选择对应的事件处理器进行处理。。

文件事件处理器的结构包含 4 个部分:
- 多个 socket
- IO 多路复用程序
- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
通信过程

首先,Redis 服务端进程初始化的时候,会将 server socket 的 AE_READABLE 事件与连接应答处理器关联。
客户端 socket01 向 Redis 进程的 server socket 请求建立连接,此时 server socket 会产生一个 AE_READABLE 事件,IO 多路复用程序监听到 server socket 产生的事件后,将该 socket 压入队列中。文件事件分派器从队列中获取 socket,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的 socket01,并将该 socket01 后面产生的 AE_READABLE 事件与命令请求处理器关联。
假设此时客户端发送了一个 set key value 请求,此时 Redis 中的 socket01 会产生 AE_READABLE 事件,IO 多路复用程序将 socket01 压入队列,此时事件分派器从队列中获取到 socket01 产生的 AE_READABLE 事件,由于前面 socket01 的 AE_READABLE 事件已经与命令请求处理器关联,因此事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取 socket01 的 key value 并在自己内存中完成 key value 的设置。操作完成后,它会将 socket01 的 AE_WRITABLE 事件与命令回复处理器关联。
如果此时客户端准备好接收返回结果了,那么 Redis 中的 socket01 会产生一个 AE_WRITABLE 事件,同样压入队列中,事件分派器找到相关联的命令回复处理器,由命令回复处理器对 socket01 输入本次操作的一个结果,比如 ok ,之后解除 socket01 的 AE_WRITABLE 事件与命令回复处理器的关联。
Redis6.0引入多线程
Redis在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。
使用Redis时,几乎不存在CPU成为瓶颈的情况, Redis主要受限于内存和网络。因为读写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的 IO 消耗, 所以Redis6.0引入多线程。

1、主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列
2、主线程通过 RR(Round Robin) 将这些连接分配给这些 IO 线程
3、主线程阻塞等待 IO 线程读取 socket 完毕
4、主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行
5、主线程阻塞等待 IO 线程将数据回写 socket 完毕
6、解除绑定,清空等待队列

- IO 线程要么同时在读 socket,要么同时在写,不会同时读或写
- IO 线程只负责读写 socket 解析命令,不负责命令处理
Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行,不会存在线程并发安全问题。
就是任务过来判断等待队列是否满,如果不满就加入等待队列,如果等待队列满了,就平均分给IO线程组处理,主线程阻塞等待IO线程组读socket并解析请求完成,主线程再执行所有请求命令。然后主线程再阻塞等待 IO 线程将数据回写 socket 完毕。
为什么 Redis 单线程却能支撑高并发
- 纯内存操作。
- 核心是基于非阻塞的 IO 多路复用机制。
- C 语言实现,一般来说,C 语言实现的程序“距离”操作系统更近,执行速度相对会更快。
- 单线程反而避免了多线程的频繁上下文切换问题,预防了多线程可能产生的竞争问题。
过期策略
Redis 过期策略是:定期删除+惰性删除。
所谓定期删除,指的是 Redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。
定期删除可能会导致很多过期 key 到了时间并没有被删除掉,那咋办呢?
惰性删除,在获取某个 key 的时候,会检查下这个 key 是否过期了,如果过期就删除。
获取 key 的时候,如果此时 key 已经过期,就删除,不会返回任何东西。
如果定期删除漏掉了很多过期 key,然后你又没去获取 key(没走惰性删除),此时就会有大量过期 key 堆积在内存里,就可能导致 Redis 内存块耗尽。如果出现这种问题,就需要走内存淘汰机制。
内存淘汰机制
- noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错。(一般没人用)
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个最常用)。
- allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key。(一般没人用)
- volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(一般没人用)。
- volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。(一般没人用)
- volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。(一般没人用)
手写一个 LRU 算法
LRU是Least Recently Used的缩写,即最近最少使用,是一种常用的页面置换算法,选择最近最久未使用的数据予以淘汰。
查找快、插入快、删除快,(O(1)时间复杂度)且还需要先后排序———->什么样的数据结构可以满足这个问题?
LRU的算法核心是哈希链表
public class LRUDemo extends LinkedHashMap<Integer, Integer> {
private int capacity;
public LRUDemo(int initialCapacity) {
// true 表示让 linkedHashMap 按照访问顺序来进行排序,最近访问的放在头部,最老访问的放在尾部。
super(initialCapacity, 0.75F, true);
this.capacity = initialCapacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, Integer> eldest) {
// 当 map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据。
return super.size() > capacity;
}
// 测试
public static void main(String[] args) {
LRUDemo lruDemo = new LRUDemo(3);
lruDemo.put(1, 1);
lruDemo.put(2, 2);
lruDemo.put(3, 3);
System.out.println(lruDemo.keySet());
lruDemo.put(4, 4);
System.out.println(lruDemo.keySet());
lruDemo.put(3, 3);
lruDemo.put(3, 3);
System.out.println(lruDemo.keySet());
}
}持久化机制
- RDB:对数据执行周期性的持久化。
- AOF:对每条写命令都作为日志写入日志文件中,以后可以通过回放日志中的写命令来重新构建整个数据集。
RDB 优缺点
优点:
- 可以通过配置生成多个时间段的数据文件进行保存,这种方式很适合做冷备。
- 对 Redis 对外提供读写服务影响非常小,只需要 fork 一个子线程来单独进行持久化即可。
- 直接基于 RDB 数据文件来重启恢复 Redis 进程比 AOF 文件更快。
缺点:
- 因为是周期备份,所以在 Redis 故障时会丢失一部分时间的数据。
- 如果生成快照文件的数据特别大,可能会导致客户端提供的服务暂停数毫秒,甚至数秒。
AOF优缺点
优点:
- 可以更好的保护数据,每隔 1 秒就会执行一次 fsync 操作,最多只会丢失 1 秒钟的数据。
- 日志文件以 Append-Only 模式写入,没有磁盘寻址开销,写入性能高,文件不容易破损。
- 日志文件即使过大的时候,出现后台重写操作时,也不会影响客户端的读写。因为在 rewrite log 的时候,会对其中的指导进行压缩,创建出一份需要恢复数据的最小日志出来。在创建新日志文件的时候,老的日志文件还是照常写入。当新的 merge 后的日志文件 ready 的时候,再交换新老日志文件即可。
缺点:
- 对于同一份数据来说,AOF 日志文件通常比 RDB 数据快照文件更大。
- AOF 开启后,支持的写 QPS 会比 RDB 支持的写 QPS 低,因为 AOF 一般会配置成每秒 fsync 一次日志文件,当然,每秒一次 fsync,性能也还是很高的。
RDB 和 AOF 到底该如何选择
两个都要用,用 AOF 来保证数据不丢失,作为数据恢复首选项。用 RDB 做多种冷备,在 AOF 文件丢失或损坏的时候,还可以恢复大部分数据。
主从架构
简述
Redis 虽然读写很快,但是也会产生性能瓶颈(单机最多几万 QPS),特别是在读的压力上(通常写请求是比较少的),为了分担压力,就需要用到主从架构。
其特点是一主多从,由主节点负责写,并且将数据同步到从节点上,由从节点负责读。如果 QPS 暴增,只要增加从节点就可以了。

Redis replication 的核心机制
- Redis 采用异步方式复制数据到 slave 节点,slave 复制时,不会阻塞 master 的正常工作。
- 一个 master node 是可以配置多个 slave node 的;同时slave node 也可以连接其他的 slave node;
- slave node 在做复制的时候,也不会 block 对自己的查询操作,它会用旧的数据集来提供服务;但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了;
- slave node 主要用来进行横向扩容,做读写分离,扩容的 slave node 可以提高读的吞吐量。
Redis 主从复制的核心原理
当启动一个 slave node 的时候,它会发送一个 PSYNC 命令给 master node。
如果 slave 节点是首次连接 master 节点,那么会触发一次全量复制。
如果 slave 节点是重新连接 master 节点,那么会做增量更新(断点续传)。

全量复制
- master 会异步 fork 一个子线程生成 RDB 快照文件,同时还会将新收到的所有写命令保存在内存中。
- RDB 文件生成完毕后,master 会把这个文件发给 slave。
- slave 会先写入本地磁盘,再从本地磁盘加载到内存中。
- 接着 master 会把保存在内存中的所有写命令发送到 slave,salve 也会同步这些数据。
断点续传
网络连接断了,连接之后 master node 仅会复制给 slave 部分缺少的数据。
- master 在接受数据写入后,会写到数据缓冲区,同时也会积压到 backlog 中。
- master 和 slave 两端都会维护一个 offset 记录当前已经同步过的命令。
- 如果 slave 断开重连,会发送 psync runid offset 指令,让 master 从 上次的 replication offset 开始复制。
- 如果没有找到对应的 offset,那么就会执行一次全量复制。
slave过期key处理
slave 不会过期 key,只会等待 master 过期 key。如果 master 过期了一个 key,或者通过 LRU 淘汰了一个 key,那么会模拟一条 del 命令发送给 slave。
哨兵架构
哨兵的介绍
sentinel,中文名是哨兵。哨兵是 Redis 集群架构中非常重要的一个组件,主要有以下功能:
- 集群监控:负责监控 Redis master 和 slave 进程是否正常工作。
- 消息通知:如果某个 Redis 实例有故障,那么哨兵负责发送消息作为报警通知给管理员。
- 故障转移:如果 master node 挂掉了,会自动转移到 slave node 上。
- 配置中心:如果故障转移发生了,通知 client 客户端新的 master 地址。
哨兵用于实现 Redis 集群的高可用,本身也是分布式的,作为一个哨兵集群去运行,互相协同工作。
- 故障转移时,判断一个 master node 是否宕机了,需要大部分的哨兵都同意才行,涉及到了分布式选举的问题。
- 即使部分哨兵节点挂掉了,哨兵集群还是能正常工作的,保证自身高可用。
哨兵的核心知识
- 哨兵至少需要 3 个实例,来保证自己的健壮性。
- 哨兵 + Redis 主从的部署架构,是不保证数据零丢失的,只能保证 Redis 集群的高可用性。
- 对于哨兵 + Redis 主从这种复杂的部署架构,尽量在测试环境和生产环境,都进行充足的测试和演练。
哨兵至少要 3 个实例
哨兵集群如果要做主备切换,至少需要2数量的哨兵认为 odown,才能选举出一个哨兵来做切换。如果你配置2个哨兵实例,如果宕机一个,就只剩1数量的哨兵了。
哨兵主备切换的数据丢失问题
Redis 主备切换会有两种数据丢失的情况:异步复制 & 集群脑裂。
异步复制导致的数据丢失
因为 master->slave 的复制是异步的,所以可能有部分数据还没复制到 slave,master 就宕机了,此时这部分数据就丢失了。

脑裂导致的数据丢失
脑裂,也就是说,某个 master 所在机器突然脱离了正常的网络,跟其他 slave 机器不能连接,但是实际上 master 还运行着。此时哨兵可能就会认为 master 宕机了,然后开启选举,将其他 slave 切换成了 master。这个时候,集群里就会有两个 master ,也就是所谓的脑裂。
此时虽然某个 slave 被切换成了 master,但是可能 client 还没来得及切换到新的 master,还继续向旧 master 写数据。因此旧 master 再次恢复的时候,会被作为一个 slave 挂到新的 master 上去,自己的数据会清空,重新从新的 master 复制数据。而新的 master 并没有后来 client 写入的数据,因此,这部分数据也就丢失了。

数据丢失问题的解决方案
加两个配置,核心思想是,如果跟任何一个 slave 丢了连接,在 10 秒后发现没有 slave 给自己 ack,那么就拒绝新的写请求。
min-slaves-to-write 1
min-slaves-max-lag 10减少异步复制数据的丢失
有了 min-slaves-max-lag 这个配置,就可以确保说,一旦 slave 复制数据和 ack 延时太长,就认为可能 master 宕机后损失的数据太多了,那么就拒绝写请求,这样可以把 master 宕机时由于部分数据未同步到 slave 导致的数据丢失降低的可控范围内。
减少脑裂的数据丢失
如果一个 master 出现了脑裂,跟其他 slave 丢了连接,那么上面两个配置可以确保说,如果不能继续给指定数量的 slave 发送数据,而且 slave 超过 10 秒没有给自己 ack 消息,那么就直接拒绝客户端的写请求。因此在脑裂场景下,最多就丢失 10 秒的数据。
sdown 和 odown 转换机制
- sdown 是主观宕机,就一个哨兵如果自己觉得一个 master 宕机了,那么就是主观宕机
- odown 是客观宕机,如果 quorum 数量的哨兵都觉得一个 master 宕机了,那么就是客观宕机
slave->master 选举算法
如果一个 master 被认为 odown 了,而且 majority 数量的哨兵都允许主备切换,那么某个哨兵就会执行主备切换操作,此时首先要选举一个 slave 来,会考虑 slave 的一些信息:
- 跟 master 断开连接的时长
- slave 优先级
- 复制 offset
- run id
如果一个 slave 跟 master 断开连接的时间已经超过了 down-after-milliseconds 的 10 倍,外加 master 宕机的时长,那么 slave 就被认为不适合选举为 master。
接下来会对 slave 进行排序:
- 按照 slave 优先级进行排序,slave priority 越低,优先级就越高。
- 如果 slave priority 相同,那么看 replica offset,哪个 slave 复制了越多的数据,offset 越靠后,优先级就越高。
- 如果上面两个条件都相同,那么选择一个 run id 比较小的那个 slave。
集群架构
Redis-cluster 是官方 3.0 版本后的集群方案,它具有以下特点:
- 多主多从:能支撑多个 master,且每个 master 都可以挂载多个 slave。
- 读写分离:写操作会用 master,读操作会从 master 对应的 slave 去读。
- 横向扩容:自动将数据进行分片,每个 master 都放一部分数据,只要增加 master 节点就能存放更多的数据。
- 高可用:如果 master 挂掉,Redis cluster 这套机制,就会自动将某个 slave 切换成 master。
分布式寻址算法
最老土的 Hash 算法
设节点数为 N,写一个数据的时候,根据 hash(key) % N 计算出哈希值,用来决定数据映射到哪一个节点上。

一旦某一个 master 节点宕机,所有请求过来,都会基于最新的剩余 master 节点数去取模,尝试去取数据。这会导致大部分的请求过来,全部无法拿到有效的缓存,导致大量的流量涌入数据库。
一致性哈希分区
一致性 hash 算法将整个 hash 值空间组织成一个虚拟的圆环,整个空间按顺时针方向组织,下一步将各个 master 节点(使用服务器的 ip 或主机名)进行 hash。这样就能确定每个节点在其哈希环上的位置。
来了一个 key,首先计算 hash 值,并确定此数据在环上的位置,从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置。
在一致性哈希算法中,如果一个节点挂了,受影响的数据仅仅是此节点到环空间前一个节点(沿着逆时针方向行走遇到的第一个节点)之间的数据,其它不受影响。增加一个节点也同理。
当一致性哈希算法在节点太少时,容易因为节点分布不均匀而造成缓存热点的问题。为了解决这种热点问题,一致性 hash 算法引入了虚拟节点机制,即对每一个节点计算多个 hash,每个计算结果位置都放置一个虚拟节点。这样就实现了数据的均匀分布,负载均衡。

Redis cluster 的 hash slot 算法
Redis cluster 有固定的 16384 个 hash slot,对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot。
Redis cluster 中每个 master 都会持有部分 slot,比如有 3 个 master,那么可能每个 master 持有 5000 多个 hash slot。hash slot 让 node 的增加和移除很简单,增加一个 master,就将其他 master 的 hash slot 移动部分过去,减少一个 master,就将它的 hash slot 移动到其他 master 上去。
任何一台机器宕机,另外两个节点,不影响的。因为 key 找的是 hash slot,不是机器。

缓存雪崩
缓存挂了,造成 DB 短时间承受大量请求而崩掉。
- 事前:Redis 高可用,主从+哨兵,Redis cluster,避免全盘崩溃。
- 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
- 事后:Redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

缓存穿透
恶意查询缓存和 DB 都没有的数据,造成数据库短时间内承受大量请求而崩掉。
布隆过滤器:布隆过滤器可以非常方便地判断一个给定数据是否存在于海量数据中。具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,我会先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端。

缓存击穿
热点数据突然失效,造成 DB 短时间承受大量请求而崩掉。
- 若缓存的数据是基本不会发生更新的,则可尝试将该热点数据设置为永不过期
- 加Redis、zookeeper 等分布式中间件的分布式互斥锁
- 最好在代码中也再上限流,防止DB挂了
缓存与数据库的双写一致性
初级情况的解决方法
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候先删除缓存,再更新数据库
比较复杂的数据不一致问题
问题是说数据库修改还没提交,新的请求过来读缓存发现为空,于是查了旧的数据,又放回缓存里,随后数据库修改提交,造成缓存中数据不一致。
- 同一个主键操作放到RocketMQ的同一队列中,一个队列对应一个消费者,可以保证顺序性
Redis分布式锁
redis支持nx和ex操作是同一原子操作。
set resourceName value ex 5 nx- NX:表示只有 key 不存在的时候才会设置成功,如果此时 redis 中存在这个 key,那么设置失败,返回 nil。
- expire(),设置过期时间,防止死锁,假设,如果一个锁set后,一直不删掉,那这个锁相当于一直存在,产生死锁。
- value要唯一,防止删了其他线程的锁
删除锁的时候,先要判断是否是当前锁,再删除,这两个过程涉及原子性问题,所以也只能使用lua脚本
当机器A申请到一把锁之后,如果Redis主宕机了,这个时候从机并没有同步到这一把锁,那么机器B再次申请的时候就会再次申请到这把锁,为了解决这个问题Redis作者提出了RedLock红锁的算法,在Redission中也对RedLock进行了实现。
RedLock基本原理是利用多个Redis集群,用多数的集群加锁成功,减少Redis某个集群出故障,造成分布式锁出现问题的概率。
Redis事务
概念
Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。
总结说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
Redis不保证原子性
Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。
Redis事务相关命令
watch key1 key2 ... : 监视一或多个key,如果在事务执行之前,
被监视的key被其他命令改动,则事务被打断 ( 类似乐观锁 )
multi : 标记一个事务块的开始( queued )
exec : 执行所有事务块的命令 ( 一旦执行exec后,之前加的监控锁都会被取消掉 )
discard : 取消事务,放弃事务块中的所有命令
unwatch : 取消watch对所有key的监控使用案例
- 正常执行

- 放弃事务

- 命令性错误
若在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行

- 执行性错误
若在事务队列中存在执行性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。

- 使用watch
使用watch检测balance,事务期间balance数据未变动,事务执行成功

使用watch检测balance,在开启事务后(标注1处),在新窗口执行标注2中的操作,更改balance的值,模拟其他客户端在事务执行期间更改watch监控的数据,然后再执行标注1后命令,执行EXEC后,事务未成功执行。

