- Redis支持5种基本数据类型:字符串(String),列表(List),集合(set),哈希结构(hash),有序集合(sorted set);还有两种数据结构:HyperLogLog 和 BitMap。
- Redis 五种数据结构的 key 都是唯一的字符串,通过这个唯一的 key 来获取对应的 value 数据。不同类型的数据结构主要不同在它们的 value 结构不同。
Redis基础数据类型 | 底层数据结构 | 应用背景 |
字符串(String) | 数组 | 计数器(incr和incrby方法),统计在线人数,存储图片或视频 |
列表(List) | 双向链表 | 存储粉丝列表、文章评论列表,分页查询;实现简单的队列机制,比如:到货通知、邮件发送 |
哈希(字典) | 数组 + 链表二维结构 | 存储对象的基本属性信息,比如用户信息,商品信息 |
集合(set) | hash | 去重,例如用户名不能重复;交集并集功能,比如查找微博里两个大v的共同好友 |
有序集合(sorted set) | Hash+跳跃表 | 可以自定义排序规则,比如实现排行榜功能 |
1. Redis 的字符串类型 String:(set,get,setnx,incr,incrby)
- Redis 字符串是一种动态字符串,结构类似于 Java 中的 ArrayList,采用预分配冗余空间的方式来减少内存的频繁分配。
- set 进行写:set name test(mset name1 test1 name2 test2 name3 test3)
- get 进行读:get name(mget name1 name2 name3)
- expire 设置过期时间来自动删除(表示5秒后过期):expire name 5
- setex 同时设置过期时间和进行写操作:setex name 5 test
- setnx 表示 name 不存在时才执行 set 进行创建:setnx name test
- 如果 value 的值是一个整数,那么就可以对它进行自增操作,范围是 long 类型的最大最小值:
- incr 表示自增 1 :incr age
- incrby 表示增加指定范围:incrby age 5
2. 列表 list:(rpush,lpop,lrange)
- Redis 的列表相当于 Java 中的 LinkedList,插入和删除的速度非常快,时间复杂度为 O(1),但查找数据很慢,时间复杂度为 O(n) 。
- 可以用来做异步队列:将需要延后处理的任务序列化为字符串,塞进 redis 的 list 列表中,然后另一个线程从这个列表中轮询数据进行处理。rpush 生产消息,lpop 消费消息。当 lpop 没有消息时,可以使用 blpop,它会阻塞住直到消息到来。
- rpush 表示右边进,lpop 表示左边出,构成的是一个队列。
- rpush 右边进,rpop 右边出,构成的是一个栈。
list 深入研究:
- Redis 底层存储的并不是一个简单的 LinkedList,而是快速链表 quickList。
- 首先在列表元素较少时,使用一块连续的内存来存储,这个结构叫 zipList,也就是压缩列表,它将所有元素紧挨着一起存储,分配的是一块连续的内存。
- 当数据量较多时,改为 quickList,这么做的原因是:普通的链表需要附加的指针会占用空间,而且加重了内存的碎片化。所以 Redis 将链表和 zipList 结合组成了 quickList,它将多个 zipList 通过双向指针串起来,这样既能满足快速的插入删除性能,又不会出现太大的空间冗余。
3. hash(字典)(hset,hget,hlen,hmset 批量set,hincrby 自增)
- Redis 的字典相当于 Java 中的 HashMap。
- Redis 的字典与 HashMap 的相同点:无序,采用的数组 + 链表的结构,数组位置碰撞时,将碰撞的元素使用链表串接起来。
- 不同点:Redis 字典的值只能是字符串,而 HashMap 的值可以是多种类型。另外它们 rehash 的方式不一样,因为 HashMap 在字典很大时,一次性全部 rehash 会是一个很耗时的操作,Redis 为了高性能,不能堵塞服务,所以采用了渐进式 rehash 的策略。渐进式 rehash 就是同时保留旧数组和新数组,在后续对 hash 的操作中渐渐的将旧数组中的数据迁移到新数组中,所以在操作处于 rehash 过程的字典时,需要同时访问新旧两个数组,如果在旧数组中找不到元素,就需要去新数组中查找。
- redis 中每个字典都带有两个哈希表:ht[0] 和 ht[1],平时使用的是ht[0],ht[1] 只在进行 rehash 时使用。
- redis的哈希表是如何解决冲突的?:它采用链地址法来解决键冲突的问题,每个哈希表节点都有一个next指针指向另一个哈希表,通过这个next指针将两个索引值相同的键连接起来。
字典rehash的步骤:(通过 rehash 操作来扩展或收缩哈希表)
- 为字典的 ht[1] 哈希表分配空间,这个空间大小取决于要执行的操作,以及 ht[0] 当前包含的键值对数量,也就是 ht[0].used 的值,used 记录哈希表已有的键值对数量,如果执行的是扩展操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used x 2 的 2n ;如果执行的是收缩操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used 的 2n
- 将保存在 ht[0] 中的所有键值对 rehash 到 ht[1] 上面,rehash 指的是重新计算键的哈希值和索引值,然后将键值对放到 ht[1] 哈希表中。
- 当 ht[0] 包含的所有键值对都迁移到 ht[1] 后,ht[0] 变为空表,释放 ht[0],将 ht[1] 设为 ht[0] ,并为 ht[1] 分配一个新的空白哈希表,为下一次 rehash 做准备。
渐进式rehash的原理(步骤):
- 为 ht[1] 分配空间,让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器 rehashidx,值设为0,表示开始 rehash 操作。
- 在 rehash 期间,每次对字典执行添加、删除、查找或更新操作时,程序会将 ht[0] 哈希表在索引上的键值对 rehash 到 ht[1] ,并将索引值加1。
- 随着字典操作的不断执行,最终 ht[0] 的所有键值对都会被 rehash 到 ht[1] ,然后将索引值设为 -1,表示 rehash 操作完成。
4. Set 集合:(sadd,spop,scard,smembers)
- Redis 的集合相当于一个特殊的字典,所有 value 值都是 null。
- 它的键值对是无序而且唯一的,所以 set 结构可以用来存储活动中奖的用户 ID,因为有去重功能,可以保证同一个用户 ID 不会中奖两次。
- sadd book java
- spop book (弹出一个)
- scard book (获取长度,类似 count)
- smembers book (查看所有元素)
5. zset 有序集合:(zadd,zrange,zcard,zrank)
- zset 内部由 hash 字典加跳跃表来实现。
- zadd test 99 “math”
- zrange test 60 100 (区间查找60到100分的 value)
- zcard
- zrank test ”math“ (排名)
使用场景:
- 可以用来存储学生的成绩,value 是学生的 ID,score 是他的考试成绩,可以按成绩进行排序,这样就可以得到学生的名次了。
- 还能用来存储粉丝列表,value 是粉丝的用户 ID,score 是关注时间,可以对粉丝按关注时间进行排序。
跳表: 跳表是一种链表加多层索引的结构,支持快速的插入、删除、查找操作,时间复杂度都是O(logn)。空间复杂度为O(n)。
跳跃表的时间复杂度为O(logn):假设跳表每两个元素提取出一个元素作为上一级的索引,也就是开始是1/2,然后1/4,1/8 … ,每一级索引减少上一级一半的元素,那么最高级索引有 2 个元素,假设总元素个数为 n,那么 n/(2h) = 2 ,得到高度 h = logn - 1,再加上原始链表这一层,整个跳表的高度就是 logn 了。在这个跳表中查询元素时,每一层都要遍历 2 个节点,这样查找一个数据的时间复杂度就是 O(2*logn),所以跳表的时间复杂度为 O(logn)。
跳跃表的空间复杂度为O(n):假设每两个元素提取一个索引,那么最后额外需要的空间是一个等比数列,为:n/2 + n/4 + n/8 … + 8 + 4 + 2 = n - 2 ,所以跳表的空间复杂度为 O(n)。(计算方法是对式子 + 2 - 2)
Redis之所以使用跳表,是因为跳表能以 O(logn) 的时间复杂度定位到区间的起点,然后在原始链表中顺序往后遍历来找到区间中的元素。
为什么Redis选择使用跳表而不是红黑树来实现有序集合 zset?跳表优点:Redis的有序集合支持的操作有:插入元素,删除元素,查找元素,有序输出所有元素,查找区间内所有元素。前4项红黑树都可以完成,且时间复杂度与跳表一致。但最后一项,红黑树的效率就没有跳表高了。在跳表中,要查找区间的元素,只需定位到两个区间端点在最低层级的位置,然后顺序遍历元素即可,非常高效。而红黑树只能定位到端点后,再从首位置开始每次都要查找后继节点,相对来说比较耗时。此外,跳表更加灵活,可以通过改变索引结构来平衡执行效率和内存消耗之间的关系,而且跳表实现起来简单易读,红黑树实现起来相对困难,所以Redis选择使用跳表来实现有序集合。
为什么有序集合 zset 需要同时使用跳跃表和字典来实现?:因为无论单独使用字典还是跳跃表,性能与同时使用它们相比会降低。比如只使用字典实现有序集合,那么虽然以O(1)复杂度查找成员分值的特性会被保留,但因为字典是以无序的方式来保存集合元素,所以执行范围型操作,比如 zrank、zrange命令时,程序都需要对字典保存的所有元素进行排序,完成这种排序至少需要O(NlogN)的时间复杂度,以及额外O(N)的内存空间,因为要创建一个数组来保存排序后的元素。 如果只使用跳跃表来实现有序集合,那么跳跃表执行范围型操作的所有优点都会被保留,但因为没有了字典,所以根据成员查找分值这一操作的复杂度将从O(1)上升为O(logN)。 因此,为了让有序集合的查找和范围型操作都尽可能快的执行,redis选择同时使用字典和跳跃表来实现有序集合,这两种数据结构都会通过指针来共享相同元素的成员和分值,所以同时使用它们来保存集合元素不会产生重复成员或分值,因此也不会浪费额外的内存。
redis中跳跃表的应用场景:redis只在两个地方用到了跳跃表,一个是实现有序集合键(zset),另一个是在集群节点中用作内部数据结构。
跳表是可以实现二分查找的有序链表(链表加上多级索引的结构,就是跳表)
6. HyperLogLog(UV:网站的独立访客)
- 采用基数算法实现,它设有 16384 个桶进行独立计数,也就是 2 的 14 次方,每个桶占 6 位,2 的 14 次方乘以 6 再除以 8,就等于 12k 字节了。它根据输入元素来计算基数,而不是存储元素本身。
- 占用空间小,无论统计多少个数据,最多占用12K的内存。(redis 设有 16384个桶,每个桶里的数据最多需要6位进行存储,16384 x 6 / 8 约等于 12k 字节)
- 它统计值时存在误差,标准误差为0.81%
HyperLogLog 使用场景:
- 根据用户的IP来统计访问量,需要去重,同一个用户一天之内的多次访问请求只能计数一次,所以每个网页请求都需要带上用户的ID,无论是登录用户还是未登录的游客,都需要一个唯一ID来标识。
- 最开始容易想到的是为每一个页面设置一个独立的 set 集合来存储当天访问该页面的所有用户ID,但如果页面访问量非常大,比如几千万的 UV,这样 set 集合就会非常大,浪费了空间,而且对于这个 UV 值,一般情况下并不需要多么的精确,比如 100 万和 101 万,对于这个场景,并没什么大的区别。所以想到了采用 redis 的 HyperLogLog 数据结构来实现。
- HyperLogLog 提供不精确的去重计数方案,标准误差为 0.81%,这个精确度已经可以满足 UV 的统计需求了,而且占用的内存不超过12k。
7. Bitmap 位图(DAU:日活跃用户)
- 位图不是特殊的数据结构,它其实就是普通的字符串,也就是 byte 数组,用 getbit 和 setbit 来操作,能够统计精确的值。
- 可以用于布尔型数据的存取,比如用户一年的签到记录,签到了是1,没签到是0,记录365天,通过 bitcount 指令来统计用户一共签到了多少天,每个签到记录只占用一位,365位大约是46个字节大小,用户上亿时,大大节约了内存空间。