Redis学习(一)基础篇

Redis: Remote Directionary Service(远程字典服务),是一款将数据存于内存NoSQL数据库

官网:https://redis.io/

中文官网: http://www.redis.cn/

中文文档:http://doc.redisfans.com/index.html

Linux版下载:https://redis.io/download

Windows版下载:https://github.com/microsoftarchive/redis/tags

1.Redis诞生历程

背景:

  • Redis 的作者笔名叫 antirez,2008 年的时候他做了一个记录网站访问情况的系统,比如每天有多少用户,多少个页面被浏览,访客的IP、操作系统、浏览器、使用的搜索关键词等等(跟百度统计、CNZZ 功能一样)。最开始存储方案用 MySQL,但是实在慢得不行,09 年的时候 antirez 就自己写了一个内存的 List,这个就是 Redis。
  • 作者网站:http://antirez.com

请添加图片描述

  • 最开始 Redis 只支持 List。现在数据类型丰富了、功能也丰富了,在全时间都非常流行。

2.SQL 与 NoSQL

背景:

​ 从 Redis 的诞生历史中我们看到,在某些场景中,关系型数据库并不适合用来存储我们的 Web 应用数据。那么,关系型数据库和非关系型数据库,或者说 SQL 和 NoSQL,到底有什么不一样呢?

关系型数据库的特点:

  1. 以表格的形式,基于行存储数据,是一个二维的模式。
  2. 存储的是结构化的数据,数据存储有固定的模式(Schema),数据需要适应表结构。
  3. 表与表之间存在关联(Relationship)。
  4. 大部分关系型数据库都支持SQL(结构化查询语言)的操作,支持复杂的关联查询。
  5. 通过支持事务(ACID)来提供严格或者实时的数据一致性。Atomicity(原子性)、Consistency(一致性)、Isolation(独立性)、Durability(持久性)。

关系型数据库的限制:

  1. 要实现扩容的话,只能向上(垂直)扩展,比如磁盘限制了数据的存储,就要扩大磁盘容量,通过堆硬件的方式,不支持动态的扩缩容。水平扩容需要复杂的技术来实现,比如分库分表。
  2. 表结构修改困难,因此存储的数据格式也受到限制。
  3. 关系型数据库通常会把数据持久化到磁盘,在高并发和高数据量的情况下,基于磁盘的读写压力比较大。

​ 为了规避关系型数据库的一系列问题,我们就有了非关系型的数据库NoSQL(Not Only SQL)

非关系型数据库的特点:

  1. 存储非结构化的数据,比如文本、图片、音频、视频。
  2. 表与表之间没有关联,可扩展性强。
  3. 保证数据的最终一致性,遵循BASE理论。Basically Available(基本可用);Soft-state(软状态);Eventually Consistent(最终一致性)。
  4. 支持海量数据的存储和高并发的高校读写。
  5. 支持分布式,能够对数据进行分片存储,扩缩容简单。

常见的非关系型数据库:

  1. KV 存储:Redis 和 Memcached。

  2. 文档存储:Elastic Search、MongoDB。

  3. 列存储:HBase。

  4. 图存储:Neo4j。

  5. 对象存储。

  6. XML 存储等等。

    这个网页列举了各种各样的 NoSQL 数据库:http://nosql-database.org/

​ 能不能把 SQL 和 NoSQL 的特性结合在一起呢?当然可以。所以现在有了所谓的 NewSQL 数据库。

​ NewSQL 结合了 SQL 和 NoSQL 的特性。例如 TiDB (PingCAP)、VoltDB、ScaleDB。

特性SQLNoSQLNewSQL
关系模型×
SQL语法×
ACID×
水平扩展×
海量数据×
无结构化××

​ 这一块的内容是为了让大家对于 Redis 的定位有一个全局的认识。也是为让大家跳出来看到,在数据存储的这个领域,有哪一些解决方案,其实我们的选择很多。

3.Redis特性

​ 对于 Redis,我们大部分时候的认识是一个缓存的组建,当然从它的发展历史我们也可以看到,它最开始并不是作为缓存使用的。只是在很多的互联网应用里面,它作为缓存发挥了最大作用。所以下面我们大概地来聊一下,Redis 的主要特性有哪些,我们为什么要使用它作为数据库的缓存。

为什么要把数据放在内存中?

  1. 内存的速度更快,10w QPS
  2. 减少计算的时间,减轻数据库压力

如果是用内存的数据结构作为缓存,为什么不用 HashMap 或者 Memcached?

  1. 更丰富的数据类型
  2. 支持多种编程语言
  3. 功能丰富:持久化机制、内存淘汰策略、事务、发布订阅、pipeline、lua
  4. 支持集群、分布式

面试题:Memcached 和 Redis 的主要区别是什么?

答案:Memcached 只能存储 KV、没有持久化机制、不支持主从复制、是多线程的。

2020年Redis发生了两件大事

  • 2020年5月2日,Redis发布了 6.0 版本(http://antirez.com/news/131)。
  • 2020年7月1日,作者antirez发文宣布退出了Redis的维护(http://antirez.com/news/133)。

4.Redis 安装启动

4.1 下载

Linux版下载:https://redis.io/download

Windows版下载:https://github.com/microsoftarchive/redis/tags

说明: Redis 作者没有为 Windows 编写 Redis 服务端,微软自行编写了一个 Redis 服务端,自从2016年之后就没有更新过,可用于简单的测试和学习。

4.2 启动

src目录下,直接启动

$ ./redis-server

后台启动(指定配置文件)

1)redis.conf 修改两行配置

daemonize yes
bind 0.0.0.0

2)启动 Redis

$ redis-server /usr/local/soft/redis-6.0.9/redis.conf

总结:redis 的参数可以通过三种方式配置:

  • redis.conf
  • 启动时,–参数
  • config set

4.3 客户端工具

​ 简单一点的用 Redis Desktop Manager 就OK了(现在是收费的)。

早期不收费版本下载:https://share.weiyun.com/AbxeYNKo

5.基本操作

5.1Redis的连接

$ redis-cli -h 服务器地址 -p 端口号 -a 密码

连接之后可以通过info命令查看redis的版本等信息:

请添加图片描述

5.2 Redis的库操作

Redis 默认有16个库(0-15)。可以在配置文件 redis.conf 中修改。

# 库的数量
database 16

​ 因为没有完全隔离,不像数据库的 database,不适合把不同的库分配给不同的业务使用。默认使用第一个db0,在集群里面只能使用第一个 db

# 切换数据库
select 0

# 清空当前数据库
flushdb

# 清空所有数据库
flushall

​ Redis 的存储我们叫做 Key-Value 存储,或者叫字典结构。key的最大长度限制是 512M,值的限制不同,有的是用长度限制,有的使用个数限制的。

5.3 Redis的增删改查

# 存值(如果对同一个key,set多次会直接覆盖旧值)
set key1 123
# 取值
get key1

# 查看所有key
keys *
# 获取当前db的key总数(生产环境数据量大,慎用)
dbsize

# 查看key是否存在
exists key1
# 删除key,支持删除多个
del key1 key2
# 重命名key
rename key1 key2

5.4 Redis 基本数据类型

# 查看key的值类型
type key1

Redis一共8种数据类型,常用前5种:

  • StringHashSetListZset、Hyperloglog、Geo、Streams
  • 最常用的数据类型是String。set 和 get 命令就是String的操作命令。
  • Redis 的 String 类型也叫做二进制安全的字符串

注意:Redis中所有的key的数据类型只有1种String,Value有8种数据类型。

接下来我们对所有的数据类型,从4个维度来分析:

  • 存储类型
  • 操作命令
  • 实现原理
  • 应用场景

5.6 String 字符串

存储类型:

  • 可以用来存储 int(整数)、float(单精度浮点数)、String(字符串)。

操作命令:

# 获取指定范围的字符
getrange key1 0 1

# 获取值长度
strlen key1

# 字符串追加内容
append key1 good

# 设置多个值(批量操作,原子性)
mset key1 111 key2 222
# 获取多个值
mget key1 key2

# 设置值,如果key存在,则不成功(set not exist)
setnx key1 123
# 基于此可实现分布式锁。用del key释放锁。
# 但如果释放锁的操作失败了,导致其他节点永远获取不到锁,怎么办?加过期时间。
# 单独用expire加过期时间,也失败了,无法保证原子性,怎么办?多参数。
set key value [expiration EX seconds|PX milliseconds][NX|XX]
# 使用参数的方式
set k1 v1 EX 10 NX

# (整数)值递增(值不存在会得到1)
incr key
# (整数)值增加100
incrby key 100

# (整数)值递减(值不存在会得到-1)
decr key
# (整数)值减少100
decrby key 100

# 浮点数增量
set fkey 1.1
incrybyfloat fkey 0.1
# 浮点数递减
incrybyfloat fkey -0.1

注意:incr 如果是对字符串或者浮点数操作会报错。

请添加图片描述

实现原理

​ Redis 是 KV 的数据库,Key-Value 我们一般会用什么数据结构来存储它?哈希表。Redis的最外层确实是通过 hashtable 实现的(我们把这个叫做外层的哈希)。

​ 在Redis里面,这个哈希表怎么实现呢?我们看一下 C 语言的源码(dict.h 47行),每一个键值对都是一个dictEntry(怪不得叫远程字典服务),通过指针指向key和value的存储结构,而且 next 存储了指向下一个键值对的指针。

typedef struct dictEntry {
	void *key;/* key 关键字定义 */
    union {
        void *val;/* value 定义 */
        uint64_t u64;
        int64_t s64;
        double d;
	}v;
    struct dictEntry *next;/* 指向下一个键值对节点 */
} dictEntry;

​ 实际上最外层是 redisDb,redisDB 里面放的是 dict。源码 server.h 661行。

typedef struct redisDb {
	dict *dict; /* 所有的键值对 *//* The keyspace for this DB */
    dict *expires; /* 设置了过期时间的键值对 *//* Timeout of keys with a timeout set */
    dict *blocking_keys; /* Keys with clients waiting for data(BLPOP) */
    dict *ready_keys;    /* Blocked keys that received a PUSH */
    dict *watched_keys;  /* WATCHED keys for MULTI/EXEC CAS */
    int id;              /* Database ID */
    long long avg_ttl;   /* Average TTL,just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle */
    list *defrag_later;  /* List of key names to attempt to defrag one by one,gradually */
} redisDb;

​ 以 set hello word 为例,因为 key 是字符串,Redis自己实现了一个字符串类型,叫做 SDS,所以 hello 指向一个 SDS 结构。

请添加图片描述

​ value是world,同样是一个字符串,是不是也用 SDS 存储呢?

​ 当value存储一个字符串的时候,Redis并没有直接使用SDS存储。而是存储在 redisObject 中。实际上五种常用的数据类型的任何一种的 value,都是通过 redisObject 来存储的。

​ 最终 redisObject 再通过一个指针指向实际的数据结构,比如字符串或者其他。

​ 我们来看一下 redisObject 怎么定义的:

redisObject

​ 源码 src/server.h 622行。

typedef struct redisObject {
    unsigned type:4; /* 对象的类型,包括:OBJ_STRING、OBJ_LIST、OBJ_HASH、OBJ_SET、OBJ_ZSET */
    unsigned encoding:4; /* 具体的数据结构 */
    unsigned lru:LRU_BITS; /* 24位,对象最后一次被命令程序访问的时间,与内存回收有关 */
    int refcount; /* 引用计数。当 refcount 为 0 的时候,表示该对象已经不被任何对象引用,则可以进行垃圾回收了 */
    void *ptr; /* 指向对象实际的数据结构 */
} robj;

​ 用 type 命令看到的类型就是 type 的内容:

127.0.0.1:6379> type qs
String

​ 这里我们就比较好奇了,为什么一个value会有一种对外的类型,还有一种实际的编码呢?我们刚才说字符串会用 SDS 存储,那这个 redisObject 的 value 就会指向一个 SDS:

请添加图片描述

​ 我们来看看这个实际的编码到底是什么:

# 举个例子
# 1.设置三个值
set number 1
set key1 "12345678901234567890123456789012345678901234567890"
set key2 yes
# 2.查看类型,均为string
type number
type key1
type key2

# 3.查看编码,分别为int、raw、enbstr
object encoding number
object encoding huihui
object encoding qs

​ 有意思了,虽然对外都是 string,用的 string 的命令,但是出现了三种不同的编码。

这三种编码有什么区别呢?

  1. int,存储 8 个字节的长整型(long,2^63-1)
  2. embstr,代表 embstr 格式的 SDS,存储小于等于 44 个字节的字符串,
  3. raw,存储大于44个字节的字符串
/* object.c */
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44

问题1、SDS是什么?

​ Redis 中字符串的实现,Simple Dynamic String 简单动态字符串。

​ 源码:sds.h 47行

struct _attribute_((_packed_))sdshdr8 {
    uint8_t len;/* 当前字符数组的长度 */
    uint8_t alloc;/* 当前字符数组总共分配的内存大小 */
    unsigned char flags;/* 当前字符数组的属性、用来表示到底是 sdshdr8 还是 sdshdr16 等 */
    char buf[];/* 字符串真正的值 */
}

​ 本质上其实还是字符数组。

​ SDS 又有多种结构(sds.h):sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同的长度的字符串,分别代表 25=32byte,28=256byte,216=65536byte=64KB,232byte=4GB。

问题2、为什么 Redis 要用 SDS 实现字符串?

​ 我们知道,因为 C 用语言本身没有字符串类型,只能用字符数组 char[] 实现。

  1. 使用字符数组必须先给目标变量分配足够的空间,否则可能会溢出。
  2. 如果要获取字符长度,必须遍历字符数组,时间复杂度是 O(n)。
  3. C 字符串长度的变更会对字符数组做内存重分配。
  4. 通过从字符串开始到结尾碰到的第一个’\0’来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全。

SDS 的特点:

  1. 不用担心内存溢出问题,如果需要会对 SDS 进行扩容
  2. 获取字符串长度时间复杂度为 O(1),因为定义了 len 属性。
  3. 通过“空间预分配”(sdsMakeRoomFor)和“惰性空间释放”,防止多次重分配内存。
  4. 判断是否结束的标识是 len 属性,可以包含 ‘\0’(它同样以 ‘\0’ 结尾是因为这样就可以使用 C 语言中的函数库操作字符串的函数了)。

请添加图片描述

问题3、embstr 和 raw 编码的区别?为什么要为不同大小设计不同编码?

​ embstr 的使用只分配一次内存空间(因为 RedisObject 和 SDS 是连续的),而 raw 需要分配两次内存空间(分别是 RedisObject 和 SDS 分配空间)。

​ 因此与 raw 相比,embstr 的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。

请添加图片描述

​ 而 embstr 的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个 RedisObject 和 SDS 都需要重新分配空间,因此 Redis 中的 embstr 实现为只读(这种编码的内容是不能修改的)。

问题4:int 和 embstr 什么时候转化为 raw?

  1. int 数据不再是整数 ——> raw
  2. int 大小超过了 long 的范围(2^63-1)——> embstr
  3. embstr 长度超过了44个字节——> raw
# 举个栗子
set k1 1
object encoding k1
# 输出"int"
append k1 a
# int数据不再是整数
object encoding k1
# 输出"raw"

set k2 9223372036854775807
object encoding k2
# 输出"int"
set k3 9223372036854775808
# int 大小超过了long的范围
object encoding k3
# 输出"embstr"

set k4 aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeee
object encoding k4
# 输出"embstr"
set k5 aaaaaaaaaabbbbbbbbbbccccccccccddddddddddeeeee
# 输出"raw"
# 注意:redis 3.2版本之前是以39字节为分界线的,redis 3.2版本之后是以44字节为分界线的。

问题:命名没有超过 44 个字节,为什么变成 raw 了?

set k6 a
object encoding k6
# 输出"embstr"
append k6 b
object encoding k6
# 输出"raw"

​ 我们前面说过了,对于 embstr,由于它的实现是只读的,因此在对 embstr 对象进行修改时,都会先转化为 raw 再进行修改。

​ 因此,只要是修改 embstr 对象,修改后的对象一定是 raw 的,无论是否到达了44个字节。

问题5:当长度小于阈值时,会还原吗?

​ 关于 Redis 内部编码的转换,都符合以下规律:编码转换在 Redis 写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换(但是不包括重新 set)。

问题6:为什么要对底层的数据结构使用 redisObject 进行一层包装呢?

​ 总结一下:其实无论是设计 redisObject,还是对存储字符设计这么多的 SDS,都是为了根据存储的不同内容选择不同的存储方式,这样可以实现尽量地节省内存空间和提升查询速度的目的。

应用场景

1)缓存

​ String 类型,缓存热点数据。例如网站热搜、网站首页、报表数据等等。可以显著提升热点数据的访问速度。

2)分布式数据共享

​ String 类型,因为 Redis 是分布式的独立服务,可以在多个应用之间共享。例如:分布式 Session

<dependency>
	<groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>

3)分布式锁

​ String 类型的 setnx 方法,只有不存在时才能添加成功,返回true。

public Boolean getLock(Object lockObject) {
    jedisUtil = getJedisConnection();
    boolean flag = jedisUtil.setNX(lockObj, 1);
    if (flag) {
        expire(lockObje, 10);
	}
    return flag;
}

public void releaseLock(Object lockObject) {
    del(lockObj);
}

4)全局ID

​ INT 类型,INCRBY,利用原子性

incrby userid 1000
# 分库分表的场景,一次性拿一段

5)计数器

​ INT 类型,INCR 方法

​ 例如:文章的阅读量,微博点赞数,允许一定的延迟,先写入 Redis 再定时同步到数据库。

6)限流

​ INT 类型,INCR 方法

​ 以访问者的 IP 和其他信息作为 key,访问一次增加一次计数,超过次数则返回false。

​ 总结一下:来利用 redis 本身的特性,和 String 内存的存储内容,以及提供的操作方法,我们可以用来打到很多的业务目的。

5.7 Hash 哈希

背景:

​ 如果一个对象的 value 有多个值的时候,怎么存储?

​ 例如:用一个 key 存储一张表的数据。

请添加图片描述

​ 序列化?例如 JSON/Protobuf/XML,会增加序列化和反序列化的开销,并且不能单独获取、修改一个值。

​ 可以通过 key 分层的方式来实现(英文冒号),例如:

mset student:1:sno STU1 student:1:sname 彪哥 student:1:company 京东

​ 获取值的时候一次获取多个值,用 list 接收:

mget student:1:sno student:1:sname student:1:company

​ 缺点:key太长,占用的空间太多。有没有更好的方式?Hash。

存储类型

​ Hash 用来存储多个无序的键值对。最大存储数量 2^32-1(40亿左右)。

请添加图片描述

​ 注意:前面我们说 Redis 所有的 KV 本身就是键值对,用 dictEntry 实现的,叫做外层的哈希。现在我们将的是内层的哈希。

​ 注意:Hash 的 value 只能是字符串,不能嵌套其他类型,比如 hash 或者 list。

同样是存储字符串,Hash 与 String 的主要区别?

  1. 把所有相关的值聚集到一个 key 中,节省内存空间。
  2. 只是用一个key,减少key冲突。
  3. 当需要批量获取值的时候,只需要使用一个命令,减少 内存/IO/CPU 的消耗。

Hash 不适用的场景:

  1. Field 不能单独设置过期时间。
  2. 需要考虑数据量分布的问题(field 非常多的时候,无法分布到多个节点)

操作命令

# 插入
hset h1 f 6
hset h1 e 5
hmset h1 a 1 b 2 c 3 d 4

# 获取
hget h1 a
hget h1 a b c d
hkeys h1
hvals h1
hgetall h1

# key操作
hdel h1 a
# 删除key
hlen h1
# 获取h1中key的数量

实现原理

​ Redis 的 Hash 本身也是一个 KV 的结构。是不是跟外层的哈希一样,用 dictEntry 实现呢?

​ 内层的哈希底层可以使用两种数据结构实现:

​ ziplist:OBJ_ENCODING_ZIPLIST(压缩列表)

​ hashtable:OBJ_ENCODING_HT(哈希表)

hset h2 f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# 64个a
hset h3 f aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
# 65个a
object encoding h2
# 输出"ziplist"
object encoding h3
# 输出"hashtable"

ziplist 压缩列表

​ ziplist 是一个经过特殊编码的,由连续内存块组成的双向链表。

​ 它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点长度和当前节点长度。这样读写可能会慢一些,因为你要去算长度,但是可以节省内存,是一种时间换空间的思想。

​ ziplist 的内部结构?源码 ziplist.c 第16行的注释:

  • <zlbytes><zltail><zllen><entry><entry>…<entry><zlend>

请添加图片描述

​ 我们最关心的 Entry 的内容呢?ziplist.c

typedef struct zlentry {
	unsigned int prevrawlensize; /* 存储上一个链表节点的长度数值所需要的字节数 */
    unsigned int prevrawlen; /* 上一个链表节点占用的长度 */
    unsigned int lensize; /* 存储当前链表节点长度数值所需要的字节数 */
    unsigned int len; /* 当前链表节点占用的长度 */
    unsigned int headersize; /* 当前链表节点的头部大小(prevrawlensize + lensize),即非数据域的大小 */
    unsigned char encoding; /* 编码方式 */
    unsigned char *p; /* 压缩链表以字符串的形式保存,该指针指向当前节点起始位置 */
} zlentry;

​ 所以展开来看是这样的:

请添加图片描述

​ 编码有哪些?

#define ZIP_STR_06B(0<<6)// 长度小于等于63字节
#define ZIP_STR_14B(1<<6)// 长度小于等于16383字节
#define ZIP_STR_32B(2<<6)// 长度小于等于4294967295字节

问题:什么时候使用 ziplist 存储?

​ 当 hash 对象同时满足以下两个条件的时候,使用 ziplist 编码:

​ 1)哈希对象保存的键值对数量 < 512 个;

​ 2)所有的键值对的键和值的字符串长度 < 64byte(一个英文字母一个字节)。

​ src/redis.conf 配置

hash-max-ziplist-value 64 // ziplist 中最大能存放的值长度
hash-max-ziplist-entries 512 // ziplist 中最多能存放的 entry 节点数量

​ 如果超过这两个阈值的任何一个,存储结构就会转换成 hashtable。

​ 总结:字段个数少,字段值小,用 ziplist。这是我们新掌握的一种数据结构。

hashtable(dict)

​ 在 Redis 中,hashtable 被称为字典(dictionary)。

​ 前面我们知道了,Redis 的 KV 结构是通过一个 dictEntry 来实现的。

​ 在 hashtable 中,又对 dictEntry 进行了多层的封装。

​ 源码位置:dict.h 47行。首先有一个 dictEntry:

typedef struct dictEntry {
	void *key; /* key 关键字定义*/
    union {
        void *val;	uint64_t u64; /* value定义 */
        int64_t s64;	double d;
    } v;
    struct dictEntry *next; /* 指向下一个键值对节点 */
} dictEntry;

​ dictEntry 放到了 dictht(hashtable 里面):

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table.*/
typedef struct dictht {
	dictEntry **table; /* 哈希表数组 */
    unsigned long size; /* 哈希表大小 */
    unsigned long sizemask; /* 掩码大小,用于计算索引值。总是等于 size-1 */
    unsigned long used; /* 已有节点数 */
} dictht;

​ ht 放到了 dict 里面:

typedef struct dict {
    dictType *type; /* 字典类型 */
    void *privdata; /* 私有数据 */
    dictht ht[2]; /* 一个字典有两个哈希表 */
    long rehashidx; /* rehash 索引 */
    unsigned long iterators; /* 当前正在使用的迭代器数量 */
} dict;

​ 从最底层到最高层 dictEntry -> dictht -> dict。他是一个数组+链表的结构。

​ 展开一下,哈希的整体存储结构:

请添加图片描述

​ 注意:dictht 后面是 NULL 说明第二个 ht 还没用到。dictEntry* 后面是 NULL 说明没有 hash 到这个地址。dictEntry 后面是 NULL 说明没有发生哈希冲突。

问题:为什么要定义两个哈希表,其中一个不用呢?

​ redis 的 hash 默认使用的是 ht[0],ht[1] 不会初始化和分配空间。

​ 哈希表 dictht 是用链地址法来解决碰撞问题的。在这种情况下,哈希表的性能取决于它的大小(size 属性)和它所保存的节点的数量(used 属性)之间的比率:

  • 比率在 1:1 时(一个哈希表 ht 只存储一个节点 entry),哈希表的性能最好;
  • 如果节点数量比哈希表的大小要大很多的话(这个比例用 ratio 表示,5 表示平均一个 ht 存储 5 个 entry),那么哈希表就会退化成多个链表,哈希表本身的性能优势就不再存在。

​ 如果单个哈希表的节点数量过多,哈希表的大小需要扩容。Redis 里面的这种操作叫做 rehash。

​ rehash 的步骤:

  1. 为字符 ht[1] 哈希表分配空间。ht[1] 的大小为第一个大于等于 ht[0].used*2 的 2 的 N 次方幂。比如已经使用了 10000,那就是 16384.
  2. 将所有的 ht[0] 上的节点 rehash 到 ht[1] 上,重新计算 hash 值和索引,然后放入指定的位置。
  3. 当 ht[0] 全部迁移到了 ht[1] 之后,释放 ht[0] 的空间,将 ht[1] 设置为 ht[0] 表,并创建新的 ht[1],为下次 rehash 做准备。

问题:什么时候触发扩容?

​ 负载因子(源码 dict.c):

static int dict_can_resize = 1; // 是否需要扩容
static unsigned int dict_force_resize_ratio = 5; // 扩容因子

​ 扩容判断和扩容的操作大家可以自己看一下,跟 HashMap 一样,也有缩容。

​ 总结一下,Redis 的 Hash 类型,可以用 ziplist 和 hashtable 来实现。

应用场景

​ String 可以做的事情,Hash 都可以做。

​ 存储对象类型的数据。比如对象或者一张表的数据,比 String 节省了更多 key 的空间,也更加便于集中管理。

​ 再补充一个案例:(购物车)

请添加图片描述

​ key:用户 id;field:商品 id;value:商品数量。

​ +1:hincr

​ -1:hdecr

​ 删除:hincrby key field -1

​ 全选:hgetall

​ 商品数:hlen

​ 简单总结一下:

  • String 底层编码有 int 和 embstr、raw。
  • Hash 用 ziplist 和 hashtable 实现。

5.8 List 集合

存储类型

​ 存储有序的字符串(从左到右),元素可以重复。最大存储数量 2^32-1(40亿左右)。

请添加图片描述

操作命令

​ 元素递减:

127.0.0.1:6379> lpush queue a
(integer) 1
127.0.0.1:6379> lpush queue b c
(integer) 3
127.0.0.1:6379> rpush queue d e
(integer) 5
127.0.0.1:6379> lpop queue
"c"
127.0.0.1:6379> rpop queue
"e"

​ 取值:

127.0.0.1:6379> lindex queue 0
"b"
127.0.0.1:6379> lrange queue 0 -1
(empty list or set)

请添加图片描述

存储类型

​ 在早期的版本中,数据量较小时用 ziplist 存储(特殊编码的双向链表),达到临界值时转换为 linkedlist 进行存储,分别对应 OBJ_ENCODING_ZIPLIST 和OBJ_ENCODING_LINKEDLIST。

​ 3.2 版本之后,统一用 quicklist 来存储。quicklist 存储了一个双向链表,每个节点都是一个 ziplist,所以是 ziplist 和 linkedlist 的结合体。

127.0.0.1:6379> object encoding queue
"ziplist"

quicklist

​ 先看总体结构:

请添加图片描述

​ quicklist.h 105 行:

typedef struct quicklist {
	quicklistNode *head; /* 指向双向列表的表头 */
    quicklistNode *tail; /* 指向双向列表的表尾 */
    unsigned long count; /* 所有的 ziplist 中一共存了多少元素 */
    unsigned long len; /* 双向链表的长度,node 的数量 */
    int fill : QL_FILL_BITS; /* ziplist 最大大小,对应 list-max-ziplist-size */
    unsigned int compress : QL_COMP_BITS; /* 压缩深度,对应 list-compress-depth */
    unsigned int bookmark_count: QL_BM_BITS; /* 4位,bookmarks 数组的大小 */
    quicklistBookmark bookmarks[]; /* bookmarks 是一个可选字段,quicklist 重新分配内存空间时使用,不使用时不占用空间 */
} quicklist;

redis.conf 相关参数:

请添加图片描述

​ quicklist.h 46 行:

typedef struct quicklistNode {
	struct quicklistNode *prev; /* 指向前一个节点 */
	struct quicklistNode *next; /* 指向后一个节点 */
	unsigned char *zl; /* 指向实际的 ziplist */
	unsigned int sz; /* 当前 ziplist 占用多少字节 */
	unsigned int count : 16; /* 当前 ziplist 中存储了多少个元素,占 16bit(下同),最大 65536 个 */
	unsigned int encoding : 2; /* 是否采用了 LZF 压缩算法压缩节点 */ /* RAW==1 or LZF==2 */
	unsigned int container : 2; /* 2: ziplist, 未来可能支持其他结构存储 */ /* NONE==1 or ZIPLIST==2 */
	unsigned int recompress : 1; /* 当前 ziplist 是不是已经被解压出来作临时使用 */
	unsigned int attempted_compress : 1; /* 测试用 */
	unsigned int extra : 10; /* 预留给未来使用 */
} quicklistNode;

​ ziplist 的结构前面已经介绍过,不再重复。

​ 总结一下:quicklist 是一个数组 + 链表的结构。

应用场景

​ List 主要用在存储有序内容的场景。大家在各种各样的网站系统里面见过什么按顺序排列的列表。

列表

​ 例如用户的消息列表、网站的公告列表、活动列表、博客的文章列表、评论列表等等。思路:存储所有字段,LRANGE 取出一页,按顺序显示。

请添加图片描述

队列/栈

​ List 还可以当作分布式环境的队列/栈使用。

​ List 提供了两个阻塞的弹出操作:BLPOP/BRPOP,可以设置超时时间(单位:秒)。

127.0.0.1:6379> blpop queue 1
1) "queue"
2) "b"
127.0.0.1:6379> brpop queue 1
1) "queue"
2) "d"

​ BLPOP: BLPOP key1 timeout 移出并获取列表的第一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

​ BRPOP:BRPOP key1 timeout 移出并获取列表的最后一个元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。

请添加图片描述

​ 队列:先进先出:rpush blpop,左头右尾,右边进入队列,左边出队列。

​ 栈:先进后出:rpush brpop

总结一下:

​ List 存储有序的内容,用 quicklist 实现,本质上是数组+链表。

​ Hashtable 也是数组+链表,只是内部编码结构不一样。

5.9 Set 集合

存储类型

​ Set 存储 String 类型的无序集合,最大存储数量 2^32-1(40亿左右)。

请添加图片描述

操作命令

# 添加一个或者多个元素
127.0.0.1:6379> sadd myset a b c d e f g
(integer) 7
# 获取所有元素
127.0.0.1:6379> smembers myset
1) "c"
2) "g"
3) "b"
4) "d"
5) "e"
6) "a"
7) "f"
# 统计元素个数
127.0.0.1:6379> scard myset
(integer) 7
# 随机获取一个元素
127.0.0.1:6379> srandmember myset
"g"
# 随机弹出一个元素
127.0.0.1:6379> spop myset
"b"
# 移除一个或者多个元素
127.0.0.1:6379> srem myset d e f
(integer) 3
# 查看元素是否存在
127.0.0.1:6379> sismember myset a
(integer) 1

存储类型

​ Redis 用 insert 或 hashtable 存储 set。如果元素都是整数类型,就用 insert 存储。

​ insert.h 35 行

typedef struct intset {
    uint32_t encoding; // 编码类型 int16_t、int32_t、int64_t
    uint32_t length; // 长度 最大长度: 2^32
    int8_t contents[]; // 用来存储成员的动态数组
} intset;

​ 如果不是整数类型,就用 hashtable(数组+链表的存储结构)。

​ 如果元素个数超过 512 个,也会用 hashtable 存储。跟一个配置有关:

set-max-intset-entries 512

​ 问题:set 的 key 没有 value,怎么用 hashtable 存储?value 存 null 就好了。

应用场景:

抽奖

​ 随机获取元素:spop myset

点赞、签到、打卡

请添加图片描述

​ 这条微博的 ID 是 t1001,用户ID 是 u3001

​ 用 like:t1001 来维护 t1001 这条微博的所有点赞用户。

​ 点赞了这条微博:sadd like:t1001 u3001

​ 取消点赞:srem like:t1001 u3001

​ 是否点赞:sismember like:t1001 u3001

​ 点赞的所有用户:smembers like:t1001

​ 点赞数:scard like:t1001

​ 比关系型数据库简单许多。

商品标签

​ 用 tags:i5001 来维护商品所有的标签。

请添加图片描述

​ sadd tags:i5001 画面清晰细腻
​ sadd tags:i5001 真彩清晰显示屏
​ sadd tags:i5001 流畅至极

商品筛选

# 获取差集
> sdiff set1 set2
# 获取交集(intersection)
> sinter set1 set2
# 获取并集
> sunion set1 set2

请添加图片描述

​ P40 上市了。

> sadd brand:huawei p40
> sadd os:android p40
> sadd sreensize:6.0-6.24 p40
# 筛选商品,华为的,android 的,屏幕在 6.0-6.24 之间的
> sinter brand:huawei os:android screensize:6.0-6.24

用户关注、推荐模型

​ 1)相互关注?

​ 2)我关注的人也关注了他?

​ 3)可能认识的人?

5.10 ZSet 有序集合

存储类型

​ sorted set 存储有序的元素。每个元素有个 score,按照 score 从小到大排名。

​ score 相同时,按照 key 的 ASCII 码排序。

请添加图片描述

数据结构对比:

请添加图片描述

操作命令

# 添加元素
zadd myzset 10 java 20 php 30 ruby 40 cpp 50 python
# 获取全部元素
zrange myzset 0 -1 withscores
zrevrang myzset 0 -1 withscores
# 根据分值区间获取元素
zrangebyscore myzset 20 30
# 移除元素 也可以根据 score rank 删除
zrem myzset php cpp
# 统计元素个数
zcard myzset
# 分值递增
zincrby myzset 5 python
# 根据分值统计个数
zcount myzset 20 60
# 获取元素 rank
zrank myzset python
# 获取元素 score
zscore myzset python
# 也有倒序的 rev 操作(reverse)

存储类型

​ 默认使用 ziplist 编码(第三次见到了,hash 的小编码、quicklist 的 Node,都是 ziplist)。

​ 在 ziplist 的内部,按照 score 排序递增来存储。插入的时候要移动之后的数据。

​ 如果元素数量大于等于 128 个,或者任一 memeber 长度大于等于 64 字节使用 skiplist+dict 存储。

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

什么是 skiplist(跳表)?

​ 我们先来看一下有序链表:

请添加图片描述

​ 在这样一个链表中,如果我们要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止。时间复杂度为 O(n)。同样,当我们要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。二分查找法只适用于有序数组,不适用于链表。

​ 加入我们每相邻两个节点增加一个指针,让指针指向下下个节点(或者理解为有三个元素进入了第二层)。

请添加图片描述

​ 这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中时 7,19,26)。

​ 问题:是那些元素运气这么好,进入到第二层?再插入一个数据的时候,决定要放到哪一层,取决于一个算法,源码:t_zset.c 122 行

int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF)<(ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

​ 现在当我们想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再到下一层进行查找。

请添加图片描述

​ 比如,我们想查找23,查找的路径是沿着标红的指针所指向的方向进行的:

1. 23 首先和 7 比较,再和 19 比较,比它们都大,继续向后比较。
2. 但 23 和 26 比较的时候,比 26 要小,因此回到下面的链表(原链表),与 19 在第一层的下一个节点 22 比较。
3. 23 比 22 要大,沿下面的指针继续向后 和 26 比较。23 比 26 小,说明待查数据 23 在原链表中不存在。

​ 在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。这就是跳跃表。

​ 为什么不用 AVL树 或者 红黑树?因为 skiplist 更加简洁。

​ 因为 level 是随机的,得到的 skiplist 可能是这样的,有些在第四层,有些在第三层,有些在第二层,有些在第一层。

请添加图片描述

​ 我们看一下 Redis 里面 skiplist 的实现:

​ 源码:server.h 904 行

typedef struct zskiplistNode {
    sds ele; /* zset 的元素 */
    double score; /* 分值 */
    struct zskiplistNode *backward; /* 后退指针 */
    struct zskiplistLevel {
        struct zskiplistNode *forward; /* 前进指针,对应 level 的下一个节点 */
        unsigned long span; /* 从当前节点到下一个节点的跨度(跨越的节点数) */
    } level[]; /* 层 */
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail; /* 指向跳跃表的头节点和尾节点 */
    unsigned long length; /* 跳跃表的节点数 */
    int level; /* 最大的层数 */
} zskiplist;

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

应用场景

顺序会动态变化的列表。

排行榜

​ 例如:百度热榜、微博热榜。

​ id 为 6001 的新闻点击数+1:zipcrby hotNews:20251111 1 n 6001

​ 获取今天点击最多的 15 条:zrevrange hotNews:20251111 0 15 withscores

5.11 其他数据结构简介

BigMaps

​ BigMaps 是在字符串类型上面定义的位操作。一个字节由 8 个二进制组成。

请添加图片描述

127.0.0.1:6379> set k1 a
OK

​ 获取 value 在 offset 处的值(a 对应的 ASCII 码 是 97,转换为二进制数据时 01100001)

127.0.0.1:6379> getbit k1 0
(integer) 0

​ 修改二进制数据

127.0.0.1:6379> setbit k1 6 1
(integer) 0
127.0.0.1:6379> setbit k1 7 0
(integer) 1
127.0.0.1:6379> get k1
"b"

问题:怎么变成 b 了

​ 答:b 对应的 ASCII 码是 98,转换为二进制数据是 01100010

​ 统计二进制位中 1 的个数

127.0.0.1:6379> bitcount k1
(integer) 3

​ 获取第一个 1 或者 0 的位置

127.0.0.1:6379> bitpos k1 1
(integer) 1
127.0.0.1:6379> bitpos k1 0
(integer 0)

​ 因为 bit 非常节省空间(1 MB = 8388608 bit),可以用来做大数据量的统计。

​ 例如:在线用户统计,存留用户统计

127.0.0.1:6379> setbit onlineusers 0 1
(integer) 0
127.0.0.1:6379> setbit onlineusers 1 1
(integer) 0
127.0.0.1:6379> setbit onlienusers 2 0
(integer) 0

​ 支持按位与、按位或等操作。

127.0.0.1:6379> set key1 0
127.0.0.1:6379> set key2 1
# 对一个或多个 key 求逻辑与,并将结果保存到 destkey
127.0.0.1:6379> bitop and destkey key1 [key2 ...]
(integer) 1
# 对一个或多个 key 求逻辑或,并将结果保存到 destkey
127.0.0.1:6379> bitop or destkey key1 [key2 ...]
(integer) 1
# 对一个或多个 key 求逻辑异或,并将结果保存到 destkey
127.0.0.1:6379> bitop xor destkey key1 [key2 ...]
(integer) 1
# 对给定 key 求逻辑非,并将结果保存到 destkey
127.0.0.1:6379> bitop not destkey key1
(integer) 1

​ 计算出 7 天都在线的用户(假设用户编号有序,依次放在位图)

127.0.0.1:6379> bitop and 7_days_both_online_users day_1_online_users day_2_online_users ... day_7_online_users

应用场景

​ 用户访问统计

​ 在线用户统计

Hyperloglogs

​ Hyperloglogs:提供了一种不太精确的基数统计方法,用来统计一个集合中不重复的元素个数,比如统计网站的 UV(独立访客量),货主额应用的日活、月活,存在一定的误差。

​ 代码:HyperLogLogTest.java

​ 在 Redis 中实现的 HyperLogLog,只需要 12K 内存就能统计 2^64 个数据。

Geo

​ 在消费金融公司,给客户使用的客户端有这么一种需求,要获取半径 1 公里以内的门店,那么我们就要把门店的经纬度保存起来。那个时候我们是直接把经纬度保存在数据库的,一个字段存经度一个字段存维度。计算距离比较复杂。Redis 的 Geo 直接提供了这个方法

127.0.0.1:6379> geoadd location 112.881953 28.238426 location1
(integer) 1
127.0.0.1:6379> geopos location location1
1) 1) "112.8819546103477478"
    2) "28.23842480810194644"

​ 操作:增加地址位置信息、获取地址位置信息、计算两个位置的距离、获取指定范围内的地理位置集合等等。GeoTest.java

Streams

​ 5.0 推出的数据类型。支持多播的可持久化的消息队列,用于实现发布订阅功能,借鉴了 kafka 的设计。

6.总计

数据结构总结

请添加图片描述

编码转换总结

请添加图片描述

应用场景总结

​ 缓存——提升热点数据的访问速度

​ 共享数据——数据的存储和共享的问题

​ 全局 ID——分布式全局ID的生成方案(分库分表)

​ 分布式锁——进程间共享数据的原子操作保证

​ 在线用户统计和计数

​ 队列、栈——跨进程的队列/栈

​ 消息队列——异步解耦的消息机制

​ 服务注册与发现——RPC通信机制的服务协调中心(Dubbo 支持 Redis)

​ 购物车

​ 新浪/Twitter 用户消息时间线

​ 抽奖逻辑(礼物、转发)

​ 点赞、签到、打卡

​ 商品标签

​ 用户(商品)关注(推荐)模型

​ 电商产品筛选

​ 排行榜

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

不愿放下技术的小赵

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值