文章目录
1. 底层数据结构
简单动态字符串_sds
-
参考文献
-
SDS 结构
- 相比 C 语言多了 len 和 free 属性, C 语言求字符串长度,需要遍历计算
-
SDS 相比于 C 语言中字符串的好处
-
常数复杂度获取字符串长度
C 语言: strlen() 而 sds : len直接获取
-
杜绝缓冲区溢出
C 语言 拼接字符串, strcat 函数,分配空间不足,会造成缓冲区溢出
SDS 会检查内存空间是否满足要求. 通过比较当前字符串的
free
与即将拼接字符串的len
的大小,就知道是否可以拼接。如果free的值不够,会再申请内存空间,避免溢出。 -
减少修改字符串的内存重新分配次数
1)空间预分配 避免频繁申请空间
- 分配的规则是,如果增长字符串后,新的字符串比1MB小,则额外申请字符串当前所占空间的大小作为free值;如果增长后,字符串长度超过1MB,则额外申请1MB大小。 (增长完>1MB,则额外申请1MB,否则额外申请增长完自身大小作为free)
- 该机制,使得字符串增长n次,需要申请空间的次数,从必定为n次的情况,降为最多n次的情况。
2)懒惰空间释放
- 当需要缩短sds的长度时,并不立即释放空间,而是使用free来保存剩余可用长度,并等待将来使用。当有剩余空间,而有有增长字符串操作时,则又会调用空间预分配机制。
- 当redis内存空间不足时,会自动释放sds中未使用的空间,因此也不需要担心内存泄漏问题。
-
二进制安全 写入的是什么内容,返回的也是什么内容,并没有限制。
SDS 的API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。
-
兼容部分 C 字符串函数
-
链表_list=>linkedlist
-
参考文献
-
结构
结点结构 (listNode)
typedef struct listNode{ //指向前一个结点 struct listNode *prev; //指向后一个结点 struct listNode *next; //当前结点的值 struct *value; }
多个 listNode 组成的双向链表
链表结构 (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); }
-
特点
- 双向
- 无环,head.prev = null , tail.next = null
- 有头节点和尾结点
- 有长度计数器,获取链表结点数 O(1)
- 多态: 可以保存不同类型的值
- 感觉和 Java 里得 Linked List 差不多
- list 每个结点由 listNode 实现
字典_dict=>hashtable
-
参考文献
-
简介
-
字典,又称符号表、关联数组、映射,是一种保存键值对的抽象数据结构。每个键(key)和唯一的值(value)关联,键是独一无二的,通过对键的操作可以对值进行增删改查。 (Java_HashMap)
-
redis中字典应用广泛,对redis数据库的增删改查就是通过字典实现的。即redis数据库的存储,和大部分关系型数据库不同,不采用B+tree进行处理,而是采用hash的方式进行处理。
-
另外,毫无疑问,redis的hash数据类型也是通过字典方式实现。
-
-
哈希表结构
哈希表 (HashMap 中的 table)
typedef struct dictht{ //一个数组,存放每个元素指向 dictEntry 结构得指针 => dictEntry 是键值对结构 dictEntry **table; //哈希表大小 也是 table 的大小 unsigned long size; //sizemask = size -1 该值与hash 值 一起决定当作 table 的哪个位置 unsigned long sizemask; //已有键值对结点数量 unsigned long used; }dictht;
哈希表结点 (HashMap 中的 Entry)
typedef struct dictEntry{ //键 key void *key; union{ void *val; //指针 uint64_t u64; // uint64_t 整数 int64_t s64; // int64_t 整数 }v; // value struct dictEntry *next; //使用拉链法解决 hash 冲突的问题, 同 HashMap }dictEntry;
-
字典结构 dict
dict
typedef struct dict{ dictType *type; //用于存放私有数据,保存传给type内的函数的数据 void *privdata; //dictht 类型 : 每个项是一个哈希表,一般情况下只是用ht[0],只有在对ht[0]进行rehash时,才会使用ht[1]。 dictht ht[2]; //是一个索引,当没有在rehash(扩容/收缩)进行时,值是-1, 设置为 0, 表示正在 rehash int rehashidx; }dict; //定义了字典的各种操作函数 typedef struct dictType{ //哈希值计算函数 unsigned int (*hashFunction) (const void *key); //键复制 void *(*keyDup) (void *privdata, const void *key); //值复制 void *(*valDup) (void *privdata, const void *obj); //键比较 int *(*keyCompare) (void *privdata, const void *key1, const void*key2); //键销毁 void *(*keyDestructor) (void *privdata, void *key); //值销毁 void *(*valDestructor) (void *privdata, void *obj); }dictType;
-
hash 算法
//redis实现哈希的代码是: hash =dict->type->hashFunction(key); // 计算 key 的 hash 值 index = hash& dict->ht[x].sizemask; //hash & sizemask,(sizemask = size -1) 和 HashMap 找落在哪个桶是一样的
-
键冲突解决 => 拉链法解决, 插在尾部, 即尾插
-
渐进式rehash: 什么时候 rehash? => 一个哈希表保存的键太多或者太少
rehash 过程:
-
判断是扩展还是收缩.
扩展的话 ht[1] 的分配空间大小为 第一个大于等于 ht[0].used x 2 的 2^n 的值. h[0].used = 30 => 64
收缩的话 ht[1] 的分配空间大小为 第一个大于等于 ht[0].used 的 2^n 的值. h[0].used = 30 => 32
-
将 ht[0] 上所有的键值重新 rehash 到 ht[1], 然后释放 ht[0] ,并将 ht[1] 修改为 ht[0], 载创建一个新的空的 ht[1], 用于之后的 rehash(扩容/收缩) . ht[1] 就是一个临时的空间,默认存放 ht[0]
rehash 条件: (used => 键值对数目, size => table 数组的长度)
-
load_factor =ht[0].used / ht[0].size 即 ht[0].used = ht[0].size x load_factor ( 类似 HashMap )
-
自动扩容:
服务器目前没有在执行BGSAVE或者BGREWRITEAOF命令,且负载因子大于等于1。
服务器目前正在在执行BGSAVE或者BGREWRITEAOF命令,且负载因子大于等于5
-
自动收缩
当负载因子小于0.1时,redis自动开始哈希表的收缩工作。
copy-on-write简介
-
写时复制技术,是操作系统层面,为了提升性能而进行的一个策略。
策略如下:每次写文件操作,都写在特定大小的一块内存中(磁盘缓存),并不是直接写到磁盘中。只有当我们关闭文件时,才写到磁盘上(这就是为什么如果文件不关闭,所写的东西会丢失的原因)。更有甚者是文件关闭时都不写磁盘,而一直等到关机或是内存不够时才写磁盘,Unix就是这样一个系统,如果非正常退出,那么数据就会丢失,文件就会损坏。
写时复制技术,大大降低了磁盘I/O的次数,而I/O往往是性能瓶颈,这样一来就最大程度上避免了此瓶颈。
-
为什么自动 扩容时要检查 bgsave 是否在执行?
因为 bgsave / bgwriteaof 执行过程中, redis 需要创建当前服务器进程的子线程 (?不懂有什么影响),而多数OS都是使用 copy-on-write 技术优化子进程使用效率. 如果此时频繁扩容, 会出现 ht[1] 建立完 ht[0] 还未清空,占用两份内存的问题,浪费内存.
渐进式 rehash
-
ht[0] => ht[1] 不是 一次性完成的,而是渐进式分多次完成
-
在rehash期间,对哈希表的查找、修改、删除,会先在ht[0]进行。如果ht[0]中没找到相应的内容,则会去ht[1]查找,并进行相关的修改、删除操作。而增加的操作,会直接增加到ht[1]中,目的是让ht[0]只减不增,加快迁移的速度。
-
-
-
小结: 一个字典, 有两个 哈希表, 一个哈希表正常使用,另一个在 rehash 时候使用.(扩容/收缩)
跳跃表_skiplist
-
参考文献
-
简介
-
跳跃表(skiplist)是一种有序的数据结构,它通过每个节点中维持多个指向其他节点的指针,从而实现快速访问。跳跃表平均O(logN),最坏O(N),支持顺序遍历查找。
-
在redis中,有序集合(sortedset)的其中一种实现方式就是跳跃表。
-
本质是解决查找问题
-
时间复杂度分析
跳表查询、插入、删除的时间复杂度为O(log n),与平衡二叉树接近;
为什么不选择红黑树?
- 插入,删除,查找,有序输出所有元素效率和跳表相同
- 但是 按照范围区间查找元素,红黑树的效率远远不如跳表,跳表找到开头的那个之后依次遍历即可
-
-
结构
跳跃表节点数据结构
typedef struct zskiplistNode{ //表示 层 L1 就是第一层, L2 就是第二层 struct zskiplistLevel{ //前进指针, 用于访问跳表尾部方向 struct zskiplistNode *forward; //跨度: unsigned int span; }level[]; //指向当前节点的前一个节点,后退指针不会指向头节点。 struct zskiplistNode *backward; //各节点中的数字就是分值,跳跃表中,节点按照分值从小到大排列。 double score; //指向存储着节点的具体内容的redis的sds类型的字符串 robj *obj; }zskiplistNode;
层的意义 (有点类似索引的感觉)
-
层的作用是加快访问速度,这也是跳跃表的核心思想。每次创建跳跃表后,根据幂次定律,越大的数字出现的概率越小,随机生成一个1~32之间的值作为level数组的大小,这个就是层的高度。
-
插入数据选择层数时,不要求上下相邻两层链表之间的节点个数有严格的对应关系. 每个节点随机出一个层数(level). 这样就避免了插入时调整层数而导致的效率低了.
随机层数 : 直观上期望的目标是 50% 的概率被分配到
Level 1
,25% 的概率被分配到Level 2
,12.5% 的概率被分配到Level 3
,以此类推…有 2-63 的概率被分配到最顶层,因为这里每一层的晋升率都是 50%。
跳表结构
typedef struct zskiplist{ struct zskiplistNode *header,*tail; //跳跃表的长度(即跳跃表节点的数量,不含头结点) unsigned long length; //层数最大节点的层数(不计算表头结点)。 int level; }zskiplist;
-
跳表结构
-
从跳表中查找数据 ①->…->⑥
-
跳表插入数据
-
整数集合_intset
-
参考文献
-
简介
- 整数集合(intset)是redis数据结构集合(set)的底层实现之一,如果set中只包含整数元素,且元素个数不多时,redis会使用整数集合作为set的底层实现
- 整数集合是redis保存整数值集合的底层实现,可以保存 int16_t、int32_t、int64_t 的整数值,且集合中每个值都不一样(不允许重复)
-
结构
typedef struct intset{ //编码方式, INTSET_ENC_INT16 = > int16_t uint32_t encoding; //数组中元素的个数 uint32_t length; //保存元素的数组 int8_t contents[]; }intset;
int16_t, int32_t, int64_t:
这些数据类型中都带有_t, _t 表示这些数据类型是通过 typedef 定义的,而不是新的数据类型。也就是说,它们其实是我们已知的类型的别名。
typedef short int int16_t; //2字节 16位 typedef int int32_t; //4字节 32位 typedef long long int int64_t; //8字节 64位
-
整数升级
升级过程:
- 触发条件: 当新元素添加到 contents 数组中,该元素类型比现有元素类型长,则会对 contents 元素进行升级.
- 升级什么:将底层所有元素都转换成新类型,放在原位置上,保持大小顺序不变
- 迁移过程:如果新元素最大,则放在最后一个位置, 如果新元素最小,则放在最前面的位置,之后的元素,依次向后移动,和数组插入后数组迁移是相同的操作.
- 为什么要升级: 节约内存(需要升级在升级), 灵活性: C 语言本身是不支持的, 现在 redis 支持自动升级
- redis 不支持降级,一旦升级后,即使大类型元素被删除,仍会保持原来的状态
压缩列表_ziplist
-
参考文献
-
简介
压缩列表(ziplist)是列表键(list)和哈希键(hash)底层的实现之一。当列表项(list)较少,且每项要么是小的整数值,要么是长度比较短的字符串,则使用ziplist。当哈希的键值对较少,且每个键值对都是小整数或短字符串,也是使用 ziplist。
-
压缩列表结构
- zlbytes: uint32_t 类型,4字节,记录整个 ziplist 占字节数
- zltail: uint32_t 类型, 记录表尾结点(entry) 距离 头结点 (entry) 多少字节,这样可以通过偏移量计算出尾结点(entry)位置
- zllen: uint16_32, < 65535 就是 ziplist 中 entry 结点数量,大于 65535 则需要遍历计算
- entry x: 列表结点类型
- zlend: 0xFF, 标记 ziplist 的结尾
entry 结构:
-
previous_entry_length : 记录 ziplist 前一个结点的长度 (1 字节 = 1111 1111 => max = 255) ,用于计算出该节点前一个节点的内存位置,以便于从表尾向表头进行遍历。
前一个结点长度小于 254 字节,该属性为 1 字节. 如 前一结点长度为 5 字节, 则 previous_entry_length = 0x05;
前一个结点长度大于 254 字节,则该属性为 5 字节,如 前一结点长度为 10086 字节
则 previous_entry_length = 0xFE 00 00 27 66; (后 8 位是真实的长度)
-
encoding: 记录了节点content的属性所保存的类型和长度
-
content 保存节点具体的值,可以是一个字节数组或者一个整数,值的类型和长度由上面的encoding决定。
-
存在的问题 : 连锁更新
ziplist中有多个连续的、长度在250253字节的节点e1eN,则所有节点的previous_entry_length属性都是1字节。
此时,将一个大于254字节的节点插入到 e1 之前,则 e1 的 previous_entry_length 需要扩充到5字节。
而这样会造成一个麻烦,此时由于e1增加了4个字节,导致其从250253字节变成了254257字节,则e2的previous_entry_length也需要扩充到5字节。这样的更新会一直连锁到eN为止。上述情况称为连锁更新(cascadeupdate)
2. Redis 常用数据结构
Redis对象
-
参考文献
-
简介
- Redis 不是简单利用 sds, dict, skiplist, intset, ziplist 等数据结构,而是基于这些数据结构构建了一个对象系统, 这个对象系统包括 redis 客户端可以直接使用的 5 种: String, list, hash, set, zset.
- 通过这五种对象,redis在执行命令前,会判断对象是否可以执行命令。针对不同的场景(数据量、数据类型),redis可以给对象用不同的数据结构实现,达到最优化。
- redis基于引用计数的内存回收机制,当不再需要对象时,自动释放相应内存;还通过引用计数实现对象的共享。
- redis对象还带有访问时间信息,该信息可以计算数据库键的空转时长,在服务器启用memory功能的情况下,空转时长大的,内存不足时会优先被回收。
-
结构
redis用对象存储键值对,因此每当创建一个键值对,至少会创建两个对象,一个是键对象,一个是值对象。 例如set msg ‘a’,创建了一个msg的键对象,一个a的值对象。
//redis 每个对象都是由 redisObject 结构表示 typedef struct redisObject { // 类型 unsigned type:4; // 编码 unsigned encoding:4; // 对象最后一次被访问的时间 unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */ // 引用计数 int refcount; // 指向实际值的指针 void *ptr; } robj;
使用 redisobject 的好处:
- 根据对象类型判断一个对象是否可以执行给定的命令
- 可以在不同的场景使用不同的实现方式,优化内存和查询速度
-
5 大数据结构由什么数据结构编码实现?
每个值对象至少有两种不同的编码(底层使用至少两种数据结构实现)
-
string:
整数值(int)
字符串长度小于等于 32 字节(embstr)
字符串长度大于 32 字节(raw)
-
list:
列表对象中字符串元素长度小于64字节且元素数量小于 512 时 使用 ziplist
其余情况使用 linkedlist(即链表 list)
-
hash:
列表对象中字符串元素长度小于64字节且元素数量小于 512 时 使用 ziplist
``其余情况使用 hashtable(字典)` -
set:
所有元素都是 整数 且元素数量小于 512 使用 整数集合(intset)
其余情况使用 hashtable(字典)
-
zset:
元素数量小于128 且 所有元素长度小于 64 使用 ziplist
其余情况使用 跳表(skiplist)
-
在redis客户端,用object encoding命令,可以看到键对应的值的编码方式。
-
string 对象
-
int : 当字符串对象保存的是整数(只有整数,不含浮点数),并可以用long类型表示,则对象会将整数值直接保存在字符串对象ptr属性,并且把void类型改成long。这是唯一一种ptr属性直接保存值的情况,其他情况下ptr都是指向某个地址。
-
embstr, raw:
字符串长度小于等于 32 字节(embstr)
字符串长度大于 32 字节(raw)
-
编码转换
-
当对现有的字符串对象进行操作,重新赋值以后,如果新的值不满足原来的类型,如原来int后面变成string,则编码方式会转换。
-
另外,由于redis的 embstr 编码方式没有任何修改的程序,因此embstr可以认为是只读的。因此,当embstr编码的对象进行任何的修改命令,都会将embstr编码转为raw编码(即使修改没有使string的字节超过32字节)。
-
list 对象
hash 对象
-
结构
set 对象
-
结构
zset 对象
-
结构
-通过 ziplist
-
通过 skiplist + hashtable
- skiplist: 适合范围查询
- hashtable: 适合等值查询.
- 使用两者结合,结合了范围查询和等值的优点
-
键空间
-
结构
typedef struct redisDb { // 数据库键空间,保存着数据库中的所有键值对. 键都是字符串对象,而值可以是 5 种对象中的任一种. dict *dict; /* The keyspace for this DB */ // 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳 dict *expires; /* Timeout of keys with a timeout set */ // 正处于阻塞状态的键 dict *blocking_keys; /* Keys with clients waiting for data (BLPOP) */ // 可以解除阻塞的键 dict *ready_keys; /* Blocked keys that received a PUSH */ // 正在被 WATCH 命令监视的键 dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */ struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */ // 数据库号码 int id; /* Database ID */ // 数据库的键的平均 TTL ,统计信息 long long avg_ttl; /* Average TTL, just for stats */ } redisDb;
-
redis 读/写键过程
redis对于读写键空间,还会有相应的维护操作。其中,写操作都会先读键,因此下列的读,也包括写之前的读操作。主要如下:
1)读取一个键以后,服务器会根据键是否存在,来更新服务器中的键空间命中次数(hit)或未命中次数(miss)。这两个值可以在info status命令的keyspace_hits属性和keyspace_misses属性查看。
2)读取一个键以后,服务器会更新键的LRU,即最后访问时间,redis可以通过当前时间减去lru,确认该键的闲置时间。可以通过命令object ideltime key来查看key键的当前闲置时间。
3)读取一个键以后,发现该键已经过期,会先删除该键,然后再进行后续的操作。
4)如果客户端使用watch命令监视某个键,则修改键的值后,键会被标记为脏(dirty),从而让事务程序注意到该键已经被修改过。
5)服务器每次修改一个键后,都会对脏键计数器值增1,这个值会触发服务器持久化以及复制操作。
6)如果服务器开启数据库通知功能,则修改键后,服务器将按照配置,发送相应的数据库通知。
redis 数据库
-
结构:
typedef structredisServer{ //省略其他内容.... //存放数据库的数组 redisDb *db; //数据库的数量.初始化服务器的时候,会根据此值创建数据库个数。该属性由配置文件中的database选项决定。 int dbnum; };
-
切换数据库
typedef structredisClient{ //如果此时在客户端select 0,则redisClient的db指针又会指向db[0]。 //因此,select命令的原理,就是通过修改redisClient的db指针的指向,来实现数据库的切换。 redisDb *db; }redisClient;
3. 位图
简介
-
Bitmaps 本身不是一种数据结构,实际上它就是字符串,但是它可以对字符串的位进行操作。
-
Redis 中的命令 setbit, getbit, bitcount 详解
setbit key offset value : 给一个指定 key 的值 的 第 offset 位 赋值为 value value : bool / int 返回值是 Long 0/1
set andy a // a => 97 => 0110 0001 setbit andy 6 1 // 0110 0011 setbit andy // 0110 0010 => b get andy // b bitcount andy //3 bitcount 统计字符串中 1 的个数, byte getbit andy 7 // 0 => 和 setbit 一样, 单位 bit,注意和 bitcount 之间的区别
getbit key offset
bitcount [start] [end] : bitcount key 0 -1 : key 对应值中 1 的个数, bitcount key 0 0:key 对应值中 第一个字节(8位) 1 的个数
bitop op destkey key[key…] : 对不同的 bitmaps 进行运算, 结果存在destkey 中. 运算有: and(交集), or (并集), not (非), xor (异或)
bitpos key targetBit [start] [end] : 计算Bitmaps中第一个值为targetBit的偏移量
使用场景
-
统计活跃量:
将每个独立用户是否访问过网站存放在Bitmaps中,将访问的用户记做1,没有访问的用户记做0,用偏移量作为用户的id。
分析优劣:
假设网站有1亿用户: (用户 id = 5 登录, 设置 第 4 位 为 1,表示用户登录过)
-
每天独立访问的用户有5千万,如果每天用集合类型和Bitmaps分别存储活跃用户
-
假如该网站每天的独立访问用户很少,例如只有10万(大量的僵尸用户)
-
bitmaps 上大部分位 为 0, 浪费空间,此时不适合使用 bitmaps 存储
-
4. HyperLogLog
- HyperLogLog并不是一种新的数据结构(实际类型为字符串类型),而是一种基数算法,通过HyperLogLog可以利用极小的内存空间完成独立总数的统计
- HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的。
5. GEO
-
支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能
-
Redis 使用 geohash 将二维经纬度转换为一维字符串
-
geohash有如下特点:
GEO的数据类型为zset,Redis将所有地理位置信息的geohash存放在zset 中。
字符串越长,表示的位置更精确,表3-8给出了字符串长度对应的精度,例如geohash长度为9时,精度在2米左右
两个字符串越相似,它们之间的距离越近,Redis利用字符串前缀匹配算法实现相关的命令
geohash编码和经纬度是可以相互转换的