原子性操作命令
set命令
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
:只在键已经存在时,才对键进行设置操作。
因为 SET 命令可以通过参数来实现和 SETNX 、 SETEX 和 PSETEX 三个命令的效果,所以将来的 Redis 版本可能会废弃并最终移除 SETNX 、 SETEX 和 PSETEX 这三个命令。
redis命令方式设置锁,可以使用setnx,incr命令,但是这两个命令还要再设置expire过期时间,防止意外退出,锁未删除,所以上述两种方式都不是原子性的,但是可以使用set nx ex
来设置,这样可以一次性实现设置锁并设置过期时间
setex案例
18.16.200.68 dev:3>setex address 30 beijing
"OK"
18.16.200.68 dev:3>get address
"beijing"
18.16.200.68 dev:3>ttl address
"23"
18.16.200.68 dev:3>pttl address
"15595"
set ex 案例
18.16.200.68 dev:3>set aa bb ex 30
"OK"
18.16.200.68 dev:3>get aa
"bb"
18.16.200.68 dev:3>ttl aa
"24"
18.16.200.68 dev:3>pttl aa
"21944"
setnx案例
18.16.200.68 dev:3>get aa
null
18.16.200.68 dev:3>setnx aa bb
"1"
18.16.200.68 dev:3>get aa
"bb"
18.16.200.68 dev:3>setnx aa bbb
"0"
18.16.200.68 dev:3>get aa
"bb"
set nx案例
18.16.200.68 dev:3>get aaa
null
18.16.200.68 dev:3>set aaa bbb nx
"OK"
18.16.200.68 dev:3>get aaa
"bbb"
18.16.200.68 dev:3>set aaa ddd nx
null
18.16.200.68 dev:3>get aaa
"bbb"
setnx
和set nx
这两个可以用于判断锁,如果 key 不存在,将 key 设置为 value
如果 key 已存在,则 SETNX
不做任何动作
set nx ex一次性设置锁和过期时间
18.16.200.68 dev:3>get lock
null
18.16.200.68 dev:3>set lock true nx ex 30
"OK"
18.16.200.68 dev:3>ttl lock
"25"
18.16.200.68 dev:3>get lock
"true"
18.16.200.68 dev:3>set lock false nx
null
18.16.200.68 dev:3>get lock
"true"
18.16.200.68 dev:3>ttl lock
"5"
incr命令
18.16.200.68 dev:3>get n1
null
18.16.200.68 dev:3>incr n1
"1"
18.16.200.68 dev:3>get n1
"1"
18.16.200.68 dev:3>incr n1
"2"
18.16.200.68 dev:3>get n1
"2"
key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作进行加一。
该命令可用于锁,其它用户在执行 INCR 操作进行加一时,如果返回的数大于 1 ,说明这个锁正在被使用当中。
watch,multi命令
watch命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。
Multi 命令用于标记一个事务块的开始,事务块内的多条命令会按照先后顺序被放进一个队列当中,最后由 EXEC 命令原子性(atomic)地执行。
18.16.200.68 dev:3>set key 1
"OK"
18.16.200.68 dev:3>get key
"1"
18.16.200.68 dev:3>watch key
"OK"
18.16.200.68 dev:3>set key 2
"OK"
18.16.200.68 dev:3>multi
"OK"
18.16.200.68 dev:3>set key 3
"QUEUED"
18.16.200.68 dev:3>get key
"QUEUED"
18.16.200.68 dev:3>exec
18.16.200.68 dev:3>get key
"2"
必须是事务执行之前,被监控key被修改,后续事务就无效
sadd命令
18.16.200.68 dev:3>sadd name hongda
"1"
18.16.200.68 dev:3>get name
"WRONGTYPE Operation against a key holding the wrong kind of value"
18.16.200.68 dev:3>sadd name da
"1"
18.16.200.68 dev:3>sadd name da2
"1"
18.16.200.68 dev:3>smembers name
1) "da2"
2) "da"
3) "hongda"
Redis Sadd 命令将一个或多个成员元素加入到集合中,已经存在于集合的成员元素将被忽略。
假如集合 key 不存在,则创建一个只包含添加的元素作成员的集合。
当集合 key 不是集合类型时,返回一个错误。
Redis过期字典
db.expires
熟悉 redis 的朋友都知道,每个数据库维护了两个字典:
db.dict
:数据库中所有键值对,也被称作数据库的 keyspacedb.expires
:带有生命周期的 key 及其对应的 TTL(存留时间),因此也被称作 expire set
maxmemory-samples
为了保证性能,redis 中使用的 LRU 与 LFU 算法是一类近似实现。
简单来说就是:算法选择被淘汰记录时,不会遍历所有记录,而是以 随机采样 的方式选取部分记录进行淘汰。
maxmemory-samples
选项控制该过程的采样数量,增大该值会增加 CPU 开销,但算法效果能更逼近实际的 LRU 与 LFU 。
lazyfree-lazy-eviction
清理缓存就是为了释放内存,但这一过程会阻塞主线程,影响其他命令的执行。
当删除某个巨型记录(比如:包含数百条记录的 list)时,会引起性能问题,甚至导致系统假死。
延迟释放 机制会将巨型记录的内存释放,交由其他线程异步处理,从而提高系统的性能。
开启该选项后,可能出现使用内存超过 maxmemory
上限的情况。
Redis中ttl命令
18.16.200.68 dev:3>set name hongda
"OK"
18.16.200.68 dev:3>get name
"hongda"
18.16.200.68 dev:3>ttl name
"-1"
18.16.200.68 dev:3>expire name 10
"1"
18.16.200.68 dev:3>ttl name
"8"
18.16.200.68 dev:3>get name
"hongda"
18.16.200.68 dev:3>ttl name
"1"
18.16.200.68 dev:3>ttl name
"-2"
18.16.200.68 dev:3>get name
null
当 key 不存在时,返回 -2 。 当 key 存在但没有设置剩余生存时间时,返回 -1 。 否则,以秒为单位,返回 key 的剩余生存时间。
Redis如果没有设置expire,是否默认永不过期
Redis
无论有没有设置expire
,他都会遵循redis的配置好的删除机制,在配置文件里设置:
redis
最大内存不足"时,数据清除策略,默认为"volatile-lru
"。
如果设置的清除策略是volatile-lru
,即从设置了过期时间的key中使用LRU算法进行淘汰,
在这种清除策略下,如果没有设置有效期,即使内存用完,redis
自动回收机制也是看设置了有效期的,不会动没有设定有效期的,如果清理后内存还是满的,就不再接受写操作。
Redis的持久化
RDB快照
RDB(快照)持久化:保存某个时间点的全量数据快照,生成RDB文件在磁盘中。RDB文件是一个压缩过的二进制文件,可以还原为Redis的数据。
触发和载入方式
-
手动触发方式
- SAVE命令:阻塞Redis的服务器进程,直到RDB文件被创建完毕,阻塞期间服务器不能处理任何命令请求。
- BGSAVE命令:Fork出一个子进程来创建RDB文件,不阻塞服务器进程。lastsave 指令可以查看最近的备份时间。
-
载入方式
- Redis没有主动载入RDB文件的命令,RDB文件是在服务器启动时自动载入,只要Redis服务器检测到RDB文件的存在,即会载入。且载入过程,服务器也会是阻塞状态。
-
自动触发方式
-
根据redis.conf配置里的save m n定时触发(用的是BGSAVE),m表示多少时间内,n表示修改次数。save可以设置多个条件,任意条件达到即会执行BGSAVE命令。
save 900 1 //设置条件1,即服务器在900秒内,对数据库进行了至少1次修改,即会触发BGSAVE save 300 10 //设置条件2,即服务器在300秒内,对数据库进行了至少10次修改,即会触发BGSAVE save 60 1000 //设置条件3,即服务器在60秒内,对数据库进行了至少1000次修改,即会触发BGSAVE
-
redis如何保存自动触发方式的save配置呢?
- redisServer结构中维护了一个saveParam的数组,数组每个saveParam都存储着一个save条件,如下图:
- 前文所述三个save,其saveParam的数组将会是下图的样子
- redisServer结构中维护了一个saveParam的数组,数组每个saveParam都存储着一个save条件,如下图:
-
自动触发方式如何实现的呢?
- redisServer结构维护了一个dirty计数器和lastsave属性。
- dirty计数器记录了上次SAVE或者BGSAVE之后,数据库执行了多少次的增删改,当服务器成功执行一个修改命令后,程序就会对该值+1,(对集合操作n个元素,dirty+n)。SAVE或者BGSAVE命令执行后,dirty计数器清零。
- lastsave属性是一个unix时间戳,记录了服务器上次成功执行SAVE或者BGSAVE命令的时间。
- Redis服务器有个周期性操作函数serverCron,默认每100毫秒执行一次,它其中一项工作就是检查saveParam保存的条件,并根据dirty和lastsave字段判断是否有哪一条条件已经被满足。
-
快照期间,是否可以对数据进行改动
为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。
简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。
此时,如果主线程对这些数据也都是读操作(键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(键值对 C),那么,这块数据就会被复制一份,生成该数据的副本(键值对 C’)。然后,主线程在这个数据副本上进行修改。同时,bgsave 子进程可以继续把原来的数据(键值对 C)写入 RDB 文件。
AOF日志
AOF持久化的实现
AOF持久化功能的实现可以分为命令追加(append)、文件写入、文件同步(sync)三个步骤。
- 命令追加
- 当AOF持久化功能处于打开状态时,服务器在执行完一个写命令后,会以协议格式将被执行的写命令追加到服务器状态的 aof_buf缓存区的末尾。
- AOF文件的写入和同步
- Redis的服务器进程就是一个事件循环。
- 每次结束一个事件循环之前,都会调用flushAppendOnlyFile函数,考虑是否将缓冲区的内容写入和保存到AOF文件里面。
- flushAppendOnlyFile函数根据配置项appendsync的不同选值有不同的同步策略。
AOF文件的载入
Redis读取AOF文件并还原数据库状态的详细步骤如下:
- 服务器创建一个不带网络连接的伪客户端(fake client)(因为Redis的命令只能在客户端上下文中执行);
- 从AOF文件中分析并读取出一条写命令。
- 一直执行步骤2和步骤3,直到AOF文件中的所有写命令都被处理完毕为止。
AOF重写
体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF文件的体积越大,使用AOF文件来进行数据还原所需的时间就越多。
为了解决AOF文件体积膨胀的问题,Redis
提供了AOF
文件重写(rewrite)功能。
通过该功能,Redis
服务器可以创建一个新的AOF
文件来替代现有的AOF
文件,新旧两个AOF
文件所保存的数据库状态相同,但新AOF
文件不会包含任何浪费空间的冗余命令,所以新AOF
文件的体积通常会比旧AOF
文件的体积要小得多。
我们称新的AOF
文件为AOF重写文件,AOF
重写文件不是像AOF
一样记录每一条的写命令,也不是对AOF
文件的简单复制和压缩。AOF重写是通过读取当前Redis数据库状态来实现的。
在AOF
中,我们要保存四条写命令,而在AOF
重写文件中,我们使用一条SADD animals "Dog" "Panda" "Tiger" "Lion" "Cat"
来替代四条命令。
从数据库中读取键现在的值,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这就是AOF重写功能的实现原理。(比如连续6条RPUSH命令会被整合成1条)
在实际中,为了避免在执行命令时造成客户端输入缓冲区溢出,重写程序在处理列表、哈希表、集合、有序集合这四种可能会带有多个元素的键时,会先检查键所包含的元素数量,如果元素的数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量(默认为64)的值,那么重写程序将使用多条命令来记录键的值,而不单单使用一条命令。例如如果SADD后面加入的元素为90条,那么会分成两条SADD,第一条SADD 64个元素,第二条SADD 36个元素。
总结
RDB 快照
优点:文件结构紧凑,节省空间,易于传输,能够快速恢复
缺点:生成快照的开销只与数据库大小相关,当数据库较大时,生成快照耗时,无法频繁进行该操作
AOF 日志
优点:细粒度记录对磁盘I/O
压力小,允许频繁落盘,数据丢失的概率极低
缺点:恢复速度慢;记录日志开销与更新频率有关,频繁更新会导致磁盘 I/O
压力上升
RDB 和 AOF 到底该如何选择
- 不要仅仅使用
RDB
,因为那样会导致你丢失很多数据; - 也不要仅仅使用
AOF
,因为那样有两个问题:第一,你通过AOF
做冷备,没有 RDB 做冷备来的恢复速度更快;第二,RDB
每次简单粗暴生成数据快照,更加健壮,可以避免AOF
这种复杂的备份和恢复机制的bug
; redis
支持同时开启开启两种持久化方式,我们可以综合使用AOF
和RDB
两种持久化机制,用AOF
来保证数据不丢失,作为数据恢复的第一选择; 用RDB
来做不同程度的冷备,在AOF
文件都丢失或损坏不可用的时候,还可以使用RDB
来进行快速的数据恢复。
RDB-AOF 混合持久化
Redis
用户通常会因为 RDB
持久化和 AOF
持久化之间不同的优缺点而陷入两难的选择当中:
RDB
持久化能够快速地储存和恢复数据, 但是在服务器停机时却会丢失大量数据;AOF
持久化能够有效地提高数据的安全性, 但是在储存和恢复数据方面却要耗费大量的时间。
为了让用户能够同时拥有上述两种持久化的优点, Redis 4.0
推出了一个能够“鱼和熊掌兼得”的持久化方案 —— RDB-AOF 混合持久化: 这种持久化能够通过 AOF
重写操作创建出一个同时包含 RDB
数据和 AOF
数据的 AOF
文件, 其中 RDB
数据位于 AOF
文件的开头, 它们储存了服务器开始执行重写操作时的数据库状态: 至于那些在重写操作执行之后执行的 Redis
命令, 则会继续以 AOF
格式追加到 AOF
文件的末尾, 也即是 RDB
数据之后。
RDB-AOF 混合持久化功能默认是处于关闭状态的, 为了启用该功能, 用户不仅需要开启 AOF 持久化功能, 还需要将 aof-use-rdb-preamble
选项的值设置为真
Redis的淘汰策略
淘汰策略
当达到内存使用上限maxmemory
时,可指定的清理缓存所使用的策略有:
noeviction
当达到最大内存时直接返回错误,不覆盖或逐出任何数据(默认的)allkeys-lfu
淘汰整个 keyspace 中最不常用的 (LFU) 键 (4.0 或更高版本)allkeys-lru
淘汰整个 keyspace 最近最少使用的 (LRU) 键allkeys-random
淘汰整个 keyspace 中的随机键volatile-ttl
淘汰 expire set 中 TTL 最短的键volatile-lfu
淘汰 expire set 中最不常用的键 (4.0 或更高版本)volatile-lru
淘汰 expire set 中最近最少使用的 (LRU) 键volatile-random
淘汰 expire set 中的随机键
当 expire set
为空时,volatile-*
与 noeviction
行为一致。
查看Redis设置的内存大小
通过配置文件查看
通过在Redis
安装目录下面的redis.conf
配置文件中添加以下配置设置内存大小
通过命令查看并设置内存大小
λ redis-cli -h 18.16.200.82 -p 6379 -a shitou123 --raw
18.16.200.82:6379> config get maxmemory
maxmemory
0
18.16.200.82:6379> config set maxmemory 800mb
OK
18.16.200.82:6379> config get maxmemory
maxmemory
838860800
如果不设置最大内存大小或者设置最大内存大小为0,在64
位操作系统下不限制内存大小,在32
位操作系统下最多使用3GB内存
查看Redis设置的淘汰策略
设置及查看redis淘汰策略
18.16.200.82:6379> config get maxmemory-policy
maxmemory-policy
noeviction
18.16.200.82:6379> config set maxmemory-policy volatile-lru
OK
18.16.200.82:6379> config get maxmemory-policy
maxmemory-policy
volatile-lru
LRU算法 (最近最少使用)
LRU(Least Recently Used)
,即最近最少使用,是一种缓存置换算法。 其核心思想是:可以记录每个缓存记录的最近访问时间,最近未被访问时间最长的数据会被首先淘汰。
其原理是维护一个双向链表,key -> node,其中node
保存链表前后节点关系及数据data
。新插入的key时,放在头部,并检查是否超出总容量,如果超出则删除最后的key
;访问key
时,无论是查找还是更新,将该Key被调整到头部。
Redis中实际使用的LRU算法
Redis
中的 LRU
不是严格意义上的LRU算法实现,是一种近似的 LRU
实现,主要是为了节约内存占用以及提升性能。Redis 有这样一个配置 —— maxmemory-samples
,Redis
的 LRU
是取出配置的数目的key
,然后从中选择一个最近最不经常使用的 key
进行置换,默认的 5,如下:
maxmemory-samples 5
可以通过调整样本数量来取得 LRU
置换算法的速度或是精确性方面的优势。
Redis
不采用真正的 LRU
实现的原因是为了节约内存使用。
Redis 如何管理热度数据
前面我们讲述字符串对象时,提到了 redisObject
对象中存在一个 lru
属性:
typedef struct redisObject {
unsigned type:4;//对象类型(4位=0.5字节)
unsigned encoding:4;//编码(4位=0.5字节)
unsigned lru:LRU_BITS;//记录对象最后一次被应用程序访问的时间(24位=3字节)
int refcount;//引用计数。等于0时表示可以被垃圾回收(32位=4字节)
void *ptr;//指向底层实际的数据存储结构,如:SDS等(8字节)
} robj;
lru
属性是创建对象的时候写入,对象被访问到时也会进行更新。正常人的思路就是最后决定要不要删除某一个键肯定是用当前时间戳减去 lru
,差值最大的就优先被删除。但是 Redis
里面并不是这么做的,Redis
中维护了一个全局属性 lru_clock
,这个属性是通过一个全局函数 serverCron
每隔 100
毫秒执行一次来更新的,记录的是当前 unix
时间戳。
最后决定删除的数据是通过 lru_clock
减去对象的 lru
属性而得出的。那么为什么Redis
要这么做呢?直接取全局时间不是更准确吗?
这是因为这么做可以避免每次更新对象的 lru
属性的时候可以直接取全局属性,而不需要去调用系统函数来获取系统时间,从而提升效率(Redis
当中有很多这种细节考虑来提升性能,可以说是对性能尽可能的优化到极致)。
不过这里还有一个问题,我们看到,redisObject
对象中的 lru
属性只有 24
位,24
位只能存储 194
天的时间戳大小,一旦超过 194
天之后就会重新从 0
开始计算,所以这时候就可能会出现 redisObject
对象中的 lru
属性大于全局的 lru_clock
属性的情况。
正因为如此,所以计算的时候也需要分为 2
种情况:
- 当全局
lruclock
>lru
,则使用lruclock
-lru
得到空闲时间。 - 当全局
lruclock
<lru
,则使用lruclock_max
(即194
天) -lru
+lruclock
得到空闲时间。
需要注意的是,这种计算方式并不能保证抽样的数据中一定能删除空闲时间最长的。这是因为首先超过 194
天还不被使用的情况很少,再次只有 lruclock
第 2
轮继续超过lru
属性时,计算才会出问题。
比如对象 A
记录的 lru
是 1
天,而 lruclock
第二轮都到 10
天了,这时候就会导致计算结果只有 10-1=9
天,实际上应该是 194+10-1=203
天。但是这种情况可以说又是更少发生,所以说这种处理方式是可能存在删除不准确的情况,但是本身这种算法就是一种近似的算法,所以并不会有太大影响。
LFU算法 (最不经常使用)
LFU
算法是Redis4.0
里面新加的一种淘汰策略。它的全称是Least Frequently Used
,它的核心思想是根据key的最近被访问的频率进行淘汰,很少被访问的优先被淘汰,被访问的多的则被留下来。
LFU
算法能更好的表示一个key
被访问的热度。假如你使用的是LRU
算法,一个key
很久没有被访问到,只刚刚是偶尔被访问了一次,那么它就被认为是热点数据,不会被淘汰,而有些key将来是很有可能被访问到的则被淘汰了。如果使用LFU算法则不会出现这种情况,因为使用一次并不会使一个key成为热点数据。LFU原理使用计数器来对key进行排序,每次key被访问的时候,计数器增大。计数器越大,可以约等于访问越频繁。具有相同引用计数的数据块则按照时间排序。
LFU
全称为:Least Frequently Used
。即:最不经常使用,这个主要针对的是使用频率。这个属性也是记录在redisObject
中的 lru
属性内。
当我们采用 LFU
回收策略时,lru
属性的高 16
位用来记录访问时间(last decrement time:ldt,单位为分钟),低 8
位用来记录访问频率(logistic counter:logc),简称 counter
。
访问频次递增
LFU
计数器每个键只有 8
位,它能表示的最大值是 255
,所以 Redis
使用的是一种基于概率的对数器来实现 counter
的递增。r
给定一个旧的访问频次,当一个键被访问时,counter
按以下方式递增:
- 提取
0
和1
之间的随机数R
。 counter
- 初始值(默认为5
),得到一个基础差值,如果这个差值小于0
,则直接取0
,为了方便计算,把这个差值记为baseval
。- 概率
P
计算公式为:1/(baseval * lfu_log_factor + 1)
。 - 如果
R < P
时,频次进行递增(counter++
)。
公式中的 lfu_log_factor
称之为对数因子,默认是 10
,可以通过参数来进行控制:
lfu_log_factor 10
下图就是对数因子 lfu_log_factor
和频次 counter
增长的关系图
可以看到,当对数因子 lfu_log_factor
为 100
时,大概是 10M(1000万)
次访问才会将访问 counter
增长到 255
,而默认的 10
也能支持到 1M(100万)
次访问 counter
才能达到 255
上限,这在大部分场景都是足够满足需求的。
访问频次递减
如果访问频次 counter
只是一直在递增,那么迟早会全部都到 255
,也就是说 counter
一直递增不能完全反应一个 key
的热度的,所以当某一个 key
一段时间不被访问之后,counter
也需要对应减少。
counter
的减少速度由参数 lfu-decay-time
进行控制,默认是 1
,单位是分钟。默认值 1
表示:N
分钟内没有访问,counter
就要减 N
。
lfu-decay-time 1
具体算法如下:
- 获取当前时间戳,转化为分钟 后取低
16
位(为了方便后续计算,这个值记为now
)。 - 取出对象内的
lru
属性中的高16
位(为了方便后续计算,这个值记为ldt
)。 - 当
lru
>now
时,默认为过了一个周期(16
位,最大65535
),则取差值65535-ldt+now
:当lru
<=now
时,取差值now-ldt
(为了方便后续计算,这个差值记为idle_time
)。 - 取出配置文件中的
lfu_decay_time
值,然后计算:idle_time / lfu_decay_time
(为了方便后续计算,这个值记为num_periods
)。 - 最后将
counter
减少:counter - num_periods
。
看起来这么复杂,其实计算公式就是一句话:取出当前的时间戳和对象中的 lru
属性进行对比,计算出当前多久没有被访问到,比如计算得到的结果是 100
分钟没有被访问,然后再去除配置参数 lfu_decay_time
,如果这个配置默认为 1
也即是 100/1=100
,代表100
分钟没访问,所以 counter
就减少 100
。
Redis分布式锁问题
Redis锁必须设置过期时间
如果不设置过期时间,客户端故障,锁就永远一直存在,资源永远不能被再次获取
Redis锁中value设置随机值
场景:客户A获取锁,设置过期时间5s,但是因为某些原因超时,超时期间客户B也获取了同样key的锁,
客户A执行完,删除键值为key的锁,但是其实该锁为客户B的
解决方法:保证每个客户端可以区分自己的锁,比如即使key值相等,也可以通过设置value来区分,删除锁之前,可以先比对key,value,再进行删除。
Redis锁中的删除操作使用lua脚本
上述的删除操作,必须先获取锁,比对key,value,再进行删除,那么就必须调用到Redis的get,del命令,这样明显就不是原子性操作,不安全。
建议使用Redisson,源码中加锁/释放锁操作都是用lua脚本完成的,封装的非常完善,开箱即用。
failover(故障转移)策略机制不可靠
主从同步通常是异步的,并不能真正的容错。
造成锁不独享的场景如下图所示:
- 客户端A申请从master实例获取锁key=test001,由于之前key=test001在master实例上不存在,所以客户端A获取锁成功。
- master在通过异步主从同步将key=test001同步至slave之前挂掉了,此时slave经过failover升级为master,但是此时slave上并无key=test001。
- 此时,客户端B申请从redis获取锁key=test001,由于此时slave上不存在key=test001,同样的,客户端B获取锁成功。
- 最终的结果是,由于关键时刻的master宕机,造成两个客户端同时加锁成功,这与分布式锁的
独享
特性相互违背。
为什么Redis单线程效率高
Redis
官方提供的数据是可以达到10w+
的QPS
(每秒内查询次数)
Redis中只有网络请求模块和数据操作模块是单线程的。而其他的如持久化存储模块、集群支撑模块等是多线程的。
- 基于内存操作
- 单线程,避免了线程上下文频繁切换,也避免了各种加锁,释放锁问题
- 采用网络IO多路复用技术来提升
Redis
的网络IO
利用率
采用非阻塞IO
,使用epoll
作为IO
多路复用技术的实现,让单个线程高效的处理多个连接请求(尽量减少网络IO
的时间消耗),且Redis
在内存中操作数据的速度非常快(内存内的操作不会成为这里的性能瓶颈),主要以上两点造就了Redis
具有很高的吞吐量。
如果宿主机的cpu
性能太高或太低,可以多起几个Redis
进程,因为多起几个进程可以利用cpu
多核优势。
缺点:因为是单线程的,所以如果某个命令执行事件过长,会导致其他命令被阻塞。
Redis 的瓶颈并不在 CPU,而在内存和网络。
内存不够的话,可以加内存或者做数据结构优化和其他优化等,但网络的性能优化才是大头,网络 IO 的读写在 Redis 整个执行期间占用了大部分的 CPU 时间,如果把网络处理这部分做成多线程处理方式,那对整个 Redis 的性能会有很大的提升。
Redis不是一直号称单线程效率也很高吗,为什么又采用多线程了?
Jedis,Redisson,Lettuce三个Redis客户端框架区别
Redis底层数据结构
- Redis 字符串,是自己构建了一种名为 简单动态字符串(simple dynamic string , SDS)的抽象类型,并将 SDS 作为 Redis 的默认字符串表示。
- Redis List ,底层是 ZipList ,不满足 ZipList 就使用双向链表。ZipList 是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。
最详细的Redis五种数据结构详解(理论+实战),建议收藏。
hash与String对比
hash类型数据比较少时,使用的时ziplist,比较省空间(相对于hash中设置key,value方式),但是相比String序列化对象不一定省空间,数据量大了就变成dict方式
hash-max-ziplist-entries 512
hash-max-ziplist-value 64
在如下两个条件之一满足的时候,ziplist会转成dict:
- 当hash中的数据项(即field-value对)的数目超过512的时候,也就是ziplist数据项超过1024的时候(请参考t_hash.c中的
hashTypeSet
函数)。 - 当hash中插入的任意一个value的长度超过了64的时候(请参考t_hash.c中的
hashTypeTryConversion
函数)。
Redis的hash之所以这样设计,是因为当ziplist变得很大的时候,它有如下几个缺点:
- 每次插入或修改引发的realloc操作会有更大的概率造成内存拷贝,从而降低性能。
- 一旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更大的一块数据。
- 当ziplist数据项过多的时候,在它上面查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。
总之,ziplist本来就设计为各个数据项挨在一起组成连续的内存空间,这种结构并不擅长做修改操作。一旦数据发生改动,就会引发内存realloc,可能导致内存拷贝。
hash比较好的就是可以hget直接获取value值(hset直接设置value值)
Redis集群作用
- 自动将数据进行分片,每个
master
上放置一部分数据 - 提供内置的高可用支持,部分
master
不可用时,还是可以继续使用的。
在 redis cluster
架构下,每个 redis
要放开两个端口号,比如一个是 6379
,另外一个就是 加1w 的端口号,比如 16379
。
16379
端口号是用来进行节点间通信的,也就是 cluster bus
的东西,cluster bus
的通信,用来进行故障检测、配置更新、故障转移授权。cluster bus
用了另外一种二进制的协议,gossip
协议,用于节点间进行高效的数据交换,占用更少的网络带宽和处理时间。
Redis 是怎么进行水平扩容的? Redis 集群数据分片的原理是什么?
Redis 数据分片原理是哈希槽(hash slot)。
Redis 集群有 16384 个哈希槽。每一个 Redis 集群中的节点都承担一个哈希槽的子集。
哈希槽让在集群中添加和移除节点非常容易。例如,如果我想添加一个新节点 D ,我需要从节点 A 、B、C 移动一些哈希槽到节点 D。同样地,如果我想从集群中移除节点 A ,我只需要移动 A 的哈希槽到 B 和 C。当节点 A 变成空的以后,我就可以从集群中彻底删除它。因为从一个节点向另一个节点移动哈希槽并不需要停止操作,所以添加和移除节点,或者改变节点持有的哈希槽百分比,都不需要任何停机时间(downtime)。
一致性hash算法
一致性 Hash 算法将整个哈希值空间组织成一个虚拟的圆环, 我们对 key 进行哈希计算,使用哈希后的结果对 2 ^ 32 取模,hash 环上必定有一个点与这个整数对应。依此确定此数据在环上的位置,从此位置沿环顺时针“行走”,第一台遇到的服务器就是其应该定位到的服务器。 一致性 Hash 算法对于节点的增减都只需重定位环空间中的一小部分数据,具有较好的容错性和可扩展性。 比如,集群有四个节点 Node A 、B 、C 、D ,增加一台节点 Node X。Node X 的位置在 Node B 到 Node C 直接,那么受到影响的仅仅是 Node B 到 Node X 间的数据,它们要重新落到 Node X 上。 所以一致性哈希算法对于容错性和扩展性有非常好的支持。
Redis变慢原因分析
你需要去查看一下 Redis 的慢日志(slowlog)。
Redis 提供了慢日志命令的统计功能,它记录了有哪些命令在执行时耗时比较久。
查看 Redis 慢日志之前,你需要设置慢日志的阈值。例如,设置慢日志的阈值为 5 毫秒,并且保留最近 500 条慢日志记录:
# 命令执行耗时超过 5 毫秒,记录慢日志
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近 500 条慢日志
CONFIG SET slowlog-max-len 500
查看慢查询日志:
127.0.0.1:6379> SLOWLOG get 5
1) 1) (integer) 32693 # 慢日志ID
2) (integer) 1593763337 # 执行时间戳
3) (integer) 5299 # 执行耗时(微秒)
4) 1) "LRANGE" # 具体执行的命令和参数
2) "user_list:2000"
3) "0"
4) "-1"
2) 1) (integer) 32692
2) (integer) 1593763337
3) (integer) 5044
4) 1) "GET"
2) "user_info:1000"
...
变慢的原因:
redis
操作内存数据,时间复杂度较高,需要花费更多的cpu
资源redis
一次需要返回的数据过多,更多时间花费在数据协议组装和网络传输。bigkey
,一个key
写入的value
太大,分配内存,释放内存也比较耗时- 有规律的变慢,大概是
redis
设置为主动过期,大量key
集中到期,主线程删除过期key
- 内存到达上限,先要根据淘汰策略剔除一部分数据,再把新数据写入
rdb
,aop rewrite
期间延迟,主线程需要创建一个子线程进行数据持久化,创建子线程会调用操作系统的fork
函数,fork
会消耗大量cpu
资源,在fork
之前,整个redis
会被阻塞,无法处理客户端请求。- 操作系统是否开启内存大页机制,
redis
申请内存变大,申请内存耗时变长,导致每个写请求延迟增加。 AOF
刷盘机制设置为always
,即每次执行写操作立刻刷盘- 设置了绑定
cpu
- 查看
redis
是否使用了swap
,swap
是使用磁盘,性能差。 redis
设置为开启内存碎片整理,也会导致redis
性能下降。- 网络
IO
过载
Redis 为什么变慢了?一文讲透如何排查 Redis 性能问题 | 万字长文
Redis选举
当slave
(从节点)发现自己的master
(主节点)不可用时,变尝试进行Failover
(故障转移),以便称为新的master
。由于挂掉的master
可能会有多个slave
,从而存在多个slave
竞争成为master
节点的过程, 其过程如下:
slave
发现自己的master
不可用;slave
将记录集群的currentEpoch
(选举周期)加1,并广播FAILOVER_AUTH_REQUEST
信息进行选举;- 其他节点收到
FAILOVER_AUTH_REQUEST
信息后,只有其他的master
可以进行响应,master
收到消息后返回FAILOVER_AUTH_ACK
信息,对于同一个Epoch
,只能响应一次ack; slave
收集maste返回的ack消息slave
判断收到的ack消息个数是否大于半数的master
个数,若是,则变成新的master
;- 广播
Pong
消息通知其他集群节点,自己已经成为新的master
。
注意:从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保我们等待FAIL状态在集群中传播,slave
如果立即尝试选举,其它masters
或许尚未意识到FAIL状态,可能会拒绝投票。
- 延迟计算公式:
DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms
SLAVE_RANK
表示此slave
已经从master
复制数据的总量的rank
。Rank
越小代表已复制的数据越新。这种方式下,持有最新数据的slave
将会首先发起选举(理论上)。
Redis集群为什么至少需要三个master节点?
因为新master
的选举需要大于半数的集群master
节点同意才能选举成功,如果只有两个master
节点,当其中一个挂了,是达不到选举新master
的条件的。
Redis集群为什么至少推荐节点数为奇数?
奇数个master
节点可以在满足选举该条件的基础上节省一个节点,比如三个master
节点和四个master
节点的集群相比,大家如果都挂了一个master
节点都能选举新master
节点,如果都挂了两个master
节点都没法选举新master
节点了,所以奇数的master
节点更多的是从节省机器资源角度出发说的。
网络不稳定是否会是否引起选举?
真实世界的机房网络往往并不是风平浪静的,它们经常会发生各种各样的小问题。比如网络抖动就是非常常见的一种现象,突然之间部分连接变得不可访问,然后很快又恢复正常。
为解决这种问题,Redis Cluster
提供了一种选项cluster-node-timeout
,表示当某个节点持续 timeout
的时间失联时,才可以认定该节点出现故障,需要进行主从切换。如果没有这个选项,网络抖动会导致主从频繁切换 (数据的重新复制)。
Redis主从数据同步
全量复制
- slave第一次启动时,连接Master,发送PSYNC命令,格式为
psync {runId} {offset}
{runId}
为master的运行id;{offset}
为slave自己的复制偏移量- 由于此时是slave第一次连接master,slave不知道master的runId,也不知道自己偏移量,这时候会传一个问号和-1,告诉master节点是第一次同步。格式为
psync ? -1
- 当master接收到
psync ? -1
时,就知道slave是要全量复制,就会将自己的runId
和offset
告知slave,回复命令+fullresync {runId} {offset}
。同时,master会执行bgsave
命令来生成RDB文件,并使用缓冲区记录此后的所有写命令
- slave接受到master的回复命令后,会保存master的
runId
和offset
- slave此时处于同步状态,如果此时收到请求,当配置参数
slave-server-stale-data yes
时,会响应当前请求,no
则返回错误。
-
master
bgsave
执行完毕,向Slave发送RDB文件,同时继续缓冲此期间的写命令。RDB文件发送完毕后,开始向Slave发送存储在缓冲区的写命令 -
slave收到RDB文件,丢弃所有旧数据,开始载入RDB文件;并执行Master发来的所有的存储在缓冲区里的写命令。
-
此后 master 每执行一个写命令,就向Slave发送相同的写命令。
增量复制
- 如果出现网络闪断或者命令丢失等异常情况时,当主从连接恢复后,由于从节点之前保存了自身已复制的偏移量和主节点的运行ID。因此会把它们当作psync参数发送给主节点,要求进行部分复制操作,格式为
psync {runId} {offset}
- 主节点接到psync命令后首先核对参数runId是否与自身一致,如果一致,说明之前复制的是当前主节点;之后根据参数offset在自身复制积压缓冲区查找,如果偏移量之后的数据存在缓冲区中,则对从节点发送+CONTINUE响应,表示可以进行部分复制;否则进行全量复制。
- 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态
主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。当然,如果有需要,slave 在任何时候都可以发起全量同步。redis 策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。
复制偏移量
执行主从复制的双方都会分别维护一个复制偏移量,master
每次向 slave
传播 N
个字节,自己的复制偏移量就增加 N
;同理 slave
接收 N
个字节,自身的复制偏移量也增加 N
。通过对比主从之间的复制偏移量就可以知道主从间的同步状态。
复制积压缓冲区
复制积压缓冲区是 master
维护的一个固定长度的 FIFO
队列,默认大小为 1MB
。当 master
进行命令传播时,不仅将写命令发给 slave
还会同时写进复制积压缓冲区,因此 master
的复制积压缓冲区会保存一部分最近传播的写命令。当 slave
重连上 master
时会将自己的复制偏移量通过 PSYNC 命令发给 master
,master
检查自己的复制积压缓冲区,如果发现这部分未同步的命令还在自己的复制积压缓冲区中的话就可以利用这些保存的命令进行部分同步,反之如果断线太久这部分命令已经不在复制缓冲区,就只能进行全量同步。
运行 ID
run_id
是做什么用?这是因为 master
可能会在 slave
断线期间发生变更,例如可能超时失去联系或者宕机导致断线重连的是一个崭新的 master
,不再是断线前复制的那个。自然崭新的 master
没有之前维护的复制积压缓冲区,只能进行全量同步。
因此每个 Redis server
都会有自己的运行 ID
,由 40
个随机的十六进制字符组成。当 slave
初次复制 master
时,master 会将自己的运行 ID 发给 slave
进行保存,这样 slave
重连时再将这个运行 ID
发送给重连上的 master
,master
会接受这个 ID
并于自身的运行 ID
比较进而判断是否是同一个 master
。
同源增量复制
slave
重启后丢失了原master
的编号和复制偏移量,这导致重启后需要全量同步,这很好办,把这些信息存下来就可以了。
主从切换后,主节点信息变化了,导致从节点需要全量同步,这也容易解决,只需能确认新主节点上的数据是从原主节点复制来的,那就可以继续从新的主节点上进行复制。
Redis 4.0
以后,对 PSYNC
进行了改进,提出了同源增量复制的解决方案,该方案解决了前面提到的两个问题。
slave
重启后,需要跟master
全量同步,这本质上是因为slave
丢失了master
的编号信息(runId
),在 Redis 4.0
后,master
的编号信息被写入到 RDB
中持久化保存。切主后,slave
需要和new master
全量同步,本质原因是new master
不认识old master
的编号。slave
发送 PSYNC
<原主节点编号> <复制偏移量> 给new master
,如果new master
能够认识 <原主节点编号>,并明白自己的数据就是从该节点复制来的。那么new master
就应该清楚,它和该从节点师出同门,应该接受部分同步。
如何才能识别?,只需要让从节点在切换为主节点时,将自己之前的主节点的编号记录下来即可。Redis 4.0
以后,主从切换后,新的主节点会将先前的主节点记录下来,观察 info replication 的结果,可以可以看到 master_replid
和 master_replid2
两个编号,前者是当前主节点的编号,后者为先前主节点的编号
Redis 中目前值保留了两个主节点编号,但完全可以实现一个链表,将过往的主节点的编号信息都记录下来,这样就可以追溯的更远了。这样以来,如果一个从节点断开后,执行了多次主从切换,该从节重新连接后,依然可以识别出它们的数据是同源的。但 Redis 没有这么做,这是因为没有必要,因为就算数据是同源的,但复制积压缓冲区中保存的数据是有限的,多次主从切换后,复制积压缓冲区中保存的命令已经无法满足部分同步了。有了同源增量复制后,主节点切换后,其他从节点可以基于新的主节点继续增量同步。
无盘全量同步和无盘加载
Redis 执行全量复制,需要生成当前数据库的一份快照,具体做法是执行 fork 创建子进程,子进程遍历所有数据并编码后写入 RDB 文件中。RDB 生成后,在主进程中,会读取此文件并发送给从节点。读写磁盘上的 RDB 文件是比较耗资源的,在主进程中执行势必会导致 Redis 的响应时间变长。因此一个优化方案是 dump 后直接将数据直接发送数据给从节点,不需要将数据先写入到 RDB 。
Redis 6.0
中实现了这种无盘全量同步和无盘加载的策略。采用无盘全量同步,避免了对磁盘的操作,但也有缺点。一般情况下,在子进程中直接使用网络发送数据,这比在子进程中生成 RDB 要慢,这意味着子进程需要存活的时间相对较长。子进程存在的时间越长,写时复制造成的影响就越大,进而导致消耗的内存会更多。在全量复制时候,从节点一般是先接收 RDB 将其存在本地,接收完成后再载入 RDB。同样地,从节点也可以直接载入主节点发来的数据,避免将其存入本地的 RDB 文件中,而后再从磁盘加载。
共享主从复制缓冲区
在主节点的视角中,从节点就是一个客户端,从节点发送了 PSYNC
命令后,主节点就要与它们完成全量同步,并不断地把写命令同步给从节点。Redis
的每个客户端连接上存在一个发送缓冲区。主节点执行了写命令后,就会将命令内容写入到各个连接的发送缓冲区中。发送缓冲区存储的是待传播的命令,这意味着多个发送缓冲区中的内容其实是相同的。而且,这些命令还在复制积压缓冲区中存了一份呢。这就造成了大量的内存浪费,尤其是存在很多从节点的时候。
在 Redis 7.0 中,提出并实现了共享主从复制缓冲区的方案解决了这个问题。该方案让发送缓冲区与复制积压缓冲区共享,避免了数据的重复,可有效节省内存。
Redis各个版本主从复制演化总结
- 宏观来看
Redis
的主从复制分为全量同步和命令传播两个阶段。主节点先发送快照给从节点,然后源源不断地将命令传播给从节点,以此保证主从数据的一致。 Redis 2.8
之前的主从复制存在闪断后需要重新全量同步的问题,Redis 2.8
引入了复制积压缓冲区解决了这一问题。- 在
Redis 4.0
中,同源增量复制的策略被提出,解决了主从切换后从节点需要全量同步的问题。至此,Redis
的主从复制整体上已经比较完善了。 Redis 6.0
中,为进一步优化主从复制的性能,无盘同步和加载被提出,避免全量同步时读写磁盘,提高主从同步的速度。- 在
Redis 7.0 rc1
中,采用了共享主从复制缓冲区的策略,降低了主从复制带来的内存开销。
先更新数据库,再删除缓存,会有什么问题?
先更新数据库,再删除缓存。可能出现以下情况:
- 如果更新完数据库,
Java
服务提交了事务,然后挂掉了,那Redis
还是会执行,这样也会不一致。 - 如果更新数据库成功,删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
先删除缓存,再更新数据库。
- 如果删除缓存失败,那就不更新数据库,缓存和数据库的数据都是旧数据,数据是一致的。
- 如果删除缓存成功,而数据库更新失败了,那么数据库中是旧数据,缓存中是空的,数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。
Redission框架
Redis锁过期问题解决办法 (看门狗)
设置过期时间,如果到过期时间,但是任务还未执行完毕,其他任务就会获取锁,这时就会有多个任务同时获取到资源
现有的解决办法是:
redisson
在加锁成功后,会注册一个定时任务监听这个锁,每隔10秒就去查看这个锁,如果还持有锁,就对过期时间
进行续期。默认过期时间30秒。这个机制也被叫做:“看门狗
”。
看门狗功能是Redisson的,不是redis的,获取锁没指定过期时间的,看门狗就会生效
默认情况下,看门狗的时间lockWatchdogTimeout(可配)默认为30s,会有task每10s (internalLockLeaseTime / 3)循环判断,如果该线程还持有锁执行任务,就重置延时30s,直到锁丢失,获取线程不持有该锁
Redisson的“看门狗”机制,一个关于分布式锁的非比寻常的BUG
设置看门狗,没设置锁过期时间,如果客户端在执行unlock之前挂机会一直占用锁吗?
不会,不设置过期时间,就是leaseTime
为空,会默认为-1
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture ttlRemainingFuture;
if (leaseTime != -1L) {
ttlRemainingFuture = this.tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
ttlRemainingFuture = this.tryLockInnerAsync(waitTime, this.internalLockLeaseTime, TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e == null) {
if (ttlRemaining == null) {
if (leaseTime != -1L) {
this.internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
this.scheduleExpirationRenewal(threadId);
}
}
}
});
return ttlRemainingFuture;
}
可以看到,为-1
的时候,会设置锁的默认过期时间为internalLockLeaseTime
(默认为30s).
protected void scheduleExpirationRenewal(long threadId) {
RedissonBaseLock.ExpirationEntry entry = new RedissonBaseLock.ExpirationEntry();
RedissonBaseLock.ExpirationEntry oldEntry = (RedissonBaseLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.putIfAbsent(this.getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
try {
this.renewExpiration();
} finally {
if (Thread.currentThread().isInterrupted()) {
this.cancelExpirationRenewal(threadId);
}
}
}
}
用EXPIRATION_RENEWAL_MAP
这个map
来维护当前线程加锁次数,在unlock的时候,也会执行cancelExpirationRenewal(threadId)
oldEntry
表示该线程重入锁,只执行addThreadId(threadId)
public static class ExpirationEntry {
private final Map<Long, Integer> threadIds = new LinkedHashMap();
private volatile Timeout timeout;
public ExpirationEntry() {
}
public synchronized void addThreadId(long threadId) {
Integer counter = (Integer)this.threadIds.get(threadId);
if (counter == null) {
counter = 1;
} else {
counter = counter + 1;
}
this.threadIds.put(threadId, counter);
}
.....
}
加锁一次,次数加一。解锁一次,次数减一
private void renewExpiration() {
RedissonBaseLock.ExpirationEntry ee = (RedissonBaseLock.ExpirationEntry)EXPIRATION_RENEWAL_MAP.get(this.getEntryName());
if (ee != null) {
Timeout task = this.commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
public void run(Timeout timeout) throws Exception {
RedissonBaseLock.ExpirationEntry ent = (RedissonBaseLock.ExpirationEntry)RedissonBaseLock.EXPIRATION_RENEWAL_MAP.get(RedissonBaseLock.this.getEntryName());
if (ent != null) {
Long threadId = ent.getFirstThreadId();
if (threadId != null) {
RFuture<Boolean> future = RedissonBaseLock.this.renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
RedissonBaseLock.log.error("Can't update lock " + RedissonBaseLock.this.getRawName() + " expiration", e);
RedissonBaseLock.EXPIRATION_RENEWAL_MAP.remove(RedissonBaseLock.this.getEntryName());
} else {
if (res) {
RedissonBaseLock.this.renewExpiration();
} else {
RedissonBaseLock.this.cancelExpirationRenewal((Long)null);
}
}
});
}
}
}
}, this.internalLockLeaseTime / 3L, TimeUnit.MILLISECONDS);
ee.setTimeout(task);
}
}
这块逻辑主要就是一个基于时间轮的定时任务。
TimerTask
就是这个定时任务,触发的时间条件:internalLockLeaseTime / 3
。
internalLockLeaseTime
默认情况下是 30* 1000,所以这里默认就是每 10 秒执行一次续命的任务,所以看门锁的ttl 的时间先从 30 变成了 20 ,然后一下又从 20 变成了 30。
renewExpirationAsync
方法是用来设置过期时间
future.onComplete
中e==null
表示执行redis命令pexpire
命令异常,说明可能是Redis卡住了,或者掉线了,或者连接池没有连接了等等各种情况,都可能会执行不了命令,导致异常.
res
为false
,表示key不存在或无法设置超时时间,那么说明锁已经没有了,或者易主了。那么也就没有当前线程什么事情了,啥都不用做,默默的结束就行了。
res
为true
,表示设置超时时间成功,说明续命成功,则再次调用 renewExporation
方法,等待着时间轮触发下一次.
Redisson分布式锁存储值格式
hset命令赋值,key为锁的名称,field为“客户端唯一ID:线程ID”,value为1
客户端唯一id,就是uuid,value为可重入锁次数
Redis锁问题
主从同步问题
于 Redis 的复制是异步的,Master 节点获取到锁后在未完成数据同步的情况下发生故障转移,此时其他客户端上的线程依然可以获取到锁,因此会丧失锁的安全性。
整个过程如下:
- 客户端 A 从 Master 节点获取锁。
- Master 节点出现故障,主从复制过程中,锁对应的 key 没有同步到 Slave 节点。
- Slave升 级为 Master 节点,但此时的 Master 中没有锁数据。
- 客户端 B 请求新的 Master 节点,并获取到了对应同一个资源的锁。
- 出现多个客户端同时持有同一个资源的锁,不满足锁的互斥性。
客户端挂机问题
客户端A在Redis设置分布式锁,设置时间超长,然后A挂机了,没释放锁
如果使用zookeeper
,那么因为嗅探机制,一旦节点挂了,就可以立刻释放锁.
Redis这边只能通过设置短的过期时间,看门狗延长过期时间来一定程度避免这个问题
Redis的集群方式
Redis集群方式共有三种:主从模式,哨兵模式,cluster
(集群)模式
主从模式会保证数据在从节点还有一份,但是主节点挂了之后,需要手动把从节点切换为主节点。它非常简单,但是在实际的生产环境中是很少使用的。
哨兵模式就是主从模式的升级版,该模式下会对响应异常的主节点进行主观下线或者客观下线的操作,并进行主从切换。它可以保证高可用。
cluster (集群)模式保证的是高并发,整个集群分担所有数据,不同的 key 会放到不同的 Redis 中。每个 Redis 对应一部分的槽。
哨兵模式
在 Redis 主从复制模式中,因为系统不具备自动恢复的功能,所以当主服务器(master)宕机后,需要手动把一台从服务器(slave)切换为主服务器。在这个过程中,不仅需要人为干预,而且还会造成一段时间内服务器处于不可用状态,同时数据安全性也得不到保障,因此主从模式的可用性较低,不适用于线上生产环境。
Redis 官方推荐一种高可用方案,也就是 Redis Sentinel 哨兵模式,它弥补了主从模式的不足。Sentinel 通过监控的方式获取主机的工作状态是否正常,当主机发生故障时, Sentinel 会自动进行 Failover(即故障转移),并将其监控的从机提升主服务器(master),从而保证了系统的高可用性。
哨兵主要作用
- 哨兵节点会以每秒一次的频率对每个 Redis 节点发送
PING
命令,并通过 Redis 节点的回复来判断其运行状态。 - 当哨兵监测到主服务器发生故障时,会自动在从节点中选择一台将机器,并其提升为主服务器,然后使用
PubSub
发布订阅模式,通知其他的从节点,修改配置文件,跟随新的主服务器。
1) 主观下线
主观下线,适用于主服务器和从服务器。如果在规定的时间内(配置参数:down-after-milliseconds
),Sentinel
节点没有收到目标服务器的有效回复,则判定该服务器为“主观下线”。比如 Sentinel1
向主服务发送了PING
命令,在规定时间内没收到主服务器PONG
回复,则 Sentinel1
判定主服务器为“主观下线”。
2) 客观下线
客观下线,只适用于主服务器。 Sentinel1
发现主服务器出现了故障,它会通过相应的命令,询问其它 Sentinel
节点对主服务器的状态判断。如果超过半数以上的 Sentinel
节点认为主服务器 down
掉,则 Sentinel1
节点判定主服务为“客观下线”。
3) 投票选举
投票选举,所有 Sentinel
节点会通过投票机制,按照谁发现谁去处理的原则,选举 Sentinel1
为领头节点去做 Failover
(故障转移)操作。Sentinel1
节点则按照一定的规则在所有从节点中选择一个最优的作为主服务器,然后通过发布订功能通知其余的从节点(slave
)更改配置文件,跟随新上任的主服务器(master
)。至此就完成了主从切换的操作。
简单总结
Sentinel
负责监控主从节点的“健康”状态。当主节点挂掉时,自动选择一个最优的从节点切换为主节点。客户端来连接 Redis
集群时,会首先连接 Sentinel
,通过 Sentinel
来查询主节点的地址,然后再去连接主节点进行数据交互。当主节点发生故障时,客户端会重新向 Sentinel
要地址,Sentinel
会将最新的主节点地址告诉客户端。因此应用程序无需重启即可自动完成主从节点切换。
Redis哨兵模式(sentinel)学习总结及部署记录(主从复制、读写分离、主从切换)
RedLock实现
为了解决redis的master节点挂掉,follower节点未同步到锁的问题,提出了RedLock.
在Redis的分布式环境中,我们假设有N个Redis master。这些节点完全互相独立,不存在主从复制或者其他集群协调机制。我们确保将在N个实例上使用与在Redis单实例下相同方法获取和释放锁。现在我们假设有5个Redis master节点,同时我们需要在5台服务器上面运行这些Redis实例,这样保证他们不会同时都宕掉。
为了取到锁,客户端应该执行以下操作:
- 获取当前 Unix 时间,以毫秒为单位。
- 依次尝试从 N 个实例,使用相同的 key 和随机值获取锁。在步骤 2,当向 Redis 设置锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为 10 秒,则超时时间应该在 5-50 毫秒之间。这样可以避免服务器端 Redis 已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试另外一个 Redis 实例。
- 客户端使用当前时间减去开始获取锁时间(步骤 1 记录的时间)就得到获取锁使用的时间。当且仅当从大多数(这里是 3 个节点)的 Redis 节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。
- 如果取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间(步骤 3 计算的结果)。
- 如果因为某些原因,获取锁失败(没有在至少 N/2+1 个Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的 Redis 实例上进行解锁(即便某些 Redis 实例根本就没有加锁成功)。
基于RedLock思想,遍历所有的Redis客户端,然后依次加锁,最后统计成功的次数来判断是否加锁成功。
RedLock代码用法
首先,我们来看一下redission封装的redlock算法实现的分布式锁用法,非常简单,跟重入锁(ReentrantLock)有点类似:
Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
.setMasterName("masterName")
.setPassword("password").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);
// 还可以getFairLock(), getReadWriteLock()
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
isLock = redLock.tryLock();
// 500ms拿不到锁, 就认为获取锁失败。10000ms即10s是锁失效时间。
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
if (isLock) {
//TODO if get lock success, do something;
}
} catch (Exception e) {
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
}
故障重启
RedLock是不能解决故障重启后带来的锁的安全性的问题。看下下面这个场景:
我们一共有 A、B、C 这三个节点。
- 客户端 1 在 A,B 上加锁成功。C 上加锁失败。
- 这时节点 B 崩溃重启了,但是由于持久化策略导致客户端 1 在 B 上的锁没有持久化下来。
- 客户端 2 发起申请同一把锁的操作,在 B,C 上加锁成功。
- 这个时候就又出现同一把锁,同时被客户端 1 和客户端 2 所持有了。
比如,Redis 的 AOF 持久化方式默认情况下是每秒写一次磁盘,即 fsync 操作,因此最坏的情况下可能丢失 1 秒的数据。
当然,你也可以设置成每次修改数据都进行 fsync 操作(fsync=always),但这会严重降低 Redis 的性能,违反了它的设计理念。
延迟重启
针对故障重启问题,Redis 的作者又提出了延迟重启(delayed restarts) 的概念。
意思就是说,一个节点崩溃后,不要立即重启它,而是等待一定的时间后再重启。等待的时间应该大于锁的过期时间(TTL)。这样做的目的是保证这个节点在重启前所参与的锁都过期。相当于把以前的帐勾销之后才能参与后面的加锁操作。
但是有个问题就是:在等待的时间内,这个节点是不对外工作的。那么如果大多数节点都挂了,进入了等待。就会导致系统的不可用,因为系统在TTL时间内任何锁都将无法加锁成功。
RedLock释放锁
释放锁的时候是要向所有节点发起释放锁的操作的。这样做的目的是为了解决有可能在加锁阶段,这个节点收到加锁请求了,也set成功了,但是由于返回给客户端的响应包丢了,导致客户端以为没有加锁成功。所有,释放锁的时候要向所有节点发起释放锁的操作。
Redis中大key对持久化有什么影响
当 AOF 写回策略配置了 Always
策略,如果写入是一个大 Key,主线程在执行fsync()
函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。
AOF 重写机制和 RDB 快照(bgsave
命令)的过程,都会分别通过 fork()
函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程):
- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
- 创建完子进程后,如果父进程修改了共享数据中的大
Key
,就会发生写时复制,这期间会拷贝物理内存,由于大Key
占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。
大 key
除了会影响持久化之外,还会有以下的影响。
- 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
- 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 阻塞工作线程。如果使用
del
删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。 - 内存分布不均。集群模型在
slot
分片均匀情况下,会出现数据和查询倾斜情况,部分有大key
的 Redis 节点占用内存多,QPS 也会比较大。
如果避免大Key
最好在设计阶段,就把大 key 拆分成一个一个小 key。或者,定时检查 Redis 是否存在大 key ,如果该大 key 是可以删除的,不要使用 DEL
命令删除,因为该命令删除过程会阻塞主线程,而是用 unlink
命令(Redis 4.0+
)删除大 key,因为该命令的删除过程是异步的,不会阻塞主线程。
Redis 删除数据的过期策略有哪些
惰性删除:当读/写一个已经过期的 key 时,会触发惰性删除策略,直接删除掉这个过期 key ,并按照 key 不存在去处理。惰性删除,对内存不太好,已经过期的 key 会占用太多的内存。
定期删除: Redis 默认每 1 秒运行 10 次(每 100 ms 执行一次),每次随机抽取一些设置了过期时间的 key,检查是否过期,如果发现过期了就直接删除。如果过期的key超过25%
,那么再次执行定期删除操作.
不管是定时删除,还是惰性删除。当数据删除后,master 会生成删除的指令记录到 AOF 和 slave 节点。
大批过期数据,如果删除策略不能解决,还有内存淘汰机制兜底.
过期与持久化
主从或者集群架构中,两台机器的时钟严重不同步,会有什么问题么?
key 过期信息是用 Unix
绝对时间戳表示的。
为了让过期操作正常运行,机器之间的时间必须保证稳定同步,否则就会出现过期时间不准的情况。
比如两台时钟严重不同步的机器发生 RDB 传输, slave 的时间设置为未来的 2000 秒,假如在 master 的一个 key 设置 1000 秒存活,当 Slave 加载 RDB 的时候 key 就会认为该 key 已过期,立刻删除该key,而不是等到1000秒之后删除(假设忽略传输耗时)。
Redis 存储数据的内存占用远小于操作系统分配给 Redis 的内存,而又无法保存数据?
如果你发现明明 Redis 存储数据的内存占用远小于操作系统分配给 Redis 的内存,而又无法保存数据,那可能出现大量内存碎片了。
通过 info memory
命令,看下内存碎片mem_fragmentation_ratio
指标是否正常。
那么我们就开启自动清理并合理设置清理时机和 CPU 资源占用,该机制涉及到内存拷贝,会对 Redis 性能造成潜在风险。
如果遇到 Redis 性能变慢,排查下是否由于清理碎片导致,如果是,那就调小 active-defrag-cycle-max
的值。
Redis热Key
热key问题,就是大流量访问Redis实例,导致出现瓶颈(IO或CPU),服务受到影响。
有赞的Hermes
有赞透明多级缓存解决方案(TMC),可以实现Redis热key自动发现,并程序自动处理
Hermes-SDK
都会通过其通信模块将key访问事件异步上报给Hermes服务端集群,以便其根据上报数据进行“热点探测”。发现热key就会应用中使用本地缓存。
当缓存过期或值被修改,通过 etcd集群 推送给应用集群中其他 Hermes-SDK 节点,删除或更新本地缓存。
Redis的BigKey
Big Key就是某个key对应的value很大,占用的redis空间很大,本质上是大value问题。
BigKey导致的问题:
- Client发现Redis变慢;
- Redis内存不断变大引发OOM,或达到maxmemory设置值引发写阻塞或重要Key被逐出;
- Redis Cluster中的某个node内存远超其余node,但因Redis Cluster的数据迁移最小粒度为Key而无法将node上的内存均衡化;
- 大Key上的读请求使Redis占用服务器全部带宽,自身变慢的同时影响到该服务器上的其它服务;
- 删除一个大Key造成主库较长时间的阻塞并引发同步中断或主从切换;
识别bigkey方法:
1、使用redis自带的命令识别
例如可以使用Redis官方客户端redis-cli加上--bigkeys参数,可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。
优点是可以在线扫描,不阻塞服务;缺点是信息较少,内容不够精确。
2、使用debug object key命令
根据传入的对象(Key的名称)来对Key进行分析并返回大量数据,其中serializedlength的值为该Key的序列化长度,需要注意的是,Key的序列化长度并不等同于它在内存空间中的真实长度,此外,debug object属于调试命令,运行代价较大,并且在其运行时,进入Redis的其余请求将会被阻塞直到其执行完毕。并且每次只能查找单个key的信息,官方不推荐使用。
3、redis-rdb-tools开源工具
这种方式是在redis实例上执行bgsave,bgsave会触发redis的快照备份,生成rdb持久化文件,然后对dump出来的rdb文件进行分析,找到其中的大key。
GitHub地址:https://github.com/sripathikrishnan/redis-rdb-tools
优点在于获取的key信息详细、可选参数多、支持定制化需求,结果信息可选择json或csv格式,后续处理方便,其缺点是需要离线操作,获取结果时间较长。
4、rdr
./rdr show -p 8080 *.rdb
5、redis-rdb-cli
https://github.com/leonchen83/redis-rdb-cli
RDB持久化:是一种内存快照的形式,按照一定的频次进行快照落盘。
优点:这是一种理想化的选择,不会影响redis服务的进行。缺点:有些redis服务没有采用RDB持久化,不具有普遍性。时效性更差。
解决方法:
要解决Big Key问题,无非就是减小key对应的value值的大小,也就是对于String数据结构的话,减少存储的字符串的长度;对于List、Hash、Set、ZSet数据结构则是减少集合中元素的个数。
1、对大Key进行拆分
将一个Big Key拆分为多个key-value这样的小Key,并确保每个key的成员数量或者大小在合理范围内,然后再进行存储,通过get不同的key或者使用mget批量获取。
2、对大Key进行清理
对Redis中的大Key进行清理,从Redis中删除此类数据。Redis自4.0起提供了UNLINK命令,该命令能够以非阻塞的方式缓慢逐步的清理传入的Key,通过UNLINK,你可以安全的删除大Key甚至特大Key。
3、监控Redis的内存、网络带宽、超时等指标
通过监控系统并设置合理的Redis内存报警阈值来提醒我们此时可能有大Key正在产生,如:Redis内存使用率超过70%,Redis内存1小时内增长率超过20%等。
4、定期清理失效数据
如果某个Key有业务不断以增量方式写入大量的数据,并且忽略了其时效性,这样会导致大量的失效数据堆积。可以通过定时任务的方式,对失效数据进行清理。
5、压缩value
使用序列化、压缩算法将key的大小控制在合理范围内,但是需要注意序列化、反序列化都会带来一定的消耗。如果压缩后,value还是很大,那么可以进一步对key进行拆分。
Redis中什么是Big Key(大key)问题?如何解决Big Key问题?
参考:
Redis: 分布式锁的官方算法RedLock以及Java版本实现库Redisson
Redis 缓存过期(maxmemory) 配置/算法 详解
细说Redis分布式锁:setnx/redisson/redlock?了解一波?