目录
一、缓存通识
缓存:存储在计算机上的⼀个原始数据复制集,以便于访问。
缓存是介于数据访问者和数据源之间的⼀种⾼速存储,当数据需要多次读取的时候,⽤于加快读取的速 度。
缓存(Cache) 和 缓冲(Buffer) 的分别?
缓存:⼀般是为了数据多次读取。
缓冲:⽐如CPU要把数据先硬盘,因为硬盘⽐较慢,先到缓冲设备Buffer,⽐如内存,Buffer读和写都需要。
1.1 ⽆处不在的缓存
CPU 缓存
操作系统缓存
数据库缓存
JVM 编译缓存
CDN 缓存
代理与反向代理缓存
前端缓存
应⽤程序缓存
分布式对象缓存
1.2 多级缓存 (重点)
二、Redis简介
2.1 什么是Redis
Redis是⽤C语⾔开发的⼀个开源的⾼性能键值对(key-value)的NoSQL数据库。它通过提供多种键值数据类型来适应不同场景下的存储需求。
Redis作为⼀个单线程的应⽤,为什么处理请求性能如此NB?IO多路复⽤
NoSQL,泛指⾮关系型的数据库,NoSQL即Not-Only SQL,它可以作为关系型数据库的良好补充。
2.2 Redis的应用场景
缓存(数据查询、短连接、新闻内容、商品内容等等)。(最多使⽤)
分布式集群架构中的session分离。
聊天室的在线好友列表。
任务队列。(秒杀、抢购、12306等等)
应⽤排⾏榜。
⽹站访问统计。
数据过期处理(可以精确到毫秒)
三、Redis数据存储的细节
3.1 Redis数据类型
Redis整体上是⼀个KV结构,但是它的Value⼜可以分⽂以下五种数据类型。
⽬前为⽌Redis⽀持的键值数据类型如下:
字符串类型(string) set key value
散列类型(hash) hset key field value
列表类型(list) lpush key a b c d
集合类型(set) sadd key a b c d
有序集合类型(zset/sortedset) zadd key a score b score
3.2 内存结构
下图是执⾏set hello world时,所涉及到的数据模型。
1. dictEntry:Redis是Key-Value数据库,因此对每个键值对都会有⼀个dictEntry,⾥⾯存储了指向Key和Value的指针;next指向下⼀个dictEntry,与本Key-Value⽆关。
2. Key:图中右上⻆可⻅,Key(”hello”)并不是直接以字符串存储,⽽是存储在SDS结构中。
3. redisObject:Value(“world”)既不是直接以字符串存储,也不是像Key⼀样直接存储在SDS中,⽽是存储在redisObject中。实际上,不论Value是5种类型的哪⼀种,都是通过redisObject来存储的;⽽redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。实际上,redisObject除了type和ptr字段以外,还有其他字段图中没有给出,如⽤于指定对象内部编码的字段;后⾯会详细介绍。
4. jemalloc:⽆论是DictEntry对象,还是redisObject、SDS对象,都需要内存分配器(如
jemalloc)分配内存进⾏存储。
3.3 内存分配器
Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。
jemalloc作为Redis的默认内存分配器,在减⼩内存碎⽚⽅⾯做的相对⽐较好。jemalloc在64位系统中,将内存空间划分为⼩、⼤、巨⼤三个范围;每个范围内⼜划分了许多⼩的内存块单位;当Redis存储数据时,会选择⼤⼩最合适的内存块进⾏存储。
在 jemalloc 类⽐过来的物流系统中,同城仓库相当于 tcache —— 线程独有的内存仓库;区域仓库相当于 arena —— ⼏个线程共享的内存仓库;全国仓库相当于全局变量指向的内存仓库,为所有线程可⽤。
在 jemalloc 中,整块批发内存,之后或拆开零售,或整块出售。整块批发的内存叫做 chunk,对于⼩件和⼤件订单,则进⼀步拆成 run。Chunk 的⼤⼩为 4MB(可调)或其倍数,且为 4MB 对⻬;⽽ run ⼤⼩为⻚⼤⼩的整数倍。
在 jemalloc 中,⼩件订单叫做 small allocation,范围⼤概是 1-57344 字节。并将此区间分成 44 档,每次⼩分配请求归整到某档上。例如,⼩于8字节的,⼀律分配 8 字节空间;17-32分配请求,⼀律分配32 字节空间。
对于上述 44 档,有对应的 44 种 runs。每种 run 专⻔提供此档分配的内存块(叫做 region)。
⼤件订单叫做 large allocation,范围⼤概是 57345-4MB不到⼀点的样⼦,所有⼤件分配归整到⻚⼤⼩。
jemalloc划分的内存单元如下图所示:
例如,如果需要存储⼤⼩为130字节的对象,jemalloc会将其放⼊160字节的内存单元中。
3.4 redisObject
Redis对象有5种类型;⽆论是哪种类型,Redis都不会直接存储,⽽是通过redisObject对象进⾏存储。
redisObject对象⾮常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject⽀持,下⾯将通过redisObject的结构来说明它是如何起作⽤的。
Redis中的每个对象都是由如下结构表示(列出了与保存数据有关的三个属性)
{
unsigned type:4;//类型 五种对象类型
unsigned encoding:4;//编码
void *ptr;//指向底层实现数据结构的指针
//...
int refcount;//引⽤计数
//...
unsigned lru:24;//记录最后⼀次被命令程序访问的时间
//...
}robj;
3.4.1 type
type字段表示对象的类型,占4个⽐特;⽬前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。当我们执⾏type命令时,便是通过读取RedisObject的type字段获得对象的类型;如下图所示:
3.4.2 encoding
encoding表示对象的内部编码,占4个⽐特。
对于Redis⽀持的每种类型,都有⾄少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。通过encoding属性,Redis可以根据不同的使⽤场景来为对象设置不同的编码,⼤⼤提⾼了Redis的灵活性和效率。以列表对象为例,有压缩列表和双端链表两种编码⽅式;如果列表中的元素较少,Redis倾向于使⽤压缩列表进⾏存储,因为压缩列表占⽤内存更少,⽽且⽐双端链表可以更快载⼊;当列表对象元素较多时,压缩列表就会转化为更适合存储⼤量元素的双端链表。
通过object encoding命令,可以查看对象采⽤的编码⽅式,如下图所示:
5种对象类型对应的编码⽅式以及使⽤条件,将在后⾯介绍。
3.4.3 ptr
ptr指针指向具体的数据,如前⾯的例⼦中,set hello world,ptr指向包含字符串world的SDS。
3.4.4 refcount
refcount与共享对象
refcount记录的是该对象被引⽤的次数,类型为整型。refcount的作⽤,主要在于对象的引⽤计数和内存回收。
当创建新对象时,refcount初始化为1;当有新程序使⽤该对象时,refcount加1;当对象不再被⼀个新程序使⽤时,refcount减1;当refcount变为0时,对象占⽤的内存会被释放。
共享对象的具体实现
Redis的共享对象⽬前只⽀持整数值的字符串对象。之所以如此,实际上是对内存和CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,判断操作复杂度为O(1);对于普通字符串,判断复杂度O(n);⽽对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。虽然共享对象只能是整数值的字符串对象,但是5种类型都可能使⽤共享对象(如哈希、列表等的元素可以使⽤)。
共享对象池
共享对象池是指Redis内部维护[0-9999]的整数对象池。
创建⼤量的整数类型redisObject存在内存开销,每个redisObject内部结构⾄少占16字节,甚⾄超过了整数⾃身空间消耗。
所以Redis内存维护⼀个[0-9999]的整数对象池,⽤于节约内存。
除了整数值对象,其他类型如list、hash、set、zset内部元素也可以使⽤整数对象池。
因此开发中在满⾜需求的前提下,尽量使⽤整数对象以节省内存。
就⽬前的实现来说,Redis服务器在初始化时,会创建10000个字符串对象,值分别是0~9999的整数值;当Redis需要使⽤值为0~9999的字符串对象时,可以直接使⽤这些共享对象。10000这个数字定义在源码的 OBJ_SHARED_INTEGERS 常量中定义。共享对象的引⽤次数可以通过object refcount命令查看,如下图所示。命令执⾏的结果⻚佐证了只有0~9999之间的整数会作为共享对象。
3.4.5 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会优先选择空转时间最⻓的对象进⾏释放。
3.4.6 ⼩结
综上所述,redisObject的结构与对象类型、编码、内存回收、共享对象都有关系;⼀个redisObject对象的⼤⼩为16字节:
4bit(类型)+4bit(编码)+24bit(lru)+4Byte(refcount)+8Byte(指针)=16Byte
3.5 SDS
3.5.1 SDS内存结构
Redis没有直接使⽤C字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,⽽是使⽤了SDS。SDS是简单动态字符串(Simple Dynamic String)的缩写。
(1) 3.2之前
struct sdshdr{
//记录buf数组中已使⽤字节的数量
//等于 SDS 保存字符串的⻓度
int len;
//记录 buf 数组中未使⽤字节的数量
int free;
//字节数组,⽤于保存字符串
char buf[];
}
其中,buf表示字节数组,⽤来存储字符串;len表示buf已使⽤的⻓度,free表示buf未使⽤的⻓度。下⾯是两个例⼦。
通过SDS的结构可以看出,[buf数组的⻓度=free+len+1(其中1表示字符串结尾的空字符);所以,⼀个SDS结构占据的空间为:free所占⻓度+len所占⻓度+ buf数组的⻓度+1=4+4+字符串⻓度+1=字符串⻓度+9。
(2) 3.2之后
typedef char *sds;
struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串⻓度⼩于 1<<5 32字 节
unsigned char flags; /* 3 lsb of type, and 5 msb of string length int
embstr*/
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 对应的字符串⻓度⼩于 1<<8 256
uint8_t len; /* used */ //⽬前字符创的⻓度 ⽤1字节存储
uint8_t alloc; //已经分配的总⻓度 ⽤1字节存储
unsigned char flags; //flag⽤3bit来标明类型,类型后续
解释,其余5bit⽬前没有使⽤ embstr raw
char buf[]; //柔性数组,以'\0'结尾
};
struct __attribute__ ((__packed__)) sdshdr16 { // 对应的字符串⻓度⼩于 1<<16
uint16_t len; /*已使⽤⻓度,⽤2字节存储*/
uint16_t alloc; /* 总⻓度,⽤2字节存储*/
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 { // 对应的字符串⻓度⼩于 1<<32
uint32_t len; /*已使⽤⻓度,⽤4字节存储*/
uint32_t alloc; /* 总⻓度,⽤4字节存储*/
unsigned char flags;/* 低3位存储类型, ⾼5位预留 */
char buf[];/*柔性数组,存放实际内容*/
};
struct __attribute__ ((__packed__)) sdshdr64 { // 对应的字符串⻓度⼩于 1<<64
uint64_t len; /*已使⽤⻓度,⽤8字节存储*/
uint64_t alloc; /* 总⻓度,⽤8字节存储*/
unsigned char flags; /* 低3位存储类型, ⾼5位预留 */
char buf[];/*柔性数组,存放实际内容*/
};
flag属性保存的是当前使⽤的SDS类型:
3.5.2 SDS与C字符串的⽐较
获取字符串⻓度:SDS是O(1),C字符串是O(n)
缓冲区溢出:使⽤C字符串的API时,如果字符串⻓度增加(如strcat操作)⽽忘记重新分配内存,很容易造成缓冲区的溢出;⽽SDS由于记录了⻓度,相应的API在可能造成缓冲区溢出时会⾃动重新分配内存,杜绝了缓冲区溢出。
修改字符串时内存的重分配:对于C字符串,如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串⻓度增⼤时会造成内存缓冲区溢出,字符串⻓度减⼩时会造成内存泄露。⽽对于SDS,由于可以记录len和free,因此解除了字符串⻓度和空间数组⻓度之间的关联,可以在此基础上进⾏优化:空间预分配策略(即分配内存时⽐实际需要的多)使得字符串⻓度增⼤时重新分配内存的概率⼤⼤减⼩;惰性空间释放策略使得字符串⻓度减⼩时重新分配内存的概率⼤⼤减⼩。
存取⼆进制数据:SDS可以,C字符串不可以。因为C字符串以空字符作为字符串结束的标识,⽽对于⼀些⼆进制⽂件(如图⽚等),内容可能包括空字符串,因此C字符串⽆法正确存取;⽽SDS以字符串⻓度len来作为字符串结束标识,因此没有这个问题。
四、Redis的对象类型与内存编码
Redis⽀持5种对象类型,⽽每种结构都有⾄少两种编码;
这样做的好处在于:
⼀⽅⾯接⼝与实现分离,当需要增加或改变内部编码时,⽤户使⽤不受影响;
另⼀⽅⾯可以根据不同的应⽤场景切换内部编码,提⾼效率。
Redis各种对象类型⽀持的内部编码如下图所示(只列出重点的):
4.1 字符串
4.1.1 概况
字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他⼏种复杂类型的元素也是字符串。字符串⻓度不能超过512MB。
4.1.2 内部编码
字符串类型的内部编码有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+字符串⻓