总结整理Redis常见数据类型、底层存储原理及应用。
Redis
基础数据结构包括:
String
字符串Hash
哈希List
列表Set
集合Zset
有序集合
随着Redis
的版本迭代,又新增了四种数据类型:
BitMap
位图(V2.2
)HyperLogLog
基数估算(V2.8
)GEO
经纬度(V3.2
)Stream
消息队列(V5.0
)
String
Redis
作为一种K-V
存储系统,其Key
为基本的字符串类型,其大小上限为512M
,当然为了性能考虑最好不要设置太大。
String
是Redis
最基本的K-V
数据结构,虽然Key
一定是字符串,但Value
并不局限于字符串类型,还可以是正整数、浮点数甚至二进制数据,其最大数据长度也是512M
存储原理
首先Redis
采用一个RedisObject
结构体来存储一个Value
:
typedef struct redisObject { |
对于String
类型,会将type
设置为String
,并根据存储的实际类型设置相应的encoding
,String
具有三种编码方案:
- int
- raw
- embstr
对于int
型的数据,Redis
会直接把它存储在ptr
这个指针变量中,并强制转换为long
类型
而对于其他类型的数据则可以使用SDS
(Simple Dynamic String
,简单动态字符串)来存储,并将ptr
指针指向该SDS
对象地址,SDS
由三部分构成:
struct sdshdr { |
相比C默认的char*,这种方式优势如下:
- 二进制安全,可保存二进制数据,不会像 C 那样对字符串内数据做假设(
\0
认为是结尾) - 高效获取字符串长度,可直接使用
len
属性获取字符串长度,时间复杂度为O(1)
- 可扩容,可进行字符串拼接,如空间不够会自动扩容。
其中SDS
扩容策略伪代码如下:
def sdsMakeRoomFor(sdshdr, required_len): |
而对于不同长度的内容,redis
也采用不同的编码方案(32字节的阈值为2.+版本,不同版本的设置也是不一样的):
- 内容小于等于32字节时,使用
embstr
编码进行优化,仅通过一次内存分配来分配一块连续的内存空间存储redisObject
和SDS
,但这也会使得该数据大小不可变,如为其执行append
等操作导致数据长度增加时,redis
会将embstr
转换成raw
,再执行修改命令。
内容大于32字节时,使用
raw
编码,通过两次内存分配来分配两块内存空间保存redisObject
和SDS
embstr
这种方案优点如下:
embstr
编码将创建字符串对象所需的内存分配次数从raw
编码的两次降低为一次- 释放
embstr
编码的字符串对象同样只需要调用一次内存释放函数; redisObject
和SDS
放到连续内存种可以更好地利用cache
,提高系统性能
应用场景
缓存对象属性
可以将对象属性进行批量缓存,也可直接缓存整个对象的JSON
字符串。(此外还有一个叫作 RedisJSON 的扩展模块,可以直接将json
存储到redis
中,可直接操作具体的Key
,而不需要进行编解码操作)
计数
得益于Redis
单线程处理的策略,所有执行过程都是原子性的,可以进行原子计数
分布式锁
SET
命令有个 NX
参数可以实现 key不存在才插入,配合过期时间,可以用它来实现分布式锁:
- 加锁:
SET lock_key unique_value NX PX 10000
- lock_key 锁名
- unique_value 是客户端生成的唯一的标识
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作,如果已经存在了则会失败
- PX 10000 表示过期时间,避免因为异常无法释放锁
- 解锁:解锁需要两步
- 判断
unique_value
是否和本地一致,即这个锁是不是自己加的,不是自己加的不能解锁 - 如果是自己加的则把key移除掉,即可解锁
- 判断
为了保证解锁的原子性,需要采用LUA
脚本执行解锁操作。
共享Session
由于后端服务在多机部署时,本地存储的信息不互通,可以使用redis
来进行集中缓存。
List
List
列表可以实现从存储多个数据,并可以从两端插入(LPUSH
、RPUSH
)或获取(LPOP
、RPOP
),还支持范围查询LRANGE
,以及阻塞查询BLPOP
。
存储原理
Redis 3.2
之前采用双向链表和压缩列表存储:
- 当列表的元素个数小于
512
个,列表每个元素的值都小于64
字节,使用压缩列表(ziplist
)作为 List 类型的底层数据结构 - 如果列表的元素不满足上面的条件,
Redis
会使用双向链表作为 List 类型的底层数据结构
为什么在数据少时使用压缩列表呢?
因为 redis 中的集合容器中,很多情况都用到了链表的实现,元素和元素之间通过储存的关联指针有序的串联起来,但是这样的指针往往是 随机I/O,也就是指针地址是不连续的(分布不均匀)。而我们的 ziplist 它本身是一块连续的内存块,所以它的读写是 顺序I/O,从底层的磁盘读写来说,顺序I/O 的效率肯定是高于 随机I/O 。你可能会问了,那为什么不都用 顺序I/O 的 ziplist 代替 随机I/O 呢,因为 ziplist 是连续内存,当你元素数量多了,意味着当你创建和扩展的时候需要操作更多的内存,所以 ziplist 针对元素少的时候才能提升效率。
由于双向链表的pre、next指针需要占用很多的附加空间,并且会造成内存的碎片化,在Redis 3.2
之后,List
数据类型底层数据结构改为由quicklist
实现, quicklist
实际上是zipList
和 linkedList
的混合体,它将 linkedList
按段切分,每一段使用 zipList
来紧凑存储,多个 zipList
之间使用双向指针串接起来。
应用场景
消息队列
List
可以借助LPUSH + RPOP
实现先进先出的数据存取,然而Redis
并没有提供通知机制,消费端需要轮询是否有新数据,为了提高效率可以采用阻塞查询BRPOP
。另一方面,由于没有ACK
机制,POP
出数据之后这条记录就没了,如果消费端拿到了数据但没有完成处理就崩溃了就会导致数据的丢失,为此redis
提供了RPOPLPUSH
指令,POP
时会同步放到另一个List
中做备份,如果出现故障可以去备份List
中获取。
当然使用Redis List
实现消息队列是比较基础和简陋的,无法实现消息组等功能,同时Redis
宕机也会导致数据丢失。
Hash
Hash
是一个键值对(key - value
)集合,适用于存储对象。Hash
与String
对象的区别如下图所示:
存储原理
Redis 7.0
前Hash
采用压缩列表和哈希表来实现
- 如果哈希类型元素个数小于
512
个(默认值,可由hash-max-ziplist-entries
配置),所有值小于64
字节(默认值,可由hash-max-ziplist-value
配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构 - 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。
在 Redis 7.0
中,改为 listpack
实现,主要是为了解决压缩列表中间元素更新时导致的连锁更新问题(因为后续的prevlen
都会发生改变)。listpack
每个元素项不再保存上一个元素的长度,而是通过记录entry长度以及element-tot-len中特殊的结束符,来保证既可以从前也可以向后遍历。此处可参考深入分析redis之listpack,取代ziplist?
应用场景
缓存对象
和基于String
来存储json
一样,Hash
也可以用来缓存对象,他们的区别如下:
String json
直接存储整个对象,占用空间少,但不能单独获得和更新某个属性,适用于频繁获取整个对象的场景。Hash
占用空间会多一些,但支持单独处理某个属性,读取整个对象效率不高,适用于频繁更新单个属性的场景。
此外对比前面提到的RedisJSON
扩展,RedisJSON
性能弱于Hash
但可以处理嵌套等更复杂的对象结构。参考 Redis: When to use hashes vs RedisJSON?
Set
和Python
的Set
数据结构一样,这种数据类型是无序且无重复的,一个集合最多可以存储 2^32-1
个元素。除了支持集合内的增删改查,还支持交集、并集、差集等操作。
存储原理
Set
采用哈希表或整数集合实现:
- 若集合内元素均为整数且数量小于512个,会使用整数集合来存储
- 若不满足上述条件,则采用哈希表进行存储
应用场景
借助Set
无序,不重复,以及集合运算等特性,可以应用到统计点赞,共同好友,抽奖等场景。
Zset
Zset
在Set
的基础上增加了一个排序属性score
,可以按照score
进行排序。
存储原理
Zset 类型的底层数据结构是由压缩列表(Redis 7.0之后改为listpack)或跳表实现
- 如果有序集合的元素个数小于
128
个,并且每个元素的值小于64
字节时,Redis
会使用压缩列表作为Zset
类型的底层数据结构 - 如果有序集合的元素不满足上面的条件,
Redis
会使用跳表(深入理解Redis跳跃表的基本实现和特性)作为Zset
类型的底层数据结构。
应用场景
借助有序的特性,可以实现排行榜功能,通过点赞不断更新相应Key
的score
,维护从高到低的排行榜。
BitMap
Bitmap
,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1
的设置,表示某个元素的值或者状态,时间复杂度为O(1)。
应用场景
可以应用在只有两种状态的记录场景,如记录用户的签到数据,判断用户的登录状态,用户的权限信息等。
HyperLogLog
HyperLogLog
,用于在一大批数据中估算不重复的元素数量(统计基数),其优点是所需要的空间固定,仅需要 12 KB
左右的内存空间,就可以估算出最多2^64
的基数。但是这种方法无法得到精确的结果,标准误差为 0.8125%
估算原理
HyperLogLog
算法的基本思想来自伯努利过程, 抛硬币正面反面的概率都是1/2
,那么抛出正正反
这样一个序列的概率大概就是1/8
,就可以估算出大概进行了8次
抛硬币实验。HyperLogLog
原理思路是通过给定 n 个的元素集合,记录集合中数字的比特串第一个1出现位置的最大值k,也可以理解为统计二进制低位连续为零(前导零)的最大个数。通过k值可以估算集合中不重复元素的数量m,m近似等于 2^k。此外Redis HyperLogLog
还进行了分桶优化,来改善误差。具体可参考 Redis 中 HyperLogLog 的使用场景。
应用场景
可以用于估算网页的访问量(按用户去重)
GEO
Redis GEO
是 Redis 3.2
版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。可用于查询一定距离内的key
,以及计算两个key
的距离等。
存储原理
GEO
类型使用 GeoHash
将二维的经纬度数据编码成一维数据,再利用Zset
进行有序索引。具体原理可参考Redis GEO & 实现原理深度分析一文。
Stream
Redis Stream
是 Redis 5.0
版本新增加的数据类型,Redis
专门为消息队列设计的数据类型,相比基于List
实现的消息队列,Stream
可以支持:
- 自动生成全局唯一 ID (生产者插入消息时会自动生成一个ID并返回)
- 消息的持久化及
ack
确认消息的模式,Stream
会自动使用内部队列(也称为PENDING List
)留存消费组里每个消费者读取的消息,直到消费者使用XACK
命令通知Stream
“消息已经处理完成”。未完成处理的消息,Redis
会为之保存。 - 消费组模式(使用
XGROUP
命令即可创建消费组,一条消息可以供多个消费组消费)
Redis Stream 消息会丢失吗?
消息丢失可能发生在三个位置:生产者、队列中间件、消费者
- 生产者,生产者只需要确保消息成功插入即可保证消息不丢失
- 队列中间件,
redis
的持久化机制导致数据不会实时落盘,因此宕机时会可能导致数据丢失,此外在进行主从复制时也可能导致数据的丢失。- 消费者,凭借消息的持久化及
ack
确认消息的模式可以保证消息不丢失
Redis 发布/订阅机制为什么不可以作为消息队列?
- 发布/订阅机制没有基于任何数据类型实现,所以不具备「数据持久化」的能力,也就是发布/订阅机制的相关操作,不会写入到 RDB 和 AOF 中,当 Redis 宕机重启,发布/订阅机制的数据也会全部丢失。
- 发布订阅模式是“发后既忘”的工作模式,如果有订阅者离线重连之后不能消费之前的历史消息。
- 当消费端有一定的消息积压时,也就是生产者发送的消息,消费者消费不过来时,如果超过 32M 或者是 60s 内持续保持在 8M 以上,消费端会被强行断开,这个参数是在配置文件中设置的,默认值是
client-output-buffer-limit pubsub 32mb 8mb 60
。
相比其他消息队列,Redis
数据存在内存中,其性能更高,但其对消息积压的容忍性较低,因此设置了消息队列的最大长度,超出后会删除旧消息,导致消息丢失,因此需要结合业务需求选择是否使用redis stream
作为消息队列, 其适用于对消息丢失不敏感,不易出现消息堆积的轻量应用场景。
参考