首页 > 技术文章 > 3. Redis有哪些数据类型?

traditional 2020-07-10 14:28 原文

Redis 的五种常见数据结构

Redis 的数据类型可谓是 Redis 的精华所在,同样的数据类型,但不同的值对应的存储结构也是不同的。比如:当你存储一个短字符串(小于 44 字节),实际存储的结构是 embstr;长字符串对应的实际存储结构是 raw,这样设计的目的就是为了更好的节约内存。

那么 Redis 都有哪些数据类型呢?

最常用的数据类型有 5 种:String(字符串类型)、Hash(字典类型)、List(列表类型)、Set(集合类型)、ZSet(有序集合类型),那么这些数据类型都支持哪些操作呢?我们来一一介绍。当然 Redis 支持的数据结构不止上面五种,还有几个更高级的数据结构,我们后面介绍,但是最常用的还是上面五种。

不过我们首先要安装 Redis,最方便的做法是采用 yum 安装,或者采用 docker 安装,由于过程比较简单就不说了。但这两种做法我们都不用,这里我们选择下载源码包、然后编译安装。可以直接去 https://download.redis.io/releases/ 这个网址进行下载,Redis 所有的发布版本都在里面,我下载的是 6.0.5。然后丢到 Linux 服务器上(我使用的是阿里云 CentOS)进行安装即可,命令如下:

tar -zxvf redis-6.0.5.tar.gz
cd redis-6.0.5
make && make install

可以看到安装过程非常简单,但是上面的命令先别急着执行,还有一些细节没有说。由于 Redis 是使用 C 语言编写的,所以编译它需要 gcc 环境,并且对于 6.0 以上的 Redis  需要的也是高版本的 gcc。

可以看到,我当前的 gcc 版本是不高的,如果直接 make && make install 的话,是会出现编译错误的,所以我们需要升级 gcc。

yum -y install centos-release-scl
yum -y install devtoolset-9-gcc devtoolset-9-gcc-c++ devtoolset-9-binutils
scl enable devtoolset-9 bash
echo "source /opt/rh/devtoolset-9/enable" >>/etc/profile

然后查看 gcc 版本,会发现已经高版本了。

接下来就可以编译 Redis 了,解压、进入 Redis 主目录、执行 make && make install。安装完毕之后,redis 的相关可执行文件都在 /usr/local/bin 目录下

我们直接输入 redis-server 即可启动 redis,但这种启动方式会选择前台启动,为了方便我们选择后台启动,所以需要修改配置文件 redis.conf,该文件位于 Redis 主目录中,我们将其拷贝到 /etc 目录下。

cp /root/redis-6.0.5/redis.conf /etc

redis.conf 里面的配置项非常多,后续会详细介绍,这里先修改两个,vim /etc/redis.conf:

# 将 bind 从 127.0.0.1 改成 0.0.0.0,因为我们不仅要本机访问,后续还要通过 Python 和 Go 访问
bind 0.0.0.0  
# 将 daemonize 改成 yes,选择后台启动
daemonize yes

保存之后启动 Redis,启动方式:redis-server /etc/redis.conf,这里是指定配置文件启动。当然启动的时候也可以不指定配置文件,直接把要修改的配置写在命令行里面也是可以的,比如:

redis-server --bind 0.0.0.0 --daemonize yes

但这种方式适用于修改的配置项不多的时候,如果要修改的配置项很多,那么还是建议修改配置文件,然后通过指定配置文件启动。但如果某个配置项在命令行和配置文件中都出现了,那么会以命令行为准。

这里我们就统一以配置文件的方式启动了,但启动之后,我们需要了解一下 Redis 的前置知识。

  • Redis 默认有16个数据库,数据库名类似于数组的下表,从 0 到 15,默认使用 0 号库。
  • select:切换数据库,比如 select 1 就表示切换到 1 号库
  • 统一密码管理,16 个库都是一样的密码,要么都 ok 要么都连接不上。
  • 默认端口是 6379,当然可以通过配置文件修改

下面就来看一下 Redis 中常见的数据结构。

Redis 字符串 (String)

Redis 的 string 类型,是一个 key 对应一个 value,并且底层是使用自己内部实现的简单动态字符串(SDS)来表示 String 类型,没有直接使用 C 语言定义的字符串类型。

struct sdshdr{
    // 记录 buf 数组中已使用字节的数量
    // 等于 SDS 保存的字符串的长度
    int len;
    // 记录 buf 数组的总长度
    int alloc;
    // 字节数组,用于保存字符串
    char buf[];
}

然后我们来看看其支持的 api 操作。

 

set key value:给指定的 key 设置 value

127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> set word "hello world"
OK  # 如果字符串之间有空格,我们可以使用双引号包起来
# 对同一个 key 多次设置 value 相当于更新,会保留最后一次设置的 value

设置成功之后会返回一个 ok,表示设置成功。除此之外,set 还可以指定一些可选参数。

  • set key value ex 60:设置的时候指定过期时间为 60 秒,等价于 setex key 60 value
  • set key value px 60:设置的时候指定过期时间为 60 毫秒,等价于 psetex key 60 value
  • set key value nx:只有 key 不存在的时候才会设置,存在的话会设置失败,而如果不加 nx 则会覆盖。等价于 setnx key value
  • set key value xx:只有 key 存在的时候才会设置,不存在的话会设置失败。注意:没有 setxx key value

我们发现默认参数使用 set 足够了,因此未来可能会移除 setex、psetex、setnx。另外,我们可以同一个 key 多次 set,相当于对原来的值进行了覆盖。

 

get key:获取指定 key 对应的 value

127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get word
"hello world"
127.0.0.1:6379> get age
(nil)

如果 key 不存在,那么返回 nil,也就是 C 语言中的 NULL,Python 中的 None、Go 里的 nil。存在的话,则返回 key 对应的 value。

 

del key1 key2···:删除指定 key,可以同时删除多个

127.0.0.1:6379> set age 28
OK
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get age
"28"  # 虽然我们设置的是一个数值,但是在 Redis 中都是字符串格式
127.0.0.1:6379> del name age gender
(integer) 2  # 会返回删除的 key 的个数,表示有效删除了两个,而 gender 不存在,因此无法删除一个不存在的 key
127.0.0.1:6379> get name
(nil)
127.0.0.1:6379> get nil
(nil)
127.0.0.1:6379> 

 

append key value:追加

如果 key 存在,那么会将 value 的值追加到 key 对应的值的末尾;如果不存在,那么会重新设置,等价于于 set key value。

127.0.0.1:6379> set name han
OK
127.0.0.1:6379> set age 2
OK
127.0.0.1:6379> append name ser
(integer) 6  # 返回拼接之后的字符数量
127.0.0.1:6379> append age 8
(integer) 2  # 按照字符串的格式拼接
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> get age
"28"
127.0.0.1:6379> append gender female
(integer) 6  # gender 不存在,相当于重新设置,或者理解为往一个空字符后面追加也行
127.0.0.1:6379> get gender
"female"
127.0.0.1:6379> 

 

strlen key:查看对应 key 的长度

127.0.0.1:6379> strlen name
(integer) 6
127.0.0.1:6379> strlen age
(integer) 2
127.0.0.1:6379> strlen not_exists
(integer) 0  # 不存在的 key 返回 0
127.0.0.1:6379> 

 

incr key:为 key 存储的值自增 1,必须可以转成整型,否则报错。如果不存在 key,默认先设置该 key 值为 0,然后自增 1

127.0.0.1:6379> get age
"28"
127.0.0.1:6379> incr age
(integer) 29  # 返回自增后的结果
127.0.0.1:6379> get age
"29"
127.0.0.1:6379> incr age1 
(integer) 1
127.0.0.1:6379> get age1
"1"
127.0.0.1:6379> incr name
(error) ERR value is not an integer or out of range
127.0.0.1:6379> 

 

decr key:为 key 存储的值自减 1,必须可以转成整型,否则报错。如果不存在 key,默认先设置该 key 值为 0,然后自减 1

127.0.0.1:6379> decr age
(integer) 28
127.0.0.1:6379> decr age2
(integer) -1
127.0.0.1:6379> 

 

incrby key number:为 key 存储的值自增 number,必须可以转成整型,否则报错,如果不存在的话,默认先将该值设置为 0,然后自增 number

127.0.0.1:6379> incrby age 20
(integer) 48
127.0.0.1:6379> incrby age3 5
(integer) 5
127.0.0.1:6379> 

 

decrby key number:为 key 存储的值自减 number,必须可以转成整型,否则报错,如果不存在的话,默认先将该值设置为 0,然后自减 number

127.0.0.1:6379> decrby age 20
(integer) 28
127.0.0.1:6379> decrby age4 5
(integer) -5
127.0.0.1:6379> decrby age4 -5
(integer) 0  # 指定负数也是可以的,同理 incrby 也是如此
127.0.0.1:6379> 

 

getrange key start end:获取指定 value 的同时指定范围,第一个字符为 0,最后一个为 -1。注意:redis 中的索引都是包含结尾的,不管是这里的 getrange,还是后面的列表操作,索引都是包含两端的。

127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> getrange name 0 -1
"hanser"
127.0.0.1:6379> getrange name 0 4
"hanse"
127.0.0.1:6379> getrange name -3 -1
"ser"
127.0.0.1:6379> getrange name -3 10086
"ser"
127.0.0.1:6379> getrange name -3 -4
""
127.0.0.1:6379> 

我们看到,索引是可以从后往前数,但是只能从前往后、不能从后往前获取。也就是 getrange word -1 -3 是不可以的,会返回一个空字符串,因为 -1 在 -3 的后面。

 

setrange key start value:从索引为 start 的地方开始,将 key 对应的值替换为 value,替换的个数等于 value 的个数。

127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> setrange name 0 you
(integer) 6  # 从索引为0的地方开始替换,替换三个字符,因为我们指定了3个字符
127.0.0.1:6379> get name
"youser"
127.0.0.1:6379> setrange name 10 you
(integer) 13  # 从索引为10的地方开始替换,但是字符串索引最大为6,因此会使用\x00填充
127.0.0.1:6379> get name
"youser\x00\x00\x00\x00you"
127.0.0.1:6379> setrange myself 3 gagaga
(integer) 9  # 对于不存在的key也是如此
127.0.0.1:6379> get myself
"\x00\x00\x00gagaga"
127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> setrange name 0 "han han han han"
(integer) 15  # 替换的字符串长度比相应的key长没有关系,会自动扩充
127.0.0.1:6379> get name
"han han han han"

 

mset key1 value1 key2 value2:同时设置多个 key value

这是一个原子性操作,要么都设置成功,要么都设置不成功。注意:这些都是会覆盖原来的值的,如果不想这样的话,可以使用 msetnx,这个命令只会在所有的 key 都不存在的时候才会设置。

mget key1 key2:同时返回多个 key 对应的 value

如果有的 key 不存在,那么返回 nil。

127.0.0.1:6379> get name
"han han han han"
127.0.0.1:6379> mset name hanser age 28
OK
127.0.0.1:6379> mget name age
1) "hanser"
2) "28"
127.0.0.1:6379> 

 

getset key value:先返回 key 的旧值,然后设置新值

127.0.0.1:6379> getset name yousa
"hanser"
127.0.0.1:6379> get name
"yousa"
127.0.0.1:6379> getset ping pong
(nil)
127.0.0.1:6379> get ping
"pong"
127.0.0.1:6379> 

如果有的 key 不存在,那么返回 nil,然后设置。

 

另外,Redis 中还有很多关于 key 的操作,这些操作不是专门针对 string 结构的,但是有必要提前说一下。

首先在 Redis 中,string、list、hash、set、zset 都有自己的 key,key 不可以重名,比如有一个 key 为 name 的 string,那么就不可以再有一个 key 还为 name 的 list。因为在 Redis 内部会维护一个全局的哈希表,存放所有的 key、value,而哈希表里面的 key 是不重复的。

key 是一个 string,而 value 可以是 Redis 中任意的一种数据结构,而全局哈希表则负责维护所有的 key value。

 

keys pattern:查看所有名称满足 pattern 的 key,至于 key 对应的 value 则可以是 Redis 的任意类型

127.0.0.1:6379> keys *  # 查看所有的 key
 1) "gender"
 2) "word"
 3) "ping"
 4) "name"
 5) "age2"
 6) "age1"
 7) "age"
 8) "myself"
 9) "age3"
10) "age4"
127.0.0.1:6379> keys *a*  # 查看包含 a 的 key
1) "name"
2) "age2"
3) "age1"
4) "age"
5) "age3"
6) "age4"
127.0.0.1:6379> keys age?  # 查看以 age 开头、总共 4 个字符的 key
1) "age2"
2) "age1"
3) "age3"
4) "age4"

 

exists key:判断某个 key 是否存在

127.0.0.1:6379> exists name
(integer) 1
127.0.0.1:6379> exists name1
(integer) 0  # 存在返回 1,不存在返回 0
127.0.0.1:6379> exists name name1
(integer) 1  # 也可以指定多个 key,返回存在的 key 的个数,但是此时无法判断到底是哪个 key 存在

 

ttl key:查看还有多少秒过期,-1 表示永不过期,-2 表示已过期

127.0.0.1:6379> ttl name
(integer) -1  # -1 表示永不过期
127.0.0.1:6379> ttl name1
(integer) -2  # -2 表示已经过期
127.0.0.1:6379> 

key 是可以设置过期时间的,如果过期了就不能再用了。我们看到 name1 这个 key 压根就不存在,返回的也是 -2,因为过期了就相当于不存在了。而 name 是 -1,表示永不过期。

 

expire key 秒钟:为给定的 key 设置过期时间

127.0.0.1:6379> expire name 60
(integer) 1  # 设置 60s,设置成功返回 1
127.0.0.1:6379> ttl name
(integer) 55  # 查看时间,还剩下 55 秒
127.0.0.1:6379> expire name1 60
(integer) 0  # name1 不存在,设置失败,返回 0
127.0.0.1:6379> 

这里设置 60s 的过期时间,另外设置完之后,在过期时间结束之前是可以再次设置的,比如我先设置了 60s,然后快结束的时候我再次设置 60s,那么还会再持续 60s。

 

type key:查看你的 key 是什么类型

127.0.0.1:6379> type name
none  # name过期了,相当于不存在了,因此为none
127.0.0.1:6379> type age
string  # 类型为string
127.0.0.1:6379>

 

move key db:将 key 移动到指定的 db 中

127.0.0.1:6379> flushdb
OK  # 清空当前库,如果是清空所有库,可以使用 flushall,当然后面都可以加上 async,表示异步删除,我们前面说过的
127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> keys *
1) "name"
127.0.0.1:6379> move name 3
(integer) 1  # 将 name 移动到索引为3的库中
127.0.0.1:6379> keys *
(empty array)  # 此时当前库已经没有 name 了
127.0.0.1:6379> select 3
OK  # 切换到索引为 3 的库中
127.0.0.1:6379[3]> keys *
1) "name"  # keys * 查看,发现 name 已经有了
127.0.0.1:6379[3]> select 0  # 切换回来
OK
127.0.0.1:6379> 

 

那么字符串这种数据结构可以用在什么地方呢?

我们讨论完字符串的相关操作,那么我们还要理解字符串要用在什么地方。

首先字符串类型的使用场景有很多,但从功能的角度来区分,大致可分为以下两种:

  • 1. 字符串存储和操作;
  • 2. 整数类型和浮点类型的存储和计算。

其最常用的业务场景大致分为以下几个。

1. 页面数据缓存

我们知道,一个系统最宝贵的资源就是数据库资源,随着公司业务的发展壮大,数据库的存储量也会越来越大,并且要处理的请求也越来越多,当数据量和并发量到达一定级别之后,数据库就变成了拖慢系统运行的 “罪魁祸首”,为了避免这种情况的发生,我们可以把查询结果放入缓存(Redis)中,让下次同样的查询直接去缓存系统取结果,而非查询数据库,这样既减少了数据库的压力,同时也提高了程序的运行速度。

画一张图来说明一下:

2. 数据计算与统计

Redis 可以用来存储整数和浮点类型的数据,并且可以通过命令直接累加并存储整数信息,这样就省去了每次先要取数据、转换数据、拼加数据、再存入数据的麻烦,只需要使用一个命令就可以完成此流程。比如:微博、哔哩哔哩等社交平台,我们经常会点赞,然后还有点赞数。每点一个赞,点赞数就加 1,这个功能就完全可以交给 Redis 实现。

3. 共享 Session 信息

通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用。

例如用户 A 的 Session 信息被存储在第一台服务器,但第二次访问时用户 A 的请求被分配到第二台服务器,这个时候该服务器并没有用户 A 的 Session 信息,就会出现需要重复登录的问题。由于分布式系统每次会把请求随机分配到不同的服务器,因此我们需要借助缓存系统对这些 Session 信息进行统一的存储和管理,这样无论请求发送到哪台服务器,服务器都会去统一的缓存系统获取相关的 Session 信息,这样就解决了分布式系统下 Session 存储的问题。

  • 分布式系统单独存储Session

  • 分布式系统使用统一的缓存系统存储Session

虽然这确实是 Redis 使用场景之一,只不过在现在的 web 开发中已经很少会使用共享 session 的方式了。

使用 Python 操作 Redis 字符串

下面看看如何使用 Python 操作 Redis 字符串,首先 Python 想操作 Redis 需要使用一个第三方库,也叫 redis,直接 pip install redis 即可。

安装完毕之后,我们来操作一波。

import redis

# 获取 value 时得到的默认是字节,指定 decode_responses,会自动进行解码
# 当然里面还有许多其它参数,但基本上都是见名知意,可以点击源码中看一下
# 比如端口不是 6379,那么就通过 port 参数指定端口,有密码的话就使用 password 参数指定密码,还有连接超时时间等等
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 1. set key value
client.set("name", "yousa", ex=None, px=None, nx=False, xx=False)

# 2. get key
print(client.get("name"), client.get("age"))  # yousa None

# 3. del key1 key2 ...
print(client.delete("name", "age"))  # 1

# 4. apend key value
client.set("name", "han")
print(client.append("name", "ser"))  # 6
print(client.get("name"))  # hanser

# 5. strlen key
print(client.strlen("name"))  # 6

# 6. incr key
client.set("age", 28)
# incr key 其实等价于 incrby key 1,因此在这里两个命令都是通过 incr 实现
# 第二个参数为 1,是默认值,当然我们也可以自己指定
client.incr("age", 1)
print(client.get("age"))  # 29

# 7. decr key
client.decr("age", 10)
print(client.get("age"))  # 19

# 8. getrange key start end
print(client.getrange("name", -3, -1))  # ser

# 9. setrange key start value
client.setrange("name", 3, "sa")
print(client.get("name"))  # hansar

# 10. mset key1 value1 key2 value2
client.mset({"name": "yousa", "age": 20})

# 11. mget key1 key2
print(client.mget(["name", "age", "gender"]))  # ['yousa', '20', None]

# 12. getset key value
print(client.getset("name", "hanser"))  # yousa
print(client.get("name"))  # hanser

# 13. keys pattern
print(client.keys("*"))  # ['name', 'age']

# 14. exists key
print(client.exists("name"), client.exists("ping"))  # 1 0

# 15. ttl key
print(client.ttl("name"))  # -1

# 16. expire key 秒钟
client.expire("name", 60) 
import time; time.sleep(2)
print(client.ttl("name"))  # 58

# 17. type key
print(client.type("name"))  # string

# 18. move key db
client.move("name", 15)
print(client.get("name"))  # None
# 需要重新连接,连接到 15 号库
print(redis.Redis(host="47.94.174.89", decode_responses="utf-8", db=15).get("name"))  # hanser

我们看到,和 Redis 命令之间是几乎没有什么区别的。

使用 Go 操作 Redis 字符串

下面再来看看如何使用 Go 操作 Redis 字符串,而 Go 想操作 Redis 也需要使用相应的第三方库,因为 Go 标准库没有提供连接 Redis 的包。

# 设置代理,因为从 GitHub 上拉取代码比较慢
go env -w GOPROXY=https://goproxy.cn,direct
# 下载连接 Redis 的第三方库 go-redis
go get github.com/go-redis/redis/v8

注意:如果是高版本的 go-redis,那么需要你初始化一个 go module 之后才可以使用。

# 启用 go module 模式
go env -w GO111MODULE=on
# 在工程目录中初始化一个 module,"go mod init 随便起个名字",
go mod init redis
go mod tidy

此时在你的工程目录中会有一个 go.mod 文件,内容如下:

module redis

go 1.16

然后我们把需要的包加进去即可,比如:

module redis

go 1.16

require github.com/go-redis/redis/v8 v8.11.1

只有当包以上面这种方式加入到 go.mod 中才可以使用,加入方式:在自己的工程目录中输入 go get github.com/go-redis/redis/v8 即可,和下载对应的命令一样。当然,如果你用的是 Goland 这种智能编辑器的话,也可以不用手动做这一步,只需要直接导入即可,会自动将依赖加入到 go.mod 中,前提是相关的依赖包你已经安装了。

但不得不说,Go 采用 GitHub 做包管理工具真的是让人难受。

下面就来操作一波,但是注意:之前为了方便,Redis 没有设密码,结果阿里云服务器遭到恶意脚本投递了,所以这里设置密码,通过配置 requirepass 指定。

# 将密码设置成 "satori"
requirepass satori

Redis 的 6379 端口真的非常容易遭到入侵,在生产环境中 Redis 的端口不要对外开放,如果真的要对外开放使用,那么一定要设置密码,并且最好把监听的端口从 6379 改成别的。这里我们设置个密码吧,端口就不改了。

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
    "time"
)

func main() {
    // 设置连接参数,其它参数可以进入源码中查看,注释非常详细
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    // 创建连接 Redis Server 的客户端
    client := redis.NewClient(&options)
    // 创建 Context 对象
    ctx := context.Background()
    // 下面开始发送请求

    {
        // 设置键值对,参数一:Context 对象,参数二:key,参数三:value,参数四:过期时间
        // Go 里面设置过期时间统一用纳秒表示,这里设置 30 秒后过期,-1 表示永不过期
        client.Set(ctx, "name", "matsuri", time.Second*30)
        // 不像 Python,Go 没有关键字参数,所以把 nx 和 xx 放在 Set 函数里面会不方便
        // 为此 go-redis 专门提供了两个函数 SetNX 和 SetXX,虽然 Redis 命令没有 setxx,但是这里提供了 SetXX 函数
        client.SetNX(ctx, "age", 17, -1)
    }

    {
        // 获取键对应的值,这里会返回 *StringCmd,里面提供了很多方法
        cmd := client.Get(ctx, "name")
        // 获取 value
        fmt.Println(cmd.Val()) // matsuri
        // 如果 key 不存在那么会返回空字符串,这样我们就无法判断到底是 key 不存在,还是 value 本身就是空字符串
        // 所以可以使用 result.Result(),会返回 value 和 error,然后通过 error 进行判断
        value, err := cmd.Result()
        fmt.Println(value, err) // matsuri <nil>
        // 即便设置的是整型,那么得到的也是一个字符串
        fmt.Println(client.Get(ctx, "age").Val() == "17") // true
        // 所以我们可以调用 Int(),会自动转换,由于可能转换失败,所以还会返回一个 error
        fmt.Println(client.Get(ctx, "age").Int()) // 17 <nil>
        // 除了 Int(),还有 Float32()、Float64()、Bool() 等等
        _, err = client.Get(ctx, "age").Bool()
        // 这里转化失败,字符串 17 无法转为 bool 类型
        fmt.Println(err) // strconv.ParseBool: parsing "17": invalid syntax

    }

    {
        // 删除 key,返回 *IntCmd
        cmd := client.Del(ctx, "name", "age", "gender")
        fmt.Println(cmd.Result())  // 2 <nil>
    }

    {
        // apend key value
        client.Set(ctx, "name", "han", -1)
        // 所有的操作都会返回一个 *...Cmd,然后调用 Result 拿到结果
        fmt.Println(client.Append(ctx, "name", "ser").Result())  // 6 <nil>
        fmt.Println(client.Get(ctx, "name").Result())  // hanser <nil>
    }

    {
        // strlen key
        fmt.Println(client.StrLen(ctx, "name").Result()) // 6 <nil>
    }

    {
        // incr key
        client.Set(ctx, "age", 28, -1)
        client.Incr(ctx, "age")
        client.IncrBy(ctx, "age", 10)
        fmt.Println(client.Get(ctx, "age").Result())  // 39 <nil>
    }

    {
        // decr key
        client.Decr(ctx, "age")
        client.DecrBy(ctx, "age", 10)
        fmt.Println(client.Get(ctx, "age").Result())  // 28 <nil>
    }

    {
        // getrange key start end
        fmt.Println(client.GetRange(ctx, "name", -3, -1).Result())  // ser <nil>
    }

    {
        // setrange key start value
        client.SetRange(ctx, "name", 3, "sa")
        fmt.Println(client.Get(ctx, "name").Result())  // hansar <nil>
    }

    {
        // mset key1 value1 key2 value2
        client.MSet(ctx, "name", "yousa", "age", 20)
        // mget key1 key2
        fmt.Println(client.MGet(ctx, "name", "age").Result())  // [yousa 20] <nil>
        fmt.Println(client.MGet(ctx, "name", "age1").Result())  // [yousa <nil>] <nil>
    }

    {
        // getset key value
        fmt.Println(client.GetSet(ctx, "name", "hanser").Result())  // yousa <nil>
        fmt.Println(client.Get(ctx, "name").Result())  // hanser <nil>
    }

    {
        // keys pattern
        fmt.Println(client.Keys(ctx, "*").Result())  // [age name]
        // exists key,返回 1 表示存在,返回 0 表示不存在
        fmt.Println(client.Exists(ctx, "name").Result())  // 1 <nil>
        fmt.Println(client.Exists(ctx, "name1").Result())  // 0 <nil>
    }

    {
        // ttl key
        fmt.Println(client.TTL(ctx, "name").Result())  // -1ns <nil>
        // expire key
        client.Expire(ctx, "name", time.Second * 60)
        time.Sleep(time.Second * 2)
        fmt.Println(client.TTL(ctx, "name").Result())  // 58s <nil>
    }

    {
        // type key
        fmt.Println(client.Type(ctx, "name").Result())  // string <nil>
    }

    {
        // move key db
        client.Move(ctx, "name", 15)
        if _, err := client.Get(ctx, "name").Result(); err != nil {
            fmt.Println("key 不存在")  // key 不存在
        }

        // 重新连接 15 号库
        options.DB = 15
        client = redis.NewClient(&options)
        fmt.Println(client.Get(ctx, "name").Result())  // hanser <nil>
    }
}

由于 Go 是静态语言,所以操作起来会稍微复杂一些,不过这些没有必要刻意去记,借助于 Goland IDE 自动提示即可,返回值以及相应的方法也可以跳转到源码中查看,这就是静态语言的好处。因为不管代码多复杂,通过 IDE 都能一层一层地找下去。

Redis 列表 (List)

列表类型(List)是一个使用链表结构存储的有序结构,它的元素插入会按照先后顺序存储到链表结构中,因此它的元素操作(插入、删除)时间复杂度为 \(O(1)\),所以相对来说速度还是比较快的,但它的查询时间复杂度为 \(O(n)\),因此查询可能会比较慢。

Redis 中的列表和字符串比较类似,只不过字符串是一个 key 对应一个 value、获取的时候直接通过 key 来获取;而列表是一个 key 对应的多个 value、获取的时候通过 key + 索引 来获取。

 

下面我们来看看它所支持的 api 操作

lpush key value1 value2 ...:将多个值设置到列表里面,从左边 push

rpush key value1 value2 ...:将多个值设置到列表里面,从右边 push

127.0.0.1:6379> lpush girls mashiro koishi
(integer) 2  # 返回插入成功之后,列表的元素个数。这里是 lpush,所以此时列表内的元素是 koishi mashiro
127.0.0.1:6379> rpush girls satori
(integer) 3
127.0.0.1:6379> 

lrange key start end:遍历列表,索引从 0 开始,最后一个为 -1,且包含两端

127.0.0.1:6379> lrange girls 0 -1
1) "koishi"
2) "mashiro"
3) "satori"
127.0.0.1:6379> lrange girls 0 2
1) "koishi"
2) "mashiro"
3) "satori"
127.0.0.1:6379> lrange girls 0 1
1) "koishi"
2) "mashiro"
127.0.0.1:6379> lrange lst 0 -1
(empty array)  # 对不存在的列表使用 lrange,会得到空数组

lpop key:从列表的左端弹出一个值,列表长度改变

rpop key:从列表的右端弹出一个值,列表长度改变

127.0.0.1:6379> lpop girls
"koishi"
127.0.0.1:6379> rpop girls
"satori"
127.0.0.1:6379> lrange girls 0 -1
1) "mashiro"
127.0.0.1:6379> 

lindex key index:获取指定索引位置的元素,列表长度不变

127.0.0.1:6379> lindex girls 0
"mashiro"
127.0.0.1:6379> lrange girls 0 -1
1) "mashiro"
127.0.0.1:6379> lindex lst 0 
(nil)  # 对不存在的列表使用 lindex,会得到 nil
127.0.0.1:6379> 

llen key:获取指定列表的长度

127.0.0.1:6379> llen girls
(integer) 1
127.0.0.1:6379> llen lst
(integer) 0  # 对不存在的列表使用 llen,会得到 0。
127.0.0.1:6379> 

lrem key count value:删除 count 个 value,如果 count 为 0,那么将全部删除

127.0.0.1:6379> lpush lst  1 1 1 1
(integer) 4
127.0.0.1:6379> lrem lst 3 1
(integer) 3  # 删除 3 个 1
127.0.0.1:6379> lrange lst 0 -1
1) "1"
127.0.0.1:6379> 

ltrim key start end:从 start 截取到 end,再重新赋值给 key

127.0.0.1:6379> rpush lst 2 3 4 5
(integer) 5
127.0.0.1:6379> lrange lst 0 -1
1) "1"
2) "2"
3) "3"
4) "4"
5) "5"
127.0.0.1:6379> ltrim lst 4 -1
OK  # 将 5 重新赋值给 lst
127.0.0.1:6379> lrange lst 0 -1
1) "5"
127.0.0.1:6379> 

rpoplpush key1 key2:移除 key1 的最后一个元素,并添加到 key2 的开头

127.0.0.1:6379> rpush lst1 1 2 3
(integer) 3
127.0.0.1:6379> rpush lst2 11 22 33
(integer) 3
127.0.0.1:6379> rpoplpush lst1 lst2
"3"
127.0.0.1:6379> lrange lst2 0 -1
1) "3"
2) "11"
3) "22"
4) "33"
127.0.0.1:6379> 

lset key index value:将 key 中索引为 index 的元素设置为 value

127.0.0.1:6379> lrange lst2 0 -1
1) "3"
2) "11"
3) "22"
4) "33"
127.0.0.1:6379> lset lst2 1 2333
OK
127.0.0.1:6379> lrange lst2 0 -1
1) "3"
2) "2333"
3) "22"
4) "33"
127.0.0.1:6379> lset lst2 10 2333
(error) ERR index out of range  # 索引越界则报错,显然索引为 10 越界了
127.0.0.1:6379> 

linsert key before/after value1 value2:在 value1 的前面或者后面插入一个 value2

127.0.0.1:6379> rpush lst3 1 2 2 3
(integer) 4
127.0.0.1:6379> linsert lst3 before 2 666
(integer) 5
127.0.0.1:6379> lrange lst3 0 -1
1) "1"
2) "666"
3) "2"
4) "2"
5) "3"
127.0.0.1:6379> linsert lst3 after 2 2333
(integer) 6
127.0.0.1:6379> lrange lst3 0 -1
1) "1"
2) "666"
3) "2"
4) "2333"
5) "2"
6) "3"
127.0.0.1:6379> 

我们看到插入位置是由第一个元素决定的。

 

然后我们来分析一下 Redis 列表类型的内部实现:

127.0.0.1:6379> object encoding list
"quicklist"
127.0.0.1:6379> 

我们看到列表底层的数据类型是 quicklist(快速列表),quicklist 是 Redis3.2 引入的数据类型,早期的列表是使用 ziplist(压缩列表)和双向列表组成的,Redis3.2 的时候改为 quicklist,下面就来看一下它的底层实现。

// src/quicklist.h
typedef struct quicklist { 
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;                       /* ziplist 的个数 */
    unsigned long len;                         /* quicklist 的节点数 */
    unsigned int compress : 16;                /* LZF 压缩算法深度 */
    //...
} quicklist;

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;                         /* 对应的 ziplist */
    unsigned int sz;                           /* ziplist 字节数 */
    unsigned int count : 16;                   /* ziplist 个数 */
    unsigned int encoding : 2;                 /* RAW==1 or LZF==2 */
    unsigned int container : 2;                /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1;               /* 该节点先前是否被压缩 */
    unsigned int attempted_compress : 1;       /* 节点太小无法压缩 */
    //...
} quicklistNode;

typedef struct quicklistLZF {
    unsigned int sz; 
    char compressed[];
} quicklistLZF;

从源码中可以看出 quicklist 是一个双向链表,链表中的每一个节点实际上是一个 quicklistNode,每个 quicklistNode 对应一个 ziplist,对应结构如图所示。

ziplist 作为 quicklist 的实际存储结构,它本质是一个字节数组,ziplist 数据结构如下图所示:

  • zlbytes:压缩列表字节长度,占 4 字节
  • zltail:压缩列表尾元素相对于起始元素地址的偏移量,占 4 字节
  • zllen:压缩列表的元素个数
  • entryX:压缩列表存储的所有元素,可以是字节数组或者是整数
  • zlend:压缩列表的结尾,占 1 字节

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 \(O(1)\)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 \(O(n)\) 了。

 

使用场景

列表的典型使用场景有以下两个:

  • 消息队列:列表类型可以使用 rpush 实现先进先出的功能,同时又可以使用 lpop 轻松的弹出(查询并删除)第一个元素,所以列表类型可以用来实现消息队列;
  • 文章列表:对于博客站点来说,当用户和文章都越来越多时,为了加快程序的响应速度,我们可以把用户自己的文章存入到 List 中,因为 List 是有序的结构,所以这样又可以完美的实现分页功能,从而加速了程序的响应速度;

使用 Python 操作 Redis 列表

老规矩,我们来看看如何使用 Python 来操作 Redis 中的列表,和操作字符串是类似的,因为 Python 操作 Redis 的模块提供的 api 和 redis-cli 控制台所使用的 api 是高度一致的,包括后面的 Go 也是。

import redis
 
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")
 
# 1. lpush key value1 value2 ...
client.lpush("list", 1, 2, 3)
 
# 2. rpush key value1 value2 ...
client.rpush("list", 2, 2, 2)
 
# 3. lrange key start end
print(client.lrange("list", 0, -1))  # ['3', '2', '1', '2', '2', '2']
 
# 4. lpop key
print(client.lpop("list"))  # 3
 
# 5. rpop key
print(client.rpop("list"))  # 2
 
# 6. lindex key
print(client.lindex("list", 0))  # 2
 
# 7. llen key
print(client.llen("list"))  # 4
 
# 8. lrem key count value
client.lrem("list", 1, 2)
print(client.lrange("list", 0, -1))  # ['1', '2', '2']
 
# 9. ltrim key start end
print(client.lrange("list", 0, -1))  # ['1', '2', '2']
client.ltrim("list", 0, -2)
print(client.lrange("list", 0, -1))  # ['1', '2]
 
# 10. rpoplpush key1 key2
client.rpush("list1", 1, 2, 3)
client.rpush("list2", 11, 22, 33)
client.rpoplpush("list1", "list2") 
print(client.lrange("list2", 0, -1))  # ['3', '11', '22', '33']
 
# 11. lset key index value
client.lset("list2", -1, "古明地觉")
print(client.lrange("list2", 0, -1))  # ['3', '11', '22', '古明地觉']
 
# 12. linsert key before/after value1 value2
client.linsert("list2", "before", 22, "aaa")
client.linsert("list2", "after", 22, "bbb")
print(client.lrange("list2", 0, -1))  # ['3', '11', 'aaa', '22', 'bbb', '古明地觉']

使用 Go 操作 Redis 列表

再来看看如何使用 Go 来操作 Redis 中的列表,做法是类似的,这里我们先将上面使用 Python 创建的 key 给删掉。

127.0.0.1:6379> del list list1 list2
(integer) 3

下面使用 Go 连接 Redis。

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 添加元素
    client.LPush(ctx, "list", 1, 2, 3)
    client.RPush(ctx, "list", 2, 2, 2)

    // 查看元素
    cmd := client.LRange(ctx, "list", 0, -1)
    fmt.Println(cmd.Result())  // [3 2 1 2 2 2] <nil>

    // 弹出元素
    fmt.Println(client.LPop(ctx, "list").Result())  // 3 <nil>
    fmt.Println(client.RPop(ctx, "list").Result())  // 2 <nil>

    // 索引元素
    fmt.Println(client.LIndex(ctx, "list", 0).Val())  // 2

    // 查看长度
    fmt.Println(client.LLen(ctx, "list").Val())  // 4

    // 删除指定 count 个 value
    client.LRem(ctx, "list", 1, 2)
    fmt.Println(client.LRange(ctx, "list", 0, -1).Val())  // [1 2 2]

    // 截取指定位置
    fmt.Println(client.LRange(ctx, "list", 0, -1).Val())  // [1 2 2]
    client.LTrim(ctx, "list", 0, -2)
    fmt.Println(client.LRange(ctx, "list", 0, -1).Val())  // [1 2]

    // 从 key1 的尾部删除一个元素,并移动到 key2 的开头
    client.RPush(ctx, "list1", 1, 2, 3)
    client.RPush(ctx, "list2", 11, 22, 33)
    client.RPopLPush(ctx, "list1", "list2")
    fmt.Println(client.LRange(ctx, "list2", 0, -1).Val())  // [3 11 22 33]

    // 将 key 中索引为 index 的元素设置为 value
    client.LSet(ctx, "list2", -1, "satori")
    fmt.Println(client.LRange(ctx, "list2", 0, -1).Val())  // [3 11 22 satori]

    // 在 value1 的前面或后面添加一个 value2
    client.LInsert(ctx, "list2", "before", 22, "aaa")
    client.LInsert(ctx, "list2", "after", 22, "bbb")
    fmt.Println(client.LRange(ctx, "list2", 0, -1).Val())  // [3 11 aaa 22 bbb satori]
}

Redis 字典 (Hash)

字典类型(Hash)又被成为散列类型或是哈希表类型,它底层是通过哈希表存储的,这个哈希表包含两列数据:字段和值,假设我们使用字典来存储文章的详情信息,存储结构如图所示:

同理我们也可以使用字典来存储用户信息,并且使用字典存储此类信息是不需要序列化和反序列化的,所以使用起来更加的方便和高效。

下面看看字典所支持的api

hset key field1 value1 field2 value2···:设置键值对,可同时设置多个。这里的键值对指的是 field、value,而命令中的 key 指的是字典、或者哈希表的名称

127.0.0.1:6379> hset girl name hanser age 28 gender f
(integer) 3  # 返回 3 表示成功设置 3 个键值对
127.0.0.1:6379> 

hget key field:获取 hash 中 field 对应的 value

127.0.0.1:6379> hget girl name
"hanser"
127.0.0.1:6379> 

hgetall key:获取 hash 中所有的键值对

127.0.0.1:6379> hgetall girl
1) "name"
2) "hanser"
3) "age"
4) "28"
5) "gender"
6) "f"
127.0.0.1:6379> 

hlen key:获取 hash 中键值对的个数

127.0.0.1:6379> hlen girl
(integer) 3
127.0.0.1:6379> 

hexists key field:判断 hash 中是否存在指定的 field

127.0.0.1:6379> hexists girl name
(integer) 1  # 存在返回 1
127.0.0.1:6379> hexists girl where
(integer) 0  # 不存在返回 0
127.0.0.1:6379> 

hkeys/hvals key:获取 hash 中所有的 field 和所有的 value

127.0.0.1:6379> hkeys girl
1) "name"
2) "age"
3) "gender"
127.0.0.1:6379> hvals girl
1) "hanser"
2) "28"
3) "f"
127.0.0.1:6379> 

hincrby key field number:将 hash 中字段 field 对应的值自增 number,number 必须指定,显然 field 对应的 value 要能解析成整型

127.0.0.1:6379> hincrby girl age 3
(integer) 31  # 返回增加之后的值
127.0.0.1:6379> hincrby girl age -3
(integer) 28  # 可以为正、可以为负
127.0.0.1:6379> 

hsetnx key field1 value1:每次只能设置一个键值对,不存在则设置,存在则无效。

127.0.0.1:6379> hsetnx girl name yousa
(integer) 0  # name 存在,所以设置失败
127.0.0.1:6379> hget girl name
"hanser"  # 还是原来的结果
127.0.0.1:6379> hsetnx girl length 155.5
(integer) 1  # 设置成功
127.0.0.1:6379> hget girl length
"155.5"
127.0.0.1:6379> 

hdel key field1 field2······:删除 hash 中的键,当然键没了,整个键值对就没了

127.0.0.1:6379> hdel girl name age
(integer) 2
127.0.0.1:6379> hget girl name
(nil)
127.0.0.1:6379> hget girl age
(nil)
127.0.0.1:6379> 

那么 Redis 中的字典是如何实现的呢?

字典类型本质上是由数组和链表结构组成的,来看字典类型的源码实现:

typedef struct dictEntry { // dict.h
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next; // 下一个 entry
} dictEntry;

字典类型的数据结构,如下图所示:

通常情况下字典类型会使用数组的方式来存储相关的数据,但发生哈希冲突时才会使用链表的结构来存储数据。

哈希冲突

字典类型的存储流程是先将键进行 Hash 计算,得到存储键对应的数组索引,再根据数组索引进行数据存储,但在小概率事件下可能会出完全不相同的键进行 Hash 计算之后,得到相同的 Hash 值,这种情况我们称之为哈希冲突。

哈希冲突一般通过链表的形式解决,相同的哈希值会对应一个链表结构,每次有哈希冲突时,就把新的元素插入到链表的尾部,请参考上面数据结构的那张图。

键查询的流程如下:

  • 通过算法(Hash,计算和取余等)操作获得数组的索引值,根据索引值找到对应的元素;
  • 判断元素和查找的键值是否相等,相等则成功返回数据,否则需要查看 next 指针是否还有对应其他元素,如果没有,则返回 null,如果有的话,重复此步骤。

渐进式 rehash

Redis 为了保证应用的高性能运行,提供了一个重要的机制——渐进式 rehash。 渐进式 rehash 是用来保证字典缩放效率的,也就是说在字典进行扩容或者缩容是会采取渐进式 rehash 的机制。

1) 扩容

当元素数量等于数组长度时就会进行扩容操作,源码在 dict.c 文件中,核心代码如下:

int dictExpand(dict *d, unsigned long size)
{
    /* 需要的容量小于当前容量,则不需要扩容 */
    if (dictIsRehashing(d) || d->ht[0].used > size)
        return DICT_ERR;
    dictht n; 
    unsigned long realsize = _dictNextPower(size); // 重新计算扩容后的值
    /* 计算新的扩容大小等于当前容量,不需要扩容 */
    if (realsize == d->ht[0].size) return DICT_ERR;
    /* 分配一个新的哈希表,并将所有指针初始化为 NULL */
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = zcalloc(realsize*sizeof(dictEntry*));
    n.used = 0;
    if (d->ht[0].table == NULL) {
        // 第一次初始化
        d->ht[0] = n;
        return DICT_OK;
    }
    d->ht[1] = n; // 把增量输入放入新 ht[1] 中
    d->rehashidx = 0; // 非默认值 -1,表示需要进行 rehash
    return DICT_OK;
}

从以上源码可以看出,如果需要扩容则会申请一个新的内存地址赋值给 ht[1],并把字典的 rehashindex 设置为 0,表示之后需要进行 rehash 操作。

2) 缩容

当字典的使用容量不足总空间的 10% 时就会触发缩容,Redis 在进行缩容时也会把 rehashindex 设置为 0,表示之后需要进行 rehash 操作。

3) 渐进式 rehash 流程

在进行渐进式 rehash 时,会同时保留两个 hash 结构,新键值对加入时会直接插入到新的 hash 结构中,并会把旧 hash 结构中的元素一点一点的移动到新的 hash 结构中,当移除完最后一个元素时,清空旧 hash 结构,主要的执行流程如下:

  • 1. 扩容或者缩容时把字典中的字段 rehashidx 标识为 0;
  • 2. 在执行定时任务或者执行客户端的 hset、hdel 等操作指令时,判断是否需要触发 rehash 操作(通过 rehashidx 标识判断),如果需要触发 rehash 操作,也就是调用 dictRehash 函数,dictRehash 函数会把 ht[0] 中的元素依次添加到新的 Hash 表 ht[1] 中;
  • 3. rehash 操作完成之后,清空 Hash 表 ht[0],然后对调 ht[1] 和 ht[0] 的值,把新的数据表 ht[1] 更改为 ht[0],然后把字典中的 rehashidx 标识为 -1,表示不需要执行 rehash 操作。

通过渐进式 rehash,可以把一次性大量拷贝的开销分摊到多次处理的请求中,避免了耗时操作,从而保证数据的快速访问。

那么 Redis 中的字典都在哪些场景中使用呢?

哈希字典的典型使用场景如下:

  • 商品购物车,购物车非常适合用哈希字典表示,使用人员唯一编号作为 key(哈希表的名称),哈希表本身则负责存储商品的 id 和数量等信息;比如:hset person_id0001 product_id product001 count 20
  • 存储用户的属性信息,使用人员唯一编号作为 key,哈希表存储属性字段和对应的值;
  • 存储文章详情页信息等。

因此通过上面内容我们知道了字典类型实际是由数组和链表组成的,当字典进行扩容或者缩容时会进行渐进式 rehash 操作,渐进式 rehash 是用来保证 Redis 运行效率的,它的执行流程是同时保留两个哈希表,把旧表中的元素一点一点的移动到新表中,查询的时候会先查询两个哈希表,当所有元素都移动到新的哈希表之后,就会删除旧的哈希表。

使用 Python 操作 Redis 字典

下面看看 Python 如何操作 Redis 的字典

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 1. hset key field1 value1 field2 value2···
# 这里的 hset 只能设置单个值,如果设置多个需要使用 hmset,传入 key 和字典
client.hmset("girl1", {"name": "yousa", "age": 26, "length": 148})

# 2. hget key field
print(client.hget("girl1", "name"))  # yousa

# 3. hgetall key
print(client.hgetall("girl1"))  # {'name': 'yousa', 'age': '26', 'length': '148'}
# 这里还支持同时获取多个值
print(client.hmget("girl1", ["name", "age"]))  # ['yousa', '26']

# 4. hlen key
print(client.hlen("girl1"))  # 3

# 5. hexists key field
print(client.hexists("girl1", "name"))  # True
print(client.hexists("girl1", "name1"))  # False

# 6. hkeys/hvals key
print(client.hkeys("girl1"))  # ['name', 'age', 'length']
print(client.hvals("girl1"))  # ['yousa', '26', '148']

# 7. hincrby key field number
client.hincrby("girl1", "age", 2)  
print(client.hget("girl1", "age"))  # 28

# 8. hsetnx key field1 value1
client.hsetnx("girl1", "name", "hanser")
client.hsetnx("girl1", "gender", "female")
print(client.hmget("girl1", ["name", "gender"]))  # ['yousa', 'female']

# 9. hdel key field1 field2······
client.hdel("girl1", "name", "age")
print(client.hmget("girl1", ["name", "age"]))  # [None, None]

使用 Go 操作 Redis 字典

下面看看 Go 如何操作 Redis 的字典

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 设置元素
    client.HMSet(ctx, "girl1", "name", "hanser", "age", 28, "length", 155)

    // 获取元素
    fmt.Println(client.HGet(ctx, "girl1", "name").Val())  // hanser
    fmt.Println(client.HMGet(ctx, "girl1", "name", "age").Val())  // [hanser 28]
    fmt.Println(client.HGetAll(ctx, "girl1").Val())  // map[age:28 length:155 name:hanser]

    // 获取长度
    fmt.Println(client.HLen(ctx, "girl1").Val())  // 3

    // 判断某个 field 是否存在
    fmt.Println(client.HExists(ctx, "girl1", "name").Val())  // true
    fmt.Println(client.HExists(ctx, "girl1", "name1").Val())  // false

    // 获取所有的 key、value,注意这里的 key 是字典里面的 key
    // 或者就把这里的 key 看做是 field,这里的 key 并不是全局哈希表里面的 key
    fmt.Println(client.HKeys(ctx, "girl1").Val())  // [name age length]
    fmt.Println(client.HVals(ctx, "girl1").Val())  // [hanser 28 155]

    // 给某个类型为 int 的 key 自增 number
    client.HIncrBy(ctx, "girl1", "age", 1)
    fmt.Println(client.HGet(ctx, "girl1", "age").Val())  // 29

    // 设置一个键值对,但只有不存在时才会设置
    fmt.Println(client.HMGet(ctx, "girl1", "name", "gender").Val())  // [hanser <nil>]
    client.HSetNX(ctx, "girl1", "name", "憨")
    client.HSetNX(ctx, "girl1", "gender", "female")
    fmt.Println(client.HMGet(ctx, "girl1", "name", "gender").Val())  // [hanser female]

    // 删除指定的键值对
    client.HDel(ctx, "girl1", "name", "age")
    fmt.Println(client.HMGet(ctx, "girl1", "name", "age").Val())  // [<nil> <nil>]
}

Redis 集合 (Set)

Redis的集合和列表是类似的,都是用来存储多个标量,但是它和列表又有不同:

  • 1. 列表中的元素是可以重复的,而集合中的元组不会重复。
  • 2. 列表在插入元素的时候可以保持顺序,而集合不保证顺序(集合在存储数据时,底层也是使用了哈希表,后面会说)。

下面我们来看看它所支持的 api 操作

sadd key value1 value2···:向集合插入多个元素,如果重复会自动去重

127.0.0.1:6379> sadd set1 1 1 2 3
(integer) 3  # 返回成功插入的元素的个数,这里是 3 个,因为元素有重复。两个 1,只会插入一个
127.0.0.1:6379> 

smembers key:查看集合的所有元素

127.0.0.1:6379> smembers set1
1) "1"
2) "2"
3) "3"
127.0.0.1:6379> 

sismember key value:查看 value 是否在集合中

127.0.0.1:6379> sismember set1 1
(integer) 1  # 在的话返回 1
127.0.0.1:6379> sismember set1 5
(integer) 0  # 不在返回 0
127.0.0.1:6379> 

scard key:查看集合的元素个数

127.0.0.1:6379> scard set1
(integer) 3
127.0.0.1:6379> 

srem key value1 value2 ······:删除集合中的元素

127.0.0.1:6379> srem set1 1 2
(integer) 2  # 返回删除成功的元素个数
127.0.0.1:6379> srem set1 1 2
(integer) 0
127.0.0.1:6379> 

spop key count:随机弹出集合中 count 个元素,注意:count 是可以省略的,如果省略则弹出 1 个。另外一旦弹出,原来的集合里面也就没有了。

127.0.0.1:6379> smembers set1
1) "3"  # 还有一个元素
127.0.0.1:6379> sadd set1 1 2
(integer) 2  # 添加两个进去
127.0.0.1:6379> 
127.0.0.1:6379> smembers set1
1) "1"
2) "2"
3) "3"
127.0.0.1:6379> spop set1 1
1) "2"  # 弹出 1 个元素,返回弹出的元素
127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> 

srandmember key count:随机获取集合中 count 个元素,注意:count 是可以省略的,如果省略则获取 1 个。可以看到类似 spop,但是 srandmember 不会删除集合中的元素。

127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> srandmember set1 1
1) "1"
127.0.0.1:6379> smembers set1
1) "1"
2) "3"

smove key1 key2 value:将 key1 当中的 value 移动到 key2 当中,因此 key1 当中的元素会少一个,key2 会多一个(前提是 value 在 key2 中不重复,否则 key2 还和原来保持一致)。

127.0.0.1:6379> smembers set1
1) "1"
2) "3"
127.0.0.1:6379> smembers set2
1) "1"
127.0.0.1:6379> smove set1 set2 3
(integer) 1
127.0.0.1:6379> smembers set1
1) "1"
127.0.0.1:6379> smembers set2
1) "1"
2) "3"
127.0.0.1:6379> 

sinter key1 key2:返回即在 key1 中,又在 key2 中的元素

sunion key1 key2:返回在 key1 中,或者在 key2 中的元素

sdiff key1 key2:返回在 key1 中,但不在 key2 中的元素

127.0.0.1:6379> sinter set1 set2
1) "2"
2) "3"
127.0.0.1:6379> sunion set1 set2
1) "1"
2) "2"
3) "3"
4) "4"
127.0.0.1:6379> sdiff set1 set2
1) "1"
127.0.0.1:6379> sdiff set2 set1
1) "4"
127.0.0.1:6379> 

 

那么 Redis 的集合底层是如何实现的呢?

集合类型是由 intset(整数集合)或 hashtable(普通哈希表)组成的。当集合类型以 hashtable 存储时,哈希表的 key 为要插入的元素值,而哈希表的 value 则为 Null,如下图所示:

当集合中所有的值都为整数时,Redis 会使用 intset 结构(Redis 为整数专门设计的一种集合结构)来存储,如下代码所示:

127.0.0.1:6379> sadd s 1 2 3
(integer) 3
127.0.0.1:6379> object encoding s
"intset"
127.0.0.1:6379> 

从上面代码可以看出,当所有元素都为整数时,集合会以 intset 结构进行数据存储。 当发生以下两种情况时,会导致集合类型使用 hashtable 而非 intset 存储:

  • 1)当元素的个数超过一定数量时,默认是 512 个,该值可通过命令 set-max-intset-entries xxx 来配置
  • 2)当元素为非整数时,集合将会使用 hashtable 来存储,如下代码所示:
import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

client.sadd("s1", *range(513))
# 超过 512 个,使用哈希表存储
print(client.object("encoding", "s1"))  # hashtable

client.sadd("s2", *range(512))
# 没超过 512 个,使用 intset
print(client.object("encoding", "s2"))  # intset

client.sadd("s3", "hanser")
# 不是整数,使用哈希表存储
print(client.object("encoding", "s3"))  # hashtable

源码解析

集合源码在 t_set.c 文件中,核心源码如下:

/* 
 * 添加元素到集合
 * 如果当前值已经存在,则返回 0 不作任何处理,否则就添加该元素,并返回 1。
 */
int setTypeAdd(robj *subject, sds value) {
    long long llval;
    if (subject->encoding == OBJ_ENCODING_HT) { // 字典类型
        dict *ht = subject->ptr;
        dictEntry *de = dictAddRaw(ht,value,NULL);
        if (de) {
            // 把 value 作为字典到 key,将 Null 作为字典到 value,将元素存入到字典
            dictSetKey(ht,de,sdsdup(value));
            dictSetVal(ht,de,NULL);
            return 1;
        }
    } else if (subject->encoding == OBJ_ENCODING_INTSET) { // inset 数据类型
        if (isSdsRepresentableAsLongLong(value,&llval) == C_OK) {
            uint8_t success = 0;
            subject->ptr = intsetAdd(subject->ptr,llval,&success);
            if (success) {
                // 超过 intset 的最大存储数量,则使用字典类型存储
                if (intsetLen(subject->ptr) > server.set_max_intset_entries)
                    setTypeConvert(subject,OBJ_ENCODING_HT);
                return 1;
            }
        } else {
            // 转化为整数类型失败,使用字典类型存储
            setTypeConvert(subject,OBJ_ENCODING_HT);

            serverAssert(dictAdd(subject->ptr,sdsdup(value),NULL) == DICT_OK);
            return 1;
        }
    } else {
        // 未知编码(类型)
        serverPanic("Unknown set encoding");
    }
    return 0;
}

以上这些代码验证了,我们上面所说的内容,当元素都为整数并且元素的个数没有到达设置的最大值时,键值的存储使用的是 intset 的数据结构,反之到元素超过了一定的范围,又或者是存储的元素为非整数时,集合会选择使用 hashtable 的数据结构进行存储。

使用场景

集合类型的经典使用场景如下:

  • 微博关注我的人和我关注的人都适合用集合存储,可以保证人员不会重复;
  • 中奖人信息也适合用集合类型存储,这样可以保证一个人不会重复中奖。

使用 Python 操作 Redis 集合

下面看看 Python 如何操作 Redis 的集合

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 1. sadd key value1 value2·····
client.sadd("s1", 1, 2, 3, 1)  

# 2. smembers key
print(client.smembers("s1"))  # {'2', '1', '3'}

# 3. sismember key value
print(client.sismember("s1", 1))  # True
print(client.sismember("s1", 5))  # False

# 4. scard key
print(client.scard("s1"))  # 3

# 5. srem key value1 value2······
client.srem("s1", 1, 2)
print(client.smembers("s1"))  # {'3'}

# 6. spop key count
print(client.smembers("s1"))  # {'3'}
print(client.spop("s1", 1))  # ['3']
print(client.smembers("s1"))  # set()

# 7. srandmember key count
client.sadd("s1", 1, 2, 3)
print(client.smembers("s1"))  # {'2', '1', '3'}
print(client.srandmember("s1", 2))  # ['2', '3']
print(client.smembers("s1"))  # {'2', '1', '3'}

# 8. smove key1 key2 value
client.sadd("s2", 1)
client.smove("s1", "s2", 3)
print(client.smembers("s2"))  # {'1', '3'}

# 9. sinter key1 key2
# 10. sunion key1 key2
# 11. sdiff key1 key2
client.sadd("s3", 1, 2, 3)
client.sadd("s4", 2, 3, 4)
print(client.sinter("s3", "s4"))  # {'2', '3'}
print(client.sunion("s3", "s4"))  # {'2', '4', '1', '3'}
print(client.sdiff("s3", "s4"))  # {'1'}

使用 Go 操作 Redis 集合

下面看看 Go 如何操作 Redis 的集合

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 添加元素
    client.SAdd(ctx, "s1", 1, 2, 3, 1)

    // 获取元素
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [1 2 3]

    // 查看是否包含某个元素
    fmt.Println(client.SIsMember(ctx, "s1", 1).Val())  // true
    fmt.Println(client.SIsMember(ctx, "s1", 5).Val())  // false

    // 查看集合内部的元素数量
    fmt.Println(client.SCard(ctx, "s1").Val())  // 3

    // 删除集合内部的元素
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [1 2 3]
    client.SRem(ctx, "s1", 1, 3)
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [2]

    // 从集合中弹出 count 个元素
    // 弹出一个的话也等价于 SPop,但返回的不是一个切片、而是一个标量
    fmt.Println(client.SPopN(ctx, "s1", 1).Val())  // [2]
    fmt.Println(client.SMembers(ctx, "s1").Val())  // []

    // 随机获取 count 个元素
    client.SAdd(ctx, "s1", 1, 2, 3)
    fmt.Println(client.SRandMember(ctx, "s1").Val())  // 1
    fmt.Println(client.SRandMemberN(ctx, "s1", 2).Val())  // [3 1]
    fmt.Println(client.SMembers(ctx, "s1").Val())  // [1 2 3]

    // 将 value 从 s1 移动到 s2 中
    client.SAdd(ctx, "s2", 1)
    client.SMove(ctx, "s1", "s2", 1)
    fmt.Println(client.SMembers(ctx, "s2").Val())  // [1]

    // 交集、并集、差集
    client.SAdd(ctx, "s3", 1, 2, 3)
    client.SAdd(ctx, "s4", 2, 3, 4)
    fmt.Println(client.SInter(ctx, "s3", "s4").Val())  // [2 3]
    fmt.Println(client.SUnion(ctx, "s3", "s4").Val())  // [1 2 3 4]
    fmt.Println(client.SDiff(ctx, "s3", "s4").Val())  // [1]
}

Redis 有序集合 (zset,Sorted Set)

Redis的有序集合相比集合多了一个排序属性:score(分值),对于有序集合 zset 来说,每个存储元素相当于有两个值,一个是有序集合的元素值,一个是排序值(分值)。有序集合存储的元素值也是不重复的,但分数可以重复。

当我们把学生的成绩存储在有序集合中,它的存储结构如下图所示:

下面我们来看看它所支持的 api 操作

zadd key score1 value1 score2 value2:设置 score 和 value

127.0.0.1:6379> zadd zset1 1 n1 2 n2 3 n2
(integer) 2
127.0.0.1:6379> 

一个 score 对应一个 value,value 不会重复,因此即便我们这里添加了 3 个,但是后面两个的 value 都是 n2,所以实际上只有两个元素,并且 n2 是以后一个 score 为准,因为相当于覆盖了。

zscore key value:获取 value 对应的 score

127.0.0.1:6379> zscore zset1 n2
"3"
127.0.0.1:6379>

zrange key start end:获取指定范围的 value,递增排列,这里是基于索引获取

127.0.0.1:6379> zadd zset2 1 n1 3 n3 2 n2 4 n4
(integer) 4
127.0.0.1:6379> zrange zset2 0 -1
1) "n1"
2) "n2"
3) "n3"
4) "n4"
127.0.0.1:6379> zrange zset2 0 2
1) "n1"
2) "n2"
3) "n3"
127.0.0.1:6379> 

如果结尾加上 with scores 参数,那么会和 score 一同返回,注意:score 是在下面。我们看到这个 zset 有点像 hash 啊,value 是 hash 的 k,score 是 hash 的 v。

127.0.0.1:6379> zrange zset2 0 2 withscores
1) "n1"
2) "1"
3) "n2"
4) "2"
5) "n3"
6) "3"
127.0.0.1:6379> 

zrevrange key start end:获取所有的 value,递减排列,同理也有 withscores 参数

127.0.0.1:6379> zrevrange zset2 0 -1
1) "n4"
2) "n3"
3) "n2"
4) "n1"
127.0.0.1:6379> 

zrangebyscore key 开始score 结束score:获取 >=开始score  and <=结束score 的 value,递增排列,同理也有 withscores 参数

zrevrangebyscore key 结束score 开始score:获取 >=开始score and <=结束score 的value,递减排列,同理也有 withscores 参数。注意:这里的开始和结束是相反的。

127.0.0.1:6379> zadd zset3 1 n1 2 n2 3 n3 4 n4 5 n5 6 n6 7 n7
(integer) 7
127.0.0.1:6379> zrangebyscore zset3 3 6
1) "n3"
2) "n4"
3) "n5"
4) "n6"
127.0.0.1:6379> zrevrangebyscore zset3 6 3
1) "n6"
2) "n5"
3) "n4"
4) "n3"
127.0.0.1:6379> zrangebyscore zset3 (3 (6
1) "n4"  # 如果在分数前面加上了 (, 那么会不匹配边界,同理也支持 withscores
2) "n5"
127.0.0.1:6379> zrevrangebyscore zset3 (6 (3
1) "n5"
2) "n4"
127.0.0.1:6379> 

zrem key value1 value2···:移除对应的 value

127.0.0.1:6379> zrem zset3 n1 n2 n3 n4
(integer) 4
127.0.0.1:6379> zrange zset3 0 -1
1) "n5"
2) "n6"
3) "n7"

zcard key:获取集合的元素个数

127.0.0.1:6379> zcard zset3
(integer) 3
127.0.0.1:6379> 

zcount key 开始分数区间 结束分数区间:获取集合指定分数区间内的元素个数

127.0.0.1:6379> zcount zset3 6 8
(integer) 2
127.0.0.1:6379> zcount zset3 5 7
(integer) 3
127.0.0.1:6379> 

 

下面看看 Redis 有序集合的底层实现

有序集合是由 ziplist(压缩列表)或 skiplist(跳跃表)组成的。

1)ziplist

当数据比较少时,有序集合使用的是 ziplist 存储的,如下代码所示:

127.0.0.1:6379> zadd my_zset 1 n1 2 n2
(integer) 2
127.0.0.1:6379> object encoding my_zset 
"ziplist"
127.0.0.1:6379> 

从结果可以看出,有序集合把键值对存储在 ziplist 结构中了。 有序集合使用 ziplist 格式存储必须满足以下两个条件:

  • 有序集合保存的元素个数要小于等于 128 个;
  • 有序集合保存的所有元素成员的长度都必须小于等于 64 字节。

如果不能满足以上两个条件中的任意一个,有序集合将会使用 skiplist 结构进行存储。 接下来我们来测试以下,当有序集合中某个元素长度大于 64 字节时会发生什么情况? 代码如下:

import redis

client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")

# 元素长度超过64
client.zadd("my_zset2", {"a" * 65: 1})
print(client.object("encoding", "my_zset2"))  # skiplist

# 集合元素超过128个
client.zadd("my_zset3", dict(zip(range(129), range(129))))
print(client.object("encoding", "my_zset3"))  # skiplist

通过以上代码可以看出,当有序集合保存的元素的长度大于 64 字节、或者元素个数超过128个时,有序集合就会从 ziplist 转换成为 skiplist。

可以通过配置文件中的 zset-max-ziplist-entries(默认 128)和 zset-max-ziplist-value(默认 64)来设置有序集合使用 ziplist 存储的临界值。

2)skiplist

skiplist 数据编码底层是使用 zset 结构实现的,而 zset 结构中包含了一个字典和一个跳跃表,源码如下:

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

1. 跳跃表实现原理

跳跃表的结构如下图所示:

根据以上图片展示,当我们在跳跃表中查询值 62 时,执行流程如下:

  • 从最上层开始找,1 比 62 小,在当前层移动到下一个节点进行比较;
  • 27 比 62 小,当前层移动下一个节点比较,然后 100 大于 62,所以以 27 为目标,移动到下一层继续向后比较;
  • 50 小于 62,继续向后移动查找,对比 100 大于 62,以 50 为目标,移动到下一层继续向后比较;
  • 对比 62 等于 62,值被顺利找到。

从上面的流程可以看出,跳跃表会先从最上层开始找起,依次向后查找,如果本层的节点大于要找的值,或者本层的节点为 Null 时,以上一个节点为目标,往下移一层继续向后查找并循环此流程,直到找到该节点并返回,如果对比到最后一个元素仍未找到,则返回 Null。

2. 为什么是跳跃表?而非红黑树?

因为跳跃表的性能和红黑树基本相近,但却比红黑树更好实现,所以 Redis 的有序集合会选用跳跃表来实现存储。

使用场景

有序集合的经典使用场景如下:

  • 学生成绩排名
  • 粉丝列表,根据关注的先后时间排序

总结

关于有序集合,我们了解到了如下几点:

  • 有序集合具有唯一性和排序的功能,排序功能是借助分值字段 score 实现的,score 字段不仅可以实现排序功能,还可以实现数据的筛选与过滤的功能。
  • 有序集合是由 压缩列表 (ziplist) 或跳跃列表 (skiplist) 来存储的,当元素个数小于 128 个,并且所有元素的值都小于 64 字节时,有序集合会采取 ziplist 来存储,反之则会用 skiplist 来存储。
  • skiplist 是从上往下、从前往后进行元素查找的,相比于传统的普通列表,可能会快很多,因为普通列表只能从前往后依次查找。

使用 Python 操作 Redis 有序集合

下面看看如何使用 Python 操作 Redis 的有序集合。

import redis
 
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8", password="satori")
 
# 1. zadd key score1 value1 score2 value2
# 这里使用字典的方式传递,因为 value 不重复,所以作为字典传递的话,value 作为键、分数作为值
client.zadd("zset1", {"n1": 1, "n2": 2, "n3": 3})
 
# 2. zscore key value
print(client.zscore("zset1", "n1"))  # 1.0
 
# 3. zrange key start end
print(client.zrange("zset1", 0, -1))  # ['n1', 'n2', 'n3']
print(client.zrange("zset1", 0, -1, withscores=True))  # [('n1', 1.0), ('n2', 2.0), ('n3', 3.0)]
 
# 4. zrevrange key start end
print(client.zrevrange("zset1", 0, -1))  # ['n3', 'n2', 'n1']
print(client.zrevrange("zset1", 0, -1, withscores=True))  # [('n3', 3.0), ('n2', 2.0), ('n1', 1.0)]
 
# 5. zrangebyscore key 开始score 结束score
# 6. zrevrangebyscore key 结束score 开始score
print(client.zrangebyscore("zset1", 1, 3))  # ['n1', 'n2', 'n3']
print(client.zrevrangebyscore("zset1", 3, 1))  # ['n3', 'n2', 'n1']
 
# 7. zrem key value1 value2······
client.zrem("zset1", "n1", "n2")
print(client.zrange("zset1", 0, -1))  # ['n3']
 
# 8. zcard key
print(client.zcard("zset1"))  # 1
 
# 9. zcount key 开始分数区间 结束分数区间
client.zadd("zset2", {"n1": 1, "n2": 2, "n3": 3, "n4": 4, "n5": 5})
print(client.zcount("zset2", 1, 4))  # 4

使用 Go 操作 Redis 有序集合

下面看看如何使用 Go 操作 Redis 的有序集合。

package main

import (
    "context"
    "fmt"
    "github.com/go-redis/redis/v8"
)

func main() {
    options := redis.Options{Addr: "47.94.174.89:6379", Password: "satori"}
    client := redis.NewClient(&options)
    ctx := context.Background()

    // 添加元素,Go-Redis 里面不叫 value、叫 member,不过没什么区别
    client.ZAdd(ctx, "zset1",
        &redis.Z{Score: 1, Member: "n1"},
        &redis.Z{Score: 2, Member: "n2"},
        &redis.Z{Score: 3, Member: "n3"})

    // 根据 value 获取 score
    fmt.Println(client.ZScore(ctx, "zset1", "n1").Val())  // 1

    // zrange key start end
    fmt.Println(client.ZRange(ctx, "zset1", 0, -1).Val())  // [n1 n2 n3]
    fmt.Println(client.ZRangeWithScores(ctx, "zset1", 0, -1).Val())  // [{1 n1} {2 n2} {3 n3}]

    // zrevrange key start end
    fmt.Println(client.ZRevRange(ctx, "zset1", 0, -1).Val())  // [n3 n2 n1]
    fmt.Println(client.ZRevRangeWithScores(ctx, "zset1", 0, -1).Val())  // [{3 n3} {2 n2} {1 n1}]

    // zrangebyscore key 开始score 结束score
    // zrevrangebyscore key 结束score 开始score
    fmt.Println(client.ZRangeByScore(ctx, "zset1", &redis.ZRangeBy{Min: "1", Max: "3"}).Val())  // [n1 n2 n3]
    fmt.Println(client.ZRevRangeByScore(ctx, "zset1", &redis.ZRangeBy{Min: "1", Max: "3"}).Val())  // [n3 n2 n1]

    // zrem key value1 value2······
    client.ZRem(ctx, "zset1", "n1", "n2")
    fmt.Println(client.ZRange(ctx, "zset1", 0, -1).Val())  // [n3]

    // zcard key
    fmt.Println(client.ZCard(ctx, "zset1").Val())  // 1

    // zcount key 开始分数区间 结束分数区间
    client.ZAdd(ctx, "zset2",
        &redis.Z{Score: 1, Member: "n1"},
        &redis.Z{Score: 2, Member: "n2"},
        &redis.Z{Score: 3, Member: "n3"},
        &redis.Z{Score: 4, Member: "n4"},
        &redis.Z{Score: 5, Member: "n5"})
    fmt.Println(client.ZCount(ctx, "zset2", "1", "4").Val())  // 4
}

 

以上就是 Redis 基本的数据结构、相关命令行操作,以及 Python 和 Go 的 api 操作。最开始我们就说过,Redis 的数据结构是一大亮点,不仅丰富,而且针对不同的数据量有着不同的实现。当然我们说 Redis 不止上面这五种,但这五种绝对是最常用的,至于 Redis 更高级的数据结构以及其它用法我们后面再慢慢说。

Redis 的 String 你真的用对了吗?

介绍完这几种数据结构之后,相信你已经知道它们的使用场景了,但有些时候我们不光要考虑使用上的便捷性,还要考虑内存的开销。比如某个场景下使用 String 是非常自然的选择,但当你深入思考之后会发现当数据量非常大的时候 String 并不适合,当然这是一个正常现象,因为很多架构设计也是如此,本来非常完美的架构,但随着数据量或者业务体量的增大而不断地暴露出各种问题。那么下面我们就从内存使用、资源利用率的角度来分析一下,不同的数据结构应该用在什么地方,首先是 String。

假设有一个图片存储系统,我们往里面上传的每一张图片,存储系统都会为其生成一个长度 10 的字符串,作为该图片的 "图片存储对象 ID",根据这个 "图片存储对象 ID" 即可从存储系统中下载指定的图片。但是上传到存储系统的图片本身就自带一个 ID("图片 ID"),也是长度为 10 的字符串,后续再访问图片时都是根据 "图片 ID" 进行访问的。因此我们就需要将上传图片时自带的 "图片 ID" 和存储完之后自动生成的 "图片存储对象 ID" 都保存起来,并且后续传递 "图片 ID" 时能够快速查找到对应的 "图片存储对象 ID",然后再将图片下载下来。

那么我们应该用 Redis 的哪一个数据结构进行存储呢?首先 String 肯定可以,直接将 key 作为 "图片 ID",value 作为 "图片存储对象 ID",直接就可以根据 key 找到 value。

# 比如某张图片的 "图片 ID" 是 100b68d6f3,"图片存储对象 ID" 是 e7e4eac9i3
set 100b68d6f3 e7e4eac9i3
# 这里我们只关注两个 ID 之间的关系,至于图片本身我们不需要关心

这个做法从设计上来讲是没有任何问题的,但如果你的图片非常多,比如上亿,那么会发现 Redis 内存的使用量会非常大,那么在生成 RDB 的时候就会响应变慢。所以很多设计从一开始都是没有问题的,但数据量一大,问题就会凸显出来。所以 String 虽然很方便,但短板就是在保存数据时所消耗的内存空间较多,下面就来解释一下原因。

为什么 String 类型开销大

除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点 "喧宾夺主" 的意思。那么 String 到底是怎么保存数据的呢?

首先该类型虽然叫 String,但它也可以保存整数,当你保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节的 Long 类型整数,这种保存方式通常也叫作 int 编码方式。

但是,当你保存的数据中包含字符时,String 类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存,如下图所示:

  • len:占 4 个字节,表示 buf 中已存储字符的长度
  • alloc:buf 的总长度
  • buf:字符数组,自带一个 \0

可以看到在 SDS 中,buf 保存实际数据,而 len 和 alloc 本身其实是 SDS 结构体的额外开销。但是对于 String 类型来说,除了 SDS 的额外开销,还有一个来自于 RedisObject 结构体的开销。因为 Redis 的数据类型有很多,而不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以 Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。

Redis 中的数据实际上就是一个 RedisObject 实例,RedisObject 里面记录了数据的元信息(8 字节),并存储了一个指针(8 字节),这个指针指向的内存才是具体数据类型的实际数据所在,例如指向 String 类型的 RedisObject 存储的指针指向的就是 SDS 结构体。关于 RedisObject 的具体结构细节,我们会在后面详细介绍,现在只要了解它的基本结构和元数据开销就行了。

然而为了节省内存空间,Redis 还对 Long 类型整数和 SDS 的内存布局做了专门的设计。

当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。

当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。

当保存的是字符串,并且字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式。

好了现在我们可以计算创建一个 String 类型的键值对所需要的额外开销了,首先元数据 8 字节、指针 8 字节,SDS 中的 len 占 4 字节,alloc 占 4 字节,此时就已经有 24 字节的额外开销了。当然 buf 的长度一般会比实际存储的字符串个数要多一些,因为我们字符串可以动态追加,所以 Redis 的内存分配库 jemalloc 在分配内存时,是按照 2 的幂次方进行进行分配的,比如:2、4、8、16、32、64、128,......。然后根据我们申请的字节数 N,找一个比 N 大、但是最接近 N 的数作为分配的空间,假设申请的字节数为 1023,那么实际会申请 1024,如果申请的是 1024,那么实际会申请 2048。所以长度为 10 的 ID,实际上会申请 16 个字节,因此加上 6 总共就有 30 字节的额外开销。

但是注意,还没完,我们上面说 Redis 会用一个全局哈希表来存储所有的键值对,哈希表的每一项是一个 dickEntry 结构体,dictEntry 里面有三个指针:key(指向具体的键)、value(指向具体的 value)、next(指向下一个 dictEntry)。

然后三个指针又用了 24 个字节,所以总共有 54 字节的额外开销,相信到这里你应该明白为什么 String 在面对这种场景会有如此严重的内存浪费了,实际数据总共 10 字节,但是额外空间就占了 54 字节,所以尽管 String 用起来很方便,但是在数量非常大的时候它并不是一个好的选择。

用什么数据结构可以节省内存?

既然 String 浪费内存严重,那么我们应该使用什么结构来应对当前这种场景呢?

Redis 有一种底层数据结构,叫压缩列表(ziplist),我们上面说过的,这是一种非常节省内存的结构。我们先回顾下压缩列表的构成。表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、压缩列表尾元素相对于起始元素地址的偏移量、以及列表中的 entry 个数。压缩列表尾还有一个 zlend,表示列表结束。

压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据,每个 entry 的元数据包括下面几部分。

  • prev_len:,表示前一个 entry 的长度,prev_len 有两种取值情况:1 字节或 5 字节。当 prev_len 占 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 占为 1 字节,否则,就占为 5 字节。
  • encoding:表示编码方式,1 字节
  • len:表示自身长度,4 字节
  • content:保存实际数据

这些 entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。我们以刚才的保存图片存储对象 ID 为例,来分析一下压缩列表是如何节省内存空间的。

每个 entry 保存一个图片存储对象 ID(10 字节),此时每个 entry 的 prev_len 只需要 1 个字节就行,因为每个 entry 的前一个 entry 长度都只有 10 字节,小于 254 字节。这样一来,一个图片的存储对象 ID 所占用的内存大小是 16 字节(1+4+1+10=16)。

Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销。当用 String 类型时,一个键值对就有一个 dictEntry,但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。

只不过这个方案听起来很好,但还存在一个问题:在用集合类型保存键值对时,一个键对应了一个集合的数据,但是在我们的场景中,一个 "图片 ID" 只对应一个 "图片存储对象 ID",我们该怎么用集合类型呢?换句话说,在一个键对应一个值(也就是单值键值对)的情况下,我们该怎么用集合类型来保存这种单值键值对呢?

使用 Hash 保存单值的键值对

在保存单值的键值对时,可以采用基于 Hash 类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value,这样一来,我们就可以把单值数据保存到 Hash 集合中了。

我们可以把图片 ID 的前 7 位作为 Hash 类型的键,把图片 ID 的最后 3 位和图片存储对象 ID 分别作为 Hash 类型值中的 key 和 value,然后通过 info memory 查看的内存使用的话,会发现只有使用 String 的四分之一,因此满足了节省内存空间的需要。

但你可能也会有疑惑:二级编码一定要把 "图片 ID" 的前 7 位作为 Hash 类型的键,把最后 3 位作为 Hash 类型值中的 key 吗?

其实,二级编码方法中采用的 ID 长度是有讲究的,我们说过 Redis Hash 类型的两种底层实现结构,分别是压缩列表和哈希表。压缩列表中存了两个阈值,当数据量没有达到这两个阈值时,使用压缩列表存储,否则就使用哈希表存储,而这里的阈值由通过以下两个配置决定:

  • hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数
  • hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度

一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。但在节省内存空间方面,哈希表就没有压缩列表那么高效了。所以为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在 Hash 集合中的元素个数。因此在刚才的二级编码中,我们只用图片 ID 最后 3 位作为 Hash 集合的 key,也就保证了 Hash 集合的元素个数不超过 1000,同时,我们把 hash-max-ziplist-entries 设置为 1000,这样一来,Hash 集合就可以一直使用压缩列表来节省内存空间了。

当然以上只能说是根据当前业务进行抽象而设计出的方案,它并不是一个通用的办法,这里只是为了更好的配合我们的主题,也就是在极端情况下 String 的表现。至于工作中,还要根据自身业务灵活变通,但绝大部分情况下,只要稍微一分析都能选择出最合适的数据结构。

推荐阅读