Redis原理篇

2021-07-14 15:27:25 浏览数 (1)

Redis原理篇

1.Redis 的发布订阅模式

1.发布订阅模式

1.1列表的局限

前面我们说通过队列的rpush和blpop可以实现消息队列(队尾进队列出),没有任何元素可以弹出的时候,连接会被阻塞。 但是基于list实现的消息队列,不支持一对多的消息分发,相当于只有一个消费者。如果要实现一对多的消息分发,怎么办?

1.2发布订阅模式

除了通过list实现消息队列外,redis还提供了发布订阅的功能。

  • 订阅频道 消息的生产者和消费者是不同的客户端,连接到同一个redis的服务。通过什么对象把生产者和消费者关联起来呢?在RabbitMQ里面叫做Queue,在kafka里面叫做topic。Redis的模型里面叫channel(频道)。 订阅者可以订阅一个或者多个channel。消息的发布者可以给指定的channel发布消息。只要有消息到达了channel,所有订阅了这个channel的订阅者都会收到这条消息。

1.3.发布订阅基本命令

订阅者订阅频道:可以一次订阅多个,比如这个客户端就订阅了3个频道,频道不要声明创建

1.4.按规则订阅频道

按照规则(pattern)订阅频道:支持?和*占位符(?代表一个字符,*代表0个或者多个字符)。

1.5.发布消息

  • **Redis** 中可以通过队列的 **rpush/lpop** 来实现消息队列(队尾进,队头出),但消费者需要不停地调用 **lpop** 查看 **list** 中是否有等待处理的消息(通过循环)。为了减少通信消耗可以使用 **Thread#sleep()** 方法睡一段时间再进行消费。但这样会出现一些问题:
    • 当生产者生产消息的速度大于消费者消费消息的速度,**List** 会占用大量的内存;
    • 消息的实时性降低,**list** 提供了一个阻塞的命令(**blpop**); 当没有任何元素弹出时,连接会被阻塞;
    • 基于 **list** 实现的消息队列,不支持一对多的消息分发。
  • 除了通过 **list** 的方式来实现消息队列外,在 **Redis** 中还提供了一组命令实现发布/订阅模式;该方式实现了解耦的方式,因为生产者和消费者没有直接的关联,接受者也不需要持续尝试获取消息。
  • 在发布/订阅模式中有很多的频道 **channel**,订阅者可以订阅一个或多个频道;消息的生产者可以给指定的频道发送消息,当消息到达了频道时,所有订阅了该频道的订阅者都会接收到这条消息。但因为消息已经从队列中移除的原因,发出去的消息不会被持久化,所以消费者只能接收到它开始订阅这个频道之后发布的信息。
代码语言:javascript复制
# 订阅者订阅频道,客户端订阅了三个频道
subscribe channel-one channel-two channel-three

# 发布者向指定频道发布消息(不支持一次向多个频道发送消息)
publish channel-one hello

# 取消订阅(在非订阅状态下使用)
unsubscribe channel-one

# 按照占位符匹配: ? 表示单个字符,* 表示 0 个或多个字符
# 关注销售职位
psubscribe *sales

# 关注所有职位
psubscribe job*

# 关注开发职位
psubscribe job-developer

# 生产者发布消息
publish job-sales phone 
publish job-teaching math  
publish job-developer java

转账

2.Redis 的事务

为什么需要事务

我们知道Redis 的单个命令是原子性的(比如 get set mget mset),如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就需要用到事务。 例如用setnx实现分布式锁,我们先set,然后设置对key设置expire,防止del发生异常的时候锁不会被释放,业务处理完了以后再del,这三个动作我们就希望它们作为一组命令执行。

Redis的事务有两个特点:

  1. 按进入队列的顺序执行。
  2. 不会受到其他客户端的请求的影响。

事务的用法

通过multi的命令开启事务。事务不能嵌套,多个multi命令效果一样。 multi执行后,客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中, 当 exec 命令被调用时, 所有队列中的命令才会被执行。 通过exec的命令执行事务。如果没有执行exec,所有的命令都不会被执行。 可以调用discard可以清空事务队列,放弃执行。

watch命令

在Redis中还提供了一个watch命令。 它可以为 Redis 事务提供 CAS 乐观锁行为(Check and Set / Compare andSwap),也就是多个线程更新变量的时候,会跟原值做比较,只有它没有被其他线程修改的情况下,才更新成新的值。 我们可以用watch监视一个或者多个key,如果开启事务之后,至少有一个被监视key键在 exec 执行之前被修改了, 那么整个事务都会被取消(key提前过期除外)。可以用unwatch取消。

事务的回滚

  • 我们把事务执行遇到的问题分成两种
  1. 在执行exec之前发生错误

在这种情况下事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。 2. 一种是在执行exec之后发生错误。

最后我们发现setk11的命令是成功的,也就是在这种发生了运行时异常的情况下,只有错误的命令没有被执行,但是其他命令没有受到影响。

这个显然不符合我们对原子性的定义,也就是我们没办法用Redis 的这种事务机制来实现原子性,保证数据的一致,为什么?

  1. redis追求的就是’快’、‘轻量’,所以如果内部还需要进行回滚势必会增加复杂度。
  2. redis中存储的数据大部分不会特别重要,对准确性的要求不会过于高。
  3. redis的错误通常不是因为数据的问题,而是程序员自己的问题,这种问题在测试的时候可以发现,在真正的生成环境不会出现此类问题。

Redis事务不支持回滚

为什么不回滚? 官方的解释:

  • Redis命令只会因为错误的语法而失败,也就是说,从实用性的角度来说,失败的命令是由代码错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中(这个是程序员的锅)。
  • 因为不需要对回滚进行支持,所以Redis的内部可以保持简单且快速。需要知道的是:回滚不能解决代码的问题(程序员的锅必须程序员来背)。

3.Redis 中使用 Lua 脚本语言

定义

Lua/ˈluə/是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。 使用Lua脚本来执行Redis命令的好处:

  1. 一次发送多个命令,减少网络开销。
  2. Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。
  3. 对于复杂的组合命令,我们可以放在文件中,可以实现程序之间的命令集复用。

Redis中执行Lua脚本

使用eval方法,语法格式:

redis> eval lua-script key-num [keyl key2 key3 …] [value 1 value2 value3 …]

  • eval代表执行Lua语言的命令。
  • lua-script代表Lua语言脚本内容。
  • key-num表示参数中有多少个key,需要注意的是Redis中key是从1开始的,如果没有key的参数,那么写0。
  • [keyl key2 key3…]是key作为参数传递给Lua语言,也可以不填,但是需要和key-num的个数对应起来。
  • [valuel value2 value3…」这些参数传递给Lua语言,它们是可填可不填的。

示例,返回一个字符串,0个参数:

代码语言:javascript复制
redis> eval ”return 'Hello World"' 0

实际上,Lua脚本在Redis里面真正的用途是用来执行Redis命令。

在Lua脚本中执行Redis命令

命令格式

使用 redis.call(command, key [paraml, param2…])进行操作。语法格式:

代码语言:javascript复制
redis. call (command, key [param l,param2...])
  • command 是命令,包括 set、get> del 等。
  • key是被操作的键。
  • paraml,param2…代表给 key 的参数。

简单的案例,让Lua脚本执行set snail 2673 (Redis客户端执行):

代码语言:javascript复制
eval return redis.call('set', 'snail','2573')  0

这种方式是写死值的,当然也可以用传参的方式:

代码语言:javascript复制
eval return redis.call('set' .KEYS[1],ARGV[1])" 1 snail xiaoming

如果KEY和ARGV有多个,继续往后面加就是了。 在redis-cli中直接写Lua脚本不够方便,也不能实现编辑和复用,通常我们会把Lua 脚本放在文件里面,然后执行这个文件。

Lua脚本文件

创建Lua脚本文件:

代码语言:javascript复制
cd /usr/local/soft/redis-6.0.9/src
vim snail.lua

Lua脚本内容,先赋值,再取值:

代码语言:javascript复制
redis. call('set', 'snail',lua1')
return redis.call('get','snail')

调用脚本文件:

代码语言:javascript复制
cd /usr/local/soft/redis-6.0.9/src
redis-cli --eval snail.lua 0

案例:对IP进行限流

需求:每个用户在X秒内只能访问Y次。

设计思路: 1、首先是数据类型。用String的key记录IP,用value记录访问次数。几秒钟和几次要用参数动态传进去。 拿到IP以后,对IP 1。如果是第一次访问,对key设置过期时间(参数1)。否则判断次数,超过限定的次数(参数2),返回0。如果没有超过次数则返回1。超过时间, key过期之后,可以再次访问。 KEY[1]是IP, ARGV[1]是过期时间X, ARGV[2]是限制访问的次数Y。

代码语言:javascript复制
-- ip_limit.lua
-- ip限流,对某个IP频率进行限制,6秒钟访问10次
local num=redis.call('incr',KEYS[1])
if tonumber(num)== 1 then
        redis.call('expire',KEYS[1],ARGV[1])
        return 1
      elseif tonumber(num)>tonumber(ARGV[2]) then
return 0
else
return 1
end

6秒钟内限制访问10次,调用测试(连续调用10次):redis-cli --eval ipjimit.lua app:ip:limit: 192.168.8.111,6 10

  • app:ip:limit:192.168.8.111是key值,后面是参数值,中间要加上一个空格和 —个逗号,再加上一个空格。 即:redis-cli -eval [lua 脚本][key…]空格,空格[args…]
  • 多个参数之间用空格分割。

缓存Lua脚本

为什么要缓存?

代码语言:javascript复制
在Lua脚本比较长的情况下,如果每次调用脚本都需要把整个脚本传给Redis服务 端,会产生比较大的网络开销。为了解决这个问题,Redis可以缓存Lua脚本并生成SHA1 摘要码,后面可以直接通过摘要码来执行Lua脚本。

如何缓存? 这里面涉及到两个命令,首先是在服务端缓存山a脚本生成一个摘要码,用script load命令。

代码语言:javascript复制
script load "return ‘Hello World’“

第二个命令是通过摘要码执行缓存的脚本:

代码语言:javascript复制
evalsha ”470877a599ac74fbfda41caa908de682c5fc7d4b” 0

自乘案例

Redis有incrby这样的自增命令,但是没有自乘,比如乘以3,乘以5。

代码语言:javascript复制
set num 2

我们可以写一个自乘的运算,让它乘以后面的参数:

代码语言:javascript复制
local curVal = redis.call(''get", KEYS[1]) 
if curVal == false then
  curVal = 0
else
  curVal = tonumber(curVal)
end
curVal = curVal * tonumber(ARGV[ 1 ]) 
redis.call("set", KEYS[1], curVal) 
return curVal

把这个脚本变成单行,语句之间使用分号隔开:

代码语言:javascript复制
local curVal = redis.call("getL KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVal

script load命令(Redis客户端执行)

代码语言:javascript复制
script load local curVal = redis.call("get", KEYS[1]); if curVal == false then curVal = 0 else curVal = tonumber(curVal) end; curVal = curVal * tonumber(ARGV[1]); redis.call("set", KEYS[1], curVal); return curVar

“be4f93d8a5379e5e5b768a74e77c8a4eb0434441” 调用:

代码语言:javascript复制
evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6

使用Lua脚本需要注意一个问题—脚本超时。

脚本超时

Redis的指令执行本身是单线程的,这个线程还要执行客户端的Lua脚本,如果Lua 脚本执行超时或者陷入了死循环,是不是没有办法为客户端提供服务了呢?

代码语言:javascript复制
eval  'while(true) do end' 0

还真是的。它会导致其他的命令都会进入等待状态。 当然,这种小问题,antirez在设计的时候引入lua脚本的时候就考虑到了。 首先,脚本执行有一个超时时间,默认为5秒钟。

代码语言:javascript复制
lua-time-limit 5000

超过5秒钟,其他客户端的命令不会等待,而是直接会返回"BUSY"错误。 但是这样也不行,不能一直拒绝其他客户端的命令执行吧。在提示里面我们也看到了, 有两个命令可以使用,第一个是script kill,中止脚本的执行。

代码语言:javascript复制
script kill

但是需要注意:并不是所有的lua脚本执行都可以kill。如果当前执行的Lua脚本对 Redis的数据进行了修改(SET、DEL等),那么通过script kill命令是不能终止脚本运 行的。

代码语言:javascript复制
eval  "redis.call('set','snail','666') while true do end" 0

这时候执行script kill会返回UNKILLABLE错误。为什么要这么设计?为什么包含修改的脚本不能中断?因为要保证脚本运行的原子性。如果脚本执行了一部分被终止, 那就违背了脚本原子性的目标。 遇到这种情况,只能通过shutdown nosave命令,直接把Redis服务停掉。 正常关机是 shutdown。 shutdown nosave 和 shutdown 的区别在于 shutdown nosave不会进行持久化操作,意味着发生在上一次快照后的数据库修改都会丢失。 总结:如果我们有一些特殊的需求,可以用Lua来实现,但是要注意那些耗时的操作。

4.Redis 到底有多快?

https://redis.io/topics/benchmarks

代码语言:javascript复制
[root@bogon src]# pwd
/usr/local/soft/redis-5.0.5/src
[root@bogon src]# ./redis-benchmark -t set,lpush -n 100000 -q	

结果(本地虚拟机): SET: 87260.03 requests per second —— 每秒钟处理8万多次 set 请求 LPUSH: 89525.52 requests per second —— 每秒钟处理8万多次 lpush 请求

代码语言:javascript复制
[root@bogon src]# ./redis-benchmark -n 100000 -q script load "redis.call('set','foo','bar')"
script load redis.call('set','foo','bar'): 90991.81 requests per second

结果(本地虚拟机): script load redis.call(‘set’,‘foo’,‘bar’): 90991.81 requests per second —— 每秒钟 9万次 lua 脚本调用

根据官方的数据,Redis 的 QPS 可以达到 10 万左右(每秒请求数)。

Redis为什么这么快?

总结:1)纯内存结构、2)单线程、3)多路复用

内存

KV 结构的内存数据库,时间复杂度 O(1)。 第二个,要实现这么高的并发性能,是不是要创建非常多的线程?恰恰相反,Redis 是单线程的。

单线程

单线程有什么好处呢?

  1. 没有创建线程、销毁线程带来的消耗
  2. 避免了上线文切换导致的 CPU 消耗
  3. 避免了线程之间带来的竞争问题,例如加锁释放锁死锁等等
异步非阻塞

异步非阻塞 I/O,多路复用处理并发连接。

Redis为什么是单线程的?

不是白白浪费了 CPU 的资源吗? https://redis.io/topics/faq#redis-is-single-threaded-how-can-i-exploit-multiple-cpu–cores 因为单线程已经够用了,CPU 不是 redis 的瓶颈。Redis 的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且 CPU 不会成为瓶颈,那就顺理成章地采用单线程的方案了。

单线程为什么这么快?

因为 Redis 是基于内存的操作,我们先从内存开始说起。

虚拟存储器(虚拟内存 Vitual Memory)

名词解释:主存:内存; 辅存:磁盘(硬盘)

计算机主存(内存)可看作一个由M个连续的字节大小的单元组成的数组,每个字节有一个唯一的地址,这个地址叫做物理地址(PA)。早期的计算机中,如果CPU需要内存,使用物理寻址,直接访问主存储器。

这种方式有几个弊端:

  1. 在多用户多任务操作系统中,所有的进程共享主存,如果每个进程都独占一块物理地址空间,主存很快就会被用完。我们希望在不同的时刻,不同的进程可以共用同一块物理地址空间。
  2. 如果所有进程都是直接访问物理内存,那么一个进程就可以修改其他进程的内存数据,导致物理地址空间被破坏,程序运行就会出现异常。

为了解决这些问题,我们就想了一个办法,在 CPU 和主存之间增加一个中间层。CPU 不再使用物理地址访问,而是访问一个虚拟地址,由这个中间层把地址转换成物理地址, 最终获得数据。这个中间层就叫做虚拟存储器(Virtual Memory)。 具体的操作如下所示:

在每一个进程开始创建的时候,都会分配一段虚拟地址,然后通过虚拟地址和物理地址的映射来获取真实数据,这样进程就不会直接接触到物理地址,甚至不知道自己调用的哪块物理地址的数据。 目前,大多数操作系统都使用了虚拟内存,如Windows系统的虚拟内存、Linux 系统的交换空间等等。Windows的虚拟内存(pagefile.sys)是磁盘空间的一部分。

在 32 位的系统上,虚拟地址空间大小是 2^32bit=4G。

在 64 位系统上,最大虚拟地址空间大小是多少?是不是 2^64bit=1024*1014TB=1024PB=16EB?实际上没有用到 64 位,因为用不到这么大的空间,而且会造成很大的系统开销。Linux 一般用低 48 位来表示虚拟地址空间,也就是 2^48bit=256T。

代码语言:javascript复制
cat /proc/cpuinfo

address sizes : 40 bits physical, 48 bits virtual 实际的物理内存可能远远小于虚拟内存的大小。 总结:引入虚拟内存,可以提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单。并且可以对物理内存进行隔离,不同的进程操作互不影响。还可以通过把同一块物理内存映射到不同的虚拟地址空间实现内存共享。

用户空间和内核空间

为了避免用户进程直接操作内核,保证内核安全,操作系统将虚拟内存划分为两部分,一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。

内核是操作系统的核心,独立于普通的应用程序,可以访问受保护的内存空间,也 有访问底层硬件设备的权限。 内核空间中存放的是内核代码和数据,而进程的用户空间中存放的是用户程序的代码和数据。不管是内核空间还是用户空间,它们都处于虚拟空间中,都是对物理地址的映射。 在 Linux 系统中, 内核进程和用户进程所占的虚拟内存比例是 1:3。

当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。 进程在内核空间以执行任意命令,调用系统的一切资源;在用户空间只能执行简单的运算,不能直接调用系统资源,必须通过系统接口(又称 system call),才能向内核发出指令。 top 命令:

us 代表 CPU 消耗在 User space 的时间百分比; sy 代表 CPU 消耗在 Kernel space 的时间百分比。

进程切换(上下文切换)

什么是进程切换?

**

多任务操作系统是怎么实现运行远大于 CPU 数量的任务个数的?当然,这些任务实际上并不是真的在同时运行,而是因为系统通过时间片分片算法,在很短的时间内,将 CPU 轮流分配给它们,造成多任务同时运行的错觉。 为了控制进程的执行,内核必须有能力挂起正在 CPU 上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。 什么叫上下文? 在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器(ProgramCounter),这个叫做 CPU 的上下文。 而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。 在切换上下文的时候,需要完成一系列的工作,这是一个很消耗资源的操作。

进程的阻塞

正在运行的进程由于提出系统服务请求(如 I/O 操作),但因为某种原因未得到操作系统的立即响应,该进程只能把自己变成阻塞状态,等待相应的事件出现后才被唤醒。 进程在阻塞状态不占用 CPU 资源。

文件描述符 FD

Linux 系统将所有设备都当作文件来处理,而 Linux 用文件描述符来标识每个文件对象。 文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引, 用于指向被打开的文件,所有执行 I/O 操作的系统调用都通过文件描述符;文件描述符是一个简单的非负整数,用以表明每个被进程打开的文件。 Linux 系统里面有三个标准文件描述符。 0:标准输入(键盘);1:标准输出(显示器);2:标准错误输出(显示器)。

传统 I/O 数据拷贝

以读操作为例: 当应用程序执行 read 系统调用读取文件描述符(FD)的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据。如果数据不存在,则先将数据从磁盘加载数据到内核缓冲区中,再从内核缓冲区拷贝到用户进程的页内存中。(两次拷贝,两次 user 和 kernel 的上下文切换)。

I/O 的阻塞到底阻塞在哪里?

Blocking I/O

当使用 read 或 write 对某个文件描述符进行过读写时,如果当前 FD 不可读,系统就不会对其他的操作做出响应。从设备复制数据到内核缓冲区是阻塞的,从内核缓冲区拷贝到用户空间,也是阻塞的,直到 copy complete,内核返回结果,用户进程才解除 block 的状态。

为了解决阻塞的问题,我们有几个思路。

  1. 在服务端创建多个线程或者使用线程池,但是在高并发的情况下需要的线程会很多,系统无法承受,而且创建和释放线程都需要消耗资源。
  2. 由请求方定期轮询,在数据准备完毕后再从内核缓存缓冲区复制数据到用户空间 (非阻塞式 I/O),这种方式会存在一定的延迟。
I/O 多路复用(I/O Multiplexing)

I/O 指的是网络 I/O。 多路指的是多个 TCP 连接(Socket 或 Channel)。 复用指的是复用一个或多个线程。

它的基本原理就是不再由应用程序自己监视连接,而是由内核替应用程序监视文件描述符。 客户端在操作的时候,会产生具有不同事件类型的 socket。在服务端,I/O 多路复用程序(I/O Multiplexing Module)会把消息放入队列中,然后通过文件事件分派器(File event Dispatcher),转发到不同的事件处理器中。

多路复用有很多的实现,以 select 为例,当用户进程调用了多路复用器,进程会被阻塞。内核会监视多路复用器负责的所有 socket,当任何一个 socket 的数据准备好了, 多路复用器就会返回。这时候用户进程再调用 read 操作,把数据从内核缓冲区拷贝到用户空间。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符, 而这些文件描述符(套接字描述符)其中的任意一个进入读就绪(readable)状态,select() 函数就可以返回。 Redis 的多路复用, 提供了 select, epoll, evport, kqueue 几种选择,在编译的时 候来选择一种。源码 ae.c

evport 是 Solaris 系统内核提供支持的; epoll 是 LINUX 系统内核提供支持的; kqueue 是 Mac 系统提供支持的; select 是 POSIX 提供的,一般的操作系统都有支撑(保底方案); 源码 ae_epoll.c、ae_select.c、ae_kqueue.c、ae_evport.c

内存回收

Reids 所有的数据都是存储在内存中的,在某些情况下需要对占用的内存空间进行回收。内存回收主要分为两类,一类是 key 过期,一类是内存使用达到上限(max_memory) 触发内存淘汰。

过期策略

要实现 key 过期,我们有几种思路。

定时过期(主动淘汰)

每个设置过期时间的 key 都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的 CPU 资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。

惰性过期(被动淘汰)

只有当访问一个 key 时,才会判断该 key 是否已过期,过期则清除。该策略可以最大化地节省 CPU 资源,却对内存非常不友好。极端情况可能出现大量的过期 key 没有再次被访问,从而不会被清除,占用大量内存。 例如 String,在 getCommand 里面会调用 expireIfNeeded

代码语言:javascript复制
server.c expireIfNeeded(redisDb *db, robj *key)

第二种情况,每次写入 key 时,发现内存不够,调用 activeExpireCycle 释放一部分 内存。

代码语言:javascript复制
expire.c activeExpireCycle(int type)
定期过期

每隔一定的时间,会扫描一定数量的数据库的 expires 字典中一定数量的 key,并清除其中已过期的 key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时,可以在不同情况下使得 CPU 和内存资源达到最优的平衡效果。 Redis 中同时使用了惰性过期和定期过期两种过期策略。

淘汰策略

Redis 的内存淘汰策略,是指当内存使用达到最大内存极限时,需要使用淘汰算法来决定清理掉哪些数据,以保证新数据的存入。

最大内存设置

redis.conf 参数配置:

代码语言:javascript复制
# maxmemory <bytes>

如果不设置 maxmemory 或者设置为 0,64 位系统不限制内存,32 位系统最多使 用 3GB 内存。 动态修改:

代码语言:javascript复制
redis> config set maxmemory 2GB
淘汰策略

https://redis.io/topics/lru-cache redis.conf

代码语言:javascript复制
# maxmemory-policy noeviction
代码语言:javascript复制
# volatile-lru -> Evict using approximated LRU among the keys with an expire set. 
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU among the keys with an expire set. 
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key among the ones with an expire set. 
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
# noeviction -> Don't evict anything, just return an error on write operations.

先从算法来看: LRU,Least Recently Used:最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。 LFU,Least Frequently Used,最不常用,4.0 版本新增。 random,随机删除。

定时任务:刷新全局时间

策略

含义

volatile-lru

根据 LRU 算法删除设置了超时属性(expire)的键,直到腾出足够内存为止。如果没有可删除的键对象,回退到 noeviction 策略。

allkeys-lru

根据 LRU 算法删除键,不管数据有没有设置超时属性,直到腾出足够内存为止。

volatile-lfu

在带有过期时间的键中选择最不常用的。

allkeys-lfu

在所有的键中选择最不常用的,不管数据有没有设置超时属性。

volatile-random

在带有过期时间的键中随机选择。

allkeys-random

随机删除所有键,直到腾出足够内存为止。

volatile-ttl

根据键值对象的 ttl 属性,删除最近将要过期数据。如果没有,回退到 noeviction 策略。

noeviction

默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息(error)OOM command not allowed when used memory,此时 Redis 只响应读操作。

如果没有符合前提条件的 key 被淘汰,那么 volatile-lru、volatile-random 、 volatile-ttl 相当于 noeviction(不做内存回收)。 动态修改淘汰策略:

代码语言:javascript复制
redis> config set maxmemory-policy volatile-lru

建议使用 volatile-lru,在保证正常服务的情况下,优先删除最近最少使用的 key。

LRU 淘汰原理 问题:如果基于传统 LRU 算法实现 Redis LRU 会有什么问题? 需要额外的数据结构存储,消耗内存。Redis LRU 对传统的 LRU 算法进行了改良,通过随机采样来调整算法的精度。

如果淘汰策略是 LRU,则根据配置的采样值 maxmemory_samples(默认是 5 个),随机从数据库中选择 m 个 key, 淘汰其中热度最低的 key 对应的缓存数据。所以采样参数m配置的数值越大, 就越能精确的查找到待淘汰的缓存数据,但是也消耗更多的CPU计算,执行效率降低。

问题:如何找出热度最低的数据?

Redis 中所有对象结构都有一个 lru 字段, 且使用了 unsigned 的低 24 位,这个字段用来记录对象的热度。对象被创建时会记录 lru 值。在被访问的时候也会更新 lru 的值。但不是获取系统当前的时间戳,而是设置为全局变量server.lruclock 的值。 源码:server.h

代码语言:javascript复制
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;

server.lruclock 的值怎么来的?

Redis中有个定时处理的函数serverCron,默认每100毫秒调用函数 updateCachedTime 更新一次全局变量的server.lruclock的值,它记录的是当前unix时间戳。

源码:server.c

代码语言:javascript复制
void updateCachedTime(void) {
time_t unixtime = time(NULL);
atomicSet(server.unixtime,unixtime);
server.mstime = mstime();
struct tm tm;
localtime_r(&server.unixtime,&tm);
server.daylight_active = tm.tm_isdst;
}

问题:为什么不获取精确的时间而是放在全局变量中?不会有延迟的问题吗?

这样函数 lookupKey 中更新数据的 lru 热度值时,就不用每次调用系统函数 time,可以提高执行效率。

OK,当对象里面已经有了 LRU 字段的值,就可以评估对象的热度了。 函数 estimateObjectIdleTime 评估指定对象的 lru 热度,思想就是对象的 lru 值和全局的 server.lruclock 的差值越大(越久没有得到更新), 该对象热度越低。

源码 evict.c

代码语言:javascript复制
unsigned long long estimateObjectIdleTime(robj *o) {
unsigned long long lruclock = LRU_CLOCK();
if (lruclock >= o->lru) {
return (lruclock - o->lru) * LRU_CLOCK_RESOLUTION;
} else {
return (lruclock   (LRU_CLOCK_MAX - o->lru)) *
LRU_CLOCK_RESOLUTION;
}
}

server.lruclock 只有 24 位,按秒为单位来表示才能存储 194 天。当超过 24bit 能表示的最大时间的时候,它会从头开始计算。

server.h

代码语言:javascript复制
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) / Max value of obj->lru /

在这种情况下,可能会出现对象的 lru 大于 server.lruclock 的情况,如果这种情况出现那么就两个相加而不是相减来求最久的 key。

为什么不用常规的哈希表 双向链表的方式实现?需要额外的数据结构,消耗资源。而 Redis LRU 算法在 sample 为 10 的情况下,已经能接近传统 LRU 算法了。

问题:除了消耗资源之外,传统 LRU 还有什么问题?

如图,假设 A 在 10 秒内被访问了 5 次,而 B 在 10 秒内被访问了 3 次。因为 B 最后一次被访问的时间比 A 要晚,在同等的情况下,A 反而先被回收。

问题:要实现基于访问频率的淘汰机制,怎么做?

JAVA实现LRU算法

代码语言:javascript复制
public class LRUCache {
    // KV形式存储缓存
    private HashMap<String, LRUNode> map;
    private int capacity; // 链表容量
    private LRUNode head; // 头结点
    private LRUNode tail; // 尾节点

    public void set(String key, Object value) {
        // 设置值,节被被访问时,移除节点,放到队头
        LRUNode node = map.get(key);
        if (node != null) {
            node = map.get(key);
            node.value = value;
            remove(node, false);
        } else {
            node = new LRUNode(key, value);
            if (map.size() >= capacity) {
                // 每次容量不足时先删除最久未使用的元素
                remove(tail, true);
            }
            map.put(key, node);
        }
        // 将刚添加的元素设置为head
        setHead(node);
    }

    // 取值,节被被访问时,移除节点,放到队头
    public Object get(String key) {
        LRUNode node = map.get(key);
        if (node != null) {
            // 将刚操作的元素放到head
            remove(node, false);
            setHead(node);
            return node.value;
        }
        return null;
    }

    // 将节点设置为头节点
    private void setHead(LRUNode node) {
        // 先从链表中删除该元素
        if (head != null) {
            node.next = head;
            head.prev = node;
        }
        head = node;
        if (tail == null) {
            tail = node;
        }
    }

    // 从链表中删除此Node,需注意该Node是head或者是tail的情形
    private void remove(LRUNode node, boolean flag) {
        if (node.prev != null) {
            node.prev.next = node.next;
        } else {
            head = node.next;
        }
        if (node.next != null) {
            node.next.prev = node.prev;
        } else {
            tail = node.prev;
        }
        node.next = null;
        node.prev = null;
        if (flag) {
            map.remove(node.key);
        }
    }

    public LRUCache(int capacity) {
        this.capacity = capacity;
        this.map = new HashMap<String, LRUNode>();
    }

    // 链表中的节点
    class LRUNode {
        String key;
        Object value;
        LRUNode prev;
        LRUNode next;
        public LRUNode(String key, Object value) {
            this.key = key;
            this.value = value;
        }
    }
}
LFU

当这 24 bits 用作 LFU 时,其被分为两部分:

  • 高 16 位用来记录访问时间(单位为分钟,ldt,last decrement time)
  • 低 8 位用来记录访问频率,简称 counter(logc,logistic counter)

counter 是用基于概率的对数计数器实现的,8 位可以表示百万次的访问频率。对象被读写的时候,lfu 的值会被更新。

db.c——lookupKey

代码语言:javascript复制
void updateLFU(robj *val) {
unsigned long counter = LFUDecrAndReturn(val);
counter = LFULogIncr(counter);
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

增长的速率由,lfu-log-factor 越大,counter 增长的越慢。 redis.conf 配置文件:# lfu-log-factor 10

如果计数器只会递增不会递减,也不能体现对象的热度。没有被访问的时候,计数器怎么递减呢?

减少的值由衰减因子 lfu-decay-time(分钟)来控制,如果值是 1 的话,N 分钟没有访问就要减少 N。 redis.conf 配置文件# lfu-decay-time 1

持久化机制

https://redis.io/topics/persistence Redis 速度快,很大一部分原因是因为它所有的数据都存储在内存中。如果断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis 提供了两种持久化的方案,一种是 RDB 快照(Redis DataBase),一种是 AOF(Append Only File)。

RDB

RDB 是 Redis 默认的持久化方案。当满足一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件 dump.rdb。Redis 重启会通过加载 dump.rdb 文件恢复数据。

RDB 触发
  1. 配置规则触发。 redis.conf, SNAPSHOTTING,其中定义了触发把数据保存到磁盘的触发频率。 如果不需要 RDB 方案,注释 save 或者配置成空字符串""。

save 900 1 # 900 秒内至少有一个 key 被修改(包括添加) save 300 10 # 400 秒内至少有 10 个 key 被修改 save 60 10000 # 60 秒内至少有 10000 个 key 被修改

  1. 注意上面的配置是不冲突的,只要满足任意一个都会触发。 RDB 文件位置和目录:
代码语言:javascript复制
# 文件路径,
dir ./
# 文件名称
dbfilename dump.rdb
# 是否是LZF压缩rdb文件 
rdbcompression yes
# 开启数据校验 rdbchecksum yes

参数

说明

dir

rdb文件默认在启动目录下(相对路径)

dbfilename

文件名称

rdbcompression

开启压缩可以节省存储空间,但是会消耗一些 CPU 的计算时间,默认开启

rdbchecksum

使用 CRC64 算法来进行数据校验,但是这样做会增加大约 10%的性能消耗,如果希望获取到最 大的性能提升,可以关闭此功能。

  1. 为什么停止 Redis 服务的时候没有 save,重启数据还在? RDB 还有两种触发方式:
  2. shutdown 触发,保证服务器正常关闭。
  3. flushall,RDB 文件是空的,没什么意义(删掉 dump.rdb 演示一下)。
手动触发

如果我们需要重启服务或者迁移数据,这个时候就需要手动触 RDB 快照保存。Redis 提供了两条命令:

  1. save save 在生成快照的时候会阻塞当前 Redis 服务器, Redis 不能处理其他命令。如果内存中的数据比较多,会造成 Redis 长时间的阻塞。生产环境不建议使用这个命令。
  2. bgsave 执行 bgsave 时,Redis 会在后台异步进行快照操作,快照同时还可以响应客户端请求。 具体操作是 Redis 进程执行 fork 操作创建子进程(copy-on-write),RDB 持久化过程由子进程负责,完成后自动结束。它不会记录 fork 之后后续的命令。阻塞只发生在 fork 阶段,一般时间很短。 用 lastsave 命令可以查看最近一次成功生成快照的时间。
RDB 数据的恢复
添加键值

redis> set k1 1 redis> set k2 2 redis> set k3 3 redis> set k4 4 redis> set k5 5

停服务器,触发 save

redis> shutdown

备份 dump.rdb 文件

cp dump.rdb dump.rdb.bak

启动服务器
代码语言:javascript复制
/usr/local/soft/redis-5.0.5/src/redis-server /usr/local/soft/redis-5.0.5/redis.conf
数据都在

redis> keys *

RDB 文件的优势和劣势
优势
  1. RDB 是一个非常紧凑(compact)的文件,它保存了 redis 在某个时间点上的数据集。这种文件非常适合用于进行备份和灾难恢复。
  2. 生成 RDB 文件的时候,redis 主进程会 fork()一个子进程来处理所有保存工作,主进程不需要进行任何磁盘 IO 操作。
  3. RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。
劣势
  1. RDB 方式数据没办法做到实时持久化/秒级持久化。因为 bgsave 每次运行都要执行 fork 操作创建子进程,频繁执行成本过高。
  2. 在一定间隔时间做一次备份,所以如果 redis 意外 down 掉的话,就会丢失最后

一次快照之后的所有修改(数据有丢失)。 如果数据相对来说比较重要,希望将损失降到最小,则可以使用 AOF 方式进行持久化。

AOF

Append Only File AOF:Redis 默认不开启。AOF 采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改 Redis 数据的命令时,就会把命令写入到 AOF 文件中。 Redis 重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。

AOF 配置

配置文件 redis.conf

开关

appendonly no

文件名

appendfilename “appendonly.aof”

参数

说明

appendonly

Redis 默认只开启 RDB 持久化,开启 AOF 需要修改为 yes

appendfilename “appendonly.aof”

路径也是通过 dir 参数配置 config get dir

数据都是实时持久化到磁盘吗?

由于操作系统的缓存机制,AOF 数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存。什么时候把缓冲区的内容写入到 AOF 文件?

参数

说明

appendfsync everysec

AOF 持久化策略(硬盘缓存到磁盘),默认 everysec

1,no表示不执行fsync,由操作系统保证数据同步到磁盘,速度最快,但是不太安全; 2,always表示每次写入都执行fsync,以保证数据同步到磁盘,效率很低; 3,everysec 表示每秒执行一次 fsync,可能会导致丢失这 1s 数据。通常选择 everysec,兼顾安全性和效率。 |

文件越来越大,怎么办?

由于 AOF 持久化是 Redis 不断将写命令记录到 AOF 文件中,随着 Redis 不断的进 行,AOF 的文件会越来越大,文件越大,占用服务器内存越大以及 AOF 恢复要求时间 越长。 例如 set wenbin 666,执行 1000 次,结果都是 wenbin=666。 为了解决这个问题,Redis 新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集。 可以使用命令 bgrewriteaof 来重写。 AOF 文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对, 然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的 AOF 文件。

重写触发机制

auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb

参数

说明

auto-aof-rewrite-percentag e

默认值为 100。aof 自动重写配置,当目前 aof 文件大小超过上一次重写的 aof 文件大小的百分之多少进行重写,即当 aof 文件增长到一定大小的时候,Redis 能够调用 bgrewriteaof 对日志文件进行重写。当前 AOF 文件大小是上次日志重写得到 AOF 文件大小的二倍(设置为 100)时,自动启动新的日志重写过程。

auto-aof-rewrite-min-size

默认 64M。设置允许重写的最小 aof 文件大小,避免了达到约定百分比但尺寸仍然很小的情况还要重写。

重写过程中,AOF 文件被更改了怎么办?

另外有两个与 AOF 相关的参数:

参数

说明

no-appendfsync-on-rewrite

在 aof 重写或者写入 rdb 文件的时候,会执行大量 IO,此时对于 everysec 和 always 的 aof 模式来说,执行 fsync 会造成阻塞过长时间,no-appendfsync-on-rewrite 字段设置为默认设置为 no。如果对延迟要求很高的应用,这个字段可以设置为 yes,否则还是设置为 no,这样对持久化特性来说这是更安全的选择。设置为 yes 表示 rewrite 期间对新写操作不 fsync, 暂时存在内存中,等 rewrite 完成后再写入,默认为 no,建议修改为 yes。Linux 的默认 fsync 策略是 30 秒。可能丢失 30 秒数据。

aof-load-truncated

aof 文件可能在尾部是不完整的,当 redis 启动的时候,aof 文件的数据被载入内存。重启 可能发生在 redis 所在的主机操作系统宕机后,尤其在 ext4 文件系统没有加上 data=ordered 选项,出现这种现象。redis 宕机或者异常终止不会造成尾部不完整现象,可以选择让 redis 退出,或者导入尽可能多的数据。如果选择的是 yes,当截断的 aof 文件被导入的时候, 会自动发布一个 log 给客户端然后 load。如果是 no,用户必须手动 redis-check-aof 修复 AOF 文件才可以。默认值为 yes。

AOF 数据恢复

重启 Redis 之后就会进行 AOF 文件的恢复。

AOF 优势与劣势
优点

AOF 持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步一次,Redis 最多也就丢失 1 秒的数据而已。

缺点
  1. 对于具有相同数据的的 Redis,AOF 文件通常会比 RDB 文件体积更大(RDB 存的是数据快照)。
  2. 虽然 AOF 提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。在高并发的情况下,RDB 比 AOF 具好更好的性能保证。

两种方案比较

那么对于 AOF 和 RDB 两种持久化方式,我们应该如何选择呢? 如果可以忍受一小段时间内数据的丢失,毫无疑问使用 RDB 是最好的,定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快。 否则就使用 AOF 重写。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当 redis 重启的时候会优先载入 AOF 文件来恢复原始的数据,因为在通常情况下 AOF 文件保存的数据集要比 RDB 文件保存的数据集要完整。

0 人点赞