Redis
Posted java_wxid
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis相关的知识,希望对你有一定的参考价值。
—
文章目录
多路复用
redis的多路复用模式
redis使用模型有:select、poll、epoll。这里简单讲二种。
应用对外提供服务的过程
一个应用程序, 想对外提供服务, 一般都是通过建立套接字监听端口来实现, 也就是socket。
应用对外提供服务的过程:
- 创建套接字
- 绑定端口号
- 开始监听
- 当监听到连接时, 调用系统read去读取内容,但是读取操作是阻塞的。
select
问题:如果主线程处理read,就不能接收其他连接了, 所以只能开新的线程去处理这个事情。而且read操作是调用系统函数, 需要进行进程的切换, 从用户进程切换到系统进程,连接少还好,连接多的话,无疑降低了性能。
解决方案:用户线程批量将要查询的连接发给操作系统,这个过程只发生一次进程的切换,用户线程告诉操作系统,需要哪些数据, 它遍历查找,然后将结果返回给用户线程,这就是select。
操作系统接收到一组文件描述符,然后批量处理这些文件描述符,有顺序的循环检查有没有数据,然后返回结果。无论是监听端口还是建立了连接,程序拿到的都是一个文件描述符,将这些文件描述符批量查询就是了。
epoll
问题:有10万个连接,其中有数据的只有一个,那就回有9999次无效的操作,每次查询都要把所有的都传过去, 10万个就要传10万。
解决方案:直接知道哪些连接是有数据的,然后操作系统通知用户线程,那个连接是可以直接拿到数据的,用户线程就直接通过这个连接去读数据就好了,不需要遍历,这就是epoll。
建立一个需要回调的连接, 将需要监听的文件描述符都扔给操作系统,当有新数据到达时,会直接返回给用户线程。用户线程将监听的列表交给操作系统维护,这样当有新数据来的时候,操作系统知道这是你要的,等你下次来拿的时候,直接给你了,少去了上面的遍历。
多路复用的定义
“多路”指的是多个网络连接,“复用”指的是复用同一个线程。
采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。
多路复用的举例
redis 需要处理 3 个 IO 请求,同时把 3 个请求的结果返回给客户端,所以总共需要处理 6 个 IO 事件, 由于 Redis服务端对于命令的处理是单线程的,同一时间只能处理一个 IO 事件。于是 redis 需要在合适的时间暂停对某个 IO 事件的处理,转而去处理另一个 IO 事件, 这样 redis 就好比一个开关,当开关拨到哪个 IO 事件这个电路上,就处理哪个 IO 事件,其他 IO 事件就暂停处理了。这就是IO多路复用技术。用一句话总结就是,一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上。
多路复用的实现
select, poll, epoll 都是I/O多路复用的具体的实现。epoll性能比其他几者要好。redis中的I/O多路复用的所有功能通过包装常见的select、epoll、evport和kqueue这些I/O多路复用函数库来实现的。 redis的io模型主要是基于epoll实现的,不过它也提供了 select和poll的实现,默认采用epoll。
过程一:数据未就绪
多个客户端并发请求时,用户线程发起请求的时候,首先会将socket监听列表添加到select中,让select调用操作系统的API,这个过程就是从用户态到内核态,由于当前请求是交给了操作系统去处理,现在的用户线程这时候就空闲了,可以重新接收新的客户端请求。
操作系统等待select调用,当数据到达时,select函数被激活,操作系统将select函数的结果socket监听结果为可读,返回给用户线程,告诉用户线程这个连接可以读取数据了,这个时候用户线程才正式发起read请求,读取数据,这个读取数据的过程也是非阻塞的。
过程二:数据就绪
read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。只有当操作系统告诉用户线程数据已经准备就绪的时候,数据从内核缓冲区拷贝到用户缓冲区才通知用户进程调用完成,返回结果,这个过程是阻塞的。
对于用户线程来说,它可以注册多个socket监听,然后不断地调用select读取,操作系统找到用户线程需要的连接,这个连接里面监听到有用户线程所需要的数据,就会激活socket把结果返回给用户线程。
单线程模型
为什么redis使用单线程模型还能保证高性能?
第一个是因为redis 是纯内存操作,内存的响应时长是 100 纳秒左右,这是 redis 的 QPS 过万的重要基础。
第二个是因为redis 的核心是基于非阻塞的IO多路复用机制,单线程模型避免了线程切换和竞态产生的消耗,解决了多线程的切换性能损耗问题。
第三个是因为redis底层使用C语言实现,一般来说,C 语言实现的程序"距离"操作系统更近,执行速度相对会更快。
你是如何理解redis单线程模型的?
Redis 里面的单线程主要是 Redis 的网络 IO 和键值对读写,它是由一个线程来完成的,但是 Redis 的其他功能, 比如说持久化、异步删除、集群数据同步等等,这些其实是由额外的线程执行的,这里的单线程主要是Redis 对外提供键值存储服务来说的。
主要流程是这样的:redis 会将每个客户端都关联一个指令队列,客户端的指令通过队列来按顺序处理,先到先处理,一个客户端指令队列中的指令是按顺序执行的。 redis 的每个客户端都关联一个响应队列,通过响应队列有顺序地将指令的返回结果返回给客户端,并且redis 同一时间每次都只能处理一个客户端队列中的指令或者响应。
Redis底层数据结构
简单字符串
先简单了解一下C语言是怎么处理字符串的:
在C语言中,字符串结束的标识是空字符,也就是’’,这会有一个问题,就是字符串的内容可能包括空字符串,这个时候是不是就没办法正确存取字符串的内容了,它有可能中途读取一半就完了。
除此之外,它还不记录字符串的长度,这也会有一系列问题,
如果需要获取字符串的长度通过遍历计数来获取的,这会导致它的时间复杂度会比较高。
如果需要修改字符串,就要重新分配内存,不重新分配的话,字符串长度增大,超出给定的长度,这个时候会造成内存缓冲区溢出,字符串长度减小还会造成内存泄露。
如果需要对两个字符串进行拼接,是通过调用strcat函数来实现的,如果没有给它分配足够长度的内存空间,就会直接导致缓冲区溢出。
既然C语言处理字符串有这么多的弊端,那么Redis它是怎么处理字符串的呢?
Redis专门创建了一种数据结构SDS,什么意思呢?simple dynamic string,简单字符串。
官方代码:
struct sdshdr
int len;
int free;
char buf[];
这个对象有三个属性:
- len表示字符串的长度
- free表示还有多少长度剩余,就是下面buf数组中还有多少字符串未使用的字节数量
- buf[]表示存储的字符串
问题一:这种数据结构有什么优势呢?跟C语言相比,改进了哪些问题?
长度和内存重新分配问题,C语言是不记录长度,而SDS它有len属性和free属性。
len记录了字符串的长度,直接取值就可以了,不像C语言需要遍历。 如果需要对字符串进行修改的话,也不需要像C语言一样,直接重新分配内存,
它可以通过len 属性检查内存空间是不是需要进行扩展内存,如果字符串长度增加,长度超过了len,就会增加相应的内存,接着修改。
如果字符串长度缩短了,它也不会立马就重新分配内存,而是有一个free属性记录下来,等你后面什么时候用了,重新计算或者分配内存。
结尾标识问题,C语言是以空字符串结尾标识的,而SDS是以len长度作为结尾标识的,避免了C语言无法正确读取字符串的问题。
链表
Redis的list类型的键值对底层数据结构是由链表构成的,那么链表是什么呢?
它是由一连串节点组成,没有顺序,不是连续的,每个节点由数据和一或两个用来指向上一个或下一个节点位置的链接组成,在每一个节点里存到下一个节点的指针,通过链表中的指针链接次序可以实现逻辑顺序。
链表也分好几种:单向链表、双端链表、双向链表、有序链表以及有迭代器的链表
单向链表:用户的操作(添加、删除、遍历)只能从链表头开始。向一个方向遍历,查找一个节点的时候从第一个节点开始访问下一个节点,一直访问到需要的位置,最后一个节点存储地址的部分指向空值。
双端链表:双端链表相对于单端链表多了一个特性:对最后一个链接点的引用
双向链表:单端链表只能从链表头开始正向遍历,双向链表可以逆向遍历,每个节点需要保存前一个节点和后一个节点的引用
有序链表:插入元素时,将插入的元素与头结点及其后面的结点比较,找到合适的位置插入。
有迭代器的链表:单链表的基本操作中,大部分要用到依次遍历单链表中的每一个元素。当你新增一个对单链表的操作并需要使用遍历时,你就得重新写一个for循环而实现遍历。所以将迭代(遍历)作为一种基本的ADT(抽象数据类型)操作。链表中用于处理遍历、访问和更新的方法封装到一个新的迭代器类中。
跳跃表
跳跃表:跳跃表基于有序链表的扩展,在链表上建索引,每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引,每个跳跃表节点的层高都是1至32之间的随机数。
举例说明:
比如给一个长度为7的有序链表,节点值依次是1->3->4->5。取出所有值为奇数的节点作为关键节点(索引),这个时候要插入一个值是2的新节点,就不需要将节点一个个比较,只要比较1,3,5,确定了值在1和3之间,就可以快速插入。
加一层索引之后,查找一个结点需要遍历的结点个数减少了,虽然增加了50%的额外空间,但是查找效率提高了,同理再加一级索引,这种链表加多级索引的结构,就是跳跃表。
索引是占内存的,原始链表中存储的可能是大的对象,索引结点只要存储关键值和几个指针,并不需要存储对象,当节点本身比较大或者元素数量比较多的时候,优势必然会被放大,而缺点则可以忽略。
问题:当大量的新节点通过逐层比较,最终插入到原链表之后,上层的索引节点会慢慢的不够用,那么这个时候要怎么选取一部分节点提到上一层呢?
抛硬币法:随机决定新节点是否选拔,每向上提拔一层的几率是50%。
原因:跳跃表的删除和添加节点是无法预测的,不能保证索引绝对分步均匀,不过可以让大体趋于均匀。
插入节点的工作流程:跳跃表插入操作的时间复杂度是O(logN),空间复杂度是 O(N)。
- 第一步:新节点和上层索引节点逐个比较,找到原链表的插入位置,时间复杂度为O(logN)
- 第二步:把索引插入到原链表,时间复杂度为O(1)
- 第三步:随机决定新节点是否提升为上一级索引,结果为"正面"则提升,继续抛硬币,结果为"反面"则停止,时间复杂度为O(logN)
删除节点的工作流程:跳跃表删除操作的时间复杂度是O(logN)
- 第一步:自上而下,查找第一次出现节点的索引,并逐层找到每一层对应的节点。时间复杂度为O(logN)
- 第二步:删除每一层查找到的节点,如果该层只剩下1个节点,删除整个一层(原链表除外)。时间复杂度为O(logN)
跳跃表由zskiplistNode和skiplist两个结构组成,zskiplistNode用于表示跳跃表节点,zskiplist用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
字典
字典,顾名思义,通过字典(牛津字典等)前面的目录快速定位到所要查找的单词。
在C 语言中没有这种数据结构,所以这种数据结构是Redis自己创造的,字典中的键都是唯一的,通过键可以对值来进行查询或更改。
底层是通过哈希表实现的,而哈希表又基于数组,类似于key-value的结构形式进行存储的,它的值通过哈希函数映射为数组的下标。
那什么是哈希函数呢?不急,我们慢慢道来。
前面我们讲了通过数组的方式存储值,那么数组的值和数组的下标怎么建立关联关系呢?或者说,我们怎么通过数组的下标找到数组的值呢?
在学习 ASCII 编码的时候,我们知道,a可以用97这个数值表示,b可以用98这个数值表示,以此类推,我们就可以通过单个字母用数字表达。
有了字母,那么一个单词由多个字母组成,它又该如何表达呢?
假设我有一本字典,它有10000个单词,我其中一个单词就是ab,使用ASCII编码进行表达。
ab = 97 + 98 = 195
那么存储在数组中的下标为195,这就是字母表达的基本原理,但是如果只是这样还是远远不够的,因为会出现一个数组存储多个单词的情况。
举例说明:假设有个单词有 10 个字母,那么字典的某个单词为 zzzzzzzzzz ,转换为数字:zzzzzzzzzz = 26*10 = 260。
补充说明:这个时候会发现我一本字典里10000个单词,在260这个范围内肯定是不够存储10000个单词的,10000/260=39(38.4补一位),一个数组项它要存储39个单词。
解决方案:为了保证数值的唯一,让每个数组都能够只存储一个单词,进行升级, 将单词表示的数拆开,27 的幂乘以这些位数,有26个可能的字符,以及空格,一共27个。
ab = 97乘以27的一次幂加上98乘以27的零次幂 = 27*97 + 98 = 2717。解决了数组存储多个单词的问题,又引出新的问题数组分配大空间太多了。
举例说明:假设有个单词有 10 个字母,那么字典的某个单词为 zzzzzzzzzz ,转换为数字:zzzzzzzzzz = 26的9次幂 = 7000000000000
补充说明:数组中只有小部分存放了单词,其他空间都是空着的
解决方案:将巨大的整数范围压缩到可接受的数组范围内,可以通过取余解决,一个整数被另一个整数除后的余数。
举例说明:假设要把从0-99的数字(用large表示),压缩为从0-9的数字(用number表示),后者有10个数,所以变量range 的值为10,这个转换的表达式为:
补充说明:number = large % range。当一个整数被 10 整除时,余数是在0-9之间,把从0-99的数压缩为从0-9的数,压缩率为 10 :1。
使用哈希函数向数组插入数据后,这个数组就是哈希表,它的值就是通过上面这种方式映射到数组的下标上的。
这也就是哈希函数的工作模式,它把一个大范围的数字哈希转化成一个小范围的数字,这个小范围的数对应着数组的下标。
但是这种工作模式会有一点问题:把大的数字范围压缩到小的数字范围,会有几个不同的单词哈希化到同一个数组下标,这就是所谓的哈希冲突。
问题:那么如何解决哈希冲突呢?
开放地址法:指定的数组范围大小是存储数据的两倍,有一半的空间是空的。
当冲突产生时,通过(线性探测、二次探测以及再哈希法)方法找到数组的一个空位,把单词填入,不用哈希函数得到数组的下标。
线性探测中,如果哈希函数计算的原始下标是x, 线性探测就是x+1, x+2, x+3, 以此类推,而在二次探测中,探测的过程是x+1,
x+4, x+9, x+16。这二种方式都会有聚集情况。
什么是聚集呢?当哈希表快要满的时候,每插入新的数据,都要频繁的探测插入位置,很多位置都被前面插入的数据所占用了,这称为聚集。
再哈希法:依赖关键字的探测序列,把关键字用不同的哈希函数再做一遍哈希化,用这个结果作为步长,步长在整个探测中是不变的,不过不同的关键字使用不同的步长。
链地址法:数组的每个数据项都创建一个子链表或子数组,那么数组内不直接存放单词,当产生冲突时,新的数据项直接存放到这个数组下标表示的链表中。
整数集合:顾名思义,用来保存整数值类型的集合,保证元素不会重复。
定义:
typedef struct intset
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
intset;
contents数组声明为int8_t类型,但是contents数组并不保存任何int8_t类型的值,真正类型由encoding决定。比如:
- encoding属性的值为INTSET_ENC_INT16,contents是int16_6类型的数组,数组里的每个项是int16_t类型的是整数值。
- encoding属性的值为INTSET_ENC_INT32,contents是int32_t类型的数组,数组里的每个项是int32_t类型的整数值。
- encoding属性的值为INTSET_ENC_INT64,contents是int64_t类型的数组,数组里的每个项是int64_t的整数值。
新增的元素类型比原集合元素类型的长度大的时候,根据新元素类型增加整数集合底层数组的容量,给新元素分配空间,
将底层数组现有的所有元素都转成与新元素相同类型的元素,把转换后的元素放到正确的位置,整个元素顺序是有序的,能极大地节省内存。
压缩列表
压缩列表,它是特殊编码的连续内存块组成的顺序型数据结构,压缩列表有任意多个节点(entry),每个节点有一个字节数组或者一个整数值。
压缩列表不是用某种算法对数据进行压缩,它将数据按照一定规则编码,放在一块连续的内存区域,目的是节省内存。
压缩列表包含以下:
zlbytes:记录整个压缩列表占用的内存字节数。
zltail:记录压缩列表表尾节点距离压缩列表的初始地址有多少字节。
zllen:记录压缩列表包含的节点数量。
zlend:用来标记压缩列表的末端。
entryX:列表的节点,包含
- previous_entry_ength:记录压缩列表前一个字节的长度。
- encoding:节点的encoding保存的是节点的content的内容类型以及长度。
- content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。
总结:
简单字符串:SDS作为redis专门为字符串存取开发的数据结构,有获取字符串长度快,杜绝了缓存区的溢出,减少了修改字符串长度时所需的内存重分配次数,二进制安全,兼容部分C函数
链表:用作列表键、发布与订阅、慢查询、监视器等功能实现。
字典:用哈希表实现,字典有两个哈希表,一个正常使用,另一个用于rehash时使用,链地址法解决哈希冲突。
跳跃表:表中的节点按照分值大小进行排序。
整数集合:底层由数组构成,升级特性能尽可能的节省内存。
压缩列表:顺序型数据结构。
Redis五大数据类型的应用场景
字符串string
- 可以通过set key value 实现单值缓存。
- 可以通过setnx product:10001 true 实现分布式锁,返回1表示获取锁成功,返回0表示获取锁失败,这个值已经被设置过。
- 可以通过incr acticle:readcount文章id 实现计数器,每执行一次加一
- 可以通过incrby orderId 1000实现分布式系统全局序列号,一次性拿1000个序列号,在redis里面加一,批量生成序列号提升性能。因为string类型是二进制安全的,可以用来存放图片,视频等内容,另外由于Redis的高性能读写功能,而string类型的value也可以是数字,可以用作计数器(INCR,DECR),比如分布式环境中统计系统的在线人数,秒杀等。
哈希hash
- 哈希hash(比string类型操作消耗内存和cpu更小,更节约空间,集群架构下不适合大规模使用)。
- 可以通过hmget user 1:name 1:balance 实现对象缓存:多个字段修改方便一些,大数据量要进行分段存储。
- 电商购物车实现场景可以通过hset 添加商品 hincrby 添加商品数量 hlen获取商品总数 hdel删除商品 hgetall获取购物车所有商品。
- 可以做单点登录存放用户信息
列表list
lpush把值设置到列表的表头(最左边)rpush把值设置到列表的表尾(最右边)lpop从列表头拿掉值(最左边)rpop从列表尾拿掉值(最右边)
- Stach(栈):lpush+lpop
- Queue(队列):lpush+rpop
- Blocking MQ(阻塞队列):lpush + brpop
微博消息和微信公众号文章,我关注了MacTalk和备胎说车,MacTalk先发了一篇文章,文章id为10018,备胎说车后发了一篇文章,文章id为10086
查看最新消息,LRANGE msg:我的用户id 0 4//查看自己订阅文章的最新五篇
可以实现简单的消息队列,另外可以利用lrange命令,做基于redis的分页功能
集合set
微信抽奖小程序
sadd key userId 点击参与抽奖加入集合
smembers key 查看参与抽奖所有用户
srandmember key [count] /spop key [count] 抽取count名中奖者
微信微博点赞,收藏,标签
sadd key value 点赞
srem key value 取消点赞
sismember key value 检查用户是否点过赞
smembers key 获取点赞的用户列表
scard key 获取点赞用户数
实现微博微信关注模型
SINTER 交集 取共同的元素 可以实现商品筛选面包屑
SUNION 并集 取所有的元素
SDIFF 差集 以第一个集合为基准减去后面所有集合的并集,最后看第一个集合还剩下的元素
共同关注的人:交集
我可能认识的人:进到王五的主页,拿王五的集合和自己的集合对比,取差集
我关注的人也关注他:取出自己关注人的集合的交集
由于底层是字典实现的,查找元素特别快,另外set 数据类型不允许重复,利用这两个特性我们可以进行全局去重,比如在用户注册模块,判断用户名是否注册;另外就是利用交集、并集、差集等操作,可以计算共同喜好,全部的喜好,自己独有的喜好等功能。
有序集合zset
实现排行榜
zincrby key count 实现点击新闻
zreverange key 0 9 withscores 实现展示当日排行前十
zunionstore key count 几日搜索榜单
zreveange key 0 9 withscores 展示七日排行前十
有序的集合,可以做范围查找,排行榜应用,取 TOP N 操作等。
Redis五大数据类型实现原理
对于五大数据类型(String,list,Hash,Set,Zset)实现原理,Redis在底层用到了多种数据结构,通过数据结构来实现键值对,将数据结构创建了一个对象redisObject,根据对象的类型type,为对象设置多种不同的数据结构,对象可以执行特定的命令。
本章主要涉及到的知识点有:
- redisObject的属性
- 五大数据类型编码
注意:本章内容每一小节可单独学习,无论先后。
redisObject属性
学完本章中,读者需要回答:
1.Redis底层数据结构如何实现?
2.Redis是如何回收内存?
Redis的一个键值对,有两个对象,一个是键对象,一个是值对象,键总是一个字符串对象,而值可以是字符串、列表、集合等对象,Redis中的值对象都是由 redisObject 结构来表示:
typedef struct redisObject
//表示类型:string,list,hash,set,zset
unsigned type:4;
//编码:比如字符串的编码有int编码,embstr编码,raw编码
unsigned encoding:4;
//指向底层数据结构的指针,prt是个指针变量,存放地址,指向数据存储的位置
void *ptr;
//引用计数,类似java里的引用计数
int refcount;
//记录最后一次被程序访问的时间
unsigned lru:22;
robj
type属性
redisObject 对象的type属性记录了对象的类型(string,list,hash,set,zset),可以通过type key命令来判断对象类型,从而区分redis中key-value的类型
127.0.0.1:6379> set testString testValue
OK
127.0.0.1:6379> lpush testList testValue1 testValue2 testValue3
(integer) 3
127.0.0.1:6379> hmset testhash 1:testvalue 2:testvalue2
OK
127.0.0.1:6379> sadd testset testvalue
(integer) 1
127.0.0.1:6379> zadd testzset 1 testvalue
(integer) 1
127.0.0.1:6379> type testString
string
127.0.0.1:6379> type testList
list
127.0.0.1:6379> type testhash
hash
127.0.0.1:6379> type testset
set
127.0.0.1:6379> type testzset
zset
prt和encoding属性
redisObject 对象的 prt 指针,存放数据的地址,指向对象底层的数据结构,通过它可以找到数据的位置。
refcount 属性
由于C语言跟贴近操作系统,直接跟操作系统交互,命令执行响应比较快,所以Redis选择C语言进行编写可以提高性能,但是C 语言不具备自动回收内存功能,于是乎Redis自己构建了一个内存回收机制。
创建一个新对象,redisObject 对象中的refcount属性就会加1,对象被一个新程序使用,调用incrRefCount函数进行加 1,如果有对象不再被应用程序使用了,那么它就会调用decrRefCount函数进行减 1,当对象的引用计数值为 0 的时候,那么这个对象所占用的内存就会被释放。
从这里可以看出来,这其实就是Java虚拟机中引用计数的内存回收机制,在Java中这种回收机制不被使用,因为它不能解决循环引用的问题。
循环引用举例:A引用B,B引用C,C引用A。
Redis通过在配置文件中修改相关的配置,来达到解决循环引用的问题,在Redis的配置文件里,Windows的配置文件是redis.windows.conf,Linux系统的配置文件是redis.conf。
在配置文件中有一个配置:maxmemory-policy,当内存使用达到最大值时,redis使用的清楚策略,默认配置是noeviction
1)volatile-lru 删除已有的过期时间的key
2)allkeys-lru 删除所有的key
3)volatile-random 已有过期时间的key 随机删除
4)allkeys-random 随机删除key
5)volatile-ttl 删除即将过期的key
6)noeviction 不删除任何key,只是返回一个写错误,这个是默认选项 对于整数值的字符串对象(例如:1,2,3这种的)可实现内存共享。
问题:什么是内存共享?
定义:键不同,值相同。
举例:输入命令set key1 1024,键为 key1,值为1024的字符串对象,接着输入命令 set key2 1024 ,键为 key2,值为1024 的字符串对象。这个时候,有二个不同的键,一个相同的值。
实现原理:键的值,指针指向一个有值的对象,被共享的值对象引用refcount 加 1。
局限性:判断两个对象是否相等需要消耗运算的额外的时间。整数值,判断操作复杂度低;普通字符串,判断复杂度相比较而已是高的;哈希、列表、集合和有序集合,判断的复杂度更高,所以内存共享只适用于整数值的字符串。
lru 属性
Lru属性是redisObject 记录对象最后一次被命令程序访问的时间,用来辅助lru算法删除过期内存的。
在Redis 配置文件中有三个配置,最大内存配置 maxmemory,触发数据淘汰后的淘汰策略 maxmemory_policy,随机采样的精度maxmemory_samples。
当有条件符合配置文件中三个配置的时候,继续往Redis中加key时,会触发执行 lru 策略,进行内存清除。最近最少使用,lru算法根据数据的历史访问记录进行数据淘汰。
Lru策略的运行原理是数据插入到链表头部,当缓存数据被访问之后,数据会移到链表头,链表满的时候,链表尾部的数据会被丢弃。
redis配置中的淘汰策略(maxmemory_policy)对应的值:
- Noeviction:缓存里的数据超过maxmemory值,这个时候如果客户端正在执行命令,会让内存分配,给客户端返回错误响应
- allkeys-lru: 所有的key都用LRU进行淘汰。
- volatile-lru: LRU策略淘汰已经设置过过期时间的键。
- allkeys-random:随机淘汰使用的。
- key volatile-random:随机淘汰已设置过过期时间的key
- volatile-ttl:只回收设置了过期时间的key
从redis缓存中淘汰数据,我们的需求是淘汰一些不可能被使用的数据,保留有些以后可能会频繁访问的数据,频繁访问的数据,将来被访问的可能性大很多,所以redis它记录每个数据的最后一次访问时间(lru记录的时间),通过当前时间减去键值对象lru记录的时间,最后可以计算出最少空闲时间,最少空闲时间的数据是最有可能被访问到,这就是LRU淘汰策略的设计思想,是不是很棒。
举例说明:
A数据每10s访问一次,B数据每5s访问一次,C数据每50s访问一次,|代表计算空闲时间的截止点。
预测被访问的概率是B > A > C。
过期key的删除策略有两种:
惰性删除:每次获取键时,都检查键是否过期,过期的话,就删除该键;未过期,就返回该键。
定期删除:每隔一段时间,进行一次检查,删除里面的过期键。
encoding属性
数据结构由 encoding 属性,也就是编码,由它来决定,可以通过object encoding key命令查看一个值对象的编码。
127.0.0.1:6379> object encoding testString
"embstr"
127.0.0.1:6379> object encoding testList
"quicklist"
127.0.0.1:6379> object encoding testhash
"ziplist"
127.0.0.1:6379> object encoding testset
"hashtable"
127.0.0.1:6379> object encoding testzset
"ziplist"
String类型编码
我们最常使用的redis的一个数据类型就是String类型,实现单值缓存,分布式锁,计数器,分布式系统全局序列号等等功能。
它的底层编码分为三种,int,raw或者embstr。
int编码:存储整数值(例如:1,2,3),当 int 编码保存的值不再是整数值,又或者值的大小超过了long的范围,会自动转化成raw。例如:(1,2,3)->(a,b,c)
embstr编码:存储短字符串。
它只分配一次内存空间,redisObject和sds是连续的内存,查询效率会快很多,也正是因为redisObject和sds是连续在一起,伴随了一些缺点:当字符串增加的时候,它长度会增加,这个时候又需要重新分配内存,导致的结果就是整个redisObject和sds都需要重新分配空间,这样是会影响性能的,所以redis用embstr实现一次分配而后,只允许读,如果修改数据,那么它就会转成raw编码,不再用embstr编码了。
raw编码:用来存储长字符串。
它可以分配两次内存空间,一个是redisObject,一个是sds,二个内存空间不是连续的内存空间。和embstr编码相比,它创建的时候会多分配一次空间,删除时多释放一次空间。
版本区别:
embstr编码版本之间的区别:在redis3.2版本之前,用来存储39字节以内的数据,在这之后用来存储44字节以内的数据。
raw编码版本之间的区别:和embstr相反,redis3.2版本之前,可用来存储超过39字节的数据,3.2版本之后,它可以存储超过44字节的数据。
问题一:为什么是39字节?
从上面可以得知,embstr是一块连续的内存区域,由redisObject和sdshdr组成。
embstr最多占64字节场景:
redisObject占16个字节
struct RedisObject
int4 type; // 4bits,不同的redis对象会有不同的数据类型(string、list、hash等),type记录类型,会用到4bits。
int4 encoding; // 4bits,存储编码形式,用4bits。
int24 lru; // 24bits,用24bits记录对象的LRU信息
int32 refcount; // 4bytes = 32bits,引用计数器,用到32bits
void *ptr; // 8bytes,64-bit system,指针指向对象的具体内容,需要64bits
计算: 4 + 4 + 24 + 32 + 64 = 128bits = 16bytes
sdshdr占48字节
struct sdshdr
unsigned int len;//4个字节
unsigned int free;//4个字节
char buf[];//假设buf里面是39个字节
;
if (ptr)
memcpy(sh->buf,ptr,len);
sh->buf[len] = '\\0';//一个字节
sdshdr的大小为8+39+1=48
那么一个embstr最多占64字节:16+48(4+4+1+39)=64
从2.4版本开始,redis用jemalloc内存分配器,比glibc的malloc要好一些,省内存,jemalloc会分配8,16,32,64等类型字节的内存。
embstr最小为33字节场景:
从上面我们可以得知redisObject占16个字节,现在buf中取8字节。
struct sdshdr
unsigned int len;//4个字节
unsigned int free;//4个字节
char buf[];//假设buf里面是8个字节
;
if (ptr)
memcpy(sh->buf,ptr,len);
sh->buf[len] = '\\0';//一个字节
sdshdr的大小为4+4+8+1=17
计算得出:16+17(4+4+1+8)=33
8,16,32都比33字节小,所以最小分配64字节。
通过对比:
16+17(4+4+1+8)=33
16+48(4+4+1+39)=64
当字符数大于8时,会分配64字节。当字符数小于39时,会分配64字节。这个默认39就是这样来的。
问题二:为什么分界值由39字节会变成44字节?
被暴打的回答是:REDIS_ENCODING_EMBSTR_SIZE_LIMIT值被换成了44了。
##define REDIS_ENCODING_EMBSTR_SIZE_LIMIT 39##define REDIS_ENCODING_EMBSTR_SIZE_LIMIT 44
正经的回答是:
每个sds都有一个sdshdr,里面的len和free记录了这个sds的长度和空闲空间。
struct sdshdr unsigned int len; unsigned int free;
用的unsigned int可以表示很大的范围,短的sds空间被浪费了(unsigned int len和unsigned int free 8个字节)
commit之后,unsigned int 变成了uint8_t,uint16_t,uint32_t
struct __attribute__ ((__packed__)) sdshdr8 uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ char flags; /* 2 lsb of type, and 6 msb of refcount */ char buf[];;struct __attribute__ ((__packed__)) sdshdr16 uint16_t len; /* used */ uint16_t alloc; /* excluding the header and null terminator */ char flags; /* 2 lsb of type, and 6 msb of refcount */ char buf[];;struct __attribute__ ((__packed__)) sdshdr32 uint32_t len; /* used */ uint32_t alloc; /* excluding the header and null terminator */ char flags; /* 2 lsb of type, and 6 msb of refcount */ char buf[];;struct __attribute__ ((__packed__)) sdshdr64 uint64_t len; /* used */ uint64_t alloc; /* excluding the header and null terminator */ char flags; /* 2 lsb of type, and 6 msb of refcount */
除此之外还将原来的sdshdr改成了sdshdr16,sdshdr32,sdshdr64
sizes = sdscatprintf(sizes,"sdshdr:%d", (int)sizeof(struct sdshdr));改成了sizes = sdscatprintf(sizes,"sdshdr8:%d", (int)sizeof(struct sdshdr8));sizes = sdscatprintf(sizes,"sdshdr16:%d", (int)sizeof(struct sdshdr16));sizes = sdscatprintf(sizes,"sdshdr32:%d", (int)sizeof(struct sdshdr32))redis启动,停止
Redis 基础 -- Redis简介CentOS 7 单机安装Redis启动Redis(后台启动Redis 指定配置文件启动Redis 开机自启Redis )Redis客户端(含图形化界面)