JavaEE 企业级分布式高级架构师(五)Redis学习笔记(3)

Redis学习笔记

底层篇

缓存通识

  • 缓存:存储在计算机上的一个原始数据复制集,以便于访问。——维基百科
  • 缓存是介于数据访问者和数据源之间的一种高速存储,当数据需要多次读取的时候,用于加快读取的速度。

缓存(Cache) 和 缓冲(Buffer) 的区别?
缓存:一般是为了数据多次读取。
缓冲:比如CPU把数据写到硬盘,因为硬盘比较慢,先到缓冲设备Buffer,比如内存,Buffer读和写都需要。

无处不在的缓存

  • CPU缓存
  • 操作系统缓存
  • 数据库缓存
  • JVM编译缓存
  • CDN 缓存
  • 代理与反向代理缓存
  • 前端缓存
  • 应用程序缓存
  • 分布式对象缓存——Redis

缓存数据存储(hash表)

在这里插入图片描述

  • 缓存数据存储:一般数据放到内存中。
  • 主要是存储数据结构:Hash表。
  • 业界缓存数量:百万级~亿级。

缓存的关键指标

缓存命中率

  • 缓存是否有效依赖于能多少次重用同一个缓存响应业务请求,这个度量指标被称作缓存命中率。
  • 如果查询一个缓存,十次查询九次能够得到正确结果,那么它的命中率是 90%。

影响缓存命中率的主要指标

  • 缓存键集合大小
  • 缓存可使用内存空间
  • 缓存对象生存时间
缓存键集合大小
  • 缓存中的每个对象使用缓存键进行识别,定位一个对象的唯一方式就是对缓存键执行精确匹配。例如,如果想为每个商品缓存在线商品信息,你需要使用商品 ID 作为缓存键。换句话说,缓存键空间是你的应用能够生成的所有键的数量。
  • 从统计数字上看,应用生成的唯一键越多,重用的机会越小。例如,如果想基于客户 IP 地址缓存天气数据,则可能有多大 40 亿个键(这是所有可能 IP 地址的数量)。如果要基于客户来源国家缓存天气数据,则可能仅需几百个缓存键(世界上所有国家的数量)。
  • 一定要想办法减少可能的缓存数量。键数量越少,缓存的效率越高。
缓存可使用内存空间
  • 缓存可使用内存空间直接解决了缓存对象的平均大小和缓存对象数量。
  • 因为缓存通常存储在内存中,缓存对象可用空间受到严格限制且相对昂贵。如果想缓存更多的对象,就需要先删除老的对象,再添加新的对象。清除对象会降低缓存命中率,因为缓存对象被删除后,将来的请求就无法命中了。物理上能缓存的对象越多,缓存命中率就越高。
缓存对象生存时间
  • 缓存对象生存时间称为TTL(Time To Live)。
  • 在某些场景中,例如,缓存天气预报数据 15 分钟没问题。在这个场景下,你可以设置缓存对象定义 TTL 为 15 分钟。在其它场景中,你可能不能冒险使用过于陈旧的数据。例如,在一个电子商务系统中,店铺管理员可能在任何时刻修改商品价格,如果这些价格需要准确地展示在整个网站中。在这个场景下,你需要在每个商品价格修改时让缓存失效。简单讲,对象缓存的时间越长,缓存对象被重用的可能性就越高。

缓存类型

代理缓存

在这里插入图片描述

反向代理缓存

在这里插入图片描述

多层反向代理缓存

在这里插入图片描述

内容分发网络(CDN - Content Distribution Network)

在这里插入图片描述
CDN国内公司:ChinaCache在08年的时候,互联网需求量很大的时候,ChinaCache差点垮掉。ChinaCache提前买了很多CDN服务器放到运营商(中国移动、电信)。但是优酷、土豆等互联网公司快速发展,他们也选择了自建,自己买CDN服务器放到运营商机房。

  • 小网站:云服务提供。
  • 大网站:Alibaba、腾讯等都是自建CDN。

CDN同时配置静态文件和动态内容

在这里插入图片描述

通读缓存(Read-Through)
  • 代理缓存,反向代理缓存,CDN 缓存都是通读缓存。
  • 通读缓存给客户端返回缓存资源,并在请求未命中缓存时获取实际数据。
  • 客户端连接的是通读缓存而不是生成相应的原始服务器。
    在这里插入图片描述
旁路缓存(Cache-Aside)
  • 对象缓存是一种旁路缓存,旁路缓存通常是一个独立的键值对(Key-Value)存储。
  • 应用代码通常会询问对象缓存需要的对象是否存在,如果存在,它会获取并使用缓存的对象,如果不存在或已过期,应用会连接主数据源来组装对象,并将其保存回对象缓存中以便将来使用。
    在这里插入图片描述
浏览器缓存
// 在 WebStorage 中缓存对象的 JavaScript 代码
var preferences = { /* data object to be stored */ };
localStorage.setItem('preferences', JSON.stringify(preferences));
// 访问缓存对象的 JavaScript 代码
var cachedData = localStorage.getItem('preferences'); 
var preferences = JSON.parse(cachedData);
// 1234567
本地对象缓存
  • 对象直接缓存在应用程序内存中。
  • 对象存储在共享内存,同一台机器的多个进程可以访问它们。
  • 缓存服务器作为独立应用和应用程序部署在同一个服务器上。
    在这里插入图片描述
  • 小规模可以满足需求,比如Session数据缓存。
  • 大规模不合适:
    • 因为是数据都是同步的,那么数据存储的数量比较少。
    • 数据同步,暂用比较大的网络带宽。
远程分布式对象缓存

在这里插入图片描述

  • 本地缓存是内存访问,没有远程交互开销,性能最好,但是受限于单机容量,一般缓存较小且无法扩展。
  • 分布式缓存一般都具有良好的水平扩展能力,对较大数据量的场景也能应付自如。缺点就是需要进行远程请求,性能不如本地缓存。
  • 为了平衡这种情况,实际业务中一般采用多级缓存,本地缓存只保存访问频率最高的部分热点数据,其他的热点数据放在分布式缓存中。
各种介质数据访问延迟

在这里插入图片描述

  • 缓存比数据库访问速度快100倍。请求1k数据,分布式缓存约需要500微妙,数据库大概需要50毫秒。
技术栈各个层次的缓存

在这里插入图片描述

缓存为什么能显著提升性能

  • 缓存数据通常来自内存,比磁盘上的数据有更快的访问速度。
  • 缓存存储数据的最终结果形态,不需要中间计算,减少 CPU 资源的消耗。
  • 缓存降低数据库、磁盘、网络的负载压力,使这些 I/O 设备获得更好的响应特性。

缓存时系统性能优化的大杀器

  • 技术简单。
  • 性能提升显著。
  • 应用场景多。

合理使用缓存

  • 使用缓存对提高系统性能有很多好处,但是不合理的使用缓存可能非但不能提高系统的性能,还会成为系统的累赘,甚至风险。实践中,缓存滥用的情节屡见不鲜 – 过分依赖缓存、不合适的数据访问特性等。
  • 频繁修改的数据:这种数据如果缓存起来,由于频繁修改,应用还来不及读取就已经失效或更新,徒增系统负担。
  • 一般说来,数据的读写比在 2:1 以上,缓存才有意义

Redis内存模型

查看Redis内存统计

127.0.0.1:6379> info memory
# Memory #Redis分配的内存总量,包括虚拟内存(字节) 
used_memory:853464 
#占操作系统的内存,不包括虚拟内存(字节) 
used_memory_rss:12247040 
#内存碎片比例 如果小于1说明使用了虚拟内存 
mem_fragmentation_ratio:15.07 
#内存碎片字节数 
mem_fragmentation_bytes 
#Redis使用的内存分配器 
mem_allocator:jemlloc-5.1.0

在这里插入图片描述

used_memory
  • 由Redis内存分配器分配的数据内存缓冲内存的内存总量(单位是字节),包括使用的虚拟内存(即swap);used_memory_human只是显示更加人性化。
used_memory_rss
  • 记录的是由操作系统分配的Redis进程内存和Redis内存中无法再被 jemalloc 分配的内存碎片(单位是字节)。

used_memory和used_memory_rss的区别:
前者是从Redis角度得到的量,后者是从操作系统角度得到的量。二者之所以有所不同,一方面是因为内存碎片和Redis进程运行需要占用内存,使得前者可能比后者小,另一方面虚拟内存的存在,使得前者可能比后者大。

  • 由于在实际应用中,Redis的数据量会比较大,此时进程运行占用的内存与Redis数据量和内存碎片相比,都会小得多;因此used_memory_rss和used_memory的比例,便成了衡量Redis内存碎片率的参数;这个参数就是mem_fragmentation_ratio。
mem_fragmentation_ratio
  • 内存碎片比率,该值是used_memory_rss / used_memory的比值。
  • mem_fragmentation_ratio一般大于1,且该值越大,内存碎片比例越大。
  • mem_fragmentation_ratio<1,说明Redis使用了虚拟内存,由于虚拟内存的媒介是磁盘,比内存速度要慢很多,当这种情况出现时,应该及时排查,如果内存不足应该及时处理,如增加Redis节点、增加Redis服务器的内存、优化应用等。
  • 一般来说,mem_fragmentation_ratio在1.03左右是比较健康的状态(对于jemalloc来说);刚开始的mem_fragmentation_ratio值很大,是因为还没有向Redis中存入数据,Redis进程本身运行的内存使得used_memory_rss 比used_memory大得多。
mem_allocator
  • Redis使用的内存分配器,在编译时指定;可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc

Redis内存划分

数据
  • 作为数据库,数据是最主要的部分;这部分占用的内存会统计在 used_memory 中。
  • Redis 使用键值对存储数据,其中的值(对象)包括 5 种类型,即字符串、哈希、列表、集合、有序集合。
  • 这 5 种类型是 Redis 对外提供的,实际上,在 Redis 内部,每种类型可能有 2 种或更多的内部编码实现。
进程
  • Redis 主进程本身运行肯定需要占用内存,如代码、常量池等等;这部分内存大约几M,在大多数生产环境中与 Redis 数据占用的内存相比可以忽略。
  • 这部分内存不是由 jemalloc 分配,因此不会统计在 used_memory 中。
缓冲内存
  • 缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF 缓冲区等;其中,客户端缓冲区存储客户端连接的输入输出缓冲;复制积压缓冲区用于部分复制功能;AOF 缓冲区用于在进行 AOF 重写时,保存最近的写入命令。
  • 在了解相应功能之前,不需要知道这些缓冲的细节;这部分内存由 jemalloc 分配,因此会统计在used_memory 中。
内存碎片
  • 内存碎片是 Redis 在分配、回收物理内存过程中产生的。例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致 Redis 释放的空间在物理内存中并没有释放。
  • 但 Redis 又无法有效利用,这就形成了内存碎片,内存碎片不会统计在 used_memory 中。
  • 内存碎片的产生与对数据进行的操作、数据的特点等都有关;此外,与使用的内存分配器也有关系:如果内存分配器设计合理,可以尽可能的减少内存碎片的产生。如果 Redis 服务器中的内存碎片已经很 大,可以通过安全重启的方式减小内存碎片。因为重启之后,Redis 重新从备份文件中读取数据,在内 存中进行重排,为每个数据重新选择合适的内存单元,减小内存碎片。
图解

在这里插入图片描述

Redis数据存储的细节

  • Redis 是一个K-V NOSQL,五种类型都是针对K-V中的V的。
  • 下图是执行set hello world时,所涉及到的数据模型。
    在这里插入图片描述
  • dictEntry:Redis是Key-Value数据库,因此对每个键值对都会有一个dictEntry,里面存储了指向Key和Value的指针,next指向下一个dictEntry,与本Key-Value无关。
  • Key:图中右上角可见,Key(”hello”)并不是直接以字符串存储,而是存储在SDS结构中。
  • redisObject:Value(“world”)既不是直接以字符串存储,也不是像Key一样直接存储在SDS中,而是存储在redisObject中。实际上,不论Value是5种类型的哪一种,都是通过redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。实际上,redisObject除了type和ptr字段以外,还有其他字段图中没有给出,如用于指定对象内部编码的字段等。
  • jemalloc:无论是DictEntry对象,还是redisObject、SDS对象,都需要内存分配器(如jemalloc)分配内存进行存储。

jemalloc

  • Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。
  • jemalloc作为Redis的默认内存分配器,在减小内存碎片方面做的相对比较好。jemalloc在64位系统中,将内存空间划分为小、大、巨大三个范围;每个范围内又划分了许多小的内存块单位;当Redis存储数据时,会选择大小最合适的内存块进行存储。
  • jemalloc划分的内存单元如下图所示:
    在这里插入图片描述
  • 例如,需要空间 9,会给你分配16,这样内存碎片 = 16 - 9 = 7。
  • 这样划分:既能保证充分利用内存空间,又能保证我们的性能使用。
  • 例如,如果需要存储大小为130字节的对象,jemalloc会将其放入160字节的内存单元中。

redisObject

  • Redis对象有5种类型,无论是哪种类型,Redis都不会直接存储,而是通过redisObject对象进行存储。
  • redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持,下面将通过redisObject的结构来说明它是如何起作用的。
  • Redis中的每个对象都是由如下结构表示(列出了与保存数据有关的三个属性)
{
	unsigned type:4;//类型 五种对象类型
	unsigned encoding:4;//编码
	void *ptr;//指向底层实现数据结构的指针
	//...
	int refcount;//引用计数
	//...
	unsigned lru:24;//记录最后一次被命令程序访问的时间 //...
}robj;
type
  • type表示对象的类型,占4个比特。目前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。
  • 当我们执行type命令时,便是通过读取RedisObject的type字段获得对象的类型。
    在这里插入图片描述
encoding
  • encoding表示对象的内部编码,占4个比特。对于Redis支持的每种类型,都有至少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。
  • 通过encoding属性,Redis可以根据不同的使用场景来为对象设置不同的编码,大大提高了Redis的灵活性和效率。以列表对象为例,有压缩列表和双端链表两种编码方式;如果列表中的元素较少,Redis倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更适合存储大量元素的双端链表。
  • 通过object encoding命令,可以查看对象采用的编码方式,如下图所示:
    在这里插入图片描述
ptr

ptr指针指向具体的数据,如前面的例子中,set hello world,ptr指向包含字符串world的SDS。

refcount
  • refcount记录的是该对象被引用的次数,类型为整型。refcount的作用,主要在于对象的引用计数和内存回收。

当创建新对象时,refcount初始化为1;当有新程序使用该对象时,refcount加1;当对象不再被一个新程序使用时,refcount减1;当refcount变为0时,对象占用的内存会被释放。

  • Redis中被多次使用的对象(refcount>1),称为共享对象。
  • Redis为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。这个被重复使用的对象,就是共享对象。
  • 目前共享对象仅支持整数值的字符串对象。
共享对象的具体实现
  • Redis的共享对象目前只支持整数值的字符串对象。之所以如此,实际上是对内存和CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,判断操作复杂度为O(1);对于普通字符串,判断复杂度为O(n);而对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。
  • 虽然共享对象只能是整数值的字符串对象,但是5种类型都可能使用共享对象(如哈希、列表等的元素可以使用)。
  • 就目前的实现来说,Redis服务器在初始化时,会创建10000个字符串对象,值分别是0 ~ 9999的整数值;当Redis需要使用值为0 ~ 9999的字符串对象时,可以直接使用这些共享对象。10000这个数字可以通过调整参数REDIS_SHARED_INTEGERS(4.0中是OBJ_SHARED_INTEGERS)的值进行改变。
  • 共享对象的引用次数可以通过object refcount命令查看,如下图所示。命令执行的结果页佐证了只有0~9999之间的整数会作为共享对象。
    在这里插入图片描述
lru
  • lru记录的是对象最后一次被命令程序访问的时间,占据的比特数不同的版本有所不同(2.6版本占22比特,4.0版本占24比特)。
  • 通过对比lru时间与当前时间,可以计算某个对象的闲置时间;object idletime命令可以显示该闲置时间(单位是秒)。object idletime命令的一个特殊之处在于它不改变对象的lru值。
    在这里插入图片描述
  • lru值除了通过object idletime命令打印之外,还与Redis的内存回收有关系:如果Redis打开了maxmemory选项,且内存回收算法选择的是volatile-lru或allkeys—lru,那么当Redis内存占用超过maxmemory指定的值时,Redis会优先选择空转时间最长的对象进行释放。
总结
  • 综上所述,redisObject的结构与对象类型、编码、内存回收、共享对象都有关系:一个redisObject对象的大小为16字节:

4bit+4bit+24bit+4Byte+8Byte=16Byte

SDS k-v

  • Redis没有直接使用C字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,而是使用了SDS。SDS是简单动态字符串(Simple Dynamic String)的缩写。
  • 类似 Java 中动态数组 ArrayList
3.2版本之前
struct sdshdr{ 
	//记录buf数组中已使用字节的数量 
	//等于 SDS 保存字符串的长度
	int len;
	//记录 buf 数组中未使用字节的数量 
	int free; 
	//字节数组,用于保存字符串
	char buf[];
}
3.2版本之后
typedef char *sds;
// 对应的字符串长度小于 1<<5 32字节
struct __attribute__ ((__packed__)) sdshdr5 { 
	// 3 lsb of type, and 5 msb of string length   int embstr
    unsigned char flags; 
    char buf[];
};
// 对应的字符串长度小于 1<<8 256字节
struct __attribute__ ((__packed__)) sdshdr8 {
	// 目前字符串的长度 用1字节存储
    uint8_t len; /* used */
    // 已经分配的总长度 用1字节存储
    uint8_t alloc;
    // flag用3bit来标明类型,类型
    unsigned char flags;
    // 柔性数组,以'\0'结尾
	char buf[];
};
// 对应的字符串长度小于 1<<16
struct __attribute__ ((__packed__)) sdshdr16 {
	// 已使用长度,用2字节存储
	uint16_t len; 
	// 总长度,用2字节存储
	uint16_t alloc; 
	// 3 lsb of type, 5 unused bits
	unsigned char flags;
	char buf[];
};
// 对应的字符串长度小于 1<<32
struct __attribute__ ((__packed__)) sdshdr32 {
	// 已使用长度,用4字节存储
	uint32_t len;
	// 总长度,用4字节存储
	uint32_t alloc;
	// 低3位存储类型, 高5位预留
	unsigned char flags;
	// 柔性数组,存放实际内容
	char buf[];
};
// 对应的字符串长度小于 1<<64
struct __attribute__ ((__packed__)) sdshdr64 {
	// 已使用长度,用8字节存储
	uint64_t len; /**/
	// 总长度,用8字节存储
	uint64_t alloc; 
	// 低3位存储类型, 高5位预留
	unsigned char flags;
	// 柔性数组,存放实际内容 
	char buf[];
};

在这里插入图片描述

SDS 与 C 字符串的比较
  • 获取字符串长度:SDS是O(1),C字符串是O(n)。
  • 缓冲区溢出:使用C字符串的API时,如果字符串长度增加(如strcat操作)而忘记重新分配内存,很容易造成缓冲区的溢出;而SDS由于记录了长度,相应的API在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
  • 修改字符串时内存的重分配:对于C字符串,如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而对于SDS,由于可以记录len和free,因此解除了字符串长度和空间数组长度之间的关联,可以在此基础上进行优化:空间预分配策略(即分配内存时比实际需要的多)使得字符串长度增大时重新分配内存的概率大大减小;惰性空间释放策略使得字符串长度减小时重新分配内 存的概率大大减小。
  • 存取二进制数据:SDS可以,C字符串不可以。因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而SDS以字符串长度len来作为字符串结束标识,因此没有这个问题。
  • 此外,由于SDS中的buf仍然使用了C字符串(即以’\0’结尾),因此SDS可以使用C字符串库中的部分函数;但是需要注意的是,只有当SDS用来存储文本数据时才可以这样使用,在存储二进制数据时则不行(’\0’不一定是结尾)。
  • 类比ArrayList length = alloc, size= len

SDS 比如大小17字节 : 32空间
8字节 : 32空间
24字节空间 : 不会分配给其他对象
Redis 是多线程还是单线程?
主线程 是单线程
处理上主要是指I/O 多线程

Redis的对象类型与内存编码

  • Redis支持5种对象类型,而每种结构都有至少两种编码,这样做的好处在于:一方面接口与实现分离,当需要增加或改变内部编码时,用户使用不受影响;另一方面可以根据不同的应用场景切换内部编码,提高效率。
  • Redis各种对象类型支持的内部编码如下图所示(只列出重点的):
    在这里插入图片描述

字符串(SDS)

概况
  • 字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他几种复杂类型的元素也是字符串。
  • 字符串长度不能超过512MB
内部编码

字符串类型的内部编码有3种,它们的应用场景如下:

  • int:8个字节的长整型。字符串值是整型时,这个值使用long整型表示。
  • embstr<=44字节的字符串。embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。
  • raw:大于44个字节的字符串。3.2之后 embstr和raw进行区分的长度,是44;是因为redisObject的长度是16字节,sds的长度是4+字符串长度;因此当字符串长度是44时,embstr的长度正好是16+4+44 =64,jemalloc正好可以分配64字节的内存单元。3.2 之前embstr和raw进行区分的长度,是39,因为redisObject的长度是16字节,sds的长度是9+字符串长度;因此当字符串长度是39时,embstr的长度正好是16+9+39 =64,jemalloc正好可以分配64字节的内存单元。
  • 例子:
    在这里插入图片描述

列表

概况
  • 列表(list)用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储232-1个元素。
  • Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。
  • LinkedList
内部编码
  • Redis3.0之前列表的内部编码可以是压缩列表(ziplist)或双端链表(linkedlist)。选择的折中方案是两种数据类型的转换,但是在3.2版本之后因为转换也是个费时且复杂的操作,引入了一种新的数据格式,结合了双向列表linkedlist和ziplist的特点,称之为quicklist。所有的节点都用quicklist存储,省去了到临界条件是的格式转换。
压缩列表
  • 压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表只包含少量列表项时,并且每个列表项是小整数值或短字符串,那么Redis会使用压缩列表来做该列表的底层实现。
  • 压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值,放到一个连续内存区
    在这里插入图片描述
  • previous_entry_ength:记录压缩列表前一个字节的长度。
  • encoding:节点的encoding保存的是节点的content的内容类型。
  • content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。
双向链表
  • 双向链表(linkedlist)由一个list结构和多个listNode结构组成,典型结构如下图所示:
    在这里插入图片描述
  • 通过图中可以看出,双端链表同时保存了表头指针和表尾指针,并且每个节点都有指向前和指向后的指针;链表中保存了列表的长度;dup、free和match为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。而链表中每个节点指向的是type为字符串的redisObject。
快速列表
  • 简单的说,我们仍旧可以将其看作一个双向列表,但是列表的每个节点都是一个ziplist,其实就是linkedlist和ziplist的结合。quicklist中的每个节点ziplist都能够存储多个数据元素。Redis3.2开始,列表采用quicklist进行编码。
    在这里插入图片描述
//32byte 的空间
typedef struct quicklist {
	// 指向quicklist的头部 
	quicklistNode *head;
	// 指向quicklist的尾部
	quicklistNode *tail;
	// 列表中所有数据项的个数总和
	unsigned long count;
	// quicklist节点的个数,即ziplist的个数
	unsigned int len;
	// ziplist大小限定,由list-max-ziplist-size给定
	// 表示不用整个int存储fill,而是只用了其中的16位来存储 
	int fill : 16;
	// 节点压缩深度设置,由list-compress-depth给定 
	unsigned int compress : 16;
} quicklist;
typedef struct quicklistNode {
	// 指向上一个ziplist节点
    struct quicklistNode *prev;
    // 指向下一个ziplist节点
    struct quicklistNode *next;
    // 数据指针,如果没有被压缩,就指向ziplist结构,反之指向quicklistLZF结构
    unsigned char *zl;
	// 表示指向ziplist结构的总长度(内存占用长度)
	unsigned int sz;
	// 表示ziplist中的数据项个数
	unsigned int count : 16; 
	// 编码方式,1--ziplist,2--quicklistLZF
	unsigned int encoding : 2; 
	// 预留字段,存放数据的方式,1--NONE,2--ziplist
	unsigned int container : 2;
	// 解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为1,之后再重新进行压缩
	unsigned int recompress : 1; 
	// 测试相关
	unsigned int attempted_compress : 1; 
	// 扩展字段,暂时没用 
	unsigned int extra : 10; 
} quicklistNode;

哈希(压缩列表和哈希表)

概况
  • 哈希(作为一种数据结构),不仅是redis对外提供的5种对象类型的一种(与字符串、列表、集合、有序结合并列),也是Redis作为Key-Value数据库所使用的数据结构。为了说明的方便,后面当使用“内层的哈希”时,代表的是redis对外提供的5种对象类型的一种;使用“外层的哈希”代指Redis作为Key-Value数据库所使用的数据结构。
内部编码
  • 内层的哈希使用的内部编码可以是压缩列表(ziplist)和哈希表(hashtable)两种;Redis的外层的哈希则只使用了hashtable。
  • 压缩列表前面已介绍。与哈希表相比,压缩列表用于元素个数少、元素长度小的场景;其优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于哈希中元素数量较少,因此操作的时间并没有明显劣势。
  • hashtable:一个hashtable由1个dict结构、2个dictht结构、1个dictEntry指针数组(称为bucket)和 多个dictEntry结构组成。
  • 正常情况下(即hashtable没有进行rehash时)各部分关系如下图所示:
    在这里插入图片描述
dict
  • **一般来说,通过使用dictht和dictEntry结构,便可以实现普通哈希表的功能;但是Redis的实现中,在 dictht结构的上层,还有一个dict结构。**下面说明dict结构的定义及作用。
  • dict结构如下:
typedef struct dict{
	dictType *type; // type里面主要记录了一系列的函数,可以说是规定了一系列的接口 
	void *privdata; // privdata保存了需要传递给那些类型特定函数的可选参数
	//两张哈希表
	dictht ht[2];//便于渐进式rehash
	int trehashidx; //rehash 索引,并没有rehash时,值为 -1 
	//目前正在运行的安全迭代器的数量
	int iterators;
} dict;
typedef struct dict{
    dictType *type;
    void *privdata;
    dictht ht[2];
    int trehashidx; //rehash
    int iterators;
} dict;
  • 其中,type属性和privdata属性是为了适应不同类型的键值对,用于创建多态字典。
  • ht属性和trehashidx属性则用于rehash,即当哈希表需要扩展或收缩时使用。ht是一个包含两个项的数组,每项都指向一个dictht结构,这也是Redis的哈希会有1个dict、2个dictht结构的原因。通常情况下,所有的数据都是存在放dict的ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。然后将ht[1]赋值给ht[0],并清空ht[1]。

因此,Redis中的哈希之所以在dictht和dictEntry结构之外还有一个dict结构,一方面是为了适应不同类型的键值对,另一方面是为了rehash。
于567 余568
解决冲突 开放地址法、链地址法

dictht
  • dictht结构如下
typedef struct dictht{ 
	//哈希表数组,每个元素都是一条链表 
	dictEntry **table; 
	//哈希表大小
	unsigned long size;
	// 哈希表大小掩码,用于计算索引值 
	// 总是等于 size - 1
	unsigned long sizemask;
	// 该哈希表已有节点的数量 
	unsigned long used;
}dictht;

其中,各个属性的功能说明如下:

  • table属性是一个指针,指向bucket
  • size属性记录了哈希表的大小,即bucket的大小
  • used记录了已使用的dictEntry的数量
  • sizemask属性的值总是为size-1,这个属性和哈希值一起决定一个键在table中存储的位置
bucket
  • bucket是一个数组,数组的每个元素都是指向dictEntry结构的指针。redis中bucket数组的大小计算规则如下:大于dictEntry的、最小的2n
  • 例如,如果有 1000 个 dictEntry,那么bucket大小为1024;如果有1500个dictEntry,则bucket大小为2048。
  • n % 32 = n & (32 -1)
dictEntry
  • dictEntry结构用于保存键值对,结构定义如下:
// 键
typedef struct dictEntry{
	void *key;
	union{ //值v的类型可以是以下三种类型
        void *val;
        uint64_tu64;
        int64_ts64;
	}v;
	// 指向下个哈希表节点,形成链表
    struct dictEntry *next;
}dictEntry;

其中,各个属性的功能如下:

  • key:键值对中的键;
  • val:键值对中的值,使用union(即共用体)实现,存储的内容既可能是一个指向值的指针,也可能是64位整型,或无符号64位整型;
  • next:指向下一个dictEntry,用于解决哈希冲突问题。
  • 在64位系统中,一个dictEntry对象占24字节(key/val/next各占8字节)
编码转换
  • 如前所述,Redis中内层的哈希既可能使用哈希表,也可能使用压缩列表。只有同时满足下面两个条件时,才会使用压缩列表:① 哈希中元素数量小于512个,② 哈希中所有键值对的键和值字符串长度都小于64字节
  • 下图展示了Redis内层的哈希编码转换的特点:
    在这里插入图片描述

集合(整数结合和哈希表)

概况
  • 集合(set)与列表类似,都是用来保存多个字符串,但集合与列表有两点不同:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。
  • 一个集合中最多可以存储232-1个元素;除了支持常规的增删改查,Redis还支持多个集合取交集、并集、差集。
内部编码
  • 集合的内部编码可以是整数集合(intset)或哈希表(hashtable)
  • 哈希表前面已经讲过,这里略过不提;需要注意的是,集合在使用哈希表时,值全部被置为null。
  • 整数集合的结构定义如下:
typedef struct intset{
    uint32_t encoding;// 编码方式
    uint32_t length;// 集合包含的元素数量
    int8_t contents[];// 保存元素的数组
} intset;
  • 其中,encoding代表contents中存储内容的类型,虽然contents(存储集合中的元素)是int8_t类型,但实际上其存储的值是int16_t、int32_t或int64_t,具体的类型便是由encoding决定的;length表示元素个数。
  • 整数集合适用于集合所有元素都是整数且集合元素数量较小的时候,与哈希表相比,整数集合的优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于集合数量较少,因此操作的时间并没有明显劣势。
编码转换
  • 只有同时满足下面两个条件时,集合才会使用整数集合:① 集合中元素数量小于512个;② 集合中所有元素都是整数值。如果有一个条件不满足,则使用哈希表;且编码只可能由整数集合转化为哈希表,反方向则不可能
  • 下图展示了集合编码转换的特点:
    在这里插入图片描述

有序集合

概况
  • 有序集合与集合一样,元素都不能重复;但与集合不同的是,有序集合中的元素是有顺序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。
内部编码
  • 有序集合的内部编码可以是压缩列表(ziplist)或跳跃表(skiplist)。ziplist在列表和哈希中都有使用,前面已经讲过,这里略过不提。
  • 跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
  • 除了跳跃表,实现有序数据结构的另一种典型实现是平衡树。大多数情况下,跳跃表的效率可以和平衡树媲美,且跳跃表实现比平衡树简单很多,因此redis中选用跳跃表代替平衡树。跳跃表支持平均O(logN)、最坏O(N)的复杂点进行节点查找,并支持顺序操作。
  • Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃表节点。
  • 只有同时满足下面两个条件时,才会使用压缩列表:① 有序集合中元素数量小于128个;② 有序集合中所有成员长度都不足64字节。
  • 如果有一个条件不满足,则使用跳跃表;且编码只可能由压缩列表转化为跳跃表,反方向则不可能。
  • 下图展示了有序集合编码转换的特点:
    在这里插入图片描述
跳跃表(图片来自网络)
图示
  • 普通单向链表图示:
    在这里插入图片描述
  • 跳跃表图示:
    在这里插入图片描述
    在这里插入图片描述
  • 跳表就是特殊的linkedlist
插入
  • 使用概率算法,在此还是以上图为例:跳跃表的初试状态第L1层如下图,表中没有一个元素
    在这里插入图片描述
  • 如果我们要插入元素2,首先是在底部插入元素2,如下图:
    在这里插入图片描述
  • 然后我们抛硬币,结果是正面,那么我们要将2插入到L2层,如下图:
    在这里插入图片描述
  • 继续抛硬币,结果是反面,那么元素2的插入操作就停止了,插入后的表结构就是上图所示。接下来,我们插入元素33,跟元素2的插入一样,先在L1层插入33,如下图:
    在这里插入图片描述
  • 然后抛硬币,结果是反面,那么元素33的插入操作就结束了,插入后的表结构就是上图所示。接下来,我们插入元素55,首先在L1插入55,插入后如下图:
    在这里插入图片描述
  • 然后抛硬币,结果是正面,那么L2层需要插入55,如下图:
    在这里插入图片描述
  • 继续抛硬币,结果又是正面,那么L3层需要插入55,如下图:
    在这里插入图片描述
  • 继续抛硬币,结果又是正面,那么要在L4插入55,结果如下图:
    在这里插入图片描述
  • 继续抛硬币,结果是反面,那么55的插入结束,表结构就如上图所示。
  • 以此类推,我们插入剩余的元素。当然因为规模小,结果很可能不是一个理想的跳跃表。但是如果元素个数n的规模很大,学过概率论的同学都知道,最终的表结构肯定非常接近于理想跳跃表(隔一个一跳)。
  • 优化后:随机数 1-32
  • 实际插入:节点层数恰好等于1的概率为1-p(p为1/4)。 3/4
  • 每层概率(1/4)n-1*(1-1/4)
  • 那么节点层数恰好等于32的概率为(1/4)31*(1-1/4),这个值的分子是3,分母是432(18,446,744,073,709,551,616),是个千亿亿级的数字,比中彩票的概率还小。
删除
  • 直接删除元素,然后调整一下删除元素后的指针即可。跟普通的链表删除操作完全一样。
    在这里插入图片描述
小结
  • 搜索:从最高层的链表节点开始,如果比当前节点要大和比当前层的下一个节点要小,那么则往下找,也就是和当前层的下一层的节点的下一个节点进行比较,以此类推,一直找到最底层的最后一个节点,如果找到则返回,反之则返回空。
  • 插入:首先确定插入的层数,有一种方法是假设抛一枚硬币,如果是正面就累加,直到遇见反面为止,最后记录正面的次数作为插入的层数。当确定插入的层数k后,则需要将新元素插入到从底层到k 层。
  • 删除:在各个层中找到包含指定值的节点,然后将节点从链表中删除即可,如果删除以后只剩下头尾两个节点,则删除这一层。
跳跃表的完美实现
typedef struct zskiplistNode { 
	//层
	struct zskiplistLevel{ 
		//前进指针 后边的节点
		struct zskiplistNode *forward; 
		//跨度
		unsigned int span;
	}level[];
	//后退指针
	struct zskiplistNode *backward; 
	//分值
	double score;
	//成员对象
	robj *obj;
} zskiplistNode
// 链表
typedef struct zskiplist{
	//表头节点和表尾节点
	structz skiplistNode *header, *tail; 
	//表中节点的数量
	unsigned long length; 
	//表中层数最大的节点的层数
	int level;
}zskiplist;

总结Redis基本数据类型的内存编码

在这里插入图片描述

  • string
    • int:8个字节的长整型
    • embstr:小于等于44字节的字符串
    • raw:大于44个字节的字符串
  • list
    • 3.0之前:压缩列表(ziplist)或双端链表(linkedlist)
    • 3.2之后:quicklist
  • hash
    • 内层的哈希:压缩列表(ziplist)和哈希表(hashtable)
    • 外层的哈希:hashtable
  • set
    • 整数集合(intset)或哈希表(hashtable)
    • 只有同时满足下面两个条件时,集合才会使用整数集合:
      • 集合中元素数量小于512个
      • 集合中所有元素都是整数值
  • zset
    • 压缩列表(ziplist)或跳跃表(skiplist)
    • 只有同时满足下面两个条件时,才会使用压缩列表
      • 有序集合中元素数量小于128
      • 有序集合中所有成员长度都不足64字节
      • 如果有一个条件不满足,则使用跳跃表,且编码只可能由压缩列表转化为跳跃表,反方向则不可能。

Redis 设计优化(实际使用)

估算Redis内存使用量

  • 要估算redis中的数据占据的内存大小,需要对redis的内存模型有比较全面的了解,包括hashtable、sds、redisobject、各种对象类型的编码方式等
  • 下面以最简单的字符串类型来进行说明:假设有90000个键值对,每个key的长度是12个字节,每个value的长度也是12个字节(且key和value都不是整数)。
    在这里插入图片描述
  • 下面来估算这90000个键值对所占用的空间。在估算占据空间之前,首先可以判定字符串类型使用的编码方式:embstr。
  • 90000个键值对占据的内存空间主要可以分为两部分:一部分是90000个dictEntry占据的空间;一部分是键值对所需要的bucket空间。
每个dictEntry占据的空间包括
  • 一个dictEntry结构,24字节,jemalloc会分配32字节的内存块(64位操作系统下,一个指针8字节,一个dictEntry由三个指针组成)。
  • 一个key,12字节,所以SDS(key)需要12+4=16 个字节([SDS的长度=4+字符串长度),jemalloc会分配16字节的内存块。
  • 一个redisObject,16字节,jemalloc会分配16字节的内存块(4bit+4bit+24bit+4Byte+8Byte=16Byte)。
  • 一个value,12字节,所以SDS(value)需要12+4=16个字节([SDS的长度=4+字符串长度),jemalloc会分配16字节的内存块。
  • 综上,一个dictEntry所占据的空间需要32+16+16+16=80个字节。
bucket空间
  • bucket数组的大小为大于90000的最小的2n,是131072;每个bucket元素(bucket中存储的都是指针元素)为8字节(因为64位系统中指针大小为8字节)。
  • 因此,可以估算出这90000个键值对占据的内存大小为:[9000080 + 1310728 = 8248576。
  • 作为对比将key和value的长度由12字节增加到13字节,则对应的SDS变为17个字节,jemalloc会分配32个字节,因此每个dictEntry占用的字节数也由80字节变为112字节。此时估算这90000个键值对占据内存大小为:90000112 + 1310728 = 11128576。

优化内存占用

了解redis的内存模型,对优化redis内存占用有很大帮助。下面介绍几种优化场景和方式

利用jemalloc特性进行优化
  • 由于jemalloc分配内存时数值是不连续的,因此key/value字符串变化一个字节,可能会引起占用内存很大的变动,在设计时可以利用这一点。
  • 例如,如果key的长度如果是13个字节,则SDS为17字节,jemalloc分配32字节;此时将key长度缩减为12个字节,则SDS为16字节,jemalloc分配16字节;则每个key所占用的空间都可以缩小一半。
使用整型/长整型
  • 如果是整型/长整型,Redis会使用int类型(8字节)存储来代替字符串,可以节省更多空间。因此在可以使用长整型/整型代替字符串的场景下,尽量使用长整型/整型。
共享对象
  • 利用共享对象,可以减少对象的创建(同时减少了redisObject的创建),节省内存空间。目前redis中的共享对象只包括10000个整数(0-9999);可以通过调整REDIS_SHARED_INTEGERS参数提高共享对象的个数。
  • 例如将REDIS_SHARED_INTEGERS调整到20000,则0-19999之间的对象都可以共享。
  • 论坛网站在redis中存储了每个帖子的浏览数,而这些浏览数绝大多数分布在0-20000之间,这时候通过适当增大REDIS_SHARED_INTEGERS参数,便可以利用共享对象节省内存空间。
缩短键值对的存储长度
  • 键值对的长度是和性能成反比的,比如我们来做一组写入数据的性能测试,执行结果如下:
    在这里插入图片描述
  • 从以上数据可以看出,在 key 不变的情况下,value 值越大操作效率越慢,因为 Redis 对于同一种数据类型会使用不同的内部编码进行存储,比如字符串的内部编码就有三种:int(整数编码)、raw(优化内存分配的字符串编码)、embstr(动态字符串编码),这是因为 Redis 的作者是想通过不同编码实现效率和空间的平衡,然而数据量越大使用的内部编码就越复杂,而越是复杂的内部编码存储的性能就越低。
  • 这还只是写入时的速度,当键值对内容较大时,还会带来另外几个问题:内容越大需要的持久化时间就越长,需要挂起的时间越长,Redis 的性能就会越低;内容越大在网络上传输的内容就越多,需要的时间就越长,整体的运行速度就越低;内容越大占用的内存就越多,就会更频繁的触发内存淘汰机制,从而给 Redis 带来了更多的运行负担。
  • 因此在保证完整语义的同时,我们要尽量的缩短键值对的存储长度,必要时要对数据进行序列化和压缩再存储,以 Java 为例,序列化我们可以使用 protostuff 或 kryo,压缩我们可以使用 snappy。

Redis版本历史(增加了解)

  • Redis2.6:在2012年正式发布
  • Redis2.8:在2013年11月22日正式发布,是 Redis Sentinel 第二版,相比于Redis2.6的Redis Sentinel,此版本已经变成生产可用。
  • Redis3.0:在2015年4月1日正式发布
  • Redis3.2:在2016年5月6日正式发布,集群高可用
  • Redis 4.0:在2017年7月发布为GA,主要是增加了混合持久化和LFU淘汰策略
  • Redis5.0:2018年10月18日正式发布,Stream 是重要新增特性

Redis 5.0 源码清单(对源码感兴趣的,看一下)

基本数据结构

  • 动态字符串sds.c
  • 整数集合intset.c
  • 压缩列表ziplist.c
  • 快速链表quicklist.c
  • 字典dict.c

Redis数据类型的底层实现

  • Redis对象object.c
  • 字符串t_string.c
  • 列表t_list.c
  • 字典t_hash.c
  • 集合及有序集合t_set.c和t_zset.c

Redis数据库的实现

  • 数据库的底层实现db.c
  • 持久化rdb.c和aof.c

Redis服务端和客户端实现

  • 事件驱动ae.c和ae_epoll.c
  • 网络连接anet.c和networking.c
  • 服务端程序server.c
  • 客户端程序redis-cli.c

集群相关

  • 主从复制replication.c
  • 哨兵sentinel.c
  • 集群cluster.c

特殊数据类型

  • 其他数据结构,如hyperloglog.c、geo.c
  • 数据流t_stream.c
  • Streams的底层实现结构listpack.c和rax.c
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

讲文明的喜羊羊拒绝pua

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值