Redis 五大数据结构

1,132 阅读25分钟

Redis 发展到现在已经有 9 种数据类型了,其中最基础、最常用的数据类型有 5 种,它们分别是:字符串类型、列表类型、哈希表类型、集合类型、有序集合类型

整体大纲

image.png

1、字符串

字符串类型的全称是 Simple Dynamic Strings 简称 SDS,中文意思是:简单动态字符串

它是以键值对 key-value 的形式进行存储的,根据 key 来存储和获取 value 值,它的使用相对来说比较简单,但在实际项目中应用非常广泛。

字符串类型能做什么?

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

  • 字符串存储和操作
  • 整数类型和浮点类型的存储和计算

页面数据缓存

使用场景:去数据库中查询同样的数据

正常来说,我们的每次请求都去数据库中查,而数据库是持久化的,也就是说是存放在硬盘中的,速度慢,而且压力大。

我们可以把查询结果放入缓存(Redis)中,让下次同样的查询直接去缓存系统取结果,而非查询数据库,这样既减少了数据库的压力,同时也提高了程序的运行速度。

具体流程:先将数据序列化为字符串存入缓存,再从缓存中读取到字符串,反序列化成对象,然后再赋值到页面进行显示。

注意:这里的序列化不是文件的序列化,是 JSON 序列化和反序列化,因为 JSON 数据在 Java 中是字符串形式的,并且通过一些工具类可以很轻松的将 JSON 转换成对象。

数字计算与统计

使用场景:统计访问量

访问量的统计是很频繁的(如果访问人数多的话),用 Redis 来统计性能高,而且很方便,一行命令即可搞定。

共享 Session 信息

Session 就是用户信息,保存在服务器上的。假如我们做负载均衡或者高可用的话,这就意味着我们有多台服务器。这就意味着我们得考虑多台服务器的 Session 同步问题,否则会出现明明已经认证过了,但会出现未认证的情况。这种用 Redis 来实现是很合适的。将所有的 Session 都存储到 Redis 中,这样所有服务器都能共享,只用保存一份即可。

这也就是我们通常所说的分布式 Session

分布式session的几种解决方案,你中意哪种?

字符串如何使用?

命令行操作方式

字符串的操作命令有很多,但大体可分为以下几类:

  • 单个键值对操作
  • 多个键值对操作
  • 数字统计

使用 redis-cli 来实现对 Redis 的操作。

这些命令得自己多操作,背 API 无意义。

单个键值对操作

(1)添加键值对

语法:set key value [expiration EX seconds|PX milliseconds] [NX|XX],示例:

(2)获取键值对

语法:get key

(3)给元素追加值

语法:append key value

(4)查询字符串的长度

语法:strlen key

多个键值对操作

(1)创建一个或多个键值对

语法:mset key value [key value …]

mset 是一个原子性(atomic)操作,所有给定 key 都会在同一时间内被设置,不会出现某些 key 被更新,而另一些 key 没被更新的情况。

(2)查询一个或多个元素

语法:mget key [key …]

数字统计

Redis 中可以直接操作整型和浮点型,例如可以直接使用命令来加、减值。

(1)给整数类型的值加 1

语法:incr key

(2)给整数类型的值减 1

语法:decr key

(3)根据 key 减去指定的值

语法:decrby key decrement

如果 key 不存在,则会先初始化此 key 为 0 ,然后再执行减整数值操作。

(4)根据 key 加指定的整数值

语法:incrby key increment

如果 key 不存在,则会先初始化此 key 为 0 ,然后再执行加整数值的操作。

(5)根据 key 加上指定的浮点数

语法:incrbyfloat key increment

如果 key 不存在,则会先初始化此 key 为 0 ,然后再执行加浮点数的操作。

更多使用命令:官方文档命令手册

代码操作方式

Jedis 是 Redis 官方推荐的 Java 客户端开发包,用于实现快速简单的操作 Redis

(1)引入依赖(可以去 Maven 仓库中找自己想要的版本)

<!-- https://mvnrepository.com/artifact/redis.clients/jedis -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>3.7.0</version>
</dependency>

(2)代码:

import redis.clients.jedis.Jedis;
import java.util.List;

public class StringExample {
    public static void main(String[] args) {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        // jedis.auth("xxx"); // 输入密码,没有密码,可以不设置
        // 添加一个元素
        jedis.set("mystr", "redis");
        // 获取元素
        String myStr = jedis.get("mystr");
        System.out.println(myStr); // 输出:redis
        // 添加多个元素(key,value,key2,value2)
        jedis.mset("db", "redis", "lang", "java");
        // 获取多个元素
        List<String> mlist = jedis.mget("db", "lang");
        System.out.println(mlist);  // 输出:[redis, java]
        // 给元素追加字符串
        jedis.append("db", ",mysql");
        // 打印追加的字符串
        System.out.println(jedis.get("db")); // 输出:redis,mysql
        // 当 key 不存在时,赋值键值
        Long setnx = jedis.setnx("db", "db2");
        // 因为 db 元素已经存在,所以会返回 0 条修改
        System.out.println(setnx); // 输出:0
        // 字符串截取
        String range = jedis.getrange("db", 0, 2);
        System.out.println(range); // 输出:red
        // 添加键值并设置过期时间(单位:毫秒)
        String setex = jedis.setex("db", 1000, "redis");
        System.out.println(setex); // 输出:ok
        // 查询键值的过期时间
        Long ttl = jedis.ttl("db");
        System.out.println(ttl); // 输出:1000
    }
}

代码实战

首先添加 JSON 转换类,用于对象和字符串之间的序列化和反序列化。使用谷歌的 gson 来实现。

MSON,让JSON序列化更快

(1)添加依赖

<!-- https://mvnrepository.com/artifact/com.google.code.gson/gson -->
<dependency>
  <groupId>com.google.code.gson</groupId>
  <artifactId>gson</artifactId>
  <version>2.8.6</version>
</dependency>

(2)序列化到 Redis

Jedis jedis = new Jedis("xxx.xxx.xxx.xxx", 6379);
jedis.auth("xxx");
Gson gson = new Gson();
// 构建用户数据
User user = new User();
user.setId(1);
user.setName("Redis");
user.setAge(10);
String jsonUser = gson.toJson(user);
// 打印用户信息(json)
System.out.println(jsonUser); // 输出:{"id":1,"name":"Redis","age":10}
// 把字符串存入 Redis
jedis.set("user", jsonUser);

(3)从 Redis 反序列化出来

String getUserData = jedis.get("user");
User userData = gson.fromJson(getUserData, User.class);
// 打印对象属性信息
System.out.println(userData.getId() + ":" + userData.getName()); // 输出结果:1:Redis

字符串的内部实现(重要)

源码分析

Redis 3.2 之前 SDS 源码如下:

struct sds{
    int len; // 已占用的字节数
    int free; // 剩余可用字节数
    char buf[]; // 存储字符串的数据空间
}

为了更加有效的利用内存,Redis 3.2 优化了 SDS 的存储结构,源码如下:

typedef char *sds;

struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串长度小于 1<<5
    unsigned char flags;
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 对应的字符串长度小于 1<<8
    uint8_t len; /* 已使用长度,1 字节存储 */
    uint8_t alloc; /* 总长度 */
    unsigned char flags; 
    char buf[]; // 真正存储字符串的数据空间
};
struct __attribute__ ((__packed__)) sdshdr16 { // 对应的字符串长度小于 1<<16
    uint16_t len; /* 已使用长度,2 字节存储 */
    uint16_t alloc; 
    unsigned char flags; 
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 { // 对应的字符串长度小于 1<<32
    uint32_t len; /* 已使用长度,4 字节存储 */
    uint32_t alloc; 
    unsigned char flags; 
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 { // 对应的字符串长度小于 1<<64
    uint64_t len; /* 已使用长度,8 字节存储 */
    uint64_t alloc; 
    unsigned char flags; 
    char buf[];
};

这样就可以针对不同长度的字符串申请相应的存储类型,从而有效的节约了内存使用。

数据类型

可以使用 object encoding key 命令来查看对象(键值对)存储的数据类型,SDS 对象包含了三种不同的数据类型:int、embstr 和 raw

int 类型
127.0.0.1:6379> set key 666
OK
127.0.0.1:6379> object encoding key
"int"
embstr 类型
127.0.0.1:6379> set key abc
OK
127.0.0.1:6379> object encoding key
"embstr"
raw 类型
127.0.0.1:6379> set key abcdefghigklmnopqrstyvwxyzabcdefghigklmnopqrs
OK
127.0.0.1:6379> object encoding key
"raw"

int 类型很好理解,整数类型对应的就是 int 类型,而字符串则对应是 embstr 类型,当字符串长度大于 44 字节时,会变为 raw 类型存储。

为什么是 44 字节?

Redis 中,如果 SDS 的存储值大于 64 字节时,Redis 的内存分配器会认为此对象为大字符串,并使用 raw 类型来存储,当数据小于 64 字节时(字符串类型),会使用 embstr 类型存储。

既然内存分配器的判断标准是 64 字节,那为什么 embstr 类型和 raw 类型的存储判断值是 44 字节?

这是因为 Redis 在存储对象时,会创建此对象的关联信息,redisObject 对象头和 SDS 自身属性信息,这些信息都会占用一定的存储空间,因此长度判断标准就从 64 字节变成了 44 字节。

Redis 中,所有的对象都会包含 redisObject 对象头。我们先来看 redisObject 对象的源码:

typedef struct redisObject {
    unsigned type:4; // 4 bit
    unsigned encoding:4; // 4 bit
    unsigned lru:LRU_BITS; // 3 个字节
    int refcount; // 4 个字节
    void *ptr; // 8 个字节
} robj;
  • type:对象的数据类型,例如:string、list、hash 等,占用 4 bits 也就是半个字符的大小;
  • encoding:对象数据编码,占用 4 bits
  • lru:记录对象的 LRU(Least Recently Used 的缩写,即最近最少使用)信息,内存回收时会用到此属性,占用 24 bits(3 字节)
  • refcount:引用计数器,占用 32 bits(4 字节)
  • *ptr:对象指针用于指向具体的内容,占用 64 bits(8 字节)

redisObject 总共占用 0.5 bytes + 0.5 bytes + 3 bytes + 4 bytes + 8 bytes = 16 bytes(字节)。

2、字典

字典类型 (Hash) 又被称为散列类型或者是哈希表类型,它是将一个键值 (key) 和一个特殊的“哈希表”关联起来,这个“哈希表”表包含两列数据:字段和值

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

使用场景

  • 商品购物车,购物车非常适合用哈希字典表示,使用用户唯一 ID 作为字典的 keyvalue 值可以存储商品的 id 和数量等信息;
  • 存储用户的属性信息,使用用户唯一 ID 作为字典的 keyvalue 值为属性字段和对应的值;
  • 存储文章详情页信息等,文章 ID 对应文章详情。

基础使用

插入单个元素

语法:hset key field value

当某键不存在时,插入数据

语法:hsetnx key field value

如果尝试插入已存在的键,不会改变原来的值。

查询单个元素

语法:hget key field

删除 key 中的一个或多个元素

语法:hdel myhash field [field ...]

注意:不能使用类似于 hdel myhash 的命令删除整个 Hash 值的。

某个整数值累加计算

语法:hincrby key field increment

插入一个或多个元素

语法:hmset key field value [field value ...]

查询一个或多个元素

语法:hmget key field [field ...]

查询某个 key 的所有字段

语法:hkeys key

查询某个 key 的所有值

语法:hvals key

查询某个 key 的所有字段和值

语法:hgetall key

某个浮点值累加计算

语法:hincrbyfloat key field increment

查询元素是否存在

语法:hexists key field

查询元素个数

语法:hlen key

代码实战

同样是使用 Jedis 来操作 Redis

import redis.clients.jedis.Jedis;
import java.util.Map;

public class HashExample {
    public static void main(String[] args) throws InterruptedException {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        // 把 Key 值定义为变量
        final String REDISKEY = "myhash";
        // 插入单个元素
        jedis.hset(REDISKEY, "key1", "value1");
        // 查询单个元素
        Map<String, String> singleMap = jedis.hgetAll(REDISKEY);
        System.out.println(singleMap.get("key1"));  // 输出:value1
        // 查询所有元素
        Map<String, String> allMap = jedis.hgetAll(REDISKEY);
        System.out.println(allMap.get("k2")); // 输出:val2
        System.out.println(allMap); // 输出:{key1=value1, k1=val1, k2=val2, k3=9.2, k4=v4...}
        // 删除单个元素
        Long delResult = jedis.hdel(REDISKEY, "key1");
        System.out.println("删除结果:" + delResult);    // 输出:删除结果:1
        // 查询单个元素
        System.out.println(jedis.hget(REDISKEY, "key1")); // 输出:返回 null
    }
}

从代码中可以看出,在 Jedis 中我们可以直接使用 Map 来接收 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 的机制。

扩容

当元素数量等于数组长度时就会进行扩容操作,源码在 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 操作。

缩容

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

渐进式 rehash 流程

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

  • 扩容或者缩容时把字典中的字段 rehashidx 标识为 0;

  • 在执行定时任务或者执行客户端的 hset、hdel 等操作指令时,判断是否需要触发 rehash 操作(通过 rehashidx 标识判断),如果需要触发 rehash 操作,也就是调用 dictRehash 函数,dictRehash 函数会把 ht[0] 中的元素依次添加到新的 Hash 表 ht[1] 中;

  • rehash 操作完成之后,清空 Hash 表 ht[0],然后对调 ht[1] 和 ht[0] 的值,把新的数据表 ht[1] 更改为 ht[0],然后把字典中的 rehashidx 标识为 -1,表示不需要执行 rehash 操作。

3、列表

列表类型 (List) 是一个使用链表结构存储的有序结构,它的元素插入会按照先后顺序存储到链表结构中,也就是说是有序的

它的元素操作 (插入\删除) 时间复杂度为 O(1),所以相对来说速度还是比较快的,但它的查询时间复杂度为 O(n),因为会遍历链表才能找到想要的值,因此查询可能会比较慢。

image-20220128140940896

使用场景

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

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

基础使用

给列表添加一个或多个元素

语法:lpush key value [value …]

给列表尾部添加一个或多个元素

语法:rpush key value [value …]

返回列表指定区间内的元素

语法:lrange key start stop

获取并删除列表的第一个元素

语法:lpop key

获取并删除列表的最后一个元素

语法:rpop key

根据下标获取对应的元素

语法:lindex key index

在某值之前/之后添加某个元素

语法:linsert key before|after pivot value

根据下标修改元素

语法:lset key index value

根据下标删除元素

语法:ltrim key start stop

查询列表的长度

语法:llen key

删除指定个数的元素

语法lrem key count value

代码实战

同样是使用 Jedis 操作 Redis

public class ListExample {
    public static void main(String[] args) {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        // 声明 Redis key
        final String REDISKEY = "list";
        // 在头部插入一个或多个元素
        Long lpushResult = jedis.lpush(REDISKEY, "Java", "Sql");
        System.out.println(lpushResult); // 输出:2
        // 获取第 0 个元素的值
        String idValue = jedis.lindex(REDISKEY, 0);
        System.out.println(idValue); // 输出:Sql
        // 查询指定区间的元素
        List<String> list = jedis.lrange(REDISKEY, 0, -1);
        System.out.println(list); // 输出:[Sql, Java]
        // 在元素 Java 前面添加 MySQL 元素
        jedis.linsert(REDISKEY, ListPosition.BEFORE, "Java", "MySQL");
        System.out.println(jedis.lrange(REDISKEY, 0, -1)); // 输出:[Sql, MySQL, Java]
        jedis.close();
    }
}

内部实现

debug encoding key 来查看列表类型的内部存储类型。列表类型的底层数据类型是 quicklist

quicklist (快速列表)Redis 3.2 引入的数据类型,早期的列表类型使用的是 ziplist (压缩列表)双向链表组成的,Redis 3.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,它们的结构如下图所示:

image-20220128141328303

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

image-20220128141430167

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

源码解析

添加功能源码分析

quicklist 添加操作对应函数是 quicklistPush,源码如下

void quicklistPush(quicklist *quicklist, void *value, const size_t sz,
                   int where) {
    if (where == QUICKLIST_HEAD) {
        // 在列表头部添加元素
        quicklistPushHead(quicklist, value, sz);
    } else if (where == QUICKLIST_TAIL) {
        // 在列表尾部添加元素
        quicklistPushTail(quicklist, value, sz);
    }
}

quicklistPushHead 为例,源码如下:

int quicklistPushHead(quicklist *quicklist, void *value, size_t sz) {
    quicklistNode *orig_head = quicklist->head;
    if (likely(
            _quicklistNodeAllowInsert(quicklist->head, quicklist->fill, sz))) {
        // 在头部节点插入元素
        quicklist->head->zl =
            ziplistPush(quicklist->head->zl, value, sz, ZIPLIST_HEAD);
        quicklistNodeUpdateSz(quicklist->head);
    } else {
        // 头部节点不能继续插入,需要新建 quicklistNode、ziplist 进行插入
        quicklistNode *node = quicklistCreateNode();
        node->zl = ziplistPush(ziplistNew(), value, sz, ZIPLIST_HEAD);
        quicklistNodeUpdateSz(node);
        // 将新建的 quicklistNode 插入到 quicklist 结构中
        _quicklistInsertNodeBefore(quicklist, quicklist->head, node);
    }
    quicklist->count++;
    quicklist->head->count++;
    return (orig_head != quicklist->head);
}

插入流程图:

列表类型使用-插入流程图.png

删除功能源码分析

源码位于 src/quicklist.c 文件中。

单一元素删除
void quicklistDelEntry(quicklistIter *iter, quicklistEntry *entry) {
    quicklistNode *prev = entry->node->prev;
    quicklistNode *next = entry->node->next;
    // 删除指定位置的元素
    int deleted_node = quicklistDelIndex((quicklist *)entry->quicklist,
                                         entry->node, &entry->zi);
    //...
}

可以看出 quicklistDelEntry 函数的底层,依赖 quicklistDelIndex 函数进行元素删除。

区间元素删除
// start 表示开始删除的下标,count 表示要删除的个数
int quicklistDelRange(quicklist *quicklist, const long start,
                      const long count) {
    if (count <= 0)
        return 0;
    unsigned long extent = count; 
    if (start >= 0 && extent > (quicklist->count - start)) {
        // 删除的元素个数大于已有元素
        extent = quicklist->count - start;
    } else if (start < 0 && extent > (unsigned long)(-start)) {
        // 删除指定的元素个数
        extent = -start; /* c.f. LREM -29 29; just delete until end. */
    }
    //...
    // extent 为剩余需要删除的元素个数,
    while (extent) {
        // 保存下个 quicklistNode,因为本节点可能会被删除
        quicklistNode *next = node->next;
        unsigned long del;
        int delete_entire_node = 0;
        if (entry.offset == 0 && extent >= node->count) {
            // 删除整个 quicklistNode
            delete_entire_node = 1;
            del = node->count;
        } else if (entry.offset >= 0 && extent >= node->count) {
           // 删除本节点的所有元素
            del = node->count - entry.offset;
        } else if (entry.offset < 0) {
            // entry.offset<0 表示从后向前,相反则表示从前向后剩余的元素个数
            del = -entry.offset;
            if (del > extent)
                del = extent;
        } else {
            // 删除本节点部分元素
            del = extent;
        }
        D("[%ld]: asking to del: %ld because offset: %d; (ENTIRE NODE: %d), "
          "node count: %u",
          extent, del, entry.offset, delete_entire_node, node->count);
        if (delete_entire_node) {
            __quicklistDelNode(quicklist, node);
        } else {
            quicklistDecompressNodeForUse(node);
            node->zl = ziplistDeleteRange(node->zl, entry.offset, del);
            quicklistNodeUpdateSz(node);
            node->count -= del;
            quicklist->count -= del;
            quicklistDeleteIfEmpty(quicklist, node);
            if (node)
                quicklistRecompressOnly(quicklist, node);
        }
        // 剩余待删除元素的个数
        extent -= del;
        // 下个 quicklistNode
        node = next;
        // 从下个 quicklistNode 起始位置开始删除
        entry.offset = 0;
    }
    return 1;
}

quicklist 在区间删除时,会先找到 start 所在的 quicklistNode,计算删除的元素是否小于要删除的 count,如果不满足删除的个数,则会移动至下一个 quicklistNode 继续删除,依次循环直到删除完成为止。

更多源码

  • quicklistCreate:创建 quicklist
  • quicklistInsertAfter:在某个元素的后面添加数据;
  • quicklistInsertBefore:在某个元素的前面添加数据;
  • quicklistPop:取出并删除列表的第一个或最后一个元素;
  • quicklistReplaceAtIndex:替换某个元素。

4、集合

集合类型 (Set) 是一个无序并唯一的键值集合

无序集合:它的存储顺序不会按照插入的先后顺序进行存储。

集合类型和列表类型的区别如下

  • 列表可以存储重复元素,集合只能存储非重复元素
  • 列表是按照元素的先后顺序存储元素的,而集合则是无序方式存储元素的。

使用场景

  • 微博关注我的人和我关注的人都适合用集合存储,可以保证人员不会重复
  • 中奖人信息也适合用集合类型存储,这样可以保证一个人不会重复中奖
  • 把双方的好友分别存储到两个集合中,求出两个集合交集的方式得到共同好友
  • 微信抽奖小程序
  • 微信朋友圈点赞

基础使用

集合类型的功能比列表类型丰富一些,集合类型可以用来统计多个集合的交集、错集和并集

添加一个或多个元素

语法:sadd key member [member ...]

查询集合所有元素

语法:smembers key

查询集合的成员数量

语法:scard key

查询集合中是否包含某个元素

语法:sismember key member

从一个集合中移动一个元素到另一个集合

语法:smove source destination member

移除集合中一个或多个元素

语法:srem key member [member ...]

注意:使用 srem 指令,不存在的元素将会被忽略。

移除并返回集合中的一个随机元素

语法:spop key [count]

随机返回集合中指定数量的元素列表

语法:srandmember key [count]

返回一个集合或多个集合的交集

语法:sinter key [key ...]

把集合的交集复制到新的集合中

语法:sinterstore destination key [key ...]

查询一个或多个集合的并集

语法:sunion key [key ...]

把一个或多个集合的并集复制到新集合中

语法:sunionstore destination key [key ...]

查询一个或多个集合的错集

语法:sdiff key [key ...]

把一个或多个集合的错集复制到新集合

语法:sdiffstore destination key [key ...]

代码实战

import redis.clients.jedis.Jedis;
import java.util.Set;

public class SetExample {
    public static void main(String[] args) {
        Jedis jedis = new Jedis("xxx.xxx.xxx.xxx", 6379);
        jedis.auth("xxx");
        // 创建集合并添加元素
        jedis.sadd("set1", "java", "golang");
        // 查询集合中的所有元素
        Set<String> members = jedis.smembers("set1");
        System.out.println(members); // 输出:[java, golang]
        // 查询集合中的元素数量
        System.out.println(jedis.scard("set1"));
        // 移除集合中的一个元素
        jedis.srem("set1", "golang");
        System.out.println(jedis.smembers("set1")); // 输出:[java]
        // 创建集合 set2 并添加元素
        jedis.sadd("set2", "java", "golang");
        // 查询两个集合中交集
        Set<String> inters = jedis.sinter("set1", "set2");
        System.out.println(inters); // 输出:[java]
        // 查询两个集合中并集
        Set<String> unions = jedis.sunion("set1", "set2");
        System.out.println(unions); // 输出:[java,golang]
        // 查询两个集合的错集
        Set<String> diffs = jedis.sdiff("set2", "set1");
        System.out.println(diffs); // 输出:[golang]
    }
}

内部实现

集合类型是由 intset (整数集合) 或 hashtable (普通哈希表) 组成的

  • 当元素为非整数时,集合会使用 hashtable 进行存储。哈希表的 key 为要插入的元素值,而哈希表的 value 则为 Null
  • 当所有元素都为整数时,集合会以 intset 结构进行(数据)存储

两种情况,会导致集合类型使用 hashtable

(1)当元素的个数超过一定数量时,默认是 512 个,该值可通过命令 set-max-intset-entries xxx 来配置。

(2)当元素为非整数时,集合将会使用 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 的数据结构进行存储。

5、有序集合

有序集合类型 (Sorted Set) 相比于集合类型多了一个排序属性 score(分值)

对于有序集合 ZSet 来说,每个存储元素相当于由两个值组成的,一个是有序集合的元素值,一个是排序值。

有序集合的存储元素值也是不能重复的,但分值是可以重复的。

使用场景

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

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

基础使用

添加一个或多个元素

语法:zadd key [NX|XX] [CH] [INCR] score member [score member ...]

查询所有元素列表

语法:zrange key start stop [WITHSCORES]

删除一个或多个元素(根据元素值)

语法:zrem key member [member ...]

查询某元素的 score 值

语法:zscore key member

查询 score 区间内元素

语法:zrangebyscore key min max [WITHSCORES] [LIMIT offset count]

查询某元素排名

语法:zrank key member

排名是从 0 开始的,排名可以理解为元素排序后的下标值

查询有序集合的总个数

语法:zcard key

查询 score 区间内的元素个数

语法:zcount key min max

累加元素的 score 值

语法:zincrby key increment member

查询某元素倒序排名

语法:zrevrank key member

根据排名删除元素

语法:zremrangebyrank key start stop

删除 score 区间内的元素

语法:zremrangebyscore key min max

复制交集元素到新集合

语法:zinterstore destination numkeys key [key …] [WEIGHTS weight] [AGGREGATE SUM|MIN|MA

参数 numkeys 表示需要几个集合参与查询。

复制并集元素到新集合

语法:zunionstore destination numkeys key [key …] [WEIGHTS weight] [AGGREGATE SUM|MIN|MA

代码实战

import redis.clients.jedis.Jedis;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;

public class ZSetExample {
    public static void main(String[] args) {
        Jedis jedis = new Jedis("127.0.0.1", 6379);
        Map<String, Double> map = new HashMap<>();
        map.put("小明", 80.5d);
        map.put("小红", 75d);
        map.put("老王", 85d);
        // 为有序集合(ZSet)添加元素
        jedis.zadd("grade", map);
        // 查询分数在 80 分到 100 分之间的人(包含 80 分和 100 分)
        Set<String> gradeSet = jedis.zrangeByScore("grade", 80, 100);
        System.out.println(gradeSet); // 输出:[小明, 老王]
        // 查询小红的排名(排名从 0 开始)
        System.out.println(jedis.zrank("grade", "小明")); // 输出:1
        // 从集合中移除老王
        jedis.zrem("grade", "老王");
        // 查询有序集合中的所有元素(根据排名从小到大)
        Set<String> range = jedis.zrange("grade", 0, -1);
        System.out.println(range); // 输出:[小红, 小明]
        // 查询有序集合中的所有元素(根据 score 从小到大)
        Set<String> rangeByScore = jedis.zrangeByScore("grade", 0, 100);
        System.out.println(rangeByScore);
    }
}

内部实现

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

ziplist

当数据比较少时,有序集合使用的是 ziplist 存储的。

有序集合使用 ziplist 格式存储必须满足以下两个条件:

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

当不满足这两个条件中的任何一个时,有序集合就会从 ziplist 转换成为 skiplist

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

skiplist

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

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

跳跃表实现原理

跳跃表(skip list)

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

有序集合-跳跃表.png

当我们在跳跃表中查询值 32 时,执行流程如下:

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

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

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

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

查询和更新的时间复杂度都是 O(LogN),只是常数时间略有不一样。

参考

《Redis 核心原理与实战》