Redis问题收集
Redis 问题收集
原子性操作命令
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
结构维护了一个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
安装目录下面的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
的递增。
给定一个旧的访问频次,当一个键被访问时,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不是一直号称单线程效率也很高吗,为什么又采用多线程了?
Redis
作为一个成熟的分布式缓存框架,它由很多个模块组成,如网络请求模块、索引模块、存储模块、高可用集群支撑模块、数据操作模块等。
很多人说Redis
是单线程的,就认为Redis
中所有模块的操作都是单线程的,其实这是不对的。
我们所说的Redis
单线程,指的是"其网络IO和键值对读写是由一个线程完成的",也就是说,Redis中只有网络请求模块和数据操作模块是单线程的。而其他的如持久化存储模块、集群支撑模块等是多线程的。
Jedis,Redisson,Lettuce三个Redis客户端框架区别
Redis底层数据结构
Redis
字符串,是自己构建了一种名为 简单动态字符串(simple dynamic string
,SDS
)的抽象类型,并将SDS
作为Redis
的默认字符串表示。Redis List
,底层是ZipList
,不满足ZipList
就使用双向链表。ZipList
是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。
String
在Redis
中String
是可以修改的,称为动态字符串
(Simple Dynamic String
简称 SDS
),说是字符串但它的内部结构更像是一个 ArrayList
,内部维护着一个字节数组,并且在其内部预分配了一定的空间,以减少内存的频繁分配。
Redis
的内存分配机制是这样:
- 当字符串的长度小于
1MB
时,每次扩容都是加倍现有的空间。 - 如果字符串长度超过
1MB
时,每次扩容时只会扩展1MB
的空间。
这样既保证了内存空间够用,还不至于造成内存的浪费,字符串最大长度为 512MB
Redis为什么要自己实现SDS字符串
Redis
是使用c
语言编写的,c
语言中的字符串是通过字符数组实现的,底层开辟了一块连续的存放空间,依次存放字符串当中的每个字符,为了表示字符串的结束,会在字符数组最后记录\0
,也就是说识别到字符数组中的\0
的时候,就表示这个字符串结束了,
使用这种方式存在两个问题
- 这种字符串不能存储任意内容,至少
\0
这个字符是不可以的,因为一但遇到这个字符,字符串就被截断了,这种情况redis
肯定不可以接受 C
语言中以\0
作为识别字符串结束的方式,当要判断字符串长度,已经对字符串进行追加的时候,都需要从头开始遍历,一直遍历到\0
的时候再返回长度或做追加,这使的字符串的操作效率比较低
Redis
中对字符串做了自己实现,还是使用字符数组表示字符串,在字符串中增加两个字段
- 一个表示分配给该字符数组的总长度
alloc
- 一个表示字符串现有长度的
len
这样的话,再获取字符串长度,直接返回len
,如果做追加操作,直接判断新最近部分的len
加上已有字符串的len
是否大于alloc
,如果超过了重新再申请空间,如果没超过,直接在后面追加即可!
List
Redis
中的list
和Java
中的LinkedList
很像,底层都是一种链表结构, list
的插入和删除操作非常快,时间复杂度为 0(1)
,不像数组结构插入、删除操作需要移动数据。
像归像,但是redis
中的list
底层可不是一个双向链表那么简单。
当数据量较少的时候它的底层存储结构为一块连续内存,称之为ziplist(压缩列表)
,它将所有的元素紧挨着一起存储,分配的是一块连续的内存;当数据量较多的时候将会变成quicklist(快速链表)
结构。
可单纯的链表也是有缺陷的,链表的前后指针 prev
和 next
会占用较多的内存,会比较浪费空间,而且会加重内存的碎片化。在redis 3.2
之后就都改用ziplist+链表
的混合结构,称之为 quicklist(快速链表)
。
Hash
Redis
中的 Hash
和 Java
的 HashMap
更加相似,都是数组+链表
的结构,当发生 hash
碰撞时将会把元素追加到链表上,值得注意的是在 Redis
的 Hash
中 value
只能是字符串.
hset books java "Effective java" (integer) 1
hset books golang "concurrency in go" (integer) 1
hget books java "Effective java"
hset user age 17 (integer) 1
hincrby user age 1 #单个 key 可以进行计数 和 incr 命令基本一致 (integer) 18
Hash
和String
都可以用来存储用户信息 ,但不同的是Hash
可以对用户信息的每个字段单独存储;String
存的是用户全部信息经过序列化后的字符串,如果想要修改某个用户字段必须将用户信息字符串全部查询出来,解析成相应的用户信息对象,修改完后在序列化成字符串存入。而 hash
可以只对某个字段修改,从而节约网络流量,不过hash
内存占用要大于 String
,这是 hash
的缺点。
Set
Redis
中的 set
和Java
中的HashSet
有些类似,它内部的键值对是无序的、唯一 的。它的内部实现相当于一个特殊的字典,字典中所有的value
都是一个值 NULL
。当集合中最后一个元素被移除之后,数据结构被自动删除,内存被回收。
Zset
zset
也叫SortedSet
一方面它是个 set
,保证了内部 value
的唯一性,另方面它可以给每个 value
赋予一个score
,代表这个value
的排序权重。它的内部实现用的是一种叫作“跳跃列表
”的数据结构。
zset
可以用做排行榜,但是和list
不同的是zset
它能够实现动态的排序,例如: 可以用来存储粉丝列表,value
值是粉丝的用户 ID
,score
是关注时间,我们可以对粉丝列表按关注时间进行排序。
zset
还可以用来存储学生的成绩, value
值是学生的 ID
, score
是他的考试成绩。 我们对成绩按分数进行排序就可以得到他的名次。
最详细的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
收集master
返回的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
中,采用了共享主从复制缓冲区的策略,降低了主从复制带来的内存开销。
Redis主从同步延迟
- 保证网络环境良好,尽量降低延迟,业务上允许一定时间的延迟
- 强制读主,从节点的
slave-serve-stale-data
参数设置为yes
(默认设置),从库会继续响应客户端的请求,反之则不提供读服务。 - 代理监控,比如最近进行过写操作的,路由到
master
进行读取。可以有个第三方的存储,比如zookeeper
,存储最近操作记录,过期后读取master
。
先更新数据库,再删除缓存,会有什么问题?
先更新数据库,再删除缓存。可能出现以下情况:
- 如果更新完数据库,
Java
服务提交了事务,然后挂掉了,那Redis
还是会执行,这样也会不一致。 - 如果更新数据库成功,删除缓存失败了,那么会导致数据库中是新数据,缓存中是旧数据,数据就出现了不一致。
先删除缓存,再更新数据库。
- 如果删除缓存失败,那就不更新数据库,缓存和数据库的数据都是旧数据,数据是一致的。
- 如果删除缓存成功,而数据库更新失败了,那么数据库中是旧数据,缓存中是空的,数据不会不一致。因为读的时候缓存没有,所以去读了数据库中的旧数据,然后更新到缓存中。
Redisson框架
Redis锁过期问题解决办法 (看门狗,redisson不设置过期时间时生效)
设置过期时间,如果到过期时间,但是任务还未执行完毕,其他任务就会获取锁,这时就会有多个任务同时获取到资源
现有的解决办法是:
redisson
在加锁成功后,会注册一个定时任务监听这个锁,每隔10
秒就去查看这个锁,如果还持有锁,就对过期时间
进行续期。默认过期时间30秒。这个机制也被叫做:“看门狗
”。
看门狗功能是Redisson的,不是redis的,获取锁没指定过期时间的,看门狗就会生效
默认情况下,看门狗的时间lockWatchdogTimeout(可配)默认为30s,会有task每10s (internalLockLeaseTime / 3)循环判断,如果该线程还持有锁执行任务,就重置延时30s,直到锁丢失,获取线程不持有该锁
使用时间轮加递归的方式实现了看门狗
Redisson的“看门狗”机制,一个关于分布式锁的非比寻常的BUG
设置看门狗,没设置锁过期时间,如果客户端在执行unlock之前挂机会一直占用锁吗?
看客户端申请锁的线程是否存在,如果存在就一直持有锁,如果不存在,每次设置30s
的时候会进行检查,不存在就设置,自动过期。
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
).
并且设置为-1的时候,才会有看门狗,就是scheduleExpirationRenewal
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分布式锁存储值格式
hash
数据结构,key
为锁的名称,field
为“客户端唯一ID:线程ID”,value
为1
客户端唯一id,就是uuid,value为可重入锁次数
redission
使用hash
数据格式实现了可重入锁,大key
就是设置的标记,小key
就是客户端id和线程id,小value
就是重入次数。
根据代码分析uuid到底是怎么来的
这时发现跟链接配置有关系,走她,接着进去。发现ConnectionManger是个接口,那我就随便找一个实现发现,所有的最底层的子实现都继承了MasterSlaveConnectionManager
发现这个时候,id就开始被指定进去了而且是UUID而已。 那么得出来的结论:ARGV[2]=UUID+":"+threadId
为啥要UUID+“:”+threadId作为锁名称
在分布式的服务中,由于仅仅通过threadId
根本不能指定到是哪个服务器节点的那一个线程进行执行,那就必须用一个全局唯一的前缀进行区分了(UUID
) 有同学问?那能不能不用UUID
呢? 可以啊,只要保证每台服务器节点生产的分布式锁前缀保证区分就行。但这就要每台服务器节点进行维护写前缀了,算了算不划算(没闲情搞这些,但是有这场景的哦,你们自己想想看),那还不如直接用uuid
(好用又方便)
锁等待问题
自旋加 semaphore
wait
等待
Redisson
获取锁的主流程代码:
1 @Override
2 public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
3 long time = unit.toMillis(waitTime);
4 long current = System.currentTimeMillis();
5 long threadId = Thread.currentThread().getId();
6 Long ttl = tryAcquire(leaseTime, unit, threadId);
7 //1、 获取锁同时获取成功的情况下,和lock(...)方法是一样的 直接返回True,获取锁False再往下走
8 if (ttl == null) {
9 return true;
10 }
11 //2、如果超过了尝试获取锁的等待时间,当然返回false 了。
12 time -= System.currentTimeMillis() - current;
13 if (time <= 0) {
14 acquireFailed(threadId);
15 return false;
16 }
17
18 // 3、订阅监听redis消息,并且创建RedissonLockEntry,其中RedissonLockEntry中比较关键的是一个 Semaphore属性对象,用来控制本地的锁请求的信号量同步,返回的是netty框架的Future实现。
19 final RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
20 // 阻塞等待subscribe的future的结果对象,如果subscribe方法调用超过了time,说明已经超过了客户端设置的最大wait time,则直接返回false,取消订阅,不再继续申请锁了。
21 // 只有await返回true,才进入循环尝试获取锁
22 if (!await(subscribeFuture, time, TimeUnit.MILLISECONDS)) {
23 if (!subscribeFuture.cancel(false)) {
24 subscribeFuture.addListener(new FutureListener<RedissonLockEntry>() {
25 @Override
26 public void operationComplete(Future<RedissonLockEntry> future) throws Exception {
27 if (subscribeFuture.isSuccess()) {
28 unsubscribe(subscribeFuture, threadId);
29 }
30 }
31 });
32 }
33 acquireFailed(threadId);
34 return false;
35 }
36
37 //4、如果没有超过尝试获取锁的等待时间,那么通过While一直获取锁。最终只会有两种结果
38 //1)、在等待时间内获取锁成功 返回true。2)等待时间结束了还没有获取到锁那么返回false。
39 while (true) {
40 long currentTime = System.currentTimeMillis();
41 ttl = tryAcquire(leaseTime, unit, threadId);
42 // 获取锁成功
43 if (ttl == null) {
44 return true;
45 }
46 // 获取锁失败
47 time -= System.currentTimeMillis() - currentTime;
48 if (time <= 0) {
49 acquireFailed(threadId);
50 return false;
51 }
52 }
53 }
流程分析:
- 尝试获取锁,返回
null
则说明加锁成功,返回一个数值,则说明已经存在该锁,ttl
为锁的剩余存活时间。 - 如果此时客户端
2
进程获取锁失败,那么使用客户端2
的线程id
(其实本质上就是进程id
)通过Redis
的channel
订阅锁释放的事件,如果等待的过程中一直未等到锁的释放事件通知,当超过最大等待时间则获取锁失败,返回false
,也就是第 39 行代码。如果等到了锁的释放事件的通知,则开始进入一个不断重试获取锁的循环。 - 循环中每次都先试着获取锁,并得到已存在的锁的剩余存活时间。如果在重试中拿到了锁,则直接返回。如果锁当前还是被占用的,那么等待释放锁的消息,具体实现使用了
JDK
的信号量Semaphore
来阻塞线程,当锁释放并发布释放锁的消息后,信号量的release()
方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。
特别注意:以上过程存在一个细节,这里有必要说明一下,也是分布式锁的一个关键点:当锁正在被占用时,等待获取锁的进程并不是通过一个
while(true)
死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。
Redisson中公平锁与非公平锁
RedissonLock
为非公平锁,RedissonFairLock
为公平锁,使用方法类型,如下:
// 获取一个FairLock
RLock fairLock = redissonClient.getFairLock("lockName");
// 加锁
fairLock.lock();
// 释放锁
fairLock.unlock();
RedissonFairLock
是在RedissonLock
基础上扩展来的
public class RedissonFairLock extends RedissonLock implements RLock {
private final long threadWaitTime;
private final CommandAsyncExecutor commandExecutor;
private final String threadsQueueName;
private final String timeoutSetName;
public RedissonFairLock(CommandAsyncExecutor commandExecutor, String name) {
this(commandExecutor, name, 300000L);
}
。。。。。。。
内部有threadsQueueName
,timeoutSetName
,这两个分别是一个队列(内部使用redis
的list
类型),一个超时记录集合(sorted set
),可以利用zscore
命令获取对应的超时时间。
RedissonFairLock
只是重载了RedissonLock
的方法
tryLockInnerAsync
加锁acquireFailedAsync
取消获取锁unlockInnerAsync
释放锁forceUnlockAsync
强制释放锁
非公平加锁的流程图:
加锁的场景图:
公平锁与不公平锁的区别在于:获取锁时,队列中是否已有等待的,有则去排队。
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
时间,以毫秒为单位。 - 依次尝试从
5
个实例,使用相同的key
和具有唯一性的value(例如UUID
)获取锁。当向Redis
请求获取锁时,客户端应该设置一个网络连接和响应超时时间,这个超时时间应该小于锁的失效时间。例如你的锁自动失效时间为10
秒,则超时时间应该在5-50
毫秒之间。这样可以避免服务器端Redis
已经挂掉的情况下,客户端还在死死地等待响应结果。如果服务器端没有在规定时间内响应,客户端应该尽快尝试去另外一个Redis
实例请求获取锁。 - 客户端使用当前时间减去开始获取锁时间(步骤1记录的时间)就得到获取锁使用的时间。当且仅当从大多数(
N/2+1
,这里是3
个节点)的Redis节点都取到锁,并且使用的时间小于锁失效时间时,锁才算获取成功。 - 如果取到了锁,
key
的真正有效时间等于有效时间减去获取锁所使用的时间(步骤3
计算的结果)。 - 如果因为某些原因,获取锁失败(没有在至少
N/2+1
个Redis
实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的Redis实例上进行解锁(即便某些Redis
实例根本就没有加锁成功,防止某些节点获取到锁但是客户端没有得到响应而导致接下来的一段时间不能被重新获取锁)。
基于RedLock思想,遍历所有的Redis客户端,然后依次加锁,最后统计成功的次数来判断是否加锁成功。
Redis Cluster是基于AP模型的,会存在数据丢失,所以设计了redlock,redlock用于加锁多台独立的机器或集群,只要保证一半以上的数据不丢失即可。
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 存储数据的内存占用远小于操作系统分配给 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
内存远超其余no
de,但因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中大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
是可以删除的,不要使用 DEL
命令删除,因为该命令删除过程会阻塞主线程,而是用 unlink
命令(Redis 4.0+
)删除大 key
,因为该命令的删除过程是异步的,不会阻塞主线程。
Redis的原子性
任何数据库都要有一套自己的事务控制机制,Redis
事务是一次可以执行多个命令,它的本质是一组命令的集合。一个事务中所有的命令都会被序列化,在事务执行的过程中会按照顺序执行队列中的命令。其它客户端提交的命令请求会等到事务执行完毕再执行。
总的来说:redis
事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
multi
开启了事务,只是多次set
,此时的返回结果为QUEUED
,代表进入了执行队列,此时还没有执行命令,直到执行exec
时才会把队列的命令依次执行。
Redis
的lua
脚本中,如果出现指令语法错误,指令参数错误的情况,这种场景下,异常的指令不执行,其他正常的指令继续执行。Redis
官方解释,上述情况都是程序员的锅,是程序员代码写错了。
redis
并没有严格的保证了数据的原子性操作,这样的好处在于redis
不需要像数据库一样还要保存回滚日志等,可以让redis
执行的更快
redis
只能保证lua
脚本执行,不可被拆分,不可被中断,但是不保证ACID
中的要么都执行,要么都回滚的原子性
Redis 的事务保证了 ACID 中的一致性(C)和隔离性(I),但并不保证原子性(A)和持久性(D)。
Redis不提供回滚操作,但是可以使用watch命令来实现回滚。
我们可以使用 watch
命令来监视一个或多个 key
,如果被监视的 key
在事务执行前被修改过那么本次事务将会被取消,也就是所谓的回滚。
只有确保被监视的 key
,在事务开始前到执行 这段时间内未被修改过事务才会执行成功(类似乐观锁)
如果一次事务中存在被监视的 key
,无论此次事务执行成功与否,该 key
的监视都将会在执行后失效 也就是说监视是一次性的。
lua脚本中,需要对所有设置修改的key进行watch,比较繁琐。
Redis 删除数据的过期策略
惰性删除:当读/写一个已经过期的 key
时,会触发惰性删除策略,直接删除掉这个过期 key
,并按照 key
不存在去处理。惰性删除,对内存不太好,已经过期的 key
会占用太多的内存。
定期删除: Redis
默认每 1
秒运行 10
次(每 100 ms
执行一次),每次随机抽取一些设置了过期时间的 key
,检查是否过期,如果发现过期了就直接删除。如果过期的key
超过25%
,那么再次执行定期删除操作.
惰性删除:只删除了DB字典过期key,从DB字典关系进行删除,并没有真正释放内存,释放内存的操作交给后台线程异步执行,也就是说一个key真正意义上的删除有一定的延迟。
不管是定时删除,还是惰性删除。当数据删除后,master 会生成删除的指令记录到 AOF 和 slave 节点。
大批过期数据,如果删除策略不能解决,还有内存淘汰机制兜底.
过期与持久化
主从或者集群架构中,两台机器的时钟严重不同步,会有什么问题么?
key
过期信息是用 Unix
绝对时间戳表示的。
为了让过期操作正常运行,机器之间的时间必须保证稳定同步,否则就会出现过期时间不准的情况。
比如两台时钟严重不同步的机器发生 RDB
传输, slave
的时间设置为未来的 2000
秒,假如在 master
的一个 key
设置 1000
秒存活,当 Slave
加载 RDB
的时候 key
就会认为该 key
已过期,立刻删除该key
,而不是等到1000
秒之后删除(假设忽略传输耗时)。
Redis淘汰池
Redis3.0
在2.8
的基础上增加了一个淘汰池,淘汰池默认大小为16
。在原来的策略中,每次抽取的key
在删除最久未被访问的元素后即全部释放,而3.0
增加了一个淘汰池(实现方式为数组),每次抽取完,都会将所有的key
放入淘汰池中,然后删除数组最后一个非空元素(数组内按空闲时间由小到大排序,最后一个非空元素就是我们要删除的最久未被访问的元素),淘汰池种剩下的元素再参与下一次删除过程。
Redis淘汰池的目的是为了提高淘汰数据的精准度
Redis Cluster集群中,怎么将业务中不同的key设置到同一台机器
大括号
jedisCluster.sadd("PhiAd{materialType}qqqq","71","73");
jedisCluster.sadd("PhiAd{materialType}ssss","72","73");
Set<String> set = jedisCluster.sinter("PhiAd{materialType}qqqq","PhiAd{materialType}ssss");
System.out.println(JedisClusterCRC16.getSlot("PhiAd{materialType}qqqq"));
System.out.println(JedisClusterCRC16.getSlot("PhiAd{materialType}ssss"));
redis
在使用hash
算法将键映射到slot
时,只会计算{}
里面的内容,若{}
内的内容相同,则将键映射到同一个slot
例子中{}
内容均为materialType
,这样在JedisClusterCRC16.getSlot(key)
时得到相同的slot
编码号。
这样就可以使用jedisCluster.sinter(key1,key2)
方法取交集,避免了键在不同的slot
时,该方法报错
Redis扩容
Redis
头插法
Redis中的数据就是以key-value的形式存储在dict中,dict数据结构的示意图可以表示如下。
即一个dict数据结构持有两张哈希表dictht,每张dictht中持有一个存储元素的节点数组,每对键值对会被封装成一个dictEntry节点然后添加到节点数组中,当存在哈希冲突时,Redis中使用拉链法解决哈希冲突。但是dictEntry数组的默认容量为4,发生哈希冲突的概率极高,如果不进行扩容,会导致哈希表的时间复杂度恶化为O(logN)
,所以满足一定条件时,需要进行dictEntry数组的扩容,即进行Redis的扩容
Redis会在如下两种情况触发扩容。
- 如果没有
fork
子进程在执行RDB或者AOF的持久化,一旦满足ht[0].used >= ht[0].size
,此时触发扩容; - 如果有
fork
子进程在执行RDB或者AOF的持久化时,则需要满足ht[0].used > 5 * ht[0].size
,此时触发扩容。
那如果突然机器掉电会怎样?
取决于 AOF
日志 sync
属性的配置,如果不要求性能,在每条写指令时都 sync
一下磁盘,就不会丢失数据。但是在高性能的要求下每次都 sync
是不现实的,一般都使用定时 sync
,比如 1
秒 1
次,这个时候最多就会丢失 1
秒的数据。 实际上,极端情况下,是最多丢失 2
秒的数据。因为 AOF
线程,负责每秒执行一次 fsync
操作,操作完成后,记录最后同步时间。主线程,负责对比上次同步时间,如果超过 2
秒,阻塞等待成功。
bgsave 的原理是什么?
fork
和 cow
。fork
是指 Redis
通过创建子进程来进行 bgsave
操作。cow
指的是 copy on write
,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。 这里 bgsave
操作后,会产生 RDB
快照文件。
假如 Redis 里面有 1 亿个 key,其中有 10w 个 key 是以某个固定的已知的前缀开头的,如果将它们全部找出来?
使用 keys
指令可以扫出指定模式的 key
列表。
- 对方接着追问:如果这个
Redis
正在给线上的业务提供服务,那使用keys
指令会有什么问题? - 这个时候你要回答
Redis
关键的一个特性:Redis
的单线程的。keys
指令会导致线程阻塞一段时间,线上服务会停顿,直到指令执行完毕,服务才能恢复。这个时候可以使用scan
指令,scan
指令可以无阻塞的提取出指定模式的key
列表,但是会有一定的重复概率,在客户端做一次去重就可以了,但是整体所花费的时间会比直接用keys
指令长。
参考:
Redis: 分布式锁的官方算法RedLock以及Java版本实现库Redisson
Redis 缓存过期(maxmemory) 配置/算法 详解
细说Redis分布式锁:setnx/redisson/redlock?了解一波?