Redis核心原理与应用实践(二)

一、Redis持久化

Redis的数据全部在内存里,如果突然宕机,数据就会全部丢失,因此你须有一种机制来保证Redis的数据不会因为故障而丢失,这种机制就是Redis的持久化机制。

Redis的持久化机制有两种:

  1. RDB(快照):该持久化方式会fork一个子进程,由子进程负责持久化过程(生成内存快照后写入硬盘),主进程在持久化期间仍然能够继续运行(执行IO操作及客户端命令),因此阻塞只会发生在fork子进程的时候。
  2. AOF:AOF日志存储的是Redis服务器的顺序指令序列,AOF日志只记录对内存进行修改的指令记录。一个空的Redis通过顺序执行AOF日志中的所有指令就可以恢复Redis当前实例的内存数据结构状态。

1.1 RDB(快照)原理

Redis使用操作系统的多进程COW(Copy On Write)机制来实现快照持久化。Redis在持久化时会调用glibc的函数fork产生一个子进程,快照持久化完全交给子进程来处理,父进程继续处理客户端请求。子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。这时你可以把父子进程想象成一一个连体婴儿,它们在共享身体。这是Linux操作系统的机制,为了节约内存资源,所以尽可能让它们共享起来。在进程分离的一瞬间,内存的增长几乎没有明显变化。在当后续Redis收到修改数据的命令后,内存才会有变化,具体过程参看下文。

子进程做数据持久化,不会修改现有的内存数据结构,它只是对数据结构进行遍历读取,然后序列化写到磁盘中。但是父进程不一样,它必须持续服务客户端请求,然后对内存数据结构进行不间断的修改。

这个时候就会使用操作系统的cow机制来进行数据段页面的分离。数据段是由很多操作代码段系统的页面组合而成,当父进程对其中一个页面的数据进行修改时,会将被共享的页面复制一份分离出来,然后对这个复制的页面进行修改。这时子进程相应的页面是没有变化的,还是进程产生时那一瞬间的数据。

随着父进程修改操作的持续进行,越来越多的共享页面被分离出来,内存就会持续增长,但是也不会超过原有数据内存的2倍大小。另外,Redis 实例里冷数据占的比例往往是比较高的,所以很少会出现所有的页面都被分离的情况,被分离的往往只有其中一部分页面。每个页面的大小只有4KB,一个 Redis 实例里面一般都会有成千上万个页面。

子进程因为数据没有变化,它能看到的内存里的数据在进程产生的一 瞬间就凝固了,再也不会改变,这也是为什么Redis的持久化叫“快照”的原因。接下来子进程就可以非常安心地遍历数据,进行序列化写磁盘了。
Redis核心原理与应用实践(二)_第1张图片

1.2 AOF原理

AOF日志存储的是Redis服务器的顺序指令序列,AOF日志只记录对内存进行修改的指令记录。

假设AOF日志记录了自Redis 实例创建以来所有的修改性指令序列,那么就可以通过对一个空的Redis实例顺序执行所有的指令—也就是 “重放”,来恢复Redis当前实例的内存数据结构的状态。

Redis会在收到客户端修改指令后,进行参数校验、逻辑处理,如果没问题,就立即将该指令文本存储到AOF日志中,也就是说,先执行指令才将日志存盘。这点不同于leveldb、hbase 等存储引擎,它们都是先存储日志再做逻辑处理。

Redis在长期运行的过程中,AOF 的日志会越来越长。如果实例宕机重启,重放整个AOF日志会非常耗时,导致Redis长时间无法对外提供服务,所以需要对AOF日志瘦身。

1.2.1 AOF重写

Redis提供了bgrewriteaof指令用于对AOF日志进行瘦身,其原理就是开辟一个子进程对内存进行遍历,转换成系列Rcdis的操作指令,序列化到一个新的AOF日志文件中。序列化完毕后再将操作期间发生的增量AOF日志追加到这个新的AOF日志文件中,追加完毕后就立即替代旧的AOF日志文件了,瘦身工作就完成了。此时在AOF中的指令会比原AOF指令更简短,但其表示的Redis内存数据结构是一样的。如下示例:

 # 假设服务器对键list执行了以下命令s;
127.0.0.1:6379> RPUSH list "A" "B"
(integer) 2
127.0.0.1:6379> RPUSH list "C"
(integer) 3
127.0.0.1:6379> RPUSH list "D" "E"
(integer) 5
127.0.0.1:6379> LPOP list
"A"
127.0.0.1:6379> LPOP list
"B"
127.0.0.1:6379> RPUSH list "F" "G"
(integer) 5
127.0.0.1:6379> LRANGE list 0 -1
1) "C"
2) "D"
3) "E"
4) "F"
5) "G"
127.0.0.1:6379> 

结果说明:
当前列表键list在数据库中的值就为[“C”, “D”, “E”, “F”, “G”]。要使用尽量少的命令来记录list键的状态,最简单的方式不是去读取和分析现有AOF文件的内容,,而是直接读取list键在数据库中的当前值,然后用一条RPUSH list “C” “D” “E” “F” "G"代替前面的6条命令。

使用子进程进行AOF重写的问题:子进程在进行AOF重写期间,服务器进程还要继续处理命令请求,而新的命令可能对现有的数据进行修改,这会让当前数据库的数据和重写后的AOF文件中的数据不一致。

1.2.2 修正AOF重写期间的脏数据

为了解决这种数据不一致的问题,Redis增加了一个AOF重写缓存,这个缓存在fork出子进程之后开始启用,Redis服务器主进程在执行完写命令之后,会同时将这个写命令追加到AOF缓冲区和AOF重写缓冲区
即子进程在执行AOF重写时,主进程需要执行以下三个工作:

  • 执行client发来的命令请求;
  • 将写命令追加到现有的AOF文件中;
  • 将写命令追加到AOF重写缓存中。
    Redis核心原理与应用实践(二)_第2张图片

可以保证:

  • AOF缓冲区的内容会定期被写入和同步到AOF文件中,对现有的AOF文件的处理工作会正常进行;
  • 从创建子进程开始,服务器执行的所有写操作都会被记录到AOF重写缓冲区中;

完成AOF重写之后:

当子进程完成对AOF文件重写之后,它会向父进程发送一个完成信号,父进程接到该完成信号之后,会调用一个信号处理函数,该函数完成以下工作:

  • 将AOF重写缓存中的内容全部写入到新的AOF文件中;这个时候新的AOF文件所保存的数据库状态和服务器当前的数据库状态一致;
  • 对新的AOF文件进行改名,原子的覆盖原有的AOF文件;完成新旧两个AOF文件的替换。

当这个信号处理函数执行完毕之后,主进程就可以继续像往常一样接收命令请求了。在整个AOF后台重写过程中,只有最后的“主进程写入命令到AOF缓存”和“对新的AOF文件进行改名,覆盖原有的AOF文件。”这两个步骤(信号处理函数执行期间)会造成主进程阻塞,在其他时候,AOF后台重写都不会对主进程造成阻塞,这将AOF重写对性能造成的影响降到最低。

参考:https://www.cnblogs.com/fanBlog/p/9707422.html

1.2.3 fsync(强制内存中的AOF日志内容刷新到硬盘中)

核心问题:

当程序对AOF日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的。这就意味着如果机器突然宕机,AOF日志内容可能还没有来得及完全刷到磁盘中,这个时候就会出现日志丢失。那该怎么办?

Linux的glibc 提供了fsyncCint fd)函数可以将指定文件的内容强制从内核缓存刷到磁盘。只要Redis进程实时调用fsync函数就可以保证AOF日志不丢失。但是fync是一个磁盘10操作,它很慢!如果Redis执行条指令就要fsync一次, 那么Redis高性能的地位就不保了。

所以在生产环境的服务器中,Redis 通常是每隔1s左右执行次fyne操作,这个is的周期是可以配置的。这是在数据安全性和性能之间做的一个折中, 在保持高性能的同时,尽可能使数据少丢失。

Redis同样也提供了另外两种策略,一个是永不调 用fsync-一让操作系统来决定何时同步磁盘,这样做很不安全,另一个是来一个指令就调用iyne一次店来导致非常慢。这两种策略在生产环境中基本不会使用,了 解一下即可 。

1.3 两种持久化方式的优缺点

1.3.1 RDB的优势

  • 1). 一旦采用该方式,那么你的整个Redis数据库将只包含一个文件,这对于文件备份而言是非常完美的。比如,你可能打算每个小时归档一次最近24小时的数据,同时还要每天归档一次最近30天的数据。通过这样的备份策略,一旦系统出现灾难性故障,我们可以非常容易的进行恢复。
  • 2). 对于灾难恢复而言,RDB是非常不错的选择。因为我们可以非常轻松的将一个单独的文件压缩后再转移到其它存储介质上。
  • 3).性能最大化。对于Redis的服务进程而言,在开始持久化时,它唯一需要做的只是fork出子进程,之后再由子进程完成这些持久化的工作,这样就可以极大的避免服务进程执行IO操作了。
  • 4). 相比于AOF机制,如果数据集很大,RDB的启动效率会更高。

1.3.2 RDB的劣势

  • 1).如果你想保证数据的高可用性,即最大限度的避免数据丢失,那么RDB将不是一个很好的选择。因为系统一旦在定时持久化之前出现宕机现象,此前没有来得及写入磁盘的数据都将丢失。
  • 2).由于RDB是通过fork子进程来协助完成数据持久化工作的,因此,如果当数据集较大时,可能会导致整个服务器停止服务几百毫秒,甚至是1秒钟。

1.3.3 AOF的优势

  • 1).该机制可以带来更高的数据安全性,即数据持久性。Redis中提供了3中同步策略,即每秒同步、每修改同步和不同步。事实上,每秒同步也是异步完成的,其效率也是非常高的,所差的是一旦系统出现宕机现象,那么这一秒钟之内修改的数据将会丢失。而每修改同步,我们可以将其视为同步持久化,即每次发生的数据变化都会被立即记录到磁盘中。可以预见,这种方式在效率上是最低的。至于无同步,无需多言,我想大家都能正确的理解它。
  • 2).由于该机制对日志文件的写入操作采用的是append模式,因此在写入过程中即使出现宕机现象,也不会破坏日志文件中已经存在的内容。然而如果我们本次操作只是写入了一半数据就出现了系统崩溃问题,不用担心,在Redis下一次启动之前,我们可以通过redis-check-aof工具来帮助我们解决数据一致性的问题。
  • 3).如果日志过大,Redis可以自动启用rewrite机制。即Redis以append模式不断的将修改数据写入到老的磁盘文件中,同时Redis还会创建一个新的文件用于记录此期间有哪些修改命令被执行。因此在进行rewrite切换时可以更好的保证数据安全性。
  • 4). AOF包含一个格式清晰、易于理解的日志文件用于记录所有的修改操作。事实上,我们也可以通过该文件完成数据的重建。

1.3.4 AOF的劣势

  • 1). 对于相同数量的数据集而言,AOF文件通常要大于RDB文件。RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
  • 2). 根据同步策略的不同,AOF在运行效率上往往会慢于RDB。总之,每秒同步策略的效率是比较高的,同步禁用策略的效率和RDB一样高效。

二者选择的标准,就是看系统是愿意牺牲一些性能,换取更高的缓存一致性(aof),还是愿意写操作频繁的时候,不启用备份来换取更高的性能,待手动运行save的时候,再做备份(rdb)。rdb这个就更有些 eventually consistent的意思了。

参考:https://www.cnblogs.com/chenliangcl/p/7240350.html

1.3 Redis4.0混合持久化

重启Redis时,我们很少使用RDB来恢复内存状态,因为会丢失大量数据。我们通常使用AOF日志重放,但是重放AOF日志相对于使用rdb来说要慢很多,这样在Redis实例很大的时候,启动需要花费很长的时间。

Redis 4.0为了解决这个问题,带来了一个新的持久化选项一混合持久化。 即将RDB文件的内容和增量的AOF日志文件存在一起。这里的AOF日志不再是全量的日志,而是自持久化开始到持久化结束的这段时间发生的增量AOF日志,通常这部分AOF曰志很小。

于是在Redis重启的时候,可以先加载RDB的内容,然后再重放增量AOF日志,就可以完全替代之前的AOF全量文件重放,重启效率因此得到大幅提升。

二、Redis的过期策略及内存淘汰机制

Redis所有的数据都可以设置过期时间,时间一到,就会被自动删除。而在删除过期数据时,Redis采用了何种策略释放其内存空间?另外,Redis作为单线程程序在删除大量过期数据时是否会过于繁忙而造成线上读写指令卡顿?

2.1 Redis的过期策略

Redis会将每个设置了过期时间的key放入一个独立的字典中,在正常模式下(Redis内存未超过物理内存限制)会采用两种方式释放过期数据占用的内存:

  • 定时扫描:redis默认是每隔100ms就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。
  • 惰性删除:在客户端访问这个key时,Redis对key的过期时间进行检查,如果过期了就立即进行删除。如果说定时删除时集中处理,那么惰性删除就是零散处理。

定时扫描策略详解

Redis默认每100ms进行一次过期扫描,过期扫描不会遍历字典中所有的key,而是采用了一种简单的贪心策略,步骤如下:

  1. 从过期字典中随机选出20个key。
  2. 删除这20↑key中已经过期的key。
  3. 如果过期的key的比例超过1/4那就重复步骤 1。

同时为了保证过期扫描不会出现循环过度,导致线程卡死的现象,算法还增加了扫描时间的上限,默认不会超过25ms。

假设一个大型的Redis实例中所有的key在同一时间过期了,会出现怎样的结果呢?

毫无疑问,Redis会持续扫描过期字典(循环多次),直到过期字典中过期的变得稀疏。才会停止(循环次数用显下降)。这就会导致线上读写请来出现明显的卡顿现象。导致这种卡顿的另外一种原因是内存管理器需要频集回收内存页,这也会产生一定的CPU消耗。

当客户端请求到来时,服务器如果正好进入过期扫描状态,客户端的请求将会等待至少25ms后才会进行处理,如果客户端将超时时间设置得比较短,比如10ms,那么就会出现大量的链接因为超时而关闭,业务端就会出现很多异常,而且这时你还无法从Redis的slowlog中看到慢查询记录,因为慢查询指的是逻辑处理过程慢,不包含等待时间。

所以业务开发人员一 定要注意过期时间,如果有大批量的key过期,要给过期时间设置一个随机范围,而不能全部在同一时间过期。

#在目标过期时间上增加一天的随机时间
redis.expire at (key, random.randint (86400) + expire_ts)

在一 些活动系统中, 因为活动是一期会,下一期活动举办时,前面几期活动的很多数据都可以丢弃了,所以需要给相关的活动数据设置一个过期时间, 以减少不必要的Redis内存占用。如果不加注意,你可能会将过期时间设置为活动结束时间再增加一个常量的冗余时间,如果参与活动的人数太多,就会导致大量的key同时过期。通过将过期时间随机化总是能很好地解决这个问题,希望读者们今后能避免这样的错误。

2.2 Redis的内存淘汰

当Redis内存超出物理内存限制时,内存的数据会开始和磁盘产生频繁的交换(swap)。交换会让Radi的性能急用下降,对于访问量比较大的Redis来说,这样龟速的存取效率基本上等于不可用。

在生产环境中我们是不允许Reis出现交换行为的,为了限制最大使用内存,Redis提供了配置参数maxmemony来限制内存超出期望大小。

实际内存超出maxmemony时(注意和过期策略的使用场景区别),Redis 提供了几种可选策略( maxmoypoliy)来让用户自己决定该如何腾出新的空间以继续提供读写服务。

  1. noeviction:不会继续服务写请求(del 请求可以继续服务),读请求可以继续进行。这样可以保证不会丢失数据,但是会让线上的业务不能持续进行。这是默认的淘汰策略。
  2. volatile-lru:尝试淘汰设置了过期时间的key, 最少使用的key优先被淘汰。没有设置过期时间的key不会被淘汰,这样可以保证需要持久化的数据不会突然丢失。
  3. volatile-tt:跟上面几乎一样,不过淘汰的策略不是LRU, 而是比较key的剩余寿命ttl的值,ttl 越小越优先被淘汰。
  4. volatile-random: 跟上面几乎一样, 不过淘汰的key是过期key集合中随机的key。
  5. allkeys-lru:区别于volaile-ru, 这个策略要淘汰的key对象是全体的key集合,而不只是过期的key集合。这意味着一些没有设 置过期时间的key也会被淘汰。
  6. allkeys-random:跟上面几乎样,不过淘汰的key是随机的key.

volatile-xxx策略只会针对带过期时间的key进行淘汰,allkeys-xxx策略会对所有的key进行淘汰。如果你只是拿Redis做缓存,那么应该使用allkeys-xxx策略,客户端写缓存时不必携带过期时间。如果你还想同时使用Redis的持久化功能,那就使用volatile-xxx策略,这样可以保留没有设置过期时间的key,它们是永久的key, 不会被LRU算法淘汰。

LRU算法

实现LRU算法除了需要key/value字典外,还需要附加一个链表,链表中的元素按照一定的顺序进行排列。当空间满的时候,会踢掉链表尾部的元素。当字典的某个元素被访问时,它在链表中的位置会被移动到表头,所以链表的元素排列顺序就是元素最近被访问的时间顺序。
位于链表尾部的元素就是不被重用的元素,所以会被踢掉。位于表头的元素就是最近刚刚被人用过的元素,所以暂时不会被踢。

近似LRU算法

Redis使用的是一种近似LRU算法,它跟LRU算法还不太一样。之所以不使用LRU算法,是因为其需要消耗大量的额外内存,需要对现有的数据结构进行较大的改造。近似LRU算法很简单,在现有数据结构的基础上使用随机采样法来淘汰元素,能达到和LRU算法非常近似的效果。Redis为实现近似LRU算法,给每个key增加了一个额外的小字段,这个字段的长度是24个bit,也就是最后一次被访问的时间戳。

上一节提到处理key过期方式分为集中处理和懒惰处理, LRU淘汰不一样,它的处理方式只有懒惰处理。当Redis执行写操作时,发现内存超出maxmemory,就会执行一次LRU淘汰算法。这个算法也很简单,就是随机采样出5(该数量可以设置)个key,然后淘汰掉最旧的key,如果淘汰后内存还是超出maxmemory,那就继续随机采样淘汰,直到内存低于maxmemory为止。

如何采样要看maxmemory-policy的设置, 如果是 allkeys,就从所有的key字典中随机采样,如果是volatile,就从带过期时间的key字典中随机采样。每次采样多少个key取决于maxmemory-samples的设置,默认为5。

下图所示是随机LRU算法和严格LRU算法的效果对比图。
Redis核心原理与应用实践(二)_第3张图片
图中绿色部分是新加入的key,深灰色部分是老旧的key,浅灰色部分是通过LRU算法淘汰掉的key。从图中可以看出采样数量越大,近似LRU算法的效果越接近严格LRU算法。同时Redis 3.0在算法中增加了淘汰池,进一步提升了近似LRU算法的效果。

淘汰池是一个数组,它的大小是maxmemory_samples,在每一次淘汰循环中,新的随机得出的 key列表会和淘汰池中的key列表进行融合, 淘汰掉最旧的一个key后,保留剩余较旧的key列表放入淘汰池中留待下一个循环。

2.3 删除大对象—懒惰删除

一直以来我们都知道Redis是单线程的,单线程为Redis带来了代码的简洁性和丰富多样的数据结构。不过Redis内部实际上并不是只有一个主线程,它还有几个异步线程专门用来处理一些耗时的操作。

Redis为什么使用懒惰删除?

删除指令del会直接释放对象的内存,大部分情况下,这个指令非常快,没有明显延迟。不过如果被删除的key是一个非常大的对象,比如一个包含了上千万个元素的hash,那么删除操作就会导致单线程卡顿。

Redis为了解决这个卡顿问题,在4.0版本里引入了unlink指令,它能对删除操作进行懒处理,丢给后台线程来异步回收内存。

>unlink key

三、Redis部分容器的实现分析

1.字符串
先声明字符串在Redis中的存储形式,RedisObject+SDS,两种存储格式(embstr、raw)可能要分开讲。

讲SDS的具体结构,尤其是其中的capacity及len元素,了解了Capacity元素就可以说字符串的扩容机制了。接着就可以提到为什么长度超过44字节时就需要改变存储形式。

讲RedisObject的结构,从而可以分析出上述问题的结论。

2.字典

Redis中万物皆字典,hash、key-value、以及配置过期时间的key集合。
字典包含两个hashtable,一新一旧。hashtable结构与java.util.HashTable几乎完全一样,在扩容时有所区别,hashtable使用渐进式rehash进行扩容,一次只copy一个桶中的数据。
https://blog.csdn.net/cqk0100/article/details/80400811

你可能感兴趣的:(Redis)