Redis
本文最后更新于:星期一, 九月 12日 2022, 1:25 凌晨
基础
redis
数据类型
string
hash
list
set
sorted set
string
最简单类型,普通的set
和get
,做简单的KV
缓存。
set name wjs
hash
类似map
,可以将结构化的数据,比如一个对象(前提是这个对象没嵌套其他的对象)给缓存在redis
里,然后每次读写缓存的时候,可以就操作hash
里的某个字段。
hset person name wjs
hset person age 20
hset person id 1
hget person name
person = {
"name": "wjs",
"age": 20,
"id": 1
}
list
有序列表,通过list
存储一些列表型的数据结构,类似粉丝列表、文章的评论列表之类的东西
通过lrange
命令,读取某个闭区间内的元素,基于list
实现分页查询,可以做类似微博那种下拉不断分页的东西,性能高,就一页一页走。
# 0开始位置,-1结束位置,结束位置为-1时,表示列表的最后一个位置,即查看所有。
lrange mylist 0 -1
也可以搞个简单的消息队列,从list
头放进去,从list
尾巴里出来。
lpush mylist 1
lpush mylist 2
lpush mylist 3 4 5
# 1
rpop mylist
set
无序集合,自动去重
直接基于set
将系统里需要去重的数据扔进去,自动就给去重,如果需要对一些数据进行快速的全局去重,当然可以基于HashSet
进行去重,但如果系统部署在多台机器上,得基于redis
进行全局的set
去重。
基于set
玩交集、并集、差集的操作
#-------操作一个set-------
# 添加元素
sadd mySet 1
# 查看全部元素
smembers mySet
# 判断是否包含某个值
sismember mySet 3
# 删除某个/些元素
srem mySet 1
srem mySet 2 4
# 查看元素个数
scard mySet
# 随机删除一个元素
spop mySet
#-------操作多个set-------
# 将一个set的元素移动到另外一个set
smove yourSet mySet 2
# 求两set的交集
sinter yourSet mySet
# 求两set的并集
sunion yourSet mySet
# 求在yourSet中而不在mySet中的元素
sdiff yourSet mySet
sorted set
排序set
,去重且可以排序,写进去的时候给一个分数,自动根据分数排序。
zadd board 85 zhangsan
zadd board 72 lisi
zadd board 96 wangwu
zadd board 63 zhaoliu
# 获取排名前三的用户(默认是升序,所以需要 rev 改为降序)
zrevrange board 0 3
# 获取某用户的排名
zrank board zhaoliu
线程模型
redis
内部使用文件事件处理器file event handler
,它是单线程的,所以redis
才叫做单线程的模型。
采用IO
多路复用机制同时监听多个socket
,将产生事件的socket
压入内存队列中,事件分派器根据socket
上的事件类型来选择对应的事件处理器进行处理
file event handler
结构:
- 多个
socket
IO
多路复用程序- 文件事件分派器
- 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)
多个socket
可能会并发产生不同的操作,每个操作对应不同的文件事件,但是IO
多路复用程序会监听多个socket
,会将产生事件的socket
放入队列中排队,事件分派器每次从队列中取出一个socket
,根据socket
的事件类型交给相应的事件处理器进行处理。
客户端与redis交互过程
前提:通信是通过
socket
来完成
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
事件与命令回复处理器的关联。
redis
单线程模型为啥效率这么高?
- 纯内存操作。
- 核心是基于非阻塞的
IO
多路复用机制。 - C语言实现,一般来说,C语言实现的程序“距离”操作系统更近,执行速度相对会更快。
- 单线程反而避免了多线程的频繁上下文切换问题,预防了多线程可能产生的竞争问题。
过期策略
定期删除+惰性删除
定期删除,指的是redis
默认每隔100ms
随机抽取一些设置了过期时间的key
,检查其是否过期,如果过期就删除。
问题是定期删除可能会导致很多过期key
到了时间并没有被删除掉,所以还要惰性删除。获取key
时,如果此时key
已经过期,就删除,不会返回任何东西。
但是还有问题,如果定期删除漏掉了很多过期key
,也没及时去查,也没走惰性删除,此时大量过期key
堆积在内存里,会导致redis
内存块耗尽。如何解决?
答案是:内存淘汰机制。
内存淘汰机制
redis
内存淘汰机制如下:
noeviction
: 当内存不足以容纳新写入数据时,新写入操作会报错allkeys-lru
:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key
(这个是最常用的)allkeys-random
:当内存不足以容纳新写入数据时,在键空间中,随机移除某个key
volatile-lru
:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的key
volatile-random
:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个key
volatile-ttl
:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的key
优先移除
持久化
两种方式
RDB
对redis
中的数据执行周期性的持久化AOF
对每条写入命令作为日志,以append-only
的模式写入一个日志文件中,在redis
重启的时候,可以通过回放AOF
日志中的写入指令来重新构建整个数据集
使用mac
电脑的同学都知道mac用来做硬盘数据备份的”时间机器“app
,rdb
就好比我们经常用云空间或移动硬盘开启时间机器app
进行硬盘数据定期备份。而aof
就好比我们执行每个操作时候同步进行的备份操作。当然mac
电脑并没有提供类似aof
这样的备份机制。
如果redis
挂了,服务器上的内存和磁盘上的数据都丢了,可从云服务上拷贝之前的数据,放到指定目录,然后重新启动redis
,redis
就会自动根据持久化数据文件中的数据,去恢复内存中的数据,继续对外提供服务。
如果同时使用RDB
和AOF
两种持久化机制,那么在redis
重启的时候,会使用AOF
来重新构建数据,因为AOF
中的数据更加完整。
RDB
优缺点
RDB
会生成多个数据文件,每个数据文件都代表了某一个时刻redis
数据,这种多个数据文件的方式,非常适合做冷备,可将这种完整的数据文件发送到一些远程的安全存储上去,以预定好的备份策略来定期备份redis
数据。
RDB
对redis
对外提供的读写服务,影响非常小,可以让redis
保持高性能,因为redis
主进程只需要fork
一个子进程,让子进程执行磁盘IO
操作来进行RDB
持久化即可。
相对于AOF
持久化机制来说,直接基于RDB
数据文件来重启和恢复redis
进程,更加快速。
如果想要尽可能少的丢数据,那么RDB
没有AOF
好。RDB
数据快照文件,都是每隔5分钟,或者更长时间生成一次,这个时候就得接受一旦redis
进程挂机,那么最近5分钟的数据会丢失。
RDB
每次在fork
子进程来执行RDB快照数据文件生成的时候,如果数据文件特别大,可能会导致对客户端提供的服务暂停数毫秒,或者甚至数秒。
AOF
优缺点
AOF
会每隔1秒,通过一个后台线程执行一次fsync
操作,最多丢失1秒钟的数据。
AOF
日志文件以append-only
模式写入,没有任何磁盘寻址的开销,写入性能非常高,而且文件不容易破损,即使文件尾部破损,也很容易修复。
AOF
日志文件即使过大的时候,出现后台重写操作,也不会影响客户端的读写。因为在rewrite log
的时候,会对其中的指令进行压缩,创建出一份需要恢复数据的最小日志出来。在创建新日志文件的时候,老的日志文件还是照常写入。当新的merge
后的日志文件ready
的时候,再交换新老日志文件即可。
AOF
日志文件的命令通过非常可读的方式进行记录,这个特性非常适合做灾难性的误删除的紧急恢复。比如不小心用flushall
命令清空了所有数据,只要这个时候后台rewrite
还没有发生,那么就可以立即拷贝AOF
文件,将最后一条flushall
命令删了,然后再将该AOF
文件放回去,就可通过恢复机制,自动恢复所有数据。
对于同一份数据来说,AOF
日志文件通常比RDB
数据快照文件更大。
AOF
开启后,支持的写QPS
会比RDB
支持的写QPS
低,因为AOF
一般会配置成每秒fsync
一次日志文件,当然,每秒一次fsync
,性能也还是很高的。(如果实时写入,那么QPS
会大降,redis
性能会大大降低)
类似AOF
这种较为复杂的基于命令日志/merge/
回放的方式,比基于RDB
每次持久化一份完整的数据快照文件的方式,更加脆弱一些,容易有bug
。不过AOF
为了避免rewrite过程导致的bug
,每次rewrite
并不是基于旧的指令日志进行merge
的,而是基于当时内存中的数据进行指令的重新构建,这样健壮性会好一点。
该选哪种来持久化?
- 不要仅仅使用
RDB
,因为这样会导致你丢失很多数据 - 也不要仅仅使用
AOF
,因为这样有两个问题- 通过
AOF
做冷备,没有RDB
做冷备来的恢复速度更快 RDB
每次简单粗暴生成数据快照,更加健壮,可避免AOF
这种复杂的备份和恢复机制的bug
- 通过
redis
支持同时开启开启两种持久化方式,可综合使用AOF
和RDB
,用AOF
来保证数据不丢失,作为数据恢复的第一选择; 用RDB
来做不同程度的冷备,在AOF
文件都丢失或损坏不可用的时候,还可使用RDB
来进行快速的数据恢复。
架构
主从(master-slave
)架构
一主多从,主负责写,并且将数据复制到其它的slave
节点,从节点负责读。所有的读请求全部走从节点。这样可以轻松实现水平扩容,支撑读高并发
redis replication
- 采用异步方式复制数据到
slave
节点,从redis2.8
开始,slave
会周期性地确认自己每次复制的数据量 - 一个
master
可配置多个slave
slave
也可连接其他的slave
slave
复制时,不会block master
的正常工作slave
在做复制时,也不会block
对自己的查询操作,它会用旧的数据集来提供服务;但是复制完成的时候,需要删除旧数据集,加载新数据集,此时就会暂停对外服务了slave
主要用来进行横向扩容,读写分离,扩容slave
提高读的吞吐量
采用主从架构,必须开启master
持久化。因为如果关掉master
持久化,在master
挂机重启时,数据是空的,然后可能一经过复制,slave
的数据也丢了。
另外,需要对master
做各种备份方案。万一本地所有文件丢失,从备份中挑选一份rdb
去恢复master
,这样才能确保启动时有数据,即使采用了高可用机制,slave
可以自动接管master
,也可能还没检测到master failure
,master
就自动重启了,还是可能导致所有的slave
数据被清空。
主从复制核心原理
启动一个slave
时,它会发送一个PSYNC
命令给master
。
如果这是slave
第一次连接到master
,就会触发一次full
resynchronization
(全量复制)。
此时master
会启动一个后台线程,开始生成一份RDB
快照文件,同时还会将从客户端client
新收到的所有写命令缓存在内存中。RDB
文件生成完毕后,master
会将这个RDB
发送给slave
,slave
会先写入本地磁盘,然后再从本地磁盘加载到内存中,
接着master
会将内存中缓存的写命令发送到slave
,slave
也会同步这些数据。slave
如果跟master
有网络故障,断开连接,会自动重连,连接之后master
仅会复制给slave
部分缺少的数据
几个小问题需要厘清
主从复制的断点续传
从redis2.8
开始,就支持主从复制的断点续传,如果主从复制过程中,网络断了,可以接着上次复制的地方,继续复制。
master
会在内存中维护一个backlog
,master
和slave
都会保存一个replica offset
还有一个master run id
,offset
就是保存在backlog
中的。如果master
和slave
网络断了,slave
会让master
从上次replica offset
开始继续复制,如果没有找到对应的offset
,那么就会执行一次全量复制
如果根据
host+ip
定位master
是不靠谱的,如果master
重启或数据出现了变化,slave
应该根据不同的run id
区分
无磁盘化复制
master
在内存中直接创建RDB
,然后发送给slave
,不会在自己本地落盘。只需要在配置文件中开启
repl-diskless-sync yes
过期key
处理
slave
不会过期key
,只会等待master
过期key
。如果master
过期了一个key
,或通过LRU
淘汰了一个key
,那么会模拟一条del
命令发送给slave
主从复制完整流程
slave
启动时,会在自己本地保存master
的信息,包括master
的host
和ip
,但是复制流程没开始。
slave
内部有个定时任务,每秒检查是否有新的master
要连接和复制,如果发现,就跟master
建立socket
网络连接。然后slave
发送ping
命令给master
。如果master
设置了requirepass
,那么slave
必须发送masterauth
的口令过去进行认证。master
第一次执行全量复制,将所有数据发给slave
。而在后续,master
持续将写命令,异步复制给slave
全量复制
master
执行bgsave
,在本地生成一份rdb
快照文件。master
将rdb
快照文件发送给slave
,如果rdb
复制时间超过60秒(repl-timeout
),那么slave
就会认为复制失败,可以适当调大这个参数master
在生成rdb
时,会将所有新的写命令缓存在内存中,在slave
保存了rdb
之后,再将新的写命令复制给slave
如果在复制期间,内存缓冲区持续消耗超过64MB,或者一次性超过256MB,那么停止复制,复制失败
client-output-buffer-limit slave 256MB 64MB 60
slave
接收到rdb
之后,清空自己的旧数据,然后重新加载rdb
到自己的内存中,同时基于旧的数据版本对外提供服务如果
slave
开启了AOF
,那么会立即执行BGREWRITEAOF
,重写AOF
增量复制
- 全量复制过程中,如果网络断了,那么
slave
重新连接master
时,会触发增量复制。 master
直接从自己的backlog
中获取部分丢失的数据,发送给slave
,默认backlog
就是1MBmaster
根据slave
发送的psync
中的offset
来从backlog
中获取数据
心跳(heartbeat
)
- 主从节点互相发送
heartbeat
信息 master
默认每隔10秒发送一次heartbeat
,slave
每隔1秒发送一个heartbeat
异步复制
master
每次接收到写命令之后,先在内部写入数据,然后异步发送给slave
如何实现Redis
高可用?
redis
的高可用架构,叫做failover
故障转移,也可叫做主备切换
master
在故障时,自动检测,并将某个slave
自动切换为master
的过程,就叫做主备切换
redis3.x
版本开始引入自身的cluster
集群机制,已经不需要使用哨兵模式来实现自身的高可用
redis cluster
,主要是针对海量数据+高并发+高可用的场景。redis cluster
支撑N个redis
的master
,每个master
都可以挂载多个slave
。整个redis
就可横向扩容。如果要支撑更大数据量的缓存,那就横向扩容更多的master
节点,每个master
就能存放更多的数据。
redis cluster
介绍
- 自动将数据进行分片,每个
master
上放一部分数据 - 提供内置的高可用支持,部分
master
不可用时,还是可以继续工作的
在redis cluster
架构下,每个redis
要开放两个端口,比如一个是6379
,另外一个就是加1w的端口号,比如16379
。(其实都可以自行在配置文件中指定不同的端口号,只要端口号没被系统占用就行)
16379
端口号是用来进行节点间通信的,也就是cluster bus
,cluster bus
通信是用来进行故障检测、配置更新、故障转移授权。它使用了另外一种二进制的协议,gossip
协议,用于节点间进行高效的数据交换,占用更少的网络带宽和处理时间。
内部通信机制
集群元数据的维护有两种方式:集中式、Gossip
协议。
redis cluster
节点间采用gossip
协议进行通信
gossip
协议:
所有节点都持有一份元数据,不同的节点如果出现了元数据的变更,就不断将元数据发送给其它的节点,让其它节点也进行元数据的变更。
gossip
优点
元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续打到所有节点上去更新,降低了压力
gossip
缺点
元数据的更新有延时,可能导致集群中的一些操作会有一些滞后
每个节点都有一个专门用于节点间通信的端口,比如之前说的6379
,那么用于节点间通信的就是16379
端口。每个节点每隔一段时间都会往另外几个节点发送ping
消息,同时其它几个节点接收到ping
之后返回pong
交换的信息包括故障信息,节点的增加和删除,hash slot
信息等等。
gossip
协议包含多种消息,诸如ping
,pong
,meet
,fail
等等。
meet
: 某个节点发送meet
给新加入的节点,让新节点加入集群中,然后新节点就会开始与其它节点进行通信- 内部发送了一个
gossip meet
消息给新加入的节点,通知那个节点去加入我们的集群 ping
: 每个节点都会频繁给其它节点发送ping
,其中包含自己的状态还有自己维护的集群元数据,互相通过ping
交换元数据pong
: 返回ping
和meet
,包含自己的状态和其它信息,也用于信息广播和更新fail
: 某个节点判断另一个节点fail
之后,就发送fail
给其它节点,通知其它节点说,某个节点宕机了
hash slot
算法
redis cluster
有固定的16384
(2的14次方)个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
上去。移动hash slot
的成本是非常低的。客户端的api
,可以对指定的数据,让他们走同一个hash slot
,通过hash tag
来实现。
任何一台机器挂机,另外两个节点不影响。因为key
找的是hash slot
,不是机器。
redis cluster
实现高可用
判断节点挂机
如果一个节点认为另外一个节点挂机,那么就是pfail
: 主观挂机。如果多个节点都认为另外一个节点挂机了,那么就是fail
: 客观挂机
在cluster-node-timeout
内,某个节点一直没有返回pong
,那么就被认为pfail
。
如果一个节点认为某个节点pfail
了,那么会在gossip ping
消息中,ping
给其他节点,如果超过半数的节点都认为pfail
了,那么就会变成fail
。
从节点过滤
对挂机的master
,从其所有的slave
中,选择一个切换成master
。
检查每个slave
与master
断开连接的时间,如果超过了cluster-node-timeout
,那么就没有资格切换成master
。
从节点选举
每个slave
都根据自己对master
复制数据的offset
,来设置一个选举时间,offset
越大(复制数据越多)的slave
,选举时间越靠前,优先进行选举
所有的master
开始slave
选举投票,给要进行选举的slave
进行投票,如果大部分master
(N/2 + 1
)都投票给了某个slave
,那么选举通过,这个slave
就可变成master
。
然后,slave
开始执行主备切换,切换成master
。
Redis
缓存
目前大多数公司对redis
的应用在于构建分布式缓存方面,因此对于redis
缓存使用,面试官会问下列一些问题
缓存雪崩
指缓存在某一时刻数据全部失效,所有对数据查询请求全部跑到DB
,DB
瞬时因为请求压力太大,产生挂机或耗时处理。严重的,甚至会让DB
也挂掉。导致整体系统处于不可用状态。
缓存数据全部失效有两种可能:
- 缓存服务器挂掉了
- 缓存数据中部分数据的缓存有效时间在某一个时刻全部集体失效,虽然缓存服务器还是可用状态,但是其中大部分缓存数据已不存在,对数据进行查询的请求只能走
DB
解决方案:
- 保证
redis
高可用,使用redis cluster
避免全盘崩溃 - 本地
ehcache
缓存+hystrix
限流&降级,避免DB
挂机 redis
数据做持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据- 在原有的缓存有效时间上增加一个随机值,比如1-5分钟随机,或使用
java
的random
方法。保证每个缓存数据有效时间的重复率降低,就不太会引发集体失效的事情
缓存穿透
黑客发起的恶意攻击或是人为向缓存数据发起了一个缓存和db
都不存在的数据查询请求。这个不存在的数据每次请求发现缓存中没有就会去DB
查询,这样就失去了缓存存在意义。请求流量大时,可导致DB
挂机不可用。
解决方案:
- 将不存在的数据缓存起来,并设置一个过期时间,下次有相同的不存在数据查询请求过来,在缓存失效之前,都可直接从缓存中取出这个数据
- 在缓存之前,设置布隆过滤器,实际上是一个
bitMap
结构。当一个元素被加入时,将这个元素映射成一个位数组中的K个点,把它们置为1。检索时,只要看看这些点是不是都是1就(大约)知道集合中有没有它了。如果这些点有任何一个0,则被检元素一定不存在;如果都是1,则被检元素很可能存在。不存在就直接返回(针对黑客攻击都采用这一方案)
缓存击穿
某个key
非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个key
在失效瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。又被称之为缓存并发
解决方案:
使用分布式锁,保证对于每个key
同时只有一个线程去查询,其他线程没有获得分布式锁,因此只能等待。这种方式将高并发的压力转移到了分布式锁,因此对分布式锁的考验很大。下面会详细讲redis
分布式锁实现。
至于某些资料说将数据设置为”永不过期“,个人认为是治标不治本的方法。容易引起缓存和DB
数据不一致问题。
缓存和DB
数据双写一致
DB
数据随着时间可能会发生变化,而缓存数据不及时同步更新就会不一致。所以需要做到最终一致。
目前做法是
- 先写
DB
,再删除缓存(Cache Aside
模式) - 先删除缓存,再写
DB
其实都有问题
如果1,在删除缓存前,写DB
的线程挂了,没有删除掉缓存,则会出现数据不一致
如果2,删除了缓存,还没有来得及写DB
,另一个线程就来读取,发现缓存为空,则去DB中读取数据写入缓存,此时缓存中就为脏数据。
本质是因为写和读是并发的,没法保证顺序,所以会出现数据不一致问题。
解决方案:
情况1,异步更新缓存(基于订阅
binlog
的同步机制)binlog
增量订阅消费+消息队列+增量数据更新到缓存
步骤- 写
DB
- 数据更新日志写入
binlog
中 - 读取
binlog
后,解析数据,利用消息队列推送到各节点更新缓存数据 - 尝试删除缓存操作
- 删除失败,将需要删除的缓存
Key
发送到消息队列中 - 从队列中拿到要删除的缓存
key
,再次尝试删除缓存,如果再次删除失败,可重发消息多次尝试;
总的来说就是提供一个”重试保障机制”,如果删除缓存失败,可将删除失败的key发送到消息队列,再进行重试删除操作
- 写
情况2,延时双删策略+缓存超时设置
写DB
前后都进行redis.del
(key
操作,并设定合理的有效时间)。
步骤先删缓存
再写
DB
休眠500毫秒
那么,这个500毫秒怎么确定的,具体休眠多久呢?评估自己项目的读数据业务逻辑的耗时。目的是确保读请求结束,写请求可以删除读请求造成的缓存脏数据
还要考虑
redis
和DB
主从同步的耗时。最后写数据的休眠时间应该在读数据业务逻辑的耗时基础上,加几百ms再删缓存
给缓存设置有效时间,用来保证最终一致性。所有的写操作以DB
为准,只要缓存有效时间到了,则后面的读请求自然会从DB
中读取新值然后回填缓存
结合延时双删策略+缓存超时设置,最差情况是在有效时间内数据存在不一致,且增加了写请求的耗时
Redis
分布式锁
单Redis
节点
使用setnx
命令创建一key
,这样就算加锁。
SET resource_name my_random_value NX PX 30000
my_random_value
是由客户端生成的一个随机字符串,它要保证在足够长的一段时间内在所有客户端的所有获取锁的请求中都是唯一的NX
表示只有key
不存在的时候才会设置成功。(如果此时存在这个key
,那么设置失败,返回nil
)PX 30000
意思是30s后锁自动释放。别人创建的时候如果发现已经有了就不能加锁了
释放锁就是删除key
,一般可以用lua
脚本删除,判断value
一样才删除
-- 删除锁的时候,找到 key 对应的 value,跟自己传过去的 value 做比较,如果是一样的才删除。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
这段Lua
脚本在执行的时候要把前面的my_random_value
作为ARGV[1]
的值传进去,把resource_name
作为KEYS[1]
的值传进去。
问题
锁必须要设置一个过期时间。否则当一个客户端获取锁成功之后,假如它崩溃了,或者网络不可用导致它再也无法和
Redis
节点通信了,那么它就会一直持有这个锁,而其它客户端永远无法获得锁。这个过期时间被称为锁的有效时间lock validity time
。获得锁的客户端必须在这个时间之内完成对共享资源的访问获取锁操作不应该写成
SETNX resource_name my_random_value EXPIRE resource_name 30
虽然这两个命令和前面描述中的
SET
命令执行效果相同,但却不是原子的。如果客户端在执行完SETNX
后崩溃了,那么就没有机会执行EXPIRE
了,导致它一直持有这个锁设置一个随机字符串
my_random_value
保证了一个客户端释放的锁必须是自己持有的那个锁。假设获取锁时SET
的不是随机字符串,而是固定值,那么可能会发生下面的执行序列- 客户端1获取锁成功。
- 客户端1在某个操作上阻塞了很长时间。
- 过期时间一到,锁自动释放。
- 客户端2获取到了对应同一个资源的锁。
- 客户端1从阻塞中恢复过来,释放掉了客户端2持有的锁。
之后,客户端2在访问共享资源的时候,就没有锁为它提供保护了
释放锁的操作必须使用Lua脚本来实现。释放锁其实包含三步操作:
GET
、判断和DEL
,用Lua
脚本来实现能保证这三步的原子性。否则,如果把这三步操作放到客户端逻辑中去执行的话,就有可能发生与前面问题3类似的执行序列- 客户端1获取锁成功。
- 客户端1访问共享资源。
- 客户端1为了释放锁,先执行
GET
操作获取随机字符串的值。 - 客户端1判断随机字符串的值,与预期的值相等。
- 客户端1由于某个原因阻塞住了很长时间。
- 过期时间到了,锁自动释放了。
- 客户端2获取到了对应同一个资源的锁。
- 客户端1从阻塞中恢复过来,执行
DEL
操作,释放掉了客户端2持有的锁。
假如
Redis
节点挂机了,那么所有客户端就都无法获得锁,服务变得不可用。为了提高可用性,给这个Redis
节点挂一个Slave
,当Master
不可用时,系统自动切到Slave
上(failover
)。但由于Redis
的主从复制(replication
)是异步的,这可能导致failover
过程中丧失锁了的安全性。考虑下面的执行序列:- 客户端1从
Master
获取了锁 Master
挂机了,存储锁的key
还没有来得及同步到Slave
上。Slave
升级为Master
- 客户端2从新
Master
获取到了对应同一个资源的锁
于是,客户端1和客户端2同时持有了同一个资源的锁。锁的安全性被打破
- 客户端1从
基于单Redis
节点的分布式锁无法解决这个问题5。而正是这个问题催生了RedLock
的出现
Redission
原理图
加锁
Lua
脚本
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hset', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]);
为什么要用lua
?
通过封装在lua
脚本中发送给redis
,保证执行的原子性
解释
KEYS[1]
:表示你加锁的那个key
,比如说
RLock lock = redisson.getLock(“myLock”);
这里你自己设置了加锁的那个锁key
就是myLock
。
ARGV[1]
:表示锁的有效期,默认30sARGV[2]
:表示表示加锁的客户端ID,类似于下面这样8743c9c0-0795-4907-87fd-6c719a6b4586:1
用exists myLock
命令判断,如果要加锁的那个锁key
不存在就进行加锁,接着执行pexpire myLock 30000
命令,设置myLock
这个锁key
的生存时间是30秒(默认)
锁互斥
如果客户端2尝试加锁,执行了同样的一段lua
脚本,第一个if
判断会执行exists myLock
,发现myLock
这个锁key
已经存在了。
接着第二个if
判断,判断一下,myLock
锁key
的hash
数据结构中,是否包含客户端2的ID
,但明显不是,包含的是客户端1的ID
。
所以,客户端2会获取到pttl myLock
返回的一个数字,这个数字代表了myLock
这个锁key
的剩余生存时间。比如还剩15000毫秒的生存时间。
此时客户端2会进入一个while
循环,不停的尝试加锁。
watch dog
自动延期机制
客户端1加锁的锁key
默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办?
只要客户端1一旦加锁成功,就会启动一个watch dog
看门狗,这是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key
,那就会不断的延长锁key
的生存时间。续命操作
可重入加锁机制
如果客户端1已经持有这把锁,可重入的加锁会如何
#重入加锁
RLock lock = redisson.getLock("myLock")
lock.lock();
//业务代码
lock.lock();
//业务代码
lock.unlock();
lock.unlock();
分析上面lua
代码
第一个if
判断不成立,exists myLock
会显示锁key
已经存在了
第二个if
会成立,因为myLock
的hash
数据结构中包含的客户端1的ID
此时就会执行可重入加锁的逻辑,用incrby
这个命令,对客户端1的加锁次数,累加1
解锁
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
执行lock.unlock()
,释放分布式锁,每次对myLock
数据结构中的那个加锁次数减1。如果发现加锁次数是0了,说明客户端1已不再持有锁,此时就会用:del myLock
命令,从redis
里删除这个key
。
然后另外的客户端2就可尝试加锁。
问题
见单Redis节点问题5,redis
主从异步复制导致redis
分布式锁的最大缺陷:
在redis master
实例挂掉时,可能导致多个客户端同时完成加锁。所以还是要实现RedLock
集群Redis
节点(RedLock
)
运行RedLock
算法的客户端依次执行下面各个步骤,来完成获取锁的操作
- 获取当前时间(毫秒数)
- 按顺序依次向N个
Redis
节点执行获取锁的操作。这个获取操作跟单Redis
节点获取锁的过程相同,包含随机字符串my_random_value
,也包含过期时间(比如PX 30000
,即锁的有效时间)。为保证在某个Redis
节点不可用的时候算法能够继续运行,这个获取锁的操作还有一个超时时间(time out
),它要远小于锁的有效时间(几十毫秒量级)。客户端在向某个Redis
节点获取锁失败以后,应该立即尝试下一个Redis
节点。这里的失败,应该包含任何类型的失败,比如该Redis
节点不可用,或者该Redis
节点上的锁已经被其它客户端持有 - 计算整个获取锁的过程总共消耗了多长时间,计算方法是用当前时间减去第1步记录的时间。如果客户端从大多数
Redis
节点(>= N/2+1
)成功获取到了锁,并且获取锁总共消耗的时间没有超过锁的有效时间(lock validity time
),那么这时客户端才认为最终获取锁成功;否则,认为最终获取锁失败。 - 如果最终获取锁成功了,那么这个锁的有效时间应该重新计算,它等于最初的锁的有效时间减去第3步计算出来的获取锁消耗的时间。
- 如果最终获取锁失败了(可能由于获取到锁的Redis节点个数少于
N/2+1
,或整个获取锁的过程消耗的时间超过了锁的最初有效时间),那么客户端应立即向所有Redis
节点发起释放锁的操作(即前面介绍的Lua
脚本)
当然,上面描述的只是获取锁的过程,而释放锁的过程比较简单: 客户端向所有Redis
节点发起释放锁的操作,不管这些节点当时在获取锁的时候成功与否。
问题
如果有节点发生崩溃重启,还是会对锁的安全性有影响的。具体的影响程度跟
Redis
对数据的持久化程度有关。
假设一共有5个Redis
节点:A, B, C, D, E。设想发生了如下的事件序列:- 客户端1成功锁住了A, B, C,获取锁成功(但D和E没有锁住)。
- 节点C崩溃重启了,但客户端1在C上加的锁没有持久化下来,丢失了。
- 节点C重启后,客户端2锁住了C, D, E,获取锁成功。
这样,客户端1和客户端2同时获得了锁(针对同一资源),需要延迟重启解决。也就是说,一个节点崩溃后,先不立即重启,等待一段时间再重启,这段时间应该大于锁的有效时间(
lock validity time
)。这样这个节点在重启前所参与的锁都会过期,它在重启后就不会对现有的锁造成影响。释放锁的时候,客户端应该向所有
Redis
节点发起释放锁的操作。即使当时向某个节点获取锁没有成功,在释放锁的时候也不应该漏掉这个节点。why?设想这样一种情况,客户端发给某个Redis
节点的获取锁请求成功到达了该Redis
节点,这个节点也成功执行了SET
操作,但是它返回给客户端的响应包丢失了。在客户端看来,获取锁的请求由于超时而失败,但在Redis
这边看来,加锁已经成功了。因此,释放锁的时候,客户端也应该对当时获取锁失败的那些Redis
节点同样发起请求
参考资料
推荐书单
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!