Redis 历险

211 阅读9分钟

这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战

Redis数据结构

1. String

字符串类型,是可以修改的字符串,类似于StringBuilder,采用预分配冗余空间的方式来减少内存的频繁分配,一般实际分配的空间 capacity 要高于字符串的实际长度 len。

2.  list 列表

底层采用链表结构,插入和删除的时间复杂度为 O(1)。可以通过 list 的 lpop 和 rpop 操作实现队列和栈。

list 常用来做异步消息队列,将需要延后处理的任务序列化成字符串存到redis列表中,另一个线程从这个列表中轮询数据进行处理。

3. hash 散列表

也称“字典”。与 Java 中的 HashMap 类似,底层以数组+链表的结构的存储,发生冲突时,将元素以链表的方式连接。

与 HashMap 不同的是,Redis 中的 hash 结构采用的是渐进式rehash,渐进式rehash 会在 rehash 的同时,保留新旧两个 hash 结构,渐进的将旧hash 迁移到 新hash 中,查询时会同时查询两个 hash 结构(以空间换效率)。如果是写操作,会直接写进新hash中。

常用来存储对象,其中的一个个key就可以看作对象的field。

存储对象还可以采用String+JSON的方式,序列化比Hash容易,但数据修改麻烦。另外,hash 可以对对象的单个字段进行存储,获取对象信息时可以部分获取。

4. set 集合

set 类似于 Java 中的 HashSet,集合内的value值不重复。底层结构是一个特殊的字典hash,value都为 null。

可以做到全局去重。如果使用JVM自带的Set来实现全局去重,还要再起一个服务,太麻烦。

可以用来存储活动中奖用户的ID,因为有去重功能,所以能够保证同一个用户不会中奖两次。

5. zset 有序集合

首先,zset是一个 set,value值不重复。其次,zset给每个value都赋予一个score,通过 score 进行排序。

zset底层使用散列表+跳跃链表来存储。

zset 可以用来存粉丝列表,value 值是粉丝的用户 ID,score 是关注时间。我们可以对粉丝列表按关注时间进行排序。还可以做排行榜功能,取TOP N。

Redis的集群以及主从特性

Redis集群的各个节点之间可通过ping命令测试连接是否正常,当客户端连接到任何一个节点进行操作时,都可能将操作转发到集群中的其它节点

集群中的节点可以作为主机,与多台redis从机建立主从关系。

主机和从机上的数据保持实时同步,主机发生增删改操作时,会将数据同步到从机中。主机可以读写操作,从机只能读,并接收来自主机的数据。

Redis的持久化方式

1. RDB

RDB是redis默认的持久化方式。RDB是将数据以二进制的方式写进文件,是在某个时间点将数据写入某个临时文件,持久化结束后,用这个临时文件替换上次持久化的文件。使用单独的子进程来进行持久化,保证了redis的高性能。

2. AOF

将“操作+数据”以字符串的方式追加到文件的尾部。当Redis重启时会通过执行文件中的写命令来重建整个数据库的数据。

Redis 为什么快?

1. 特殊的数据结构

String 类型的数据空间不够时,会申请更多的内存。多余的空间不会返还给系统,通过这种方式减少内存的申请次数。(空间换时间)

渐进式 Rehash。(空间换时间)

ZSet 跳跃表通过层实现节点跳跃,达到加速访问的目的。

2. 内存型数据库

Redis 的操作都是基于内存的。(除持久化)

3. 单线程

单线程相比于多线程的优点在于锁和上下文切换。所以 Redis 的性能瓶颈不在CPU,而在于内存。

4. IO 多路复用

当一个连接阻塞时,线程会去处理其他连接。通过IO多路复用的方式,监听多个套接字,来处理多个客户端请求的,保证系统的高吞吐量。

缓存雪崩、缓存穿透、缓存预热

1. 缓存雪崩

缓存雪崩是指在一段时间内,大量的缓存到期,访问这些缓存的请求就回去访问数据库,给数据库造成巨大压力。严重时数据库容易宕机。

解决方法: 

a. 通过加锁或用队列的方式对访问数据库的线程进行限制。如果采用加锁的方式,集群使用分布式锁,单机可以使用线程锁。但加锁并不能提高系统的吞吐量,假如有1000个线程同时访问,但同一时间只能有一个线程正在访问,效率很低。

b. 不同的key,设置不同的过期时间,让过期时间尽量均匀一些。

c. 使用消息中间件

还有一个概念跟缓存雪崩很像:缓存击穿。缓存击穿是指大量请求都指向redis的同一个缓存,当这个缓存失效时,这些请求就全都去访问数据库。缓存击穿和缓存雪崩的区别:缓存击穿的对象的是一个缓存,缓存雪崩的对象是大量缓存。

2. 缓存穿透

用户要访问的数据在数据库中没有,在Redis中自然也不会有,导致每次缓存中找不到,都要去数据库中再查询一遍。用户每次查询都会查询两遍,给数据库造成压力。

解决方法:采用布隆过滤器,将所有可能存在的数据哈希到散列表中,一个一定不存在数据会被拦截掉,避免请求访问数据库。(原理:通过Hash函数将数据映射成位阵列中的点,只要看看这个点是不是1就知道有没有这个数据)

3. 缓存预热

系统上线后,将相关的缓存数据直接加载到Redis。用户的请求会直接访问被加载额数据。

 Redis的过期策略和内存淘汰机制

redis采用的是定期删除+惰性删除策略。

为什么不用定时删除?

定时删除,也可以说是实时删除,用一个定时器监控key,过期则删除。虽然即使释放了内存空间,但十分消耗CPU资源。

定期删除,redis默认每100ms检查是否有key过期。不是每隔100ms便将所有key检查一遍,而是随机抽取进行检查。因此,如果只采用定期删除策略,会导致很多key到时间后没有被删除。

惰性删除,在获取某个key时,redis会检查这个key是否过期,如果过期了此时就会被删除。

定期删除+惰性删除能够做的一劳永逸吗?

如果有些key过期后,在定期删除时不会被随机检查到,而且也几乎不会被访问,这些key就会一直存在。

redis的解决方案:内存淘汰机制

在redis.conf中有一行配置:

maxmemory-policy volatile-lru

volatile-lru:从已设置过期时间的数据集中挑选最近最少使用的数据淘汰
volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
allkeys-lru:从数据集中挑选最近最少使用的数据淘汰
allkeys-random:从数据集中任意选择数据淘汰

管道 Pipeline

Redis分布式锁

分布式锁是控制分布式系统不同进程间访问共享资源的一种方式。多用于解决分布式系统下的多进程并发问题。

情景:线程A和线程B都共享某个变量X

如果是单机情况下(单JVM进程),线程之间共享内存,使用线程锁就可以解决并发问题。

如果是分布式情况下(多JVM进程),线程A和B可能不在同一JVM进程中,线程锁便无法起到作用,需要使用分布式锁来解决进程间的冲突问题

redis实现分布式锁的关键:

排他性:在同一时间只能有一个客户端获取到锁,其他客户端无法获取

避免死锁: 锁信息必须是会过期的,不能让一个线程一直占有锁而导致死锁。

除了锁自动过期之外,还可以解锁,加锁和解锁必须是同一线程。

加锁:

Redis实现分布式锁,主要是依赖redis自身的原子操作:

SET user_key user_value NX PX 100

NX:只在在键不存在时,才对键进行设置操作,SET key value NX 效果等同于 SETNX key value。
PX 100:设置键的过期时间为100毫秒。

当某个进程设置成功之后,就可以去执行业务逻辑了,等业务逻辑执行完毕之后,再去进行解锁。

解锁:
解锁很简单,只需要删除这个key就可以了,不过删除之前需要判断,这个key对应的value是当初自己设置的value。加判断的话,会导致这段操作不是原子性操作,如果中途由于某些意外而中断,解锁的操作便得不到执行。这就需要使用 Lua 脚本来处理,因为 Lua 脚本可以保证多个连续指令的原子性操作。

if redis.call("get",KEYS[1]) == ARGV[1] then
  return redis.call("del",KEYS[1])
else
  return 0
end

如果一个锁过期但线程业务还没执行完,怎么办?

可以创建一个守护线程,一直为锁续期。如果主线程没执行完,守护线程也不会死掉,直到主线程执行完毕。