Redis 数据结构
基本数据结构
String
特点
- 最大能存储 512MB == 536870912 B(byte) ;
- 二进制安全,在传输数据的时候,能保证二进制数据的信息安全,也就是不会被篡改、破译;如果被攻击,能够及时检测出来
- 能存储各种类型的数据,字符串、数字,以至对象(通过json序列化)、位图等。
基本使用
容量:512M
1 | set aa 'str' |
操作总结
1 | set/get/del/append/strlen key |
常见应用
字符串、jpg图片、序列化对象、一些复杂的计数功能的缓存
Hash
存储 String 类型键值对的映射表、对象
基本使用方法
容量:每个 Hash 可存 2^32 - 1(约 40 亿)个键值对
1 | hmset user username 'name' password '123456' # 定义一个有两个元素的Hash表 |
常见应用
单点登录(存<CookieId, 用户信息>,设置 30 分钟为缓存过期时间,能很好地模拟出类似 Session 的效果)。
List
String列表
基本使用方法
容量:每个 List 可存 2^32 - 1 个元素
1 | lpush ball basketball soccer # 按顺序从左侧压入,如果ball列表不存在则创建 |
操作总结
1 | lpush/rpush/lrange |
常见应用
简单的消息队列
基于 Redis 的分页功能(利用 lrang 命令,性能极佳,用户体验好)
Set
字符串的无序集合,使用 Hash 实现(key 和 value 相同的 Hash)
基本使用方法
容量:2^32 - 1 个成员
1 | sadd myset li1 # 向集合myset中添加一个元素li1,若不存在则创建 |
常见应用
全局去重(为什么不用 JDK 自带的 Set 去重?因为我们的系统一般都是集群部署)
计算共同喜好、全部喜好、自己独有的喜好等功能(交集、并集、差集)
ZSet
字符串的有序集合,每个元素都关联一个 double 类型的权重参数 score,集合中的元素能够按 score 进行排列。
基本使用方法
容量:2^32 - 1 个成员
1 | zadd myzset 0 abc |
常见应用
排行榜
取 Top N 操作
延时任务(https://www.cnblogs.com/rjzheng/p/8972725.html)
范围查找
原理
实现上类似于 Java 的 SortedSet 和 HashMap 的结合体,value 唯一(Set 结构的特点),每个 value 一个 score 代表该 value 的排序权重。
zset 内部是使用一种叫做跳跃列表的结构实现的。
Redis 数据结构的实现
数据结构的声明和实现
Redis 中的 set、zset 等结构在 Redis 中并不是由一个单独的数据结构实现的,而是会根据情况有所变化。
set
set和Java中的HashSet有点像,它本身是HashMap的封装,key是集合中的对象,而value直接用NULL代替。
但是注意一些特殊情况:
- 创建集合对象时,如果发现集合内的元素可以使用整数(longlong)编码,则创建一个intset而不是dict;
- 之前是intset编码的情况下,插入的新元素如果是非整数的,那么集合会被重新转换成dict编码的;或者插入的元素数量达到了阈值(512),也会自动转换成dict。
创建代码见:t_set.c/setTypeCreate()
zset
zset同样有两种形态:ziplist编码和skiplist编码。
- 按ziplist编码的情况下:
zset本身就是个ziplist对象。 - 按skiplist编码的情况下:
zset的集合功能是通过dict实现的,这部分和set并无区别;
zset的有序性是通过skiplist实现的,skiplist按分值排序成员,支持平均复杂度为O(logN)的按分值定位成员的操作。
执行zadd命令代码:t_zset.c/zaddGenericCommand()
创建zset对象:object.c/createZsetZiplistObject()
、object.c/createZsetObject()
SDS(Simple Dynamic String)
Redis 中的动态数组有以下特点:
- 可动态扩展内存。sds 表示的字符串其内容可以修改,也可以追加。在很多语言中字符串会分为 mutable 和 immutable 两种,显然 sds 属于 mutable 类型的。
- 减少修改字符串的内存重新分配次数
C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。
而对于SDS,由于len
属性和free
属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:
1、空间预分配:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。
2、惰性空间释放:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用 free 属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。) - 二进制安全(Binary Safe)。sds 能存储任意二进制数据,而不仅仅是可打印字符。
- 与传统的 C 语言字符串类型兼容。
1 | struct SDS<T> { |
下面的函数将 t 数组拷贝到 s 中,如果长度不够则需要进行扩容:
1 | /* Append the specified binary-safe string pointed by 't' of 'len' bytes to the |
SDS 有 embstr 和 raw 两种存储结构,它们的区别是:
- 内存分配上:
embstr 调用 1 次 malloc, 因此 redisObject 和 SDS 内存是连续分配的;
raw 需要调用 2 次 malloc, 因此 redisObject 和 SDS 内存不连续分配 - 使用上:
embstr 整体 64 byte, 正好和cpu cache line 64byte 一样, 可以更好的使用缓存, 效率更高
quicklist
Redis 早期版本存储 list 数据结构采用(元素少时 ziplist、多时 linkedlist )的方案,但是:
- 链表的附加空间太高,prev 和 next 指针就要占去 16 个字节(64 位系统);
- 链表每个节点都是单独分配,会加剧内存的碎片化。
因此在之后的版本中转换为了 quicklist 存储。
quicklist 是 ziplist 和 linkedlist 的混合体,它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来。
1 | struct ziplist { |
ziplist
ziplist 是一种压缩存储的数组结构,当 Redis 中的集合数据结构很小,则它会使用这种紧凑的存储形式存储,元素之间紧挨着存储,查找就是对数组进行遍历找到目标对象。
- zset 和 hash 容器在元素个数较少时会采用 ziplist 存储。当存储的对象数量小于 512 且所有 entry 的 value 值长度小于 64,采用 ziplist 存储,否则转为采用 hashtable 存储。
1
2
3
4
5
6
7
8
9import redis
client = redis.StrictRedis()
client.delete("hello")
for i in range(512):
client.hset("hello", str(i), str(i))
print client.object("encoding", "hello")
client.hset("hello", "512", "512")
# 或者插入一个长度为65的值也能起到转化的作用
print client.object("encoding", "hello")
可以上服务器上使用debug object
命令验证数据结构的类型:
1 | > zadd hgc_test 1.0 go 2.0 python 2.0 java |
结构
1 | struct ziplist<T> { |
- zltail_offset 字段可以快速定位到 ziplist 中的最后一个节点,可以用于倒序遍历,entry 中的 prevlen 表示前一个 entry 的字节长度,可以用于倒序遍历时找到下一个元素的位置;
- encoding 记录编码类型,ziplist 利用该字段决定后面的 content 内容的形式,比如用
00xxxxxx
表示最大长度为 63 的短字符串,01xxxxxx xxxxxxxx
表示中等长度的字符串;
插入
ziplist 是紧凑存储的,没有冗余空间,因此插入一个新元素就需要调用 realloc 重新分配内存空间,并将之前的内容一次性拷贝到新的内存空间中。
重新分配空间是比较耗时的,因此 ziplist 不适合存储大量数据。
更新/删除
修改或删除一个元素后其后一个位置的元素中的 prevlen 也需要级联更新,prevlen 字段又是变长的,所以可能会导致连锁反应。
ziplist vs dict
为什么 hash 结构中会采用 ziplist 而不是 dict,主要原因如下:
- 数据量小时,ziplist 的速度也很快;
- 数据量大时,ziplist 在每次插入或修改时引发的 realloc 操作会有更大的概率造成内存拷贝,从而降低性能,而且数据项过多的时候,在 ziplist 上查找指定数据项的性能会变得很低,因为在 ziplist 上的查找需要进行遍历。
dict(字典)
dict 是 Redis 中使用最广泛的数据结构:
- hash 结构的数据会用到字典;
- 整个 Redis 数据库的所有 key 和 value 也组成了一个全局字典;
- 带过期时间的 key 集合也是一个字典;
- set 结构的底层实现也是字典,只是所有 value 都是 NULL;
- zset 集合中存储 value 和 score 值的映射关系也是通过 dict 结构实现的。
1 | struct RedisDb { |
1 | struct zset { |
hashtable
dict 中的 hashtable 结构和 Java 中的 HashMap 类似,使用一个数组来保存所有的哈希桶,通过siphash函数来将 key 散列到数组中的某个桶上,每个哈希桶都是一个链表,也就是说如果发生哈希冲突,则将新元素直接插入到桶的头部。
1 | struct dictEntry { |
扩容:渐进式 rehash
正常情况下,当 hashtable 中元素的个数等于第一维数组的长度时、来了一个新增/修改/删除操作,就会触发扩容,扩容的新数组是原数组大小的 2 倍。
存在一个特殊情况:如果 Redis 正在做 bgsave,为了减少内存页的过多分离 (Copy On Write),Redis 尽量不去扩容 (
dict_can_resize
),但是如果 hash 表已经非常满了,元素的个数已经达到了第一维数组长度的 5 倍 (dict_force_resize_ratio
),说明 hash 表已经过于拥挤了,这个时候就会强制扩容。
一般情况下 dict 中只有一个 hashtable 有值,但是在扩容时会分配另一个新的 hashtable,然后执行渐进式的数据迁移,避免一次性对所有 key 执行 rehash,而是将 rehash 操作分散到了对 dict 的各个增删改查操作中去了。
- 在扩容过程中,如果有新增元素,则该元素会被同时添加到新 hashtable 中;
- 查询、删除、修改操作中,会先查询旧 hashtable,若存在则迁移这个 key 所在的桶并返回元素,若不存在则到新 hashtable 中查找元素。
- 有一个异步线程执行定时任务对字典主动迁移。
dict 之所以这样设计,是为了避免 rehash 期间单个请求的响应时间剧烈增加。
当旧 hashtable 中无元素时,即代表迁移完毕,这时会将新旧 hashtable 的指针交换,旧的会被删除,而新的则取而代之。
缩容
当 hash 表因为元素的逐渐删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。缩容的条件是元素个数低于数组长度的 10%。
缩容不会考虑 Redis 是否正在做 bgsave,因为 COW 的特性是当内存页上的数据被修改时会复制一页做修改,如果删除操作并不会触发删除内存页的数据,操作系统回收内存机制导致的。
全局哈希表
get a
和llen b
中的a和b是不同数据结构的对象,他们统统被存储在一个叫全局哈希表的地方。
哈希表中的每个哈希桶存储的不是值本身,而是指向它们的指针。
代码定义在:redis.h/redisDb
缺点:
- 过多的哈希冲突容易产生过长的哈希桶(哈希冲突链)。
为了减少这个问题产生的影响,需要对哈希表进行rehash操作,这个rehash操作和dict数据结构的rehash原理是一样的。全局哈希表实际上就是dict,可以看源码中的定义。
优点:
- 合适的散列函数和扩容机制可以保证
O(1)
的操作复杂度。
skiplist
zset 中除了 dict(字典)外,还会用一个 skiplist 来提供按score排序的要求,以实现指定 score 的范围来获取 value 列表的功能。
1 | struct zslnode { |
- 各层均为一个有序的链表结构;
- 层数越大,节点越少;
- 有一个 header 节点作为哨兵,value=null,score=Double.MIN_VALUE。
插入
插入时会先自顶向下找到新节点在跳表中底层的插入位置,插入每一层时都有概率晋升到更高一层,在 Redis 中是 25%。
删除
删除每一层上的对应节点。
更新
如果不影响排序则直接更新,否则会先删除再重新插入。
布隆过滤器
HyperLogLog
布隆过滤器用于实现contains
的需求,而 HyperLogLog 主要用于实现count
。
同样是一个特别大的位数组,HyperLogLog 将位数组拆分为桶,每个桶是连续的 6 个位,计数时并非单独对某个桶计数,而是:
- set 操作:计算 key 的散列值,为一个 64 位的数字,前 14 位是桶的位置,桶记录后 50 位中第一个 1 的位置 count,并且
count = max(count, oldCount)
,即每次记录最大的计数。 - count 操作:因为是概率算法,每个桶的计数值并不精确,但是所有桶的调和均值非常接近真实的计数值。
pubsub
用于实现轻量级的发布订阅功能。
geohash
QA
使用string还是hash?
当数据量少时,使用hash明显更加节省内存,因为数据少时hash会转成ziplist的结构,而string每个kv都需要一大堆的额外空间存储元数据。
如何使用Redis的数据结构实现统计?
- 需要支持集合运算(差集、交集、并集)的场合
使用set、zset,数据量少时会转成ziplist节省内存。 - 需要进行二值统计的场合
使用bitmap - 需要大规模统计,且不要求精确统计的场合
使用HyperLogLog
采用渐进式hash时,如果实例暂时没有接收到新请求,是不是就不会做rehash了?
不会,还有一个定时任务每隔100ms执行rehash,而且每次执行时长不会超过1ms,以免影响其他任务。