Redis

132 阅读24分钟

Redis 5 种基本数据结构详解

String(字符串)

介绍

String 是 Redis 中最简单同时也是最常用的一个数据结构。

String 是一种二进制安全的数据结构,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象(也就是java对象转换为字节序列的过程)。value 最多可以容纳的数据长度是 512M

常用指令

普通字符串的基本操作:

# 设置 key-value 类型的值
> SET name lin
OK
# 根据 key 获得对应的 value
> GET name
"lin"
# 判断某个 key 是否存在
> EXISTS name
(integer) 1
# 返回 key 所储存的字符串值的长度
> STRLEN name
(integer) 3
# 删除某个 key 对应的值
> DEL name
(integer) 1

批量设置 :

# 批量设置 key-value 类型的值
> MSET key1 value1 key2 value2 
OK
# 批量获取多个 key 对应的 value
> MGET key1 key2 
1) "value1"
2) "value2"

计数器(字符串的内容为整数的时候可以使用):

# 设置 key-value 类型的值
> SET number 0
OK
# 将 key 中储存的数字值增一
> INCR number
(integer) 1
# 将key中存储的数字值加 10
> INCRBY number 10
(integer) 11
# 将 key 中储存的数字值减一
> DECR number
(integer) 10
# 将key中存储的数字值键 10
> DECRBY number 10
(integer) 0

过期(默认为永不过期):

# 设置 key60 秒后过期(该方法是针对已经存在的key设置过期时间)
> EXPIRE name  60 
(integer) 1
# 查看数据还有多久过期
> TTL name 
(integer) 51

#设置 key-value 类型的值,并设置该key的过期时间为 60 秒
> SET key  value EX 60
OK
> SETEX key  60 value
OK

不存在就插入:

# 不存在就插入(not exists)
>SETNX key value
(integer) 1

应用场景

缓存对象

通过json的格式储存对象

常规计数

因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。因此 String 数据类型适合计数场景,比如计算访问次数、点赞、转发、库存数量等等。

List(列表)

Redis 实现了自己的链表数据结构。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。

# 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面
LPUSH key value [value ...] 
# 将一个或多个值value插入到key列表的表尾(最右边)
RPUSH key value [value ...]
# 移除并返回key列表的头元素
LPOP key     
# 移除并返回key列表的尾元素
RPOP key 

# 返回列表key中指定区间内的元素,区间以偏移量start和stop指定,从0开始
LRANGE key start stop

# 从key列表表头弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BLPOP key [key ...] timeout
# 从key列表表尾弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BRPOP key [key ...] timeout

应用场景

消息队列

List 可以使用 LPUSH + RPOP (或者反过来,RPUSH+LPOP)命令实现消息队列。

Hash

介绍

Hash 是一个键值对(key - value)集合,其中 value 的形式如: value=[{field1,value1},...{fieldN,valueN}]。Hash 特别适合用于存储对象。

Hash 与 String 对象的区别如下图所示:

image.png

常用命令

# 存储一个哈希表key的键值
HSET key field value   
# 获取哈希表key对应的field键值
HGET key field

# 在一个哈希表key中存储多个键值对
HMSET key field value [field value...] 
# 批量获取哈希表key中多个field键值
HMGET key field [field ...]       
# 删除哈希表key中的field键值
HDEL key field [field ...]    

# 返回哈希表key中field的数量
HLEN key       
# 返回哈希表key中所有的键值
HGETALL key 

# 为哈希表key中field键的值加上增量n
HINCRBY key field n                 

应用场景

缓存对象

Hash 类型的 (key,field, value) 的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。

在介绍 String 类型的应用场景时有所介绍,String + Json也是存储对象的一种方式,那么存储对象时,到底用 String + json 还是用 Hash 呢?

一般对象用 String + Json 存储,对象中某些频繁变化的属性可以考虑抽出来用 Hash 类型存储。

Set(集合)

介绍

Redis 中的 Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet 。当你需要存储一个列表数据,又不希望出现重复数据时,Set 是一个很好的选择,并且 Set 提供了判断某个元素是否在一个 Set 集合内的重要接口,这个也是 List 所不能提供的。

你可以基于 Set 轻易实现交集、并集、差集的操作,比如你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。这样的话,Set 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。

常用命令

Set常用操作:

# 往集合key中存入元素,元素存在则忽略,若key不存在则新建
SADD key member [member ...]
# 从集合key中删除元素
SREM key member [member ...] 
# 获取集合key中所有元素
SMEMBERS key
# 获取集合key中的元素个数
SCARD key

# 判断member元素是否存在于集合key中
SISMEMBER key member

# 从集合key中随机选出count个元素,元素不从key中删除
SRANDMEMBER key [count]
# 从集合key中随机选出count个元素,元素从key中删除
SPOP key [count]

Set运算操作:

# 交集运算
SINTER key [key ...]
# 将交集结果存入新集合destination中
SINTERSTORE destination key [key ...]

# 并集运算
SUNION key [key ...]
# 将并集结果存入新集合destination中
SUNIONSTORE destination key [key ...]

# 差集运算
SDIFF key [key ...]
# 将差集结果存入新集合destination中
SDIFFSTORE destination key [key ...]

应用场景

共同关注

Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。

key 可以是用户id,value 则是已关注的公众号的id。

uid:1 用户关注公众号 id 为 5、6、7、8、9,uid:2 用户关注公众号 id 为 7、8、9、10、11。

# uid:1 用户关注公众号 id 为 5、6、7、8、9
> SADD uid:1 5 6 7 8 9
(integer) 5
# uid:2  用户关注公众号 id 为 7、8、9、10、11
> SADD uid:2 7 8 9 10 11
(integer) 5

uid:1 和 uid:2 共同关注的公众号:

# 获取共同关注
> SINTER uid:1 uid:2
1) "7"
2) "8"
3) "9"

给 uid:2 推荐 uid:1 关注的公众号:

> SDIFF uid:1 uid:2
1) "5"
2) "6"

点赞

Set 类型可以保证一个用户只能点一个赞,这里举例子一个场景,key 是文章id,value 是用户id。

Zset

介绍

Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。

有序集合保留了集合不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。

image.png

常用命令

Zset 常用操作:

# 往有序集合key中加入带分值元素
ZADD key score member [[score member]...]   
# 往有序集合key中删除元素
ZREM key member [member...]                 
# 返回有序集合key中元素member的分值
ZSCORE key member
# 返回有序集合key中元素个数
ZCARD key 

# 为有序集合key中元素member的分值加上increment
ZINCRBY key increment member 

# 正序获取有序集合key从start下标到stop下标的元素 1 2 3
ZRANGE key start stop [WITHSCORES]
# 倒序获取有序集合key从start下标到stop下标的元素 3 2 1 
ZREVRANGE key start stop [WITHSCORES]

应用场景

Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set。

排行榜

有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。

我们以博文点赞排名为例,小林发表了五篇博文,分别获得赞为 200、40、100、50、150。

# arcticle:1 文章获得了200个赞
> ZADD user:xiaolin:ranking 200 arcticle:1
(integer) 1
# arcticle:2 文章获得了40个赞
> ZADD user:xiaolin:ranking 40 arcticle:2
(integer) 1
# arcticle:3 文章获得了100个赞
> ZADD user:xiaolin:ranking 100 arcticle:3
(integer) 1
# arcticle:4 文章获得了50个赞
> ZADD user:xiaolin:ranking 50 arcticle:4
(integer) 1
# arcticle:5 文章获得了150个赞
> ZADD user:xiaolin:ranking 150 arcticle:5
(integer) 1

文章 arcticle:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合key中元素member的分值加上increment):

> ZINCRBY user:xiaolin:ranking 1 arcticle:4
"51"

查看某篇文章的赞数,可以使用 ZSCORE 命令(返回有序集合key中元素个数):

> ZSCORE user:xiaolin:ranking arcticle:4
"50"

获取小林文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从start下标到stop下标的元素):

# WITHSCORES 表示把 score 也显示出来
> ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES
1) "arcticle:1"
2) "200"
3) "arcticle:5"
4) "150"
5) "arcticle:3"
6) "100"

获取小林 100 赞到 200 赞的文章,可以使用 ZRANGEBYSCORE 命令(返回有序集合中指定分数区间内的成员,分数由低到高排序):

> ZRANGEBYSCORE user:xiaolin:ranking 100 200 WITHSCORES
1) "arcticle:3"
2) "100"
3) "arcticle:5"
4) "150"
5) "arcticle:1"
6) "200"

跳表

链表的查询时间复杂度为O(n),并不高效,还要满足范围查找,如何解决这个问题?那么这时候就想到,能不能给链表做一个索引,提高它的查找效率的同时,让它也能支持范围查找,构建索引的话,是为了提高效率,如果只构建一层索引,数据量小的时候无所谓,但数据量大的时候呢?好像无法起到根本上提升效率的作用,所以应该给链表添加多级索引,简单示意图如下所示 复杂度log(n)

image.png

持久化

AOF 日志

总结AOF append only file存储的是更新数据的命令,查询不会存,所以文件都是命令。

AOF不是每写一次命令都记录一次,影响性能,而是有个缓存区,等一次写入文件

随着时间越长,文件越来越大,所以要压缩,这就是AOF重写。方法是把一些能一条命令做到的多条命令合并成一条。然后fork出子进程来干。重写就不会阻塞主进程了

重写过程又有新命令插入文件怎么办?我们可以在建立个重写缓存区,在重写期间,所以的新命令都放那,等重写完了,在同步到一个新文件,这样重写期间的命令也不会丢了


试想一下,如果 Redis 每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里,然后重启 Redis 的时候,先去读取这个文件里的命令,并且执行它,这不就相当于恢复了缓存数据了吗?

缺点恢复数据慢,因为要把命令全部执行一遍

image.png

这种保存写操作命令到日志的持久化方式,就是 Redis 里的 AOF(Append Only File)  持久化功能,注意只会记录写操作命令,读操作命令是不会被记录的,因为没意义。

在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件

Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。

第一个好处,避免额外的检查开销。

因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。

而如果先执行写操作命令再记录日志的话,只有在该命令执行成功后,才将命令记录到 AOF 日志里,这样就不用额外的检查开销,保证记录在 AOF 日志里的命令都是可执行并且正确的。

第二个好处,不会阻塞当前写操作命令的执行,因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。

当然,AOF 持久化功能也不是没有潜在风险。

第一个风险,执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险

第二个风险,前面说道,由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前写操作命令的执行,但是可能会给「下一个」命令带来阻塞风险

因为将命令写入到日志的这个操作也是在主进程完成的(执行命令也是在主进程),也就是说这两个操作是同步的。

AOF 重写机制

AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。

如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。

所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。

image.png

举个例子,在没有使用重写机制前,假设前后执行了「set name xiaolin」和「set name xiaolincoding」这两个命令的话,就会将这两个命令记录到 AOF 文件。

但是在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了,因为它属于「历史」命令,没有作用了。这样一来,一个键值对在重写日志中只用一条命令就行了。

RDB 快照

快速总结:

RDB快照就是把某个时间的内存所有数据持久化,存成二进制(AOF是存命令)。

所以每次RDB都是很重的任务,一般都可以设置成每5分钟数据被更新xx次执行一次RDB。

由于任务重时间花的多,所以RDB是会创建一个子进程去做这个备份的事情

由于每次都是分钟级的备份,那么很有可能造成这几分钟的数据丢失,这是缺陷

这两种技术都会用各用一个日志文件来记录信息,但是记录的内容是不同的。

  • AOF 文件的内容是操作命令;
  • RDB 文件的内容是二进制数据。

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。

因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

RDB 文件的加载工作是在服务器启动时自动执行的,Redis 并没有提供专门用于加载 RDB 文件的命令。

Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:

save 900 1
save 300 10
save 60 10000

别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。

只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:

  • 900 秒之内,对数据库进行了至少 1 次修改;
  • 300 秒之内,对数据库进行了至少 10 次修改;
  • 60 秒之内,对数据库进行了至少 10000 次修改。

这里提一点,Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。

所以可以认为,执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。

通常可能设置至少 5 分钟才保存一次快照,这时如果 Redis 出现宕机等情况,则意味着最多可能丢失 5 分钟数据。

这就是 RDB 快照的缺点,在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,因此执行的频率不能太频繁,否则会影响 Redis 性能,而 AOF 日志可以以秒级的方式记录操作命令,所以丢失的数据就相对更少。

RDB 和 AOF 合体

尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握:

  • 如果频率太低,两次快照间一旦服务器发生宕机,就可能会比较多的数据丢失;
  • 如果频率太高,频繁写入磁盘和创建子进程会带来额外的性能开销。

那有没有什么方法不仅有 RDB 恢复速度快的优点和,又有 AOF 丢失数据少的优点呢?

当然有,那就是将 RDB 和 AOF 合体使用,这个方法是在 Redis 4.0 提出的,该方法叫混合使用 AOF 日志和内存快照,也叫混合持久化。

混合使用 AOF 日志和内存快照的方法。简单来说,内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。

缓存篇

缓存雪崩

通常我们为了保证缓存中的数据与数据库中的数据一致性,会给 Redis 里的数据设置过期时间,当缓存数据过期后,用户访问的数据如果不在缓存里,业务系统需要重新生成缓存,因此就会访问数据库,并将数据更新到 Redis 里,这样后续请求都可以直接命中缓存。

可以看到,发生缓存雪崩有两个原因:

  • 大量数据同时过期;
  • Redis 故障宕机;

不同的诱因,应对的策略也会不同。

大量数据同时过期

针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 均匀设置过期时间;
  • 互斥锁;

1. 均匀设置过期时间

如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。

2. 互斥锁

当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。

Redis 故障宕机

针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 服务熔断或请求限流机制;
  • 构建 Redis 缓存高可靠集群;

缓存击穿

我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频地访问的数据被称为热点数据。

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。

应对缓存击穿可以采取前面说到两种方案:

  • 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
  • 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

缓存穿透

当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

缓存穿透的发生一般有这两种情况:

  • 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
  • 黑客恶意攻击,故意大量访问某些读取不存在数据的业务;

应对缓存穿透的方案,常见的方案有三种。

  • 第一种方案,非法请求的限制;
  • 第二种方案,缓存空值或者默认值;
  • 第三种方案,使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在;

第一种方案,非法请求的限制

当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。

第二种方案,缓存空值或者默认值

当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。

第三种方案,使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在。

我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。

总结

缓存异常会面临的三个问题:缓存雪崩、击穿和穿透。

其中,缓存雪崩和缓存击穿主要原因是数据不在缓存中,而导致大量请求访问了数据库,数据库压力骤增,容易引发一系列连锁反应,导致系统奔溃。不过,一旦数据被重新加载回缓存,应用又可以从缓存快速读取数据,不再继续访问数据库,数据库的压力也会瞬间降下来。因此,缓存雪崩和缓存击穿应对的方案比较类似。

而缓存穿透主要原因是数据既不在缓存也不在数据库中。因此,缓存穿透与缓存雪崩、击穿应对的方案不太一样。

我这里整理了表格,你可以从下面这张表格很好的知道缓存雪崩、击穿和穿透的区别以及应对方案。

击穿:单体互斥锁,集群分布式锁.在访问数据库时加锁,只允许一个线程访问.然后在同步到缓存中

图片

数据淘汰策略

可以设置内存最大使用量,当内存使用量超出时,会施行数据淘汰策略。

Redis 具体有 6 种淘汰策略:

策略描述
volatile-lru从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
volatile-ttl从已设置过期时间的数据集中挑选将要过期的数据淘汰
volatile-random从已设置过期时间的数据集中任意选择数据淘汰
allkeys-lru从所有数据集中挑选最近最少使用的数据淘汰
allkeys-random从所有数据集中任意选择数据进行淘汰
noeviction禁止驱逐数据

作为内存数据库,出于对性能和内存消耗的考虑,Redis 的淘汰算法实际实现上并非针对所有 key,而是抽样一小部分并且从中选出被淘汰的 key。

使用 Redis 缓存数据时,为了提高缓存命中率,需要保证缓存数据都是热点数据。可以将内存最大使用量设置为热点数据占用的内存量,然后启用 allkeys-lru 淘汰策略,将最近最少使用的数据淘汰。

Redis 4.0 引入了 volatile-lfu 和 allkeys-lfu 淘汰策略,LFU 策略通过统计访问频率,将访问频率最少的键值对淘汰。