本章主要讲redis的初级篇,主要讲了三个课题.
一是:从构建一个简单的simple kv类型的数据结构入手,怎样去推导出redis各个方面的应用性.有如下几个问题需要解决:1、需要存放什么类型的数据、2、对数据可提供什么操作、3、数据保存在哪里、4、如何查找数据、5、如何保障服务的高可用性?当你想通了简单simplekv的实操过程后,你就可以开始类比着探寻redis了.
二是:redis为什么快?有什么慢操作?主要从redis的底层数据结构的角度出发,包括了保存键值的全局哈希表结构,和支持集合类型的双向链表、压缩列表、整数数组、哈希表、跳表这五大底层结构.同时还详细讲了不同数据结构的工作原理、对比查询的时间复杂度,了解数据结构的实现原理,可以帮助你从根源上根据业务需求选择合适的数据结构.
三是继续承接上篇的问题:redis单线程指的是什么?为什么用单线程?单线程redis为什么这么快?从网络io的操作角度出发,多路复用的io模型避免了accept()和send()/recv()等潜在的网络io操作阻塞点.类似医院的分诊台一样帮助医生做一些会前的预先准备工作,避免大量事件阻塞在医生导致看病效率不高.
基础架构:一个键值数据库包含什么
学习最好的方式是先建立起“系统观”.若想深入理解和优化redis,必须对它的总体架构和关键模块有一个全局的认知,再深入到具体的知识点.
假设我们构建一个简单的simplekv组建,我们会如何下手?
1、可以存哪些数据?
- memcache :string
- redis:string、哈希表、列表、集合等.
redis能在实际业务场景中得到广泛的应用,得益于支持多样化类型的value.
2、可以对数据做什么操作?
PUT/GET/DELETE/SCAN
3、键值对保存在外存还是内存?
- 内存:
(1)读写快
(2)掉电数据会丢失 - 外存:
(1)受限于磁盘的慢速读写,键值数据库整理性能降低
(2)避免数据丢失
键值数据库基本包含访问框架、索引模块、操作模块、存储模块四个部分.
4、采用什么模式访问?
(1)通过函数库调用的方式供外部应用使用(如上图libsimplev.so以动态链接库的形式链接到自己程序中,提供键值存储)
(2)通过网络框架以socket通信对外提供键值对操作
客户端发送命令后,该命令会被封装到网络包中发送给键值数据库:
PUT HELLO WORLD
数据库网络框架接到网络包,安装协议进行解析,直到客户端想写入一个键值对,开始实际写入流程.
这里会产生一个系统设计上的问题:
网络连接的处理、网络请求的解析、数据存取的处理、是一个线程、多个线程、还是多个进程?如何设计和取舍?
此乃I/O模型设计.
假设一个线程既处理网络连接、解析请求、又要完成数据存取,一旦某一步操作发发生阻塞,整个线程都会阻塞,降低系统响应速度
如果采用不同线程处理,线程间访问共享资源又回产生线程竞争,也会影响系统效率.两难选择,所以需要设计.
5、如何定位键值对的位置?
simplekv解析了客户端的请求,知道要查询操作的键值对是否存在.依赖于键值数据库的索引模块.索引的作用是根据key找到对应value存储的位置,进行操作.
索引类型常见哈希表、b+树、字典树.不同索引结构构在性能、空间消耗、并发控制不一.
memcache & redis 使用哈希表做索引,RocksDB使用跳表.
6、不同操作具体逻辑是怎样的?
- GET/SCAN 根据value的存储位置返回value即可
- PUT 为该新的键值对分配内存空间
- DELETE 删除对应键值对并释放内存空间
7、如何实现重启后快速提供服务?
simplekv采用了常见的内存分配器glibc的malloc和free.
simplekv依赖内存保管数据,提供快速访问,但是也希望simplekv重启后能重新提供服务,故在simplekv存储模块加了持久话功能.
将键值数据通过本地文件系统的操作接口保存在磁盘上
- 方法一:对每个键值对都做落盘保存,数据更可靠但是性能会受影响
- 方法二:周期性保存键值对,避免频繁写盘操作的影响,存在丢失的风险.
小节:
simplekv和redis的对比,缺少的功能模块
[数据结构]缺乏广泛的数据支持,范围查询skiplist stream等等
[高可用]缺乏哨兵or master-slave模式的高可用
[横行扩展]缺乏集群和分片功能
[内存安全性]缺乏内存过载时的key淘汰算法
[内存利用率]未对数据结构优化提供内存利用率 如压缩性的数据结构
[不具备事务性]无法保证多个操作的原子性
[内存分配器]simplekv是glibc,redis更多
画外:
redis比memcache流行,表面上是数据结构更丰富,本质上是“计算向数据迁移”,要高性能、高可用、就需要快.memcache只支持string类型,需要获取数据必须得全量返回,返回体大,redis支持指定的数据,返回体小,标志着通过网卡的流量少,更符合redis的epoll的网络模型,尽量不阻塞.
二、快速的redis有哪些慢操作
Redis 的快是指它接收到一个键值对操作后,能以微秒级别的速度找到数据,并快速完成操作。
Redis 快的原因?
-
内存数据库,所有操作都在内存上完成,内存的访问速度本身就很快。
-
高效的数据结构
数据类型
String(字符串)、List(列表)、Hash(哈希)、Set(集合)、 Sorted Set(有序集合).
数据结构的底层实现
动态字符串、双向链表、压缩列表、哈希表、跳表和整数数组。
数据类型的对应关系如下图所示:
String 类型的底层实现只有一种数据结构,即简单动态字符串。 List、Hash、Set 和 Sorted Set 这四种数据类型有两种底层实现结构。我们把这四种类型称为集合类型,特点是一个键对应了一个集合的数据。
键和值用什么结构组织?
为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。
一个哈希表就是一个数组,数组的每个元素称为一个哈希桶。一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。
哈希桶中的 entry 元素中保存了key和value指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过*value指针被查找到。
因为这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。
哈希表好处:
用 O(1) 的时间复杂度来快速查找到键值对只需要计算键的哈希值,就可找到所对应的哈希桶位置,访问相应的 entry 元素。
为什么哈希表操作变慢了?—哈希冲突
哈希冲突:
两个 key 的哈希值和哈希桶计算对应关系时,正好落在了同一个哈希桶中。
解决哈希冲突的方式—链式哈希
同一个哈希桶中的多个元素用一个链表来保存,它们之间依次用指针连接。我们可以通过 entry 元素中的指针,把它们连起来。这就形成了一个链表,也叫作哈希冲突链。
哈希链过长影响查询效率–rehash
哈希冲突链上的元素只能通过指针逐一查找操作。若哈希冲突链过长会导致链上的元素查找耗时长,效率降低。因此redis 引入rehash 操作.通过rehash增加现有的哈希桶数量.让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突
**
Redis使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始插入数据默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
- 给哈希表 2 分配更大的空间,eg:当前哈希表 1 大小的两倍;
- 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
- 释放哈希表 1 的空间。
从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,原来的哈希表 1 留作下一次 rehash 扩容备用。
步骤二中涉及大量的数据拷贝,一次性迁移所有数据会造成 Redis 线程阻塞,无法服务其他请求。因此Redis 采用了渐进式 rehash。即在拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。
巧妙地把一次性大量拷贝的开销分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
集合数据操作效率
集合类型的值,第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。那么,集合的操作效率和哪些因素相关呢?
- 与集合的底层数据结构有关。eg:使用哈希表实现的集合,要比使用链表实现的集合访问效率更高。
- 操作本身的执行特点有关,eg:读写一个元素的操作要比读写所有元素的效率高。
有哪些底层数据结构?
-
整数数组/双向链表:
操作特征都是顺序读写,通过数组下标或者链表的指针逐个元素访问,操作复杂度基本是 O(N),操作效率比较低 -
压缩列表:
类似数组,但是在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
若是查找定位第一个/最后一个元素,可通过表头三个字段的长度直接定位,复杂度是 O(1)。查找其他元素时只能逐个查找,复杂度就是 O(N) . -
跳表:
跳表在链表的基础上增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位.这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。
不同操作的复杂度
口诀:
单元素操作是基础; 范围操作非常耗时; 统计操作通常高效; 例外情况只有几个。
单元素操作:每一种集合类型对单个数据实现的增删改查操作。eg:HGET、HSET 和 HDEL 是对哈希表做操作,所以复杂度都是 O(1);Set 类型用哈希表作为底层数据结构时,它的 SADD、SREM、SRANDMEMBER 复杂度也是 O(1)。
范围操作:集合类型中的遍历操作,eg:List 类型的 LRANGE 和 ZSet 类型的 ZRANGE。复杂度一般是 O(N),比较耗时,我们应该尽量避免。
统计操作:集合类型对集合中所有元素个数的记录,eg: LLEN 和 SCARD。操作复杂度只有 O(1),这是因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计,因此可以高效地完成相关操作。
例外情况:指某些数据结构的特殊记录,eg:压缩列表和双向链表都会记录表头和表尾的偏移量。 List 类型的 LPOP、RPOP、LPUSH、RPUSH 是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。
小结
1、Redis 的底层数据结构=保存每个键和值的全局哈希表结构+支持集合类型实现的双向链表、压缩列表、整数数组、哈希表和跳表这五大底层结构。
三、高性能io模型:为什么单线程redis可以那么快
Redis的单线程是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
Redis 为什么用单线程?
多线程的开销
多线程的好处:
多线程可以增加系统吞吐率,或是可以增加系统扩展性。对于一个多线程的系统来说,在有合理的资源分配的情况下,可以增加系统中处理请求操作的资源实体,进而提升系统能够同时处理的请求数,即吞吐率。
多线程的坏处:
多线程编程模式面临的共享资源的并发访问控制问题。
多线程同时访问的共享资源,为了保证共享资源的正确性,就需要有额外的机制进行保证,带来额外的开销。- 引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。
单线程 Redis 为什么那么快?
高性能的原因:
- Redis 大部分操作在内存上完成
- 采用了高效的数据结构
- Redis 采用了多路复用机制,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。
基本 IO 模型与阻塞点
以 Get 请求为例,SimpleKV 为了处理一个 Get 请求,需要以下几个步骤:
- 监听客户端请求(bind/listen)
- 客户端建立连接(accept)
- 从 socket 中读取请求(recv)
- 解析客户端发送请求(parse
- 根据请求类型读取键值数据(get)
- 客户端返回结果,向 socket 中写回数据(send)
非阻塞模式
在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。
针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。
基于多路复用的高性能 I/O 模型
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,即select/epoll 机制。在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
下图中的 FD 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说不会阻塞在某一个特定的客户端请求处理上。因此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
这就像病人去医院瞧病。在医生实际诊断前,每个病人(等同于请求)都需要先分诊、测体温、登记等。如果这些工作都由医生来完成,医生的工作效率就会很低。所以医院都设置了分诊台,分诊台会一直处理这些诊断前的工作(类似于 Linux 内核监听请求),然后再转交给医生做实际诊断。这样即使一个医生(相当于 Redis 单线程),效率也能提升。
小结
1、Redis 真的只有单线程吗?
Redis 单线程是指它对网络 IO 和数据读写的操作采用了一个线程
2、为什么用单线程?
避免多线程开发带来的并发控制问题
3、单线程为什么这么快?
单线程的 Redis 也能获得高性能离不内存数据库、数据结构本身的高效性和多路复用的 IO 模型
,避免了 accept() 和 send()/recv() 潜在的网络 IO 操作阻塞点。