3. Redis有哪些数据类型?

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

问题:Redis都有哪些数据类型呢?

最常用的数据类型有5种:String(字符串类型)、Hash(字典类型)、List(列表类型)、Set(集合类型)、ZSet(有序集合类型)。

那么这些数据类型都支持哪些操作呢?我们来一一介绍,当然Redis支持的数据结构已经不止这五种了,还有几个更高级的数据结构,我们后面介绍,但是最常用的还是上面五种。

不过在介绍之前需要安装Redis,安装过程比较简单就不说了,我这里就使用docker安装了,比较简单。

通过:docker run -d -p 6379:6379 --name redis redis后台启动。

启动之后,我们需要了解一下Redis的前置知识。

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

Redis字符串(String)

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

struct sdshdr{
    //记录 buf 数组中已使用字节的数量
    //等于 SDS 保存字符串的长度
    int len;
    //记录 buf 数组中未使用字节的数量
    int free;
    //字节数组,用于保存字符串
    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  # 如果字符串之间有空格,我们可以使用双引号包起来

设置成功之后会返回一个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存在的时候才会设置,注意:对于xx来说,没有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、golang里的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
"youserx00x00x00x00you"
127.0.0.1:6379> setrange myself 3 gagaga
(integer) 9  # 对于不存在的key也是如此
127.0.0.1:6379> get myself
"x00x00x00gagaga"
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的操作,有必要提前说一下。

keys pattern:查看名称满足pattern所有的key,像string、list、hash、set、zset都有自己的key,key不可以重名,比如有一个叫name的string,那么就不可以再有一个还叫name的。

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> 

然后我们来看看如何使用Python来操作Redis,这里操作字符串

笔者是Python系的,所以如果你不搞Python的话,那么可以不用看。

import redis

# 获取得到的默认是字节,如果指定了decode_responses,会自动进行解码
client = redis.Redis(host="47.94.174.89", decode_responses="utf-8")

# 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
# 需要重新连接
print(redis.Redis(host="47.94.174.89", decode_responses="utf-8", db=15).get("name"))  # hanser

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

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

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

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

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

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

1. 页面数据缓存

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

画一张图来说明一下:

2. 数据计算与统计

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

3. 共享Session信息

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

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

  • 分布式系统单独存储Session

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

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> 

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

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

import redis

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

# 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', '古明地觉']

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

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

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

我们看一下quicklist的源码实现。

typedef struct quicklist { // src/quicklist.h
    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是一个双向链表,链表中的每一个节点实际上是一个ziplist,结构如图所示。

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

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

使用场景

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

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

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> 

然后老规矩,看看如何使用Python操作Redis的字典

import redis

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

# 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]

那么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 操作。

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

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

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

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

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> 

下面我们来看看如何使用Python操作Redis的集合

import redis

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

# 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'}
client.spop("s1", 1)
print(client.smembers("s1"))  # set()

# 7. srandmember key count
client.sadd("s1", 1, 2, 3)
print(client.smembers("s1"))  # {'2', '1', '3'}
client.srandmember("s1", 2)
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'}

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

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

当集合中所有的值都为整数时,Redis 会使用 intset 结构来存储,如下代码所示:

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")

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) {
                // 超过 inset 的最大存储数量,则使用字典类型存储
                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 的数据结构进行存储。

使用场景

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

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

Redis有序集合(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 <=结束score的value,递增排列,同理也有withscores参数

zrevrangebyscore key 结束score 开始score:获取>=开始score <=结束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> 

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

import redis

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

# 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

下面看看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")

# 元素长度超过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. 跳跃表实现原理

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

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

  • 从最上层开始找,1 比 32 小,在当前层移动到下一个节点进行比较;
  • 7 比 32 小,当前层移动下一个节点比较,由于下一个节点指向 Null,所以以 7 为目标,移动到下一层继续向后比较;
  • 18 小于 32,继续向后移动查找,对比 77 大于 32,以 18 为目标,移动到下一层继续向后比较;
  • 对比 32 等于 32,值被顺利找到。

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

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

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

使用场景

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

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

总结

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

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