0%

redis基础学习

redis的数据结构以及使用场景分析

1. string

a. 底层结构

string的数据结构存储的是key-value类型, value不仅可以是string,也可以是数字。

redis中的String是可以修改的,称为动态字符串(SDS),其实就是维护了一个预分配的字节数组,如下

1
2
3
4
5
6
struct SDS{
T capacity; //数组容量
T len; //实际长度
byte flages; //标志位,低三位表示类型
byte[] content; //数组内容
}

b. 常用命令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
set   [key]  [value]   给指定key设置值(set 可覆盖老的值)

get [key] 获取指定key 的值

del [key] 删除指定key

exists [key] 判断是否存在指定key

mset [key1] [value1] [key2] [value2] ...... 批量存键值对

mget [key1] [key2] ...... 批量取key

expire [key] [time] 给指定key 设置过期时间 单位秒

setex [key] [time] [value] 等价于 set + expire 命令组合

setnx [key] [value] 如果key不存在则set 创建,否则返回0

incr [key] 如果value为整数 可用 incr命令每次自增1

incrby [key] [number] 使用incrby命令对整数值 进行增加 number

c. 使用场景举例

缓存

简单key-value存储

分布式锁

setnx key value,当key不存在时,将 key 的值设为 value ,返回1

若给定的 key 已经存在,则setnx不做任何动作,返回0。

当setnx返回1时,表示获取锁,做完操作以后del key,表示释放锁,如果setnx返回0表示获取锁失败,整体思路大概就是这样

计数器

如知乎每个问题的被浏览器次数

全局标志位

例如售罄标志,防止超卖

2. hash

a. 底层结构

Redis中的Hash和 Java的HashMap更加相似,都是数组+链表的结构,当发生 hash 碰撞时将会把元素追加到链表上,下面是hash存储的一个KV结构

1
2
3
4
5
6
7
key=JavaUser293847
value={
“id”: 1,
“name”: “SnailClimb”,
“age”: 22,
“location”: “Wuhan, Hubei”
}

(可以看作是 map< key, map<key, value> >)

b. 常用指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
hset  [key]  [field] [value]    新建字段信息

hget [key] [field] 获取字段信息

hdel [key] [field] 删除字段

hlen [key] 保存的字段个数

hgetall [key] 获取指定key 字典里的所有字段和值 (字段信息过多,会导致慢查询 慎用:亲身经历 曾经用过这个这个指令导致线上服务故障)

hmset [key] [field1] [value1] [field2] [value2] ...... 批量创建

hincr [key] [field] 对字段值自增

hincrby [key] [field] [number] 对字段值增加number

c. 应用场景

可以存储对象( (对象名,成员变量名,值) )

3. list

a. 底层结构

Redis中的list和Java中的LinkedList很像,底层都是一种链表结构,list的插入和删除操作非常快,时间复杂度为 0(1),不像数组结构插入、删除操作需要移动数据。

像归像,但是redis中的list底层可不是一个双向链表那么简单。

在redis3.2版本之前,对list的实现是:

  • 当数据量较少的时候它的底层存储结构为一块连续内存,称之为ziplist(压缩列表),它将所有的元素紧挨着一起存储,分配的是一块连续的内存;(保存的是entry数组)
    1
    2
    3
    4
    5
    6
    7
    struct ziplist<T>{
    int32 zlbytes; //压缩列表占用字节数
    int32 zltail_offset; //最后一个元素距离起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; //元素个数
    T[] entries; //元素内容
    int8 zlend; //结束位 0xFF
    }
  • 当数据量较多的时候,它的底层结构是使用linkedlist来做的,是离散的。
1
2
3
4
5
typedef struct listNode{
struct listNode *prev;
struct listNode *next;
void *value;
}listNode;
1
2
3
4
5
6
7
8
9
typedef struct list{

listNode *head;
listNode *tail;

unsigned long len; // 链表长度
......
......
}list;

重新引入了一个 quicklist 的数据结构,列表的底层都由quicklist实现。
quicklist其实就是又对linkedlist和ziplist进行了一层抽象,变为quicklistnode, 它可以指向压缩后的list,也可以指向未压缩的list, 如下图
在这里插入图片描述

1
2
3
4
5
6
7
8
typedef struct quicklist {
quicklistNode *head; // 指向quicklist的头部
quicklistNode *tail; // 指向quicklist的尾部
unsigned long count; // 列表中所有数据项的个数总和
unsigned int len; // quicklist节点的个数,即ziplist的个数
int fill : 16; // ziplist大小限定,由list-max-ziplist-size给定
unsigned int compress : 16; // 节点压缩深度设置,由list-compress-depth给定
} quicklist;
1
2
3
4
5
6
7
8
9
10
11
12
typedef struct quicklistNode {
struct quicklistNode *prev; // 指向上一个ziplist节点
struct quicklistNode *next; // 指向下一个ziplist节点
unsigned char *zl; // 数据指针,如果没有被压缩,就指向ziplist结构,反之指向quicklistLZF结构
unsigned int sz; // 表示指向ziplist结构的总长度(内存占用长度)
unsigned int count : 16; // 表示ziplist中的数据项个数
unsigned int encoding : 2; // 编码方式,1--ziplist,2--quicklistLZF
unsigned int container : 2; // 预留字段,存放数据的方式,1--NONE,2--ziplist
unsigned int recompress : 1; // 解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为1,之后再重新进行压缩
unsigned int attempted_compress : 1; // 测试相关
unsigned int extra : 10; // 扩展字段,暂时没用
} quicklistNode;

b. 常用指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
rpush  [key] [value1] [value2] ......    链表右侧插入

rpop [key] 移除右侧列表头元素,并返回该元素

lpop [key] 移除左侧列表头元素,并返回该元素

llen [key] 返回该列表的元素个数

lrem [key] [count] [value] 删除列表中与value相等的元素,count是删除的个数。 count>0 表示从左侧开始查找,删除count个元素,count<0 表示从右侧开始查找,删除count个相同元素,count=0 表示删除全部相同的元素

(PS: index 代表元素下标,index 可以为负数, index= 表示倒数第一个元素,同理 index=-2 表示倒数第二 个元素。)

lindex [key] [index] 获取list指定下标的元素 (需要遍历,时间复杂度为O(n))

lrange [key] [start_index] [end_index] 获取list 区间内的所有元素 (时间复杂度为 O(n))

ltrim [key] [start_index] [end_index] 保留区间内的元素,其他元素删除(时间复杂度为 O(n))

c. 应用场景

由于list它是一个按照插入顺序排序的列表,所以应用场景相对还较多的,例如:

消息队列

lpop和rpush(或者反过来,lpush和rpop)能实现队列的功能
在这里插入图片描述

朋友圈用户消息列表

例如想拿最近发得10条动态,就可以使用 lrange 来拿

4. set

a. 底层结构

Redis中的set和Java中的HashSet有些类似,它内部的键值对是无序的、唯一 的。它的内部实现相当于一个特殊的字典,字典中所有的value都是一个值 NULL。当集合中最后一个元素被移除之后,数据结构被自动删除,内存被回收。

b. 常用指令

1
2
3
4
5
6
7
8
9
10
11
12
13
sadd  [key]  [value]  向指定key的set中添加元素

smembers [key] 获取指定key 集合中的所有元素

sismember [key] [value] 判断集合中是否存在某个value

scard [key] 获取集合的长度

spop [key] 弹出一个元素

srem [key] [value] 删除指定元素

sinterstore key1 key2 key3 将交集存在key1内

d. 高频指令

求交集:sinterstore key1 key2 key3 将交集存在key1内

c. 应用场景

在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好、共同好友等功能。这个过程也就是求交集的过程。
(关键字:共同)

5. zset ( sorted set )

和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列。

a. 底层结构

zset是基于skiplist(跳表)实现的。

b. 常用指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
zadd [key] [score] [value] 向指定key的集合中增加元素

zrange [key] [start_index] [end_index] 获取下标范围内的元素列表,按score 排序输出

zrevrange [key] [start_index] [end_index] 获取范围内的元素列表 ,按score排序 逆序输出

zcard [key] 获取集合列表的元素个数

zrank [key] [value] 获取元素再集合中的排名

zrangebyscore [key] [score1] [score2] 输出score范围内的元素列表

zrem [key] [value] 删除元素

zscore [key] [value] 获取元素的score

c. 应用场景

zset可以用做排行榜,但是和list不同的是zset它能够实现动态的排序,例如: 可以用来存储粉丝列表,value 值是粉丝的用户 ID,score 是关注时间,我们可以对粉丝列表按关注时间进行排序。

zset还可以用来存储学生的成绩,value值是学生的 ID,score是他的考试成绩。 我们对成绩按分数进行排序就可以得到他的名次。
(关键字:排行榜)
在这里插入图片描述

d. 高频指令

  • 求名次为[a,b]的分数的玩家 : zrevrange [key] [start_index] [end_index] ps: zrange是升序,zrevrange是降序
  • 求某一个玩家的排名 : zrank
  • 求分数为[a,b]的玩家 : zrangebyscore [key] [score1] [score2]
  • 求某一个玩家的分数 : zscore [key] [value] ( zscore player_set czf )

Redis缓存雪崩/穿透/击穿/无底洞问题

缓存穿透 - 大量请求不命中

在这里插入图片描述

问题:

当请求到来时,会先去查缓存,缓存中没有,然后才会“穿过”缓存层访问数据库,如果数据库中存有请求的结果,那么会将结果数据写到缓存中。 但如果访问的是数据库中也没有的记录,那么缓存中也不会存储。 当有大量请求访问数据库中不存在的数据时,那么缓存也就形同虚设,大量的并发直接落在了数据库上。

产生的原因:

1. 业务代码自身的问题

例如对于一些不合理的查询请求在业务代码层面上没有过滤掉等。

2. 恶意攻击、爬虫等

如何发现:

1. 业务的响应时间

如果出现了穿透,请求打到了存储层上,响应时间一定会收到影响。

2. 采集相关指标(对缓存进行监控):总调用数、缓存层命中数、存储层名中数

解决方法

方法一:把查询空结果也给缓存起来

  • 但这样会出现两个问题:
    1. 对于恶意攻击来说,他们可以通过组合不同的键来查询空结果,所以穿透依然无法避免。
    2. 如果在查询某个关键的key的时候,查询接口因为一些意外原因(如网络延迟过大)而导致了查询到了空结果,在把这个空结果给缓存了之后,在其失效之前,对于这个key的查询得到的结果总是空的,但这个期间,有可能查询接口又恢复正常了(但却因为缓存缓存了空结果,所以还是查询不到)。

方法二: 布隆过滤器拦截

布隆过滤器是一种数据结构,比较巧妙的概率型数据结构(probabilistic data
structure),特点是高效地插入和查询,可以用来告诉你 “某样东西一定不存在或者可能存在”。

在这里插入图片描述
因此,在查询的时候,先去布隆过滤器查询,只有对于通过了布隆过滤器的查询,才会真正的去执行查询. 但这并不能100%的过滤掉所有空查询,因为布隆过滤器可以保证不通过的一定不存在,但不能保证通过的一定存在。

缓存穿透应该是
当数据库中没有某个key对应的value时,缓存中也不会有该value的缓存。所以大量的对该value的查询该的请求会绕过缓存,直接查询数据库。

缓存中一般存的是 key+value ,但是布隆过滤器却可以告诉你 key
对应的value在数据库中存不存在,如果不存在就不用查询数据库了。

拿redis为例子: 请注意,用 redis 也可以做到判断 key 对应的value
在数据库中存不在,那就是把数据库里的所有value对应的key都储存在redis
中,而value可以为空,然后判断下key.IsExists()就可以了,但是这无疑会浪费大量空间,因为存储了数据库中所有的key。而且这也不符合缓存的初衷:咱不能暴力的把所有key都存下来,而是查询了啥key,我们缓存啥key。

而布隆过滤器是一种非常高效的数据结构,把所有数据库的value对应的key
存储到布隆过滤器里,几乎不消耗什么空间,而且查询也是相当的快!但是请注意,它只能判断 key 是否存在(而且会有一定的误差)。

所以一个查询先通过布隆顾虑器判断key是否存在(key 对应的value是否存在数据库中),如果不存在直接返回空就好了。

那么布隆过滤器是怎么做到几乎不消耗空间来储存所有的key,并快速判断特定的key是否存在呢?

其实原理很简单,布隆过滤器 只是一个 byte数组,再加上几个映射函数。

每个key 都通过一系列映射函数,得到一系列的的值k,然后在这个byte数组上的把k下标的值变成1。

当要判断key是否存在时,通过映射函数映射得到的一系列k,查看byte数组相应下标k对应的值是否为1,如果有一个不为1,那么一定不存在。如果都是1
,那么可能存在。为什么可能而不是一定呢?因为这是一个误差问题,有可能别的key把某个k的位置变成了1,key越多时,误差越大。但是放心不会很大的,这是可以控制的,byte数组越长,误差越小。

缓存雪崩

问题

一般情况下,缓存层将接受大量的服务请求,存储层只接受比较少的服务请求,但当缓存层发生异常/脱机(总之暂时无法工作)或是是指在某一个时间段,缓存集中过期失效,那么流量直接压向后端组件(例如数据库,或第三方API),造成级联故障。

级联故障的解释:
网络中,一个或少数几个节点或连线的失效会通过节点之间的耦合关系引发其他节点也发生失效,进而产生级联效应,最终导致相当一部分节点甚至整个网络的崩溃,这种现象就称为级联失效,有时也形象称之为“雪崩” 。

在这里插入图片描述

解决方案

保证缓存的高可用性

例如 Redis的主从机制,主机挂了从机上.

  • Redis Sentinel
  • Redis Cluster
  • 主从漂移

依赖隔离组件为后端限流

  • Hystrix这种隔离组件
  • 使用线程池/信号量隔离组件
  • 使用Guava提供的限流API(令牌桶,漏桶)

    提前演练:例如压力测试

    数据预热

    数据加热的含义就是在正式部署之前,我先把可能的数据先预先访问一遍,这样部分可能大量访问的数据就会加载到缓存中。在即将发生大并发访问前手动触发加载缓存不同的key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

缓存击穿

问题

一个超级热点数据如果因为超时失效或其他原因而从redis中被删除,那么短期大量的流量就会打到数据库上.

解决办法:

  • 对于热点数据不设置失效时间
  • 提前将热点数据预存到redis中
  • 使用第三方缓存或本地缓存(例如Guava的Cache),如果是热点数据的话,频繁被访问的情况话就不会被置换出去
  • 限流(线程池、信号量、RateLimiter。。。)熔断(to study)

无底洞问题优化*

问题

加机器之后,性能不但没有提升,反而下降了。(因为加的机器多了,网络请求次数也多了,开销也大了)

在这里插入图片描述

优化IO的几种方法

  1. 命令本身优化:例如慢查询keys、hgetall bigkey
  2. 减少网络通信次数(无底洞问题主要优化的位置)
  3. 降低接入成本:例如客户端长连接/连接池、NIO等

四种批量优化的方法

  1. 串行meget
  2. 串行io
  3. 并行io
  4. hash_tag
    在这里插入图片描述

Redis对过期key的删除策略

如果假设你设置了一批 key 只能存活 1 个小时,那么接下来 1 小时后,redis 是怎么对这批 key 进行删除的?

定期删除 + 惰性删除

  • 定期删除:

    redis是默认每隔100ms就随机抽取一些设置了过期时间的key,检查是否过期,如果过期就删除。注意!这里是随机抽取, 这样即使在redis中存储了很多数据的情况下,依然能够保证性能.

  • 惰性删除:

    懒惰删除就如字面意思,每次在获取key的时候,会排查这个key是否过期,如果过期了就删除。

  • Redis内存淘汰机制:

    考虑一下这种场景,定期删除漏掉了许多过期的key,同时也没有去及时去排查,也就没触发惰性删除,这时,大量的过期key就会堆积在内存里,导致redis内存块耗尽…… 而解决这个问题的办法就是redis内存淘汰机制。

    Redis提供6种数据淘汰策略

    • volatile-lru:从已经设置了过期时间的数据集中挑选最近最少使用的数据淘汰

    • volatile-ttl:从已经设置了过期时间的数据集中挑选即将过期的的数据淘汰

    • volatile-random:从已经设置了过期时间的数据集中挑选随机挑选数据淘汰

    • allkeys-lru:从所有数据集中挑选最近最少使用的数据淘汰 (最常用)

    • allkeys-random:从所有数据集中挑选随机挑选数据淘汰

    • no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。


      4.0版本之后增加了以下两种:

      • volatile-lfu:从已经设置了过期时间的数据集中挑选最不经常使用的数据淘汰
    • allkeys-lfu:从所有数据集中挑选最不经常使用的数据淘汰


Redis事务

Redis的事务其实就是将一组命令打包,然后一次性执行完,期间不允许被打断,执行完毕后才能去执行其他客户端的命令

所以Redis的事务满足:

  • 不支持回滚的原子性
  • 一致性
  • 隔离性(因为是串行的)

如果运行在特性的持久化模式下,也会具有一定程度的持久性。

redis 同一个事务中如果有一条命令执行失败,其后的命令仍然会被执行,没有回滚。(来自issue:关于 Redis 事务不是原子性问题


Redis的使用场景

从海量Key里查询出某一固定前缀的Key

kyes pattern: 查找所有符合给定模式pattern的key

  • 一次性返回所有匹配的key
  • 键的数量过大会使服务卡顿

可以使用scan cursor [Match pattern][COUNT count]

  • 基于游标的迭代器,需要基于上一次的游标延续之前的迭代过程。
  • 以0作为游标开始一次新的迭代,直到命令返回游标0完成一次遍历。
  • 不保证每次执行都返回给定数量的元素(count数大于key总数的时候)一次返回数量不可控,只能是大概率符合count。
  • 支持模糊查询,即能够返回满足pattern匹配的key

例如:scan 0 match k1* count 10 ,注意,返回的值有可能的是重复的! 因此需要去重!(例如写程序的时候用hashset)

blpop

通过Redis实现分布式锁

即分布式系统中,访问共同资源时的一种锁的实现。

在分布式场景下,无法使用单机环境下的锁来对多个节点上的进程进行同步。

可以使用 Redis 自带的 SETNX 命令实现分布式锁,setnx key val 就是如果不存在key的话,那么就设置key为val。设置成功返回1, 失败返回0

SET lock_key random_value NX PX 5000

一定要放到一个语句里,保证“获取锁”和“设置超时时间”的原子性。如果设置完setnx以后,程序就挂掉了,那么这个key(锁)就一直被占用!

使用Redis做异步队列

除了使用list的rpush, lpop, blpop 以外,可以用pub/sub:主题订阅者模式,来做。

例子:

  • 订阅一个频道:
    • redis-cli1: subscribe myTopic ,之后进入监听状态
    • redis-cli2: subscribe myTopic ,之后进入监听状态
    • redis-cli3: subscribe myTopic ,之后进入监听状态
    • redis-cli4: publish myTopic "hello!!"
      这条消息发送出去之后,监听myTopic的3个客户端都收到了“hello!!”这个字符串。

缺点
但需要注意的是,消息的发布是无状态的,也就是无法保证可达。对于发布者来说,消息是即发即失的。 若想解决这个问题,需要用专业的消息队列,如kafka,rocketmq等。

Redis如何做持久化

RDB ( 快照 ) 持久化:保存某一个时间点的全数据快照.

redis服务器加载时,会启用reids.conf文件中的配置信息,里面的:

1
2
3
4
5
6
7
8
9
10
11
....

save 900 1 # 就是900秒内如果有1条是写入指令,那么就触发一次快照
save 300 10
save 60 10000

...
stop-writes-on-bgsave-error yes #当设置成yes,
# 就是备份进程若出错了,则主进程就停止
# 接受新的写入操作, 这是为了保证数据一致性!
....

可以根据不同的情况来合理配置。 src目录下的dump.rdb文件,就是redis系统定期备份的rdb文件. 它是一个二进制文件。

生成RDB备份文件的方式:

  • 主动生成

    • SAVE: 阻塞Redis的服务器进程,直到RDB文件被创建完毕。 很少被使用,因为占用了主线程!! 主线程是用来处理client的请求的!!
    • BGSAVE:Fork一个子进程创建RBD文件,不阻塞服务器进程!此时,主进程依然继续工作,子进程将内存中的数据写入临时文件中,因为copy-on-write的机制,父子进程此时会共享相同的物理页面,当(主)父进程处理写请求时,os会为父进程要写的页面创建一个副本(这个副本用于备份),而不是写入共享的页面! RDB文件的载入,一般情况下是自动的,redis服务器启动时,若检测到rdb文件的存在,那么会载入这个文件
      在这里插入图片描述
      在fork时,子进程和父进程共享同一块资源空间,只有当父进程对此空间进行修改时,才会触发给子进程的资源复制,这一机制为copy-on-write. ,juc的copyOnWriteArrayList也是使用的这一原理。
  • 被动生成

    • 根据redis.conf里的save m n 定时触发 (用的是BGSAVE)
    • 主从复制时,主节点自动触发(主节点发送rdb文件给从结点,这时,主节点会触发一次!)
    • 执行debug reload
    • 执行shutdown且没有开启AOF持久化,那么会触发一次RDB持久化

RDB持久化的缺点:

  • 内存数据的全部同步! 数据量大的时候会因为IO而严重影响性能!
  • 可能会因为redis挂掉而丢失从当前到最近一次备份期间的所有数据!

AOF ( Append-Only-File )持久化:保存写状态

  • 记录下除了查询以外的所有变更数据库状态的指令
  • 以append的形式追加保存到AOF文件中
  • AOF持久化默认是关闭的,可以修改redis.conf来让其生效:
    • appendonly yes # 启动 aof
    • appendfsync everysec/always/no:
      • always: 一旦缓存区发生改变,就立刻将内容写到文件中!
      • everysec: 每隔1s,写入一次
      • no: 什么时候写交给os判断, 一般是等缓存区写满了就写入一次。

日志重写解决AOF文件大小不断增大的问题(例如100条incr 可以用一条add 100实现), 其原理如下:

  • 调用fork(), 创建一个子进程。
  • 子进程把新的AOF写到一个临时文件里,新的AOF是根据内存数据生成对应的命令,并不需要区依赖原来的AOF文件。
  • 主进程持续将新的变动写到内存中,并更新到“旧”的AOF文件里。
  • 重写结束之后,会给主进程一个信号,然后把内存的buff追加到新生成的AOF文件。
  • 用新的AOF替换掉旧的AOF。

从Redis中恢复数据

其实只要重启就可以了。。

  • 检查AOF是否存在,若存在则直接加载AOF,不再去找RDB
  • 若不存在AOF,则尝试加载RDB

RDB和AOF的优缺点

  • RDB优点:创建RDB那一瞬间的全部内存数据快照,文件小,恢复快

  • RDB缺点:无法保存最近一次快照之后的数据

  • AOF优点:可读性高,适合保存增量数据,数据不易丢失

  • AOF缺点:文件体积大,恢复时间长

redis 4.0之后的备份就是混合模式,即RDB-AOF.

rdb用于全量备份,aof用于增量备份,为redis4.0之后的默认备份方式。

bgsave做全量持久化,aof做增量持久化