前面我们说到Redis的所有数据都是存在一个全局hash表中的,那么初始的hash表肯定不会那么大,那发生冲突怎么办,今天就来说说这个问题
1.全局哈希表频繁发生冲突Redis是怎么解决的
- 发生了冲突,Redis会对哈希表做 rehash 操作。rehash 也就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
- 那具体怎么做
- 为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表1和哈希表2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash
- 过程分为三步:
- 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
- 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
- 释放哈希表 1 的空间。
- 这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时Redis就无法快速访问数据了。
- Redis又是怎么解决这个问题的?答案是:渐进式rehash
- 简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问
2.五大基本类型及其对应的底层数据结构,关系如下图:
- 学习底层数据结构对于如何写出高性能的代码是十分有帮助的,例如以下情况
- 我们知道要操作一个集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查
- 集合的操作效率与集合的底层数据结构有关。例如,使用哈希表实现的集合使用哈希表实现的集合,要比使用链表实现的集合访问效率更高
- 各种数据结构的时间复杂度,如下表所示:
3.为什么单线程的Redis能那么快?
想要了解这个问题的背后原理,我们就要深入地学习Redis的单线程设计机制以及多路复用机制。之后你在调优 Redis 性能时,也能更有针对性地避免会导致 Redis 单线程阻塞的操作,例如执行复杂度高的命令。
- Redis是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
- 我们可以想想如果采用多线程会有什么问题?
- 第一:系统中通常会存在被多线程同时访问的共享资源,比如一个共享的数据结构。当有多个线程要修改这个共享资源时,为了保证共享资源的正确性,就需要有额外的机制进行保证,而这个额外的机制,就会带来额外的开销。
- 第二:采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。
- 单线程Redis为什么那么快?
- 一方面,Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因。
- 另一方面,就是 Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。接下来,我们应该重点学习下多路复用机制。
4.Redis的多路复用机制
- Redis基本I/O模型,如下图所示:
- 在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()
- Redis采用基于多路复用的高性能 I/O 模型
- 下图就是基于多路复用的 Redis IO 模型。图中的多个 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
- 为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
- 这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。
5.上期问题:一亿个ID该用什么类型存?
分析:
- 首先可以查看下,存一个10位的数字需要用多少内存;存一个10位的字符串需要用到64字节,1GB=2^30次方的字节数量,10,7374,1824个字节,存亿数量级的数据,需要6GB多。
- 其次:64字节怎么来的,当然是set一个数测出来的,但这只是结果,其背后的原理是什么?怎么计算出64的?
- 在源码server.h里面的redisObject定义里面有定义,上面也有介绍,dictEntry三个字段共占24byte,然后key,value指向redisObject。2*16字节,如果用string存,则redisObject的ptr指针,就会指向SDS对象,如果是存int,那么ptr是void类型,这个是可以直接存在8字节的ptr里面的,
- malloc的时候,jemalloc会向上取2^n次方,24字节向上取就是32字节,
方案一:
- 使用string类型保存
- 缺点:内存开销大,结构化数据需要进行序列化和反序列化
方案二:
- 使用Hash类型保存
- 将ID值进行拆分,一部分作为Hash集合中的key,剩余部分作为Hash集合中的value,
- 例如:将100001003->100001作为Hash集合的Key,003作为Hash集合的value.
优先选择方案二