分布式系统绕不开的核心之一的就是数据缓存,有了缓存的支撑,系统的整体吞吐量会有很大的提升。通过使用缓存,我们把频繁查询的数据由磁盘调度到缓存中,保证数据的高效率读写。
当然,除了在内存内运行还远远不够,我们今天就以具有代表性的缓存中间件Redis为例子,分析下,它是如何达到飞起的效率。
Redis之所以能够提供超高的执行效率,主要从以下几个维度来实现的:
Redis官方站点中,有对Redis性能做了比较详细的压测,可以参考官方这一篇 How fast is Redis?,
在较高的配置基准下(比如 8C 16G +),在连接数为0~10000的时候,最高QPS可达到120000。Redis以超过60000个连接为基准,仍然能够在这些条件下维持50000个q/s,体现了超高的性能。下图中横轴是连接数,纵轴是QPS。
下面这张图为data size 与整体吞吐量之间的趋向关系:
这个大概可以得出一个容量预估,比如你的服务用户量是多少,预估峰值QPS是多少,集群需要配置多少个实例(虽然实例的多少不能线性计算),可以大致推算出去。
Redis的读写操作都是在内存中实现了,相对其他的持久化存储(如MySQL、File等,数据持久化在磁盘上),性能会高很多。因为在们在操作数据的时候,需要通过 IO 操作先将数据读取到内存里,增加工作成本。
上面那张图来源于网络,可以看看他的金字塔模型,越往上执行效率越高,价格也就越贵。下面给出每一层的执行耗时对比:
这样可能不直观,我们举个L1和SSD的对比,如果L1耗时1s的话,SSD中差不多要15~45小时。
因为 CPU 内部集成了内存控制器,所以CPU直接控制了内存,给予通信上的最优带宽。
在 Redis 缓存中,常用的主要数据类型有五种,如下:
上面这5种Redis 支持的数据类型,能够满足不同业务场景下的数据结构需求。而对于这几类数据类型的区分和支持,目的无非也是为了效率,具体的业务中使用恰当的数据结构才能保证得到应有的效率。
这5种数据类型都有一种或者多种数据结构来支撑,底层数据结构有 7 种。关系如下:
限免我们对这些数据结构一个个的分析。
Redis使用简单动态字符串(simple dynamic string,SDS)来表示字符串,Redis中字符串类型包含的数据结构有:整数(R_INT) 、 字符串(R_RAW)。我们以字符串为例子,常规的字符串,如 "Brand",如果要获取他的长度,需要从头开始遍历,直至遇到 常规的字符串,如 "Brand",如果要获取他的长度,需要从头开始遍历,直至遇到 \0 空字符代表结尾,如 C字符串。 空字符代表结尾,如 C字符串。
C 字符串结构与 SDS 字符串结构 对比图 参照如下:
比起C字符串,SDS具有以下优点:
通过上面的数据结构关系图,可以看出,压缩列表是 List 、Hash、 Set 三种数据类型底层实现之一。
当我们的list列表数据量比较少的时候,且存储的数据轻量的(如小整数值、短字符串)时候, Redis 就会通过压缩列表来进行底层实现。
ziplist 是由一系列特殊编码的连续内存块组成的顺序型的数据结构,在列表头有三个字段 zlbytes、zltail 和 zllen,列表中有多个entry,表尾还有一个 zlend,我们来具体拆解下:
参考代码如下:
struct ziplist<T> { // 列表占用字节数 int32 zlbytes; // 列表尾的偏移量,用于快速定位到最后一个节点 int32 zltail_offset; // 列表entry元素个数 int16 zllength; // 元素内容列表 T[] entries; // 标志压缩列表的结束,值恒为 0xFF int8 zlend; }
如果查找定位首个元素或最后1个元素,可以通过表头zlbytes、zltail_offset元素快速获取,复杂度是 O(1)。但是查找其他元素时,就没有这么高效了,只能逐个查找下去,比如 entry n 的复杂度就是 O(N)。
Redis List 数据类型经常使用在链表、消息队列、栈、有序的对象列表(如朋友圈的点赞顺序列表、评论顺序列表、关注时间线)等场景,无论是队列(先进先出),还是栈(先进后出),双端列表都能很好的支持。
参考代码如下:
typedef struct list { // 表头 listnode * head; // 表尾 listnode * tail; // 链表所包含的节点数量 unsigned long len; // 函数:复制节点值 void *(*dup)(void *ptr); // 函数:释放节点值 void (*free)(void *ptr); // 函数:对比节点值 int (*match)(void *ptr, void *key);} list;
Redis 的链表实现的特性可以总结如下:
这样,性能就的得到了更大的提升。
无论何种类型(string、list、hash、set、zset),Redis都是以一个Hash结构的形式来保存键值对的。整体是一个数组,数组中的每个元素都是一个独立的对象,被称为哈系桶,比如图中1 ~ n, 对应的entry保存着实际具体值的指针。
上图中的全局哈希表,它的时间复杂度是 O(1),只需要计算每个键的哈希值,便知道对应的哈希桶位置,定位桶中的 entry ,并找到对应数据。这个执行效率就很高了。
为了解决可能存在的冲突,采用了链式哈希的做法,也就是同一个桶里面的元素使用链表保存。
如果你的集合只有整数值元素,并且数量是轻量的,这时候Redis会使用使用整数集合作为Redis集合的底层数据结构。参考如下代码:
typedef struct intset{ // 编码格式 uint32_t encoding; // 集合中的元素个数 uint32_t length; // 保存元素数据 int8_t contents[];} intset;
我们拆解下:
skiplist(即跳跃表)是一种有序数据结构,所以它也是ZSet数据类型中的一种,通过在每个节点中维持多个指向其他节点的指针,达到快速定位的目标。
跳跃表的平均的节点搜索,平均时间复杂度是 O(logN)、最差时间复杂度是 O(N),还可以通过顺序性操作来批量处理节点。 跳跃表是基于链表的改良,在它基础上,增加了多层级索引,通过索引不断跳转,最终定好位到真实的数据项。这个方式是不是让大家想到b+tree,理念上有点接近,如下图所示:
可以看出,需要获取 68 这个元素需要经历3次查找,需要获取 97则需要经历4次查找。
Redis 的单线程主要是指Redis的网络IO和键值对读写是由一个线程来完成的,Redis在处理客户端的请求时包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。这也是Redis对外提供键值存储服务的主要流程。
但Redis的其他功能, 比如持久化、异步删除、集群数据同步等等,其实是由额外的线程执行的。 可以这么说,Redis工作线程是单线程的。但是,整个Redis来说,是多线程的。
那在主流程中使用单线程,主要是出于什么原因呢?
官方这么说
It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.
大概意思是,Redis是完全的纯内存操作,执行速度是非常快的,CPU通常不会是瓶颈,因为大多数请求不会是CPU密集型的。参考
Redis真正的性能瓶颈在网络IO,也就是客户端和服务端之间的网络传输延迟,因此Redis选择了单线程的IO多路复用来实现它的核心网络模型。
服务端网络编程常见的 I/O 模型有四种:同步阻塞IO(Blocking IO)、同步非阻塞IO(Non-blocking IO)、IO多路复用(IO Multiplexing)、异步IO(Asynchronous IO)。
Redis 采用的是 I/O 多路复用技术,并发的去处理连接,它的多路复用程序函数有 select、poll、epoll、kqueue。以 epoll (目前最新的也是最好的多路复用技术)函数为例,当客服端执行 read、write、accept、close 等操作命令时,它会将命令封装成一个个事件,然后利用 epoll 多路复用的特性来避免 I/O 阻塞。
下面我们看看普通 I/O 模型 和 Redis的 I/O 多路复模型的的区别,来分析Redis高频请求下如何保持高效执行。
先来看一下传统的阻塞 I/O 模型到底是如何工作的:当使用 read 或者 write 对某一个文件描述符(File Descriptor:FD)进行读写时,如果当前 FD 不可读或不可写,整个 Redis 服务就不会对其它的操作作出响应,导致整个服务不可用。
这也就是传统意义上的,也就是我们在编程中使用最多的阻塞模型:
阻塞模型虽然开发中非常常见也非常易于理解,但是由于它会影响其他 FD 对应的服务,所以在需要处理多个客户端任务的时候,往往都不会使用阻塞模型。
多路 复用指的是:多个socket连接复用一个线程。这种模式下,内核不会去监视应用程序的连接,而是监视文件描述符。
当客户端发起请求的时候,会生成不同事件类型的套接字。而在服务端,因为使用了 I/O 多路复用技术,所以不是阻塞式的同步执行,而是将消息放入 socket 队列(参考下图的 I/O Multiplexing module),然后通过 File event Dispatcher 将其转发到不同的事件处理器上,如accept、read、send。
综上,我们得出如下特性:
为帮助开发者们提升面试技能、有机会入职BATJ等大厂公司,特别制作了这个专辑——这一次整体放出。
大致内容包括了: Java 集合、JVM、多线程、并发编程、设计模式、Spring全家桶、Java、MyBatis、ZooKeeper、Dubbo、Elasticsearch、Memcached、MongoDB、Redis、MySQL、RabbitMQ、Kafka、Linux、Netty、Tomcat等大厂面试题等、等技术栈!
欢迎大家关注公众号【Java烂猪皮】,回复【666】,获取以上最新Java后端架构VIP学习资料以及视频学习教程,然后一起学习,一文在手,面试我有。
每一个专栏都是大家非常关心,和非常有价值的话题,如果我的文章对你有所帮助,还请帮忙点赞、好评、转发一下,你的支持会激励我输出更高质量的文章,非常感谢!