0. 前言

本文主要讲解 redis 的五种基本数据类型:String、List、Set、Sorted Set、Hash。学习如何使用它们,并且了解它们的底层数据结构实现,这样我们才能在适当的应用场景选择最适合的数据类型来解决我们的需求。

1. String

1.1 简单使用

String 是 redis 最简单的且最常用的数据类型,可以用来存储字符串、整型以及浮点型。

  • 字符串
127.0.0.1:6379> set name zhangsan
OK
127.0.0.1:6379> get name
"zhangsan"
  • 整型

虽然 get age 时返回的是字符串,但是可以通过 object encoding age 看到其真正的类型。

127.0.0.1:6379> set age 18
OK
127.0.0.1:6379> get age
"18"
127.0.0.1:6379> object encoding age
"int"

# 自增
127.0.0.1:6379> incr age
(integer) 19

# 自减
127.0.0.1:6379> decr age
(integer) 18
  • 浮点型

写入的浮点型,但还是通过字符串来保存的,但是在使用的时候,会将字符串转换成浮点数。

127.0.0.1:6379> set height 1.77
OK
127.0.0.1:6379> get height
"1.77"
127.0.0.1:6379> object encoding height
"embstr"

# 浮点数操作
127.0.0.1:6379> incrbyfloat height 1
"2.77"

更多的操作请参考官网

1.2 数据编码

String 支持的编码类型有 int、embstr 和 raw,在不同条件时,会转换成对应的编码。

  • int
    当存储的数据为整型时,会使用 int 编码。其实现是,会直接将整型值存储在 redisObject 的 ptr(将 void* 转换成 long) 中,并且将字符串的对象的编码设置为 int。
  • raw
    该种编码是使用一种简单动态字符串(SDS)的数据结构存储,当字符串的长度大于 44 时,会使用该种数据编码方式。
  • embstr
    也是使用 简单动态字符串(SDS)的数据结构存储,是当字符串长度小于等于 44 时使用。

embstr 和 raw 的区别是什么呢?

创建 string 使用 raw 编码时,会调用两次内存分配来创建 redisObject 和 sds 的数据结构,而 embstr 只会调用一次来创建连续的内存空间来存储 redisObject 和 sds 。

当字符串较小时,embstr 少调用一次内存分配,释放也只需要一次,明显更快,并且连续的内存空间可以更好的利用 cpu 缓存。

1.3 简单动态字符串(SDS)

在数据编码中提到 embstr 和 raw 都是使用 SDS 数据结构,那么这种数据结构的是怎么样的,有什么好处呢?

数据结构如下图所示:

redis之五种基本数据类型_redis

struct sdshdr {
    //记录buf数组中已使用字节的数量,等于SDS所保存字符串的长度
    int len;
 
    //记录buf 数组中未使用字节的数量
    int free;
 
    //字节数组,用于保存字符串
    char buf[];
};
  • free: 表示还有多少空余空间
  • len: 已使用多少空间
  • buf: 存储字符串的数组

问题:String 为什么使用简单动态字符串来实现,而不是使用 C 传统字符串来实现呢?

  • 防止缓存区溢出

在扩充字符串时,需要考虑缓冲区是否足够,SDS 提供 API 来帮助我们判断并扩充空间。

  • 常数获取字符串长度

C 传统字符串需要遍历才能知道具体的长度,时间复杂度为 O(n),而 SDS 可以直接读 len 属性获取长度,时间复杂度为 O(1)

  • 减少内存分配的次数

在扩大字符串长度时,需要重新申请更大的内存空间,然后拷贝到新的内存空间中,然后再释放旧的内存空间。

在缩小时也是一样,需要申请新的空间,再释放旧的空间。

在 SDS 中可以通过以下的方式来减少内存分配和释放,提高效率。

  1. 空间预分配,在分配空间时,除了必要的空间外,可以额外的分配未使用的空间,在下次使用,可以快速使用这部分的空间,而不必重新分配。
  2. 惰性释放空间,在缩小字符串时,不需要马上释放多余的空间,可以预留给下次使用。
  • 二进制安全

C 语言中是以空字符来表示字符串的结束,而一些二进制文件内容可能是包含空字符串的,因此 C 语言字符串无法正确读取,所以 SDS 都是以二进制方式处理 buf,并且不以空字符串判断是否结束,而是用 len 来判断。

1.4 使用场景

  • 缓存,存储热点数据作为缓存,降低持久化数据库的读写压力。
  • 分布式锁
  • 计数器,可以使用 incr 命令

2. List

2.1 简单使用

命令

说明

lpush

将值从左边推入列表

rpush

将值从右边推入列表

lpop

将值从列表左边弹出返回

rpop

将值从列表右边弹出返回

lrange

根据索引查看列表中的数据

127.0.0.1:6379> lpush mylist 1 2 3
(integer) 3

127.0.0.1:6379> lrange mylist 0 -1
1) "3"
2) "2"
3) "1"

127.0.0.1:6379> rpush mylist 4
(integer) 4

127.0.0.1:6379> lrange mylist 0 -1
1) "3"
2) "2"
3) "1"
4) "4"

127.0.0.1:6379> lpop mylist
"3"

127.0.0.1:6379> lrange mylist 0 -1
1) "2"
2) "1"
3) "4"

127.0.0.1:6379> rpop mylist
"4"

127.0.0.1:6379> lrange mylist 0 -1
1) "2"
2) "1"

2.2 数据编码

在 redis3.2 之前使用的是 ziplistlinkedlist 两种编码方式。在数据量少的时候使用 ziplist,而数据多的时候使用 linkedlist。

而之后的版本使用的是 quicklist

127.0.0.1:6379> object encoding mylist
"quicklist"

2.3 压缩列表(ziplist)

该数据结构类似于一个数组,在数组的表头添加三个字段 zlbytes、zltail、zlen,再在表尾添加 zlend 表示列表结束。

redis之五种基本数据类型_字符串_02

  • zlbytes:内存字节数
  • zltail:列表尾偏移量
  • zllen:列表元素个数
  • zlend:列表结束标记

通过这几个字段可以快速定位第一个元素和最后元素,时间复杂度为 O(1)。

除了表头与表尾之外,其他元素都是节点,节点的数据结构如下图:

redis之五种基本数据类型_数据库_03

  • previous_entry_length : 上一个节点的长度。可以推算出上一个节点的地址
  • encoding: 该节点的数据类型及长度
  • content: 节点的值

连锁更新问题

对于 previous_entry_length:

  • 前一个节点长度小于 254 字节,previous_entry_length 长度需要用 1 字节保存长度值
  • 前一个节点长度大于 254 字节,previous_entry_length 长度需要用 5 字节保存长度值

有这一种场景,如果压缩列表中有多个连续的节点长度在 250-253 字节之间,这些节点 previous_entry_length 使用 1 个字节存储大小。

  • 当在最前方添加一个节点大于等于 254 字节的节点时,后面的节点 previous_entry_length 都需要从 1 字节扩充到 5 字节,起到连锁更新,后面的节点都需要更新。

redis之五种基本数据类型_字符串_04

  • 当删除其中 small 节点时,其后节点 previous_entry_length 需要保存前面的 big 节点,需要扩充节点空间,并且后面发生连锁更新

redis之五种基本数据类型_redis_05

虽然连锁更新复杂度很高,但是造成的可能性很低。

  • 长度需要在 250 到 253 字节之间
  • 被更新的节点数量不多,也不会造成性能影响

优点

  • 内存地址连续,减少了内存碎片化,可以使用到缓存进行优化
  • 如果是双向链表,节点的头尾都需要指针来指向上一个或者下一个节点,而压缩列表省去了该部分内存空间的占用。

缺点

  • 对于删除和插入操作都需要移动该节点位置后面的元素,并且可能会触发连锁更新反应

2.4 快速列表(quicklist)

quicklist 是压缩列表和双向链表结合实现的,在宏观上它是一个双向链表,然后其每个结点上都挂了一个 ziplist。如下图所示:

redis之五种基本数据类型_字符串_06

ziplist 在列表数量大的时候性能会下降,很难分配一大块连续的内存空间,并且在删除和插入操作性能下降的更为厉害。

而双向链表会导致大量的碎片化空间,而且每个节点的头尾指针都会占用一定的内存空间。

而 quicklist 算是在两者之间取得了一个平衡。

2.5 使用场景

  • 消息队列
  • 列表及分页展示

3. Set

是一个无序的集合,集合元素是唯一的,会自动对添加的数据进行去重。

3.1 简单使用

命令

说明

SADD

向集合添加一个或多个元素

SCARD

获取集合的数量

SMEMBERS

获取集合全部元素

SISMEMBER

判断集合中是否指定元素

127.0.0.1:6379> sadd myset zheng wen feng zheng
(integer) 3

127.0.0.1:6379> scard myset
(integer) 3

127.0.0.1:6379> smembers myset
1) "zheng"
2) "feng"
3) "wen"

127.0.0.1:6379> sismember myset feng
(integer) 1

3.2 数据编码

该数据类型使用数据编码的是 intset 或者 hashtable,当 set 满足以下条件时,使用的 inset,其他情况都是 hashtable。

  • 当所有的元素都是整数值时
  • 元素个数不超过 512 个时
127.0.0.1:6379> sadd myset2 1 2 4 4
(integer) 3

127.0.0.1:6379> SMEMBERS myset2
1) "1"
2) "2"
3) "4"

127.0.0.1:6379> OBJECT ENCODING myset2
"intset"

3.3 整型数组(intset)

intset 数据结构如下所示:

typedef struct intset {
    // 编码方式
    uint32_t encoding;
    // 集合包含的元素数量
    uint32_t length;
    // 保存元素的数组
    int8_t contents[];
} intset;
  • encoding:contents 中存储的数据类型取决于该字段
  • length: 数组长度
  • contnets: 存储数据的整型数组,数据的数据类型取决于 encoding,从小到大排序

contents 数组中每一个元素的类型都是由 encoding 决定的,但是当原来的数据类型是 int16 时,现在要插入一个 int32 时,该如何操作呢?

升级

这个时候需要对 contents 中的每个元素都进行升级:

  • 根据新元素的类型,扩大 contents 数组的空间大小
  • 将数组的所有元素转换成新元素相同的类型并放入数组中
  • 最后改变 encoding 的值

这么做的好处是,当所有的数据都是小整型时,可以节省内存的开销。

目前不支持降级。

3.4 使用场景

  • 标签,给用户打上标签,以标签为 key,用户 ID 为 value 放入 set 中
  • 点赞,收藏等,利用 set 的唯一特性。

4. Sorted Set

是一个不允许重复元素的集合,但是每个元素会关联一个分数,可以通过分数对集合进行排序。

4.1 简单使用

命令

说明

ZADD

向集合添加一个或多个元素

ZRANGE

根据位置查询获取集合多个元素

ZREM

如果元素存在集合中,则进行删除

127.0.0.1:6379> zadd myzset 100 zheng 99 wen 80 feng
(integer) 3

127.0.0.1:6379> zrange myzset 0 -1
1) "feng"
2) "wen"
3) "zheng"

127.0.0.1:6379> zadd myzset 91 hello
(integer) 1

# 默认是根据score升序查询
127.0.0.1:6379> zrange myzset 0 -1
1) "feng"
2) "hello"
3) "wen"
4) "zheng"

127.0.0.1:6379> zrem myzset wen
(integer) 1

127.0.0.1:6379> zrange myzset 0 -1
1) "feng"
2) "hello"
3) "zheng"

4.2 数据编码

该类型的数据编码可以为 ziplist 或者 skiplist,当满足以下条件时,使用的 ziplist,其他情况是 skiplist

  • 元素数量小于 128
  • 搜索元素长度小于 64

当数据编码为 skiplist 时,redis 中使用的存储结构是 zset 数据结构,其中包含了 zskiplist dict

typedef struct zset {
    zskiplist *zs1;
    dict *dict;
}

通过 zskiplist 可以快速的范围查询,dict 可以快速定位单个元素。

4.3 跳表(skiplist)

在有序的链表上添加了多级索引,通过索引位置的跳转,实现数据的快速定位。如下图所示:

redis之五种基本数据类型_字符串_07

优点:

  1. 在通过范围查询或排序时,可以在跳表中先找到最小值,然后再在最底层链表中遍历获取。

4.4 使用场景

  • 排行榜,通过给视频、文章打分,然后进行排序展示

5. hash

用于存储 string 类型的键值对,适用于存储对象。

5.1 简单使用

命令

说明

hset

设置键值对

hget

获取指定 hash 的键对应的值

hgetall

获取指定 hash 中的所有键值对

hdel

删除指定 hash 中的键值对

127.0.0.1:6379> hset person name zhangsan
(integer) 1

127.0.0.1:6379> hset person age 18
(integer) 1

127.0.0.1:6379> hget persion name
"zhangsan"

127.0.0.1:6379> hgetall person
1) "name"
2) "zhangsan"
3) "age"
4) "18"

127.0.0.1:6379> hdel person age
(integer) 1

127.0.0.1:6379> hgetall person
1) "name"
2) "zhangsan"

5.2 数据编码

使用的数据编码是 ziplisthashtable,满足以下条件选择使用 ziplist,其他情况使用 hashtable

  • hash 对象保存的键和值字符串长度都小于 64 字节
  • hash 对象保存的键值对数量小于 512

5.3 使用场景

  • 用来存储对象信息

6. 总结

redis 之所以快,正是因为其有着丰富的数据结构,所以我们需要理解它们,在设计方案时,就能正确的选择数据类型来实现我们的业务需求。