redis高级特性和相关原理

redis高级特性和相关原理

发布订阅模式

列表的局限

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

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

如果要实现一对多的消息分发,可以使用,Redis的发布订阅功能。

订阅频道

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

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

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

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

1
subscribe channel-l channel-2 channel-3

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

1
publish channel-1 2673

取消订阅(不能在订阅状态下使用):

1
unsubscribe channel-1

按规则(Pattern)订阅频道

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

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

三个消费者

消费端1,关注运动信息:

1
psubscribe *sport

消费端2,关注所有新闻:

1
psubscribe news*

消费端3,关注天气新闻:

1
psubscribe news-weather

image-20210825220820993

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

1
2
3
publish news-sport kobe 
publish news-music jaychou
publish news-weather sunny

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

Redis事务

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

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

Redis提供了事务的功能,可以把一组命令一起执行。

Redis的事务有3个特点:

1、按进入队列的顺序执行。

2、不会受到其他客户端的请求的影响。

3、事务不能嵌套,多个multi命令效果一样。

事务的用法

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

案例场景:a和b各有1000元,a向b转账100元。

1
2
3
4
5
6
7
set a 1000 set b 1000 
multi
decrby a 100
incrby b 100
exec
get a
get b

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

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

如果中途不想执行事务了,可以调用discard可以清空事务队列,放弃执行。

1
2
3
4
multi
decrby a 100
discard
get a

watch命令

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

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

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

image-20210825222014059

事务可能遇到的问题

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

在执行exec之前发生错误

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

1
2
3
4
5
mutil
set a 2673
set b yes
hset c 666
exec

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

在执行exec之后发生错误

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

1
2
3
4
5
6
7
8
flushall
multi
set kl l
hset kl ab
exec
1) OK
2) (error) WRONGTYPE Operation againstakey holding the wrong kind of value
get kl

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

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

为什么不回滚?

官方的解释是这样的:

Redis命令只会因为错误的语法而失败,也就是说,从实用性的角度来说,失败的命令是由代码错误造成的,而这些错误应该在开发的过程中被发现,而不应该出现在生产环境中(这个是程序员的锅)。

因为不需要对回滚进行支持,所以Redis的内部可以保持简单且快速。需要知道的是:回滚不能解决代码的问题(程序员的锅必须程序员来背)。

Redis从2.6版本开始引入了Lua脚本,也就是说Redis可以用Lua来执行Redie 命令。

Lua脚本

Lua/‘lua/是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。

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

1、一次发送多个命令,减少网络开销。

2、Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。

3、对于复杂的组合命令,我们可以放在文件中,可以实现命令复用。

在Redis中调用Lua脚本

使用eval 方法,语法格式:

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

eval 代表执行Lua语言的命令。

lua-script 代表Lua语言脚本内容。

key-num表示参数中有多少个key,需要注意的是Redis中key是从1开始的,如果没有key的参数,那么写0。

[key1 key2 key3] 是key作为参数传递给Lua语言,也可以不填,但是需要和key-num的个数对应起来。

[value1 value2 value3] 这些参数传递给Lua语言,它们是可填可不填的。

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

1
redis> eval "return 'Hello World'" 0

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

在Lua脚本中调用Redis命令

命令格式

使用 redis.call 进行操作。

语法格式:

1
redis.call(command, key [paraml,param...])

command是命令,包括set、get、del等。

key是被操作的键。

param1,param2..代表给key的参数。

来看一个简单的案例,让Lua脚本执行set a 2673(Redis客户端执行)

1
eval "return redis.call('set','qingshan','2673')" 0

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

1
eval "return redis.call('set',KEYS[1],ARGV[1])" l qingshan miaomiaomiao

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

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

缓存Lua脚本

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

如何缓存

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

1
script load "returm 'Hello World'"

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

1
evalsha "470877a599ac74fbfda4lcaa908de682c5fc7d4b" 0

脚本超时

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

1
eval 'while(true) do end' 0

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

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

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

1
lua-time-limit 5000

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

这样也不行,不能一直拒绝其他客户端的命令执行吧。在提示里面我们也看到了,有两个命令可以使用,第一个是script kill,中止脚本的执行。但是需要注意:并不是所有的lua脚本执行都可以kil。如果当前执行的Lua脚本对Redis的数据进行了修改(SET、DEL等),那么通过script kill命令是不能终止脚本运行的。

这时候执行scriptkill会返回UNKILLABLE错误。

为什么要这么设计?为什么包含修改的脚本不能中断?

因为要保证脚本运行的原子性。如果脚本执行了一部分被终止那就违背了脚本原子性的目标。

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

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

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

Redis为什么这么快

根据测试结果,Redis的QPS 大概10万,在高性能的服务器上性能还能更强。

Redis快的原因总结起来主要是三点:

1、纯内存结构,

2、请求处理单线程

3、多路复用机制

内存

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

单线程

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

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

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

1、没有创建线程、销毁线程带来的消耗

2、避免了上下文切换导致的CPU消耗

3、避免了线程之间带来的竞争问题,例如加锁释放锁死锁等等

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

官方的解释是这样的:

在Redis中单线程已经够用了,CPU不是redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。

既然单线程容易实现,又不需要处理线程并发的问题,那就顺理成章地采用单线程的方案了。

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

I/O多路复用(I/O Multiplexing)

I/O指的是网络I/O。

多路指的是多个TCP连接(Socket或Channel)。

复用指的是复用一个或多个线程。

它的基本原理就是不再由应用程序自己监视连接,而是由内核替应用程序监视文件描述符。

客户端在操作的时候,会产生具有不同事件类型的socket,在服务端,I/O多路复用程序(I/OMultiplexing Module)会把消息放入队列中,然后通过文件事件分派器(Fileevent Dispatcher),转发到不同的事件处理器中。

image-20210825231238408

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

image-20210825231336582

所以,I/0多路复用的特点是通过一种机制让一个进程能同时等待多个文件描述符,而这些文件描述符其中的任意一个进入读就绪(readable)状态,select()函数就可以返回。

多路复用需要操作系统的支持。Redis的多路复用,提供了select,epoll,evport,kqueue几种选择,在编译的时候来选择一种。

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

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

kqueue是Mac系统提供支持的;

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

总结一下:

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

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

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

我们这里说的多线程,确切地说,叫做多线程I/O。

多线程I/O

服务端的数据返回给客户端,需要从内核空间copy数据到用户空间,然后回写到socket (write调用),这个过程是非常耗时的。所以多线程I/O指的就是把结果写到 socket的这个环节是多线程的。

处理请求依然是单线程的,所以不存在线程并发安全问题。

内存回收

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

这里我们解决要两个问题:

首先,作为一个内存的KV系统,Redis服务肯定不是无限制地使用内存,应该设置一个上限(max_memory)。

第二个,数据应该有过期属性,这样就能清除不再使用的key。

过期策略

立即过期(主动淘汰)

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

惰性过期(被动淘汰)

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

例如所有的查询都会调用expirelfNeeded判断是否过期。

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

定期过期

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

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

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

淘汰策略

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

最大内存设置

redis.conf参数配置:

1
# maxmemory <bytes>

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

动态修改(先get一下):

1
redis> config set maxmemory 2GB

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

淘汰策略

1
2
3
4
5
6
7
# 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 arandom 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。

image-20210825232730996

image-20210825232831491

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

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

持久化机制

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

持久化是Redis跟Memcache的主要区别之一。

RDB

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

什么时候写入rdb文件?

RDB触发

自动触发

a)配置规则触发

redis.conf

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

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

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

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

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

1
2
3
4
5
6
7
#文件路径,
dir./ #文件名称
dbfilename dump.rdb
#是否以LZF压缩rdb文件
rdbcompression yes
#开启数据校验
rdbchecksum yes

image-20210825234236641

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

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

c) flushall,rdb文件是空的,没什么意义。

手动触发

如果我们需要重启服务或者迁移数据,这个时候就需要手动触RDB快照保存。

Redis提供了两条命令:

a) save

save在生成快照的时候会阻塞当前Redis服务器,Redis不能处理其他命令,如果内存中的数据比较多,会造成Redis长时间的阻塞。生产环境不建议使用这个命令。为了解决这个问题,Redis提供了第二种方式。

b) bgsave

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

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

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

1
2
3
4
#开关
appendonly no
#文件名
appendfilename "appendonly.aof"

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

image-20210825235041015

image-20210825235106672

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

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

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

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

image-20210825235311213

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

image-20210825235445570

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

image-20210825235706313

A0F数据恢复

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

A0F优势与劣势

优点:

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

缺点:

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

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

两种方案比较

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

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

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

打赏

请我喝杯咖啡吧~

支付宝
微信