Redis 的底层数据结构
我们都知道 Redis 中的数据结构有:String,List,Hash,Set,Sort Set等,那么他们对应的底层的数据结构有哪些呢?
可以看到,String 类型的底层实现只有一种数据结构,即简单动态字符串。但是,其他四种数据结构,都有两种底层实现结构,它们被称为"集合类型"。
上面的数据结构都是值的底层实现,键和值之间用什么结构组织呢?
键与值之间的关联
为了实现从键到值的快速访问,Redis 使用了一个哈希表来保存所有键值对。哈希表中每个元素称为一个哈希桶,每个哈希桶中保存了键值对数据。哈希桶中的元素保存的并不是值本身,而是指向具体值的指针。
哈希表的最大好处很明显,就是让我们可以用 O(1) 的时间复杂度来快速查找键值对。
但是,我们往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。这其实是因为忽略了一个潜在的风险点,那就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。
哈希表的哈希冲突
这里的哈希冲突,就是值两个 key 的哈希值和哈希桶计算对应关系的时候,正好落在同一个哈希桶里面。这一点很好理解,因为哈希桶的个数通常要少于 key 的数量。或者,你可以想象下:你有10 个篮子,12 个苹果,很明显,要全部放到篮子里的话,其中有两个篮子会有两个苹果。
Redis 中解决哈希冲突的方法,就是链式哈希。也即:同一个哈希桶中的多个元素,使用一个链表来保存:
这里回到我们之前说的点:往 Redis 中写入大量数据后,就可能发现操作有时候会突然变慢了。很明显,当数据过多的时候,一个哈希桶上的哈希链就会越长,那么查询耗时就会增大,效率降低。这对与我们来说是不能接受的,所以我们需要对哈希表做 rehash 操作。
rehash 就是增加现有的哈希桶数量,让逐渐增多的 entry 元素能在更多的桶之间分散保存,减少单个桶中元素数量。具体操作如下:
- 为了 rehash 更有效率,Redis 默认使用两个全局哈希表:哈希表1 和哈希表2,一开始都使用 哈希表1;
- 当数据增多,要开始执行 rehash 时:
- 给哈希表 2 分配更大的空间,比如说哈希表1 的两倍;
- 把哈希表 1 中的数据重新映射到哈希表 2;
- 释放哈希表 1 的空间;
- 哈希表 1就留着用于下一次 rehash 使用;
渐进式 rehash
上面的过程看似简单,但是数据的重新映射设计大量的数据拷贝,如果一次性将其迁移完成,会造成 Redis 线程阻塞。为了避免这个问题,Redis 采用了渐进式 rehash。
简单来说就是在第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。
这样就巧妙地将一次性大量拷贝的开销,分摊到多次处理请求的过程中,避免了耗时操作。
集合类型的查找
对于 Sting 类型来说,找到哈希桶就能直接增删改查了,所以,哈希表的 O(1) 操作复杂度也就是它的复杂度了。
但是,对于集合类型来说,即使找到哈希桶了,还要在集合中再进一步操作。接下来,我们来看集合类型的操作效率又是怎样的。也即:第一步是通过全局哈希表找到对应的哈希桶位置,第二步是在集合中再增删改查。
集合类型的底层包括 5 种:整数数组、双向链表、哈希表、压缩列表和跳表。
一些使用上的点
- 范围操作,是指集合类型中的遍历操作,可以返回集合中的所有数据,比如 Hash 类型的 HGETALL 和 Set 类型的 SMEMBERS,或者返回一个范围内的部分数据。这类操作的复杂度一般是 O(N),比较耗时,我们应该尽量避免。
- 整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 Redis 还会把它们作为底层数据结构呢?
- 内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
- 数组对CPU高速缓存支持更友好,所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。(这点和Hash 类似: Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。 还有 String 的 SDS 底层也是类型)