与其他key-value数据库不同之处
不仅支持字符串值,还支持
- 字符串
- set,get
- 列表
- lpush,rpush,lrange
- 哈希
- hset,hget,hgetall
- hmset,hmget
- 集合
- sadd
- smembers
- 有序集
- zset
数据库的键则总是字符串对象
简单动态字符串
sds(simple dynamic string)
用途:
- 实现字符串对象(StingObject)
- 在Redis程序内部用作char* 类型的替代品
127.0.0.1:6379> help sadd SADD key member [member ...] summary: Add one or more members to a set since: 1.0.0 group: set 127.0.0.1:6379> sadd nosql "Redis" "MongoDB" (integer) 2 127.0.0.1:6379> help SMEMBERS SMEMBERS key summary: Get all the members in a set since: 1.0.0 group: set 127.0.0.1:6379> smembers nosql 1) "MongoDB" 2) "Redis"
char* 类型功能单一,抽象层次低,并不能高效地支持一些 Redis 常用的操作(比
如追加操作和长度计算操作),相当于在char* 基础上封装了一些操作。
Redis中
- 客户端传入服务器的协议内容
- aof缓存,返回给客户端的回复
- 等等这些重要的内容都是由sds类型来保存的
char* 类型缺点
- 每次计算字符串长度(strlen)的复杂度为 θ(N)
- 对字符串进行 N 次追加(append),必定需要对字符串进行 N 次内存重分配
针对以上两个问题,来看一下sds 实现
- 通过 len 属性,sdshdr 可以实现复杂度为 θ(1) 的长度计算操作。
- 通过对 buf 分配一些额外的空间,并使用 free 记录未使用空间的大小,
- sdshdr可以让执行追加操作所需的内存重分配次数大大减少。
优化追加操作原理
看看执行下面操作redis内部发送什么
127.0.0.1:6379> set msg "hello world" OK 127.0.0.1:6379> append msg " again!" (integer) 18 127.0.0.1:6379> get msg "hello world again!"
首先,SET 命令创建并保存 hello world 到一个 sdshdr 中,这个 sdshdr 的值如下:
struct sdshdr { len = 11; free = 0; buf = "hello world "; }
当执行 APPEND 命令时,相应的 sdshdr 被更新,字符串 " again!" 会被追加到原来的"hello world" 之后:
struct sdshdr { len = 18; free = 18; // 空白的地方为预分配空间,共 18 + 18 + 1 个字节 buf = "hello world again! "; }
注意set命令不会创建额外空间,append才会分配额外空间,额外空间为 append之后新字符串长度*2+1;
内存预分配优化策略
sds.c/sdsMakeRoomFor函数描述了sdshdr的内存分配优化策略
- 预分配空间足够,无须再进行空间分配
- 如果新字符串的总长度小于 SDS_MAX_PREALLOC
- 那么为字符串分配 2 倍于所需长度的空间
- 否则就分配所需长度加上 SDS_MAX_PREALLOC 数量的空间
分配策略会浪费内存吗?
- append带来的预分配额外空间,不会被释放,除非字符串对应的键被删除;
- 通常append字符串键数量不多,因此不是什么问题
- 如果append操作的键很多,而字符串的体积又很大的话,就会很浪费内存
- 需要修改Redis服务器,让它定时释放一些字符串预分配空间,从而有效使用内存
sds模块基于sds类型和sdshdr 结构提供以下API
小结
- Redis 的字符串表示为 sds ,而不是 C 字符串(以 结尾的 char*)。
- 对比 C 字符串,sds 有以下特性:
- 可以高效地执行长度计算( strlen);
- 可以高效地执行追加操作( append);
- 二进制安全;
- sds 会为追加操作进行优化:加快追加操作的速度,并降低内存分配的次数,
- 代价是多占用了一些内存,而且这些内存不会被主动释放。
双端链表
双端链表作为一种通用的数据结构,在 Redis 内部使用得非常多
- 它既是 Redis 列表结构的底层实现之一
- 还被大量 Redis 模块所使用,用于构建 Redis 的其他功能
列表类型例子
redis> RPUSH brands Apple Microsoft Google (integer) 3 redis> LPOP brands "Apple" redis> LLEN brands (integer) 2 redis> LRANGE brands 0 -1 1) "Microsoft" 2) "Google"
Redis 列表使用两种数据结构作为底层实现:
- 双端链表
- 压缩列表
因为双端链表占用的内存比压缩列表要多,所以当创建新的列表键时,列表会优先考虑使用压
缩列表作为底层实现,并且在有需要的时候,才从压缩列表实现转换到双端链表实现。
Redis自身功能使用双端链表
- 事务模块使用双端链表来按顺序保存输入的命令;
- 服务器模块使用双端链表来保存多个客户端;
- 订阅/发送模块使用双端链表来保存订阅模式的多个客户端;
- 事件模块使用双端链表来保存时间事件( time event);
双端链表的实现
双端链表的实现由 listNode 和 list 两个数据结构构成,
下图展示了由这两个结构组成的一个双端链表实例:
重数据结构定义上看行为和性能特性:
- listNode 带有 prev 和 next 两个指针,因此,对链表的遍历可以在两个方向上进行:从表头到表尾,或者从表尾到表头。
- list 保存了 head 和 tail 两个指针,因此,对链表的表头和表尾进行插入的复杂度都为θ(1)
- 这是高效实现 LPUSH 、 RPOP 、 RPOPLPUSH 等命令的关键。
- list 带有保存节点数量的 len 属性,所以计算链表长度的复杂度仅为 θ(1) ,这也保证了 LLEN 命令不会成为性能瓶颈。
迭代器
Redis 为双端链表实现了一个迭代器,迭代器可以从两个方向对双端链表进行迭代:
- 沿着节点的 next 指针前进,从表头向表尾迭代
- 沿着节点的 prev 指针前进,从表尾向表头迭代
小结
- Redis 实现了自己的双端链表结构。
- 双端链表主要有两个作用:
- 作为 Redis 列表类型的底层实现之一;
- 作为通用数据结构,被其他功能模块所使用;
- 双端链表及其节点的性能特性如下:
- 节点带有前驱和后继指针,访问前驱节点和后继节点的复杂度为 O(1) ,并且对链表的迭代可以在从表头到表尾和从表尾到表头两个方向进行;
- 链表带有指向表头和表尾的指针,因此对表头和表尾进行处理的复杂度为 O(1)
- 链表带有记录节点数量的属性,所以可以在 O(1) 复杂度内返回链表的节点数量(长度)
字典
字典(dictionary0),又名映射(map)或关联数组(associative array), 它是一种抽象数据结
构,由一集键值对( key-value pairs)组成,各个键值对的键各不相同,程序可以将新的键值对
添加到字典中,或者基于键进行查找、更新或删除等操作。
字典用途
- 实现数据库键空间( key space)
- 用作 Hash 类型键的其中一种底层实现
实现数据库键空间
当用户添加一个键值对到数据库时(不论键值对是什么类型),程序就将该键值对添加到键空
间;当用户从数据库中删除一个键值对时,程序就会将这个键值对从键空间中删除;
redis> FLUSHDB OK ##执行 DBSIZE 则返回键空间上现有的键值对: redis> DBSIZE (integer) 0 ##还可以用 SET 设置一个字符串键到键空间,并用 GET 从键空间中取出该字符串键的值: redis> SET number 10086 OK redis> GET number "10086" redis> DBSIZE (integer) 1
在<数据库>一章会键空间以及数据库的实现作详细介绍。
Hash类型键的一种底层实现
RedisHash键用一下两种数据结构作为底层实现
- 字典;
- 压缩列表
因为压缩列表比字典更节省内存,所以程序在创建新 Hash 键时,默认使用压缩列表作为底层
实现,当有需要时,程序才会将底层实现从压缩列表转换到字典;
字典的实现
字典实现的多种方法:
- 最简单的就是使用链表或数组,但是这种方式只适用于元素个数不多的情况下
- 要兼顾高效和简单性,可以使用哈希表
- 如果追求更为稳定的性能特征,并且希望高效地实现排序操作的话,则可以使用更为复
杂的平衡树;
Redis选择高校且实现简单的哈希表作为字典的底层实现
dict.h/dict 给出了这个字典的定义:
/* * 字典 ** 每个字典使用两个哈希表,用于实现渐进式 rehash */ typedef struct dict { // 特定于类型的处理函数 dictType *type; // 类型处理函数的私有数据 void *privdata; // 哈希表( 2 个) dictht ht[2]; // 记录 rehash 进度的标志,值为-1 表示 rehash 未进行 int rehashidx; // 当前正在运作的安全迭代器数量 int iterators; } dict;
其中ht[0]是字典主要使用的哈希表,而ht[1]则只有在程序对ht[0]号哈希表进行rehash时才使用
- sizemask:数组长度掩码,用于计算索引值
- size:数组长度
- used: 节点个数
- used/size :判断是否需要rehash
数组易于寻址,而不易于增删改
链表易于增删改,不易于寻址
哈希即易于寻址,也易于增删改
什么是哈希表?
哈希表根据键来直接访问数据,
它把键通过哈希函数映射到表中具体位置来直接访问(易于寻址),
因为其值并没有前后关系,索引也易于增删改。
具体做法:
- 把Key通过一个固定的算法函数既所谓的哈希函数转换成一个整型数字,
- 然后就将该数字对数组长度进行取余,取余结果就当作数组的下标(也就是哈希索引),
- 将value存储在以该数字为下标的数组空间里。
查询时
- 再次使用哈希函数将key转换为对应的数组下标,并定位到该空间获取value,
- 如此一来,就可以充分利用到数组的定位性能进行数据定位
问题:是哈希函数生成的哈希索引,并不是唯一的,出现哈希索引碰撞时,可以使用,链表链接起来,
哈希算法
Redis使用两种不同的哈希算法
- MurmurHash2 32 bit 算法:这种算法的分布率和速度都非常好
- 基于djb算法实现的一个大小写无关散列算法
创建新字典
dict *d = dictCreate(&hash_type, NULL);
新创建的两个哈希表都没有为 table 属性分配任何空间:
- ht[0]->table 的空间分配将在第一次往字典添加键值对时进行;
- ht[1]->table 的空间分配将在 rehash 开始时进行;
添加键值对到字典
添加键值操作:
- 如果字典为未初始化(也即是字典的 0 号哈希表的 table 属性为空),那么程序需要对 0号哈希表进行初始化;
- 如果在插入时发生了键碰撞,那么程序需要处理碰撞;
- 如果插入新元素使得字典满足了 rehash 条件,那么需要启动相应的 rehash 程序
哈希碰撞处理
字典使用链地址法处理哈希碰撞问题:
使用链表将多个哈希索引值相同的节点串连起来,从而解决冲突问题
添加新键值对时触发rehash操作
当使用链地址法来处理哈希碰撞问题的时,
哈希表的性能依赖于,哈希表大小(size属性),与节点数量(used属性)之间的比率:
- 比率在1:1时,哈希表性能最好
- 说明没有发生哈希碰撞,每个哈希索引值下面只有一个节点
- 如果节点数量比哈希表的大小要大很多的话,
- 实际上那么哈希表就会退化成多个链表,哈希表本身的性能优势就不在了
为了在字典的键值对不断增多的情况下保持良好的性能,
字典需要对所使用的哈希表(ht[0])进行 rehash 操作:
ratio = used / size
rehash满足以下任何一条件则执行:
- 自然 rehash : ratio >= 1 ,且变量 dict_can_resize 为真。
- 强 制 rehash : ratio 大 于 变 量 dict_force_resize_ratio
- 目 前 版 本 中,dict_force_resize_ratio的值为5。
注意:
dict_can_resize可以为假,即不执行rehash操作,
这是因为数据库里的哈希类型键也是一个字典,当Redis使用子进程对数据库执行后台持久化任务时为了最大化地利用系统的copy on write机制, 程序会暂时将dict_can_resize 设为假,避免执行自然 rehash ,从而减少程序对内存的触碰( touch)。
Rehash执行过程
在这个阶段,ht[0]->table 的节点会被逐渐迁移到 ht[1]->table ,因为 rehash 是分多次进
行的,字典的 rehashidx 变量会记录 rehash 进行到 ht[0] 的哪个索引位置上。
- 创建一个比 ht[0]->table 更大的 ht[1]->table ;
- 将ht[0]->table中的所有键值对迁移到ht[1]->table;
- 将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0]
- 创建新的ht[1]
- 将字典的 rehashidx 属性设置为 -1 ,标识 rehash 已停止;
对比字典rehash之前和之后,新的ht[0]空间更大,并且字典原有的键值对也没有被修改或删除
问题
rehash,原本key1 索引值为0 在迁移到ht[1]时 为什么 key1的索引值会改变成6?
答
因为哈希表中sizemask(长度掩码)的不同,导致索引值得不同。
哈希索引计算
哈希索引=哈希值 & sizemask(哈希表长度掩码)
正因为sizemask的存在,确保相同key在不同sizemask有不同的哈希索引
保证key从ht[0] 迁移到 ht[1]时能有不同哈希索引,从而解决哈希冲突的问题
渐进式rehash保证高并发以及数据交互的友好性
在rehash过程中,
- 查找,删除,修改等操作需要在ht[0],ht[1]两个哈希表中进行
- 增加操作 是在ht[1]上做的。
字典收缩
收缩 rehash 和上面展示的扩展 rehash 的操作几乎一样,它执行以下步骤:
- 1. 创建一个比 ht[0]->table 小的 ht[1]->table ;
- 2. 将 ht[0]->table 中的所有键值对迁移到 ht[1]->table ;
- 3. 将原有 ht[0] 的数据清空,并将 ht[1] 替换为新的 ht[0] ;
扩展 rehash 和收缩 rehash 执行完全相同的过程,
一个rehash是扩展还是收缩字典,关键在于新分配的ht[1]->table的大小
字典收缩和字典扩展的一个区别是:
- 字典的扩展操作是自动触发的(不管是自动扩展还是强制扩展);
- 而字典的收缩操作则是由程序手动执行。
因此,使用字典的程序可以决定何时对字典进行收缩:
- 当字典用于实现哈希键的时候,每次从字典中删除一个键值对,判断是否需要收缩字典
- 如果字典达到了收缩的标准,程序将立即对字典进行收缩;
- 当字典用于实现数据库键空间 ( key space)的时候,
- 收缩的时机由redis.c/tryResizeHashTables 函数决定
小结
- 字典由键值对构成的抽象数据结构。
- Redis 中的数据库和哈希键都基于字典来实现。
- Redis 字典的底层实现为哈希表,每个字典使用两个哈希表,一般情况下只使用 0 号哈希表,只有在 rehash 进行时,才会同时使用 0 号和 1 号哈希表。
- 哈希表使用链地址法来解决键冲突的问题。
- Rehash 可以用于扩展或收缩哈希表。
- 对哈希表的 rehash 是分多次、渐进式地进行的。
跳跃表
应用于有序集
实际上就是按照索引score 排序 存储实际值member。
127.0.0.1:6379> help zadd ZADD key score member [score member ...] summary: Add one or more members to a sorted set, or update its score if it already exists since: 1.2.0 group: sorted_set 127.0.0.1:6379> help zrange ZRANGE key start stop [WITHSCORES] summary: Return a range of members in a sorted set, by index since: 1.2.0 group: sorted_set
例子:
127.0.0.1:6379> zadd s 6 666 5 555 4 444 7 777 (integer) 4 127.0.0.1:6379> zrange s 0 1 1) "444" 2) "555" 127.0.0.1:6379> zrange s 2 3 1) "666" 2) "777" 127.0.0.1:6379> zrange s 0 -1 1) "444" 2) "555" 3) "666" 4) "777"
1. 允许重复的 score 值:多个不同的 member 的 score 值可以相同。
2. 进行对比操作时,不仅要检查 score 值,还要检查 member :当 score 值可以重复时,
2. 进行对比操作时,不仅要检查 score 值,还要检查 member :当 score 值可以重复时,
单靠 score 值无法判断一个元素的身份,所以需要连 member 域都一并检查才行。
整数集合
整数集合( intset)用于有序、无重复地保存多个整数值,它会根据元素的值,自动选择该用什么长度的整数类型来保存元素。
intset可以自动从 int16_t 升级到 int32_t 或 int64_t
整数集合的应用
Intset 是集合键的底层实现之一,如果一个集合:
1. 只保存着整数元素;
2. 元素的数量不多;
1. 只保存着整数元素;
2. 元素的数量不多;
那么 Redis 就会使用 intset 来保存集合元素。
小结
- Intset 用于有序、无重复地保存多个整数值,它会根据元素的值,自动选择该用什么长度的整数类型来保存元素。
- 当一个位长度更长的整数值添加到 intset 时,需要对 intset 进行升级,新 intset 中每个元素的位长度都等于新添加值的位长度,但原有元素的值不变。
- 升级会引起整个 intset 进行内存重分配,并移动集合中的所有元素,这个操作的复杂度为 O(N) 。
- Intset 只支持升级,不支持降级。
- Intset 是有序的,程序使用二分查找算法来实现查找操作,复杂度为 O(lg N) 。
压缩列表
- Ziplist 是由一系列特殊编码的内存块构成的列表,一个 ziplist 可以包含多个节点( entry),
-
- 每个节点可以保存一个长度受限的字符数组(不以 结尾的 char 数组)或者整数
- 是哈希键、列表键和有序集合键的底层实现之一。
- 添加和删除 ziplist 节点有可能会引起连锁更新,
-
- 因此,添加和删除操作的最坏复杂度为O(N 2) ,
- 不过,因为连锁更新的出现概率并不高,所以一般可以将添加和删除操作的复杂度视为 O(N)