0%

Redis数据类型

总结整理Redis常见数据类型、底层存储原理及应用。

Redis 基础数据结构包括:

  • String 字符串
  • Hash 哈希
  • List 列表
  • Set 集合
  • Zset 有序集合

随着Redis的版本迭代,又新增了四种数据类型:

  • BitMap 位图(V2.2
  • HyperLogLog 基数估算(V2.8
  • GEO 经纬度(V3.2
  • Stream消息队列(V5.0

1730627012642.png

String

Redis作为一种K-V存储系统,其Key为基本的字符串类型,其大小上限为512M,当然为了性能考虑最好不要设置太大。

StringRedis最基本的K-V数据结构,虽然Key一定是字符串,但Value并不局限于字符串类型,还可以是正整数、浮点数甚至二进制数据,其最大数据长度也是512M

存储原理

首先Redis采用一个RedisObject结构体来存储一个Value

typedef struct redisObject {
    // 类型
    unsigned type:4;
    // 编码
    unsigned encoding:4;
    // 对象最后一次被访问的时间
    unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
    // 引用计数
    int refcount;
    // 指向实际值的指针
    void *ptr;
} robj;

对于String类型,会将type设置为String,并根据存储的实际类型设置相应的encodingString具有三种编码方案:

  • int
  • raw
  • embstr

对于int型的数据,Redis会直接把它存储在ptr这个指针变量中,并强制转换为long类型

而对于其他类型的数据则可以使用SDSSimple Dynamic String,简单动态字符串)来存储,并将ptr指针指向该SDS对象地址,SDS由三部分构成:

struct sdshdr {
// buf 数组中已使用字节的数量,即 SDS 所保存字符串的长度
int len;
// buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};

相比C默认的char*,这种方式优势如下:

  1. 二进制安全,可保存二进制数据,不会像 C 那样对字符串内数据做假设(\0认为是结尾)
  2. 高效获取字符串长度,可直接使用len属性获取字符串长度,时间复杂度为O(1)
  3. 可扩容,可进行字符串拼接,如空间不够会自动扩容。

其中SDS扩容策略伪代码如下:

def sdsMakeRoomFor(sdshdr, required_len):

# 预分配空间足够,无须再进行空间分配
if (sdshdr.free >= required_len):
return sdshdr

# 计算新字符串的总长度
newlen = sdshdr.len + required_len

# 如果新字符串的总长度小于 SDS_MAX_PREALLOC
# 那么为字符串分配 2 倍于所需长度的空间
# 否则就分配所需长度加上 SDS_MAX_PREALLOC 数量的空间
if newlen < SDS_MAX_PREALLOC:
newlen *= 2
else:
newlen += SDS_MAX_PREALLOC

# 分配内存
newsh = zrelloc(sdshdr, sizeof(struct sdshdr)+newlen+1)

# 更新 free 属性
newsh.free = newlen - sdshdr.len

# 返回
return newsh

而对于不同长度的内容,redis也采用不同的编码方案(32字节的阈值为2.+版本,不同版本的设置也是不一样的):

  • 内容小于等于32字节时,使用embstr编码进行优化,仅通过一次内存分配来分配一块连续的内存空间存储redisObjectSDS,但这也会使得该数据大小不可变,如为其执行append等操作导致数据长度增加时,redis会将embstr转换成raw,再执行修改命令。

1730449078268.png

  • 内容大于32字节时,使用raw编码,通过两次内存分配来分配两块内存空间保存redisObjectSDS

    1730449087690.png

embstr这种方案优点如下:

  1. embstr编码将创建字符串对象所需的内存分配次数从 raw 编码的两次降低为一次
  2. 释放 embstr编码的字符串对象同样只需要调用一次内存释放函数;
  3. redisObjectSDS放到连续内存种可以更好地利用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列表可以实现从存储多个数据,并可以从两端插入(LPUSHRPUSH)或获取(LPOPRPOP),还支持范围查询LRANGE,以及阻塞查询BLPOP

1730529069002.png

存储原理

Redis 3.2之前采用双向链表和压缩列表存储:

  • 当列表的元素个数小于 512 个,列表每个元素的值都小于 64 字节,使用压缩列表ziplist作为 List 类型的底层数据结构
  • 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构

1730528905240.png

为什么在数据少时使用压缩列表呢?

因为 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 实际上是zipListlinkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。

1730528920913.png

应用场景

消息队列

List可以借助LPUSH + RPOP实现先进先出的数据存取,然而Redis并没有提供通知机制,消费端需要轮询是否有新数据,为了提高效率可以采用阻塞查询BRPOP。另一方面,由于没有ACK机制,POP出数据之后这条记录就没了,如果消费端拿到了数据但没有完成处理就崩溃了就会导致数据的丢失,为此redis提供了RPOPLPUSH指令,POP时会同步放到另一个List中做备份,如果出现故障可以去备份List中获取。

当然使用Redis List实现消息队列是比较基础和简陋的,无法实现消息组等功能,同时Redis宕机也会导致数据丢失。

Hash

Hash 是一个键值对(key - value)集合,适用于存储对象。HashString 对象的区别如下图所示:

1730530118905.png

存储原理

Redis 7.0Hash采用压缩列表和哈希表来实现

  • 如果哈希类型元素个数小于 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

PythonSet数据结构一样,这种数据类型是无序且无重复的,一个集合最多可以存储 2^32-1 个元素。除了支持集合内的增删改查,还支持交集、并集、差集等操作。

存储原理

Set采用哈希表或整数集合实现:

  • 若集合内元素均为整数且数量小于512个,会使用整数集合来存储
  • 若不满足上述条件,则采用哈希表进行存储

应用场景

借助Set无序,不重复,以及集合运算等特性,可以应用到统计点赞,共同好友,抽奖等场景。

Zset

ZsetSet的基础上增加了一个排序属性score,可以按照score进行排序。

存储原理

Zset 类型的底层数据结构是由压缩列表(Redis 7.0之后改为listpack)或跳表实现

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表深入理解Redis跳跃表的基本实现和特性)作为 Zset 类型的底层数据结构。

1730621654071.png

应用场景

借助有序的特性,可以实现排行榜功能,通过点赞不断更新相应Keyscore,维护从高到低的排行榜。

BitMap

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。

1730621828342.png

应用场景

可以应用在只有两种状态的记录场景,如记录用户的签到数据,判断用户的登录状态,用户的权限信息等。

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 GEORedis 3.2 版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作。可用于查询一定距离内的key,以及计算两个key的距离等。

存储原理

GEO 类型使用 GeoHash 将二维的经纬度数据编码成一维数据,再利用Zset进行有序索引。具体原理可参考Redis GEO & 实现原理深度分析一文。

Stream

Redis StreamRedis 5.0 版本新增加的数据类型,Redis 专门为消息队列设计的数据类型,相比基于List实现的消息队列,Stream可以支持:

  • 自动生成全局唯一 ID (生产者插入消息时会自动生成一个ID并返回)
  • 消息的持久化及ack 确认消息的模式,Stream 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Stream “消息已经处理完成”。未完成处理的消息,Redis会为之保存。
  • 消费组模式(使用 XGROUP命令即可创建消费组,一条消息可以供多个消费组消费)

Redis Stream 消息会丢失吗?

消息丢失可能发生在三个位置:生产者、队列中间件、消费者

  • 生产者,生产者只需要确保消息成功插入即可保证消息不丢失
  • 队列中间件redis的持久化机制导致数据不会实时落盘,因此宕机时会可能导致数据丢失,此外在进行主从复制时也可能导致数据的丢失。
  • 消费者,凭借消息的持久化及ack 确认消息的模式可以保证消息不丢失

Redis 发布/订阅机制为什么不可以作为消息队列?

  1. 发布/订阅机制没有基于任何数据类型实现,所以不具备「数据持久化」的能力,也就是发布/订阅机制的相关操作,不会写入到 RDB 和 AOF 中,当 Redis 宕机重启,发布/订阅机制的数据也会全部丢失。
  2. 发布订阅模式是“发后既忘”的工作模式,如果有订阅者离线重连之后不能消费之前的历史消息。
  3. 当消费端有一定的消息积压时,也就是生产者发送的消息,消费者消费不过来时,如果超过 32M 或者是 60s 内持续保持在 8M 以上,消费端会被强行断开,这个参数是在配置文件中设置的,默认值是 client-output-buffer-limit pubsub 32mb 8mb 60

相比其他消息队列,Redis数据存在内存中,其性能更高,但其对消息积压的容忍性较低,因此设置了消息队列的最大长度,超出后会删除旧消息,导致消息丢失,因此需要结合业务需求选择是否使用redis stream作为消息队列, 其适用于对消息丢失不敏感,不易出现消息堆积的轻量应用场景。

参考