Redis相关面试题
1. Redis简介
Redis就是一个使用C语言开发的数据库,与传统数据库不同的是,Redis数据是存在内存中的,即他是内存数据库,读写速度非常快,因此Redis被广泛应用于缓存方向。
另外,Redis除了做缓存之外,也经常用来做分布式锁,甚至是消息队列。
Redis提供了多种数据类型来支持不同的业务场景。Redis还支持事务、持久化、Lua脚本、多种集群方案。
2. 分布式缓存
分布式缓存使用较多的主要有Memcached和Redis。不过现在基本都是使用Redis。
2.1 Redis和Memcached的比较
共同点:
- 都是基于内存的数据库,一般都用来当做缓存使用。
- 都有过期策略。
- 两者的性能都非常高。
区别:
- Redis支持更丰富的数据类型。不仅仅支持简单的k/v类型的数据,同时还提供list、set、zset、hash等数据结构的存储。Memcached只支持最简单的k/v数据类型。
- Redis支持数据持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用,而Memcached把数据全部存在内存中。
- Redis有灾难恢复机制。因为可以把缓存中的数据持久化到磁盘上。
- Redis在服务器内存使用完之后,可以将不用的数据放到磁盘。Memcached在服务器内存使用完之后,就会直接报异常。
- Memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据,Redis目前原生支持cluster模式的。
- Memcached是多线程,非阻塞IO复用的网络模型;Redis使用单线程的多路IO复用模型。(Redis6.0引入了多线程IO)
- **Redis支持发布订阅模型、Lua脚本、事务等功能,**Memcached不支持,并且Redis支持更过的编程语言。
- Redis过期数据使用了惰性删除和定期删除,Memcached只用了惰性删除。
2.2 缓存数据的处理流程?
2.3 Redis除了做缓存,还能做什么?
- 分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。
- 限流:一般是通过 Redis + Lua 脚本的方式来实现限流。
- 消息队列:Redis 自带的 list 数据结构可以作为一个简单的队列使用。Redis5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。
3. Redis持久化
Redis支持两种持久化操作,一种持久化方式是快照(RDB)、另一种是只追加文件持久化(AOF)。
3.1 快照持久化(RDB)
RDB持久化既可以手动执行,也可以根据服务器配置选项定期执行,该功能可以将某个时间点上的数据库状态保存到一个RDB文件中,RDB文件是一个经过压缩的二进制文件,保存在硬盘里面,可以用来还原生成RDB文件时的数据库状态。
RDB文件的创建与载入
有两个Redis命令可以用于生成RDB文件,一个是SAVE,另一个是BGSAVE。
SAVE命令会阻塞Redis服务器进程,直到RDB文件创建完毕为止,在服务器阻塞期间,不能处理任何命令请求。
BGSAVE命令会派生出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求。
RDB文件的载入工作是在服务器启动时自动执行的,所以没有专门的用于载入RDB文件的命令,只要启动时检测到RDB文件存在,就会自动载入RDB文件。
注意:因为AOF文件的更新频率通常比RDB文件更新频率高,所以:
如果服务器开启了AOF持久化功能,那么会优先使用AOF文件还原数据库状态;只有在AOF持久化关闭时,才会使用RDB文件还原数据库。
自动间隔性保存
因为BGSAVE命令可以在不阻塞服务器进程情况下执行,所以允许用户配置自动执行该命令。当任意一个保存条件被满足时,服务器会自动执行BGSAVE命令。
3.2 AOF持久化
与RDB持久化通过保存数据库中键值对来记录数据库状态不同,AOF持久化通过保存Redis服务器所执行的写命令来记录数据库状态的。
AOF持久化功能的实现可以分为命令追加、文件写入、文件同步三个步骤。
命令追加
AOF持久化功能打开时,服务器执行完一个写命令后,会以协议格式将被执行的写命令追加到服务器的aof_buf缓冲区末尾。
AOF文件的写入与同步
将aof_buf缓冲区中的内容写入和保存到AOF文件中,需要由flushAppendOnlyFIle
函数中的appendsync
配置选项值决定。
AOF重写
因为AOF持久化是通过保存被执行写命令来记录数据库状态,所以随着时间流逝,内容会越来越多。为了解决AOF文件体积膨胀问题,Redis提供了AOF文件重写功能,Redis服务器会创建一个新的AOF文件替代旧的AOF文件,并且两个文件保存的数据库状态相同,但新文件不会包含任何浪费空间的冗余命令。
4. Redis常见数据类型及使用场景
4.1 string
string数据结构是简单的key-value类型,相比与C原生字符串,Redis的string类型可以保存文本数据和二进制数据,并且获取长度复杂度为O(1),不会造成缓冲区溢出。
常用命令有set、get、strlen、exists、decr、incr、setex
等等。
应用场景:一般常用在需要计数的场景,比如用户访问次数、热点文章的点赞转发数量扥等
简单使用命令:
# 普通字符串的基本操作
127.0.0.1:6379> set key value #设置 key-value 类型的值
OK
127.0.0.1:6379> get key # 根据 key 获得对应的 value
"value"
127.0.0.1:6379> exists key # 判断某个 key 是否存在
(integer) 1
127.0.0.1:6379> strlen key # 返回 key 所储存的字符串值的长度。
(integer) 5
127.0.0.1:6379> del key # 删除某个 key 对应的值
(integer) 1
127.0.0.1:6379> get key
(nil)
# 批量操作
127.0.0.1:6379> mset key1 value1 key2 value2 # 批量设置 key-value 类型的值
OK
127.0.0.1:6379> mget key1 key2 # 批量获取多个 key 对应的 value
1) "value1"
2) "value2"
#计数器(字符串的内容为整数的时候使用)
127.0.0.1:6379> set number 1
OK
127.0.0.1:6379> incr number # 将 key 中储存的数字值增一
(integer) 2
127.0.0.1:6379> get number
"2"
127.0.0.1:6379> decr number # 将 key 中储存的数字值减一
(integer) 1
127.0.0.1:6379> get number
"1"
# 过期(默认永不过期)
127.0.0.1:6379> expire key 60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56
4.2 list
list即为链表,特点是易于插入与删除,但是随机访问困难。
常用命令:rpush、lpop、lpush、rpop、lrange、llen
等。
应用场景:发布与订阅或者说消息队列、慢查询。
简单使用命令:
# 通过rpush/lpop实现队列
127.0.0.1:6379> rpush myList value1 # 向 list 的头部(右边)添加元素
(integer) 1
127.0.0.1:6379> rpush myList value2 value3 # 向list的头部(最右边)添加多个元素
(integer) 3
127.0.0.1:6379> lpop myList # 将 list的尾部(最左边)元素取出
"value1"
127.0.0.1:6379> lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end
1) "value2"
2) "value3"
127.0.0.1:6379> lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一
1) "value2"
2) "value3"
# 通过rpush/rpop实现栈
127.0.0.1:6379> rpush myList2 value1 value2 value3
(integer) 3
127.0.0.1:6379> rpop myList2 # 将 list的头部(最右边)元素取出
"value3"
# 通过lrange查看对应下标范围的列表元素
127.0.0.1:6379> rpush myList value1 value2 value3
(integer) 3
127.0.0.1:6379> lrange myList 0 1 # 查看对应下标的list列表, 0 为 start,1为 end
1) "value1"
2) "value2"
127.0.0.1:6379> lrange myList 0 -1 # 查看列表中的所有元素,-1表示倒数第一
1) "value1"
2) "value2"
3) "value3"
# 通过llen查看链表长度
127.0.0.1:6379> llen myList
(integer) 3
4.3 hash
redis的哈希是一个string类型的field和value的映射表,特别适合用于对象存储。
常用命令:hset、hmset、hexists、hget、hgetall、hkeys、hvals
等。
应用场景:系统中对象数据的存储。
简单命令使用
127.0.0.1:6379> hmset userInfoKey name "guide" description "dev" age "24"
OK
127.0.0.1:6379> hexists userInfoKey name # 查看 key 对应的 value中指定的字段是否存在。
(integer) 1
127.0.0.1:6379> hget userInfoKey name # 获取存储在哈希表中指定字段的值。
"guide"
127.0.0.1:6379> hget userInfoKey age
"24"
127.0.0.1:6379> hgetall userInfoKey # 获取在哈希表中指定 key 的所有字段和值
1) "name"
2) "guide"
3) "description"
4) "dev"
5) "age"
6) "24"
127.0.0.1:6379> hkeys userInfoKey # 获取 key 列表
1) "name"
2) "description"
3) "age"
127.0.0.1:6379> hvals userInfoKey # 获取 value 列表
1) "guide"
2) "dev"
3) "24"
127.0.0.1:6379> hset userInfoKey name "GuideGeGe" # 修改某个字段对应的值
127.0.0.1:6379> hget userInfoKey name
"GuideGeGe"
4.3 set
set类型是一种无序集合,可以基于set轻易实现交集、并集、差集的操作。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。
常用命令:sadd、spop、dmembers、sismember、scard、sinterstore、sunion
等。
应用场景:需要存放的数据不能重复以及需要获取多个数据源交集和并集等场景。
简单命令使用
127.0.0.1:6379> sadd mySet value1 value2 # 添加元素进去
(integer) 2
127.0.0.1:6379> sadd mySet value1 # 不允许有重复元素
(integer) 0
127.0.0.1:6379> smembers mySet # 查看 set 中所有的元素
1) "value1"
2) "value2"
127.0.0.1:6379> scard mySet # 查看 set 的长度
(integer) 2
127.0.0.1:6379> sismember mySet value1 # 检查某个元素是否存在set 中,只能接收单个元素
(integer) 1
127.0.0.1:6379> sadd mySet2 value2 value3
(integer) 2
127.0.0.1:6379> sinterstore mySet3 mySet mySet2 # 获取 mySet 和 mySet2 的交集并存放在 mySet3 中
(integer) 1
127.0.0.1:6379> smembers mySet3
1) "value2"
4.4 sorted set
和set相比,sorted set增加了一个权重参数score,使得集合中元素能够按score进行有序排列,还可以通过score的范围来获取元素的列表。
常用命令:zadd、zcard、zscore、zrange、zrevrange、zrem等
。
应用场景:需要对数据根据某个权重进行排序的场景。比如直播系统中,实时排行信息包含直播间在线用户列表、各种礼物排行榜、弹幕消息等。
简单命令使用
127.0.0.1:6379> zadd myZset 3.0 value1 # 添加元素到 sorted set 中 3.0 为权重
(integer) 1
127.0.0.1:6379> zadd myZset 2.0 value2 1.0 value3 # 一次添加多个元素
(integer) 2
127.0.0.1:6379> zcard myZset # 查看 sorted set 中的元素数量
(integer) 3
127.0.0.1:6379> zscore myZset value1 # 查看某个 value 的权重
"3"
127.0.0.1:6379> zrange myZset 0 -1 # 顺序输出某个范围区间的元素,0 -1 表示输出所有元素
1) "value3"
2) "value2"
3) "value1"
127.0.0.1:6379> zrange myZset 0 1 # 顺序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value3"
2) "value2"
127.0.0.1:6379> zrevrange myZset 0 1 # 逆序输出某个范围区间的元素,0 为 start 1 为 stop
1) "value1"
2) "value2"
5. Redis淘汰机制
Redis提供了6种数据淘汰策略:
- volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。
- volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
- allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
- allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
- no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。
4.0版本后新增加以下两种:
- volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
- allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
6. 主从
Redis常见几种使用方式包括:
- Redis单副本
- Redis多副本(主从)
- Redis sentinel(哨兵)
- Redis cluster(集群)
使用场景:
如果数据量很少,主要是承载高并发高性能的场景,比如缓存一般就几个G,单机足够了。
主从模式:master节点挂掉后,需要手动指定新的master,可用性不高,基本不用。
哨兵模式:master节点挂掉后,哨兵进程会主动选定新的master,可用性高,但是每个节点存储的数据是一样的,浪费空间。数据量不是很多,集群规模不是很大,需要自动容错容灾的时候使用。
Redis cluster主要针对海量数据+高并发+高可用场景,如果数据量很大,建议使用Redis cluster,所有的master容量总和就是Redis cluster可缓存的数据容量。
6.1 主从复制
Redis中,用户可以执行SLAVEOF
命令或者设置slaveof选项,让一个服务器去复制另一个服务器,进行复制中的主从服务器双方的数据库将保存相同的数据。
6.2 旧版复制功能的实现
Redis的复制功能分为同步和命令传播两个操作:
- 同步操作用于将从服务器数据库状态更新至主服务器状态。
- 命令传播操作则在主服务器数据库被修改时,让主从服务器数据库重新回到一致性状态。
6.2.1 同步
当从服务器执行SLAVEOF命令复制主服务器时,从服务器首先执行同步操作:
步骤如下:
- 从服务器向主服务器发送SYNC命令;
- 收到SYNC命令的主服务器执行BGSAVE命令,在后台生成一个RDB文件,并使用一个缓冲区记录从现在开始执行的所有写命令。
- BGSAVE命令执行完毕后,主服务器将RDB文件发送给从服务器,从服务器载入这个RDB文件。
- 主服务器将记录在缓冲区里面的所有写命令发送给从服务器,从服务器执行这些命令,将数据库状态更新至主服务器一致。
6.2.2 命令传播
执行同步操作后,主从服务器数据状态处于一致,但这种一致不会保持一直不变,当客户端对主服务器执行写命令后,就会主从不一致。
为了能让主从服务器再次回到一致状态,主服务器需要对从服务器执行命令传播操作,主服务器会将自己执行的写命令发送给从服务器,重新恢复一致性。
6.2.3 旧版复制的缺陷
如果处于命令传播阶段的主从服务器因为网络原因中断了复制,从服务器通过自动重新连接连上了主服务器,这时候可能只有一小部分数据不一致,却要让主从服务器重新执行一次同步操作,非常低效。
6.3 新版复制功能的实现
新版本的Redis使用PSYNC命令代替SYNC命令来执行复制时的同步操作。PSYNC命令具有完整重同步和部分重同步两种模式:
- 完整重同步用于处理初次复制情况:执行步骤和SYNC命令一致。
- 部分重同步用于处理断线后重复制情况:主服务器可以将主从服务器连接断开期间执行的写命令发送给从服务器。
接下来介绍部分重同步的实现,部分重同步功能由以下三个部分构成:
- 主服务器的复制偏移量和从服务器的复制偏移量。
- 主服务器的复制积压缓冲区。
- 服务器的运行ID
6.3.1 复制偏移量
执行复制的双方,会分别维护一个复制偏移量,每次传播N个字节数据时,将自己的复制偏移量加上N。通过对比主从服务器的复制偏移量,可以很容易知道主从服务器是否处于一致状态:
6.3.2 复制积压缓冲区
复制积压缓冲区是由主服务器维护的一个固定长度先进先出队列,默认大小1MB。
当主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令入队到复制积压缓冲区里面。并且复制积压缓冲区会为队列的每个字节记录相应的复制偏移量。
如果offset偏移量之后的数据仍然存在于复制积压缓冲区里面,那么主服务器会向重服务器发送+CONTINUE回复,表示数据同步以部分同步模式进行,如果偏移量之后的数据不存在于复制积压缓冲区,则执行完整重同步操作。
6.3.3 服务器运行ID
除了复制偏移量和复制积压缓冲区外,还需要用到服务器运行ID,每个服务器都会有自己的运行ID,从服务器会保存主服务器的运行ID,用于判断断开连接之前复制的是否是这个服务器ID,相同则可以执行部分重同步操作。
PSYNC命令的执行流程如下:
6.4 心跳检测
在命令传播阶段,从服务器默认每秒一次的频率,向主服务器发送命令:REPLCONF ACK <replication_offset>
,其中replication_offset是从服务器当前的复制偏移量,主要有三个作用:
- 检测主从服务器的网络连接状态;
- 辅助实现min-slaves选项;
- 检测命令丢失。
6.4.1 检测主从服务器的网络连接状态
主从服务器可以通过发送和接收REPLCONF ACK命令来检测两者之间的网络连接是否正常。
6.4.2 辅助实现min-slaves配置选项
Redis的min-slaves-to-write
和min-slaves-max-lag
两个选项可以防止主服务器在不安全情况下执行写命令。
如下例子:
Min-slaves-to-write 3
Min-slaves-max-lag 10
那么在从服务器数量小于3个,或则三个从服务器延迟(lag)都大于等于10秒时,主服务器将拒绝执行写命令。
如何结局主从架构中数据丢失问题?
- 可以减少min-slaves-max-lag参数的值,可以避免发生故障时大量的数据丢失,一旦延迟超过了设定值,主服务器将拒绝执行写命令。
- 对于客户端,我们可以采取降级措施,将数据暂时写入本地缓存或者磁盘中,一段时间后重新写入master来保证数据不丢失;也可以将数据写入消息队列,隔一段时间去消费消息队列中的数据。
6.4.3 检测命令丢失
如果网络故障,主服务器传播给从服务器的写命令丢失,那么从服务器向主服务器发送REPLCONF ACK 命令时,主服务器就会发觉当前偏移量小于自己的复制偏移量,则会在积压缓冲区中找到缺失的数据,重新发送。
7 哨兵
sentinel(哨兵)是Redis的高可用性解决方案:由一个或多个sentinel实例组成sentinel系统,可以监视任意多个主服务器以及这些主服务器属下的所有从服务器,在被监视的主服务器下线后,自动将下线主服务器属下的某个从服务器升级为新的主服务器。
主服务器下线,将终止从服务器的复制:
将选一个从服务器升级为主服务器,并且监控原来的主服务器是否上线,上线后变成从服务器:
7.1 检测主观下线状态
默认情况下,sentinel会以每秒一次的频率向所有与它创建了命令连接的实例(包括主服务器、从服务器、其他sentinel)发送PING命令,并通过实例返回的PING命令回复来判断实例是否在线。
如果一个实例在down-after-milliseconds毫秒内,连续向sentinel返回无效回复,则这个实例会被标记为主观下线状态。
7.2 检测客观下线状态
当sentinel将一个主服务器判断为主观下线之后,为了确认这个主服务器是否真的下线了,会向同样监视这一主观服务器的其他sentinel进行询问,看他们是否也认为主服务器已经进入了下线状态(可以是主观下线或者客观下线)。当sentinel从其他sentinel那里接收到足够数量的已下线判断之后,sentinel就会将主服务器判定为客观下线,并对主服务器执行故障转移操作。
7.3 选举领头sentinel
当一个主服务器被判断为客观下线时,监视这个下线主服务器的各个sentinel就会协商,选举出一个领头的sentinel,并由领头的sentinel对下线主服务器执行故障转移操作,领头sentinel的选取通过共识算法—raft算法选取。
领头sentinel选举算法流程如下:
-
所有sentinel都有被选为领头sentinel的资格,每次进行领头sentinel选举之后,不论选举是否成功,所有sentinel的配置纪元的值都会自增一次。
-
在一个配置纪元里面,所有sentinel都有一次将某个sentinel设置为局部领头sentinel的机会,每个发现主服务器进入客观下线的sentinel会要求其他sentinel将自己设置为局部领头sentinel。
-
当一个sentinel(源)向另一个sentinel(目标)发送
SENTINEL is-master-down-by-addr
命令,表示源sentinel要求目标sentinel将他设置为后者的局部领头。 -
sentinel设置局部领头的规则是先到先得,后续请求会被拒绝。
-
目标sentinel会向源sentinel返回配置纪元和设置的局部sentinel领头的运行ID,源sentinel发现运行ID、配置纪元都能匹配上,则表示目标sentinel将自己设置成了局部领头。
-
如果某个sentinel被半数以上(N/2+1)的sentinel设置成了局部领头,则选举成功。
-
如果给定时限内没有sentinel被选举成功,一段时间后进入下一个纪元,继续选举。
7.4 故障转移
选举产生领头sentinel之后,领头sentinel将会对已下线的主服务器执行故障转移操作:
-
在已下线主服务器属下的所有从服务器中选出一个服务器,将其转换为主服务器。
-
修改从服务器的复制目标
-
将旧的主服务器变成从服务器。
选取升级为主服务的的从服务器标准?
- 删除从服务器列表中处于下线或者断开状态的。
- 删除列表中最近五秒内没有回复过领头sentinel的INFO命令的从服务器,保证列表中剩余的从服务器都是最近成功进行通信的。
- 删除所有与已下线主服务器连接断开超过down-after-milliseconds*10的从服务器,保证从服务器中保存的数据都是比较新的。
之后领头sentinel将根据优先级对列表中剩余从服务器排序,选取优先级最高的从服务器。如果优先级相等,就看偏移量,选取保存最新数据的从服务器(即偏移量最大)。偏移量相等,选取运行ID最小的服务器。
8 集群
Redis集群是Redis提供的分布式数据库方案,集群通过分片来进行数据共享,并提供复制和故障转移功能。
8.1 节点
一个Redis集群通常由多个节点组成,连接各节点工作可以使用CLUSTER MEET
命令。节点握手示意图:
8.2 槽指派
Redis集群通过分片的方式保存数据库中的键值对:集群的整个数据库被分为16384个槽,数据库中的每个键都属于这16384个槽的其中一个,集群中的每个节点可以处理0—16384个槽。
当数据库中的16384个槽都有节点在处理时,集群处于上线状态;相反,只要有一个槽没得到处理,集群就处于下线状态。通过CLUSTER ADDSLOTS
命令可以将槽分配给节点负责。
8.3 在集群中执行命令
当客户端节点发送与数据库键有关命令时,接收命令的节点会计算出命令要处理的数据库键属于哪个槽,并检查这个槽是否指派给了自己,流程如下:
8.4 重新分片
Redis集群的重新分片操作可以将任意数量已经指派给某个节点的槽改为指派给另一个节点,该过程不需要集群下线,并且源节点和目标节点可以继续处理命令请求。在迁移槽过程中,可能会产生ASK错误,如果节点A正在迁移槽i至节点B,那么当节点A没能在自己的数据库中找到命令指定的数据库键时,节点A会向客户端返回一个ASK错误,指引客户端到节点B继续查找指定的数据库键。
MOVED错误表示槽的负责权已经从一个节点转移到了另一个节点,而ASK错误只是两个节点在迁移槽的过程中使用的一种临时措施。
8.5 消息
集群中各节点通过发送和接收消息来进行通信,节点间发送的消息主要有5种:
- MEET消息:客户端执行CLUSTER MEET命令时, 发送者要求接受者加入到自己所处的集群中。
- PING消息:集群中每个节点默认每隔一秒钟会从已知节点列表中随机选出五个节点,然后对这五个节点中最长时间没有发送PING消息的节点发送PING消息,以此来检测被选中节点是否在线。
- PONG消息:当接受者收到发送者发来的MEET消息或者PING消息,为了告知已经确认收到,会返回一条PONG消息。
- FAIL消息:当节点A判断节点B已经进入FAIL状态时,节点A会向集群广播一条关于节点B的FAIL消息。
- PUBLISH消息:当节点接收到一个PUBLISH命令时,会广播该消息。
**Redis 的集群节点之间的通信采取 gossip 协议进行通信。**Gossip协议由MEET、PING、PONG三种消息实现。
9 Redis实现分布式锁
我们在系统中修改已有数据时,需要先读取,然后再进行修改,此时很容易遇到并发问题。由于修改和保存都不是原子操作,在并发场景下,部分对数据的操作可能会丢失。在单服务器系统我们常用本地锁来避免并发带来的问题,然而当服务器采用集群方式部署时,本地锁无法在多个服务器之间生效,这个时候保证数据的一致性就需要分布式锁来实现。
9.1 实现
Redis锁主要利用Redis的setnx
命令。
- 加锁命令:
SETNX key value
,当键不存在时,对键进行设置操作并返回成功,否则返回失败。KEY是锁的唯一标识,一般按业务决定命名。 - 解锁命令:
DEL key
,通过删除键值对释放锁,以便其他线程可以通过SETNX命令来获取锁。 - 锁超时:
EXPIRE key timeout
,设置key的超时时间,以保证即使锁没有被显式释放,锁也可以在一定时间后自动释放,避免资源被永远锁住。
9.2 存在的问题
1. SETNX和EXPIRE非原子性
如果SETNX成功,在设置锁超时时间后,服务器挂掉、重启或网络问题等,导致EXPIRE命令没有执行,锁没有设置超时时间变成死锁。
可以使用lua脚本,示例:
if (redis.call('setnx', KEYS[1], ARGV[1]) < 1)
then return 0;
end;
redis.call('expire', KEYS[1], tonumber(ARGV[2]));
return 1;
// 使用实例
EVAL "if (redis.call('setnx',KEYS[1],ARGV[1]) < 1) then return 0; end; redis.call('expire',KEYS[1],tonumber(ARGV[2])); return 1;" 1 key value 100
2. 锁误解除
如果线程 A 成功获取到了锁,并且设置了过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁;随后 A 执行完成,线程 A 使用 DEL 命令来释放锁,但此时线程 B 加的锁还没有执行完成,线程 A 实际释放的线程 B 加的锁。
通过在value中设置当前线程加锁的标识,在删除之前验证key对应的value判断锁是否是当前线程持有的。可生成一个UUID标识当前线程,使用lua脚本验证标识和解锁操作。
// 加锁
String uuid = UUID.randomUUID().toString().replaceAll("-","");
SET key uuid NX EX 30
// 解锁
if (redis.call('get', KEYS[1]) == ARGV[1])
then return redis.call('del', KEYS[1])
else return 0
end
3. 超时解锁导致并发
如果线程 A 成功获取锁并设置过期时间 30 秒,但线程 A 执行时间超过了 30 秒,锁过期自动释放,此时线程 B 获取到了锁,线程 A 和线程 B 并发执行。
两个线程并发显然是不被允许的,一般有两种解决方式:
- 将过期时间设置足够长,以保证代码逻辑能够在释放锁之前执行完成。
- 为获取锁的线程增加守护线程,为将要过期但未释放的锁增加有效时间。
4. 不可重入
当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。Redis 可通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁。
可以通过Redis map数据结构来实现分布式锁,既存锁的标识也对重入次数进行计数。
5. 无法等待锁释放
上述命令执行都是立即返回的,如果客户端可以等待锁释放就无法使用。
- 可以通过客户端轮询的方式解决。这种方式比较消耗服务器资源,并发量大的时候,会影响服务器效率。
- 可以使用Redis的发布订阅功能,当获取锁失败时,订阅锁释放信息,获取锁成功释放时,发送锁释放信息。如下:
10 保证数据库和缓存一致性
缓存异常有四种类型,分别是缓存和数据库的数据不一致、缓存雪崩、缓存击穿、缓存穿透。
10.1 如何保证缓存和数据库双写时数据一致性?
共有四种方案:
- 先更新数据库,后更新缓存
- 先更新缓存,后更新数据库
- 先删除缓存,后更新数据库
- 先更新数据库,后删除缓存
第一种和第二种方案很少使用,第一种方案存在问题:并发更新数据库场景下,会将脏数据刷到缓存。
第二种存在问题:如果先更新缓存成功,但数据库更新失败,则肯定会造成数据不一致。
10.2 先删除缓存,后更新数据库
存在问题:有两个请求,请求A(更新操作),请求B(查询操作):
- 请求A进行写操作,删除缓存
- 请求B查询发现缓存不存在
- 请求B去数据库查询得到旧值
- 请求B将旧值写入缓存
- 请求A将新值写入数据库
上述情况会导致不一致情况。而且如果不采用给缓存设置过期时间策略,该数据将永远都是脏数据。
10.2.1 延时双删
最简单的解决办法就是延时双删。
- 先淘汰缓存
- 再写数据库(和原来两步一样)
- 休眠一秒,再次淘汰缓存
这样做,可以将1秒内所造成的缓存脏数据,再次删除。确保读请求结束,写请求可以删除读请求造成的缓存脏数据。自行评估业务情况,休眠时间可以更改。
如果使用MySQL的读写分离架构,那么主从同步之间也有时间差:
请求A更新操作,删除Redis,请求B查询操作,发现Redis没有数据,去从库拿数据,但此时主从同步数据还未完成,拿到的数据是旧数据。
解决办法是:如果对Redis进行填充数据的查询数据库操作,强制将其指向主库进行查询。
10.2.2 更新与读取操作进行异步串行化
- 异步串行化
一个数据变更操作,先执行删除缓存,然后再去更新数据库,但是还没完成更新的时候,此时来了一个读请求,读到了空缓存。可以先将缓存更新的请求发送到队列中,此时会在队列中积压,排在刚才更新库操作之后,然后同步等待缓存更新完成,再读库。
- 读操作去重
多个读操作更新缓存的请求在同一个队列里面是没有意义的,可以做过滤。如果发现队列中已经有了该数据更新缓存的请求,就不用再放进队列里面。
如果请求还在等待时间范围内,不断轮询发现可以取到值,就直接返回;如果等待时间超时,那么这一次就直接返回从数据库中读取到的当前旧值,仅仅读库后返回而不放入缓存。
10.3 先更新数据库,后删除缓存
这种情况也会出现问题:比如更新数据库成功了,但在删除缓存阶段出错了没有删除成功,那么此时在读取缓存的时候每次都是错误数据。
解决方案是:利用消息队列进行删除的补偿:
- 请求A先对数据库进行更新操作
- 在对Redis进行删除操作的时候,发现删除报错,删除失败
- 此时将Redis的key作为消息体发送到消息队列中
- 系统接收到消息队列发送的消息后再次对Redis进行删除操作。
上述方案缺点是对业务代码造成大量的侵入,深深偶合在一起。优化方案:MySQL更新操作后在binlog日志中我们能够找到响应的操作,则可以订阅MySQL数据库的binlog日志对缓存进行操作。
11. Redis应用-限流
常见限流算法有三种:计数器、漏桶、令牌桶。
11.1 计数器
原理:记录每个请求,判断在设定的限流时间窗口内请求数是否大于限制数。
利用Redis有序集合实现,并用管道加速。
思路:用户ID作为key,毫秒时间戳作为score。
- 加入有序集合—zadd。
- 移除时间窗口之前的行为记录,剩下的都在时间窗口内—zremrangebyscore。
- 获取窗口内元素数量—zcard。
11.2 漏桶算法
原理:漏桶 (Leaky Bucket) 算法思路很简单,水 (请求) 先进入到漏桶里,漏桶以一定的速度出水 (接口有响应速率), 当水流入速度过大会直接溢出 (访问频率超过接口响应速率), 然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。
Redis4.0提供了一个限流Redis模块,名称为redis-cell,该模块提供了漏桶算法。
11.3 令牌桶算法
原理:令牌桶算法 (Token Bucket) 和 Leaky Bucket 效果一样但方向相反的算法,更加容易理解。随着时间流逝,系统会按恒定 1/QPS 时间间隔 (如果 QPS=100, 则间隔是 10ms) 往桶里加入 Token (想象和漏洞漏水相反,有个水龙头在不断的加水), 如果桶已经满了就不再加了。新请求来临时,会各自拿走一个 Token, 如果没有 Token 可拿了就阻塞或者拒绝服务。
利用Redis string+定时任务实现
利用定时任务不断增加令牌数,Redis—incr,自增1,令牌桶满则不再增加。
来一个请求消耗一个令牌,Redis—decr,自减1,当没有令牌时则拒绝请求。
好处是:一旦需要提高速率,则按需求提高放入桶中令牌的速率即可。
12. Redis应用-消息队列
Redis通过list数据结构来实现消息队列:主要用到以下命令:
- lpush和rpush入队列
- lpop和rpop出队列
- blpop和brpop阻塞式出队列
使用阻塞式出队好处:在队列没有数据时,会立即进入休眠,一旦数据到来,则立即醒过来,消息延迟几乎为零。
延时队列
你是否在做电商项目的时候会遇到如下场景:
- 订单下单后超过一小时用户未支付,需要关闭订单
- 订单的评论如果7天未评价,系统需要自动产生一条评论
这个时候就需要延时对了,即需要延迟一段时间后执行。Redis可以用过zset来实现。将有序集合的key设置为消息任务,score设置为消息的到期时间,然后轮询获取有序集合中到期消息进行处理。