这次彻底读透 Redis

时间:2022-10-26 11:07:14

0. Redis 基础

如果对 Redis 还不了解的同学可以先看一下这篇 Redis 基础文章 ,这里面介绍了 Redis 是什么,以及怎么用

1. Redis 管道

我们通常使用 Redis 的方式是,发送命令,命令排队,Redis 执行,然后返回结果,这个过程称为Round trip time(简称RTT, 往返时间)。但是如果有多条命令需要执行时,需要消耗 N 次 RTT,经过 N 次 IO 传输,这样效率明显很低。

于是 Redis 管道(pipeline)便产生了,一次请求/响应服务器能实现处理新的请求即使旧的请求还未被响应。这样就可以将多个命令发送到服务器,而不用等待回复,最后在一个步骤中读取该答复。这就是管道(pipelining),减少了 RTT,提升了效率

重要说明: 使用管道发送命令时,服务器将*回复一个队列答复,占用很多内存。所以,如果你需要发送大量的命令,最好是把他们按照合理数量分批次的处理,例如10K的命令,读回复,然后再发送另一个10k的命令,等等。这样速度几乎是相同的,但是在回复这10k命令队列需要非常大量的内存用来组织返回数据内容。

2. Redis 发布订阅

发布订阅是一种消息模式,发送者(sub)发送消息,订阅者(pub)接收消息

这次彻底读透 Redis

如上图所示,发布订阅基于频道实现的,同一个频道可以有多个订阅者,多个发布者。其中任意一个发布者发布消息到频道中,所以订阅者都可以收到该消息。

发布订阅 Redis 演示

我在服务器上启动了 4 个 Redis 客户端,2 个订阅者,2 个发布者,订阅者订阅的频道为 channel01

这次彻底读透 Redis

第一步:发布者 1 往 channel01 中发送消息 "wugongzi"

这次彻底读透 Redis

订阅者 1 收到消息:

这次彻底读透 Redis

订阅者 2 收到消息:

这次彻底读透 Redis

第二步:发布者 2 往频道中发布消息 "hello-redis"

这次彻底读透 Redis

订阅者 1 收到消息:

这次彻底读透 Redis

订阅者 2 收到消息:

这次彻底读透 Redis

Redis 同时支持订阅多个频道:

这次彻底读透 Redis

3. Redis 过期策略

3.1 过期时间使用

Redis 可以给每个 key 都设置一个过期时间,过期时间到达后,Redis 会自动删除这个 key。

实际生产中,我们还是要求必须要为每个 Redis 的 Key 设置一个过期时间,如果不设置过期时间,时间一久,Redis 内存就会满了,有很多冷数据,依然存在。

设置过期时间的命令:EXPIRE key seconds

在使用过程中有一点需要注意,就是在每次更新 Redis 时,都需要重新设置过期时间,如果不设置,那个 key 就是永久的,下面给大家演示一下如何使用:

这次彻底读透 Redis

3.2 过期删除策略

Redis keys过期有两种方式:被动和主动方式。

当一些客户端尝试访问过期 key 时,Redis 发现 key 已经过期便删除掉这些 key

当然,这样是不够的,因为有些过期的 keys,可能永远不会被访问到。 无论如何,这些 keys 应该过期,所以 Redis 会定时删除这些 key

具体就是Redis每秒10次做的事情:

  1. 测试随机的20个keys进行相关过期检测。
  2. 删除所有已经过期的keys。
  3. 如果有多于25%的keys过期,重复步奏1

这是一个平凡的概率算法,基本上的假设是,Redis的样本是这个密钥控件,并且我们不断重复过期检测,直到过期的keys的百分百低于25%,这意味着,在任何给定的时刻,最多会清除1/4的过期keys。

4. Redis 事务

4.1 事务基本使用

Redis 事务可以一次执行多条命令,Redis 事务有如下特点:

  • 事务是一个单独的隔离操作:事务中的所有命令都会序列化、按顺序地执行。事务在执行的过程中,不会被其他客户端发送来的命令请求所打断。
  • 事务是一个原子操作:事务中的命令要么全部被执行,要么全部都不执行。

Redis 事务通过 MULTIEXECDISCARDWATCH 几个命令来实现,MULTI 命令用于开启事务,EXEC 用于提交事务,DISCARD 用于放弃事务,WATCH 可以为 Redis 事务提供 check-and-set (CAS)行为。

这次彻底读透 Redis

4.2 事务发生错误

Reids 事务发生错误分为两种情况

第一种:事务提交前发生错误,也就是在发送命令过程中发生错误,看演示

这次彻底读透 Redis

上面我故意将 incr 命令写错,从结果我们可以看到,这条 incr 没有入队,并且事务执行失败,k1 和 k2 都没有值

第二种:事务提交后发生错误,也就是在执行命令过程中发生错误,看演示

这次彻底读透 Redis

上面的事务命令中,我给 k1 设置了一个 d,然后执行自增命令,最后获取 k1 的值,我们发现第二条命令执行发生了错误,但是整个事务依然提交成功了,从上面现象中可以得出,Redis 事务不支持回滚操作。如果支持的话,整个事务的命令都不应该被执行。

4.3 为什么 Redis 不支持回滚

如果你有使用关系式数据库的经验, 那么 “Redis 在事务失败时不进行回滚,而是继续执行余下的命令”这种做法可能会让你觉得有点奇怪。

以下是这种做法的优点:

  • Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中。
  • 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

有种观点认为 Redis 处理事务的做法会产生 bug , 然而需要注意的是, 在通常情况下, 回滚并不能解决编程错误带来的问题。 举个例子, 如果你本来想通过 incr 命令将键的值加上 1 , 却不小心加上了 2 , 又或者对错误类型的键执行了 incr , 回滚是没有办法处理这些情况的。

4.4 放弃事务

当执行 discard 命令时, 事务会被放弃, 事务队列会被清空, 并且客户端会从事务状态中退出

这次彻底读透 Redis

4.5 WATCH 命令使用

watch 使得 exec 命令需要有条件地执行: 事务只能在所有被监视键都没有被修改的前提下执行, 如果这个前提不能满足的话,事务就不会被执行

这次彻底读透 Redis

上面我用 watch 命令监听了 k1 和 k2,然后开启事务,在事务提交之前,k1 的值被修改了,watch 监听到 k1 值被修改,所以事务没有被提交

4.6 Redis 脚本和事务

从定义上来说, Redis 中的脚本本身就是一种事务, 所以任何在事务里可以完成的事, 在脚本里面也能完成。 并且一般来说, 使用脚本要来得更简单,并且速度更快。

因为脚本功能是 Redis 2.6 才引入的, 而事务功能则更早之前就存在了, 所以 Redis 才会同时存在两种处理事务的方法。

5. Reids 持久化

5.1 为什么需要持久化

我们知道 Redis 是内存数据库,主打高性能,速度快。相比 Redis 而言,MySQL 的数据则是保存再硬盘中(其实也有内存版的 MySQL 数据库,但是价格极其昂贵,一般公司不会使用),速度慢,但是稳定性好。你想想 Redis 数据保存在内存中,一旦服务器宕机了,数据岂不是全部都没了,这将会出现很大问题。所以 Redis 为了弥补这一缺陷,提供数据持久化机制,即使服务器宕机,依然可以保证数据不丢失。

5.2 持久化简介

Redis 提供了两种持久化机制 RDB 和 AOF,适用于不同场景

  • RDB持久化方式能够在指定的时间间隔能对你的数据进行快照存储
  • AOF持久化方式记录每次对服务器写的操作,当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF命令以redis协议追加保存每次写的操作到文件末尾.Redis还能对AOF文件进行后台重写,使得AOF文件的体积不至于过大

5.3 RDB

RDB 持久化是通过在指定时间间隔对数据进行快照,比如在 8 点钟对数据进行持久化,那么 Redis 会 fork 一个子进程将 8 点那一刻内存中的数据持久化到磁盘上。触发 RDB 持久化有以下几种方法

5.3.1 RDB 持久化方式

1、执行 save 命令

执行 save 命令进行持久会阻塞 Redis,备份期间 Redis 无法对外提供服务,一般不建议使用,使用场景为 Redis 服务器需要停机维护的情况下。

2、执行 bgsave 命令

bgsave 命令不会阻塞 Redis 主进程,持久化期间 Redis 依然可以正常对外提供服务

3、通过配置文件中配置的 save 规则来触发

这次彻底读透 Redis

save 900 1:900s 内有 1 个 key 发生变化,则触发 RDB 快照

save 300 10:300s 内有 10 个 key 发生变化,则触发 RDB 快照

save 60 10000:60s 内有 10000 个 key 发生变化(新增、修改、删除),则触发 RDB 快照

save "":该配置表示关闭 RDB 持久化

5.3.2 RDB 持久化原理

Redis 进行 RDB 时,会 fork 一个子进程来进行数据持久化,这样不妨碍 Redis 继续对外提供服务,提高效率。

曾经面试官出过这样面试题:

假如 Redis 在 8 点触发了 RDB 持久化,持久化用时 2 分钟,在持久化期间,Redis 中有 100 个 key 被修改了,那么 RDB 文件中的 key 是 8 点那一刻的数据,还是变化的呢?

先不要看答案,自己思考 1 分钟,一个问题只有你自己思考了,才能印象深刻。

好,下面我们一起来看下这张图:

这次彻底读透 Redis

从图中我们可以清晰的看到,Redis 备份时,fork 了一个子进程,子进程去做持久化的工作,子进程中的 key 指向了 8 点那一刻的数据,后面 k1 的值修改了,redis 会在内存中创建一个新的值,然后主进程 k1 指针指向新的值,子进程 k1 指针依然指向 19,这样 Redis 持久化的就是 8 点那一刻的数据,不会发生变化。同时,从图中我们也可以看到,Redis 持久化时并不是将内存中数据全部拷贝一份进行备份。

5.3.2 RDB 优缺点

优点

  • RDB是一个非常紧凑的文件,它保存了某个时间点得数据集,非常适用于数据集的备份,比如你可以在每个小时报保存一下过去24小时内的数据,同时每天保存过去30天的数据,这样即使出了问题你也可以根据需求恢复到不同版本的数据集
  • RDB在保存RDB文件时父进程唯一需要做的就是fork出一个子进程,接下来的工作全部由子进程来做,父进程不需要再做其他IO操作,所以RDB持久化方式可以最大化redis的性能
  • 与AOF相比,在恢复大的数据集的时候,RDB方式会更快一些

缺点

  • 如果备份间隔时间较长,RDB 会丢失较多的数据。比如 8 点备份一次,8 点半服务器宕机,那么这半小时内的数据就会丢失了

5.4 AOF

AOF 持久化是通过日志的方式,记录每次 Redis 的写操作。当服务器重启的时候会重新执行这些命令来恢复原始的数据,AOF 命令以Redis 协议追加保存每次写的操作到文件末尾。Redis 还能对 AOF 文件进行后台重写,使得 AOF 文件的体积不至于过大

5.4.1 AOF 持久化配置

# 是否开启 aof no:关闭;yes: 开启
appendonly no

# aof 文件名
appendfilename "appendonly.aof"

# aof 同步策略
# appendfsync always  # 每个命令都写入磁盘,性能较差
appendfsync everysec  # 每秒写一次磁盘,Redis 默认配置
# appendfsync no      # 由操作系统执行,默认Linux配置最多丢失30秒

# aof 重写期间是否同步
no-appendfsync-on-rewrite no

# 重写触发策略
auto-aof-rewrite-percentage 100 # 触发重写百分比 (指定百分比为0,将禁用aof自动重写功能)
auto-aof-rewrite-min-size 64mb # 触发自动重写的最低文件体积(小于64mb不自动重写)

# 加载aof时如果有错如何处理
# 如果该配置启用,在加载时发现aof尾部不正确是,会向客户端写入一个log,但是会继续执行,如果设置为 no ,发现错误就会停止,必须修复后才能重新加载。
aof-load-truncated yes

# aof 中是否使用 rdb
# 开启该选项,触发AOF重写将不再是根据当前内容生成写命令。而是先生成RDB文件写到开头,再将RDB生成期间的发生的增量写命令附加到文件末尾。
aof-use-rdb-preamble yes

5.4.2 AOF 文件写入

aof 文件是命令追加的方式,先将命令写入缓冲区,时间到了再写如磁盘中

appendfsync always    # 每个命令都写入磁盘,性能较差
appendfsync everysec  # 每秒写一次磁盘,Redis 默认配置
appendfsync no        # 由操作系统执行,默认Linux配置最多丢失30秒

上面配置就是何时写入磁盘中

5.4.3 AOF 重写

aof 文件虽然丢失的数据少,但是随着时间的增加,aof 文件体积越来越大,占用磁盘空间越来越大,恢复时间长。所以 redis 会对 aof 文件进行重写,以减少 aof 文件体积

下面以一个例子说明

-- 重写前的 aof
set k1 20
set k2 40
set k1 35
set k3 34
set k2 19

-- 这里 k1 最终的值为 35,k2 最终值为 19,所以不需要写入两个命令
-- 重写后
set k1 35
set k3 34
set k2 19

混合持久化

从 Redis 4.0 版本开始,引入了混合持久化机制,纯AOF方式、RDB+AOF方式,这一策略由配置参数aof-use-rdb-preamble(使用RDB作为AOF文件的前半段)控制,默认关闭(no),设置为yes可开启

  • no:按照AOF格式写入命令,与4.0前版本无差别;
  • yes:先按照RDB格式写入数据状态,然后把重写期间AOF缓冲区的内容以AOF格式写入,文件前半部分为RDB格式,后半部分为AOF格式。

混合持久化优点如下:

  • 大大减少了 aof 文件体积
  • 加快了 aof 文件恢复速度,前面是 rdb ,恢复速度快

AOF 数据恢复

第一种:纯 AOF

恢复时,取出 AOF 中命令,一条条执行恢复

第二种:RDB+AOF

先执行 RDB 加载流程,执行完毕后,再取出余下命令,开始一条条执行

5.4.4 AOF 优缺点

优点

  • AOF 实时性更好,丢失数据更少
  • AOF 已经支持混合持久化,文件大小可以有效控制,并提高了数据加载时的效率

缺点

  • 对于相同的数据集合,AOF 文件通常会比 RDB 文件大
  • 在特定的 fsync 策略下,AOF 会比 RDB 略慢
  • AOF 恢复速度比 RDB 慢

6. Redis 分布式锁

6.1 分布式锁介绍

学习过 Java 的同学,应该对锁都不陌生。Java 中多个线程访问共享资源时,会出现并发问题,我们通常利用 synchronized 或者 Lock 锁来解决多线程并发访问从而出现的安全问题。细心的同学可能已经发现了, synchronized 或者 Lock 锁解决线程安全问题在单节点情况下是可行的,但是如果服务部署在多台服务器上,本地锁就失效了。

分布式场景下,需要采用新的解决方案,就是今天要说的 Redis 分布式锁。日常业务中,类似抢红包,秒杀等场景都可以使用 Redis 分布式锁来解决并发问题。

6.2 分布式锁特点

分布式在保障安全、高可用的情况下需要具备以下特性

  • 互斥性:任意一个时刻,只能有一个客户端获取到锁
  • 安全性:锁只能被持久的客户端删除,不能被其他人删除
  • 高可用,高性能:加锁和解锁消耗的性能少,时间短
  • 锁超时:当客户端获取锁后出现故障,没有立即释放锁,该锁要能够在一定时间内释放,否则其他客户端无法获取到锁
  • 可重入性:客户端获取到锁后,在持久锁期间可以再次获取到该锁

6.3 解决方案

6.3.1 方案一:SETNX 命令

Redis 提供了一个获取分布式锁的命令 SETNX

setnx key value

如果获取锁成功,redis 返回 1,获取锁失败 redis 返回 0

这次彻底读透 Redis

客户端使用伪代码

if (setnx(k1,v1) == 1) {
    try{
        // 执行逻辑
    	....
    }catch() {
        
    }finally{
        // 执行完成后释放锁
        del k1;
    }
}

这个命令看似可以达到我们的目的,但是不符合分布式锁的特性,如果客户端在执行业务逻辑过程中,服务器宕机了,finally 中代码还没来得及执行,锁没有释放,也就意味其他客户端永远无法获取到这个锁

6.3.2 方案二:SETNX + EXPIRE

该方案获取锁之后,立即给锁加上一个过期时间,这样即使客户端没有手动释放锁,锁到期后也会自动释放

这次彻底读透 Redis

我们来看下伪代码

if (setnx(k1, v1) == 1){
    expire(key, 10);
    try {
       //.... 你的业务逻辑
    } finally {
        del(key);
    }
}

这个方案很完美,既可以获取到,又不用担心客户端宕机。等等,这里面真的没有问题吗?再仔细瞅瞅,一瞅就瞅出问题来了

if (setnx(k1, v1) == 1){
    // 再刚获取锁之后,想要给锁设置过期时间,此时服务器挂了
    expire(key, 10); // 这条命令没有执行
    try {
       //.... 你的业务逻辑
    } finally {
        del(key);
    }
}

这里的 setnx 命令和 expire 命令不是原子性的,他们之间执行需要一定的等等时间,虽然这个时间很短,但是依然有极小概率出现问题

6.3.3 方案三:使用 Lua 脚本

既然 setnx 和 expire 两个命令非原子性,那么我们让其符合原子性即可,通过 Lua 脚本即可实现。Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行: 当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行

具体实现如下:

if redis.call('setnx',KEYS[1],ARGV[1]) == 1 then
   redis.call('expire',KEYS[1],ARGV[2])
else
   return 0
end;

这样应该没问题了吧,看似上面的几个问题都很好解决了。不对,再想想,肯定还有没考虑到的

我们再来看一段伪代码

// 执行 lua 脚本
// 获取 k1 锁,过期时间 10 s
if (execlua()==1){
    try {
        buyGoods();
    } finally {
        del(key);
    }
}

这次彻底读透 Redis

从图中我们可以很清晰发现问题所在

  1. 客户端 A 还未执行完毕,客户端 B 就获取到了锁,这样就可能导致并发问题
  2. 客户端 A 执行完毕,开始删除锁。但此时的锁为 B 所有,相当于删除了属于客户端 B 的锁,这样肯定会发生问题

6.3.4 方案四:SET EX PX NX + 校验唯一随机值,再删除

既然锁有可能被别的客户端删除,那么在删除锁的时候我们加上一层校验,判断释放锁是当前客户端持有的,如果是当前客户端,则允许删除,否则不允许删除。

  • EX second :设置键的过期时间为 second 秒。 SET key value EX second 效果等同于 SETEX key second value
  • PX millisecond :设置键的过期时间为 millisecond 毫秒。 SET key value PX millisecond 效果等同于 PSETEX key millisecond value
  • NX :只在键不存在时,才对键进行设置操作。 SET key value NX 效果等同于 SETNX key value
  • XX :只在键已经存在时,才对键进行设置操作。

使用示例:

if(jedis.set(resource_name, random_value, "NX", "EX", 100s) == 1){ //加锁, value 传入一个随机数
    try {
        do something  //业务处理
    }catch(){
  }
  finally {
       // 判断 value 是否相等, 相等才释放锁, 这里判断和删除是非原子性, 真实场景下可以将这两步放入 Lua 脚本中执行
       if (random_value.equals(jedis.get(resource_name))) {
        	jedis.del(lockKey); //释放锁
        }
    }
}

Lua 脚本如下:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

此方案解决了锁被其他客户端解除的问题,但是依然没有解决锁过期释放,但是业务还没有执行完成的问题

6.3.5 方案五:Redisson框架

方案四中并没有解决方法未执行完成,锁就超时释放的问题。这里有个方案大家比较容易想到,那就是锁的超时时间设置长一点,比如2min,一个接口执行时间总不能比 2 min 还长,那你就等着领盒饭吧,哈哈哈。但是这么做,一来是不能每个锁都设置这么久超时时间,二来是如果接口出现异常了,锁只能 2 min 后才能释放,其他客户端等待时间较长。

这个问题早就有人想到了,并给出了解决方案,开源框架 Redisson 解决了这个问题。

这次彻底读透 Redis

Redisson 在方法执行期间,会不断的检测锁是否到期,如果发现锁快要到期,但是方法还没有执行完成,便会延长锁的过期时间,从而解决了锁超时释放问题。

6.3.6 方案六:Redlock

上面所介绍的分布式锁,都是在单台 Redis 服务器下的解决方案。真实的生产环境中,我们通常会部署多台 Redis 服务器,也就是集群模式,这种情况上述解决方案就失效了。

对于集群 Redis,Redis 的作者 antirez 提出了另一种解决方案,Redlock 算法

Redlock 算法大致流程如下:

这次彻底读透 Redis

1、获取当前Unix时间,以毫秒为单位。

2、依次尝试从N个实例,使用相同的key和随机值获取锁。在步骤2,当向Redis设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10秒,则超时时间应该在5-50毫秒之间。这样可以避免服务器端Redis已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个Redis实例。

3、客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是3个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。

4、如果取到了锁,key的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3计算的结果)。

5、如果因为某些原因,获取锁失败(没有在至少N/2+1个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis实例根本就没有加锁成功)。

总结: 简单总结一下就是客户端向 Redis 集群中所有服务器发送获取锁的请求,只有半数以上的锁获取成功后,才代表锁获取成功,否则锁获取失败

7. Redis 集群

7.1 Redis 集群的三种模式

在生产环境中,我们使用 Redis 通常采用集群模式,因为单机版 Redis 稳定性可靠性较低,而且存储空间有限。

Redis 支持三种集群模式

  • 主从复制
  • 哨兵模式
  • Cluster 模式

7.2 主从复制

7.2.1 主从复制概念

主从复制模式,有一个主,多个从,从而实现读写分离。主机负责写请求,从机负责读请求,减轻主机压力

这次彻底读透 Redis

7.2.2 主从复制原理

这次彻底读透 Redis

  • 从数据库启动成功后,连接主数据库,发送 SYNC 命令;
  • 主数据库接收到 SYNC 命令后,开始执行 BGSAVE 命令生成 RDB 文件并使用缓冲区记录此后执行的所有写命令;
  • 主数据库 BGSAVE 执行完后,向所有从数据库发送快照文件,并在发送期间继续记录被执行的写命令;
  • 从数据库收到快照文件后丢弃所有旧数据,载入收到的快照;
  • 主数据库快照发送完毕后开始向从数据库发送缓冲区中的写命令;
  • 从数据库完成对快照的载入,开始接收命令请求,并执行来自主数据库缓冲区的写命令;(从数据库初始化完成
  • 主数据库每执行一个写命令就会向从数据库发送相同的写命令,从数据库接收并执行收到的写命令(从数据库初始化完成后的操作
  • 出现断开重连后,2.8之后的版本会将断线期间的命令传给重数据库,增量复制。
  • 主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。当然,如果有需要,slave 在任何时候都可以发起全量同步。Redis 的策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。

7.2.3 主从复制优缺点

优点

  • 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离
  • Slave 同样可以接受其它 Slaves 的连接和同步请求,这样可以有效的分载 Master 的同步压力
  • Master Server 是以非阻塞的方式为 Slaves 提供服务。所以在 Master-Slave 同步期间,客户端仍然可以提交查询或修改请求

缺点

  • 主从不具备容错和恢复能力,一旦主机挂了,那么整个集群处理可读状态,无法处理写请求,会丢失数据
  • 主机宕机后无法自动恢复,只能人工手动恢复
  • 集群存储容量有限,容量上线就是主库的内存的大小,无法存储更多内容

7.3 哨兵集群

7.3.1 哨兵概念

哨兵,我们经常在电视剧中看到一些放哨的,哨兵的作用和这些放哨的差不多,起到监控作用。一旦 Redis 集群出现问题了,哨兵会立即做出相应动作,应对异常情况。

哨兵模式是基于主从复制模式上搭建的,因为主从复制模式情况下主服务器宕机,会导致整个集群不可用,需要人工干预,所以哨兵模式在主从复制模式下引入了哨兵来监控整个集群,哨兵模式架构图如下:

这次彻底读透 Redis

7.3.2 哨兵功能

监控(Monitoring):哨兵会不断地检查主节点和从节点是否运作正常。

自动故障转移(Automatic failover):当主节点不能正常工作时,哨兵会开始自动故障转移操作,它会将失效主节点的其中一个从节点升级为新的主节点,并让其他从节点改为复制新的主节点。

配置提供者(Configuration provider):客户端在初始化时,通过连接哨兵来获得当前Redis服务的主节点地址。

通知(Notification):哨兵可以将故障转移的结果发送给客户端。

7.3.3 下线判断

Redis 下线分为主观下线和客观下线两种

  • 主观下线:单台哨兵任务主库处于不可用状态
  • 客观下线:整个哨兵集群半数以上的哨兵都认为主库处于可不用状态

哨兵集群中任意一台服务器判断主库不可用时,此时会发送命令给哨兵集群中的其他服务器确认,其他服务器收到命令后会确认主库的状态,如果不可用,返回 YES,可用则返回 NO,当有半数的服务器都返回 YES,说明主库真的不可用,此时需要重新选举

这次彻底读透 Redis

7.3.4 主库选举

当哨兵集群判定主库下线了,此时需要重新选举出一个新的主库对外提供服务。那么该由哪个哨兵来完成这个新库选举和切换的动作呢?

注意:这里不能让每个哨兵都去选举,可能会出现每个哨兵选举出的新主库都不同,这样就没法判定,所以需要派出一个代表

哨兵代表选择

哨兵的选举机制其实很简单,就是一个Raft选举算法: 选举的票数大于等于num(sentinels)/2+1时,将成为领导者,如果没有超过,继续选举

  • 任何一个想成为 Leader 的哨兵,要满足两个条件:
    • 第一,拿到半数以上的赞成票;
    • 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。

以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以了。

新库选择

上面已经选举出了哨兵代表,此时代表需要完成新主库的选择,新库的选择需要满足以下几个标准

  • 新库需要处于健康状态,也就是和哨兵之间保持正常的网络连接
  • 选择salve-priority从节点优先级最高(redis.conf)的
  • 选择复制偏移量最大,只复制最完整的从节点

7.3.5 故障转移

上面一小节哨兵已经选举出了新的主库,故障转移要实现新老主库之间的切换

故障转移流程如下:

这次彻底读透 Redis

7.3.6 哨兵模式优缺点

优点

  • 实现了集群的监控,故障转移,实现了高可用
  • 拥有主从复制模式的所有优点

缺点

  • 集群存储容量有限,容量上线就是主库的内存的大小,无法存储更多内容

7.4 Cluser 集群

Redis 的哨兵模式实现了高可用了,但是每台 Redis 服务器上存储的都是相同的数据,浪费内存,而且很难实现容量上的扩展。所以在 redis3.0上加入了 Cluster 集群模式,实现了 Redis 的分布式存储,也就是说每台 Redis 节点上存储不同的内容

Redis 集群的数据分片

Redis 集群没有使用一致性hash, 而是引入了 哈希槽的概念.

Redis 集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽.集群的每个节点负责一部分hash槽,举个例子,比如当前集群有3个节点,那么:

  • 节点 A 包含 0 到 5500号哈希槽.
  • 节点 B 包含5501 到 11000 号哈希槽.
  • 节点 C 包含11001 到 16384号哈希槽.

这种结构很容易添加或者删除节点. 比如如果我想新添加个节点D, 我需要从节点 A, B, C中得部分槽到D上. 如果我想移除节点A,需要将A中的槽移到B和C节点上,然后将没有任何槽的A节点从集群中移除即可. 由于从一个节点将哈希槽移动到另一个节点并不会停止服务,所以无论添加删除或者改变某个节点的哈希槽的数量都不会造成集群不可用的状态.

8. Redis 集群实战

环境:

  • Vmware 虚拟机
  • CentOS 7
  • Redis 6.0.6

因为我是在本机上演示的,所以用的虚拟机

8.1 主从复制

集群信息如下:

节点 配置文件 端口
master redis6379.conf 6379
slave1 redis6380.conf 6380
slave1 redis6381.conf 6380

第一步:准备三个 redis.conf 配置文件,配置文件信息如下

# redis6379.conf    master
# 包含命令,有点复用的意思
include /soft/redis6.0.6/bin/redis.conf
pidfile redis_6379.pid
port    6379
dbfilename dump6379.rdb
logfile "redis-6379.log"

# redis6380.conf    slave1
include /soft/redis6.0.6/bin/redis.conf
pidfile redis_6380.pid
port    6380
dbfilename dump6380.rdb
logfile "redis-6380.log"
# 最后一行设置了主节点的 ip 端口
replicaof 127.0.0.1 6379

# redis6381.conf    slave2
include /soft/redis6.0.6/bin/redis.conf
pidfile redis_6381.pid
port    6381
dbfilename dump6381.rdb
logfile "redis-6381.log"
# 最后一行设置了主节点的 ip 端口
replicaof 127.0.0.1 6379

## 注意 redis.conf 要调整一项,设置后台运行,对咱们操作比较友好
daemonize yes

第二步:启动服务器

-- 首先启动 6379 这台服务器,因为他是主库(启动命令在 redis 安装目录的 bin 目录下)
../bin/redis-server redis6379.conf
-- 接口启动 6380 和 6381
../bin/redis-server redis6380.conf
../bin/redis-server redis6381.conf

第三步:用客户端连接服务器

cd bin
redis-cli -p 6379
redis-cli -p 6380
redis-cli -p 6381

这里我开了三个窗口分别连接三台 redis 服务器,方便查看

在 6379 客户端输入命令: info replication 可用查看集群信息

这次彻底读透 Redis

第四步:数据同步

现在集群已经搭建好了,我们在 6379 服务器写入几条数据,看下可不可以同步到 6380 和 6381

6379:

这次彻底读透 Redis

6380:

这次彻底读透 Redis

6381:

这次彻底读透 Redis

从图中可用看出,数据已经成功同步了

8.2 哨兵模式

哨兵集群是在主从复制的基础上构建的,相当于是主从+哨兵

搭建哨兵模式分为两步:

  1. 搭建主从复制集群
  2. 添加哨兵配置

哨兵模式节点信息如下,一主二从,三个哨兵组成一个哨兵集群

节点 配置 端口
master redis6379.conf 6379
slave1 redis6380.conf 6380
slave2 redis6381.conf 6381
sentinel1 sentinel1.conf 26379
sentinel2 sentinel2.conf 26380
sentinel3 sentinel3.conf 26381

主从复制集群的配置同上,这里就不再赘述,下面主要介绍下哨兵的配置,哨兵的配置文件其实非常简单

# 文件内容
# sentinel1.conf
port 26379
sentinel monitor mymaster 127.0.0.1 6379 1
# sentinel2.conf
port 26380
sentinel monitor mymaster 127.0.0.1 6379 1
# sentinel3.conf
port 26381
sentinel monitor mymaster 127.0.0.1 6379 1

配置文件创建好了以后就可以启动了,首先启动主从服务器,然后启动哨兵

../bin/redis-server redis6379.conf
../bin/redis-server redis6380.conf
../bin/redis-server redis6381.conf

-- 启动哨兵
../bin/redis-sentinel sentinel1.conf 
../bin/redis-sentinel sentinel2.conf 
../bin/redis-sentinel sentinel3.conf 

这次彻底读透 Redis

从哨兵的启动日志中我们可用看到主从服务器的信息,以及其他哨兵节点的信息

故障转移

主从同步功能上面已经演示过了,这里主要测试一下哨兵的故障转移

现在我手动将主节点停掉,在 6379 上执行 shutdown 命令

此时我们观察一下哨兵的页面:

这次彻底读透 Redis

哨兵检测到了 6379 下线,然后选举出了新的主库 6380

此时我们通过 info replication 命令查看集群信息,发现 6380 已经是主库了,他有一个从节点 6381

这次彻底读透 Redis

现在我手动将 6379 启动,看下 6379 会不会重新变成主库

这次彻底读透 Redis

重新启动后,我们发现 6379 变成了 80 的从库

8.3 Cluser 集群

官方推荐,Cluser 集群至少要部署 3 台以上的 master 节点,最好使用 3 主 3 从

节点 配置 端口
cluster-master1 redis7001.conf 7001
cluster-master2 redis7002.conf 7002
cluster-master3 redis7003.conf 7003
cluster-slave1 redis7004.conf 7004
cluster-slave2 redis7006.conf 7005
cluster-slave3 redis7006.conf 7006

配置文件内容如下,6 个配置文件信息基本相同,编辑好一份后其他文件直接复制修改端口即可

# 端口
port 7001  
# 启用集群模式
cluster-enabled yes 
# 根据你启用的节点来命名,最好和端口保持一致,这个是用来保存其他节点的名称,状态等信息的
cluster-config-file nodes_7001.conf 
# 超时时间
cluster-node-timeout 5000
appendonly yes
# 后台运行
daemonize yes
# 非保护模式
protected-mode no 
pidfile  redis_7001.pid

然后分别启动 6 个节点

../bin/redis-server redis7001.conf
../bin/redis-server redis7002.conf
../bin/redis-server redis7003.conf
../bin/redis-server redis7004.conf
../bin/redis-server redis7005.conf
../bin/redis-server redis7006.conf

启动集群

# 执行命令
# --cluster-replicas 1 命令的意思是创建master的时候同时创建一个slave

$ redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006  --cluster-replicas 1

启动过程有个地方需要输入 yes 确认:

这次彻底读透 Redis

启动成功后可用看到控制台输出结果:

这次彻底读透 Redis

3 个 master 节点,3 个 slave 节点,

master[0]槽位:0-5460

master[1]槽位:5461-10922

master[2]槽位:10923-16383

数据验证

连接 7001 服务器

redis-cli -p 7001 -c 集群模式下需要加上 -c 参数

这次彻底读透 Redis

从图中可用看出,k1 被放到 7003 主机上了,我们此时获取 k1 ,可用正常获取到

登录 7003 也可以正常拿到数据

这次彻底读透 Redis

9. Redis 缓存问题

在服务端中,数据库通常是业务上的瓶颈,为了提高并发量和响应速度,我们通常会采用 Redis 来作为缓存,让尽量多的数据走 Redis 查询,不直接访问数据库。同时 Redis 在使用过程中也会出现各种各样的问题,面对这些问题我们该如何处理?

  • 缓存穿透
  • 缓存击穿
  • 缓存雪崩
  • 缓存污染

9.1 缓存穿透

1、定义:

缓存穿透是指,当缓存和数据中都没有对应记录,但是客户端却一直在查询。比如黑客攻击系统,不断的去查询系统中不存在的用户,查询时先走缓存,缓存中没有,再去查数据库;或者电商系统中,用户搜索某类商品,但是这类商品再系统中根本不存在,这次的搜索应该直接返回空

2、解决方案

  • 网关层增加校验,进行用户鉴权,黑名单控制,接口流量控制
  • 对于同一类查询,如果缓存和数据库都没有获取到数据,那么可用用一个空缓存记录下来,过期时间 60s,下次遇到同类查询,直接取出缓存中的空数据返回即可
  • 使用布隆过滤器,布隆过滤器可以用来判断某个元素是否存在于集合中,利用布隆过滤器可以过滤掉一大部分无效请求

9.2 缓存击穿

1、定义:

缓存击穿是指,缓存中数据失效,在高并发情况下,所有用户的请求全部都打到数据库上,短时间造成数据库压力过大

2、解决方案:

  • 接口限流、熔断
  • 加锁,当第一个用户请求到时,如果缓存中没有,其他用户的请求先锁住,第一个用户查询数据库后立即缓存到 Redis,然后释放锁,这时候其他用户就可以直接查询缓存

9.3 缓存雪崩

1、定义

缓存雪崩是指 Redis 中大批量的 key 在同一时间,或者某一段时间内一起过期,造成多个 key 的请求全部无法命中缓存,这些请求全部到数据库中,给数据库带来很大压力。与缓存击穿不同,击穿是指一个 key 过期,雪崩是指很多 key 同时过期。

2、解决方案

  • 缓存过期时间设置成不同时间,不要再统一时间过期
  • 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中。

9.4 缓存污染

1、定义

缓存污染是指,由于历史原因,缓存中有很多 key 没有设置过期时间,导致很多 key 其实已经没有用了,但是一直存放在 redis 中,时间久了,redis 内存就被占满了

2、解决方案

  • 缓存尽量设置过期时间
  • 设置缓存淘汰策略为最近最少使用的原则,然后将这些数据删除