目录
[二、Redis 的事务](#二、Redis 的事务)
今日良言:从不缺乏从头开始的勇气
一、持久化
持久化就是把数据存储在硬盘上,无论是重启进程还是重启主机,数据都存在。
Redis 的持久化有两种方式:
1、RDB
RDB(Redis DataBase) 定期的把 Redis 内存中的所有数据,写入到硬盘中,并生成一个**"快照"**。
"快照" 类似于"案发现场",当出现某些事故后,拉上警戒线,然后 jc 蜀黍就开始忙碌的拍照。这样做的目的就是为了记录现场,后续就可以根据记录的照片,还原现场当时发生了什么。
RDB 就是 Redis 给当前内存中的所有数据拍个照片,生成一个文件,存储在磁盘中。后续当 Redis 重启以后(内存中的数据无了),就可以根据刚才的 "快照" 文件恢复内存中的数据。
RDB 是定期持久化的方式,定期具体来说,有两种方式:
1)手动触发
程序员通过 redis 客户端执行特定的命令来触发快照生成。
主要是通过两个命令: save bgsave
执行 save 命令的时候, redis 会全力以赴的进行"生成快照"的操作,此时就会阻塞 redis 的其它客户端的命令,导致类似于 keys * 的后果。 所以,一般不建议使用 save
bgsave 不会影响 Redis 服务器处理其它客户端的请求和命令。
那么 Redis是怎么实现这个命令的?是通过多线程吗?
其实并非是多线程,此处的 redis 使用的是 "多进程"的方式来完成的 bgsave 的实现。
bgsave 的执行流程图如下:
a.执行 bgsave 命令,Redis 父进程会判断当前进程是否有其它正在执行的子进程,如 RDB/AOF 子进程,如果存在,则直接返回。
b.父进程执行 fork 创建子线程,fork 过程中,父进程会阻塞,可以通过 info stats 命令查看 latest_fork_usec 选项,获取到最近一次 fork 操作的耗时,单位是微妙,如下图:
c.父进程 fork 完成后,bgsave命令 返回"Background saving started" 信息并不再阻塞父进程,父进程可以继续响应其它命令。如下图:
d.子进程创建 RDB 文件,根据父进程内存生成临时快照文件,完成后,对原有文件进行原子替换,执行 lastsave 命令可以查看最后一次生成 RDB 文件的时间,对应 info 统计的 rdb_last_save_time 选项。如下图:
执行 info 命令:
查看文件文件替换
首先找到默认的 rdb 文件的路径(在 redis 配置文件中可以找到):
查看当前 dump.rdb 文件的 inode编号 (相当于文件的身份标识)
当执行一次 bgsave 命令后,再次查看 dump.rdb 文件的 inode编号,发现发生变化:
e.子进程发送信号通知父进程表示完成,父进程更新统计时间。
2)自动触发
在 Redis 配置文件中可以进行设置,让 Redis 每隔多长时间/每产生多少修改就触发。
查看 Redis 配置文件中的默认配置:
上述这里默认配置的意思就是:900秒至少修改一次 key....
虽然这里的数值都是可以修改的,但是,修改此处数据要有一个基本原则:
生成一次 rdb 快照的成本比较高,不能让这个操作执行的太过频繁。
正因为 rdb 生成的不能太频繁,这就导致当前快照里的数据和当前实时的数据情况可能存在偏差,这会带来一些问题:
假设现在自动触发的配置是 60秒至少修改10000次 key。
在 12:00:00 生成了 rdb(硬盘上的快照数据和内存中一致)
12:00:01开始,redis 收到了大量的 key 的变化
在12:01:00 生成下一个快照文件
如果 redis 服务器在 60s 内挂了,此时就会导致,这60s内的数据都会丢失。AOF 就是解决这个问题的有效方案。
Redis 服务器默认采用的是 RDB持久化方式。
Redis 自动触发持久化机制主要是有三种方式:
a.通过配置文件中的 save 执行 M时间内,修改N次
b.通过 shutdown 命令(redis 里的一个命令)关闭 redis 服务器也会触发
c.redis 进行主从复制的时候,主节点也会自动生成 rdb 快照,然后把快照文件传输给从节点
通过前面的介绍可以直到,默认的 rdb 生成的文件路径是 /var/lib/redis 下,文件名通过 dbfilename 指定(默认是 dump.rdb)。那么,试想一下,如果这个 dump.rdb 文件被故意改坏了,会发生什么情况?
如果修改的是 rdb 文件的末尾,对前面的内容没什么影响,如果是修改文件中间位置,可能会导致 redis 服务器启动不了。当 redis 服务器挂了以后,可以通过观察 redis 日志,查看发生了什么,路径默认是 /var/log/redis ,在配置文件中也可以进行设置。
rdb 是一个二进制文件,直接将坏的 rdb 文件交给 redis 服务器使用,得到的结果是不可预期的,可能 redis 服务器能启动,但是得到的数据可能正确也可能有问题;也可能 redis 服务器直接启动失败。
针对上述问题:
rdb 提供了 rdb 文件的检查工具,可以先通过检查工具检查一下 rdb 文件格式是否符合要求.
该检查工具的默认路径是 /usr/bin/
在 5.0 版本检查工具和 redis 服务器是同一个可执行程序,运行的时候加入 rdb 文件作为命令行参数,此时就是以检查工具的方式来运行,并不会真的启动 redis 服务器。
最后来介绍一下 RDB 持久化的优缺点:
优点:
1)rdb 是一个紧凑压缩的二进制文件,代表 redis 在某个时间点上的数据快照,非常适用于备份、全量复制等场景。比如每 6 小时执行 bgsave 备份,并把 RDB 文件复制到远程机器或者文件系统中用于灾备。
2)redis 加载 rdb 文件的速度要远远快于 aof 的方式。
主要是由于 rdb 使用二进制的方式来组织数据,直接把数据读取到内存中,按照字节的方式取出来放到结构体/对象中即可。
aof 是使用文本的方式来组织数据,需要进行一系列的字符串切分操作。
缺点:
1)rdb 方式数据没法做到实时持久化/秒计持久化。
因为 bgsave 每次运行都要执行 fork 创建子进程,属于重量级操作,频繁执行成本过高。
2)rdb 文件使用特定二进制格式保存,redis 版本演进过程中有多个 rdb 版本,兼容性可能有风险。
rdb 最大的问题是:不能实时的持久化保存数据,在两次生成快照之间,实时的数据可能会随着重启而丢失。而 aof 则可以避免这种问题。
2、AOF
aof(append only file)类似于 mysql 的 binlog,会把用户的每次操作记录到文件中,当 redis 重新启动的时候,就会读取这个 aof 文件中的内容,用来恢复数据。
aof 默认一般是关闭状态,修改配置文件可以开启 aof 功能。
默认路径和 rdb 在同一目录下(/var/lib/redis):
查看 aof 文件中的内容:
aof 是一个文本文件,每次进行的操作都会被记录到文本文件中,通过一些特殊符号作为分隔符,来对命令的细节做出区分。
aof 的工作流程如下:
1)所有的写入命令会追加到 aof_buf(缓冲区)中。
2)AOF 缓冲区根据对应的策略向硬盘做同步操作。
3)随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
4)当 Redis 服务器在启动的时候,可以加载 AOF 文件进行数据恢复。
为什么 AOF 要使用 aof_buf 这个缓冲区呢?
主要目的是为了降低写磁盘的次数。redis 使用单线程响应命令,如果每次写 AOF 文件都直接同步硬盘,性能从内存的读写变成了 IO 读写,势必会下降。先写入缓冲区可以有效减少 IO 次数,同时,redis 还可以提供多种缓冲区同步策略,让程序员根据自己的需求做出合理的平衡。
如果把数据写入到缓冲区中,本质上还是在内存中,如果这个时候,进程突然挂掉或者主机掉电,缓冲区的数据就丢了。这种这种情况,redis 提供了一些缓冲区同步策略,让程序员可以根据实际情况来进行取舍。
AOF 缓冲区同步文件策略主要有三种:
always
命令写入 aof_buf 后调用 fsync 同步,完成后返回。
everysec (every second 默认的)
命令写入aof_buf 后只执行 write 操作,不进行fsync。每秒由同步线程进行 fsync。
no
命令写入aof_buf 后只执行 write 操作,由 OS 控制 fsync 频率。
上述三种缓冲区同步策略的刷新频率逐渐降低。
刷新频率越高,性能影响越大,数据可靠性越高。
刷新频率越低,性能影响越小,数据可靠性越低。
++write 操作会触发延迟写(delayed write)机制。Linux 在内核提供页缓冲区来提供硬盘 IO 性++
++能。write 操作在写入操作系统缓冲区后立即返回。同步硬盘操作依赖于系统调度机制,例如:缓冲区页空间写满或达到特定时间周期。同步文件之前,如果此时系统故障宕机,缓冲区内数据将丢失。++
++Fsync 针对单个文件操作,做强制硬盘同步,fsync 将阻塞直到数据写入到硬盘。++
always 每次写入都要同步 AOF 文件,性能很差,一般只能支持约几百 TPS 写入。除非是非常重要的数据,否则不建议配置。no 由于操作系统同步策略不可控,虽然提高了性能,但数据丢失风险大增,除非数据的重要程度很低,一般不建议配置。
everysec 是默认配置,也是推荐的配置,兼顾了数据安全性和性能,理论上最多只会丢失 1s 的数据。
在配置文件中也可以找到这三种策略:
随着 AOF 文件持续增长,体积越来越大,导致 redis 下次启动时间被影响,因此,redis 就存在一个机制,能够针对 AOF 文件进行"整理"操作,剔除其中的冗余操作,达到压缩 AOF 文件的效果。
AOF 文件中有一些内容是冗余的,比如:
一个客户端对 redis 进行以下操作:
lpush key 111
lpush key 222
lpush key 333
此时执行结果等同于: lpush key 111 222 333
redis 引入 AOF 重写机制压缩文件体积。
AOF 重写机制可以手动触发和自动触发
手动触发
调用 bgrewriteaof 命令
自动触发
根据 auto-aof-rewirte-min-size 和 auto-aof-rewrite-percentage 参数确定自动触发时机。
auto-aof-rewirte-min-size 表示触发重写时 AOF 的最小文件大小,默认是64MB。
auto-aof-rewrite-percentage 代表当前AOF占用大小相比于上次重写时增加的比例。
bgrewriteaof 命令执行流程如下:
1)执行 AOF 重写请求
如果当前进程正在执行 AOF 重写,则请求不执行。如果当前进程正在执行 bgsave 操作,则重写命令延迟到 bgsave 完成后再执行。
2)父进程执行 fork 创建子进程
3)重写
3.1)父进程 fork 之后,继续响应其它命令,所有修改操作写入 aof_buf 并根据缓冲区同步策略将数据同步到硬盘,保证旧 AOF 文件机制正确。
3.2)父进程将 fork 之后的修改操作写入 AOF 重写缓冲区中。
- 子进程把当前内存中的数据获取出来,以 AOF 的格式,写入到一个新的 AOF 文件中。
内存中的数据的状态,就已经相当于是把 AOF 文件结果整理后的情况了。
5)子进程完成重写
5.1)新文件写成功后,子进程发送信号通知父进程。
5.2)父进程将 AOF 重写缓冲区中保存的命令追加到这个新的 AOF 文件中。
5.3)使用新的 AOF 文件替换旧的 AOF 文件。
以上流程有一个需要关注的点:父进程 fork 完毕之后,子进程写新的 aof 文件,并且随着时间的推移,子进程很快就写完了新的文件,要让新的 aof 替换旧的 aof 文件,父进程此时还在写这个即将消亡的旧的 aof 文件是否还有意义?
不能不写!!!
考虑到一种极端情况,假设在重写过程中,重写了一半,服务器挂了,子进程内存的数据就会丢失,新的 aof 文件并不完整,所以如果父进程不坚持写旧的 aof 文件,重启就没办法保证数据的完整性了。
rdb 对于 fork 之后的数据直接不管,而 aof 则对于 fork 之后的新数据,采用了 aof_rewrite_buf 缓冲区的方式来处理。rdb 本身的设计理念,就是用来"定时备份"的,只要是定时备份,就难以和最新的数据保持一致。而 aof 的理念则是实时备份。
aof 本来是按照文本的方式来写入文件的,但是文本的方式写文件,后续加载的成本比较高,于是 redis 引入了 "混合持久化"的方式,结合了 rdb 和 aof 的特点。
混合持久化具体来说就是:按照 aof 的方式,将每一个操作/请求,都记录到文件中,在触发 aof 重写之后,就会把当前内存的数据按照 rdb 的二进制格式写入到新的 aof 文件中,后续再进行的操作,仍然是按照 aof 文本的方式追加到文件后面。
可以在配置文件中开启 混合持久化:
如果当前 redis 上同时存在 aof 和 rdb 文件,此时以谁为主呢?
aof !因为 aof 中包含的数据比 rdb 更完整。rdb 文件直接被忽略。
redis 根据持久化文件进行数据恢复的流程如下:
以上就是 Redis 持久化相关内容
二、Redis 的事务
提到事务,当时小马的第一反应就是 Redis 事务和 MySQL 的事务有什么关系呢?
后来经过学习,才认识到 Redis 的事务和 MySQL 的事务相比,就是个"弟弟"。
我们知道,MySQL 的事务有四大特性:原子性、一致性、隔离性、持久性,而 Redis 的事务是否具有原子性,一直存在争议。
原子性最原本的含义,是把多个操作打包到一起,要么全都执行,要么全都不执行。
Redis 做到了上述含义,如果事务中若干个操作,存在有失败的,不会进行回滚操作。
但是 MySQL 的原子性,是把多个操作打包到一起,要么全都执行成功,要么全都不执行。
如果事务中有操作执行失败,要进行回滚操作,也就是将中间执行的操作全都回退。
MySQL 提高了"原子性"的门槛,使得人们谈到原子性的时候,更多的是想到 MySQL 这种带有回滚的原子性。
Redis 的事务和 MySQL 的事务的区别如下:
弱化的原子性: redis 没有"回滚机制",只能保证操作"批量执行",不能保证一个失败就恢复到初始状态。
不保证一致性:不涉及"约束",也没有回滚。MySQL 的一致性体现的是运行事务前后结果都是合理有效的,不会出现中间非法状态。
不需要隔离性:Redis 是一个单线程模型的服务器程序,所有请求/事务,都是"串行"执行的。
不具备持久性:Redis 本身是内存型数据库,是否开启持久化,是 redis-server 自己的事情,和事务无关。
Redis 的事务主要的意义:就是为了"打包"(批量执行),避免其他客户端的命令,插队到中间。
Redis 中实现事务,本质上是在服务器上引入了一个"事务队列"(每个客户端都有一个)。
开启事务的时候,此时客户端输入的命令,就会发给服务器并且进入这个队列中(不会立即执行),当遇到了"执行事务"这样的命令,此时就会把队列中的这些任务按照顺序依次执行。
Redis 事务相关的几个命令如下:
MULTI
开启事务
EXEC
执行事务
DISCARD
放弃当前事务
WATCH
监控某个 key 是否在事务执行之前,发生了改变。
UNWATCH
取消监控
执行 multi 命令后,服务器会返回 ok,进行一些操作:
此时,在服务器的事务队列中,保存了上述请求。
此时,如果开启另外一个客户端,尝试查询上述命令设置的三个 key 对应的数据,是没有结果的:
只有当执行了 exec 命令后,才会真正执行上述操作。
再次查询这三个 key 对应的数据是有结果的:
当开启事务,并且给服务器发送若干个命令之后,如果此时服务器重启,当前这个事务的效果等同于 discard。
假设当前有如下两个客户端进行操作:
客户端1:
multi set key 222
客户端2:
set key 333
客户端1:
exec
从时间上来看,客户端 1 先发送了 set key 222 命令,客户端 2 后发送了 set key 333 命令,最终想要的结果应该是 key 对应的数据为 333:
但实际上,key 对应的数据为 222。由于客户端 1 中,得是 exec 执行了之后,才会真正执行 set key 222,这个操作实际上更晚执行,因此最终结果就是 222。
在上述这样的场景中,就可以使用 watch 来监控这个 key,监控这个 key 在事物的 multi 和 exex之间,set key 之后,是否被外部其它客户端修改了。
客户端 1 执行如下命令:
客户端 2 执行如下命令:
客户端 1 执行 exec:
此时的返回值为 nill,exec在执行上述事务的请求的时候,发现 key 被外部修改了,于是真正执行set key 111的时候就没有真正执行。
Redis 的 watch 就相当于是基于版本号这样的机制,来实现了"乐观锁"。
watch 命令必须搭配事务使用,并且必须在 multi 之前使用。
当执行 watch key 的时候,就会给 key 安排一个版本号,版本号可以理解成一个"整数",每次修改 key 后,版本号都会变大。
在执行事务(exec)的时候,就会做出判定,判断当前的这个 key 的版本号和最初 watch 的时候记录的版本号是否相等。如果相等,说明这个 key 在事务开启之后到执行事务之间,没有被其他客户端修改,此时可以真正执行修改操作;如果不相等,说明 key 被其他客户端修改过了,因此直接丢弃事务中的操作,exec 返回 nill。
watch 本质上是给 exec 加了一个判定条件。
以上就是 Redis 事务相关内容。
三、主从复制
分布式系统涉及到一个非常关键的问题:单点问题。
如果某个服务器程序,只有一个节点,这样就会有如下问题:
1)可用性问题:如果这个机器挂了,意味着服务就中断了。
2)性能/支持的并发量也是有限的。
引入分布式系统主要就是为了解决上述的单点问题。
在分布式系统中,往往希望有多个服务器部署 redis 服务,从而构成一个 redis 集群,此时就可以让这个集群给整个分布式系统中的其他服务器,提供更稳定/更高效的数据存储功能。
在分布式系统中,有以下几种 redis 的部署方式:
1)主从模式
2)主从+哨兵模式
3)集群模式
本章节主要介绍主从模式。
在若干个 redis 节点中,有的是"主"节点,有的是"从"节点。每个从节点只能有一个主节点,而一个主节点可以有多个从节点。
假设现在有三个物理服务器(称为三个节点),分别部署了一个 redis-server 进程,此时就可以把其中一个节点称为"主"节点,另外两个就是"从"节点。从节点得"听"主节点的并且从节点上的数据要跟随主节点变化,从节点的数据要和主节点保持一致。
从节点可以看做是主节点的副本。
Redis 主从模式中,从节点的数据不允许修改,只能读取,更准确的说,主从模式,主要是针对"读操作"进行并发和可用性的提高,而写操作的话,无论是并发还是可用性,都是非常依赖主节点的,但主节点不能设置多个。
Redis 主从模式就可以解决单点问题,之前只是单个 redis 服务器节点,此时这个节点挂了,整个 redis 就挂了,而主从结构这些 redis 节点不太可能"同时挂了",如果想要更高的可用性,可以采用"异地多活"。
主从模式下,如果从节点挂了,没什么影响,此时继续从主节点或者其他从节点读取数据,得到的效果完全相同;如果主节点挂了,会有影响,从节点只能读取数据,主节点挂掉以后无法进行写操作。
可以在一台云服务器上运行三个 redis-server 进程来实现主从模式,此时需要保证这三个 redis-server 的端口不同(在配置文件中进行修改),还需要修改 daemonize yes(按照后台的方式运行),修改后重启服务。
假设 6379 是主节点,6380和6381是从节点。
实现主从复制如下:
首先创建一个目录,并复制redis.conf配置文件:
mkdir redis-conf
然后切换到该目录下:
cp /etc/redis/redis.conf ./slave1.conf
cp /etc/redis/redis.conf ./slave2.conf
修改复制而来的这两个配置文件的端口号和daemonize:
启动这两个 redis-server 服务
此时可以看到已经启动了三个 redis-server:
由于当前是一台主机,所以只能构建类似于分布式系统的主从结构,并不是真正的分布式系统。
当前这几个节点并没有构成主从结构,要想成为主从结构,就需要进行一些配置:
配置主从结构的方式主要有三种:
1)在配置文件中加入slaveof{masterHost}{masterPort} 随 Redis 启动生效。
2)在 redis-server 启动命令时加入 --slaveof{masterHost}{masterPort}生效。
3)直接使用 redis 命令: slaveof{masterHost}{masterPort}生效。
这里采用修改配置文件的方式来构成主从结构,将6379作为主节点,修改配置文件 slave1.conf 和 slave2.conf,添加如下配置:
每次修改完配置文件后,需要重启服务:
通过 kill -9 杀死进程,然后重启这两个服务即可:
注:
使用 kill -9 停止 redis-server 这种方式,是和前面通过直接运行 redis-server 命令的方式搭配的。如果是使用 service redis-server start 这种方式启动的,就必须使用 service redis-server stop 来进行停止,通过这种方式启动,如果使用 kill -9 ,redis-server 进程能够自动启动。
此时重启这两个服务。
从节点无法进行修改数据操作:
可以通过 slaveof no one (直接在 redis 客户端中的命令)断开主从复制关系。
当从节点断开主从关系后,它就不再属于其它的节点了,里面有的数据是不会丢失的,但是,后续主节点如果针对数据做出修改,从节点就无法再自动同步数据了。
主节点和从节点之间通过网络来传输 (TCP),TCP 内部支持了nagle算法(默认开启),当开启这个算法后,就会增加 TCP 的传输延迟,节省网络带宽;
当关闭这个算法后,就会减少 TCP 的传输延迟,增加网络带宽。
主从节点建立复制流程图如下:
1)从节点保存主节点信息
从节点先保存主节点的 ip+端口号
2)主从建立连接
主节点和从节点建立 TCP 连接,也就是通过 TCP 三次握手,验证通信双方的读写能力。
3)发送 ping 命令
从节点发送 ping 命令验证主节点是否能够正常工作。
4)权限验证
主节点如果设置了 requirepass 参数,则需要密码验证,从节点通过配置 masterauth 参数来设置密码。如果验证失败,则从节点停止复制。
5)同步数据集
主要有两种情况:全量同步和部分同步。
6)命令持续复制
当从节点复制了主节点的所有数据之后,针对之后的修改命令,主节点会持续的把命令发送给从节点,从节点执行修改命令,保证主从数据的一致性。
这个过程最关键的步骤是 第5步和第6步。
redis 提供了 psync 命令完成数据同步的过程。psync 命令不需要手动执行,当建立好主从同步关系之后,redis 服务器会自动执行这个命令。
从节点负责执行 psync 命令,从主节点拉取数据。
psync 命令的语法格式:
psync replicationid offset
replicationid 是主节点生成的,主节点启动的时候就会生成,当从节点晋升成主节点的时候也会生成,即使是同一个主节点,每次重启,生成的 replicationid 都是不同的,从节点和主节点建立了复制关系,就会从主节点获取到 replicationid。
offset 偏移量,主节点和从节点都会维护偏移量(整数)。
主节点的偏移量表示:主节点收到的很多的修改操作的命令,每个命令占据的字节数进行累加。
从节点的偏移量表示:现在从节点这里的数据同步到哪里了(同步数据的进度)。
如果从节点这里的偏移量和主节点的偏移量一样了,表示数据同步完成。
psync 可以从主节点获取全量数据,也可以获取部分数据。
当 offset = -1 表示获取全量数据。
当 offset = 一个整数,表示从当前偏移量位置开始获取。
获取所有数据比较稳妥,但是会比较低效,如果从节点之前已经和主节点同步过一部分数据了,就只需要把新的没复制过的数据同步过来即可。
psync 运行流程图如下:
1)从节点发送 psync 命令给主节点,replid 和 offset 的默认值是?和 -1;
2)主节点根据 psync 的参数以及自身数据情况决定响应数据。
- 如果回复+FULLRESYNC ,则从节点需要进行全量复制
- 如果回复+CONTINUE ,则从节点进行部分复制。
- 如果回复-ERR,说明 Redis 主节点版本过低,不支持 psync 命令,从节点可以使用 sync 命令进行全量复制。
sync 会阻塞redis-server 处理其它请求,而 psync 不会。
全量复制的时机:
1)从节点首次和主节点进行数据同步;
2)主节点不方便进行部分复制的时候()。
部分复制的时机:
当从节点因为网络抖动或者重启,从节点再次连接上主节点后,尝试同步一小部分数据(大部分数据都是一致的)
全量复制的流程图如下:
1)从节点给主节点发送 psync 命令进行数据同步,由于第一次进行复制,从节点没有主节点的运行 id 和复制偏移量,所以发送 psync ? -1 。
2)主节点根据命令,解析出要进行全量复制,返回响应 +FULLRESYNC。
3)从节点接收主节点的运行信息并进行保存。
4)主节点执行 bgsave 进行 RDB 文件的持久化。
rdb 是二进制格式,比较节省空间,不能使用已有的 rdb 文件,已有的 rdb 文件可能会和当前最新的数据存在比较大的差异。
5)主节点发送 rdb 文件给从节点,从节点保存文件到本地硬盘。
6)主节点将生成 rdb 文件到从节点接收完成期间执行的命令写入缓冲区中,等从节点保存完 rdb 文件后,主节点再将缓冲区中的数据补发给从节点,补发的数据仍然按照 rdb 的二进制格式追加写入到从节点收到的 rdb 文件中,保持主从一致性。
7)从节点清空自身原有旧数据。
8)从节点加载 rdb 文件,得到和主节点一致的数据。
9)从节点加载完成 rdb 文件后,并且开启了 aof 持久化功能,从节点此时会进行 bgrewriteaof 操作,得到最近的 aof 文件。
如果从节点开启了 aof,在上述加载过程中,会产生很多 aof 日志,由于当前收到的是大批量的数据,此时产生的 aof 日志整体会有冗余信息,因此针对 aof 日志进行整理也是必要的过程。
部分复制的流程图如下:
1)当主从节点之间出现网络中断时,如果超过 repl-timeout 时间,主节点就会认为从节点故障并中断复制连接。
2)主从连接断开期间,主节点仍然响应命令,但这些复制命令都因网络中断无法及时发送给从节点,所以主节点暂时将这些命令写入复制积压缓冲区中。
复制积压缓冲区就是一个简单队列,默认大小是 1MB,当主节点有连接的从节点时被创建,这时主节点响应写命令时,不仅要发送给从节点,还会写入积压缓冲区,积压缓冲区会记录一段时间修改的数据,但总量有限,随着时间的推移,会把之前旧的数据逐渐删除。
3)当从节点网络恢复后,就会连接上主节点。
4)从节点将之前保存的 replicationId 和 复制偏移量作为 psync 参数发送给从节点,请求进行部分复制。
replicationId 其实就是在描述"数据的来源"。
5)主节点收到 psync 命令后,进行必要的验证,随后根据 offset 去积压缓冲区查找合适的数据,并响应 +CONTINUE 给从节点。
offset 描述的就是"数据复制的进度"。如果从节点需要的数据,已经超出了主节点的积压缓冲区的范围,则无法进行部分复制,只能进行全量复制了。
6)主节点将需要同步的数据发送给从节点,最终完成一致性。
实时复制:
从节点已经和主节点同步好了数据,但是之后,主节点仍然会源源不断的收到新的修改请求的命令,主节点的数据就会随之改变,主从节点之间会建立 tcp 长连接,主节点需要将这些修改操作传输给从节点,从节点就会根据收到的修改操作来修改自身的数据,从而保持和主节点数据的一致性。
在进行实时复制的时候,需要保证连接处于可用状态,于是通过心跳包机制来维护连接状态(这里的心跳是指应用层自己实现的心跳,而不是 tcp 自带的心跳)。
主节点默认每隔 10s 会发送 ping 命令给从节点,从节点收到会返回 pong。
从节点默认每隔 1s 会发送 replconf ack {offset} 命令,给主节点上报自身当前的偏移量。
如果主节点发现从节点超过 repl-timeout 时间(默认60s)还未响应,则判断从节点下线,断开连接,从节点恢复连接后,心跳机制继续进行。
主从复制,最大的问题还是在主节点上,主节点挂了以后,从节点虽然能够提供读操作,但是从节点不能自动升级成主节点,不能替换原有主节点的角色。此时,就需要程序猿/运维手工的恢复主节点,但是这个手动恢复的过程非常繁琐。
哨兵模式可以自动的对挂了的主节点进行替换。
主节点和从节点之间断开连接,有两种情况:
1)从节点主动和主节点断开连接
使用 slaveof no one 命令,这个时候,从节点能够晋升成主节点。
2)主节点挂了
此时,从节点不会晋升成主节点,需要人工干预恢复主节点。
以上就是主从复制模式的相关内容
四、哨兵模式
哨兵模式其实指的就是在主从复制的基础上增加一个或者多个"哨兵节点",让 Redis 实现高可用。
哨兵节点:监控 Redis 数据节点的节点,是一个独立的 redis-sentinel 进程。
哨兵节点集合:多个哨兵节点的抽象组合,是多个 redis-sentinel 进程。
Redis 哨兵(Sentinel):Redis 提供的高可用方案,哨兵节点集合和 Redis 主从节点。
Redis 哨兵就是为了解决主从复制模式下主节点发生故障后,进行主备切换的问题。
当主节点出现故障时,Redis Sentinel 能自动完成故障发现和故障转移,并通知应用方,从而实现正在的高可用。
Redis Sentinel 是一个分布式架构,其中包含若干个 Sentinel 节点(通常是 3 个)和 Redis 数据节点(主从节点),每个 Sentinel 节点会对数据节点和其余 Sentinel 节点进行监控。
Redis Sentinel 架构如下:
通常哨兵节点也会搞一个集群(由多个哨兵节点构成),为了避免单个哨兵节点挂了的情况以及出现误判节点挂了的情况。
上述三个哨兵进程就会监控现有的 redis master 和 slave,这些进程之间会建立 tcp 长连接,通过长连接定期发送心跳包,借助这个监控机制,就可以及时发现上述某个节点是否挂了。
如果是从节点挂了,并没有多大影响,仍然可以进行读写操作;
如果是主节点挂了,哨兵就需要发挥作用了。
redis 哨兵核心功能主要有:
1)监控
对数据节点进行监控。
2)自动的故障转移
主节点挂了后,选择从节点升级为主节点。
3)通知
当主节点挂了后,哨兵重新选取主节点的流程:
1)主观下线
哨兵节点通过心跳包判断 redis 服务器是否正常工作,如果心跳包没有如约而至,就说明 redis 服务器挂了,此时还不能排除因为网络波动的影响,因此就只能单方面的认为这个 redis 节点挂了,也就是将这个节点判定为主观下线(SDown)。
2)客观下线
多个哨兵会对主节点故障这个事情进行投票,当认为主节点故障的票数 >= 配置的法定票数之后(一般是比哨兵节点个数的一半多,比如三个哨兵节点,则法定票数设为 2),此时就会把这个判定这个主节点为客观下线(ODown)
3)选举出哨兵的 leader
要让多个哨兵选出一个 leader 节点,由这个 leader 负责选一个从节点作为主节点,这个选举的过程涉及到 Raft 算法,该算法的核心就是"先下手为强",哪个哨兵先发起拉票请求,谁就由更大的概率成为 leader。
例如:有三个哨兵节点 1 2 3,每个哨兵都要给其他哨兵发起一个拉票请求,收到拉票请求的节点,会恢复一个"投票响应",响应的结果有两种可能,投或者不投(如果有票就投,没有的话就不投,并且是给先拉票的那个哨兵投票),此时,一轮投票结束之后,发现得票超过半数的节点,自动成为 leader,所以一半哨兵节点个数设置为奇数个,避免平票带来不必要的开销。
4)leader 选举完毕,选择从节点升级为主节点
leader 需要在从节点中选择一个升级成主节点,主要考虑的因素有三个:
a.优先级:
每个 redis 节点都会在配置文件中有一个优先级设置:slave-priority,优先级高的从节点胜出。
b.offset
offset 最大的从节点胜出,offset 描述的是数据复制的进度,也就是从节点从主节点同步数据的进度,offset 越大,说明从节点的数据和主节点的越接近。
c.run id
run id 是每个 redis 节点启动的时候,随机生成的一串数字,这个大小全凭缘分了。
经过上述选择之后,leader 就会控制胜出的这个从节点执行 slaveof no one 命令,成为新的 master ,再控制其他的节点,执行 slaveof,让其他的从节点以新的主节点作为主节点。leader 还会更新哨兵集群的配置,标记新的主节点和从节点的角色变化,这包括在哨兵内部维护的配置信息以及持久化这些配置到磁盘,以便于哨兵重启后能恢复正确的集群视图,说白了就是保证修改生效。leader 会像其它哨兵节点广播新的配置信息,确保所有哨兵都知道当前新的主节点是谁。哨兵提供了 api,允许客户端应用程序向哨兵订阅(subscribe)主节点的变化事件,当主节点发生变更以后,哨兵会通过发布/订阅(Pub/Sub)通道向订阅的客户端 +switch-master 消息,通知它们主节点的地址已经变更,客户端收到消息后,需更新其连接信息,指向新的主节点。
注:
1)哨兵节点不能只有一个,否则哨兵节点挂了,也会影响系统的可用性。
2)哨兵节点最好是奇数个,方便选举 leader,得票更容易超过半数。(大部分3个就够)
3)哨兵节点不负责存储数据,仍然是 redis 主从节点负责存储。
因此,哨兵节点就可以使用一些配置不高的机器来部署。
4)哨兵+主从复制解决的问题是"提高可用性",不能解决"数据极端情况下写丢失"的问题。
5)哨兵+主从复制不能提高数据的存储容量,如果要存的数据接近或者超过机器的物理内存,这样的结构就很难胜任了。
而 redis 集群就是解决存储容量问题的有效方案。
以上就是哨兵模式的相关内容。
五、集群模式
首先,来认识一下什么是**"集群"**
广义的集群:
只要是多个机器,构成了分布式系统,都可以称为是一个"集群",前面章节提到的主从复制,哨兵模式也可以认为是"广义的集群"。
狭义的集群:
redis 提供的集群模式,这个集群模式之下,主要是解决存储空间不足的问题(拓展存储空间)。
哨兵模式提高了系统的可用性,但是本质上还是 redis 主从节点存储数据,其中就要求一个主节点/从节点就得存储整个数据的"全集",如果数据量很大,接近超出了 主/从 节点所在机器的物理内存,就可能出现严重问题了。
那么,如何获取更大的空间存储数据?
多加机器即可!一台机器装不下,多搞几台存储数据即可。
Redis 的集群就是在上述的思路之下,引入多组 Master/Slave ,每一组 Master/Slave 存储数据全集的一部分,从而构成一个更大的整体,称为 Redis 集群(Cluster)。
举例:
假设整个数据全集是 1TB,引入三组 Master/Slave 来存储,那么每一台机器只需要存储整个数据全集的 1/3 即可。如下图:
每个红框都可以称为是一个 分片(Sharding)
此时,只要机器的规模足够多,就可以存储任意大小的数据了。
Redis 集群的核心思路就是用多组机器来存储数据的每个部分,那么接下来的核心问题就是:给定一个数据(一个具体的 key),那么这个数据应该存在哪个分片上?读取的时候又应该去哪个分片读取?
有三种主流的方式:
1、哈希求余
借助哈希函数,把一个 key 映射到整数,再针对分片的个数求余就可以得到一个下标。
比如有三个分片:编号 0 1 2
此时就可以针对要插入的数据的 key 计算 hash 值(比如 使用md5),再针对这个 hash 值余上分片个数,就可以得到一个下标。
如果 hash(key) % 3 == 0 此时这个 key 就要存储在 0 号分片中。
如果 hash (key) %3 == 1 此时这个 key 就要存储在 1 号分片中......
后续想要查询某个 key 时,使用相同的算法,key 相同,hash 函数相同,得到的分片是一样的。
优点:
简单高效,数据分布均匀。
缺点:
一旦需要扩容,引入新的分片, N 就改变了,原有的映射规则被破坏,就需要重新分配(搬运数据)。
2、一致性哈希算法
在上述 hash 求余操作中,当前 key 属于哪个分片是交替的
比如 3 个分片,有四个 key 值计算出来是:102 103 104 105
102 % 3 == 0 属于第一个分片
103 % 3 == 1 属于第二个分片
104 % 3 == 2 属于第三个分片
105 % 3 == 0 属于第一个分片
交替出现,导致搬运成本变大。
而在一致性哈希这样的设定下,把交替出现改进成了连续出现,具体过程如下:
第一步,把 0-2^31-1 这个数据空间映射到一个圆环上,数据按照顺时针方向增长:
第二步,假设当前存在三个分片,就把分片放到圆环的某个位置上:
第三步,假设有一个 key,计算得到 hash 值 H,那么这个 key 映射的分片就是从 H 所在位置顺时针往下找,找到的第一个分片,就是该 key 所从属的分片。
这就相当于,N 个分片的位置,把圆环分成了 N 个管辖区间,key 的hash 值落在某个区间内,就归对应的分区管理。
那么,如果在这种情况下,想要扩容一个分片,该如何安排?
答:原有分片位置不变,只要在环上新安排一个分片位置即可,如下图:
此时,只要把 0 号分片上的部分数据搬运给 3 号分片即可(只需要搬运大概原数据的1/6即可),1 号分片和 2 号分片管理的区间都是不变的。
优点:大大降低了扩容时数据搬运的规模,提高了扩容操作的效率。
缺点:
数据分配不均匀/数据倾斜,有的分片上存储的数据多,有的存储的少。
3、哈希槽分区算法
为了解决上述搬运成本高以及数据分布不均匀的问题,Redis 集群引入了哈希槽(hash slots)算法。
hash_slot = crc16(key) % 16384
crc16 也是一种 hash 算法。
16384 是 16 * 1024,也就是 2 ^ 14。
相当于是把整个哈希值映射到 16384 个槽位上,也就是 [0,16383],然后再把这些槽位比较均匀的分给每个分片,每个分片的节点都需要记录自己持有哪些分片。
假设现在有三个分片,可能会有如下分配方式:
0 号分片:[0,5461],共 5462 个槽位
1 号分片:[5462,10923],共5462 个槽位
2 号分片:[10924,16383],共5460 个槽位
虽然不是严格意义上的"均匀",但是每个节点上的槽位数差异很小。
以上是一种可能得分片方式,实际上分片是非常灵活的,每个分片持有的槽位号可以是连续的,也可以是不连续的。
此处,每个分片都会使用**"位图"**,这样的数据结构表示出当前有多少槽位号,对于 16384 个槽位来说,需要 2048 个字节大小的内存空间表示。(16384 个 bit 位,用每一位 0/1 来区分自己这个分片是否持有该槽位号)
此时,如果需要扩容,比如新增一个 3 号分片,就可以针对原有的槽位进行重新分配,比如:可以把之前的每个分片持有的槽位,各自拿出一点,分给新分片,一种可能得分配方式如下:
0 号分片:[0,4095],共 4096 个槽位
1 号分片:[5462,9557],共 4096 个槽位
2 号分片:[10924,15019],共 4096 个槽位
3 号分片:[4096,5461]+[9558,10923]+[15019,16383],共 4096 个槽位
在上述过程中,只有被移动的槽位号对应的数据才需要搬运。
在实际使用 Redis 集群分片的时候,不需要手动指定哪些槽位分配给某个分片,只需要指定某个分片应该持有多少个槽位即可,Redis 会自动完成后续的槽位匹配以及对应的 key 搬运的工作。
这里涉及两个问题:
问题一:Redis 集群是最多有 16384 个分片吗?
答:并非如此,如果一个分片只有一个槽位,此时就很难保证数据在各个分片上的均匀分布。实际上,Redis 的作者建议集群分片数不应该超过 1000。
问题二:为什么是 16384 个槽位?
答:节点之间通过心跳包进行通信,心跳包中包含了该节点持有哪些 slots,这个是使用位图这样的数据结构表示的。表示 16384(16k)个 slots,需要的位图大小是 2 kb,如果给定的 slots 数量更多,此时就需要消耗更多的空间,假设 slots 为 65535 个,此时需要 8kb 位图表示,8 kb对于内存来说不算什么,但是在频繁的网络心跳包中,还是一个不小的开销。另一方面,Redis 集群一般不建议超过 1000 个分片,所以 16k 对于最大 1000个分片来说是足够的。
如果 Redis 集群中,有节点挂了,会出现什么情况?
答:如果挂了的是节点是从节点,挂了就挂了;如果挂了的是主节点,此时集群需要做的工作和之前哨兵做的工作类似,就会自动的把从属于该主节点的从节点挑选出来提拔成主节点。
具体的处理流程如下:
1、故障判定
1)节点 A 给节点 B 发送 ping 包,节点 B收到后返回 pong 包,ping 和 pong 除了 message type 属性之外,其它部分都是一样的,这里包含了集群的配置信息(该节点的 id,该节点从属于哪个分片,是主节点还是从节点,从属于谁,持有哪些 slots 的位图...)
2)每个节点,都会给随机的一些节点发起 ping 包,并不是全发一遍,这样的设定是为了避免在节点很多的情况下,心跳包非常多(比如 9 个节点,都发的话 9 * 9 = 72 组心跳包)
3)当节点 A 给节点 B 发送 ping 包,B 不能如期回应的时候,此时 A 就会尝试重置和 B 的 tcp 连接,看能否连接成功,如果仍然连接失败,此时 A 就会把 B 设为 PFAIL 状态(相当于主观下线)
4)A 判定 B为 PFAIL 后,就会通过 Redis 内置的 Gossip 协议和其他的节点进行通信,向其他节点确认 B 的状态(每个节点都会维护一个自己的"下线列表",由于视角不同,每个节点的下线列表也不一定相同)
5)此时 A 发现其他很多节点也认为 B 为 PFAIL ,并且数目超过集群总数的一半,那么 A 就会把 B 设为 FAIL 状态(相当于主观下线),并且把这个消息同步给其它节点(其它节点收到后,也会把 B 标记成 FAIL)
至此,B 就彻底被判定为故障节点了。
2、故障的迁移
上述 B 故障,如果 B 是从节点,那么不需要进行故障迁移;如果 B 是主节点,那么就会由 B 的从节点(比如 C 和 D)触发故障迁移(将从节点升级成主节点)了。
具体流程如下:
1)从节点首先判定自己是否具有竞选资格,如果从节点太久没和主节点进行通信(同步数据),时间超过阈值,从节点就失去竞选资格。
2)具有资格的节点,比如 C 和 D 就会休眠一段时间,休眠时间 = 500ms 基础时间 + [0,500ms] 随机时间 + 排名 * 1000ms,offset 值越大,则排名越靠前(越小)。
3)比如 C 的休眠时间到了,C 就会给集群中其它所有节点进行拉票操作,但是只有主节点才有投票资格
4)主节点就会把自己的票投给 C(每个主节点一票),当 C 收到票数超过主节点数目的一半,C 就会晋升成主节点(C 自己负责执行 slaveof no one,并且让 D 执行 slaveof C)。
5)同时,C 还会把自己成为主节点的消息,同步集群中其它节点,这些节点都会更新自己保存的集群结构信息。
上述选举的过程,是 Raft 算法,在随机休眠时间的加持下,基本上是谁先唤醒,谁就能竞选成功。
上述选举主节点的流程和哨兵模式并不相同,此处是直接选举出主节点,而哨兵模式是先选举出 leader,由 leader 负责找一个从节点升级成主节点。
以上就是集群模式相关内容。
六、缓存
Redis 最主要的用途主要有三个:
1)存储数据(内存数据库)
2)缓存(redis 最常用的场景)
3)消息队列
本章节主要介绍 redis 作为缓存的一些知识点。
对于硬件的访问速度来说,一般是:CPU寄存器 > 内存 > 磁盘 > 网络,速度快的设备就可以作为速度慢的设备的缓存,最常见的是使用内存作为硬盘的缓存。
缓存的核心思路就是把一些常用的数据放到访问速度更快的地方,方便随时读取。缓存访问速度是快,但是空间往往是不足的,因此大部分的时候,缓存只存放一些热点数据(访问频繁的数据)。
二八定律:
20% 的热点数据,能够应对80%的访问场景。因此只需要把这少量的热点数据缓存起来,就可以应对绝大多数场景,从而在整体上有明显的性能提升。
通常使用 redis 作为数据库(mysql)的缓存。数据库是非常重要的组件,绝大部分商业项目都会用到,但 mysql 的速度(性能)又比较慢,因此,可以使用 redis 作为 mysql 的缓存。
关系型数据库性能不高的原因有以下几点:
1)把数据都存放在硬盘上,硬盘的 I/O 速度并不快,尤其是随机访问。
2)如果查询不能命中索引,就需要进行表的遍历,这还会大大增加硬盘 I/O 次数。
3)关系型数据库对于 SQL 的执行会做一系列的解析,检验,优化等工作。
4)如果是一些复杂的查询,比如联合查询,需要进行笛卡尔积,效率更加低。
正因为 mysql 等数据库效率比较低,所以承担的并发量比较有限,一旦请求量多了,数据库的压力就会很大,甚至很容易出现宕机(服务器每次处理一个请求,一定要消耗一些硬件资源,比如cpu、内存、硬盘、网络等,任意一种资源的消耗超出了机器能提供的上限,机器就很容易出现故障了)。
那么,如何提高 mysql 能承担的并发量?
核心思路主要有两个:
1)开源
引入更多的机器,构成数据库集群。
2)节流
引入缓存。把一些频繁读取的热点数据保存到缓存上,后续在查询数据的时候,如果缓存中已经存在了,就不再访问 mysql 了。
redis 的访问速度比 mysql 快很多,或者说处理同一个访问请求,redis 消耗的系统资源比 mysql 少很多,因此,redis 能支持的并发量更大。
redis 作为缓存时,存储的是一些热点数据,那么,如何知道 redis 应该存储哪些热点缓存呢?
这涉及到缓存的更新策略:
1)定期生成每隔一定的周期(比如一天/一周/一个月),对于访问的数据频次进行统计,挑选出访问频次最高的前 N% 的数据。
优点:实现比较简单,过程更可控,方便排查问题。
缺点:实时性不够。比如春节期间,"春晚"这样的词会成为非常高频的词,而平时很少有人搜索"春晚"。
2)实时生成
先给缓存设定容量上限( redis.conf 配置文件中的 mamemory 参数设定),接下来,把用户的每次查询:如果在 redis 中查到了,直接返回;如果在 redis 中不存在,就从数据库查,把查到的结果同时也写入 redis。经过一段时间的"动态平衡",redis 中的 key 就逐渐成了热点数据了。
在上述实时生成中,虽然可以生成热点数据, 但是,不停地写 redis,就会使 redis 的内存占用越来越多,逐渐达到设置的内存上限,此时,如果继续往里插数据,就会触发问题,为了解决这个问题,redis 就引入了 "内存淘汰策略"。
通用的淘汰策略有以下几种:
1)FIFO(First In First Out)
把缓存中存在时间最久的(也就是先来的数据)淘汰掉。
2)LRU(Least Recently Used)
记录每个 key 的访问时间,把最近最少使用的 key 淘汰掉。
3) LFU(Least Frequently Used)
记录每个 key 最近一段时间的访问次数,淘汰访问次数最少的。
4)Random 随机淘汰
从所有的 key 中随机淘汰。
Redis 内置的淘汰策略如下:
volatile-lru:
当内存不足以写入新数据时,从设置了过期时间(包括过期和未过期)的 key 中使用 LRU算法进行淘汰。
allkeys-lru:
当内存不足以写入新数据时,从所有的 key 中使用 LRU 算法进行淘汰。
volatile-lfu:
4.0 版本新增,当内存不足以写入新数据时,从设置了过期时间的 key 中使用 LFU 算法进行淘汰。
allkeys-lfu:
4.0 版本新增,当内存不足以写入新数据时,从所有的 key 中使用 LFU 算法进行淘汰。
volatile-random:
当内存不足以写入新数据时,从设置了过期时间的 key 中随机进行淘汰。
allkeys-random:
当内存不足以写入新数据时,从所有的 key 中随机进行淘汰。
volatile-ttl:
在设置了过期时间的 key 中,根据过期时间进行淘汰,越早过期的优先被淘汰(相当于 FIFO,只不过局限于设置了过期时间的 key)。
noeviction:
默认策略,当内存不足以写入新数据时,新写入操作会报错。
最后来介绍一下:缓存预热、缓存雪崩、缓存穿透、缓存击穿
缓存预热(Cache preheating)
redis 服务器首次接入之后,服务器里面没有数据,此时,所有的请求都会查询 mysql ,随着时间的推移,redis 上的数据逐渐积累增多,mysql 承担的压力就少了。
缓存预热就是用来解决上述问题的,先通过离线的方式,通过一些统计的途径,先把热点数据找到一批,导入到 redis 中,此时导入的这批热点数据就可以帮 mysql 承担很大的压力了,后续随着时间的推移,逐渐使用新的数据淘汰旧的数据。
缓存雪崩(Cache avalanche)
在短时间内,redis上 大量的 key 失效,导致缓存命中率陡然下降,mysql 的压力迅速上升,甚至直接宕机。
出现缓存雪崩的原因有:
1)redis 直接挂了
redis 宕机/redis 集群下大量节点宕机。
2)短时间内设置的 key的过期时间是相同的。
解决方法:
1)加强监控报警,加强 redis 集群可用性的保证。
2)不给 key 设置过期时间/设置过期时间的时候添加随机的因子,避免大量缓存同时过期。
缓存穿透(Cache penetration)
查询的某个 key 在 redis 和 mysql 上都没有,此时这样的 key 并不会被放到缓存上,后续再次查询这个 key 的时候,仍然会查询 mysql。如果这样的请求过多,会导致 mysql 压力过大。
出现缓存穿透的原因有:
1)业务设计不合理,比如缺少必要的参数验证环节,导致非法的 key 也被进行查询了。
2)开发/运维误操作,不小心把部分数据从数据库上误删了。
3)黑客恶意攻击。
解决方法:
1)针对要查询的参数进行严格的合法性校验,比如要查询的 key 是用户的手机号,那么就需要检验当前 key 是否满足一个 合法的手机号的格式。
2)针对数据库上不存在的 key,也存储到 redis 中,比如 value 值就随便设置一个"",避免后续频繁访问数据库。
缓存击穿 (Cache breakdown)
相当于缓存雪崩的特殊情况,针对热点 key,突然过期了,导致大量的请求直接访问数据库,甚至引起数据库宕机。
解决方法:
1)设置热点 key 永不过期。
2)进行必要的服务降级。例如,访问数据库的时候使用分布式锁,限制同时请求数据库的并发数。
以上就是 redis 作为缓存的相关内容
七、分布式锁
本篇博客最后一章节介绍一下分布式锁。
什么是分布式锁呢?
在一个分布式系统中,也会涉及到多个节点访问同一个公共资源的情况,此时就需要通过 锁 来做互斥控制,避免出现类似于"线程安全问题"。
线程安全问题简单来说就是:多个线程并发执行的时候,执行先后顺序不确定,因此具有随机性,导致程序执行结果不同,因此需要加锁来保证程序在任意执行顺序下执行逻辑都是一致的。
java 中的 synchronized 这样的锁本质上都是只能在一个进程内部生效,而在分布式系统中,是有很多个进程的(每个服务器都是独立的进程),因此这样的锁就难以对分布式系统中的多个进程之间产生制约。分布式系统中,多个进程之间的执行顺序也是不确定的,具有随机性,因此,需要引入"分布式锁"来解决上述问题。
分布式锁本质上就是使用一个/一组单独的服务器程序,给其它的服务器提供"加锁"这样的服务,redis 是一种典型的可以用来实现分布式锁的方案,也可以是其它组件(mysql / zookeeper等)。
那么分布式锁是如何实现的呢?
实现思路非常简单:本质上就是通过一个键值对来标识锁的状态。
举个例子:购买车票的场景
现在车站提供了多个车次,每个车次的票数都是固定的。现在存在多个服务器节点,都可能需要处理这个买票的逻辑:先查询指定车次的余票,如果余票 > 0,则设置余票 -= 1。
假设客户端1先执行查询余票,发现剩余一张,在即将执行 余票-1 = 0 过程之前,客户端2也执行查询余票,发现剩余一张,客户端2也会执行 余票 -1=0 过程,此时就会发生"超卖",也就是一张票卖给了两个人。
上述这种情况显然是存在"线程安全"的问题,需要使用锁来进行控制,
可以通过在上述架构中引入一个 redis,作为分布式锁的管理器来实现加锁操作。如下图:
当买票服务器进行买票操作的时候,就需要先进行加锁,也就是往 redis 上设置一个特殊的 key-value,完成上述买票操作后,再把这个 key-value 删除,当其它买票服务器也想买票的时候,也去 redis 上尝试设置 key-value,如果发现 key-value 已经存在,就认为"加锁失败"(后续放弃/阻塞,看具体实现策略),此时,就可以保证,第一个服务器执行查询余票到更新余票这个过程中,不会有第二个服务器执行查询操作,也就解决了上述"超卖"问题。
针对上述这样的场景,Redis 的 setnx 命令刚好试用,即:key 不存在就设置,存在就失败。
使用 setnx 命令可以得到"加锁"效果,针对解锁,就可以使用 del 命令来完成,但是,还是存在问题,试想一下:某个服务器加锁成功了(setnx 成功),执行后续逻辑过程中, 服务器直接掉电,进程异常终止(没有执行到解锁操作),此时就导致 redis 上设置的 key 无人删除,也就导致其它服务器无法获取到锁了。
为了避免上述这种情况的出现,可以使用过期时间,给 set 的 key 设置过期时间,一旦时间到了,key 就会自动的被删除了。
可以使用 redis 的 set ex nx 这样的命令来完成设置,比如设置 key 的过期时间为 1000ms,那么意味着,即使出现极端情况,某个服务器挂了,没有正确释放锁,这个锁最多保持 1000 ms,也就会自动释放了。需要注意的事,不能使用 setnx +expire 这两个命令,必须使用 set ex nx 命令,redis 的多个命令之间,无法保证原子性,此时就可能出现一个成功,一个失败的情况,相比之下,使用一条命令设置,更加稳妥。
所谓的加锁,就是给 redis 上设置一个 key-value;
所谓的解锁,就是将 redis 上这个 key-value 删除。
还有可能出现如下情况:服务器1执行了加锁,而服务器2执行了解锁。
为了解决上述问题,就需要引入校验机制。
1)给每个服务器编号,每个服务器有自己的身份标识。
2)进行加锁的时候,设置 key-value,key 对应要针对哪个资源加锁(比如车次),value 就可以存储刚才服务器的编号,标识出当前这个锁是哪个服务器加上的。
后续在进行解锁的时候,就可以进行校验了,解锁的时候,先查询一下这个锁对应的服务器编号,然后判定一下这个编号是否就是当前执行解锁的服务器的编号,如果是,才执行 del,否则不执行。
通过上述校验,就可以有效避免"误解锁"问题。
在解锁的时候,先进行判定,再进行 del,此处是两步操作(不是原子的),也会出现问题:
一个服务器内部,也可能是多线程的,此时可能出现如下情况:
同一个服务器的两个线程都执行上述解锁操作:
此时,del 就会被重复执行。
上述情况来说,看起来重复执行 del 好像问题不大,实则不然。主要是引入一个新服务器,执行加锁,此时就会有问题。
在线程 1 执行完 del 之后,线程 2 执行 del 之前,服务器2的线程 3 正好执行 加锁(set),此时由于线程1把锁释放了,C的加锁是能够成功的,但是紧接着,线程2的 del 会将服务器2的加锁操作给解锁了。服务器 1和 服务器 2 进行加锁,key 是资源的编号(比如车次),服务器的 id 是 value。
使用事务能够解决上述问题(redis 事务虽然弱,但是能够避免插队)。
还可以使用 lua 脚本来使解锁操作是原子的。
lua 是一个编程语言,作为 redis 内嵌的脚本,可以用 lua 编写一些逻辑,把这个脚本上传到 redis 服务器上,然后就可以让客户端来控制 redis 执行上述脚本了。 redis 执行 lua 脚本的过程,也是原子的,相当于执行一条命令一样(redis 官方文档也明确说,lua 就属于是实物替代方案)。
lua 脚本解锁功能如下:
if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end;
上述代码可以编写成一个 .lua 后缀的文件,由 redis-cli 或者 redis-plus-plus 或者 jedis 等客户端加载并发送给 redis 服务器,由 redis 服务器来执行这段逻辑。
上述方案仍然存在一个很重要的问题,要在加锁的时候,给 key 设置过期时间,那么过期时间设置多少合适呢?
如果设置的时间太短,可能导致业务逻辑还未执行完,锁就释放了。
如果设置的时间太长,会导致"锁释放不及时"问题。
因此,相较于一个固定时间,不如动态的调整时间。初始情况下,设置一个过期时间(比如设置 1s),就提前在还剩 300ms 的时候(可以调整),如果当前任务还没执行完,就把过期时间再续上 1s,等到时间又快到了,任务还没执行完,就再续,如果服务器中途崩溃了,自然就没人负责续约了,此时,锁就能在极短的时间内被自动释放。服务器这边有一个专门的线程,负责"续约"这个操作,把这个负责的线程,叫做**"看门狗"(watch dog)。**
注:"看门狗"这个线程是在业务服务器上的,不是在 redis 服务器上的。
实际使用 redis 时,一般是使用集群的方式进行部署的(至少是主从模式,而不是单机),那么就可能出现以下情况:
服务器1向主节点进行加锁操作,这个写入 key 的过程刚刚完成,主节点挂了,还没来得及将数据同步给从节点,此时,即使从节点升级成主节点,刚才加锁对应的数据也是不存在的,服务器2仍然可以进行加锁。
为了解决这个问题,Redis 的作者提供了 Redlock 算法。
引入一组 Redis 节点。其中每组 Redis 节点都包含一个主节点和若干从节点,并且组和组之间存储的数据都是一致的,相互之间是"备份关系"(并不是数据集合的一部分,和 Redis cluster 不同)。加锁的时候,按照一定的顺序,写多个主节点,在加锁的时候需要设定操作的"超时时间",比如:50ms,即:如果 setnx 操作超过了 50ms 还没成功,就视为加锁失败。
如果给某个主节点加锁失败,就立即尝试下一个节点,当加锁成功的节点的个数超过总数的一半,才认为是加锁成功。
同时,释放锁的时候,也需要把所有的节点都进行解锁操作(即使之前超时的节点,也要尝试解锁)。
简单俩说,Redlock 算法的核心思路就是:加锁不能只给写给一个 Redis 节点,而是要写多个,分布式系统中任何一个节点都是不可靠的,最终的加锁成功结论是"少数服从多数"。
由于一个分布式系统不至于大部分节点都同时出现故障,因此这样的可靠性远比单个节点靠谱不少。
以上就是 Redis 分布式锁相关内容。
Redis 相关三部"曲",从入门到入土(啊呸,精通),希望这三篇文章能够帮助铁铁,后续博主会继续更新精品文章,感兴趣的话不妨点点关注嗷~