不得不夸赞redis的轻量级,以及代码透明度。可以通过这本书《Redis设计与实现》看原理,对照着官网上的src看code,理解更深刻!!!
可能还需要看几遍,一遍看过去也不能全部记住,重复是硬道理!!!
0. 前言
- 特性:
- redis内置集合数据类型,支持对集合执行交集、并集、差集等
- 一部分命令只能对特定数据类型执行(append只能对字符串,hset只能对哈希表),还有一部分命令可以对全部的数据类型执行(del、type以及expire)
1. 引言
- 单机功能:第一部分、第二部分、第四部分
- 多机功能:第三部分
- 源代码:C语言
- 相关订正信息:http://redisbook.com/
2. 第2章 简单动态字符串
- SDS:simple dynamic string
- 键值对:底层实现是保存着对应字符串的SDS
- 键(msg)值(hello world):2个SDS
- 键(numbers)值(1 2 3 4):5个SDS
- 还可以被用作缓冲区buffer
2.1 SDS的定义
对照redis-6.0.8的代码看
// sds.h/sdshdr8
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
- len:使用的长度
- buf:存储的char类型数组
2.2 SDS与C字符串的区别
- 获取字符串长度的复杂度从O(N)降到O(1)
- C字符串不记录自身长度:容易造成缓冲区溢出
- C字符串修改可能会引起内存重分配,Redis数据库可能会出现数据被频繁修改,如果使用SDS可以缓解
- SDS空间预分配:小于1M,free=len,大于1M,free=1M
- SDS惰性空间释放:不用空间增加到free里面。也有相应的api可以真正释放空间
- 二进制安全:C字符串不能包含空字符,否则会认为是字符串的结尾,这导致C字符串不能保存图片、音频等二进制数据,但是SDS可以
- 兼容部分C字符串函数
安装
- 依据此链接,安装一个单机版本用作学习:https://redis.io/download
- 把server跑起来,然后运行一个client,通过6379的端口进行设置
3. 链表
- 双向链表
- 包含成员变量和成员函数:表头、表尾、节点数量、节点复制函数、节点释放函数、节点值对比函数
- 特性:使用void*指针来保存节点值,可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用来保存各种不同类型的值
4. 字典
4.1 相关的数据结构
- dictht
- table:哈希表数组
- size:哈希表的大小
- sizemask:哈希表大小掩码,用于计算索引值,等于size-1(属性值和hash值一起决定这个键应该被放在table数据的哪些索引)
- used:目前已有键值对的数量
// dict.h/dictht
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
- dictEntry
- next解决键冲突collosion的问题
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
- dict
- type属性是一个指向dictType结构的指针,指向不同类型的键值对,为创建多态字典设置的
- ht是包含两项的数组,每一项都是一个hash表,一般只使用ht[0], ht[0]需要rehash的时候使用ht[1]
- rehashidx不在进行时,值为-1
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
4.2 哈希算法
- hash = dict->type->hashFunction(k0)
- index = hash & dict->ht[0].sizemask
- redis使用的MurmurHash2
- MurmurHash算法由Austin Appleby发明于2008年,是一种非加密hash算法,适用于基于hash查找的场景。murmurhash最新版本是MurMurHash3,支持32位,64位及128位值的产生。
- MurmurHash标准使用C++实现,但是也有其他主流语言的支持版本,包括:perl、C#、ruby、python、java等。这种算法即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,计算速度非常快,使用简单。因此在多个开源项目中得到应用,包括libstdc、libmemcached、nginx、hadoop等。
- Redis使用的是MurmurHash2。当字典被用作数据库的底层实现,或者哈希键的底层实现时,使用MurmurHash2算法来计算键的哈希值。
4.3 解决键值冲突
- 使用next指针解决键冲突【新增加的节点在链表的表头】
- 单向链表
4.4 rehash(灵魂设计,很巧妙)
- 为了使哈希表的负载因子维持在一个合理的范围,程序需要对哈希表的大小进行相应的扩容或者收缩,所以就进行rehash
- ht[1]哈希表空间大小
- 扩展操作:ht[1]=第一个大于等于ht[0].used*2的2^n
- 收缩操作:ht[1]=第一个大于等于ht[0].used的2^n
- 将ht[0]重的键值rehash到ht[1]
- 数据迁移结束后,释放ht[0],将ht[1]设置成ht[0],并在ht[1]新创建一个空白哈希表,等待下一次rehash
- ht[1]哈希表空间大小
- 渐进式rehash
- 如果hash表ht[0]存在很多键值对,如果一次性整到ht[1],服务器将会有一段时间停止服务。所以为了避免rehash对服务器性能造成影响,需要分多次将ht[0]里面的键值对慢慢地rehash到ht[1]
- rehashidx:值设为0表示rehash正式开始,完成一个键值对,rehashidx+=1,最后所有的键值对都迁移完成,rehashidx设为1
- 过程中,会有增删查改
- 增只会保存在ht[1],这样最终会迁移结束
- 删除、查找、更新会在两个hash表同步进行
4.5 扩展操作和收缩操作的条件
- 扩展操作
- 没有执行bgsave或者bgrewriteaof,负载因子大于等于1
- 执行bgsave和bgrewriteaof,负载因子大于等于5
- redis需要创建当前服务器进程的子进程(大多数操作系统会使用写时复制技术来优化子进程的使用效率)。在子进程存在期间,服务器会提高执行扩展操作所需要的负载因子,从而尽可能避免在子进程存在期间进行hash表的扩展操作(避免不必要的内存写入操作,较大程度节约内存)
- 收缩操作
- 负载因子小于0.1
5. 第5章 跳跃表
- 感觉自己在学习数据结构,跳跃表看这里:https://blog.csdn.net/qpzkobe/article/details/80056807
- 跳跃表
- 跳跃表【每一层有两个属性:前进指针和跨度】
- 层的跨度level.span 纪录两个节点之间的距离
- 跨度可以用来计算排位:在查找某个节点的过程中,将沿途访问的层的跨度累计起来,最后得到的就是目标节点在跳跃表中的排位
- 后退指针:BW字样标记节点的后退指针,当程序从表尾向表头遍历的时候使用
- 每次只能后退至前一个节点
- 分值和成员
- 跳跃表的所有节点按照分值从小到大排序,分值相同按照字典序的大小排序
- ele是一个sds(简单动态字符串)
- 跳跃节点的层高:1-32之间的随机数
- 跳跃表【每一层有两个属性:前进指针和跨度】
- 遗留问题:高并发情况下,怎么给跳跃表加锁
- 朋友教学:乐观锁
- 乐观锁:大多是基于数据版本Version记录机制实现。何谓数据版本?即为数据增加一个版本标识,在基于数据库表的版本解决方案中,一般是通过为数据库表增加一个“version” 字段来 实现。读取出数据时,将此版本号一同读出,之后更新时,对此版本号加一。此时,将提 交数据的版本数据与数据库表对应记录的当前版本信息进行比对,如果提交的数据 版本号大于数据库表当前版本号,则予以更新,否则认为是过期数据。
- https://blog.csdn.net/u012240455/article/details/81625081
// server.h/zskiplistNode
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
6. 第六章 整数集合
6.1 数据结构
- intset
- encoding为INTSET_ENC_INT16,contents是int16_t类型的数组
- encoding为INTSET_ENC_INT32,contents是int32_t类型的数组
- encoding为INTSET_ENC_INT64,contents是int64_t类型的数组
6.2 升级
- 如果新加入的元素比整数集合现有的所有元素的类型都要长,整数集合需要先进行升级,然后将新元素添加到整数集合里面
- 根据新元素的类型,扩展底层数组的空间大小
- 将底层数组的所有元素转换成新元素相同的类型,保证其有序性
- 将新元素添加到底层数据中
- 时间复杂度:最坏O(N)
- 优点
- 比较灵活,可以随意在数组里面添加元素
- 节约内存,有需要的时候再扩展
- 不支持降级
- 一旦数组升级,编码会一直保持升级后的状态
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
7. 第七章 压缩列表
- 为了节约内存开发的,由一系列特殊编码的连续内存块组成的顺序型数据结构
- 一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值
7.1 数据结构(压缩列表)
// ziplist.c
/* Return total bytes a ziplist is composed of. */
#define ZIPLIST_BYTES(zl) (*((uint32_t*)(zl)))
/* Return the offset of the last item inside the ziplist. */
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))
/* Return the length of a ziplist, or UINT16_MAX if the length cannot be
* determined without scanning the whole ziplist. */
#define ZIPLIST_LENGTH(zl) (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))
/* The size of a ziplist header: two 32 bit integers for the total
* bytes count and last item offset. One 16 bit integer for the number
* of items field. */
#define ZIPLIST_HEADER_SIZE (sizeof(uint32_t)*2+sizeof(uint16_t))
/* Size of the "end of ziplist" entry. Just one byte. */
#define ZIPLIST_END_SIZE (sizeof(uint8_t))
/* Return the pointer to the first entry of a ziplist. */
#define ZIPLIST_ENTRY_HEAD(zl) ((zl)+ZIPLIST_HEADER_SIZE)
/* Return the pointer to the last entry of a ziplist, using the
* last entry offset inside the ziplist header. */
#define ZIPLIST_ENTRY_TAIL(zl) ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))
/* Return the pointer to the last byte of a ziplist, which is, the
* end of ziplist FF entry. */
#define ZIPLIST_ENTRY_END(zl) ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)
7.2 数据结构(压缩列表节点)
- code
// ziplist.c
/* We use this function to receive information about a ziplist entry.
* Note that this is not how the data is actually encoded, is just what we
* get filled by a function in order to operate more easily. */
typedef struct zlentry {
unsigned int prevrawlensize; /* Bytes used to encode the previous entry len*/
unsigned int prevrawlen; /* Previous entry len. */
unsigned int lensize; /* Bytes used to encode this entry type/len.
For example strings have a 1, 2 or 5 bytes
header. Integers always use a single byte.*/
unsigned int len; /* Bytes used to represent the actual entry.
For strings this is just the string length
while for integers it is 1, 2, 3, 4, 8 or
0 (for 4 bit immediate) depending on the
number range. */
unsigned int headersize; /* prevrawlensize + lensize. */
unsigned char encoding; /* Set to ZIP_STR_* or ZIP_INT_* depending on
the entry encoding. However for 4 bits
immediate integers this can assume a range
of values and must be range-checked. */
unsigned char *p; /* Pointer to the very start of the entry, that
is, this points to prev-entry-len field. */
} zlentry;
- 解释
- prevrawlen:记录前一节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址计算出前一个节点的起始地址(可以从表尾遍历到表头)
- encoding:记录content属性所保存的数据类型以及长度
7.3 连锁更新
- 将某一个长度大于254字节的新节点new设置为压缩列表的表头节点,那么可能引起后面节点e1的prevrawlen变大,从而e2的prevrawlen变大,程序需要不断对压缩列表执行空间重分配操作
- 最坏情况下的复杂度n^2
- 添加节点和删除节点,都有可能导致连锁更新
8. 第八章 对象
8.1 对象的类型与编码
- code
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
- 解释
- type:键总是一个字符串对象,值可以是字符串对象、列表对象、hash对象、集合对象或者有序集合对象中的某一种。举例:type的属性值有REDIS_STRING
- encoding:对象使用哪种数据结构作为底层实现。一个type可以对应多种encoding
- REDIS_STRING, REDIS_ENCODING_INT/REDIS_ENCODING_EMBSTR/REDIS_ENCODING_RAW
- 可以根据不同的场景为一个对象设置不同的编码
8.2 字符串对象
- 字符串对象的编码可以是int、raw、embstr
- 如果一个字符串对象保存的是整数值,并确定用long表示,那么ptr的void*将转换成long,同时将字符串对象的编码设置成int
- 如果一个字符串对象保存的是字符串值,并这个字符串长度大于32字节,那么ptr的void*将转换成sds,同时将字符串对象的编码设置成raw
- 如果一个字符串对象保存的是字符串值,并这个字符串长度小于等于32字节,那么ptr的void*将转换成sds,同时将字符串对象的编码设置成embstr
- embstr编码和raw编码一样,都是使用redisobject和sdshdr来表示字符串对象。raw编码会调用两次内存分配函数来连续创建redisobject和sdsstr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisobject和sdshdr两个结构【一次内存分配函数】
- 一次内存释放函数,raw编码需要两次
- 字符串的所有数据都存在一块连续的内存里面,这种编码的字符串对象比起raw编码的字符串对象更能利用缓存优势
- long double类型表示的浮点数在redis里面也是作为字符串保存的
- 如果需要对浮点数进行运算操作,程序会将字符串值换回浮点数值,操作结束后,再将浮点数值的结果转换成字符串值
- long的编码是int,long double的编码是embstr或者raw
8.2.1 编码的转换
- 如果对一个保存整数值的字符串对象追加了一个字符串值,编码从int将会转换成raw
- embstr编码的字符串对象是只读的,如果需要修改需要转化成raw【redis3.0版本】
8.3 列表对象
- 种类
- ziplist压缩列表(entry是一个元素)
- linkedlist双向列表(node保存了一个字符串对象:redisobject、sdshdr)
- 何时使用ziplist(阈值可以修改)
- 所有字符串元素的长度都小于64字节
- 对象保存的元素数量小于512
8.4 哈希对象
- 编码可以是ziplist或者hashtable
- ziplist
- 保存键的节点在前,保存值的节点在后
- 先添加到哈希对象的键值对会放在表头方向,后添加的在表尾
- hashtable
- 哈希对象中的每个键值对都用一个字典键值对来保存
- 何时使用ziplist(阈值可以修改)
- 所有键值对的键和值的字符串长度小于64字节
- 键值对数量小于512个
8.5 集合对象
- 编码可以是intset或者hashtable
- 何时使用intset(阈值可以修改)
- 所有键值对都是整数值
- 键值对数量小于512个
8.6 有序集合对象
- 编码可以是ziplist或者skiplist
- ziplist:键值连续存放,由小及大
- zset(skiplist)【使用指针来共享相同元素的成员和分值,使用zsl和dict不会浪费额外的内存】
- dict:从成员到分值的映射,可以用O(1)的复杂度查找给定成员的分值
- 方便通过成员找到分值
- zsl:按分值从小到大保存了所有集合元素,object属性保存了元素成员,score保存了元素的分值
- 方便找zrank zrange
- dict:从成员到分值的映射,可以用O(1)的复杂度查找给定成员的分值
- 何时使用ziplist(阈值可以修改)
- 有序集合的元素数量小于128个
- 有序集合成员的长度都小于64字节
// server.h
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
8.7 类型检查与命令多态
- 通用命令(基于类型的多态),可以对任何类型的键执行:DEL、EXPIRE、RENAME、TYPE、OBJECT等
- 只对特定类型的键执行(基于编码的多态)
- 字符串键:SET GET APPEND STRLEN
- 哈希键: HDEL HSET HGET HLEN
- 列表键:RPUSH LPOP LINSERT LLEN
- 集合键:SADD SPOP SINSERT SCARD
- 有序集合键:ZADD ZCARD ZRANK ZSCORE
- 内部的机制:多态命令
- 一个type可能会有多个encoding,但是只要一个type,对应的命令都可以正确实现,在实现的过程中会进行类型检查、然后根据编码选择实现函数
8.8 内存回收
- C语言并不具备自动内存回收功能,redis在对象系统中构建了一个引用计数,参考以下代码里面的refcount
- 创建一个新对象,引用计数初始化为1
- 被一个程序使用,引用计数+1
- 不被一个程序使用,引用计数-1
- 引用计数值变为0,对象所占用的内存被释放
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
8.9 对象共享
- 多个键共享同一个值对象
- 将数据库键的值指针指向一个现有的值对象
- 将被共享的值对象的引用计数增一
- 需要验证共享对象和值所对应的目标对象是否完全相同
- 目标对象值越复杂,验证操作的时间复杂度越高
- 受到cpu的限制,redis仅仅对包含整数值的字符串对象进行共享
8.10 对象的空转时长
- redisObject结构中包含lru属性,该属性记录了对象最后一次被程序访问的时间
- OBJECT IDLETIME 在访问键的值对象,不会修改lru属性
- 作用:服务器有mexmemory选项,如果服务器回收内存的算法是volatile-lru或者allkeys-lru,当超出上限,空转时间长的就会被释放