Redis学习(二)原理篇

1. 发布订阅模式

1.1 列表的局限

​ 前面我们说通过队列的 rpush 和 blpop 可以实现消息队列(队尾进队头出),没有任何元素可以弹出的时候,连续会被阻塞。

​ 但是基于 list 实现的消息队列,不支持一对多的消息分发,相当于只有一个消费者。

​ 如果要实现一对多的消息分发,怎么办?

1.2 发布订阅模式

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

1.2.1 订阅频道

​ 消息的生产者和消费者是不同的客户端,连接到同一个 Redis 的服务。通过什么对象把生产者和消费者关联起来呢?

​ 在 RabbitMQ 里面叫 Queue,在 Kafka 里面叫 Topic。Redis 的模型里面这个叫 channel(频道)。

​ 订阅者可以订阅一个或者多个 channel。消息的发布者可以给指定的 channel 发布消息。只要有消息到达了 channel,所有订阅了这个 channel 的订阅者都会收到这条消息。

请添加图片描述

​ 订阅者订阅频道:可以一次订阅多个,比如这个客户端订阅了 3 个频道,频道不用事先创建。

subscribe channel-1 channel-2

​ 发布者可以向指定频道发布消息(并不支持一次向多个频道发送消息):

publish channel-1 hello

​ 取消订阅(不能再订阅状态下使用)

unsubscribe channel-1
1.2.2 按规则(Pattern)订阅频道

​ 支持 ? 和 * 占位符。? 代表一个字符,* 代表0个或者多个字符。

​ 例如,现在有三个新闻频道,运动新闻(news-sport)、音乐新闻(news-music)、天气新闻(news-weather)

​ 三个消费者,消费端1-关注运动新闻:

psubscribe *sport

​ 消费端2-关注所有新闻:

psubscribe news*

​ 消费端3-关注天气新闻:

psubscribe news-weather

请添加图片描述

​ 生产者,向3个频道发布3条信息,对应的订阅者能收到消息:

publish news-sport kobe
publish news-music jaychou
publish news-weather sunny

​ 代码:jedis-demo:PublishTest.java(先订阅,后发布消息)

​ 一般来说,考虑到性能和持久化的因素,不建议使用 Redis 的发布订阅功能来实现 MQ。Redis 的一些内部机制用到了发布订阅功能。

2. Redis 事务

官方文档:https://redis.io/topics/transactions/

2.1 为什么要用事务

​ Redis 的单个命令是原子性的(比如 get、set、mget、mset),要么成功要么失败,不存在并发干扰的问题。

​ 如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就必须要依赖 Redis 的功能特性来实现了。

​ Redis 提供了事务的功能,可以把一组命令一起执行。Redis 的事务有 3 个特点:

  1. 按进入队列的顺序执行。
  2. 不会受到其他客户端的请求的影响。
  3. 事务不能嵌套,多个 multi 命令效果一样。

2.2 事务的用法

​ Redis 的事务涉及到4个命令:multi(开启事务)、exec(执行事务)、discard(取消事务)、watch(监视)

​ 案例场景:tom和mic各有1000元,tom向mic转账100元。

127.0.0.1:6379> set tom 1000
OK
127.0.0.1:6379> set mic 1000
OK
# 开启事务
127.0.0.1:6379> multi
OK
127.0.0.1:6379> decrby tom 100
QUEUED
127.0.0.1:6379> incrby mic 100
QUEUED
# 关闭事务
127.0.0.1:6379> exec
1) (integer) 900
2) (integer) 1100
127.0.0.1:6379> get tom
"900"
127.0.0.1:6379> get mic

​ 通过 multi 命令开启事务。multi 执行后,客户端可以继续向服务器发送任意多条命令,这些命令不会立即被执行,而是被放到一个队列中。当 exec 命令被调用时,所有队列中的命令才会被执行。

​ 如果没有执行 exec,所有的命令都不会被执行。

​ 如果中途不想执行事务了,怎么办?

​ 可以调用 discard,可以清空事务队列,放弃执行。

# 开启事务
multi
decrby tom 100
# 放弃事务
discard
get tom

2.3 watch 命令

​ 为了防止事务过程中某个 key 的值被其他客户端请求修改,带来非预期的结果,在 Redis 中还提供了一个 watch 命令。

​ 也就是多个客户端变更变量的时候,会跟原值作比较,只有它没有被其他线程修改的情况下,才更新成新的值。它可以为Redis事务提供CAS乐观锁行为(Compare and Swap)。

​ 我们可以用 watch 监视一个或者多个 key,如果开启事务之后,至少有一个被监视 key 键在 exec 执行之前被修改了,那么整个事务都会被取消(key 提前过期除外)。可以用 unwatch 取消。

client 1client 2
set balance 100
watch balance
multi
incrby balance 200
decrby balance 100
exec【返回 nil】
get balance【返回200】

2.4 事务可能遇到的问题

​ 事务执行遇到的问题分为两种,一种是在执行 exec 之前发生错误,一种是在执行 exec 之后发生错误。

2.4.1 在执行 exec 之前发生错误

​ 比如:入队的命令存在语法错误,包括参数数量,参数名等等(编译器错误)。

multi
set key1 123
set key2 456
hset tom 666
exec

​ 比如这里出现了参数个数错误,事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。

2.4.2 在执行 exec 之后发生错误

​ 比如对 String 使用了 Hash 的命令,参数个数正确,但数据类型错误,这是一种运行时错误。

127.0.0.1:6379> flushdb
OK
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set k1 1
QUEUED
127.0.0.1:6379> hset k1 a b
QUEUED
127.0.0.1:6379> exec
1) OK
2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
127.0.0.1:6379> get k1
"1"

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

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

2.4.3 事务为什么没有回滚?

官方的解释是这样的:

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

3. Lua 脚本

Redis 从 2.6 版本开始引入了 Lua脚本,Lua /'luə/ 是一种轻量级脚本语言,它是用 C 语言编写的,跟数据的存储过程有点类似。

使用 Lua 脚本来执行 Redis命令 的好处:

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

3.1 在 Redis 中调用 Lua 脚本

​ eval 语法格式:

127.0.0.1:6379> eval lua-script key-num [key1 ...] [value1 ...]
  • eval 代表执行 Lua 语言的命令。
  • lua-script 代表 Lua 语言脚本内容。
  • key-num 标识参数中有多少个key,需要注意的是 Redis 中 key是从 1 开始的,如果没有 key 的参数,那么写 0。
  • [key1 …] 是 key 作为参数传递给 Lua语言,也可以不填,但是需要和 key-num 的个数对应起来。
  • [value1 …] 是 value 作为参数传递给 Lua语言,它们是可填可不填的。

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

127.0.0.1:6379> eval "return 'Hello World'" 0

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

​ Lua 脚本执行 Redis 命令也是有固定语法的。

3.2 在 Lua 脚本中调用 Redis 命令

3.2.1 命令格式

​ 语法格式:

redis.call(command, key [param1, param2, ...])
  • command 是命令,包括 set、get、del 等。
  • key 是被操作的键。
  • [param1, param2, …] 代表给 key 的参数

​ 来看一个简单的案例,让 Lua 脚本执行 set key1 123(Redis 客户端执行):

eval "return redis.call('set', 'key1', '123')" 0

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

eval "return redis.call('set', KEYS[1], ARGV[1])" 1 key1 456

​ 如果 KEY 和 ARGV 有多个,继续往后面加就是了。

​ 在 redis-cli 中直接写 Lua 脚本不够方便,也不能实现编辑和服用,通常我们会把 Lua脚本放在文件里面,然后执行这个文件。

3.2.2 Lua 脚本文件

​ 创建 Lua 脚本文件:

cd /usr/local/soft/redis-6.0.9/src
vim gupao lua

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

redis.call('set', 'key1', 'lua666')
return redis.call('get', 'key1')

​ 调用脚本文件:

cd /usr/local/soft/redis-6.0.9/src
redis-cli --eval test.lua 0
3.2.3 案例:对 IP 进行限流

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

​ 设计思路:

  1. 首先是数据类型。用 String 的 key 记录 IP,用 value 记录访问次数。几秒钟和几次要用参数动态传进去。
  2. 拿到 IP 之后,对 IP + 1。如果是第一次访问,对 key 设置过期时间(参数 1)。否则判断次数,超过限定的次数(参数 2),返回 0。如果没有超过次数则返回 1。超过时间,key 过期之后,可以再次访问。
  3. KEY[1] 是 IP,ARGV[1] 是过期时间 X,ARGV[2] 是限制访问的次数 Y。
--ip_limit.lua
--IP限流,对某个IP频率进行限制,6秒访问10次
-- 测试:以数组形式输出入参
-- return {KEYS[1],ARGV[1],ARGV[2]}
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 ip_limit.lua app:ip:limit:192.168.8.111 , 6 10
  • app:ip:limit:192.168.8.111 是 key 值 , 后面是参数值,中间要加上一个"空格,空格"。

    即:redis-cli --eval [lua脚本文件] [key …]空格,空格[arg …]

  • 多个参数之间用空格分割。

    代码:LuaTest.java

3.2.4 缓存 Lua 脚本

为什么要缓存

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

如何缓存

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

script load "return 'Hello World'"

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

evalsha "470877a599ac74fbfda41caa908de682c5fc7d4b"

自乘案例

​ Redis 有 incrby 这样的自增命令,但没有自称,比如乘以3,乘以5.

set num 2

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

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

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

local curlVal = 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

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

127.0.0.1:6379> script load 'local curlVal = 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'
"be4f93d8a5379e5e5b768a74e77c8a4eb0434441"

​ 调用:

127.0.0.1:6379> evalsha be4f93d8a5379e5e5b768a74e77c8a4eb0434441 1 num 6

​ 使用 Lua 脚本需要注意下面这个问题:

3.2.5 脚本超时

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

127.0.0.1:6379> eval 'while(true) do end' 0

​ 还真的是。他会导致其他的命令都会进入等待状态。

​ 当然,这种小问题,antirez 在设计的时候引入 lua 脚本的时候就考虑到了。

​ 首先,脚本执行有一个超时时间,默认为 5 秒钟。

lua-time-limit 5000

​ 超过 5 秒钟,其他客户端的命令不会等待,而是直接回返回 “BUSY”错误。

​ 这样不行,不能一直拒绝其他客户端的命令执行吧。在提示里面我们也看到了,有两个命令可以使用,第一个是 script kill,中止脚本的执行。

127.0.0.1:6379> script kill

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

127.0.0.1:6379> eval "redis.call('set', 'test', '666') while true do end" 0

​ 这时候执行 script kill 会返回 UNKILLABLE 错误。为什么要这么设计?为什么包含修改的脚本不能中断?因为要保证脚本运行的原子性。如果脚本执行了一部分被终止,那就违背了脚本原子性的目标。

​ 遇到这种情况,只能通过 shutdown nosave 命令,直接把 Redis 服务停掉。

​ 正常关机是 shutdown。shutdown nosave 和 shutdown 的区别在于 shutdown nosave 不会进行持久化,意味着发生在上一次快照后的数据库修改都会丢失。

​ 总结:如果我们有一些特殊的需求,可以用 Lua 来实现,但是要注意那些耗时的操作。

4. Redis 为什么这么快?

4.1 Redis 到底有多快?

官方文档:https//redis.io/topics/benchmarks

请添加图片描述

​ 使用 redis 自带的 benchmark 脚本测试:

cd /usr/local/soft/redis-6.0.9/src
redis-benchmark -t set,lpush -n 100000 -q

​ 结果:

​ SET: 136239.78 requests per second——每秒钟处理 13 万多次 set 请求

​ LPUSH: 132626.00 requests per second——每秒钟处理 13 万多次 lpush 请求

redis-benchmark -n 1000000 -q script load "redis.call('set','foo','bar')"

​ 结果:

​ script load redis.call(‘set’,‘foo’,‘bar’): 125628.14 requests per second——每秒钟 125628.14 次 lua 脚本调用。

​ 按照这个测试结果,说明 Redis 的 QPS 10 万还是比较准确的,在高性能的服务器上性能还能更强。

4.2 Redis 为什么这么快

​ 总结起来主要是三点:

  1. 存内存结构
  2. 请求处理单线程
  3. 多路复用机制
4.2.1 内存

​ KV 结构的内存数据库,时间复杂度 O(1)。

​ 思考一下,按照正常的思路来讲,要实现这么高的并发性能,是不是要创建非常多的线程?为什么我们说 Redis 是单线程的呢?这个单线程说的到底是什么?

4.2.2 单线程

​ 这里说的单线程其实指的是处理客户端的请求是单线程的,可以把它叫做主线程。从 4.0 的版本之后,还引入了一些线程处理其他的事情,比如清理脏数据、无用连接的释放、大 key 的删除。

​ 把处理请求的主线程设置成单线程有什么好处呢?

  1. 没有创建线程、销毁线程带来的消耗
  2. 避免了上下文切换导致的 CPU 消耗(什么叫上下文切换?
  3. 避免了线程之间带来的竞争问题,例如:加锁、释放锁、等等。

​ 这里有一个问题,就算单线程确实有这些好处,但是会不会白白浪费了 CPU 的资源吗?也就是说只能用到单核。

​ 官方的解释是这样的:

​ 在 Redis 中单线程已经够用了,CPU 不是 redis 的瓶颈。Redis 的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,又不需要处理线程并发的问题,那就顺理成章地采用单线程的方案了。

​ 注意,因为请求处理是单线程的,不要在生产环境运行长命令,比如 keys、flushall、flushdb。否则回导致请求被阻塞。

4.2.3 同步非阻塞 I/O

​ 同步非阻塞 I/O,多路复用处理并发连接——什么是多路复用?

4.3 单线程为什么这么快?

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

4.3.1 虚拟存储器(虚拟内存 Virtual Memory)

​ 计算机里面的内存我们叫做主存,硬盘叫做赋存。

​ 主存可以看作一个很长的数组,一个字节一个单元,每个字节有一个唯一的地址,这个地址叫做物理地址(Physical Address)。

​ 早期的计算机中,如果 CPU 需要内存,使用物理寻址,直接访问主存储器。

请添加图片描述

​ 看起来试听合乎情理的,但是这种方式有几个弊端:

  1. 一般的操作系统都是多用户多任务的,所有的进程共享内存。如果每个进程都独占一块物理地址空间,贮存很快就会被用完。

    我们希望在不同的时刻,不同的进程可以共用同一块物理地址空间。

  2. 所有的进程都是直接访问物理内存,那么一个进程就可以修改其他进程的内存数据,导致物理地址空间被破坏,程序运行就会出现异常。

​ 咋办呢?对于物理内存的使用,应该有一个角色来协调和指挥。

​ 我们想了一个办法,在 CPU 和主存之间增加一个中间层。CPU 不再使用物理地址访问主存,而是访问一个虚拟地址,由这个中间层把地址转换成物理地址,最终获得数据。这个中间层叫做 MMU(Memory Management Unit),内存管理单元。

​ 具体的操作如下所示:

请添加图片描述

​ 我们访问 MMU 就跟访问物理内存一样,所以把虚拟出来的地址叫做虚拟内存(Virtual Memory)。

​ 在每一个进程开始创建的时候,都会分配一段虚拟地址,然后通过虚拟地址和物理地址的映射来获取真实数据,这样进程就不会直接接触到物理地址,甚至不知道自己调用的哪块物理地址的数据。

​ 目前,大多数操作系统都使用了虚拟内存,如 Windows 系统的虚拟内存、Linux系统的交换空间等等。Windows 的虚拟内存(pagefile.sys)是磁盘空间的一部分。

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

$ cat /proc/cpuinfo

​ address size : 43 bits physical, 48 bits virtual

​ 实际的物理内存可能远远小于虚拟内存的大小。

​ 总结:引入虚拟内存的作用:

  1. 通过把同一块物理内存映射到不同的虚拟地址空间实现内存共享
  2. 对物理内存进行隔离,不同的进程操作互不影响
  3. 虚拟内存可以提供更大的地址空间,并且地址空间是连续的,是的程序编写、链接更加简单。

​ Linux/GNU 的虚拟内存又进一步划分成了两块:

4.3.2 用户空间 和 内核空间

​ 一部分是用户空间(User-space),一部分是内核空间(Kernel-space)。

请添加图片描述

​ 在 Linux 系统中,虚拟内存布局如下:

请添加图片描述

​ 这两块空间的区别是什么呢?

​ 进程的用户空间中存放的是用户程序的代码和数据,内核空间中存放的是内核代码和数据。不管是内核空间还是用户空间,它们都处于虚拟内存空间中,都是对物理地址的映射。

​ 当进程运行在内核空间时就处于内核态,而进程运行在用户空间时则处于用户态。

​ 进程在内核空间可以访问受保护的内存空间,也可以访问底层硬件设备。也就是可以执行任意命令,调用系统的一切资源。在用户空间只能执行简单的运算,不能直接调用系统资源,必须通过系统接口(又称 system call),才能向内核发出指令。

​ 所以,这样划分的目的是为了避免用户进程直接操作内核,保证内核安全。

​ top 命令:

请添加图片描述

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

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

4.3.3 进程切换(上下文切换)

​ 多任务操作系统是怎么运行远大于 CPU 数量的任务个数的?当然,这些任务实际上并不是真的在同时运行,而且因为系统通过时间片分片算法,在很短的时间内,将 CPU 轮流分配给它们,造成多任务同时运行的错觉。

​ 在这个交替运行的过程里面,为了控制进程的运行,内核必须有能力挂起正在 CPU 上运行的进程,一级恢复以前挂起的某个进程的执行。这种行为被称为进程切换。

​ 什么叫上下文(Context)?

​ 在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行。也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器(Program Counter),这个叫做 CPU 的上下文。

​ 而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。

4.3.4 进程的阻塞

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

4.3.5 文件描述符 FD

​ Linux 系统将所有设备都当作文件来处理,而 Linux 用文件描述符来标示每个文件对象。

​ 文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指向被打开的文件,所有执行 I/O 操作的系统调用都通过文件描述符。

​ 文件描述符是一个简单的非负整数,用以表明每个被进程打开的文件。Linux系统里面有三个标准文件描述符:

​ 0:标准输入(键盘) 1:标准输出(显示器) 2:标准错误输出(显示器)

4.3.6 传统 I/O 数据拷贝

​ 以读操作为例:

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

请添加图片描述

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

4.3.7 Blocking I/O

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

请添加图片描述

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

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

​ 能不能用一个线程处理多个客户端请求?

4.3.8 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

#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
	#ifdef HAVE_EPOLL
	#include "ae_epoll.c"
	#else
		#ifdef HAVE_KQUEUE
		#include "ae_kqueue.c"
		#else
		#include "ae_select.c"
		#endif
	#endif
#endif

​ evport 是 Solaris 系统内核提供支持的;

​ epoll 是 Linux 系统内核提供支持的;

​ kqueue 是 Mac 系统提供支持的;

​ select 是 POSIX提供的,一般的操作系统都有支撑(保底方案);

​ 源码 ae_epoll.c、ae_select.c、ae_kqueue.c、ae_evport.c

​ 总结一下:

​ Redis 抽象了一套 AE 事件模型,将 IO 事件和时间事件融入一起,同时借助多路复用机制的回调特性(Linux 上用 epoll),是的 IO 读写都是非阻塞的,实现高性能的网络处理能力。

​ 我们一直在说的 Redis 新版本多线程的特性,意思并不是服务端接收客户端请求变成多线程的了,它还是单线程的。

​ 严格意义上来说,Redis 从 4.0 之后就引入了多线程用来处理一些耗时长的工作和后台工作,那不然的话,如果真的只有一个线程,哪些耗时的操作肯定会导致客户端请求被阻塞。我们这里说的多线程,确切地说,叫做多线程 I/O。

​ 配置注释翻译:https://gper.club/articles/7e7e7f7ff3g5agc5g60

​ 为什么要用多线程来处理 I/O 呢?

4.3.9 多线程 I/O

​ 回到多路复用的图:

​ 服务端的数据返回给客户端,需要从内核空间 copy 数据到用户空间,然后会写到 socket(write 调用),这个过程非常耗时的。所以多线程 I/O 指的就是把结果写到 socket 的这个环节是多线程的。处理请求依然是单线程的,所以不存在线程并发安全问题。

请添加图片描述

​ Redis 本质上是一个存储系统。所有的存储系统在数据量大的情况下都会面临存储瓶颈,包括 MySQL、RebbitMQ 等等。

​ 这里我们解决需要两个问题:首先,作为一个内存的 KV 系统,Redis 服务肯定不是无限制地使用内存,应该设置一个上限(max_memory)。第二个,数据应该有过期属性,这样就能清除不再使用的 key。

​ 网上有一个面试题就是:Redis 内存满了怎么办?不过正常人不会这么问,这个角度有点刁钻了。

​ 我们先看一下 key 过期怎么处理,再看内存达到上限怎么处理。

5. 内存回收

5.1 过期策略

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

5.1.1 立即过期(主动淘汰)

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

​ 例如所有的查询都会调用 expireIfNeeded 判断是否过期:

​ db.c 1299 行

expireIfNeeded(redisDb *db, robj *key)

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

​ expire.c 123 行

activeExpireCycle(int type)
5.1.3 定期过期

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

​ server.h 661 行

typedef struct redisDb {
    dict *dict; /* 所有的键值对 */ /* The keyspace for this DB */
    dict *expires; /* 设置了过期时间的键值对 */ /* Timeout of keys with a timeout set */
    dict *blocking_keys; /* Keys with clients waiting for data(BLPOP) */
    dict *ready_keys; /* Blocked keys that received a PUSH */
    dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
    int id; /* Database ID */
    long long avg_ttl; /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle */
    list *defrag_later; /* List of key names to attempt to defrag one by one, gradually */
} redisDb;

​ 总结:Redis 中同时使用了惰性过期和定期过期两种过期策略,并不是实时地清除过期的 key。

​ 如果所有的 key 都没有设置过期属性,Redis 内存满了怎么办?

5.2 淘汰策略

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

5.2.1 最大内存设置

​ redis.conf 参数配置

# maxmemory <bytes>

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

​ 动态修改(先 get 一下):

127.0.0.1:6379> config set maxmemory 2GB

​ 到达最大内存以后怎么办?

5.2.2 淘汰策略

​ 官方文档:https://redis.io/topics/lru-cache

​ redis.conf

# maxmemory-policy noeviction
# volatile-lru -> Evict using approximated LRU, only keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key having an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time(minor TTL)

​ 先从后缀的算法名来看:

​ LRU,Least Recently Used:最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。

​ LFU,Least Frequently Used:最不常用,按照使用频率删除,4.0 版本新增。

​ random:随机删除。

​ 从前缀针对的对象来分:volatile 是针对设置了 ttl 的 key,allkeys 是针对所有 key。

请添加图片描述

请添加图片描述

​ 如果没有设置 ttl 或者没有符合前提条件的 key 被淘汰,那么 volatile-lru、volatile-random、volatile-ttl 相当于 noeviction(不做内存回收)。

​ 动态修改淘汰策略(先 get 一下):

127.0.0.1:6379> config set maxmemory-policy volatile-lru

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

5.2.3 LRU 淘汰原理

​ LRU 是一个很常见的算法,比如 InnoDB 的 Buffer Pool 也用到了 LRU。

​ 传统的 LRU:通过链表 + HashMap 实现,设置链表长度,如果新增或者被访问,就移动到头节点。超过链表长度,末尾的节点被删除。

请添加图片描述

问题:如果基于传统 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 622 行

typedef struct redisObject {
    unsigned type:4; /* 对象的类型,包括:OBJ_STRING、OBJ_LIST、OBJ_HASH、OBJ_SET、OBJ_ZSET */
    unsigned encoding:4; /* 具体的数据结构 */
    unsigned lru:LRU_BITS; /* 24 位,对象最后一次被命令程序访问的时间,与内存回收有关 */ /* LRU time(relative to global lru_clock) or LFU data(least significant 8 bits frequency and most significant 16 bits access time) */
    int refcount; /* 引用计数。当 refcount 为 0 的时候,表示该对象已经不被任何对象引用,则可以进行垃圾回收了 */
    void *ptr; /* 指向对象实际的数据结构 */
} robj;

​ server.lruclock 的值怎么来的?

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

​ 源码:server.c 1756 行

void updateCachedTime(int update_daylight_info) {
    server.ustime = ustime();
    server.mstme = server.ustime / 1000;
    server.unixtime = server.mstime / 1000;
    /* To get information about daylight saving time, we need to call
     * localtime_r and cache the result. However calling localtime_r in this
     * context is safe since we will never fork() while here, in the main
     * thread. The loggin function will call a thread safe version of
     * localtime that has no locks. */
    if (update_daylight_info) {
        struct tm tm;
        time_t ut = server.unixtime;
        localtime_r(&ut, &tm);
        server.daylight_active = tm.tm_isdst;
    }
}

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

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

​ OK,当对象里面已经有了 LRU 字段的值,就可以评估对象的热度了。

​ 源码 evict.c 90 行

/* Given an object returns the min number of milliseconds the object was never
 * requested, using an approximated LRU algorithm. */
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;
    }
}

​ 函数 estimateObjectIdleTime 评估指定对象的 lru 热度,方法就是对象的 lru 值和全局的 server.lruclock 的差值越大(越久没有得到更新),该对象热度越低。

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

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

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

​ 官方文档:https://redis.io/topics/lru-cache

请添加图片描述

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

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

请添加图片描述

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

5.2.4 LFU

​ server.h

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    unsigned lru:LRU_BITS; /* LRU time(relative to global lru_clock) or LFU data(least significant 8 bits frequency and most significant 16 bits access time).*/
    int refcount;
    void *ptr;
} robj;

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

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

​ counter 是用基于概率的对数计数器实现的;8位可以表示百万次的访问频率。

​ 对象被读写的时候,lfu 的值会被更新。

​ db.c 55 行 —— lookupKey

void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru=(LFUGetTimeInMinutes()<<8)|counter;
}

​ 当然,这里并不是访问一次,计数就加 1.

​ 增长的速率由一个参数决定,lfu-log-factor 越大,counter 增长的越慢

​ redis.conf 配置文件:

# lfu-log-factor 10

​ 注意一下,这个算法是 LRU,如果一段时间热点高,就一直保持这个热度,肯定也是不行的,体现不了整体频率。所以,没有被访问的时候,技术及还要递减。

​ 没有被访问的时候,计数器怎么递减呢?

​ 减少的值由衰减因子 lfu-decay-time (分钟)来控制,如果值是 1 的话,N 分钟没有访问,计数器就要减少 N。lfu-decay-time 越大,衰减越慢。

​ redis.conf 配置文件

# lfu-decay-time 1

6. 持久化机制

​ 官方文档:https://redis.io/topics/persistence

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

6.1 RDB

​ RDB 是 Redis 默认的持久化方案(注意:如果开启了 AOF,优先用 AOF)。当满足一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件 dump.rdb。Redis 重启会通过加载 dump.rdb 文件恢复数据。

​ 什么时候写入 rdb 文件?

6.1.1 RDB 触发

1、自动触发

​ a)配置规则触发。

​ redis.conf SNAPSHOTTING,其中定义了触发把数据保存到磁盘的触发频率。

​ 如果不需要 rdb 方案,注释 save 或者配置成空字符串""。

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

​ 注意:上面的配置是不冲突的,只要满足任意一个都会触发。

​ 用 lastsave 命令可以查看最近一次成功生成快照的时间。

​ rdb 文件位置和目录(默认在安装根目录下):

# 文件路径,
dir ./
# 文件名称
dbfilename dump.rdb
# 是否以 LZF 压缩 rdb 文件
rdbcompressin yes
# 开启数据校验
rdbchecksum yes

请添加图片描述

​ 除了根据配置触发生成 RDB,RDB 还有两种自动触发方式:

​ b)shutdown 触发,保证服务器正常关闭。

​ c)flushall,rdb 文件是空的,没什么意义(删掉 dump.rdb 试一下)。

2、手动触发

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

​ a)save

​ save 在生成快照的时候会阻塞当前 Redis 服务器,Redis 不能处理其他命令。如果内存中的数据比较多,会造成 Redis 长时间的阻塞。生产环境不建议使用这个命令。

​ 为了解决这个问题,Redis 提供了第二种方式。

​ b)bgsave

​ 执行 bgsave 时,Redis 会在后天异步进行快照操作,快照同时还可以响应客户端请求。

​ 具体操作是 Redis 进程执行 fork 操作创建子进程(copy-on-write),RDB 持久化过程由子进程负责,完成后自动结束。它不会记录 fork 之后产生的数据。阻塞只发生在 fork 阶段,一般时间很短。

6.1.2 RDB 数据的恢复(测试)

1、shutdown 持久化

​ 添加键值:

127.0.0.1:6379> flushdb
127.0.0.1:6379> set k1 1
127.0.0.1:6379> set k2 2
127.0.0.1:6379> set k3 3
127.0.0.1:6379> set k4 4
127.0.0.1:6379> set k5 5

​ 停止服务器,触发 save

127.0.0.1:6379> shutdown

​ 备份 dump.rdb 文件

$ cp dump.rdb dump.rdb.bak

​ 启动服务器

$ /usr/local/soft/redis-6.0.9/src/redis-server ../redis.conf

​ 数据都在:

127.0.0.1:6379> keys *

2、模拟数据丢失

​ 模拟数据丢失(删库跑路了),触发 save

127.0.0.1:6379> flushall

​ 停服务器:

127.0.0.1:6379> shutdown

​ 启动服务器:

$ /usr/local/soft/redis-6.0.9/src/redis-server ../redis.conf

​ 啥都没有:

127.0.0.1:6379> keys *

3、通过备份文件恢复数据

​ 停止服务器:

127.0.0.1:6379> shutdown

​ 重命名备份文件:

$ mv dump.rdb.bak dump.rdb

​ 启动服务器:

$ /usr/local/soft/redis-6.0.9/src/redis-server ../redis.conf

​ 查看数据:

127.0.0.1:6379> keys *
6.1.3 RDB 文件的优势和劣势

​ 一、优势

​ 1.RDB 是一个非常紧凑(compact)的文件,它保存了 redis 在某个时间点上的数据集。这种文件非常适用于进行备份和灾难恢复。

​ 2.生成 RDB 文件的时候,redis 主程序会 fork() 一个子程序来处理所有保存工作,主进程不需要进行任何磁盘 I/O 操作。

​ 3.RDB 在恢复大数据集时的速度比 AOF 的恢复速度要快。

​ 二、劣势

​ 1.RDB 方式数据没办法做到实时持久化/秒级持久化。因为 bgsave 每次运行都要执行 fork 操作创建子进程,频繁执行成本过高。

​ 2.在一定间隔时间做一次备份,所以如果 redis 意外 down 掉的话,就会丢失最后一次快照之后的所有修改(数据有丢失)。

​ 如果数据相对来比较重要,希望将损失降到最小,则可以使用 AOF 方式进行持久化。

6.2 AOF

​ Append Only File

​ AOF:Redis 默认不开启。AOF 采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更新 Redis 数据的命令时,就会把命令写入到 AOF 文件中。

​ Redis 重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复工作。

6.2.1 AOF 配置

​ 配置文件 redis.conf

# 开关
appendonly no
# 文件名
appendfilename "appendonly.aof"
参数说明
appendonlyRedis 默认只开启 RDB 持久化,开启 AOF 需要修改为yes
appendfilename “appendonly.aof”路径也是通过 dir 参数配置,查看配置命令:config get dir

​ AOF 文件的内容(vim 查看):

* 2
$ 6
SELECT
$ 1
0
* 3
$ 3
set
$ 8
test
$ 4
123

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

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

参数说明
appendfsync everysecAOF 持久化策略(硬盘缓存到磁盘),默认 everysec
* no 表示不执行 fsync,由操作系统保证数据同步到磁盘,速度最快,但是不太安全;
* always 表示每次写入都执行 fsync,以保证数据同步到磁盘,效率很低;
* everysec 表示每秒执行一次 fsync,可能会导致丢失这 1s 数据。通常选择 everysec,兼顾安全性和效率。

问题:文件越来越大,怎么办?

​ 由于 AOF 持久化是 Redis 不断将写命令记录到 AOF 文件中,随着 Redis 不断地进行,AOF 的文件会越来越大,文件越大,占用服务器内存越大以及 AOF 回复要求时间越长。

​ 例如计时器增加 100 万次,100 万个命令都记录进去了,但是结果只有 1 个。

​ 为了解决这个问题,Redis 新增了重写机制,当 AOF 文件的大小超过所设定的阈值时,Redis 就会启动 AOF 文件的内容压缩,只保留可以恢复数据的最小指令集。

​ 可以使用命令 bgrewriteaof 来重写。

​ AOF 文件重写并不是对源文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的 AOF 文件。

# 重写触发机制
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

请添加图片描述

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

请添加图片描述

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

请添加图片描述

6.2.2 AOF 数据恢复

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

6.2.3 AOF 优势与劣势

​ 优点:

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

​ 缺点:

​ 1.对于具有相同数据的 Redis,AOF 文件通常会比 RDB 文件体积更大(RDB存的是数据快照)。

​ 2.虽然 AOF 提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。在高并发的情况下,RDB 比 AOF 具有更好的性能保证。

6.3 两种方案比较

​ 那么对于 AOF 和 RDB 两种持久化方式,我们应该如何选择呢?

​ 如果可以忍受一小段时间内数据的丢失,毫无疑问使用 RDB 是最好的,定时生成 RDB快照(snapshot)非常便于进行数据库备份,并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快。

​ 否则就是用 AOF 重写。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当 redis 重启的时候会优先载入 AOF 文件来恢复历史的数据,因为在通常情况下 AOF 文件保存的数据集要比 RDB 文件保存的数据集要完整。


整理完毕,完结撒花~ 🌻

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不愿放下技术的小赵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值