Redis原理与实战 | 青训营笔记

170 阅读18分钟

Redis数据结构

1)字符串 string

设置和获取键值对

> SET key value
OK
> GET key
"value"

正如你看到的,我们通常使用 SETGET 来设置和获取字符串值。

值可以是任何种类的字符串(包括二进制数据),例如你可以在一个键下保存一张 .jpeg 图片,只需要注意不要超过 512 MB 的最大限度就好了。

当 key 存在时,SET 命令会覆盖掉你上一次设置的值:

> SET key newValue
OK
> GET key
"newValue"

另外你还可以使用 EXISTSDEL 关键字来查询是否存在和删除键值对:

> EXISTS key
(integer) 1
> DEL key
(integer) 1
> GET key
(nil)

批量设置键值对

> SET key1 value1
OK
> SET key2 value2
OK
> MGET key1 key2 key3    # 返回一个列表
1) "value1"
2) "value2"
3) (nil)
> MSET key1 value1 key2 value2
> MGET key1 key2
1) "value1"
2) "value2"

过期和 SET 命令扩展

可以对 key 设置过期时间,到时间会被自动删除,这个功能常用来控制缓存的失效时间。 (过期可以是任意数据结构)

> SET key value1
> GET key
"value1"
> EXPIRE name 5    # 5s 后过期
...                # 等待 5s
> GET key
(nil)

等价于 SET + EXPIRESETNX 命令:

> SETNX key value1
...                # 等待 5s 后获取
> GET key
(nil)

> SETNX key value1  # 如果 key 不存在则 SET 成功
(integer) 1
> SETNX key value1  # 如果 key 存在则 SET 失败
(integer) 0
> GET key
"value"             # 没有改变

计数

如果 value 是一个整数,还可以对它使用 INCR 命令进行 原子性 的自增操作,这意味着及时多个客户端对同一个 key 进行操作,也决不会导致竞争的情况:

> SET counter 100
> INCR count
(interger) 101
> INCRBY counter 50
(integer) 151

返回原值的 GETSET 命令

对字符串,还有一个 GETSET 比较让人觉得有意思,它的功能跟它名字一样:为 key 设置一个值并返回原值:

> SET key value
> GETSET key value1
"value"

这可以对于某一些需要隔一段时间就统计的 key 很方便的设置和查看,例如:系统每当由用户进入的时候你就是用 INCR 命令操作一个 key,当需要统计时候你就把这个 key 使用 GETSET 命令重新赋值为 0,这样就达到了统计的目的。

2)列表 list

Redis 的列表相当于 Java 语言中的 LinkedList,注意它是链表而不是数组。这意味着 list 的插入和删除操作非常快,时间复杂度为 O(1),但是索引定位很慢,时间复杂度为 O(n)。

我们可以从源码的 adlist.h/listNode 来看到对其的定义:

/* Node, List, and Iterator are the only data structures used currently. */

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

typedef struct listIter {
    listNode *next;
    int direction;
} listIter;

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned long len;
} list;

可以看到,多个 listNode 可以通过 prevnext 指针组成双向链表:

image.png

链表的基本操作

  • LPUSHRPUSH 分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素;
  • LRANGE 命令可以从 list 中取出一定范围的元素;
  • LINDEX 命令可以从 list 中取出指定下表的元素,相当于 Java 链表操作中的 get(int index) 操作;

示范:

> rpush mylist A
(integer) 1
> rpush mylist B
(integer) 2
> lpush mylist first
(integer) 3
> lrange mylist 0 -1    # -1 表示倒数第一个元素, 这里表示从第一个元素到最后一个元素,即所有
1) "first"
2) "A"
3) "B"

list 实现队列

队列是先进先出的数据结构,常用于消息排队和异步逻辑处理,它会确保元素的访问顺序:

> RPUSH books python java golang
(integer) 3
> LPOP books
"python"
> LPOP books
"java"
> LPOP books
"golang"
> LPOP books
(nil)

3)字典 hash

Redis 中的字典相当于 Java 中的 HashMap,内部实现也差不多类似,都是通过 "数组 + 链表" 的链地址法来解决部分 哈希冲突,同时这样的结构也吸收了两种不同数据结构的优点。源码定义如 dict.h/dictht 定义:

typedef struct dictht {
    // 哈希表数组
    dictEntry **table;
    // 哈希表大小
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值,总是等于 size - 1
    unsigned long sizemask;
    // 该哈希表已有节点的数量
    unsigned long used;
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    // 内部有两个 dictht 结构
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

table 属性是一个数组,数组中的每个元素都是一个指向 dict.h/dictEntry 结构的指针,而每个 dictEntry 结构保存着一个键值对:

typedef struct dictEntry {
    // 键
    void *key;
    // 值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

可以从上面的源码中看到,实际上字典结构的内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式搬迁 (下面说原因)

渐进式 rehash

大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个 O(n) 级别的操作,作为单线程的 Redis 很难承受这样耗时的过程,所以 Redis 使用 渐进式 rehash 小步搬迁:

image.png 渐进式 rehash 会在 rehash 的同时,保留新旧两个 hash 结构,如上图所示,查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。当搬迁完成了,就会使用新的 hash 结构取而代之。

扩缩容的条件

正常情况下,当 hash 表中 元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是 原数组大小的 2 倍。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会 强制扩容

当 hash 表因为元素逐渐被删除变得越来越稀疏时,Redis 会对 hash 表进行缩容来减少 hash 表的第一维数组空间占用。所用的条件是 元素个数低于数组长度的 10% ,缩容不会考虑 Redis 是否在做 bgsave

字典的基本操作

hash 也有缺点,hash 结构的存储消耗要高于单个字符串,所以到底该使用 hash 还是字符串,需要根据实际情况再三权衡:

> HSET books java "think in java"    # 命令行的字符串如果包含空格则需要使用引号包裹
(integer) 1
> HSET books python "python cookbook"
(integer) 1
> HGETALL books    # key 和 value 间隔出现
1) "java"
2) "think in java"
3) "python"
4) "python cookbook"
> HGET books java
"think in java"
> HSET books java "head first java"  
(integer) 0        # 因为是更新操作,所以返回 0
> HMSET books java "effetive  java" python "learning python"    # 批量操作
OK

4)集合 set

Redis 的集合相当于 Java 语言中的 HashSet,它内部的键值对是无序、唯一的。它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。

「应用场景」

  • 标签:可以将博客网站每个人的标签用 set 集合存储,然后还按每个标签 将用户进行归并。
  • 存储好友/粉丝:set 具有去重功能;还可以利用set并集功能得到共同好友之类的功能。

集合 set 的基本使用

由于该结构比较简单,我们直接来看看是如何使用的:

> SADD books java
(integer) 1
> SADD books java    # 重复
(integer) 0
> SADD books python golang
(integer) 2
> SMEMBERS books    # 注意顺序,set 是无序的
1) "java"
2) "python"
3) "golang"
> SISMEMBER books java    # 查询某个 value 是否存在,相当于 contains
(integer) 1
> SCARD books    # 获取长度
(integer) 3
> SPOP books     # 弹出一个
"java"

5)有序列表 zset

这可能使 Redis 最具特色的一个数据结构了,它类似于 Java 中 SortedSetHashMap 的结合体,一方面它是一个 set,保证了内部 value 的唯一性,另一方面它可以为每个 value 赋予一个 score 值,用来代表排序的权重。

它的内部实现用的是一种叫做 「跳跃表」 的数据结构,示例图如下:

image.png

有序列表 zset 基础操作

> ZADD books 9.0 "think in java"
> ZADD books 8.9 "java concurrency"
> ZADD books 8.6 "java cookbook"

> ZRANGE books 0 -1     # 按 score 排序列出,参数区间为排名范围
1) "java cookbook"
2) "java concurrency"
3) "think in java"

> ZREVRANGE books 0 -1  # 按 score 逆序列出,参数区间为排名范围
1) "think in java"
2) "java concurrency"
3) "java cookbook"

> ZCARD books           # 相当于 count()
(integer) 3

> ZSCORE books "java concurrency"   # 获取指定 value 的 score
"8.9000000000000004"                # 内部 score 使用 double 类型进行存储,所以存在小数点精度问题

> ZRANK books "java concurrency"    # 排名
(integer) 1

> ZRANGEBYSCORE books 0 8.91        # 根据分值区间遍历 zset
1) "java cookbook"
2) "java concurrency"

> ZRANGEBYSCORE books -inf 8.91 withscores  # 根据分值区间 (-∞, 8.91] 遍历 zset,同时返回分值。inf 代表 infinite,无穷大的意思。
1) "java cookbook"
2) "8.5999999999999996"
3) "java concurrency"
4) "8.9000000000000004"

> ZREM books "java concurrency"             # 删除 value
(integer) 1
> ZRANGE books 0 -1
1) "java cookbook"
2) "think in java

6) BloomFilter

「基本概念」
一种数据结构,是由一串很长的二进制向量组成,可以将其看成一个二进制数 组。既然是二进制,那么里面存放的不是0,就是1,但是初始默认值都是0。他的主要作用是:「判断一个元素是否在某个集合中」。比如说,我想判断20亿的号码中是否存在某个号码,如果直接插DB,那么数据量太大时间会很慢;如果将20亿数据放到 缓存 中,缓存也装不下。这个时候用 布隆过滤器 最合适了,布隆过滤器的原理是:

  1. 添加元素

    当要向布隆过滤器中添加一个元素key时,我们通过多个hash函数,算出多个值,然后将这些值所在的方格置为1.

  2. 判断元素是否存在:

    判断元素是否存在,是先将元素经过多个hash函数计算,计算到多个下标值,然后判断这些下标对应的元素值是否都为1,如果存在不是 1 的,那么元素肯定不在集合中;如果都是 1,那么元素大概率在集合中,并不能百分之百肯定元素存在集合中,因为多个不同的数据通过hash函数算出来的结果是会有重复的,所以会存在某个位置是别的数据通过hash函数置为的1。
    总的来说:「布隆过滤器可以判断某个数据一定不存在,但是无法判断一定存在。」

  3. 布隆过滤器的优缺点:

  • 优点:优点很明显,二进制组成的数组,占用内存极少,并且插入和查询速度都足够快。
  • 缺点:随着数据的增加,误判率会增加;还有无法判断数据一定存在;另外还有一个重要缺点,无法删除数据。

「使用」
redis 4.0 后可以使用 布隆过滤器的插件**「RedisBloom」**,命令如下:

bf.add 添加元素到布隆过滤器
bf.exists 判断元素是否在布隆过滤器
bf.madd 添加多个元素到布隆过滤器,bf.add只能添加一个
bf.mexists 判断多个元素是否在布隆过滤器

> bf.add boomFilter tc01
(integer) 1            # 1:存在;0:不存在
> bf.add boomFilter tc02
(integer) 1
> bf.add boomFilter tc03
(integer) 1
> bf.exists boomFilter tc01
(integer) 1
> bf.exists boomFilter tc02
(integer) 1
> bf.exists boomFilter tc03
(integer) 1
> bf.exists boomFilter tc04
(integer) 0
> bf.madd boomFilter tc05 tc06 tc07
1) (integer) 1
2) (integer) 1
3) (integer) 1
> bf.mexists boomFilter tc05 tc06 tc07 tc08
1) (integer) 1
2) (integer) 1
3) (integer) 1
4) (integer) 0

7) HyperLogLog

什么是基数?

比如数据集 {1, 3, 5, 7, 5, 7, 8}, 那么这个数据集的基数集为 {1, 3, 5 ,7, 8}, 基数(不重复元素)为5。 基数估计就是在误差可接受的范围内,快速计算基数。

Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。

在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基 数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。

但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。

统计网站登录用户数的一个实例:

> pfadd user_login_20210523 tom #  user_login_20210523是key;tom 是登录的用户
(integer)1
> pfadd user_login_20210523 tom jack lilei 的用户
(integer)1
> pfcount user_login_20210523  # 获取 key 对应值的数量,同一个用户多次登录只统计一次
(integer) 3  
> pfadd user_login_20210522 sira 
(integer)1
> pfcount user_login_20210523 user_login_20210522 # 统计22号和23号一共有多少登陆的用户
(integer)4
>pfmerge user_login_20210522_23 user_login_20210522 user_login_20210523 # 将连个键内容合并
"OK"
> pfcount user_login_20210522_23
(integer)4

Redis原理深入

Redis 持久化机制

快照(snapshotting)持久化(RDB)

==Redis 可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。==Redis 创建快照之后,==可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本==(Redis 主从结构,主要用来提高 Redis 性能),还可以将快照留在原地以便重启服务器的时候使用。

==快照持久化是 Redis 默认采用的持久化方式==,在 Redis.conf 配置文件中默认有此下配置:

conf
复制代码
save 900 1           #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

save 300 10          #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

save 60 10000        #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命

AOF(append-only file)持久化

==开启 AOF 持久化后每执行一条会更改 Redis 中数据的命令,Redis 就会将该命令写入到内存缓存 server.aof_buf 中,然后再根据 appendfsync 配置来决定何时将其同步到硬盘中的 AOF 文件。==

AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof

在 Redis 的配置文件中存在三种不同的 AOF 持久化方式,它们分别是:

conf
复制代码
appendfsync always    #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
appendfsync everysec  #每秒钟同步一次,显式地将多个写命令同步到硬盘
appendfsync no        #让操作系统决定何时进行同步

为了兼顾数据和写入性能,用户可以考虑 appendfsync everysec 选项 ,让 Redis 每秒同步一次 AOF 文件,Redis 性能几乎没受到任何影响。而且这样即使出现系统崩溃,用户最多只会丢失一秒之内产生的数据。

Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。

如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

Redis 事务

Redis 可以通过 MULTIEXECDISCARDWATCH 等命令来实现事务(transaction)功能。

使用 MULTI 命令后可以输入多个命令。Redis 不会立即执行这些命令,而是将它们放到队列,当调用了 EXEC 命令将执行所有命令。

通过 DISCARD 命令取消一个事务,它会清空事务队列中保存的所有命令。

WATCH 命令用于监听指定的键,当调用 EXEC 命令执行事务时,如果一个被 WATCH 命令监视的键被修改的话,整个事务都不会执行,直接返回失败。

Redis 是不支持 roll back 的,因而不满足原子性的(而且不满足持久性)。

缓存穿透

什么是缓存穿透?

缓存穿透说简单点就是大量请求的 key 根本不存在于缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。

有哪些解决办法?

  • 最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。
  • 缓存无效 key。如果缓存和数据库都查不到某个 key 的数据就写一个到 Redis 中去并设置过期时间,这种方式可以解决请求的 key 变化不频繁的情况,如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
  • 布隆过滤器。把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。

缓存雪崩

什么是缓存雪崩?

缓存在同一时间大面积的失效,后面的请求都直接落到了数据库上,造成数据库短时间内承受大量请求。 这就好比雪崩一样。

有哪些解决办法?

针对 Redis 服务不可用的情况:

  1. 采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。
  2. 限流,避免同时处理大量的请求。

针对热点缓存失效的情况:

  1. 设置不同的失效时间比如随机设置缓存的失效时间。
  2. 缓存永不失效。

如何保证缓存与数据库一致?

更新 DB,然后直接删除 cache 。

如果第二步失败,就引入异步重试机制。其实就是把重试请求写到「消息队列」中,然后由专门的消费者来重试,直到成功。

  • 消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)
  • 消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合我们重试的场景)
  1. 定时任务重试
  2. 消息队列

Redis实战案例

应用场景:连续签到

  • 用户每日有一次签到的机会,如果断签,连续签到计数将归0。
  • 连续签到的定义: 每天必须在23:59:59前签到
  • Key: cc_uid_1165894833417101
  • value: 252
  • expireAt: 后天的0点
// addContinuesDays 为用户签到续期  
func addContinuesDays(ctx context.Context, userID int64) {  
    key := fmt.Sprintf(continuesCheckKey, userID)  
    // 1. 连续签到数+1  
    err := RedisClient.Incr(ctx, key).Err()  
    if err != nil {  
        fmt.Errorf("用户[%d]连续签到失败", userID)  
    } else {  
        expAt := beginningOfDay().Add(48 * time.Hour)  
        // 2. 设置签到记录在后天的0点到期  
        if err := RedisClient.ExpireAt(ctx, key, expAt).Err(); err != nil {  
            panic(err)  
    } else {  
    // 3. 打印用户续签后的连续签到天数  
        day, err := getUserCheckInDays(ctx, userID)  
        if err != nil {  
            panic(err)  
        }  
            fmt.Printf("用户[%d]连续签到:%d(天), 过期时间:%s", userID, day, expAt.Format("2006-  01-02 15:04:05"))  
        }  
    }  
}  
  
// getUserCheckInDays 获取用户连续签到天数  
func getUserCheckInDays(ctx context.Context, userID int64) (int64, error) {  
    key := fmt.Sprintf(continuesCheckKey, userID)  
    days, err := RedisClient.Get(ctx, key).Result()  
    if err != nil {  
        return 0, err  
    }  
    if daysInt, err := strconv.ParseInt(days, 10, 64); err != nil {  
        panic(err)  
    } else {  
        return daysInt, nil  
    }  
}