什么是Redis
Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语编写、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言API。
Redis 定位与特性
SQL 与 NoSQL
在绝大部分时候,我们都会首先考虑用关系型数据库来存储我们的数据,比如SQLServer,Oracle,MySQL等等。
关系型数据库的特点:
- 它以表格的形式,基于行存储数据,是一个二维的模式。
- 它存储的是结构化的数据,数据存储有固定的模式(schema),数据需要适应表结构。
- 表与表之间存在关联(Relationship)。
- 大部分关系型数据库都支持SQL(结构化查询语言)的操作,支持复杂的关联查询。
- 通过支持事务(ACID)来提供严格或者实时的数据一致性。
关系型数据库的缺点:
- 要实现扩容的话,只能向上(垂直)扩展,比如磁盘限制了数据的存储,就要扩大磁盘容量,通过堆硬件的方式,不支持动态的扩缩容。水平扩容需要复杂的技术来实现,比如分库分表。
- 表结构修改困难,因此存储的数据格式也受到限制。
- 在高并发和高数据量的情况下,我们的关系型数据库通常会把数据持久化到磁盘,基于磁盘的读写压力比较大。
为了规避关系型数据库的一系列问题,我们就有了非关系型的数据库,我们一般把它叫做“non-relational”或者“Not Only SQL”。NoSQL最开始是不提供SQL的数据库的意思,但是后来意思慢慢地发生了变化。
非关系型数据库的特定:
- 存储非结构化的数据,比如文本、图片、音频、视频。
- 表与表之间没有关联,可扩展性强。
- 保证数据的最终一致性
- 支持海量数据的存储和高并发的高效读写。
- 支持分布式,能够对数据进行分片存储,扩缩容简单。
noSQL数据库对应的专门存储类型
- KV 存储,用 Key Value 的形式来存储数据。比较常见的有 Redis 和MemcacheDB。
- 文档存储,MongoDB。
- 列存储,HBase。
- 图存储,这个图(Graph)是数据结构,不是文件格式。Neo4j。
- 对象存储。
- XML存储
redis特性
硬件层面有CPU的缓存;浏览器也有缓存;手机的应用也有缓存。我们把数据缓存起来的原因就是从原始位置取数据的代价太大了,放在一个临时位置存储起来,取回就可以快一些。
- 更丰富的数据类型
- 进程内与跨进程;单机与分布式
- 功能丰富:持久化机制、过期策略
- 支持多种编程语言
- 高可用,集群
Redis 数据结构
Redis一共有八种数据类型(注意是数据类型不是数据结构)
String、Hash、Set、List、Zset、Hyperloglog、Geo、Streams
基本数据结构
可以用来存储字符串、整数、浮点数。
dictEntry
set hello word 为例,因为Redis是KV的数据库,它是通过hashtable实现的(我们把这个叫做外层的哈希)。所以每个键值对都会有一个dictEntry(源码位置:dict.h),里面指向了key和value的指针。next指向下一个dictEntry。
key是字符串,但是Redis没有直接使用C的字符数组,而是存储在自定义的SDS中。
value 既不是直接作为字符串存储,也不是直接存储在 SDS 中,而是存储在redisObject中。实际上五种常用的数据类型的任何一种,都是通过redisObject来存储的。
redisObject
TYPE就是数据的对象类型
通过type xx查询类型包括:STRING、LIST、HASH、SET、ZSET
STRING
- 字符串类型的内部编码有三种:
- int,存储8个字节的长整型(long,2^63-1)。
- embstr, 代表embstr格式的SDS(SimpleDynamicString简单动态字符串),存储小于44个字节的字符串。
- raw,存储大于44个字节的字符串(3.2版本之前是39字节)。为什么是39?
SDS
Redis中字符串的实现。
为什么要用SDS封装字符串?和C语言的特性有关
- 我们知道,C语言本身没有字符串类型(只能用字符数组char[]实现)。
- 使用字符数组必须先给目标变量分配足够的空间,否则可能会溢出。
- 如果要获取字符长度,必须遍历字符数组,时间复杂度是O(n)。
- C字符串长度的变更会对字符数组做内存重分配。
- 通过从字符串开始到结尾碰到的第一个'\0'来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全。
SDS的特点:
- 不用担心内存溢出问题,如果需要会对SDS进行扩容。
- 获取字符串长度时间复杂度为O(1),因为定义了len属性。
- 通过“空间预分配”( sdsMakeRoomFor)和“惰性空间释放”,防止多次重分配内存。
- 判断是否结束的标志是len属性(它同样以'\0'结尾是因为这样就可以使用C语言中函数库操作字符串的函数了),可以包含'\0'
embstr
embstr 的使用只分配一次内存空间(因为RedisObject 和SDS是连续的), 而 raw需要分配两次内存空间(分别为RedisObject和SDS分配空间)。
因此与 raw 相比,embstr 的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。
而 embstr 的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整RedisObject和SDS都需要重新分配空间,因此 Redis 中的 embstr 实现为只读。
embstr、int和raw的转换
- 当 int 数 据 不 再 是 整 数 , 或 大 小 超 过 了 long 的 范 围(2^63-1=9223372036854775807)时,自动转化为 embstr。
- 对于 embstr,由于其实现是只读的,因此在对 embstr 对象进行修改时,都会先 转化为 raw 再进行修改。因此,只要是修改 embstr 对象,修改后的对象一定是 raw 的,无论是否达到了 44 个字节。
- 编码转换在 Redis 写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换(但是不包括重新set)。
- 通过封装,可以根据对象的类型动态地选择存储结构和可以使用的命令,实现节省空间和优化查询速度。
Hash 哈希
包含键值对的无序散列表。value只能是字符串,不能嵌套其他类型。
HASH特点:
- 把所有相关的值聚集到一个key中,节省内存空间
- 只使用一个key,减少key冲突
- 当需要批量获取值的时候,只需要使用一个命令,减少内存/IO/CPU的消耗
Hash不适合的场景:
- Field不能单独设置过期时间
- 没有bit操作
- 需要考虑数据量分布的问题(value值非常大的时候,无法分布到多个节点)
Redis的Hash本身也是一个KV的结构,类似于Java中的HashMap。
外层的哈希(Redis KV 的实现)只用到了 hashtable。当存储hash 数据类型时,我们把它叫做内层的哈希。
内层的哈希底层可以使用两种数据结构实现:
- ziplist:OBJ_ENCODING_ZIPLIST(压缩列表)
- hashtable:OBJ_ENCODING_HT(哈希表)
ziplist
ziplist是一个经过特殊编码的双向链表,它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度,通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想。只用在字段个数少,字段值小的场景里面。
什么时候使用ziplist存储
当hash对象同时满足以下两个条件的时候,使用ziplist编码:
- 所有的键值对的健和值的字符串长度都小于等于64byte(一个英文字母一个字节);
- 哈希对象保存的键值对数量小于512个。
ZIPLIST的转换
一个哈希对象超过配置的阈值(键和值的长度有>64byte,键值对个数>512个)时,会转换成哈希表(hashtable)。
hashtable
在Redis中,hashtable被称为字典(dictionary),它是一个数组+链表的结构。
注意:dictht 后面是 NULL 说明第二个 ht 还没用到。dictEntry*后面是 NULL 说明有 hash 到这个地址。dictEntry 后面是 NULL 说明没有发生哈希冲突。
为什么要定义两个哈希表呢?ht[2]
redis的hash默认使用的是ht[0],ht[1]不会初始化和分配空间。
哈希表dictht是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size属性)和它所保存的节点的数量(used 属性)之间的比率:
- 比率在1:1 时(一个哈希表ht只存储一个节点entry),哈希表的性能最好;
- 如果节点数量比哈希表的大小要大很多的话(这个比例用ratio表示,5表示平均一个ht存储5个entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在。
在这种情况下需要扩容。Redis里面的这种操作叫做rehash。
rehash的步骤:
- 为字符ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对的数量。扩展:ht[1]的大小为第一个大于等于ht[0].used*2。
- 将所有的 ht[0]上的节点 rehash 到 ht[1]上,重新计算 hash值和索引,然后放入指定的位置。
- 当ht[0]全部迁移到了ht[1]之后,释放 ht[0]的空间,将ht[1]设置为 ht[0]表,并创建新的ht[1],为下次rehash做准备。
ratio = used / size,已使用节点与字典大小的比例dict_can_resize为1 并且dict_force_resize_ratio 已使用节点数和字典大小之间的 比率超过1:5,触发扩容
if(d->ht[0].used>=d->ht[0].size&&
(dict_can_resize||
d->ht[0].used/d->ht[0].size>dict_force_resize_ratio))
{
returndictExpand(d,d->ht[0].used*2);
}
returnDICT_OK;
扩容方法dictExpand(源码dict.c)
缩容:server.c
List 列表
存储有序的字符串(从左到右),元素可以重复。可以充当队列和栈的角色。
用quicklist来存储。quicklist存储了一个双向链表,每个节点都是一个ziplist。
quicklist
quicklist(快速列表)是ziplist和linkedlist的结合体。
quicklist.h,head和tail指向双向列表的表头和表尾
quicklistNode中的*zl指向一个ziplist,一个ziplist可以存放多个元素。
Set 集合
String类型的无序集合,最大存储数量2^32-1(40亿左右)。
Redis用intset或hashtable存储set。如果元素都是整数类型,就用inset存储。如果不是整数类型,就用hashtable(数组+链表的存来储结构)。key就是元素的值,value为null。
ZSet 有序集合
sorted set,有序的set,每个元素有个score。
score相同时,按照key的ASCII码排序。
使用skiplist+dict存储
在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。
也就是说,时间复杂度为O(n)。同样,当我们要插入新数据的时候,也要经历同样的查 找过程,从而确定插入位置。
而二分查找法只适用于有序数组,不适用于链表。
假如我们每相邻两个节点增加一个指针(或者理解为有三个元素进入了第二层), 让指针指向下下个节点。
这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。在插入一个数据的时候,决定要放到那一层,取决于一个算法
(在redis中t_zset.c 有一个zslRandomLevel这个方法)。
现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数
据大的节点时,再回到原来的链表中的下一层进行查找。比如,我们想查找23,查找的路径是沿着下图中标红的指针所指向的方向进行的:
- 23首先和7比较,再和19比较,比它们都大,继续向后比较。
- 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22 比较。
- 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数
据23在原链表中不存在
在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行
比较了。需要比较的节点数大概只有原来的一半。这就是跳跃表。
为什么不用AVL树或者红黑树?因为skiplist更加简洁。
随机获取层数的函数: