1.订阅模式
场景
假设我们聊天的所有信息都存储在redis中,我们应该如何实现这个功能?
列表的局限性
前面我们说通过队列的rpush和lpop可以实现消息队列(队尾进队头出),但是消费者需要不停地调用lpop查看List中是否有等待处理的消息(比如写一个while循环)。为了减少通信的消耗,可以sleep()一段时间再消费,但是会有两个问题:
- 如果生产者生产消息的速度远大于消费者消费消息的速度,List会占用大量的内 存。
- 消息的实时性降低。
订阅模式
除了通过list实现消息队列之外,Redis还提供了一组命令实现发布/订阅模式。这种方式,发送者和接收者没有直接关联(实现了解耦),接收者也不需要持续尝试获取消息。
订阅频道
首先,我们有很多的频道(channel),我们也可以把这个频道理解成 queue。订阅者可以订阅一个或者多个频道。消息的发布者(生产者)可以给指定的频道发布消息。
只要有消息到达了频道,所有订阅了这个频道的订阅者都会收到这条消息。
需要注意的注意是,发出去的消息不会被持久化,因为它已经从队列里面移除了,所以消费者只能收到它开始订阅这个频道之后发布的消息。
- 订阅者订阅频道:可以一次订阅多个,比如这个客户端订阅了3个频道。
- 发布者可以向指定频道发布消息(并不支持一次向多个频道发送消息):
- 取消订阅(不能在订阅状态下使用):
- 还可以使用占位符 ?代表一个字符 *代表0个或者多个字符
总结
虽然Redis提供了生产者-消费者模式,不过Redis他的主要功能还是缓存,如果真的需要这种功能,个人更推荐ActiveMQ、RocketMQ等等这种专门的中间件。
2.事务
- Redis 的事务涉及到四个命令:multi(开启事务),exec(执行事务),discard(取消事务),watch(监视),unwatch(取消监视)
为什么需要事务
我们知道Redis 的单个命令是原子性的(比如 get set mget mset),如果涉及到多个命令的时候,需要把多个命令作为一个不可分割的处理序列,就需要用到事务。
例如用setnx实现分布式锁,我们先set,然后设置对key设置expire,防止del发生异常的时候锁不会被释放,业务处理完了以后再del,这三个动作我们就希望它们作为一组命令执行。
Redis的事务有两个特点:
- 按进入队列的顺序执行。
- 不会受到其他客户端的请求的影响。
事务的用法
通过multi的命令开启事务。事务不能嵌套,多个multi命令效果一样。
multi执行后,客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行, 而是被放到一个队列中, 当 exec 命令被调用时, 所有队列中的命令才会被执行。
通过exec的命令执行事务。如果没有执行exec,所有的命令都不会被执行。
可以调用discard可以清空事务队列,放弃执行。
watch命令
在Redis中还提供了一个watch命令。
它可以为 Redis 事务提供 CAS 乐观锁行为(Check and Set / Compare andSwap),也就是多个线程更新变量的时候,会跟原值做比较,只有它没有被其他线程修改的情况下,才更新成新的值。
我们可以用watch监视一个或者多个key,如果开启事务之后,至少有一个被监视key键在 exec 执行之前被修改了, 那么整个事务都会被取消(key提前过期除外)。可以用unwatch取消。
事务的回滚
- 我们把事务执行遇到的问题分成两种
- 在执行exec之前发生错误
在这种情况下事务会被拒绝执行,也就是队列中所有的命令都不会得到执行。
- 一种是在执行exec之后发生错误。
最后我们发现setk11的命令是成功的,也就是在这种发生了运行时异常的情况下,只有错误的命令没有被执行,但是其他命令没有受到影响。
这个显然不符合我们对原子性的定义,也就是我们没办法用Redis 的这种事务机制来实现原子性,保证数据的一致,为什么?
- redis追求的就是'快'、'轻量',所以如果内部还需要进行回滚势必会增加复杂度。
- redis中存储的数据大部分不会特别重要,对准确性的要求不会过于高。
- redis的错误通常不是因为数据的问题,而是程序员自己的问题,这种问题在测试的时候可以发现,在真正的生成环境不会出现此类问题。
3.Lua脚本
定义
Lua/ˈluə/是一种轻量级脚本语言,它是用C语言编写的,跟数据的存储过程有点类似。 使用Lua脚本来执行Redis命令的好处:
- 一次发送多个命令,减少网络开销。
- Redis会将整个脚本作为一个整体执行,不会被其他请求打断,保持原子性。
- 对于复杂的组合命令,我们可以放在文件中,可以实现程序之间的命令集复 用。
如何使用 Lua 脚本
对于如何使用有兴趣的话可以自己了解
缓存 Lua 脚本
在脚本比较长的情况下,如果每次调用脚本都需要把整个脚本传给 Redis服务端,会产生比较大的网络开销。为了解决这个问题,Redis提供了EVALSHA命令,允许开发者通过脚本内容的SHA1摘要来执行脚本。
Redis在执行script load命令时会计算脚本的SHA1摘要并记录在脚本缓存中,执行 EVALSHA 命令时 Redis 会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了则执行脚本,否则会返回错误:"NOSCRIPT No matching script. Please useEVAL."
- 根据某种算法,把当前脚本的内容保存起来并返回一串数组+字母,代表该脚本
返回 "be4f93d8a5379e5e5b768a74e77c8a4eb0434441"
调用: set num2 OK
evalsha be4f93d8a5379e5e5b768a74e77c8a4eb04344411
num6 (integer)12
脚本超时
Redis的指令执行本身是单线程的,这个线程还要执行客户端的Lua脚本,如果Lua脚本执行超时或者陷入了死循环,如何处理
eval'while(true)doend'0
为了防止某个脚本执行时间过长导致 Redis 无法提供服务,Redis 提供了lua-time-limit参数限制脚本的最长运行时间,默认为5秒钟。
lua-time-limit 5000(redis.conf配置文件中)
当脚本运行时间超过这一限制后,Redis将开始接受其他命令但不会执行(以确保脚本的原子性,因为此时脚本并没有被终止),而是会返回“BUSY”错误。
Redis提供了一个script kill的命令来中止脚本的执行。新开一个客户端:
scriptkill
如果当前执行的Lua 脚本对 Redis 的数据进行了修改(SET、DEL 等),那么通过script kill命令是不能终止脚本运行的。
遇到这种情况,只能通过shutdown nosave命令来强行终止redis。
shutdown nosave 和 shutdown 的区别在于 shutdown nosave 不会进行持久化 操作,意味着发生在上一次快照后的数据库修改都会丢失。
总结
如果我们有一些特殊的需求,可以用Lua来实现,但是要注意那些耗时或者出现死循环的操作。
4.工作机制
Redis到底有多快
横轴:连接数;纵轴:QPS
根据官方的数据:Redis的QPS可以达到100000左右(每秒请求数)。
Redis为什么这么快
-
纯内存结构:KV结构的内存数据库,时间复杂度O(1)。
-
多路复用:异步非阻塞I/O,多路复用处理并发连接。
-
单线程:避免了线程之间竞争问题和CPU上下文切换的消耗。
Redis为什么用单线程
官网解释:
因为单线程已经够用了,CPU不是redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,而且CPU不会成为瓶颈,那就顺理成章地采用单线程的方案了。
内存为什么比磁盘快这么多
简单说:少了一次写到磁盘上的操作
单线程
多任务操作系统是怎么实现运行远大于CPU数量的任务个数的?当然,这些任务实际上并不是真的在同时运行,而是因为系统通过时间片分片算法,在很短的时间内,将CPU 轮流分配给它们,造成多任务同时运行的错觉。
为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。
什么叫上下文?
在每个任务运行前,CPU 都需要知道任务从哪里加载、又从哪里开始运行,也就是说,需要系统事先帮它设置好 CPU 寄存器和程序计数器(ProgramCounter),这个叫做CPU的上下文。
而这些保存下来的上下文,会存储在系统内核中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。在切换上下文的时候,需要完成一系列的工作,这是一个很消耗资源的操作。
采用单线程能节省大量的时间
多路复用
I/O指的是网络I/O。
多路指的是多个TCP连接(Socket或Channel)。
复用指的是复用一个或多个线程。
它的基本原理就是不再由应用程序自己监视连接,而是由内核替应用程序监视文件描述符。
客户端在操作的时候,会产生具有不同事件类型的socket。在服务端,I/O 多路复用程序(I/OMultiplexingModule)会把消息放入队列中,然后通过文件事件分派器(Fileevent Dispatcher),转发到不同的事件处理器中。
多路复用有很多的实现,以select为例,当用户进程调用了多路复用器,进程会被阻塞。内核会监视多路复用器负责的所有socket,当任何一个socket的数据准备好了,多路复用器就会返回。这时候用户进程再调用read操作,把数据从内核缓冲区拷贝到用户空间。
所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪(readable)状态, select()函数就可以返回。
Redis 的多路复用, 提供了 select, epoll, evport, kqueue 几种选择,在编译的时
候来选择一种。源码ae.c
evport 是Solaris系统内核提供支持的;
epoll 是LINUX系统内核提供支持的;
kqueue是Mac 系统提供支持的;
select是POSIX提供的,一般的操作系统都有支撑(保底方案);
5.内存回收机制
Reids所有的数据都是存储在内存中的,在某些情况下需要对占用的内存空间进行回收。内存回收主要分为两类,一类是key过期,一类是内存使用达到上限(max_memory)触发内存淘汰。
过期策略
- 定时过期(主动淘汰)
每个设置过期时间的key都需要创建一个定时器,到过期时间就会立即清除。该策略可以立即清除过期的数据,对内存很友好;但是会占用大量的CPU资源去处理过期的数据,从而影响缓存的响应时间和吞吐量。
- 惰性过期(被动淘汰)
只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
- 定期过期
每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清 除其中已过期的key。该策略是前两者的一个折中方案。通过调整定时扫描的时间间隔和 每次扫描的限定耗时,可以在不同情况下使得CPU和内存资源达到最优的平衡效果。
- Redis中同时使用了惰性过期和定期过期两种过期策略。
淘汰策略
如果没有手动设置expire,他会在内存满了的时候根据淘汰策略进行删除。
如果不设置maxmemory 或者设置为0,64位系统不限制内存,32位系统最多使用3GB内存。
LRU淘汰原理
最近最少使用。判断最近被使用的时间,目前最远的数据优先被淘汰。
Redis LRU对传统的LRU算法进行了改良,通过随机采样来调整算法的精度。如果淘汰策略是LRU,则根据配置的采样值maxmemory_samples(默认是5个),随机从数据库中选择m个 key, 淘汰其中热度最低的 key对应的缓存数据。所以采样参数m配置的数值越大, 就越能精确的查找到待淘汰的缓存数据,但是也消耗更多的CPU计算,执行效率降低。
Redis中所有对象结构都有一个lru字段, 且使用了unsigned的低24位,这个字段用来记录对象的热度。对象被创建时会记录lru值。在被访问的时候也会更新lru的值。但是不是获取系统当前的时间戳,而是设置为全局变量server.lruclock的值。
Redis 中 有 个 定 时 处 理 的 函 数 serverCron , 默认每100毫秒调用函数updateCachedTime 更新一次全局变量的 server.lruclock 的值,它记录的是当前unix时间戳。
OK,当对象里面已经有了LRU字段的值,就可以评估对象的热度了。
函数estimateObjectIdleTime评估指定对象的lru热度,思想就是对象的lru值和全局的server.lruclock的差值越大(越久没有得到更新), 该对象热度越低。
这种淘汰算法的问题也很明显:如果有A数据每1小时访问一次,B数据没10分钟访问一次,刚好在A访问完成后进行数据淘汰,那么B数据就会被清除,这就显得很不合理。
LFU淘汰策略
用24 bits用作LFU时,其被分为两部分:
高16位用来记录访问时间(单位为分钟,ldt,last decrement time)
低8位用来记录访问频率,简称counter(logc,logistic counter)
counter是用基于概率的对数计数器实现的,8位可以表示百万次的访问频率。
对象被读写的时候,lfu的值会被更新。
增长的速率由,lfu-log-factor越大,counter增长的越慢
redis.conf配置文件
如果计数器只会递增不会递减,也不能体现对象的热度。没有被访问的时候,计数器怎么递减呢?
减少的值由衰减因子lfu-decay-time(分钟)来控制,如果值是1的话,N分钟没有访问就要减少N。
redis.conf配置文件
6.持久化
Redis速度快,很大一部分原因是因为它所有的数据都存储在内存中。如果断电或者宕机,都会导致内存中的数据丢失。为了实现重启后数据不丢失,Redis提供了两种持久化的方案
RDB
RDB 是 Redis 默认的持久化方案。当满足一定条件的时候,会把当前内存中的数据写入磁盘,生成一个快照文件dump.rdb。Redis 重启会通过加载dump.rdb文件恢复数据。
- 自动触发
redis.conf中配置
SNAPSHOTTING,其中定义了触发把数据保存到磁盘的触发频率。
RDB还有两种触发方式: 1)shutdown触发,保证服务器正常关闭。 2)flushall,RDB文件是空的。
- 手动触发 如果我们需要重启服务或者迁移数据,这个时候就需要手动触RDB快照保存。Redis 提供了两条命令:
a)save
save在生成快照的时候会阻塞当前Redis服务器, Redis不能处理其他命令。如果内存中的数据比较多,会造成Redis长时间的阻塞。生产环境不建议使用这个命令。为了解决这个问题,Redis提供了第二种方式。
b)bgsave
执行bgsave时,Redis会在后台异步进行快照操作,快照同时还可以响应客户端请 求。 具体操作是Redis进程执行fork操作创建子进程(copy-on-write),RDB持久化过程由子进程负责,完成后自动结束。它不会记录fork之后后续的命令。阻塞只发生在 fork阶段,一般时间很短。
用lastsave命令可以查看最近一次成功生成快照的时间。
RDB的优势和缺点
- 优势
- RDB 是一个非常紧凑(compact)的文件,它保存了 redis 在某个时间点上的数据 集。这种文件非常适合用于进行备份和灾难恢复。
- 生成RDB文件的时候,redis主进程会fork()一个子进程来处理所有保存工作,主 进程不需要进行任何磁盘IO操作。
- RDB 在恢复大数据集时的速度比AOF的恢复速度要快。
- 劣势
- RDB方式数据没办法做到实时持久化/秒级持久化。因为bgsave每次运行都要 执行fork操作创建子进程,频繁执行成本过高。
- 在一定间隔时间做一次备份,所以如果redis意外down掉的话,就会丢失最后 一次快照之后的所有修改(数据有丢失)。
如果数据相对来说比较重要,希望将损失降到最小,则可以使用AOF方式进行持久 化
AOF
Append Only File AOF:Redis 默认不开启。AOF采用日志的形式来记录每个写操作,并追加到文件中。开启后,执行更改Redis数据的命令时,就会把命令写入到AOF文件中。
Redis 重启时会根据日志文件的内容把写指令从前到后执行一次以完成数据的恢复 工作。
由于操作系统的缓存机制,AOF数据并没有真正地写入硬盘,而是进入了系统的硬盘缓存
由于AOF持久化是Redis不断将写命令记录到 AOF 文件中,随着Redis不断的进行,AOF 的文件会越来越大,文件越大,占用服务器内存越大以及 AOF 恢复要求时间越长。
例如set A 666,执行1000次,结果都是A=666。
为了解决这个问题,Redis新增了重写机制,当AOF文件的大小超过所设定的阈值时,Redis就会启动AOF文件的内容压缩,只保留可以恢复数据的最小指令集。可以使用命令bgrewriteaof来重写。
AOF 文件重写并不是对原文件进行重新整理,而是直接读取服务器现有的键值对,然后用一条命令去代替之前记录这个键值对的多条命令,生成一个新的文件后去替换原来的 AOF 文件。
重写过程中,AOF文件被更改了怎么办?
重启之后就会对redis进行AOF文件的恢复
AOF的优势和缺点
- 优点
- AOF 持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步一次,Redis 最多也就丢失 1 秒的数据而已。
- 缺点
- 对于具有相同数据的的 Redis,AOF 文件通常会比 RDF 文件体积更大(RDB存的是数据快照)。
- 虽然 AOF 提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。在高并发的情况下,RDB 比 AOF 具好更好的性能保证。
如何选择
那么对于 AOF 和 RDB 两种持久化方式,我们应该如何选择呢?
如果可以忍受一小段时间内数据的丢失,毫无疑问使用 RDB 是最好的,定时生成RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快。
否则就使用 AOF 重写。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。