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 命令可以通过参数来实现和 SETNXSETEXPSETEX 三个命令的效果,所以将来的 Redis 版本可能会废弃并最终移除 SETNXSETEXPSETEX 这三个命令。

redis命令方式设置锁,可以使用setnxincr命令,但是这两个命令还要再设置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"

setnxset 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:数据库中所有键值对,也被称作数据库的 keyspace
  • db.expires:带有生命周期的 key 及其对应的 TTL(存留时间),因此也被称作 expire set

maxmemory-samples

 为了保证性能,redis 中使用的 LRULFU 算法是一类近似实现。
 简单来说就是:算法选择被淘汰记录时,不会遍历所有记录,而是以 随机采样 的方式选取部分记录进行淘汰。
maxmemory-samples 选项控制该过程的采样数量,增大该值会增加 CPU 开销,但算法效果能更逼近实际的 LRULFU

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保存的条件,并根据dirtylastsave字段判断是否有哪一条条件已经被满足。

快照期间,是否可以对数据进行改动

为了快照而暂停写操作,肯定是不能接受的。所以这个时候,Redis 就会借助操作系统提供的写时复制技术(Copy-On-WriteCOW),在执行快照的同时,正常处理写操作。

简单来说,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 支持同时开启开启两种持久化方式,我们可以综合使用 AOFRDB 两种持久化机制,用 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-samplesRedisLRU 是取出配置的数目的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 天还不被使用的情况很少,再次只有 lruclock2 轮继续超过lru 属性时,计算才会出问题。

比如对象 A 记录的 lru1 天,而 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 timeldt,单位为分钟),低 8 位用来记录访问频率(logistic counter:logc),简称 counter

访问频次递增

LFU 计数器每个键只有 8 位,它能表示的最大值是 255,所以 Redis使用的是一种基于概率的对数器来实现 counter 的递增。

给定一个旧的访问频次,当一个键被访问时,counter 按以下方式递增:

  1. 提取 01 之间的随机数 R
  2. counter - 初始值(默认为 5),得到一个基础差值,如果这个差值小于 0,则直接取 0,为了方便计算,把这个差值记为 baseval
  3. 概率 P 计算公式为:1/(baseval * lfu_log_factor + 1)
  4. 如果 R < P 时,频次进行递增(counter++)。

公式中的 lfu_log_factor 称之为对数因子,默认是 10 ,可以通过参数来进行控制:

lfu_log_factor 10

下图就是对数因子 lfu_log_factor 和频次 counter 增长的关系图

可以看到,当对数因子 lfu_log_factor100 时,大概是 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

具体算法如下:

  1. 获取当前时间戳,转化为分钟 后取低 16 位(为了方便后续计算,这个值记为 now)。
  2. 取出对象内的 lru 属性中的高 16 位(为了方便后续计算,这个值记为 ldt)。
  3. lru > now 时,默认为过了一个周期(16 位,最大 65535),则取差值 65535-ldt+now:当 lru <= now 时,取差值 now-ldt(为了方便后续计算,这个差值记为idle_time)。
  4. 取出配置文件中的 lfu_decay_time 值,然后计算:idle_time / lfu_decay_time(为了方便后续计算,这个值记为num_periods)。
  5. 最后将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,再进行删除,那么就必须调用到Redisgetdel命令,这样明显就不是原子性操作,不安全。

建议使用Redisson,源码中加锁/释放锁操作都是用lua脚本完成的,封装的非常完善,开箱即用。

failover(故障转移)策略机制不可靠

主从同步通常是异步的,并不能真正的容错。

造成锁不独享的场景如下图所示:

  1. 客户端A申请从master实例获取锁key=test001,由于之前key=test001master实例上不存在,所以客户端A获取锁成功。
  2. master在通过异步主从同步将key=test001同步至slave之前挂掉了,此时slave经过failover升级为master,但是此时slave上并无key=test001
  3. 此时,客户端B申请从redis获取锁key=test001,由于此时slave上不存在key=test001,同样的,客户端B获取锁成功。
  4. 最终的结果是,由于关键时刻的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 stringSDS)的抽象类型,并将 SDS 作为 Redis 的默认字符串表示。
  • Redis List ,底层是 ZipList ,不满足 ZipList 就使用双向链表。ZipList 是为了节约内存而开发的。和各种语言的数组类似,它是由连续的内存块组成的,这样一来,由于内存是连续的,就减少了很多内存碎片和指针的内存占用,进而节约了内存。

String

RedisString是可以修改的,称为动态字符串(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中的listJava中的LinkedList很像,底层都是一种链表结构, list的插入和删除操作非常快,时间复杂度为 0(1),不像数组结构插入、删除操作需要移动数据。

像归像,但是redis中的list底层可不是一个双向链表那么简单。

当数据量较少的时候它的底层存储结构为一块连续内存,称之为ziplist(压缩列表),它将所有的元素紧挨着一起存储,分配的是一块连续的内存;当数据量较多的时候将会变成quicklist(快速链表)结构。

可单纯的链表也是有缺陷的,链表的前后指针 prevnext 会占用较多的内存,会比较浪费空间,而且会加重内存的碎片化。在redis 3.2之后就都改用ziplist+链表的混合结构,称之为 quicklist(快速链表)

Hash

Redis 中的 HashJavaHashMap 更加相似,都是数组+链表的结构,当发生 hash 碰撞时将会把元素追加到链表上,值得注意的是在 RedisHashvalue 只能是字符串.

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

HashString都可以用来存储用户信息 ,但不同的是Hash可以对用户信息的每个字段单独存储;String存的是用户全部信息经过序列化后的字符串,如果想要修改某个用户字段必须将用户信息字符串全部查询出来,解析成相应的用户信息对象,修改完后在序列化成字符串存入。而 hash可以只对某个字段修改,从而节约网络流量,不过hash内存占用要大于 String,这是 hash 的缺点。

Set

Redis 中的 setJava中的HashSet 有些类似,它内部的键值对是无序的、唯一 的。它的内部实现相当于一个特殊的字典,字典中所有的value都是一个值 NULL。当集合中最后一个元素被移除之后,数据结构被自动删除,内存被回收。

Zset

zset也叫SortedSet一方面它是个 set ,保证了内部 value 的唯一性,另方面它可以给每个 value 赋予一个score,代表这个value的排序权重。它的内部实现用的是一种叫作“跳跃列表”的数据结构。

zset 可以用做排行榜,但是和list不同的是zset它能够实现动态的排序,例如: 可以用来存储粉丝列表,value 值是粉丝的用户 IDscore 是关注时间,我们可以对粉丝列表按关注时间进行排序。

zset 还可以用来存储学生的成绩, value 值是学生的 IDscore 是他的考试成绩。 我们对成绩按分数进行排序就可以得到他的名次。

Redis源码剖析之跳表(skiplist)


选择合适Redis数据结构,减少80%的内存占用

Redis 5种数据结构 及使用场景分析

Redis 常用数据结构及其底层存储实现总结

最详细的Redis五种数据结构详解(理论+实战),建议收藏。

Redis 5种数据结构及对应使用场景

Redis数据结构与对象

hashString对比

hash类型数据比较少时,使用的时ziplist,比较省空间(相对于hash中设置keyvalue方式),但是相比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函数)。

Redishash之所以这样设计,是因为当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 XNode X 的位置在 Node BNode C 直接,那么受到影响的仅仅是 Node BNode 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
  • 内存到达上限,先要根据淘汰策略剔除一部分数据,再把新数据写入
  • rdbaop rewrite期间延迟,主线程需要创建一个子线程进行数据持久化,创建子线程会调用操作系统的fork函数,fork会消耗大量cpu资源,在fork之前,整个redis会被阻塞,无法处理客户端请求。
  • 操作系统是否开启内存大页机制,redis申请内存变大,申请内存耗时变长,导致每个写请求延迟增加。
  • AOF刷盘机制设置为always,即每次执行写操作立刻刷盘
  • 设置了绑定cpu
  • 查看redis是否使用了swapswap是使用磁盘,性能差。
  • redis设置为开启内存碎片整理,也会导致redis性能下降。
  • 网络IO过载

Redis 为什么变慢了?一文讲透如何排查 Redis 性能问题 | 万字长文

Redis选举

slave(从节点)发现自己的master(主节点)不可用时,变尝试进行Failover(故障转移),以便称为新的master。由于挂掉的master可能会有多个slave,从而存在多个slave竞争成为master节点的过程, 其过程如下:

  1. slave发现自己的master不可用;
  2. slave将记录集群的currentEpoch(选举周期)加1,并广播FAILOVER_AUTH_REQUEST 信息进行选举;
  3. 其他节点收到FAILOVER_AUTH_REQUEST信息后,只有其他的master可以进行响应,master收到消息后返回FAILOVER_AUTH_ACK信息,对于同一个Epoch,只能响应一次ack
  4. slave收集master返回的ack消息
  5. slave判断收到的ack消息个数是否大于半数的master个数,若是,则变成新的master
  6. 广播Pong消息通知其他集群节点,自己已经成为新的master

注意:从节点并不是在主节点一进入 FAIL 状态就马上尝试发起选举,而是有一定延迟,一定的延迟确保我们等待FAIL状态在集群中传播,slave如果立即尝试选举,其它masters或许尚未意识到FAIL状态,可能会拒绝投票。

  • 延迟计算公式:DELAY = 500ms + random(0 ~ 500ms) + SLAVE_RANK * 1000ms
  • SLAVE_RANK表示此slave已经从master复制数据的总量的rankRank越小代表已复制的数据越新。这种方式下,持有最新数据的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第一次连接masterslave不知道masterrunId,也不知道自己偏移量,这时候会传一个问号和-1,告诉master节点是第一次同步。格式为psync ? -1
  • master接收到psync ? -1时,就知道slave是要全量复制,就会将自己的runIdoffset告知slave,回复命令+fullresync {runId} {offset}。同时,master会执行bgsave命令来生成RDB文件,并使用缓冲区记录此后的所有写命令
  • slave接受到master的回复命令后,会保存masterrunIdoffset
  • slave此时处于同步状态,如果此时收到请求,当配置参数slave-server-stale-data yes时,会响应当前请求,no则返回错误。
  • master bgsave执行完毕,向Slave发送RDB文件,同时继续缓冲此期间的写命令。RDB文件发送完毕后,开始向Slave发送存储在缓冲区的写命令

  • slave收到RDB文件,丢弃所有旧数据,开始载入RDB文件;并执行Master发来的所有的存储在缓冲区里的写命令。

  • 此后 master 每执行一个写命令,就向Slave发送相同的写命令。

增量复制

  1. 如果出现网络闪断或者命令丢失等异常情况时,当主从连接恢复后,由于从节点之前保存了自身已复制的偏移量和主节点的运行ID。因此会把它们当作psync参数发送给主节点,要求进行部分复制操作,格式为psync {runId} {offset}
  2. 主节点接到psync命令后首先核对参数runId是否与自身一致,如果一致,说明之前复制的是当前主节点;之后根据参数offset在自身复制积压缓冲区查找,如果偏移量之后的数据存在缓冲区中,则对从节点发送+CONTINUE响应,表示可以进行部分复制;否则进行全量复制。
  3. 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态

主从刚刚连接的时候,进行全量同步;全同步结束后,进行增量同步。当然,如果有需要,slave 在任何时候都可以发起全量同步。redis 策略是,无论如何,首先会尝试进行增量同步,如不成功,要求从机进行全量同步。

复制偏移量

执行主从复制的双方都会分别维护一个复制偏移量,master 每次向 slave 传播 N 个字节,自己的复制偏移量就增加 N;同理 slave 接收 N 个字节,自身的复制偏移量也增加 N。通过对比主从之间的复制偏移量就可以知道主从间的同步状态。

复制积压缓冲区

复制积压缓冲区是 master 维护的一个固定长度的 FIFO 队列,默认大小为 1MB。当 master 进行命令传播时,不仅将写命令发给 slave 还会同时写进复制积压缓冲区,因此 master 的复制积压缓冲区会保存一部分最近传播的写命令。当 slave 重连上 master 时会将自己的复制偏移量通过 PSYNC 命令发给 mastermaster 检查自己的复制积压缓冲区,如果发现这部分未同步的命令还在自己的复制积压缓冲区中的话就可以利用这些保存的命令进行部分同步,反之如果断线太久这部分命令已经不在复制缓冲区,就只能进行全量同步。

运行 ID

run_id 是做什么用?这是因为 master 可能会在 slave 断线期间发生变更,例如可能超时失去联系或者宕机导致断线重连的是一个崭新的 master,不再是断线前复制的那个。自然崭新的 master 没有之前维护的复制积压缓冲区,只能进行全量同步。

因此每个 Redis server 都会有自己的运行 ID,由 40 个随机的十六进制字符组成。当 slave 初次复制 master 时,master 会将自己的运行 ID 发给 slave 进行保存,这样 slave重连时再将这个运行 ID 发送给重连上的 mastermaster 会接受这个 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_replidmaster_replid2 两个编号,前者是当前主节点的编号,后者为先前主节点的编号

Redis 中目前值保留了两个主节点编号,但完全可以实现一个链表,将过往的主节点的编号信息都记录下来,这样就可以追溯的更远了。这样以来,如果一个从节点断开后,执行了多次主从切换,该从节重新连接后,依然可以识别出它们的数据是同源的。但 Redis 没有这么做,这是因为没有必要,因为就算数据是同源的,但复制积压缓冲区中保存的数据是有限的,多次主从切换后,复制积压缓冲区中保存的命令已经无法满足部分同步了。有了同源增量复制后,主节点切换后,其他从节点可以基于新的主节点继续增量同步。

无盘全量同步和加载

Redis 执行全量复制,需要生成当前数据库的一份快照,具体做法是执行 fork 创建子进程,子进程遍历所有数据并编码后写入 RDB 文件中。RDB 生成后,在主进程中,会读取此文件并发送给从节点。读写磁盘上的 RDB 文件是比较耗资源的,在主进程中执行势必会导致 Redis 的响应时间变长。因此一个优化方案是 dump 后直接将数据直接发送数据给从节点,不需要将数据先写入到 RDB

Redis 6.0 中实现了这种无盘全量同步和无盘加载的策略。采用无盘全量同步,避免了对磁盘的操作,但也有缺点。一般情况下,在子进程中直接使用网络发送数据,这比在子进程中生成 RDB 要慢,这意味着子进程需要存活的时间相对较长。子进程存在的时间越长,写时复制造成的影响就越大,进而导致消耗的内存会更多。在全量复制时候,从节点一般是先接收 RDB 将其存在本地,接收完成后再载入 RDB。同样地,从节点也可以直接载入主节点发来的数据,避免将其存入本地的 RDB 文件中,而后再从磁盘加载。

共享主从复制缓冲区

在主节点的视角中,从节点就是一个客户端,从节点发送了 PSYNC 命令后,主节点就要与它们完成全量同步,并不断地把写命令同步给从节点。Redis 的每个客户端连接上存在一个发送缓冲区。主节点执行了写命令后,就会将命令内容写入到各个连接的发送缓冲区中。发送缓冲区存储的是待传播的命令,这意味着多个发送缓冲区中的内容其实是相同的。而且,这些命令还在复制积压缓冲区中存了一份呢。这就造成了大量的内存浪费,尤其是存在很多从节点的时候。


Redis 7.0 中,提出并实现了共享主从复制缓冲区的方案解决了这个问题。该方案让发送缓冲区与复制积压缓冲区共享,避免了数据的重复,可有效节省内存。

Redis各个版本主从复制演化总结

  1. 宏观来看 Redis 的主从复制分为全量同步和命令传播两个阶段。主节点先发送快照给从节点,然后源源不断地将命令传播给从节点,以此保证主从数据的一致。
  2. Redis 2.8 之前的主从复制存在闪断后需要重新全量同步的问题,Redis 2.8 引入了复制积压缓冲区解决了这一问题。
  3. Redis 4.0 中,同源增量复制的策略被提出,解决了主从切换后从节点需要全量同步的问题。至此,Redis 的主从复制整体上已经比较完善了。
  4. Redis 6.0 中,为进一步优化主从复制的性能,无盘同步和加载被提出,避免全量同步时读写磁盘,提高主从同步的速度。
  5. Redis 7.0 rc1 中,采用了共享主从复制缓冲区的策略,降低了主从复制带来的内存开销。

Redis 主从复制的原理及演进

Redis主从同步延迟

  • 保证网络环境良好,尽量降低延迟,业务上允许一定时间的延迟
  • 强制读主,从节点的 slave-serve-stale-data 参数设置为yes(默认设置),从库会继续响应客户端的请求,反之则不提供读服务。
  • 代理监控,比如最近进行过写操作的,路由到master进行读取。可以有个第三方的存储,比如zookeeper,存储最近操作记录,过期后读取master

先更新数据库,再删除缓存,会有什么问题?

这5个常问的Redis面试题你答得出来吗?(详细剖析)

先更新数据库,再删除缓存。可能出现以下情况:

  • 如果更新完数据库, 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.onCompletee==null表示执行redis命令pexpire命令异常,说明可能是Redis卡住了,或者掉线了,或者连接池没有连接了等等各种情况,都可能会执行不了命令,导致异常.

resfalse,表示key不存在或无法设置超时时间,那么说明锁已经没有了,或者易主了。那么也就没有当前线程什么事情了,啥都不用做,默默的结束就行了。

restrue,表示设置超时时间成功,说明续命成功,则再次调用 renewExporation 方法,等待着时间轮触发下一次.

Redisson分布式锁存储值格式

hash数据结构,key为锁的名称,field为“客户端唯一ID:线程ID”,value为1

客户端唯一id,就是uuid,value为可重入锁次数

redission使用hash数据格式实现了可重入锁,大key就是设置的标记,小key就是客户端id和线程id,小value就是重入次数。

根据代码分析uuid到底是怎么来的

image.png

image.png

这时发现跟链接配置有关系,走她,接着进去。发现ConnectionManger是个接口,那我就随便找一个实现发现,所有的最底层的子实现都继承了MasterSlaveConnectionManager

image.png

发现这个时候,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 }

流程分析:

  1. 尝试获取锁,返回 null 则说明加锁成功,返回一个数值,则说明已经存在该锁,ttl 为锁的剩余存活时间。
  2. 如果此时客户端 2 进程获取锁失败,那么使用客户端 2 的线程 id(其实本质上就是进程 id)通过 Redischannel 订阅锁释放的事件,如果等待的过程中一直未等到锁的释放事件通知,当超过最大等待时间则获取锁失败,返回 false,也就是第 39 行代码。如果等到了锁的释放事件的通知,则开始进入一个不断重试获取锁的循环。
  3. 循环中每次都先试着获取锁,并得到已存在的锁的剩余存活时间。如果在重试中拿到了锁,则直接返回。如果锁当前还是被占用的,那么等待释放锁的消息,具体实现使用了 JDK 的信号量 Semaphore 来阻塞线程,当锁释放并发布释放锁的消息后,信号量的 release() 方法会被调用,此时被信号量阻塞的等待队列中的一个线程就可以继续尝试获取锁了。

特别注意:以上过程存在一个细节,这里有必要说明一下,也是分布式锁的一个关键点:当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题

Redisson 实现分布式锁原理分析

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);
    }
    。。。。。。。

内部有threadsQueueNametimeoutSetName,这两个分别是一个队列(内部使用redislist类型),一个超时记录集合(sorted set),可以利用zscore命令获取对应的超时时间。

RedissonFairLock只是重载了RedissonLock的方法

  • tryLockInnerAsync 加锁
  • acquireFailedAsync 取消获取锁
  • unlockInnerAsync 释放锁
  • forceUnlockAsync 强制释放锁

非公平加锁的流程图:

img

加锁的场景图:

img

公平锁与不公平锁的区别在于:获取锁时,队列中是否已有等待的,有则去排队。

redisson的公平锁是怎么回事

Redisson源码解读-公平锁

Redis锁问题

主从同步问题

Redis 的复制是异步的,Master 节点获取到锁后在未完成数据同步的情况下发生故障转移,此时其他客户端上的线程依然可以获取到锁,因此会丧失锁的安全性。

整个过程如下:

  • 客户端 A 从 Master 节点获取锁。
  • Master 节点出现故障,主从复制过程中,锁对应的 key 没有同步到 Slave 节点。
  • Slave升级为 Master 节点,但此时的 Master 中没有锁数据。
  • 客户端 B 请求新的 Master 节点,并获取到了对应同一个资源的锁。
  • 出现多个客户端同时持有同一个资源的锁,不满足锁的互斥性。

客户端挂机问题

客户端ARedis设置分布式锁,设置时间超长,然后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哨兵模式(详细图解)

通俗易懂讲解Redis的哨兵模式

Redis哨兵模式(sentinel)学习总结及部署记录(主从复制、读写分离、主从切换)

RedLock实现(红锁/联锁)

为了解决redismaster节点挂掉,follower节点未同步到锁的问题,提出了RedLock

Redis的分布式环境中,我们假设有NRedis 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+1Redis实例取到锁或者取锁时间已经超过了有效时间),客户端应该在所有的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:Redis分布式锁最牛逼的实现

【求锤得锤的故事】Redis锁从面试连环炮聊到神仙打架。

故障重启

RedLock是不能解决故障重启后带来的锁的安全性的问题。看下下面这个场景:

我们一共有 A、B、C 这三个节点。

  1. 客户端 1 在 A,B 上加锁成功。C 上加锁失败。
  2. 这时节点 B 崩溃重启了,但是由于持久化策略导致客户端 1 在 B 上的锁没有持久化下来。
  3. 客户端 2 发起申请同一把锁的操作,在 B,C 上加锁成功。
  4. 这个时候就又出现同一把锁,同时被客户端 1 和客户端 2 所持有了。

比如,RedisAOF 持久化方式默认情况下是每秒写一次磁盘,即 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 的数据被删除,内存占用还这么大?

Redis热Key

key问题,就是大流量访问Redis实例,导致出现瓶颈(IOCPU),服务受到影响。

有赞的Hermes

有赞透明多级缓存解决方案(TMC),可以实现Rediskey自动发现,并程序自动处理

Hermes-SDK 都会通过其通信模块将key访问事件异步上报给Hermes服务端集群,以便其根据上报数据进行“热点探测”。发现热key就会应用中使用本地缓存。

当缓存过期或值被修改,通过 etcd集群 推送给应用集群中其他 Hermes-SDK 节点,删除或更新本地缓存。

Redis 热 Key 发现以及解决办法

【原创】谈谈redis的热key问题如何解决

有赞透明多级缓存解决方案(TMC)

Redis的BigKey

Big Key就是某个key对应的value很大,占用的redis空间很大,本质上是大value问题。

BigKey导致的问题:

  1. Client发现Redis变慢;
  2. Redis内存不断变大引发OOM,或达到maxmemory设置值引发写阻塞或重要Key被逐出;
  3. Redis Cluster中的某个node内存远超其余node,但因Redis Cluster的数据迁移最小粒度为Key而无法将node上的内存均衡化;
  4. Key上的读请求使Redis占用服务器全部带宽,自身变慢的同时影响到该服务器上的其它服务;
  5. 删除一个大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实例上执行bgsavebgsave会触发redis的快照备份,生成rdb持久化文件,然后对dump出来的rdb文件进行分析,找到其中的大key
GitHub地址:https://github.com/sripathikrishnan/redis-rdb-tools
优点在于获取的key信息详细、可选参数多、支持定制化需求,结果信息可选择jsoncsv格式,后续处理方便,其缺点是需要离线操作,获取结果时间较长。

4、rdr

https://github.com/xueqiu/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中的bigkey问题

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 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 keyRedis 节点占用内存多,QPS 也会比较大。

字节二面:Redis 的大 Key 对持久化有什么影响?

如何删除大key

如果该大 key 是可以删除的,不要使用 DEL 命令删除,因为该命令删除过程会阻塞主线程,而是用 unlink 命令(Redis 4.0+)删除大 key,因为该命令的删除过程是异步的,不会阻塞主线程。

Redis的原子性

任何数据库都要有一套自己的事务控制机制,Redis事务是一次可以执行多个命令,它的本质是一组命令的集合。一个事务中所有的命令都会被序列化,在事务执行的过程中会按照顺序执行队列中的命令。其它客户端提交的命令请求会等到事务执行完毕再执行。

总的来说:redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

multi开启了事务,只是多次set,此时的返回结果为QUEUED,代表进入了执行队列,此时还没有执行命令,直到执行exec时才会把队列的命令依次执行。

Redislua脚本中,如果出现指令语法错误,指令参数错误的情况,这种场景下,异常的指令不执行,其他正常的指令继续执行。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 节点。

大批过期数据,如果删除策略不能解决,还有内存淘汰机制兜底.

Redis 的过期数据会被立马删除么?

过期与持久化

主从或者集群架构中,两台机器的时钟严重不同步,会有什么问题么?

key 过期信息是用 Unix 绝对时间戳表示的。

为了让过期操作正常运行,机器之间的时间必须保证稳定同步,否则就会出现过期时间不准的情况。

比如两台时钟严重不同步的机器发生 RDB 传输, slave 的时间设置为未来的 2000 秒,假如在 master 的一个 key 设置 1000 秒存活,当 Slave 加载 RDB 的时候 key 就会认为该 key 已过期,立刻删除该key,而不是等到1000秒之后删除(假设忽略传输耗时)。

Redis淘汰池

Redis3.02.8的基础上增加了一个淘汰池,淘汰池默认大小为16。在原来的策略中,每次抽取的key在删除最久未被访问的元素后即全部释放,而3.0增加了一个淘汰池(实现方式为数组),每次抽取完,都会将所有的key放入淘汰池中,然后删除数组最后一个非空元素(数组内按空闲时间由小到大排序,最后一个非空元素就是我们要删除的最久未被访问的元素),淘汰池种剩下的元素再参与下一次删除过程。

Redis:内存淘汰机制

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数据结构的示意图可以表示如下。

img

即一个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,此时触发扩容。

Redis-扩容机制

那如果突然机器掉电会怎样?

取决于 AOF 日志 sync 属性的配置,如果不要求性能,在每条写指令时都 sync 一下磁盘,就不会丢失数据。但是在高性能的要求下每次都 sync 是不现实的,一般都使用定时 sync,比如 11 次,这个时候最多就会丢失 1 秒的数据。 实际上,极端情况下,是最多丢失 2 秒的数据。因为 AOF 线程,负责每秒执行一次 fsync 操作,操作完成后,记录最后同步时间。主线程,负责对比上次同步时间,如果超过 2 秒,阻塞等待成功。

bgsave 的原理是什么?

forkcowfork 是指 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

Redlock:Redis分布式锁最牛逼的实现

冷饭新炒:理解Redisson中分布式锁的实现

redis缓存淘汰策略LRU和LFU对比与分析

LRU和LFU算法以及其在Redis中的实现

redis的过期时间和过期删除机制

Redis数据库结构/键空间/过期字典/事务/锁/持久化

Redis 缓存过期(maxmemory) 配置/算法 详解

LRU和LFU算法以及其在Redis中的实现

Redis数据库结构/键空间/过期字典/事务/锁/持久化

细说Redis分布式锁:setnx/redisson/redlock?了解一波?

redis 分布式锁的 5个坑,真是又大又深

分布式系统架构,回顾2020年常见面试知识点梳理(每次面试都会问到其中某一块知识点)

Redis笔记-持久化策略

Redis的47连环炮,试试你能看住几个

posted @ 2021-03-01 17:29  hongdada  阅读(73)  评论(0编辑  收藏  举报