深入理解redis原理!

  • 时间:
  • 浏览:0
  • 来源:跟我学网络

原理篇

redis 时单线程的为什么还能那么快?

数据都在内存中,运算都是内存级别的运算。

redis既然是单线程的为什么能处理那么多的并发数?

多路复用,操作系统时间轮训epoll 函数作为选择器,维护了指令队列,和响应队列,java的nio。

select ,poll, epoll

rset ,fds(文件描述符的集合)。而select模型存储fds的方式是采取的bitmap,默认最大1024个。

2.3、执行流程

1.select模型每次都直接将rset(也就是fds)全部拷贝到内核态,因为内核态速度比用户空间态快很多。

2.如果没数据的话,select函数会阻塞,如果有数据的话会执行两步

(1)将有数据的那个fd置位(也就是标记一下,代表这个fd有数据)

(2)select函数不在阻塞,将继续往下执行。也就是整体遍历fds,找到有数据的那个fd读取数据做处理。他的fd不能重用,每一次都需要重新创建新的fds且将用户空间态的fds拷贝到内核态

3、缺点

fds最大支持1024个(可以更改,但是意义不大)

fd不可重用,每次内核态都给置位了,导致为了标记fd,必须创建一个新的rset从而导致fds在用户态内存态间多次拷贝(也就是fds)

用户控件态拷贝rset到内核态也需要时间,虽然内核态执行比用户态快,但是copy也需要开销

O(n)再次遍历问题。因为rset里的fd被置位后,select函数并不知道哪个被置位了,需要从头遍历到尾,逐个对比。

poll

poll的结构体是为了fd重复利用,不需要每次都拷贝到内核态用的。

1、解决了select哪些问题

采取的链表存储,而不是bitmap,解决了1024长度限制问题

采取结构体每次置位结构体内的revents字段,而不破坏fd本身,所以可重用,不需要每次都创建新的fd。

2、缺点

用户控件态拷贝rset到内核态也需要时间,虽然内核态执行比用户态快,但是copy也需要开销

O(n)再次遍历问题。因为rset里的fd被置位后,select函数并不知道哪个被置位了,需要从头遍历到尾,逐个对比。

epoll

2.2、执行流程

epoll将fd放到了红黑树里,且不需要拷贝到内核态,因为他采取了“共享内存”的概念。(其实还是复制,只是复制采取了其他技术可以使开销极其的小)

epoll的置位是重排,比如五个fd, 1 2 3 4 5,1 3 5这三个fd有数据了,那么他会重排序,排成如下1 3 5 2 4。(也有的说是单独放到新的数组里)

每一次置位nfds的值都+1。且会回调epoll_wait

所以epoll_wait执行完会返回有几个fd有数据,那么下面的for直接遍历nfds次即可。解决了前面的两种O(n)。变成了O1

总结如下:

比如三个redis-cli,假设2个redis-cli写入命令,

select:那么select模型是轮询这三个redis-cli的fd,看哪个fd有消息,有的话读取处理消息。当他下次再写命令的时候还需要重新创建fd,然后复制到内核态然后再遍历全部。

poll:那么poll模型是轮询这三个redis-cli的fd,看哪个fd有消息,有的话读取处理消息。下次再写入的时候还是遍历全局fd,看哪个fd有消息进行处理。省去了每次都创建新的fd且复制的过程。

epoll:epoll就不轮询了,有消息进来后你通知我,我去处理你的消息,那些没消息的fd我不管。而且复制到内核态的过程我采取牛逼的技术让开销达到最小的极致。



原文链接:https://blog.csdn.net/ctwctw/java/article/details/105024324

Redis 服务器与客户端通过 RESPREdis Serialization Protocol) 协议通信。

主要以下特点: 容易实现,解析快,人类可读.

RESP 底层采用的是 TCP 的连接方式, 通过 tcp 进行数据传输, 然后根据解析规则解析相

应信息, 完成交互。

 持久化

(原文链接:https://blog.csdn.net/ctwctw/java/article/details/105147277)

一、为什么需要持久化

redis里有10gb数据,突然停电或者意外宕机了,再启动的时候10gb都没了?!所以需要持久化,宕机后再通过持久化文件将数据恢复。

二、优缺点

1、rdb文件

rdb文件都是二进制,很小。比如内存数据有10gb,rdb文件可能就1gb,只是举例。

2、优点

由于rdb文件都是二进制文件,所以很小,在灾难恢复的时候会快些。

他的效率(主进程处理命令的效率,而不是持久化的效率)相对于aof要高(bgsave而不是save),因为每来个请求他都不会处理任何事,只是bgsave的时候他会fork()子进程且可能copyonwrite,但copyonwrite只是一个寻址的过程,纳秒级别的。而aof每次都是写盘操作,毫米级别。没法比。

3、缺点

数据可靠性比aof低,也就是会丢失的多。因为aof可以配置每秒都持久化或者每个命令处理完就持久化一次这种高频率的操作,而rdb的话虽然也是靠配置进行bgsave,但是没有aof配置那么灵活,也没aof持久化快,因为rdb每次全量,aof每次只追加。

三、RDB持久化的两种方法

配置文件也可以配置触发rdb的规则。配置文件配置的规则采取的是bgsave的原理。

1、save

1.1、描述 aof 处理增量数据

同步、阻塞

1.2、缺点

致命的问题,持久化的时候redis服务阻塞(准确的说会阻塞当前执行save命令的线程,但是redis是单线程的,所以整个服务会阻塞),不能继对外提供请求,GG!数据量小的话肯定影响不大,数据量大呢?每次复制需要1小时,那就相当于停机一小时。

2、bgsave

2.1、描述  rdb 处理全量数据

异步、非阻塞

2.2、原理

fork() + copyonwrite

2.3、优点

他可以一边进行持久化,一边对外提供读写服务,互不影响,新写的数据对我持久化不会造成数据影响,你持久化的过程中报错或者耗时太久都对我当前对外提供请求的服务不会产生任何影响。持久化完会将新的rdb文件覆盖之前的。

四 、fork()

bgsave原理是fork() + copyonwrite,那么现在来聊一下fork()

1、fork()是什么

fork()是unix和linux这种操作系统的一个api,而不是Redis的api。

2、fork()有什么用

fork()用于创建一个子进程,注意是子进程,不是子线程。fork()出来的进程共享其父类的内存数据。仅仅是共享fork()出子进程的那一刻的内存数据,后期主进程修改数据对子进程不可见,同理,子进程修改的数据对主进程也不可见。比如:A进程fork()了一个子进程B,那么A进程就称之为主进程,这时候主进程子进程所指向的内存空间是同一个,所以他们的数据一致。但是A修改了内存上的一条数据,这时候B是看不到的,A新增一条数据,删除一条数据,B都是看不到的。而且子进程B出问题了,对我主进程A完全没影响,我依然可以对外提供服务,但是主进程挂了,子进程也必须跟随一起挂。这一点有点像守护线程的概念。Redis正是巧妙的运用了fork()这个牛逼的api来完成RDB的持久化操作。

五、Redis中的fork()

Redis巧妙的运用了fork()。当bgsave执行时,Redis主进程会判断当前是否有fork()出来的子进程,若有则忽略,若没有则会fork()出一个子进程来执行rdb文件持久化的工作,子进程与Redis主进程共享同一份内存空间,所以子进程可以搞他的rdb文件持久化工作,主进程又能继续他的对外提供服务,二者互不影响。我们说了他们之后的修改内存数据对彼此不可见,但是明明指向的都是同一块内存空间,这是咋搞得?肯定不可能是fork()出来子进程后顺带复制了一份数据出来,如果是这样的话比如我有4g内存,那么其实最大有限空间是2g,我要给rdb留出一半空间来,扯淡一样!那他咋做的?采取了copyonwrite技术。

六、copyonwrite

很简单,现在不就是主进程和子进程共享了一块内存空间,怎么做到的彼此更改互不影响吗?

1、原理

主进程fork()子进程之后,内核把主进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向主进程。这也就是共享了主进程的内存,当其中某个进程写内存时(这里肯定是主进程写,因为子进程只负责rdb文件持久化工作,不参与客户端的请求),CPU硬件检测到内存页是read-only的于是触发页异常中断(page-fault),陷入内核的一个中断例程。中断例程中,内核就会把触发的异常的页复制一份(这里仅仅复制异常页,也就是所修改的那个数据页,而不是内存中的全部数据),于是主子进程各自持有独立的一份。

数据修改之前的样子

数据修改之后的样子

2、回到原问题

其实就是更改数据的之前进行copy一份更改数据的数据页出来,比如主进程收到了set k 1请求(之前k的值是2),然后这同时又有子进程在rdb持久化,那么主进程就会把k这个key的数据页拷贝一份,并且主进程中k这个指针指向新拷贝出来的数据页地址上,然后进行更改值为1的操作,这个主进程k元素地址引用的新拷贝出来的地址,而子进程引用的内存数据k还是修改之前的。

3、一段话总结

copyonwritefork()出来的子进程共享主进程的物理空间,当主子进程有内存写入操作时,read-only内存页发生中断,将触发的异常的内存页复制一份(其余的页还是共享主进程的)。

4、额外补充

在 Redis 服务中,子进程只会读取共享内存中的数据,它并不会执行任何写操作,只有主进程会在写入时才会触发这一机制,而对于大多数的 Redis 服务或者数据库,写请求往往都是远小于读请求的,所以使用fork()加上写时拷贝这一机制能够带来非常好的性能,也让BGSAVE这一操作的实现变得很简单。

七、疑问

0、调用fork()也会阻塞啊

我只能说没毛病,但是这个阻塞真的可以忽略不计。尤其是相对于阻塞主线程的save。

1、会同时存在多个子进程吗?

不会,主进程每次收到bgsave命令需要fork()子进程之前都会判断是否存在子进程了,若存在也会忽略掉这次bgsave请求。若不存在我会fork()出子进程进行工作。

为什么这么搞?

我猜测原因如下:

1.如果支持并行存在多个子进程,那么不仅会拉低服务器性能,还会造成数据问题,比如八点的bgsave在工作,九点又来个bgsave命令。这时候九点的先执行完了,八点的后执行完了,那九点的不白执行了吗?这是我所谓的数据问题。再比如,都没执行完,十点又开一个bgsave,越积越多,服务器性能被拉低。

2.那为什么不阻塞?判断有子进程在工作,就等待,等他执行完我在上场,那一样,越积越多,文件过大,只会造成堆积。

2、如果没有copyonwrite这种技术是什么效果?

1.假设是全量复制,那么内存空间直接减半,浪费资源不说,数据量10g,全量复制这10g的时间也够长的。这谁顶得住?2.如果不全量复制,会是怎样?相当于我一边复制,你一边写数据,看着貌似问题不大,其实不然。比如现在Redis里有k1的值是1,k2的值是

2,比如bgsave了,这时候rdb写入了k1的值,在写k2的值之前时,有个客户端请求

set k1 11

set k2 22

1

2

那么持久化进去的是k2 22,但是k1的值还是1,而不是最新的11,所以会造成数据问题,所以采取了copyonwrite技术来保证触发bgsave请求的时候无论你怎么更改,都对我rdb文件的数据持久化不会造成任何影响。

redis cluster集群

redis cluster

  redis cluster是Redis的分布式解决方案,在3.0版本推出后有效地解决了redis分布式方面的需求

  自动将数据进行分片,每个master上放一部分数据

  提供内置的高可用支持,部分master不可用时,还是可以继续工作的

  支撑N个redis master node,每个master node都可以挂载多个slave node

  高可用,因为每个master都有salve节点,那么如果mater挂掉,redis cluster这套机制,就会自动将某个slave切换成master

redis cluster vs. replication + sentinal

  如果你的数据量很少,主要是承载高并发高性能的场景,比如你的缓存一般就几个G,单机足够了

  replication,一个mater,多个slave,要几个slave跟你的要求的读吞吐量有关系,然后自己搭建一个sentinal集群,去保证redis主从架构的高可用性,就可以了

  redis cluster,主要是针对海量数据+高并发+高可用的场景,海量数据,如果你的数据量很大,那么建议就用redis cluster

数据分布算法

hash算法

  比如你有 N 个 redis实例,那么如何将一个key映射到redis上呢,你很可能会采用类似下面的通用方法计算 key的 hash 值,然后均匀的映射到到 N 个 redis上:

  hash(key)%N

  如果增加一个redis,映射公式变成了 hash(key)%(N+1)

  如果一个redis宕机了,映射公式变成了 hash(key)%(N-1)

  在这两种情况下,几乎所有的缓存都失效了。会导致数据库访问的压力陡增,严重情况,还可能导致数据库宕机。

一致性hash算法

  一个master宕机不会导致大部分缓存失效,可能存在缓存热点问题

用虚拟节点改进

redis cluster的hash slot算法

  redis cluster有固定的16384个hash slot,对每个key计算CRC16值,然后对16384取模,可以获取key对应的hash slot

  redis cluster中每个master都会持有部分slot,比如有3个master,那么可能每个master持有5000多个hash slot

  hash slot让node的增加和移除很简单,增加一个master,就将其他master的hash slot移动部分过去,减少一个master,就将它的hash slot移动到其他master上去

  移动hash slot的成本是非常低的

  客户端的api,可以对指定的数据,让他们走同一个hash slot,通过hash tag来实现

  127.0.0.1:7000>CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000  可以将槽0-5000指派给节点7000负责。

  每个节点都会记录哪些槽指派给了自己,哪些槽指派给了其他节点。

  客户端向节点发送键命令,节点要计算这个键属于哪个槽。

  如果是自己负责这个槽,那么直接执行命令,如果不是,向客户端返回一个MOVED错误,指引客户端转向正确的节点。

 

节点间的内部通信机制

1、基础通信原理

(1)redis cluster节点间采取gossip协议进行通信

跟集中式不同,不是将集群元数据(节点信息,故障,等等)集中存储在某个节点上,而是互相之间不断通信,保持整个集群所有节点的数据是完整的

维护集群的元数据用得,集中式,一种叫做gossip

集中式:好处在于,元数据的更新和读取,时效性非常好,一旦元数据出现了变更,立即就更新到集中式的存储中,其他节点读取的时候立即就可以感知到; 不好在于,所有的元数据的跟新压力全部集中在一个地方,可能会导致元数据的存储有压力

gossip:好处在于,元数据的更新比较分散,不是集中在一个地方,更新请求会陆陆续续,打到所有节点上去更新,有一定的延时,降低了压力; 缺点,元数据更新有延时,可能导致集群的一些操作会有一些滞后

我们刚才做reshard,去做另外一个操作,会发现说,configuration error,达成一致

(2)10000端口

每个节点都有一个专门用于节点间通信的端口,就是自己提供服务的端口号+10000,比如7001,那么用于节点间通信的就是17001端口

每隔节点每隔一段时间都会往另外几个节点发送ping消息,同时其他几点接收到ping之后返回pong

(3)交换的信息

故障信息,节点的增加和移除,hash slot信息,等等

gossip协议

gossip协议包含多种消息,包括ping,pong,meet,fail,等等

meet: 某个节点发送meet给新加入的节点,让新节点加入集群中,然后新节点就会开始与其他节点进行通信

redis-trib.rb add-node

其实内部就是发送了一个gossip meet消息,给新加入的节点,通知那个节点去加入我们的集群

ping: 每个节点都会频繁给其他节点发送ping,其中包含自己的状态还有自己维护的集群元数据,互相通过ping交换元数据

每个节点每秒都会频繁发送ping给其他的集群,ping,频繁的互相之间交换数据,互相进行元数据的更新

pong: 返回ping和meet,包含自己的状态和其他信息,也可以用于信息广播和更新

fail: 某个节点判断另一个节点fail之后,就发送fail给其他节点,通知其他节点,指定的节点宕机了

3、ping消息深入

ping很频繁,而且要携带一些元数据,所以可能会加重网络负担

每个节点每秒会执行10次ping,每次会选择5个最久没有通信的其他节点

当然如果发现某个节点通信延时达到了cluster_node_timeout / 2,那么立即发送ping,避免数据交换延时过长,落后的时间太长了

比如说,两个节点之间都10分钟没有交换数据了,那么整个集群处于严重的元数据不一致的情况,就会有问题

所以cluster_node_timeout可以调节,如果调节比较大,那么会降低发送的频率

每次ping,一个是带上自己节点的信息,还有就是带上1/10其他节点的信息,发送出去,进行数据交换

至少包含3个其他节点的信息,最多包含总节点-2个其他节点的信息

-------------------------------------------------------------------------------------------------------

面向集群的jedis内部实现原理

开发,jedis,redis的java client客户端,redis cluster,jedis cluster api

jedis cluster api与redis cluster集群交互的一些基本原理

1、基于重定向的客户端

redis-cli -c,自动重定向

(1)请求重定向

客户端可能会挑选任意一个redis实例去发送命令,每个redis实例接收到命令,都会计算key对应的hash slot

如果在本地就在本地处理,否则返回moved给客户端,让客户端进行重定向

cluster keyslot mykey,可以查看一个key对应的hash slot是什么

用redis-cli的时候,可以加入-c参数,支持自动的请求重定向,redis-cli接收到moved之后,会自动重定向到对应的节点执行命令

(2)计算hash slot

计算hash slot的算法,就是根据key计算CRC16值,然后对16384取模,拿到对应的hash slot

用hash tag可以手动指定key对应的slot,同一个hash tag下的key,都会在一个hash slot中,比如set mykey1:{100}和set mykey2:{100}

(3)hash slot查找

节点间通过gossip协议进行数据交换,就知道每个hash slot在哪个节点上

(4)JedisCluster的工作原理

在JedisCluster初始化的时候,就会随机选择一个node,初始化hashslot -> node映射表,同时为每个节点创建一个JedisPool连接池

每次基于JedisCluster执行操作,首先JedisCluster都会在本地计算key的hashslot,然后在本地映射表找到对应的节点

如果那个node正好还是持有那个hashslot,那么就ok; 如果说进行了reshard这样的操作,可能hashslot已经不在那个node上了,就会返回moved

如果JedisCluter API发现对应的节点返回moved,那么利用该节点的元数据,更新本地的hashslot -> node映射表缓存

重复上面几个步骤,直到找到对应的节点,如果重试超过5次,那么就报错,JedisClusterMaxRedirectionException

jedis老版本,可能会出现在集群某个节点故障还没完成自动切换恢复时,频繁更新hash slot,频繁ping节点检查活跃,导致大量网络IO开销

jedis最新版本,对于这些过度的hash slot更新和ping,都进行了优化,避免了类似问题

(5)hashslot迁移和ask重定向

如果hash slot正在迁移,那么会返回ask重定向给jedis

jedis接收到ask重定向之后,会重新定位到目标节点去执行,但是因为ask发生在hash slot迁移过程中,所以JedisCluster API收到ask是不会更新hashslot本地缓存

已经可以确定说,hashslot已经迁移完了,moved是会更新本地hashslot->node映射表缓存的

-------------------------------------------------------------------------------------------------------

高可用性与主备切换原理

redis cluster的高可用的原理,几乎跟哨兵是类似的

1、判断节点宕机

如果一个节点认为另外一个节点宕机,那么就是pfail,主观宕机

如果多个节点都认为另外一个节点宕机了,那么就是fail,客观宕机,跟哨兵的原理几乎一样,sdown,odown

在cluster-node-timeout内,某个节点一直没有返回pong,那么就被认为pfail

如果一个节点认为某个节点pfail了,那么会在gossip ping消息中,ping给其他节点,如果超过半数的节点都认为pfail了,那么就会变成fail

2、从节点过滤

对宕机的master node,从其所有的slave node中,选择一个切换成master node

检查每个slave node与master node断开连接的时间,如果超过了cluster-node-timeout * cluster-slave-validity-factor,那么就没有资格切换成master

这个也是跟哨兵是一样的,从节点超时过滤的步骤

3、从节点选举

哨兵:对所有从节点进行排序,slave priority,offset,run id

每个从节点,都根据自己对master复制数据的offset,来设置一个选举时间,offset越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举

所有的master node开始slave选举投票,给要进行选举的slave进行投票,如果大部分master node(N/2 + 1)都投票给了某个从节点,那么选举通过,那个从节点可以切换成master

从节点执行主备切换,从节点切换为主节点

4、与哨兵比较

整个流程跟哨兵相比,非常类似,所以说,redis cluster功能强大,直接集成了replication和sentinal的功能

redis源码篇

SDS——动态字符串(原文链接:https://blog.csdn.net/qq193423571/java/article/details/81637075)

Redis中简单动态字符串sds数据结构与API相关文件是:sds.h, sds.c。

SDS本质上就是char *,因为有了表头sdshdr结构的存在,所以SDS比传统C字符串在某些方面更加优秀,并且能够兼容传统C字符串。

sds在Redis中是实现字符串对象的工具,并且完全取代char*..sds是二进制安全的,它可以存储任意二进制数据,不像C语言字符串那样以‘\0’来标识字符串结束,

因为传统C字符串符合ASCII编码,这种编码的操作的特点就是:遇零则止 。即,当读一个字符串时,只要遇到’\0’结尾,就认为到达末尾,就忽略’\0’结尾以后的所有字符。因此,如果传统字符串保存图片,视频等二进制文件,操作文件时就被截断了。

SDS表头的buf被定义为字节数组,因为判断是否到达字符串结尾的依据则是表头的len成员,这意味着它可以存放任何二进制的数据和文本数据,包括’\0’

SDS 和传统的 C 字符串获得的做法不同,传统的C字符串遍历字符串的长度,遇零则止,复杂度为O(n)。而SDS表头的len成员就保存着字符串长度,所以获得字符串长度的操作复杂度为O(1)。

总结下sds的特点是:带着长度信息的字节数组,可动态扩展内存、二进制安全、快速遍历字符串 和与传统的C语言字符串类型兼容。



下面是一个不同 SDS 结构体下的不同字符串的例子:

 

上图是sds的一个内部结构的例子。图中展示了两个sds字符串s1和s2的内存结构,一个使用sdshdr8类型的header,另一个使用sdshdr16类型的header。但它们都表达了同样的一个长度为6的字符串的值:”tielei”。下面我们结合代码,来解释每一部分的组成。

sds结构一共有五种Header定义,其目的是为了满足不同长度的字符串可以使用不同大小的Header,从而节省内存。 Header部分主要包含以下几个部分: + len:表示字符串真正的长度,不包含空终止字符 + alloc:表示字符串的最大容量,不包含Header和最后的空终止字符 + flags:表示header的类型。

2.2 在RedisObject中,SDS的两种存储形式

详情:

> set codehole abcdefghijklmnopqrstuvwxyz012345678912345678

OK

> debug object codehole

Value at:0x7fec2de00370 refcount:1 encoding:embstr serializedlength:45 lru:5958906 lru_seconds_idle:1

> set codehole abcdefghijklmnopqrstuvwxyz0123456789123456789

OK

> debug object codehole

Value at:0x7fec2dd0b750 refcount:1 encoding:raw serializedlength:46 lru:5958911 lru_seconds_idle:1...

一个字符的差别,存储形式 encoding 就发生了变化。一个是 embstr,一个是 row。

在了解存储格式的区别之前,首先了解下RedisObject结构体。

所有的 Redis 对象都有一个 Redis 对象头结构体

struct RedisObject {

int4 type; // 4bits 类型

int4 encoding; // 4bits 存储格式

int24 lru; // 24bits 记录LRU信息

int32 refcount; // 4bytes

void *ptr; // 8bytes,64-bit system

} robj;

不同的对象具有不同的类型 type(4bit),同一个类型的 type 会有不同的存储形式 encoding(4bit)。

为了记录对象的 LRU 信息,使用了 24 个 bit 的 lru 来记录 LRU 信息。

每个对象都有个引用计数 refcount,当引用计数为零时,对象就会被销毁,内存被回收。ptr 指针将指向对象内容 (body) 的具体存储位置



而Redis 的字符串共有两种存储方式,在长度特别短时,使用 emb 形式存储 (embedded),当长度超过 44 时,使用 raw 形式存储。

embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。而 raw 存储形式不一样,它需要两次 malloc,两个对象头在内存地址上一般是不连续的。

在字符串比较小时,SDS 对象头的大小是capacity+3——SDS结构体的内存大小至少是 3,一个 RedisObject 对象头共需要占据 16 字节的存储空间,字符串已\0结尾。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)。

如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。而64-19-结尾的\0,所以empstr只能容纳44字节。

embstr 存储形式是这样一种存储形式,它将 RedisObject 对象头和 SDS 对象连续存在一起,使用 malloc 方法一次分配。而 raw 存储形式不一样,它需要两次 malloc,两个对象头在内存地址上一般是不连续的。

在字符串比较小时,SDS 对象头的大小是capacity+3——SDS结构体的内存大小至少是 3。意味着分配一个字符串的最小空间占用为 19 字节 (16+3)。

如果总体超出了 64 字节,Redis 认为它是一个大字符串,不再使用 emdstr 形式存储,而该用 raw 形式。而64-19-结尾的\0,所以empstr只能容纳44字节。

2.3 扩容策略

当字符串长度小于 1M 时,扩容都是加倍现有的空间,如果超过 1M,扩容时一次只会多扩 1M 的空间

dict——字典

类似java中的hashmap结构,但是扩容是有所不同;

渐进式哈希的精髓在于:数据的迁移不是一次性完成的,而是可以通过dictRehash()这个函数分步规划的,并且调用方可以及时知道是否需要继续进行渐进式哈希操作。如果dict数据结构中存储了海量的数据,那么一次性迁移势必带来redis性能的下降,别忘了redis是单线程模型,在实时性要求高的场景下这可能是致命的。而渐进式哈希则将这种代价可控地分摊了,调用方可以在dict做插入,删除,更新的时候执行dictRehash(),最小化数据迁移的代价。

在迁移的过程中,数据是在新表还是旧表中并不是一个非常急迫的需求,迁移的过程并不会丢失数据,在旧表中找不到再到新表中寻找就是了。

dict的结构大致如上,接下来分析一下其中最重要的几个数据成员:

dictht::table:哈希表内部的table结构使用了链地址法来解决哈希冲突,刚开始看的时候我很奇怪,这怎么是个二维数组?这其实是一个指向数组的指针,数组中的每一项都是entry链表的头结点。

dictht ht[2]:在dict的内部,维护了两张哈希表,作用等同于是一对滚动数组,一张表是旧表,一张表是新表,当hashtable的大小需要动态改变的时候,旧表中的元素就往新开辟的新表中迁移,当下一次变动大小,当前的新表又变成了旧表,以此达到资源的复用和效率的提升。

rehashidx:因为是渐进式的哈希,数据的迁移并不是一步完成的,所以需要有一个索引来指示当前的rehash进度。当rehashidx为-1时,代表没有哈希操作。

————————————————

rehash是以bucket(桶)为基本单位进行渐进式的数据迁移的,每步完成一个bucket的迁移,直至所有数据迁移完毕。一个bucket对应哈希表数组中的一条entry链表。新版本的dictRehash()还加入了一个最大访问空桶数(empty_visits)的限制来进一步减小可能引起阻塞的时间。

————————————————

最后是从《Redis设计与实现》中copy来的图解,可以帮助大家更形象地理解整个incremental rehash的过程:

 

 

 

 

set——实现方式也是字典,只不过所有的value都是null,其他特性和字典一摸一样

zipList压缩列表

简介

压缩列表是 Redis 为了节约内存而开发的, 由一系列特殊编码的连续内存块组成的,

增加元素

因为ziplist紧凑,意味着每插入一个元素都要调用realloc扩展内存,依据待扩展内存的大小决定是,一次性拷贝新地址还是,在原地址扩展,如果ziplist内存过大重新分配内存可拷贝内存代价过高,故不适合存储大型字符串字符串

级联更新

因为ziplist每个元素entry都会有一个prevlen存储前一个entry的长度,如果内容小于254,prevlen=1bit 否则prevlen=5bit。这意味着如果某个entry经过修改从253到254直接,那么他的下一个entry的prevlen字段就要更新,从1->5,如果后面这个entry的长度也是253,便产生二楼级联更新

inset小整数集合

简介

集合元素都是整数并且元素个数较少时使用,当set里面放入非整数是,存储形式立即从inset转变成hash

quicklist快速列表

概述

考虑到链表的附加空间相对太高,prev 和 next 指针就要占去 16 个字节 (64bit 系统的指针是 8 个字节),另外每个节点的内存都是单独分配,会加剧内存的碎片化,影响内存管理效率。

后续版本对列表数据结构进行了改造,使用 quicklist 代替了 ziplist 和 linkedlist.

基本结构

quickList 是 zipList 和 linkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。

 

压缩深度

quicklist 默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数lis搞t-compress-depth决定。

为了支持快速的 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,此时深度就是 1。

如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第二个 ziplist 都不压缩。

zipList 长度

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个 ziplist。

ziplist 的长度由配置参数 list-max-ziplist-size 决定。

skiplist数据结构简介

skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。

我们在《Redis内部数据结构详解》系列的第一篇中介绍dict的时候,曾经讨论过:一般查找问题的解法分为两个大类:一个是基于各种平衡树,一个是基于哈希表。但skiplist却比较特殊,它没法归属到这两大类里面。

这种数据结构是由William Pugh发明的,最早出现于他在1990年发表的论文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。对细节感兴趣的同学可以下载论文原文来阅读。

skiplist,顾名思义,首先它是一个list。实际上,它是在有序链表的基础上发展起来的。

我们先来看一个有序链表,如下图(最左侧的灰色节点表示一个空的头结点):

在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间复杂度为O(n)。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。

假如我们每相邻两个节点增加一个指针,让指针指向下下个节点,如下图:

这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中进行查找。比如,我们想查找23,查找的路径是沿着下图中标红的指针所指向的方向进行的:

  • 23首先和7比较,再和19比较,比它们都大,继续向后比较。
  • 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22比较。
  • 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在,而且它的插入位置应该在22和26之间。

在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。

利用同样的方式,我们可以在上层新产生的链表上,继续为每相邻的两个节点增加一个指针,从而产生第三层链表。如下图:

在这个新的三层链表结构上,如果我们还是查找23,那么沿着最上层链表首先要比较的是19,发现23比19大,接下来我们就知道只需要到19的后面去继续查找,从而一下子跳过了19前面的所有节点。可以想象,当链表足够长的时候,这种多层链表的查找方式能让我们跳过很多下层节点,大大加快查找的速度。

skiplist正是受这种多层链表的想法的启发而设计出来的。实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。

skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如,一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。为了表达清楚,下图展示了如何通过一步步的插入操作从而形成一个skiplist的过程:

从上面skiplist的创建和插入过程可以看出,每一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。实际上,这是skiplist的一个很重要的特性,这让它在插入性能上明显优于平衡树的方案。这在后面我们还会提到。

根据上图中的skiplist结构,我们很容易理解这种数据结构的名字的由来。skiplist,翻译成中文,可以翻译成“跳表”或“跳跃表”,指的就是除了最下面第1层链表之外,它会产生若干层稀疏的链表,这些链表里面的指针故意跳过了一些节点(而且越高层的链表跳过的节点越多)。这就使得我们在查找数据的时候能够先在高层的链表中进行查找,然后逐层降低,最终降到第1层链表来精确地确定数据位置。在这个过程中,我们跳过了一些节点,从而也就加快了查找速度。

刚刚创建的这个skiplist总共包含4层链表,现在假设我们在它里面依然查找23,下图给出了查找路径: