Redis突击八股文

Redis学习指南


这个也是问的贼多,主要结合项目问,项目用到就一定要掌握好,项目没有用到,那么不做要求。

需要掌握的知识概括:

一、Redis 基础知识需要掌握的点(按照出现频率排序)

1、redis 五种常见数据结构的特点以及应用场景

2、zset 的底层数据结构 + 掌握跳表的原理

3、redis 的过期删除策略

4、理解缓存穿透 + 缓存雪崩,以及掌握他们的解决方式。

5、掌握布隆过滤器的基本原理

6、redis两种持久化方式以及他们的区别 + redis 默认用的是哪种持久化方式

7、redis 之所以快的原理

8、掌握哈希这种数据结构的扩容原理 + 渐进式 rehash

9、redis事务(简单了解,主要redis的事务还mysql有点不一样)

二、Redis 高级部分知识需要掌握的点(按照出现频率排序)

1、缓存和数据库一致性问题:为啥会出现以及常见的解决方式(上面有给了对应的文章)

2、redis 实现一个分布式锁以及用 redis 的缺点

3、redis 集群的几个理论:哨兵选举/master 选举原理

参考学习文章以及资料


本系列讲解的比较详细,故暂时没有补充文章

系统资料推荐:多线程基础,不需要看什么系统内容,跟着课程入门即可之后跟着这些面试题就行,问的不深入,并发部分属于学无止境,推荐看 0. Java并发编程课程说明,后面有余力,看《Java并发编程艺术》这本书看个三四遍,基本对并发问题无所畏惧。

正文

一、基本数据结构考点

1、能说一说你为啥使用 Redis 吗?你觉得 Redis 最核心的功能是什么?


Redis 是一款灵活的高性能 key-value 数据库,我们通常可以将其用作缓存,分布式锁,轻量级消息队列,计数器,排行榜,统计等。

回答这种问题最好的方式就是我们可以给面试官举一些真实的例子,特特是你做过的例子,这样看起来不像是背诵的,可以让面试官对你有更好的印象,下面我简单举一个例子吧

1、项目中统计网站 UV 的实现方案

首先想到的是,为每一个页面设置一个独立的 set 集合来存储所有当天访问过此页面的用户 ID。

但这样存在的问题是:

  • 占用空间大:如果网站访问量一大,用来存储的 set 集合就会非常大。
  • 统计复杂:多个 set 集合之间如果要聚合统计,复杂度非常高。

而 Redis 中的 HyperLogLog 类型就是专门用来解决这种问题的。

准确来说它是一种基数算法,可以利用极小的内存空间完成独立总数的统计,数据集可以是 IP,Email,ID 等。同时,它还支持取多个 HyperLogLog 的并集。

另一个例子是项目中的 “限制用户刷新次数” 功能,我们固然可以在用户每次刷新时都在数据库中读出用户已经刷新的次数,然后将其加一,再写入数据库。

但 Redis 提供的,拥有天然自增功能的 String 数据结构可以完美胜任这个场景。并且这个自增的操作 incr 具有原子性,可以应对高并发场景。

2、”快“是 Redis 最大的闪光点

一提到 Redis 大家可能会脱口而出:快。当然这也是大部分人使用 Redis 的原因。为了实现“快”,Redis 采用了单线程,基于多路复用的高性能IO模型,基于事件的回调机制等。

具体来说,Redis 将网络 IO 和键值对的读写都交给一个线程来完成。但单线程的 Redis 为何这么快?

原因在于 Redis “借用”了 Linux 中的 IO 多路复用机制(select/epoll),该机制中的内核会有多个监听套接字(FD)和已连接套接字,同时内核会一直监听这些套接字的连接请求或数据请求,一旦有请求到达,就会将其交给 Redis 线程处理。

但请求到达时,该怎么样通知到 Redis,需要对其进行处理呢?

「基于事件的回调机制」就是解决这种困境的,即针对不同事件的发生,调用响应的处理函数。

具体的,select/epoll 一旦检测到 FD 中有请求到达,就会触发相应的事件。并且这些事件都会被放在一个事件队列中,Redis 单线程只需不断处理这个事件队列中的事件进行处理,并调用相应的处理函数,这就实现了基于事件的回调。

整个过程如下图:


2、你知道Redis有哪些常见数据结构以及应用场景吗?


常见的数据结构有:字符串String、哈希表Hash、列表List、集合Set、有序集合Sorted Set。除了这些,还有 Bitmaps、HyperLogLog、GEO,甚至还可以自定义数据结构。接下来将一一介绍:

PS:你在回答的时候,每次介绍一个类型时,可以举一些例子,下面我会给出对应的例子,你每个类型记住一两个就可以了。

1、String – 字符串

String 类型是 Redis 最基础的数据结构,其它几种数据结构都是在字符串类型的基础上创建的。并且所有数据结构的 key 也都是字符串类型。

String 类型只能存储单个数据,即一个 key 对应一个 value。同时它还是二进制安全的,意思就是 String 类型严格按照二进制数据进行存取。

正因如此,它可以存储任何数据,包括字符串:简单的字符串,复杂的 JSON,XML 字符串;数字:整数,浮点数;甚至是二进制:图片,音频,视频等。

内部编码:

String 类型的内部编码有三种:int,embstr,raw,分别在当前值为 8 字节长整形,小于等于 39 字节的字符串,大于 39 字节的字符串时使用。

Redis 会根据当前 String 的值类型和大小自己决定使用哪种内部编码实现。

使用场景:

(1)充当缓冲:缓存用户的基本信息

这是一个最容易被想到的应用场景:由于用户基本信息更改频率比较低,但是像用户昵称,头像这些基本信息会用的比较频繁,所以我们可以对他进行缓存,例如:可以将 MySQL 中每个用户的信息转换为 JSON 格式存储在 String 类型中。其伪代码为:

// 从 MySQL 中获取用户信息
userInfo = mysql.get(id);
// 转换为 JSON 格式
userInfoJSON = JSON.parse(userInfo);
// 存入 Redis 中
redis.set("project:user:id", userInfoJSON);

(2)计数

String 类型天然可自增,利用 incr 命令可以实现快速计数功能,同时它还是原子性的操作。比如可以用来记录网站上视频的播放量。其伪代码如下:

long incrVedeoWatch(long id) {
  // 获取视频播放信息的 key
  key = "project:video:watch:" + id;
  // 对其使用自增操作
  return redis.incr(key);
}

(3)在一段时间内限制请求次数

比如为了防止用户恶意刷新网页,可以限制一个 IP 地址在一段时间内刷新网页的次数。其伪代码为:

ip = IpUitls.get(request);
key = "project:requestLimit:" + ip;
// 使用 -nx 确保键不存在
isExists = redis.set(key, 1, "EX 60", "NX");
if (isExists != null || redis.incr <= 5) {
  // 通过
} else {
  // 限速
}

(4)分布式共享 Session

通常每个服务器只会存储自己的 Session,考虑在负载均衡的情况下,分布式服务会将用户的访问均衡到不同的服务器上,此时用户的信息只存在一个服务器上显然是不合适的。

这时就可以考虑使用 Redis,将所有用户的 Session 信息进行集中管理,各个服务器在需要读取用户信息时,只需向 Redis 中查询即可。如图:

2、Hash - 哈希表

Redis 中的 Hash 类型是一个键值对集合,一个 key 对应多个 field - value 键值对,且不允许重复。

内部编码:

Hash 类型的内部编码有两种: ziplist(压缩列表),dict(字典)。当元素个数少时使用 zipllist,否则使用 dict。

ziplist 使用非常紧凑的结构实现多个元素的连续存储,在节省内存方面比 hashtable 优秀。

但是当元素多时,ziplist 的读写效率会下降,这时 hashtable 的优势就体现出来了,它的读写时间复杂度仅为 $O(1)$。所以在 Hash 类型元素多时,内部编码会自动完成从 ziplist 到 hashtable 的转变,以保证读写效率。

Reids 5 提供了一种新的数据结构 listpack,用来代替 Hash 和 Zset 类型中的 ziplist 。它的特点也是用一块连续的内存空间紧凑的存储数据,但不同于 ziplist,listpack 每个列表项只记录自己的长度,而不会记录前一列表项的长度,从而避免了连锁更新

使用场景:

(1)缓存用户基本信息

相比于使用 String 类型序列化缓存用户信息,Hash 类型将变得更加直观。同时它还支持直接修改 field,因此可以十分便捷的对信息进行更新操作。如下图:

PS:在回答的时候不要两次都说缓存用户信息,否则你就要想清楚,我应该用哪种数据结构来缓存。

(2)用户购物车的商品记录

将用户 id 作为键后缀,商品名称作为 field,商品数量为 value。操作伪代码如下:

key = "project:cart:" + id;
// 添加商品
redis.hset(key, goods);
// 添加数量
redis.hincrby(key, goods);
// 商品总数
redis.hlen(key);
// 删除商品
redis.del(key);
// 获取购物车所有商品
redis.hgetall(key);

3、List - 列表

List 类型存储的是多个有序的字符串,其中的“有序”指的是插入时间上的先后顺序

它是一种比较灵活的数据结构,可以充当栈和队列的角色。

列表类型有两个特点:首先是列表中的元素是有序的,这就意味着可以通过索引下标获取某个元素或者某个范围内的元素列表。其次是列表的元素可重复

内部编码:

列表类型有两种编码:ziplist,linkedlist(双向链表)。当元素个数少且每个元素的值也小时使用 zipllist,其它情况下则使用 hashtable。

Redis 3.2 版本提供了 quicklist 内部编码,之后取代了 List 类型的 ziplist 和 linkedlsit ,它结合了 ziplist 和 linkedlist 两者的优势,进一步压缩了内存的使用量,进一步提高了效率。

使用场景:

(1)消息队列

List 类型提供的 LPUSH + RPOP 命令组合可以实现一个简易版的”消息队列“。生产者使用 LPUSH 命令在列表左侧插入消息,消费者使用 RPOP 命令在列表的右侧获取消息。

但是如果使用这种方案的话,消费者在读取消息时,有一个潜在的性能风险:

生产者在往列表中写入消息时,并不会主动的通知消费者有新消息写入列表了。消费者需要在一个循环中不停地调用 RPOP 命令,来确保能够及时的处理消息。

这样的话,即使一段时间内没有新消息到达,消费者也要不断循环“试探”,浪费了 CPU 性能。

Redis 提供的 BRPOP 命令可以用来解决这个问题。BRPOP 命令是 RPOP 的阻塞版本,当列表中没有任何元素可以弹出时,连接将被 BRPOP 命令阻塞,直到连接超时或者有元素可弹出为止。

为了方便理解,可以看下图:

(2)模拟基础的数据结构:栈和队列

栈的特点为”先进后出“,我们可以用 List 类型的 LPUSH + LPOP 命令实现,从左边添加元素,再从左边弹出元素。

队列的特点为”先进先出“,我们可以用 List 类型的 LPUSH + RPOP 命令实现,从左边添加元素,从右边弹出元素。

4、Set - 集合

Set 类型也是用来保存多个字符串元素,它的特点和 List 类型恰好相反:Set 类型不允许有重复元素,并且其中的元素都是无序的。其中 Set 类型的自动去重功能在实际应用中非常关键。

Redis 除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。

内部编码:

Set 类型的内部编码有两种:intset(整数集合),hashtable(哈希表),当集合中的元素都是整数且元素个数少时,Redis 会用 intset 来作为内部编码。其它情况下则会用 hashtable 作为集合内部实现。

使用场景:

(1)点赞列表(集合内操作)

Set 类型可以保证在一篇文章下,一个用户只能点赞一次。

下面使用集合类型实现点赞列表的若干功能。

  1. 对文章点赞
SADD project:article1:like id1 id2
  1. 对文章取消点赞
SREM project:article1:like id1
  1. 获取文章点赞数
CARD project:article1:like
  1. 获取给文章点赞的用户
SMEMBERS project:article1:like
  1. 判断用户是否给文章点赞
SISMEMBER project:article1:like id2

(2)好友人脉(集合间操作)

由于集合类型支持集合间的运算,所以可以很好的实现共同好友,共同喜好等功能。

  1. 添加好友
SADD id1 id2 id3 id4
SADD idA idB idC idD
  1. 获取共同好友
SINTER id1 idA
  1. 验证共同好友
SISMEMBER id1 id5
SISMEMBER idA id5
  1. 删除好友
SREM id1 id2

(3)UV(独立用户总数)统计

利用 Set 类型的自动去重特性,可以快速实时统计访问网站的独立IP。

如果只需要统计独立用户总数,而不需要查询具体有哪些用户,并且追求极小的空间占用,可以使用后边介绍的 HyperLogLog 类型。

5、Zset - 有序集合

顾名思义,Redis 中的 Zset 类型中的成员是有序排列的。它和 Set 类型的相同之处在于:集合中的每个元素都是 字符串类型,并且不允许重复。

而它们最大的区别在于,Zset 类型是有序的,Set 类型是无序的。

原因在于 Zset 类型相比于 Set 类型多了一个排序属性 score(分值),它的排序功能正是通过 score 作为依据来实现的。

内部编码:

有序集合的内部编码有两种:ziplist(7.0 之后替换为 listpack),skiplist(跳表)。当元素个数少且每个元素的值也小时使用 zipllist,其它情况下则使用 dict + skiplist。

使用场景:

(1)排行榜

Zset 类型比较典型的使用场景就是排行榜,并且可以依据多个维度(点赞,播放量,时间)实现榜单。例如播放量最高的视频,点赞数最多的文章,商品的销量排名等。

下面以「视频点赞排行榜」为例,score 代表点赞数:

  1. 某用户发布了一个视频 v1,并获得了 3 个赞,可以使用有序集合的 ZADD 命令添加视频, ZINCRBY 命令增加视频的点赞数。
    ZADD project:video:like 3 v1
    ZINCRBY project:video:like 1 v1
    
  2. 这位用户又下架了视频 v1,那自然需要在视频点赞排行榜中将其踢出。
    ZREM project:video:like v1
    
  3. 展示获取点赞数最多的视频,可用 ZREVRANGE 命令实现。
    ZREVRANGE project:video:like 0 9
    
  4. 展示视频 v1 的分数和排名,可用 ZSCORE 和 ZRANK 两个命令。
    ZSCORE project:video:like v1
    ZRANK project:video:like v1
    

(2)电话,姓名排序

Zset 还支持按照 key 来进行排序,使用 ZRANGEBYLEX 命令。

需要注意的是:此操作返回指定区间的元素,按 key 正序排列,其 score 必须相同,否则结果将不准确。

可以使用此特性来实现电话,姓名的排序。

下面以电话排序为例:

  1. 添加电话号码
    ZADD project:phone 0 13700111234
    
  2. 获取所有的电话号码
    ZRANGEBYLEX project:phone - +
    
  3. 获取 137 开头的电话号码
    ZRANGEBYLEX project:phone [137 (138   // 相当于 [137, 138)
    

6、Bitmaps

可以把 Bitmaps 想象成一个以位(bit)为单位的数组,数组的每个单元只能存储 0 和 1,数组的下标在 Bitmaps 中叫做偏移量(从 0 开始算起)。如图:

使用场景:

Bitmaps 类型非常适合二值统计的场景,同时由于元素的取值只有 0 和 1 两种,在记录海量数据时,Bitmaps 能有效的节省内存空间。

(1)签到统计

在签到统计场景中,我们只需记录签到(1)或未签到(0),此时用 Bitmaps 就非常合适。

每个用户一天的签到用 1 个 bit 就能表示,那么一个月的签到情况用 30 个 bit 就能表示,一年的签到也只用 365 个 bit 就能表示。

  1. 记录 user1 在 8 月 18 号签到
    SETBIT project:sign:user1:202208 17 1  // bitmaps 只有 31 位
    
    long days = convertToDays(2022-8-18);
    redis.setbit(" project:sign:user1", days - 1, 1);  // bitmaps 有 365 位
    
  2. 查看 user1 在 8 月 18 号是否签到
    GETBIT project:sign:user1:202208 17
    
  3. 统计 user1 在 8 月份的签到次数
    BITCOUNT project:sign:user1:202208
    
  4. 统计 user1 在 8 月份的首次打卡日期。此问题需要使用 BITPOS key value [start] [end] 命令。它返回 (start 到 end 范围内)Bitmaps 中第一个值为 value 的 bit 。
    BITPOS project:sign:user1:202208
    

(2)记录独立用户访问网站的情况

类似于签到统计,可以将每个独立用户是否访问过网站存放在 Bitmaps 中,将访问的用户记做 1,没有访问的用户记做 0,用偏移量作为用户的 id。例如:

其它操作类似,参考使用场景(1)。

7、HyperLogLog

HyperLogLog 是一种基数算法,即用来统计一个集合中不重复元素的个数。通过 HyperLogLog 可以利用极小的内存空间完成独立总数的统计,数据集可以是 IP,Email,ID 等,

HyperLogLog 的基本特征是它只做基数计算,不会保存元数据。

它的内存占用量小得惊人,使用 Set 类型和 HyperLogLog 类型统计百万级用户的占用空间对比如下图:

但是利用如此小的空间能计算如此大的数据,其结果必然具有一定误差,误差在 $0.81\%$ 左右。

使用场景:

Redis 提供的 HyperLogLog 类型一共只有三个命令:PFADD,PFCOUNT,PFMERGE。下面以统计访问 IP 为例:

(1)添加

PFADD 2022_10_30:ip:unique 127.0.0.1 114.9.8.10

(2)计算独立 IP 总数

PFCOUNT 2022_10_30:ip:unique

(3)合并

PFMERGE 可以求出多个 HyperLogLog 的并集,并赋值给指定的 HyperLogLog。

PFMERGE 2022_10_30_31:ip:unique 2022_10_30:ip:unique 2022_10_31:ip:unique

8、GEO

GEO 类型主要用来实现地理信息定位功能,它使用 GeoHash 算法将二维的经纬度数据映射成一维二进制数组,并支持对这些数据进行操作。

日常生活中,我们在地图软件上使用的 “附近的餐馆”、”叫车“ 都属于基于位置信息服务(Location-Based Service,LBS)的应用。

LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中。

使用场景:

以”打车服务“为例:

  1. 将 car1 ,经纬度为(117.120042221, 39.080053576)添加到 GEO 集合:
    GEOADD cars:location 117.120042221 39.080053576 car1
    
  2. 当有用户开启 ”叫车“ 功能时,GEO 可以根据用户所在的经纬度信息(117.120042232, 39.080053562),查找以这个经纬度为中心的 5 公里内的车辆信息:
    GEORADIUS cars:location 117.120042232 39.080053562 5 km 
    

9、Stream

Stream 类型是 Redis 5.0 专门为消息队列设计的数据类型,它提供了丰富的消息队列操作命令。

它的设计满足消息队列的三个需求,分别是消息保序、处理重复的消息和保证消息可靠性。下面通过这三个要求来介绍 Stream。

(1)消息保序

Stream 类型本身就是按照先进先出的顺序对数据进行存取的,所以,Stream 已经满足消息保序的需求了。

(2)处理重复消息

生产者可以使用 Stream 类型的 XADD 命令,以键值对的方式插入一条消息,插入后的消息可以保证其有序。

原因在于使用此命令时,可以在消息队列名称后面带上 * 号,那么插入的消息将自动生成全局唯一 ID。

XADD mystream * mykey myvalue
"1667142020901-0"

消费者在用 XREAD 命令从消息队列中读取消息时,可以指定一个消息 ID,并从这个 ID 开始依次往后读取。

XADD mystream * mykey myvalue 1667142020901-0

和 List 类型一样,Stream 类型也提供了阻塞读功能,可以在使用 XREAD 命令时设定 BLOCK 配置项,并设置阻塞的毫秒数。

XREAD BLOCK 100 STREAMS mystream 1667142020901-0

(3)保证消息可靠性

为了保证某个消费者宕机重启后,仍然可以读取未处理完的消息。Stream 使用了内部队列(PENDING List)来留存每个消费者需要读取的信息,直到消费者使用 XACK 命令通知 Stream ”消息已经处理完成“。

如果消费者没有成功处理消息,Stream 就不会收到 XACK 命令,此时消息仍会留存在 PENDING List 中。在消费者恢复后,可使用 XPENDING 命令查看已经读取,但还未确认处理完成的消息。

(4)消费组

除上述之外,Stream 本身支持使用 XGROUP 创建消费组,消费组的目的为让组内的多个消费者共同分担读取消息,实现消息读取负载在多个消费者间是均衡的。

在消费组中,Stream 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。

下面演示(3)(4)中提到的命令:

  1. 创建一个消费组,末尾的 0 表示从 mystream 的首端开始消费。
    > XGROUP CREATE mystream mygroup 0
    OK
    
  2. 让消费组中的 consumer1 从 mystream 中读取所有消息,末尾的 > 表示从第一条未被消费的信息开始读取。
    > XREADGROUP GROUP mygroup consumer1 STREAMS mystream >
    1)1)"mymq"
    2)1)1)"1667142020901-0"
    2)1)"mykey1"
        2)"myvalue1"
    3)1)1)"1667142020921-0"
    2)1)"mykey2"
        2)"myvalue2"
    4)1)1)"1667142020931-0"
    2)1)"mykey3"
        2)"myvalue3"
    
  3. 让消费组中的 consumer2 从 mystream 中读取一条消息。
    > XREADGROUP GROUP mygroup consumer2 COUNT 1 STREAMS mystream >
    1)1)"mystream"
    2)1)1)"1667142020901-0"
        2)1)"mekey"
            2)"myvalue"
    
  4. 假设消息 1667142020901-0 被 consumer2 消费了,consumer2 就可以使用 XACK 命令通知 Stream,然后此条消息就会被删除。
    > XACK mystream mygroup 1667142020901-0
    (integer)1
    
  5. 查看消费组中各个消费者已读取,但尚未确认的消息个数。
    > XPENDING mystream mygroup
    1)(integer)3
    2)"1654254953808-0"  // 表示所有已读取,但尚未确认的消息中的最小 ID
    3)"1654256271337-0"  // 表示所有已读取,但尚未确认的消息中的最大 ID
    4)1)1)"consumer1"
        2)"1"
    2)1)"consumer2"
        2)"1"
    3)1)"consumer3"
        2)"1"
    

10、总结

Redis 3.0 与 Redis 7.0 数据结构与底层实现对应如图:

各数据结构的应用场景总结如下:

  • 字符串String:缓存业务的基本信息,视频播放量和弹幕计数,分布式共享Session信息,接口访问限制。
  • 哈希表Hash:记录用户信息(key为用户账号,field 和 value为用户信息),购物车中的商品记录
  • 列表List:消息队列模型,模拟栈、队列、有限集合。
  • 集合Set:点赞列表、好友人脉,统计网站的独立IP。
  • 有序集合Sorted Set:排行榜系统、点赞播放量最多的视频。
  • Bitmaps:在数据量大场景下用 bitmaps 可大幅压缩存储空间,还可用于实现”签到统计“功能。
  • HyperLogLog:利用极小的内存空间完成独立总数(IP,Email,ID)的统计,同时提供了合并多个 HyperLogLog 功能。
  • GEO:“附近的餐馆”、”打车“ 等 LBS 应用。
  • Stream:专门用做消息队列。

PS:前面 5 种是非常常见的,所以你得能很熟悉,后面 4 种估计不少人没用过,如果也能流畅说出来,那就更好了。

3、说一说 zset 底层数据结构?说一说时间复杂度?


zset 底层数据结构有两种:ziplist(压缩列表)字典 + skiplist(跳表)。但在 7.0 之后,ziplist 被替换成了 listpack。

一、首先说一说 ziplist:

ziplist 是 Redis 为了节约内存而开发的,它是一个非常特殊的 ”双向链表“,实际上占用一整块连续的内存。

ziplist 设计的巧妙之处在于:每个列表项(entry)都包含前一个列表项的长度(记为 pre_len)。再加上每个列表项都记录自己的长度(记为 encoding),就可以轻易的计算出前一个列表项和后一个列表项的起始位置,实现双向链表的前后指针功能。

每个列表项的结构如下图:

pre_len 虽然设计的非常巧妙,但也带来了一个问题:在往 ziplist 中插入或者更新时,有可能会出现 “连锁更新” ,这是一个影响效率的大问题。

正因如此,Redis 又设计出了 listpack,它的每个列表项中只记录自己的长度,不会再记录前一个列表项的长度。

这样一来,当 listpack 中插入或者更新时,只会涉及到每个列表项自己的操作,不会影响后续列表项长度的变化,也就不会出现 “连锁更新” 的问题了。

需要注意:只有在 zset 保存元素的数量小于 128,并且每个元素的长度小于 64 字节时(这两个参数可以人为改变)才会使用 ziplist。此时 ziplist 的长度小,节省内存的同时,读写效率还高。

但在元素数量大,或者元素长度长的情况下,ziplist 的读写效率会降低。此时 zset 底层数据结构就会转变成接下来要介绍的数据结构:dict(字典) + skiplist(跳表)。

二、接下来说说重头戏:dict + skiplist:

dict 是一种用于保存 key - value 键值对的抽象数据结构,由两张哈希表组成。一般情况下只用第一张哈希表,在需要对第一张哈希表进行 rehash 时才使用第二张哈希表。

在 Zset 中,dict 存储着元素(member)到分值(score)的映射。这样一来,就可以利用哈希表的特性,在 $O(1)$ 复杂度内返回某个元素的分值。

skiplist 是一种有序数据结构,它通过在每个节点维持多个指向其它节点的指针,达到快速访问节点的目的。

PS:如果不不了解跳跃表的概念,可能单独看容易蒙圈,那么先建议阅读下这篇文章:别再问我什么是跳跃表了

这个概念不好理解,看下图(为了简便,并没有画出每个节点保存的对象以及分值):

这是一个简易的跳表,每个节点中都存储着多个指向其它节点的指针,例如:$head$ 节点中存储了三个指针,分别指向 $node{-}1,node{-}2,node{-}3$ 。相比于链表,跳表可以从 $head$ 节点从 $A$ 直接 ”跳“ 到 $node{-}3$ ,省去了遍历 $node{-}1,node{-}2$ 的过程。

了解完概念之后,接下来看看 skiplist 一些重要的特性:

第一,skiplist 中每个节点含有指向其它节点的指针数(以 “层(level)” 来划分)是随机的。如图:

这点很重要,这让它在插入或删除时,不会像普通跳表一样,为了维持相邻两层中节点总数严格的 $2:1$ 关系,而重新调整其后的所有节点。

skiplist 每插入一个节点,它的 level 是随机分配的。这样在插入时,只需调整所插入节点前后的指针,不需要对很多节点进行调整,降低了插入删除操作的复杂度。

第二,skiplist 以 score 为依据进行排序,且 score 是可以重复的。

这就意味着,在 skiplist 中比较时,不能仅仅比较 score 值,还需比较数据本身。当 score 相同时,按照每个节点元素的大小进行排序。

第三,skiplist 和 dict 共享所存储的 member 及其 score 。

意思是 skiplist 和 dict 都只保存指向元素和分值的指针,并不会每个元素都复制两份,不会浪费内存空间。

第四,skiplist 中第一层链表(level 1)是一个双向链表。如图:

这是用于从表尾方向向表头迭代,当执行当执行 ZREVRANGEZREVRANGEBYSCORE 这类以逆序处理有序集的命令时,就会用到这个属性。

到这里,dict 和 skiplist 都单独介绍的差不多了,那为什么要在 skiplist 的第二种实现中同时使用它两个呢?

原因在于:通过使用 dict,将 member 本身作为 key,将 score 作为 value,zset 可以再 $O(1)$ 复杂度内:

  • 检查给定 member 是否存在于有序集
  • 取出 member 对应的 score 值(实现 ZSCORE ) 命令)

另一方面, 通过使用 skiplist, 可以让 zset 支持以下两种操作:

  • 在 $O(logN)$ 期望时间、$O(N)$ 最坏时间内根据 scoremember 进行定位
  • 高效的进行范围性查找,并返回按照 score 排序的结果

最后总结一下 zset 的时间复杂度:

  • 添加(ZADD)删除(ZREM)操作:$O(k \times log N)$, $k$ 为操作的 member 个数
  • 返回 member 的 score(ZSCORE):$O(1)$
  • 返回 member 总数(ZCARD):$O(1)$
  • 为某个 member 的 score 加上增量(ZINCRBY):$O(log N)$
  • 返回指定 member / score 区间内的成员(ZRANGE / ZRANGEBYSCORE):$O(log N)$

4、你觉得什么样的场景适合用跳表来存储呢?


跳表适合更新频繁,按区间查找高并发的场景。

首先,跳表在插入或删除一个节点时,例如下图插入两个 level 分别为 1 和 2 的节点:

可以看到,它只会调整那些直接和所插入节点相连的节点。这就意味着,在更新频繁的场景下,跳表的伸缩性好,效率也高。

同时,在需要按照区间查找数据时,因为跳表中存储的元素本来就是有序的,这种场景下跳表可以做到 $O(log N)$ 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效,效率甚至超过了红黑树。

例如下图查找分值在 [100, 365] 之间的数据:

在并发环境下,向跳表中插入或删除数据时,它需要更新的部分很少,自然需要上锁的东西也很少,那么不同线程争锁的代价就相对降低,性能也就提高了。

5、Redis 的哈希和 Java 有啥区别?可以说一说哈希动态扩容吗?为啥要渐进式扩容呢?


Redis 的哈希和 Java 的区别:

从底层数据结构上来看,Java 中 HashMap 的实现就一种,为数组 + 链表/红黑树。而 Redis 中 Hash 结构有 ziplist 和 hashtable 两种不同的实现方式,当 Hash 中元素个数少时为了节省内存会使用前者,其它情况下为了追求效率会使用后者。

从扩容方式上来看,Java 中的 HashMap 采用 “直接扩容” 的方式,即在需要扩容时直接分配一个新哈希表,并一次性将原数据转移至新哈希表中。而 Redis 采用了 “渐进式扩容”,将 “数据转移” 这个任务分配给其后的每一次操作中。

Redis 中哈希表的动态扩容操作:

首先来了解一下 Hash 类型中的 dict(字典)结构,Redis 7.0 中的 dict.h / dict 定义为:

struct dict {
    dictType * type;  // 类型特定函数

    dictEntry * * ht_table[2]; // 两张哈希表
    unsigned long ht_used[2];

    long rehashidx; /* rehashing not in progress if rehashidx == -1 */

    /* Keep small vars at end for optimal (minimal) struct padding */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
    signed char ht_size_exp[2]; /* exponent of size. (size = 1 < < exp) */
};
 

这里只需关注第 4 行,即 dict 中有两张哈希表来用作动态扩容。下面来说说具体的扩容过程:

首先,对 dict 中的第二个哈希表分配空间,size 为第一个大于等于【第一个哈希表当前包含的键值对数量 * 2】的 $2^n$。

然后,哈希表并不会立即将第一个哈希表中的键值对 rehash 至第二个哈希表中,而是将这些操作放在后续对字典执行增删改查的每次操作中

具体来说就是:在 rehash 进行期间(rehashidx 不为 -1 时),每次对字典执行添加、删除、更新或者查找操作时,程序除了执行指定的操作以外,还会顺带将第一个哈希表中的一个元素 rehash 到第二个哈希表中。

最后,随着字典操作的不断进行,最终会在某个时间点上完成对第一个哈希表到第二个哈希表的全部 rehash 操作,接着再释放第一个哈希表的空间。此时,哈希表扩容操作完成。

渐进式扩容的原因:

渐进式扩容的好处在于,它采取分而治之的方式,将 ”对键值对重哈希“ 所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash 而带来的庞大计算量。

这也是需要渐进式扩容的原因:当哈希表容量过大,且需要进行扩容操作时,庞大的 rehash 计算量可能会导致 Redis 线程阻塞,从而无法服务其它请求。

由于篇幅原因,如果你想看 rehash 的具体源码分析,给大家找来了一篇文章:哈希扩容:rehash 源码剖析,建议看哦。

二、缓存与持久化

1、什么样的数据适合被缓存?缓存加快了响应速度,那你能说一说带来的代价吗?


缓存的定义是,一种用于存储数据,以便「将来可以更快地处理对该数据的请求」的硬件或者软件。

由于它的存取速度非常快,但容量却很小,所以适合放在缓存中的数据也是有讲究的:那些频繁被访问的热点数据才适合放在缓存中,这样能大大提高缓存命中率,加快响应速度。

缓存是一把 “双刃剑”,在加快响应速度的同时,也会带来一些问题:

第一,缓存与数据库之间可能存在数据不一致问题。

比如,更新数据库成功了,但更新缓存却失败了,这样缓存中就会存在脏数据。当然,可以通过设置数据在缓存中的有效期,或者定期手动清理来解决。

第二,增加了代码维护成本

加上缓冲之后,必然会使业务逻辑代码更加复杂,这样在后续的迭代和维护过程中,会增加更多的工作量。

第三,在高并发场景中,可能还会出现缓存雪崩、缓存击穿和缓存穿透等问题

2、什么是缓存穿透,缓存雪崩?可以说一说他们的基本概念及解决方案吗?


缓存穿透是指要访问的数据既不在 Redis 缓存中,也不在数据库中。导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。

此时,应用也无法从数据库中读取数据再写入缓存,来服务后续请求。这样一来,缓存也就成了“摆设”,如果应用持续有大量请求访问数据,就会同时给缓存和数据库带来巨大压力,如下图所示:

缓存穿透的解决方案有三种:

第一种方案是,给数据缓存设空值或者缺省值。

一旦发生缓存穿透,我们就可以针对要查询的数据,在 Redis 中缓存一个空值或是和业务层协商确定的缺省值(不管是数据不存在,还是系统故障)。这样,应用发送的后续请求再进行查询时,就可以直接从 Redis 中读取空值或缺省值,返回给应用了,避免了把大量请求访问数据库,保持了数据库的正常运行。

不过这里需要注意的是,在缓存缺省值的时候,我们需要给他一个相对快的过期时间,比如一分钟过期。当然,缓存缺省值也会带来一些问题,比如

  1. 空值做了缓存,意味着缓存中存了更多的键,需要更多的内存空间,比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

  2. 缓存和存储的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如:过期时间设置为 5分钟,如果此时存储添加了这个数据,那此段时间就会出现缓存和存储数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空值。

第二种方案是,使用布隆过滤器快速判断数据是否存在,从而避免在数据库中查询数据是否存在,减轻数据库压力。

简单来说就是,将所有可能存在的数据哈希到一个足够大的布隆过滤器中,一个一定不存在的数据会被这个 布隆过滤器 拦截掉,从而避免了对底层存储系统的查询压力

布隆过滤器是由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,可以十分快速地判断某个数据是否存在,至于 布隆过滤器 的具体原理,大家感兴趣的可以看这篇文章:漫画:什么是布隆算法?

基于布隆过滤器的快速检测特性,我们可以在把数据写入数据库时,使用布隆过滤器做个标记。当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。

如果不存在,就不用再去数据库中查询了。这样一来,即使发生缓存穿透了,大量请求只会查询 Redis 和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。如下图所示:

需要注意,布隆过滤器可以使用 Redis 实现,本身就能承担较大的并发访问压力。

第三种方案是,在请求入口的前端进行请求检测。

缓存穿透的一个原因就是,有大量的恶意请求访问不存在的数据。所以,一个有效的应对方案是在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。这样一来,也就不会出现缓存穿透问题了。

说完了缓存击穿,下面说一下缓存雪崩:

缓存雪崩是指大量的应用请求无法在 Redis 缓存中进行处理,紧接着,应用将大量请求发送到数据库层,导致数据库层的压力激增。

缓存雪崩一般是由两个原因导致的,应对方案也有所不同。

第一个原因是:缓存中有大量数据同时过期,导致大量请求无法得到处理。

具体来说,当数据保存在缓存中,并且也设置了过期时间,如果在某一个时刻,大量数据同时过期。此时,应用再访问这些数据的话,就会发生缓存缺失。

之后,应用就会把请求发送给数据库,从数据库中读取数据。如果应用的并发请求量很大,那么数据库的压力也就很大,这会进一步影响到数据库的其他正常业务请求处理。如下图所示:

这种情况有两种解决方案:

第一种解决方案是,避免给大量的数据设置相同的过期时间。

具体来说,假如的确要求有些数据同时失效,可以在用 EXPIRE 命令给每个数据设置过期时间时,给这些数据的过期时间增加一个较小的随机数(例如,随机增加 1~3 分钟)。

这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。

第二种解决方案是,采用服务降级。

所谓的服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式。

  • 当业务应用访问的是非核心数据(例如电商「商品属性」)时,暂时停止从缓存中查询这些数据,进而也就不能访问数据库了,而是直接返回预定义信息、空值或是错误信息;

  • 当业务应用访问的是核心数据(例如电商「商品库存」)时,仍然允许查询缓存,如果缓存缺失,也可以继续通过数据库读取。

这样一来,只有部分过期数据的请求会发送到数据库,数据库的压力就没有那么大了。下面这张图显示的是服务降级时数据请求的执行情况:

除了大量数据同时失效会导致缓存雪崩,另外一个原因是:Redis 缓存实例发生故障宕机了,无法处理请求,这就会导致大量请求一下子积压到数据库层,从而发生缓存雪崩。

一般来说,一个 Redis 实例可以支持数万级别的请求处理吞吐量,而单个数据库可能只能支持数千级别的请求处理吞吐量,它们两个的处理能力可能相差了近十倍。

由于缓存雪崩,Redis 缓存失效,所以,数据库就可能要承受近十倍的请求压力,从而因为压力过大而崩溃。

此时,因为 Redis 实例发生了宕机,我们需要通过其他方法来应对缓存雪崩了。下面有两种解决方案:

第一种解决方案是,在业务系统中实现服务熔断或请求限流机制。

所谓的服务熔断,是指在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问。

再具体点说,就是业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回,等到 Redis 缓存实例重新恢复服务后,再允许应用请求发送到缓存系统。

这样一来,就避免了大量请求因缓存缺失,而积压到数据库系统,保证了数据库系统的正常运行。如下图所示:

服务熔断虽然可以保证数据库的正常运行,但是暂停了整个缓存系统的访问,对业务应用的影响范围大。

为了尽可能减少这种影响,我们也可以进行请求限流。这里说的请求限流就是指,我们可以在业务系统的请求入口前端控制每秒进入系统的请求数,避免过多的请求被发送到数据库。

例如,发生缓存雪崩时,数据库的每秒请求数突然增加到每秒 1 万个。此时,我们就可以启动「请求限流机制」,在请求入口前端只允许每秒进入系统的请求数为 1000 个,再多的请求就会在入口前端被直接拒绝服务,避免大量并发请求将压力传递到数据库层。如下图所示:

第二种解决方案是,采用事前预防。

使用「服务熔断」或是「请求限流机制」,来应对 Redis 实例宕机导致的缓存雪崩问题,都属于 “事后诸葛亮”,也就是已经发生缓存雪崩了,我们使用这两个机制,来降低雪崩对数据库和整个业务系统的影响。

我们也可以事先预防这种情况的发生,具体来说,通过主从节点的方式构建 Redis 缓存高可靠集群。如果 Redis 缓存的主节点故障宕机了,从节点还可以切换成为主节点,继续提供缓存服务。避免了由于缓存实例宕机而导致的缓存雪崩问题。

到这里,缓存穿透和缓存雪崩就说完了,下面对「缓存雪崩」和「缓存穿透」的发生原因和解决方案总结一下:


3、缓存一致性问题有哪些解决方案?你在项目遇到过类似场景吗?你是怎么处理的?


其实只要我们使用 Redis 缓存,就必然会面对缓存和数据库间的一致性保证问题。如果数据不一致,就会发生业务应用从缓存中读取的数据就不是最新数据,或者缓存中的数据还没来得及保存至数据库就丢失,这都会导致严重的错误。

当缓存的读写模式不同时,缓存不一致发生的情况也不同,相应的解决方案也不同。

首先,对于读写缓存来说,对数据的操作都在缓存中进行,然后根据采取的写回策略,决定是否同步写回到数据库。

如果采用「异步写回策略」,即写缓存时不同步写数据库,而是等到数据在缓存中淘汰时,再写回数据库,那么,如果数据还没有写回数据库,缓存就发生了故障,数据库就没有最新的数据了,也就发生了缓存不一致。

解决的方法就是采用「同步回写策略」,即写缓存时,也同步写数据库,保证缓存和数据库中的数据一致。但是如果采用这种策略,就需要同时更新缓存和数据库。

所以,我们要在业务应用中使用事务机制,来保证缓存和数据库的更新具有原子性,也就是说,两者要不一起更新,要不都不更新,返回错误信息,进行重试。否则,我们就无法实现同步直写。

其次,对于只读缓存来说,新增数据的操作会直接写入数据库,当数据需要删改时,只需在只读缓存中将其标记为无效。

根据这两个操作的前后顺序,以及是否有并发请求,可以分为以下四种情况讨论:

先删除缓存值,再更新数据库,且此时没有并发请求:如果删除缓存成功,但更新数据库失败,此时缓存中的是最新值,数据库中的是旧值。后续请求会直接命中缓存,得到的是最新值,短期之内对业务影响不大。

但是,一旦缓存过期或者满容后被淘汰,读请求就会从数据库中重新加载旧值到缓存中,之后的读请求会从缓存中得到旧值,对业务产生影响。

先更新数据库,再删除缓存值,且此时没有并发请求:如果更新数据库成功,但删除缓存失败,此时数据库中是最新值,但缓存中是旧值。后续的读请求会直接命中缓存,得到的是旧值。

解决的方法就是使用「重试机制」:把要删除的缓存值或是要更新的数据库值暂存到消息队列中。如果应用没有成功删除缓存值或者更新数据库值时,可以从消息队列中重新读取并再次尝试;如果应用能成功删除或更新,这些值将会从消息队列中去除,避免重复操作。

当以上两种情况中的任何一个操作可能失败时,可以使用「重试机制」:将第二步操作放入消息队列中,如果应用没有成功删除缓存值或者更新数据库值时,可以从消息队列中重新读取并再次尝试;如果应用能成功删除或更新,这些值将会从消息队列中去除,避免重复操作。

先删除缓存值,再更新数据库,且此时有并发请求:假设线程 A 删除缓存值后,还没有来得及更新数据库(比如说有网络延迟),线程 B 就开始读取数据了,那么这个时候,线程 B 会发现缓存缺失,就只能去数据库读取。这会带来两个问题:

  • 线程 B 读取到了旧值。

  • 线程 B 是在「缓存缺失」的情况下读取的数据库,所以,它还会把旧值写入缓存,这可能会导致其他线程从缓存中读到旧值。

等到线程 B 从数据库读取完数据、更新了缓存后,线程 A 才开始更新数据库。此时,缓存中的数据是旧值,而数据库中的是最新值,两者就不一致了。如下图所示:

这种情况的解决方案是,使用「延迟双删」,即在线程 A 更新完数据库值以后,我们可以让它先 sleep 一小段时间,再进行一次缓存删除操作。

之所以要加上 sleep 的这段时间,就是为了让线程 B 能够先从数据库读取数据,把缺失的数据写入缓存,然后线程 A 再进行删除。所以,线程 A sleep 的时间,就需要大于线程 B 读取数据再写入缓存的时间。伪代码如下:

redis.delKey(X);
db.update(X);
Thread.sleep(N);
redis.delKey(X);

先更新数据库,再删除缓存值,且此时有并发请求:如果线程 A 删除了数据库中的值,但还没来得及删除缓存值,线程 B 就开始读取数据了,那么此时,线程 B 查询缓存时,发现缓存命中,就会直接从缓存中读取旧值。

不过,在这种情况下,如果其他线程并发读缓存的请求不多,那么,就不会有很多请求读取到旧值。而且,线程 A 一般也会很快删除缓存值,这样一来,其他线程再次读取时,就会发生缓存缺失,进而从数据库中读取最新值。所以,这种情况对业务的影响较小。

小结一下,针对缓存一致性问题,我们可以分成读写缓存和只读缓存两种情况进行分析。

对于读写缓存来说,如果我们采用同步写回策略,那么就可以保证缓存和数据库中的数据一致。

只读缓存的情况比较复杂,总结成一张表如下图所示:


4、说一说布隆过滤器,布隆过滤器的原理是什么?它的优点是什么?缺陷是什么?


布隆过滤器是是一种用来快速判断某个元素是否存在的数据结构。

它的原理是,当一个元素被加入集合时,通过 K 个哈希函数将这个元素映射成一个 bit 数组中的 K 个点,把它们置为 1。检索时,我们只要看看这些点是不是都是 1 就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。

具体来说,布隆过滤器由一个初值都为 0 的 bit 数组和 N 个哈希函数组成,当我们想标记某个数据存在时(例如,此时数据已被写入数据库),布隆过滤器会通过三个操作完成标记:

  • 首先,使用 N 个哈希函数,分别计算这个数据,得到 N 个哈希值。

  • 然后,我们把这 N 个哈希值分别对 bit 数组的长度取模,得到每个哈希值在数组中的对应位置。

  • 最后,我们在 bit 数组中把这些对应位置的 bit 位都设置为 1,这就完成了在布隆过滤器中标记数据的操作。

如果数据不存在(例如,此时数据库里没有写入数据),我们也就不用布隆过滤器标记数据,那么,bit 数组对应 bit 位的值仍然为 0。

当需要查询某个数据时,我们就执行刚刚所说的计算过程:先得到这个数据在 bit 数组中对应的 N 个位置。紧接着,我们依次查看 bit 数组中这 N 个位置上的 bit 值。只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,那就意味着,查询的数据一定没有在数据库中保存。

为了便于理解,看下图:

图中的布隆过滤器是一个包含 10 个 bit 位的数组,使用了 3 个哈希函数。当在布隆过滤器中标记数据 X 时,X 会被计算 3 次哈希值,并对 10 取模,取模结果分别是 1、3、7。所以,bit 数组的第 1、3、7 位被设置为 1。当应用想要查询 X 时,只要依次查看数组的第 1、3、7 位是否为 1,只要有一个不为 1,X 就肯定不在数据库中。

它的优点是,相比于传统的 List、Set、Map 等数据结构,布隆过滤器更高效,占用空间更小,在时间和空间方面都有巨大的优势:插入,查询复杂度是 $O(k)$,$k$ 为哈希函数的个数;空间复杂度为 $O(m)$,$m$ 为布隆过滤器的 bit 数组长度。

它的缺点也同样明显:不能删除某个元素是其一,其返回的结果也不是确切的,具有一定误算率,且随着存入元素数量的增加,误算率也随之增加。这是因为,有可能存在两个数据使用多个哈希函数计算出来的哈希值都相同,导致它们在 bit 数组中的标记也相同,在查询数据时就有可能会出错。

5、Redis 持久化的意义是什么?Redis怎么实现持久化?持久化能保证不丢失数据吗?


Redis 的一大应用场景就是用做缓存,这里有一个不可忽略的问题:一旦服务器宕机,内存中的数据就会全部丢失。此时若再从后端数据库中恢复,那性能就太低了,同时也会给数据库带来巨大压力。

这时就凸显出 Redis 持久化的意义了:用作故障恢复,实现数据的持久化,避免从后端数据库中恢复。

Redis 的持久化主要有两大机制,即 AOF 日志和 RDB 快照。Redis 4.0 以后推出混合模式。

AOF 日志是怎么避免 Redis 宕机后的数据丢失?

首先 AOF 日志属于 “写后日志”,即 Redis 先执行命令,把数据写入内存,然后才记录日志。如图所示:

记录的日志需要在合适的时机写入磁盘,因此,Redis 提供了三种 AOF 日志写回磁盘的策略:

  • Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
  • Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
  • No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

针对避免主线程阻塞和减少数据丢失问题,这三种写回策略都无法做到两全其美:

  • “同步写回” 可以做到基本不丢数据,但是它在每一个写命令后都有一个慢速的落盘操作,不可避免地会影响主线程性能;
  • 虽然 “操作系统控制的写回” 在写完缓冲区后,就可以继续执行后续的命令,但是落盘的时机已经不在 Redis 手中了,只要 AOF 记录没有写回磁盘,一旦宕机对应的数据就丢失了;
  • “每秒写回” 采用一秒写回一次的频率,避免了 “同步写回” 的性能开销,虽然减少了对系统性能的影响,但是如果发生宕机,上一秒内未落盘的命令操作仍然会丢失。所以,这只能算是,在避免影响主线程性能和避免数据丢失两者间取了个折中。

由以上分析,我们可以根据系统对高性能和高可靠性的要求,来选择使用哪种写回策略。

总结一下就是:想要获得高性能,就选择 No 策略;如果想要得到高可靠性保证,就选择 Always 策略;如果允许数据有一点丢失,又希望性能别受太大影响的话,那么就选择 Everysec 策略。

同时 Redis 为了避免 AOF 文件过大带来的性能问题,采用了 「AOF 重写机制」。简单来说,AOF 重写机制就是在重写时,Redis 创建一个新的 AOF 文件,读取当前数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。

和 AOF 日志由主线程写回不同,重写过程是由后台线程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

正因为 AOF 方法记录的是操作命令,而不是实际的数据,所以,使用 AOF 做数据恢复时,需要逐一把操作日志都执行一遍。如果操作日志非常多,Redis 就会恢复得很缓慢,影响到正常使用。

这当然不是理想的结果。那么,还有没有既可以保证可靠性,还能在宕机时实现快速恢复的其他方法呢?

有,就是接下来要说的 RDB 持久化。

RDB 全量快照怎么实现 Redis 宕机后数据的快速恢复?

不同于 AOF 方法记录的是操作日志,RDB 记录的是 ”内存快照“,即内存中的数据在某一个时刻的状态记录。在做数据恢复时,我们可以直接把 RDB 文件读入内存,很快地完成恢复。

因为 Redis 的数据都在内存中,为了提供所有数据的可靠性保证,RDB 方法执行的是全量快照,也就是说,把内存中的所有数据都记录到磁盘中。

但是,给内存的全量数据做快照,并把它们全部写入磁盘会花费很多时间。而且,全量数据越多,RDB 文件就越大,往磁盘上写数据的时间开销就越大。

同时由于 Redis 是单线程模型,如果在主线程中执行,那么势必会影响 Redis 的性能。所以 Redis 提供了 bgsave 命令,专门创建一个子进程,用于写入 RDB 文件,避免了主线程的阻塞。

RDB 中的写时复制技术

需要注意的是,Redis 在执行 bgsave 命令时会借助操作系统提供的「写时复制技术」(Copy-On-Write)。简单来说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。

此时,如果主线程对这些数据也都是读操作(例如图中的键值对 A),那么,主线程和 bgsave 子进程相互不影响。但是,如果主线程要修改一块数据(例如图中的键值对 C),那么,这块数据才会被单独复制一份,生成该数据的副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件,而在这个过程中,主线程仍然可以直接修改原来的数据。

这既保证了快照的完整性,也允许主线程同时对数据进行修改,避免了对正常业务的影响。

该多久做一次全量快照?

直观上来说,我们想要做快照的间隔时间尽可能小,这样才能保证宕机时丢失的数据最少,但是如果频繁地执行全量快照,也会带来两方面的开销

一方面,频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。

另一方面,bgsave 子进程需要通过 fork 操作从主线程创建出来。虽然,子进程在创建后不会再阻塞主线程,但是,fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长。如果频繁 fork 出 bgsave 子进程,这就会频繁阻塞主线程了。那么,有什么其他好方法吗?

此时,我们可以做增量快照,所谓增量快照,就是指,做了一次全量快照后,后续的快照只对修改的数据进行快照记录,这样可以避免每次全量快照的开销。

不过在做增量快照时,我们需要记住「哪些数据被修改了」,它需要我们使用额外的元数据信息去记录哪些数据被修改了,这会带来额外的空间开销问题。如图所示:

如果我们对每一个键值对的修改,都做个记录,那么,如果要修改的键值对很多,需要记录的元数据信息也就很多,因此引入的额外空间开销也会比较大。

到这里,RDB 虽然跟 AOF 相比,快照的恢复速度快,但是,快照的频率不好把握,如果频率太低,两次快照间一旦宕机,就可能有比较多的数据丢失。如果频率太高,又会产生额外开销,那么,还有什么方法既能利用 RDB 的快速恢复,又能以较小的开销做到尽量少丢数据呢?

Redis 4.0 中的混合模式

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

这样一来,快照不用很频繁地执行,这就避免了频繁 fork 对主线程的影响。而且,AOF 日志也只用记录两次快照间的操作,也就是说,不需要记录所有操作了,因此,就不会出现文件过大的情况了,也可以避免日志重写的开销。

具体如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,等到第二次做全量快照时,就可以清空 AOF 日志,因为此时的修改都已经记录到快照中了,恢复时就不再用日志了。

这个方法既能享受到 RDB 文件快速恢复的好处,又能享受到 AOF 只记录操作命令的简单优势,有 “鱼和熊掌可以兼得” 的感觉。

持久化能保证不丢失数据吗?

根据上面的分析可以得出:

  • 如果使用 AOF 日志的 Always 方法,可以做到基本不丢失数据,若使用其它两个方法,宕机时,都会或多或少丢失些数据;

  • 如果使用 RDB 快照,可能会在两次 bgsave 期间发生宕机,则会丢失分钟级的区间增量数据,无法做到实时持久化。

6、AOF 和 RDB 对比一下?Redis 的默认持久化方式什么?

首先,AOF 是以文本的形式记录所有修改数据库的写命令,每执行一次命令都记录一次,为了避免 AOF 文件过大,需要不断的对 AOF 日志进行重写;而 RDB 是以二进制的形式记录某一时刻 Redis 中的键值对数据,每隔一段时间,生成一份当前数据的快照。

其次,记录 AOF 日志需要在主线程中执行,而 RDB 可以 fork 出一个子进程完成持久化工作,此时主线程仍可以执行其它命令请求,使性能最大化。

再次,在对很多数据做恢复时,AOF 需要遍历执行 AOF 文件中记录的每个操作,导致数据的恢复效率要慢于 RDB。

最后,从保证数据安全性的角度上来看,AOF 的 Always 策略可以基本保证数据的不丢失,而 RDB 一旦在持久化之前出现宕机现象,此前没有来得及写入磁盘的数据都将丢失。

Redis 的默认持久化方式是 RDB,可以在 redis.conf 文件中进行更改。

7、说一说 Redis 的过期策略?


Redis 缓存使用内存来保存数据,避免了业务应用从后端数据库中读取数据,可以提升应用的响应速度。但是,由于内存容量的限制,我们不可能把所有要访问的数据都放入缓存。

而且,缓存被被写满是不可避免的。即使经过精挑细选,确定了缓存容量,还是要面对缓存写满时的替换操作。缓存替换需要解决两个问题:决定淘汰哪些数据,如何处理那些被淘汰的数据。

八种数据淘汰策略

Redis 的过期策略,简单来说,主要包括两步:第一,根据一定的策略,筛选出对应用访问来说 “不重要” 的数据;第二,将这些数据从缓存中删除,为新来的数据腾出空间。下面来说说具体的数据淘汰策略:

Redis 4.0 之前一共实现了 6 种内存淘汰策略,在 4.0 之后,又增加了 2 种策略,一共有 8 种。我们可以按照是否会进行数据淘汰把它们分成两类:

  • 不进行数据淘汰的策略,只有 noeviction 这一种。

  • 会进行淘汰的 7 种其他策略。

会进行淘汰的 7 种策略,我们可以再进一步根据淘汰候选数据集的范围把它们分成两类:

在设置了过期时间的数据中进行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 后新增)四种。

在所有数据范围内进行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 后新增)三种。这八种分类画在一张图如下所示:

下面来解释具体的策略:

noeviction 策略是 Redis 默认状态下设置的策略,即 Redis 在使用的内存空间超过 maxmemory 值时,不会淘汰数据。也就是说,一旦缓存被写满了,再有写请求来时,Redis 不再提供服务,而是直接返回错误。这对 Redis 作为缓存时显然不合适。

volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 这四种淘汰策略。它们筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。也正因为此,即使缓存没有写满,这些数据如果过期了,也会被删除。

  • volatile-ttl 在筛选时,会针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除。

  • volatile-random 就像它的名称一样,在设置了过期时间的键值对中,进行随机删除。

  • volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对。

  • volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对。

可以看到,volatile-ttl 和 volatile-random 筛选规则比较简单,volatile-lru 和 volatile-lfu 分别涉及到了 LRU 算法 和 LFU 算法,这两种算法后面单独介绍。

allkeys-lru、allkeys-random、allkeys-lfu 这三种淘汰策略的备选淘汰数据范围,就扩大到了所有键值对,无论这些键值对是否设置了过期时间。它们筛选数据进行淘汰的规则是:

  • allkeys-random 策略,从所有键值对中随机选择并删除数据;

  • allkeys-lru 策略,使用 LRU 算法在所有数据中进行筛选。

  • allkeys-lfu 策略,使用 LFU 算法在所有数据中进行筛选。

这也就是说,如果一个键值对被删除策略选中了,即使它的过期时间还没到,也需要被删除。当然,如果它的过期时间到了但未被策略选中,同样也会被删除。

接下来,重点说一说刚提到的 LRU 和 LFU 算法。

LRU 算法

LRU 算法的全称是 Least Recently Used,从名字上就可以看出,这是按照最近最少使用的原则来筛选数据,最不常用的数据会被筛选出来,而最近频繁使用的数据会留在缓存中。

具体来说,LRU 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据。举个例子,如下图所示:

可以看到,如果这个链表中的某个数据被访问,它就会从现有的链表位置移到 MRU 端,而原本在它之前的数据则相应地往后移一位。

当有一个新数据需要被写入时,例如上面要写入数据 6,但此时链表已经没有空间了,那么,LRU 算法会做两件事:

  • 数据 6 是刚被访问的,所以它会被放到 MRU 端;

  • 算法把 LRU 端的数据 3 从缓存中删除,相应的链表中就没有数据 3 的记录了。

其实,LRU 算法背后的想法非常朴素:它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU 端;长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到 LRU 端,在缓存满时,就优先删除它。

不过,LRU 算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销。而且,当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

所以,在 Redis 中,LRU 算法被做了简化,来减轻数据淘汰对缓存性能的影响。具体来说,Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。然后,Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。

Redis 提供了一个配置参数 maxmemory-samples,这个参数就是 Redis 选出的数据个数 N。例如,我们执行如下命令,可以让 Redis 选出 100 个数据作为候选数据集:CONFIG SET maxmemory-samples 100

当需要再次淘汰数据时,Redis 以第一次淘汰时创建的候选数据集中最小的 lru 值为基准,挑选 lru 字段值小于这个最小值的数据并放入到集合中,当候选数据集中的数据个数再次达到 maxmemory-samples 时,Redis 就把候选集合中 lru 字段值最小的数据淘汰出去。

这样一来,Redis 缓存不用为所有的数据维护一个大链表,也不用在每次数据访问时都移动链表项,提升了缓存的性能。

LFU 算法

到这里 LRU 算法就说的差不多了,下面说说 LFU 算法:

LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用 LFU 策略筛选淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

Redis 在实现 LFU 策略的时,把原来 LRU 算法中提到的 24bit 大小的 lru 字段,又进一步拆分成了两部分。

  • ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;

  • counter 值:lru 字段的后 8bit,表示数据的访问次数。

具体来说,当 LFU 策略筛选数据时,Redis 会在候选数据集中,根据数据 lru 字段的后 8bit 选择访问次数最少的数据进行淘汰。当访问次数相同时,再根据 lru 字段的前 16bit 值大小,选择访问时间最久远的数据进行淘汰。

这里有一个问题:Redis 只使用了 8bit 记录数据的访问次数,而 8bit 记录的最大值是 255,一个数据有可能被访问成千上万次,255 是肯定不够的,怎么办呢?

Redis 在这里并没有采用,数据每被访问一次,就给对应的 counter 值加 1 的计数规则,而是采用了一个更优化的计数规则。

简单来说,就是每当数据被访问一次,通过一些算法决定是否要将计数器加 1。从整体上来看,就是把 $[0, +\infty]$ 中的数,映射到 $[0, 255]$ 中。

这当中有两个细节,其一,计数器的初始值默认是 5(可以设置),而不是 0 ,这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰;其二,我们可以通过 lfu_log_factor 配置项,来控制计数器增加的速度,避免 counter 值很快就到 255 了。

为了更进一步说明 LFU 策略计数器递增的效果,你可以看下下面这张表。这是 Redis 官网上提供的一张表,它记录了当 lfu_log_factor 取不同值时,在不同的实际访问次数情况下,计数器的值是如何变化的。

可以看到,当 lfu_log_factor 取值为 1 时,实际访问次数为 100K 后,counter 值就达到 255 了,无法再区分实际访问次数更多的数据了。而当 lfu_log_factor 取值为 100 时,当实际访问次数为 10M 时,counter 值才达到 255,此时,实际访问次数小于 10M 的不同数据都可以通过 counter 值区分出来。

正是因为使用了非线性递增的计数器方法,即使缓存数据的访问次数成千上万,LFU 策略也可以有效地区分不同的访问次数,从而进行合理的数据筛选。从上边的表中,我们可以看到,当 lfu_log_factor 取值为 10 时,百、千、十万级别的访问次数对应的 counter 值已经有明显的区分了。

在一些场景下,有些数据在短时间内被大量访问后就不会再被访问了。那么再按照访问次数来筛选的话,这些数据会被留存在缓存中,但不会提升缓存命中率。为此,Redis 在实现 LFU 策略时,还设计了一个 counter 值的衰减机制。

简单来说,LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。LFU 策略会计算当前时间和数据最近一次访问时间的差值,并把这个差值换算成以分钟为单位。然后,LFU 策略再把这个差值除以 lfu_decay_time 值,所得的结果就是数据 counter 要衰减的值。

举个例子,假设 lfu_decay_time 取值为 1,如果数据在 N 分钟内没有被访问,那么它的访问次数就要减 N。如果 lfu_decay_time 取值更大,那么相应的衰减值会变小,衰减效果也会减弱。

通过设置合适的 lfu_decay_time 的值,可以使 LFU 策略在某些数据不再被访问后,会较快地衰减它们的访问次数,尽早把它们从缓存中淘汰出去,避免缓存污染。

8、要不你手写一个 LRU ?


题目描述

设计并实现最近最少经用(LRU)缓存的数据结构。它应该支持以下操作:get 和 put。

get(key) ––– 如果键存在于缓存中,则获取键的值(总是正数),否则返回 -1。

put(key, value) ––– 如果键不存在,请设置或插入值。当缓存达到其容量时,它应该在插入新项目之前, 使最近最少使用的项目无效。

进阶:

你是否可以在 O(1) 时间复杂度内执行两项操作?

示例:

LFUCache cache = new LFUCache( 2 /* capacity (缓存容量) */ );

cache.put(1, 1);

cache.put(2, 2);

cache.get(1);       // 返回 1

cache.put(3, 3);    // 去除 key 2

cache.get(2);       // 返回 -1 (未找到key 2)

cache.get(3);       // 返回 3

cache.put(4, 4);    // 去除 key 1

cache.get(1);       // 返回 -1 (未找到 key 1)

cache.get(3);       // 返回 3

cache.get(4);       // 返回 4

基础版:使用单链表来解决

我们需要删除「最近最少使用」的节点,一种比较容易想到的方法就是使用单链表这种数据结构来存储了。当我们进行 put 操作的时候,会出现以下几种情况:

  • 如果要 put(key, value) 已经存在于链表之中了(根据key来判断),那么我们需要把链表中久的数据删除,然后把新的数据插入到链表的头部。

  • 如果要 put(key, value) 的数据没有存在于链表之中,我们需要判断下缓存区是否已满,如果满的话,则把链表尾部的节点删除,之后把新的数据插入到链表头部。如果没有满的话,直接把数据插入链表头部即可。

对于 get 操作,则会出现以下情况

  • 如果要 get(key) 的数据存在于链表中,则把 value 返回,并且把该节点删除,删除之后把它插入到链表的头部。

  • 如果要 get(key) 的数据不存在于链表之后,则直接返回 -1 即可。

大概的思路就是这样,不要觉得很简单,让你手写的话,十分钟你不一定手写的出来。

具体代码为:

// 定义链表节点
class LRUNode{
    String key;
    Object value;
    LRUNode next;

    public LRUNode(String key, Object value) {
        this.key = key;
        this.value = value;
    }
}

public class LRUCache {
    LRUNode head;
    int size = 0;// 当前大小
    int capacity = 0; // 最大容量

    public LRUCache(int capacity) {
        this.capacity = capacity;
    }

    // 如果键存在于缓存中,则获取键的值(总是正数),否则返回 -1。   
    public Object get(String key) {  
        LRUNode cur = head;
        LRUNode pre = head;  // 指向要删除节点的前驱

        if(head == null) {  // 先考虑特殊情况, 链表为空
          return null;
        }

        if(cur.key.equals(key)) {  // 第一个节点比较特殊,先进行判断
          return cur.value;
        }

        // 开始从第二个节点开始进行查找
        cur = cur.next;
        while (cur != null) {
            if (cur.key.equals(key)) {
                break;
            }
            pre = cur;
            cur = cur.next;
        }

        // 代表没找到节点
        if (cur == null) {
          return null;
        }

        // 进行删除
        pre.next = cur.next;
        // 删除之后插入头结点
        cur.next = head;
        head = cur;
        return cur.value;
    }

    // 如果键不存在,请设置或插入值。当缓存达到其容量时,它应该在插入新项目之前,使最近最少使用的项目无效。 
    public void put(String key, Object value) {
        if (capacity == 1) {  // 如果最大容量是 1,那就没办法了,,,,,
            head = new LRUNode(key, value);
        }

        LRUNode cur = head;
        LRUNode pre = head;

        if (head == null) {  // 先查看链表是否为空
            head = new LRUNode(key, value);
            return;
        }

        // 先查看该节点是否存在
        if (head.key.equals(key)) {  // 第一个节点比较特殊,先进行判断
            head.value = value;
            return;
        }
        // 从第二个节点开始查找
        cur = cur.next;
        while (cur != null) {
            if (cur.key.equals(key)) {
                break;
            }
            pre = cur;
            cur = cur.next;
        }

        if (cur != null) {  // 代表要插入的节点的 key 已存在,则进行 value 的更新,以及把它放到第一个节点去
            cur.value = value;
            pre.next = cur.next;
            cur.next = head;
            head = cur;
        } else {  // 代表要插入的节点的 key 不存在
            LRUNode tmp = new LRUNode(key, value);  // 先创建一个节点
            if (size >= capacity) {  // 该节点原本不存在于链表,需要判断插入后会不会溢出
                // 直接把最后一个节点移除
                cur = head;
                while (cur.next != null && cur.next.next != null) {  
                    cur = cur.next;  // 使 cur 指向倒数第二个节点
                }
                cur.next = null;
                tmp.next = head;
                head = tmp;
            }
        }
    }
}

时间、空间复杂度分析

对于这种方法,put 和 get 都需要遍历链表查找数据是否存在,所以时间复杂度为 $O(n)$。空间复杂度为 $O(1)$。

空间换时间

在实际的应用中,当我们要去读取一个数据的时候,会先判断该数据是否存在于缓存器中,如果存在,则返回,如果不存在,则去别的地方查找该数据(例如磁盘),找到后在把该数据存放于缓存器中,在返回。

所以在实际的应用中,put 操作一般伴随着 get 操作。也就是说,get 操作的次数是比较多的,而且命中率也是相对比较高的,进而 put 操作的次数是比较少的,所以我们是可以考虑采用空间换时间的方式,来加快我们的 get 的操作。

例如我们可以用一个额外哈希表(例如HashMap)来存放 key-value,这样的话,我们的 get 操作就可以在 O(1) 的时间内寻找到目标节点,并且把 value 返回了。

然而,大家想一下,用了哈希表之后,get 操作真的能够在 O(1) 时间内完成吗?

用了哈希表之后,虽然我们能够在 O(1) 时间内找到目标元素,可以,我们还需要删除该元素,并且把该元素插入到链表头部啊,删除一个元素,我们是需要定位到这个元素的前驱的,但是这个操作是需要 O(n) 时间复杂度的。

最后的结果是,用了哈希表时候,最坏时间复杂度还是 $O(1)$,而空间复杂度也变为了 $O(n)$。

双向链表+哈希表

我们都已经能够在 $O(1)$ 时间复杂度找到要删除的节点了,之所以还得花 $O(n)$ 时间复杂度才能删除,主要是时间是花在了节点前驱的查找上,为了解决这个问题,其实,我们可以把单链表换成双链表,这样的话,我们就可以很好着解决这个问题了,而且,换成双链表之后,你会发现,它要比单链表的操作简单多了。

所以我们最后的方案是:双链表 + 哈希表,采用这两种数据结构的组合,我们的 get 操作就可以在 O(1) 时间复杂度内完成了。由于 put 操作我们要删除的节点一般是尾部节点,所以我们可以用一个变量 tai 时刻记录尾部节点的位置,这样的话,我们的 put 操作也可以在 O(1) 时间内完成了。

具体代码如下:

// 链表节点的定义
class LRUNode{
    String key;
    Object value; 
    LRUNode pre;
    LRUNode next;

    public LRUNode(String key, Object value) {
        this.key = key;
        this.value = value;
    }
}

// LRU 
public class LRUCache {
    Map map = new HashMap<>();
    LRUNode head;
    LRUNode tail;

    int capacity;  // 缓存最大容量,我们假设最大容量大于 1,当然,小于等于 1 的话需要多加一些判断另行处理

    public LRUCache(int capacity) {
        this.capacity = capacity;
    }

    // 如果键存在于缓存中,则获取键的值(总是正数),否则返回 -1。  
    public Object get(String key) {
        LRUNode node = map.get(key);  // 从哈希表中以O(1)获取value
        if (node != null) {  // 键存在于缓存中
            removeAndInsert(node);  // 把这个节点删除并插入到头结点
            return node.value;
        }
        return null;
    }

    // 如果键不存在,请设置或插入值。当缓存达到其容量时,它应该在插入新项目之前,使最近最少使用的项目无效。
    public void put(String key, Object value) {
        if (head == null) {  // 链表为空
            head = new LRUNode(key, value);
            tail = head;
            map.put(key, head);
        }
        LRUNode node = map.get(key);  // 先确认要插入的键是否已经存在
        if (node != null) {
            // 存在,那就更新值,并把他从链表删除并且插入到头结点
            node.value = value;
            removeAndInsert(node);
        } else {
            LRUNode tmp = new LRUNode(key, value);  // 不存在,就构建一个新节点,判断插入后是否溢出
            // 如果会溢出
            if (map.size() >= capacity) {
                // 先把它从哈希表中删除
                map.remove(tail.key);
                // 删除尾部节点
                tail = tail.pre;
                tail.next = null;
            }
            map.put(key, tmp);
            // 插入
            tmp.next = head;
            head.pre = tmp;
            head = tmp;
        }
    }

    // 移除当前节点并将其插入头结点
    private void removeAndInsert(LRUNode node) {
        // 特殊情况先判断,例如该节点是头结点或是尾部节点
        if (node == head) {
            return;
        } else if (node == tail) {
            tail = node.pre;
            tail.next = null;
        } else {
            node.pre.next = node.next;
            node.next.pre = node.pre;
        }
        // 插入到头结点
        node.next = head;
        node.pre = null;
        head.pre = node;
        head = node;
    }
}

如果有时间,强烈建议自己手动实现一波。

2.9、如果设置热点数据永不过期会出现什么后果?


首先来看一下,一般什么情况需要设置热点数据永不过期,即不设过期时间?

需要设置热点数据永不过期,那就是在这之前,热点数据过期失效了,那么,对热点数据访问的请求无法在缓存中处理,导致大量的请求一下子都发送到了后端数据库,使数据库压力激增,这种情况也就是常见的「缓存击穿」。

此时就可以设置热点数据永不过期,同时还需要为每个 value 设置一个逻辑过期时间,当发现逻辑时间过期以后,再使用单独的线程去构建缓存,即定时刷新。

这种方案的优点,就是不会存在热点 key 导致的问题了。

缺点也有,一旦热点数据多了起来,会很容易占满系统的内存空间。同时,还会存在数据不一致的情况。

三、架构

1、简单聊一聊 Redis 的架构?单线程Redis 快的原因是什么?


Redis 架构

Redis 的架构大致来说可以分为六部分:访问框架、索引模块、操作模块、存储模块、高可用集群支撑模块和高可扩展集群支撑模块。

「访问框架」:Redis 采用网络框架以 Socket 通信的形式对外提供键值对操作,使用了基于多路复用的高性能 IO 模型。

「索引模块」:Redis 采用哈希表作为索引,由于其键值数据都是保存在内存中的,而内存的高性能随机访问特性可以很好地与哈希表 O(1) 的操作复杂度相匹配。

「操作模块」:Redis 数据模型中的 value 类型很丰富,因此也带来了更多的操作接口,例如面向列表的 LPUSH/LPOP,面向集合的 SADD/SREM 等。

「存储模块」:Redis 的内存分配器提供了多种选择,分配效率也不一样。同时为了 Redis 重启后能快速重新提供服务,Redis 在存储模块中增加了持久化功能。

「高可用集群支撑模块」:Redis 提供了主从复制模式和哨兵机制,前者实现了数据的热备份,也可用于故障恢复和实现负载均衡,后者可以监控主从节点是否正常运作,并实现自动故障转移功能。

「高可扩展集群支撑模块」:Redis 的数据分片机制允许数据拆分存放在不同的 Redis 实例上,每个Redis实例只包含所有键的子集。可以减轻单台Redis的压力,提升Redis扩展能力和计算能力。

单线程 Redis 快的原因

首先,我们先要弄清一个事实,我们通常说的 Redis 是单线程,主要是指:Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

那 Redis 为什么要使用单线程呢?

主要是因为,如果采用多线程模式,会带来一系列并发控制问题,此时,若只是简单地采用一个粗粒度互斥锁,就会出现不理想的结果:即使增加了线程,大部分线程也在等待获取访问共享资源的互斥锁,并行变串行,系统吞吐率并没有随着线程的增加而增加。

而且,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。

单线程的 Redis 为什么那么快?

这是 Redis 多方面设计选择的一个综合结果。一方面,Redis 的大部分操作在内存上完成,再加上它采用了高效的数据结构,例如哈希表和跳表,这是它实现高性能的一个重要原因;另一方面,就是 Redis 采用了基于多路复用的高性能 I/O 模型,使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。

具体来说,Redis “借用”了 Linux 中的 IO 多路复用机制(select/epoll),该机制中的内核会有多个监听套接字(FD)和已连接套接字,同时内核会一直监听这些套接字的连接请求或数据请求,一旦有请求到达,就会将其交给 Redis 线程处理。

但请求到达时,该怎么样通知到 Redis,需要对其进行处理呢?

「基于事件的回调机制」就是解决这种困境的,即针对不同事件的发生,调用响应的处理函数。

具体的,select/epoll 一旦检测到 FD 中有请求到达,就会触发相应的事件。并且这些事件都会被放在一个事件队列中,Redis 单线程只需不断处理这个队列中的事件进行处理即可,无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。

同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。

整个过程如下图:


2、可以说一说 Memcached 与 Redis 的区别吗?


Redis 和 Memcached 都是内存 key - value 型数据库,都具备高性能的特点,两者有什么区别呢?

可以从「线程模型」,「数据结构」,「淘汰策略」,「管道与事物」,「持久化」,「高可用」,「集群化」七个方面入手,进行分析。

线程模型

Redis 采用单线程模型,使用 IO 多路复用技术,对键值对的读写和网络 IO 都是由一个线程来完成。也就是说,Redis 从接受请求到处理数据都在一个线程中完成。

Memcached 则采用多线程模型,也用了 IO 多路复用技术,主线程收到请求后分发给子线程处理。虽然多线程在一定程度上可以增加系统的吞吐率,或者增加系统的扩展性,但 CPU 进行多线程的上下文切换必定会带来一定的开销,并且多线程之间访问共同资源存在锁竞争问题。

同时由于 Redis 是内存数据库,它的访问速度非常地快,所以性能瓶颈不在于 CPU,而在于内存和网络带宽,这也是作者采用单线程模型的主要原因。

数据结构

Redis 有丰富的数据结构,除了常用的 String,List,Hash,Set,Zset 之外,还有 HyperLogLog,GEO 等数据类型,并且对于不同的数据结构可以采用不同的操作方法,应用场景也不同。

  • List:构建一个链表,或者当作队列使用;
  • Hash:灵活地操作我们需要的字段,进行 “整存零取”、“零存整取” 以及 “零存零取”;
  • Set:构建一个不重复的集合,并方便地进行差集、并集运算;
  • Zset:构建一个排行榜,或带有权重的列表;
  • Geo:用于地图相关的业务,标识两个地点的坐标,以及计算它们的距离;
  • HyperLogLog:使用极少的内存计算 UV;

Memcached 支持的数据结构却很单一,仅支持 String 类型的操作。并且,我们只能把序列化后的数据写入 Memcached 中,读取时再将其反序列化出来,即只能 ”整存整取“。

淘汰策略

Redis 可以不设置内存上限,前提是内存足够多,并且提供了多种淘汰策略:

  • volatile-ttl 针对设置了过期时间的键值对,根据过期时间的先后进行删除,越早过期的越先被删除;
  • volatile-random 在设置了过期时间的键值对中,进行随机删除;

  • volatile-lru 会使用 LRU 算法筛选设置了过期时间的键值对;

  • volatile-lfu 会使用 LFU 算法选择设置了过期时间的键值对;

  • allkeys-random 从所有键值对中随机选择并删除数据;

  • allkeys-lru 使用 LRU 算法在所有数据中进行筛选;

  • allkeys-lfu 使用 LFU 算法在所有数据中进行筛选;

Memcached 则必须设置整个实例的内存上限,当容量达到上限时触发 LRU 淘汰机制,优先淘汰不常用使用的数据。

管道与事物

Redis 支持管道功能,可以使客户端一次性打包发送多条命令发送到服务端,服务端一次处理客户端发来的命令,这样可以减少客户端与服务器端的 RTT 次数,提高 Redis 的吞吐率。

此外,Redis 还支持「事物」,一般都会配合「管道」一起使用,客户端一次性打包发送多条命令到服务端,并且标识这些命令必须严格按顺序执行,不能被其他客户端打断。

Memcached 不支持管道与事物。

持久化

Redis 支持将数据持久化到磁盘上,提供了 AOF 日志和 RDB 快照两种方式:

  • RDB:记录 Redis 实例中某一时刻的快照,将其记录到到磁盘上,属于全量持久化;
  • AOF:把每一个写命令持久到磁盘,属于增量持久化;

Redis 也可以同时使用这两种方式的混合模式,保证性能的同时,最大程度保证数据的完整性。

Memcached 不支持持久化。

高可用

Redis 提供了主从库模式,用来保证数据副本的一致,主从库之间采用的是读写分离的模式。

  • 读操作:主库、从库都可以接受;
  • 写操作:首先到主库执行,然后,主库将写操作同步给从库;

同时 Redis 还提供了哨兵机制,是实现主从库自读切换的关键机制。

Memcached 只能单点部署,如果某个 Redis 实例宕机,那么该实例的全部数据都将丢失。

集群化

Redis 集群化采用的是,每个节点都维护一部分虚拟槽位,通过对 key 的哈希计算,将 key 映射到具体的虚拟槽位上,这个槽位再映射到具体的 Redis 节点。同时每个 Redis 节点都包含至少一个从库,组成主从架构,进一步提高每个节点的高可用能力。

Redis 官方提供的集群化方案为 Redis Cluster,它采用无中心化的设计。另外也有第三方提供的采用中心化设计 proxy 方式的集群化解决方案,例如 Codis、Twemproxy。

Memcached 的集群化则是在客户端采用一致性哈希算法向指定节点发送数据,当某个节点宕机时,其它节点会分担这个节点的请求。

总结

将以上几点总结成下图:


3 简单说一下 Redis 6.0 之后出现了哪些新变化?


Redis 官方在 2020 年 5 月正式推出了 6.0 版本,其中有几个关键的新特性,分别是「面向网络处理的多 IO 线程」、「客户端缓存」、「细粒度的权限控制」,以及「RESP 3 协议的使用」。

其中,面向网络处理的多 IO 线程可以提高网络请求处理的速度,而客户端缓存可以让应用直接在客户端本地读取数据,这两个特性可以提升 Redis 的性能。除此之外,细粒度权限控制让 Redis 可以按照命令粒度控制不同用户的访问权限,加强了 Redis 的安全保护。RESP 3 协议则增强客户端的功能,可以让应用更加方便地使用 Redis 的不同数据类型。

首先来看一下 6.0 版本先出的多线程特性。

从单线程处理网络请求到多线程处理网络请求

Redis 一直被大家熟知的就是它的单线程加厚,虽然有些命令操作可以用后台线程或子进程执行(比如数据删除、快照生成、AOF 重写),但是,从网络 IO 处理到实际的读写命令处理,都是由单个线程完成的。

随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。

Redis 6.0 为了应对这个问题,采用了多个 IO 线程来处理网络请求,提高网络请求处理的并行度。

但是,Redis 的多 IO 线程只是用来处理网络请求的,对于读写命令,Redis 仍然使用单线程来处理。这是因为,Redis 处理请求时,网络处理经常是瓶颈,通过多个 IO 线程并行处理网络操作,可以提升实例的整体处理性能。而继续使用单线程执行命令操作,就不用为了保证 Lua 脚本、事务的原子性,额外开发多线程互斥机制了。这样一来,Redis 线程模型实现就简单了。

下面来看一下,在 Redis 6.0 中,主线程和 IO 线程具体是怎么协作完成请求处理的。可以把主线程和多 IO 线程的协作分成四个阶段。

阶段一:服务端和客户端建立 Socket 连接,并分配处理线程

首先,主线程负责接收建立连接请求。当有客户端请求和实例建立 Socket 连接时,主线程会创建和客户端的连接,并把 Socket 放入全局等待队列中。紧接着,主线程通过轮询方法把 Socket 连接分配给 IO 线程。

阶段二:IO 线程读取并解析请求

主线程一旦把 Socket 分配给 IO 线程,就会进入阻塞状态,等待 IO 线程完成客户端请求读取和解析。因为有多个 IO 线程在并行处理,所以,这个过程很快就可以完成。

阶段三:主线程执行请求操作

等到 IO 线程解析完请求,主线程还是会以单线程的方式执行这些命令操作。下面这张图显示了刚才介绍的这三个阶段:

阶段四:IO 线程回写 Socket 和主线程清空全局队列

当主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待 IO 线程把这些结果回写到 Socket 中,并返回给客户端。

和 IO 线程读取和解析请求一样,IO 线程回写 Socket 时,也是有多个线程在并发执行,所以回写 Socket 的速度也很快。等到 IO 线程回写 Socket 完毕,主线程会清空全局队列,等待客户端的后续请求。

下面这张图展示了这个阶段主线程和 IO 线程的操作:

实现服务端协助的客户端缓存

和之前的版本相比,Redis 6.0 新增了一个重要的特性,就是实现了服务端协助的客户端缓存功能,也称为跟踪(Tracking)功能。有了这个功能,业务应用中的 Redis 客户端就可以把读取的数据缓存在业务应用本地了,应用就可以直接在本地快速读取数据了。

不过,当把数据缓存在客户端本地时,我们会面临一个问题:如果数据被修改了或是失效了,如何通知客户端对缓存的数据做失效处理?

6.0 的 Tracking 功能实现了采用两种模式,来解决这个问题。

第一种模式是普通模式。在这个模式下,实例会在服务端记录客户端读取过的 key,并监测 key 是否有修改。一旦 key 的值发生变化,服务端会给客户端发送 invalidate 消息,通知客户端缓存失效了。

在使用普通模式时,需要注意,服务端对于记录的 key 只会报告一次 invalidate 消息,也就是说,服务端在给客户端发送过一次 invalidate 消息后,如果 key 再被修改,此时,服务端就不会再次给客户端发送 invalidate 消息。

只有当客户端再次执行读命令时,服务端才会再次监测被读取的 key,并在 key 修改时发送 invalidate 消息。这样设计的考虑是节省有限的内存空间。毕竟,如果客户端不再访问这个 key 了,而服务端仍然记录 key 的修改情况,就会浪费内存资源。

第二种模式是广播模式。在这个模式下,服务端会给客户端广播所有 key 的失效情况,不过,这样做了之后,如果 key 被频繁修改,服务端会发送大量的失效广播消息,这就会消耗大量的网络带宽资源。

所以,在实际应用时,一般会让客户端注册希望跟踪的 key 的前缀。当带有注册前缀的 key 被修改时,服务端会把失效消息广播给所有注册的客户端。和普通模式不同,在广播模式下,即使客户端还没有读取过 key,但只要它注册了要跟踪的 key,服务端都会把 key 失效消息通知给这个客户端。

需要注意的是,刚才介绍的普通模式和广播模式,需要客户端使用 RESP 3 协议,RESP 3 协议是 6.0 新启用的通信协议,下面会说。

对于使用 RESP 2 协议的客户端来说,就需要使用另一种模式,也就是重定向模式(redirect)。在重定向模式下,想要获得失效消息通知的客户端,就需要执行订阅命令 SUBSCRIBE,专门订阅用于发送失效消息的频道 redis:invalidate。同时,再使用另外一个客户端,执行 CLIENT TRACKING 命令,设置服务端将失效消息转发给使用 RESP 2 协议的客户端。

从简单的基于密码访问到细粒度的权限控制

在 Redis 6.0 版本之前,要想实现实例的安全访问,只能通过设置密码来控制,例如,客户端连接实例前需要输入密码。

此外,对于一些高风险的命令(例如 KEYS、FLUSHDB、FLUSHALL 等),在 Redis 6.0 之前,我们也只能通过 rename-command 来重新命名这些命令,避免客户端直接调用。

Redis 6.0 提供了更加细粒度的访问权限控制,这主要有两方面的体现。

首先,6.0 版本支持创建不同用户来使用 Redis。在 6.0 版本前,所有客户端可以使用同一个密码进行登录使用,但是没有用户的概念,而在 6.0 中,我们可以使用 ACL SETUSER 命令创建用户。例如,我们可以执行下面的命令,创建并启用一个用户 user,把它的密码设置为 “123” :

ACL SETUSER user on > 123

另外,6.0 版本还支持以用户为粒度,设置命令操作的访问权限。我把具体操作列在了下表中,你可以看下,其中,加号(+)和减号(-)就分别表示给用户赋予或撤销命令的调用权限。

举个例子,假设我们要设置用户 user 只能调用 Hash 类型的命令操作,而不能调用 String 类型的命令操作,我们可以执行如下命令:

ACL SETUSER user +@hash -@string

除了设置某个命令或某类命令的访问控制权限,6.0 版本还支持以 key 为粒度设置访问权限。

具体的做法是使用波浪号 “~” 和 key 的前缀来表示控制访问的 key。例如,我们执行下面命令,就可以设置用户 user 只能对以 “project:” 为前缀的 key 进行命令操作:

ACL SETUSER user ~project:* +@all

到这里,可以得出,Redis 6.0 可以设置不同用户来访问实例,而且可以基于用户和 key 的粒度,设置某个用户对某些 key 允许或禁止执行的命令操作。

这样一来,我们在有多用户的 Redis 应用场景下,就可以非常方便和灵活地为不同用户设置不同级别的命令操作权限了,这对于提供安全的 Redis 访问非常有帮助。

启用 RESP 3 协议

Redis 6.0 实现了 RESP 3 通信协议,而之前都是使用的 RESP 2。在 RESP 2 中,客户端和服务器端的通信内容都是以字节数组形式进行编码的,客户端需要根据操作的命令或是数据类型自行对传输的数据进行解码,增加了客户端开发复杂度。

而 RESP 3 直接支持多种数据类型的区分编码,包括空值、浮点数、布尔值、有序的字典集合、无序的集合等。

所谓区分编码,就是指直接通过不同的开头字符,区分不同的数据类型,这样一来,客户端就可以直接通过判断传递消息的开头字符,来实现数据转换操作了,提升了客户端的效率。除此之外,RESP 3 协议还可以支持客户端以普通模式和广播模式实现客户端缓存。

总结

可以将以上特性总结如下图:


四、分布式与集群

1、Redis为什么能实现分布式锁?要不你用伪代码实现一个分布式锁?

Redis 为什么能实现分布式锁?

在 Redis 中遇到并发问题时,除了原子操作,Redis 客户端还可以通过加锁的方式,来控制并发写操作对共享数据的修改,从而保证数据的正确性。

但是,Redis 属于分布式系统,当有多个客户端需要争抢锁时,我们必须要保证,这把锁不能是某个客户端本地的锁。否则的话,其它客户端是无法访问这把锁的,当然也就不能获取这把锁了。

所以,在分布式系统中,当有多个客户端需要获取锁时,我们需要分布式锁。此时,锁是保存在一个共享存储系统中的,可以被多个客户端共享访问和获取。

而 Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁。而且 Redis 的读写性能高,可以应对高并发的锁操作场景。所以,Redis 是可以用来实现分布式锁的。

Redis 实现分布式锁

首先,˛分布式锁可以用一个变量来实现,客户端加锁和释放锁的逻辑为:加锁时需要判断锁变量的值,根据锁变量值来判断能否加锁成功;释放锁时需要把锁变量值设置为 0,表明客户端不再持有锁。

但是,由于是在分布式场景下,锁变量需要由一个共享存储系统来维护,只有这样,多个客户端才可以通过访问共享存储系统来访问锁变量。相应的,加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值。

这样,可以得出实现分布式锁的两个要求:

  • 要求一:分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性;

  • 要求二:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。

知道了具体的要求,接下来说一下如何实现。其实,我们既可以基于单个 Redis 节点来实现,也可以使用多个 Redis 节点实现。在这两种情况下,锁的可靠性是不一样的。

基于单个 Redis 节点实现分布式锁

加锁: 加锁包含了三个操作,读取锁变量、判断锁变量值以及更改锁变量值,而这三个操作在执行时需要保证原子性,此时就可以使用 SETNX 命令:

SETNX lock_name value

当一个线程执行 SETNX 返回 1,说明 lock_name 原本不存在,该线程成功得到了锁;当一个线程执行 SETNX 返回 0,说明 lock_name 已经存在,该线程抢锁失败。

释放锁:对于释放锁操作来说,我们可以在执行完业务逻辑后,直接使用 DEL 命令删除锁变量即可。

DEL lock_name

总结来说,我们可以使用 SETNXDEL 命令组合来实现加锁和释放锁操作:

// 加锁
SETNX lock_name value
// 业务逻辑
DO THINGS
// 释放锁
DEL lock_name

潜在风险和改进:以上使用 SETNXDEL 命令组合实现分布锁,存在几个潜在的风险。

1、锁超时

第一个风险是,假如某个客户端在执行了 SETNX 命令加锁之后,紧接着却在操作共享数据时发生了异常,结果一直没有执行最后的 DEL 命令来释放锁。那么,锁就一直被这个客户端持有,其它客户端无法拿到锁,也无法访问共享数据和执行后续操作,这会给业务应用带来影响。

针对这个问题,一个有效的解决方法是,给锁变量设置一个过期时间。这样一来,即使持有锁的客户端发生了异常,无法主动地释放锁,Redis 也会根据锁变量的过期时间,在锁变量过期后,把它删除。其它客户端在锁变量过期后,就可以重新请求加锁,这就不会出现无法加锁的问题了。如:

EXPRIE lock_name 30

2、SETNXEXPIRE 操作非原子性

但是这样就会导致,无法保证 SETNXEXPIRE 操作的原子性了。此时可以使用 SET 命令加上可选参数来解决:

SET lock_name value EX 10000 NX

SET 的具体的用法如下:

  • EX second: 设置键的过期时间为second秒;
  • PX millisecond:设置键的过期时间为millisecond毫秒;
  • NX:只在键不存在时,才对键进行设置操作;
  • XX:只在键已经存在时,才对键进行设置操作;
  • SET操作完成时,返回OK,否则返回nil。

3、超时释放锁导致出现并发问题

第二个风险是,如果客户端 A 执行了 SETNX 命令加锁后,又设置了锁的过期时间,但是执行业务逻辑的时间超过了锁的过期时间,导致锁过期自动释放了。此时假设客户端 B 执行了 SETNX 获取到了锁,随后,客户端 A 业务执行完成,使用 DEL 释放锁,但此时客户端 B 还没有执行完业务,客户端 A 释放的其实是客户端 B 的锁。如果客户端 C 正好在这个时候申请加锁,就可以成功获取锁,进而开始操作共享数据。这样一来,客户端 A 和 C 同时在对共享数据进行操作,数据就会被修改错误,这也是业务层不能接受的,如下图:

为了应对这个问题,我们需要能区分来自不同客户端的锁操作,一个解决办法就是,我们在加锁操作时,可以让每个客户端给锁变量设置一个唯一值,这里的唯一值就可以用来标识当前操作的客户端。在释放锁操作时,客户端需要判断,当前锁变量的值是否和自己的唯一标识相等,只有在相等的情况下,才能释放锁。这样一来,就不会出现误释放锁的问题了。

当然,还可以使用 LUA 脚本来做验证标识和解锁操作:

//释放锁 比较unique_value是否相等,避免误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

其中,KEYS[1] 表示 lock_name,ARGV[1] 是当前客户端的唯一标识,这两个值都是我们在执行 Lua 脚本时作为参数传入的。最后,再执行下面的命令,就可以完成锁释放操作了。

redis-cli  --eval  unlock.script lock_name, unique_value 

4、不可重入

还有一个风险就是,当线程在持有锁的情况下再次请求加锁,如果一个锁支持一个线程多次加锁,那么这个锁就是可重入的。如果一个不可重入锁被再次加锁,由于该锁已经被持有,再次加锁会失败。

这种情况可以通过对锁进行重入计数,加锁时加 1,解锁时减 1,当计数归 0 时释放锁来解决。

5、无法等待锁释放

上述命令执行都是立即返回的,如果客户端想要等待锁释放就无法使用。

  • 可以通过客户端轮询的方式解决该问题,当未获取到锁时,等待一段时间重新获取锁,直到成功获取锁或等待超时。这种方式比较消耗服务器资源,当并发量比较大时,会影响服务器的效率。
  • 另一种方式是使用 Redis 的发布订阅功能,当获取锁失败时,订阅锁释放消息,获取锁成功后释放时,发送锁释放消息,如下图:

到这里,如何使用 SET 命令和 Lua 脚本在 Redis 单节点上实现分布式锁。但是,我们现在只用了一个 Redis 实例来保存锁变量,如果这个 Redis 实例发生故障宕机了,那么锁变量就没有了。此时,客户端也无法进行锁操作了,这就会影响到业务的正常执行。

所以,我们在实现分布式锁时,还需要保证锁的可靠性。那怎么提高呢?这就要提到基于多个 Redis 节点实现分布式锁的方式了。

基于多个 Redis 节点实现高可靠的分布式锁

当我们要实现高可靠的分布式锁时,就不能只依赖单个的命令操作了,我们需要按照一定的步骤和规则进行加解锁操作,否则,就可能会出现锁无法工作的情况。“一定的步骤和规则”是指啥呢?其实就是分布式锁的算法。

为了避免 Redis 实例故障而导致的锁无法工作的问题,Redis 的开发者 Antirez 提出了分布式锁算法 Redlock。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。这样一来,即使有单个 Redis 实例发生故障,因为锁变量在其它实例上也有保存,所以,客户端仍然可以正常地进行锁操作,锁变量并不会丢失。

下面具体分析 Redlock 算法的执行步骤。首先,Redlock 算法的实现需要有 N 个独立的 Redis 实例。接下来,可以分成 3 步来完成加锁操作。

第一步是,客户端获取当前时间。

第二步是,客户端按顺序依次向 N 个 Redis 实例执行加锁操作。

这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX、EX/PX 选项,以及带上客户端的唯一标识。当然,如果某个 Redis 实例发生故障了,为了保证在这种情况下 Redlock 算法能够继续运行,我们需要给加锁操作设置一个超时时间。

如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。

第三步是,一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。

客户端只有在满足下面的这两个条件时,才能认为是加锁成功。

  • 条件一:客户端从超过半数(大于等于 $\frac{N}{2}+1$)的 Redis 实例上成功获取到了锁;

  • 条件二:客户端获取锁的总耗时没有超过锁的有效时间。

在满足了这两个条件后,我们需要重新计算这把锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。如果锁的有效时间已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

当然,如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作。

在 Redlock 算法中,释放锁的操作和在单实例上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。这样一来,只要 N 个 Redis 实例中的半数以上实例能正常工作,就能保证分布式锁的正常工作了。

总结

Redis 以其高性能著称,但使用其实现分布式锁来解决并发仍存在一些困难。Redis 分布式锁只能作为一种缓解并发的手段,如果要完全解决并发问题,仍需要数据库的防并发手段。

2、说一说Redis哨兵集群相关问题(怎么主从同步,怎么哨兵选举, 怎么 master 选举)

主从同步

我们通常说 Redis 具有高可靠性,其实是指「数据尽量少的丢失」和「服务尽量少的中断」。Redis 使用 AOF 和RDB 保证了前者,而对于后者,Redis 的做法是增加副本冗余量,即将一份数据保存在多个实例上。即使有一个实例出现了故障,其它实例还可以继续对外提供服务,不会影响业务正常进行。

但是这样就会面临一个问题:这么多副本,它们之间怎么保持数据一致性呢?

Redis 采用了主从库模式,来解决这个问题。具体的,主从库之间采用的是读写分离的方式。

  • 读操作:主库、从库都可以接收;

  • 写操作:首先到主库执行,然后,主库将写操作同步给从库。

下面来看看主从同步的具体过程:

主从库间的第一次同步:当我们启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。

第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。

具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync 命令包含了「主库的 runID」和「复制进度 offset」 两个参数。

  • runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为 “ ?”。

  • offset,此时设为 -1,表示第一次复制。

主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。

这里有个地方需要注意,FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。

在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。

具体的,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。

在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。

最后,也就是第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

下图描绘了以上的过程:

到这里,对于主从库模式,我们还有两个问题需要考虑:

  • 主库执行全量复制可能会导致其压力过大:如果从库数量很多,而且都要和主库进行全量复制的话,就会导致主库忙于 fork 子进程生成 RDB 文件,进行数据全量同步。fork 这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。
  • 主从库之间的网络断了怎么办:一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

首先第一个问题,一个解决方法就是 “主 - 从 - 从” 模式。在上面介绍的主从库模式中,所有的从库都是和主库连接,所有的全量复制也都是和主库进行的。现在,我们可以通过 “主 - 从 - 从” 模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。

简单来说,就是我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库。然后,我们可以再选择一些从库(例如三分之一的从库),在这些从库上执行 "replicaof 所选从库的IP 6379",让它们和刚才所选的从库建立起主从关系。

这样一来,这些从库就会知道,在进行同步时,不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:

第二个问题,在 Redis 2.8 之前,如果主从库在命令传播时出现了网络闪断,那么,从库就会和主库重新再进行一次全量复制,开销非常大。

从 Redis 2.8 开始,网络断了之后,主从库会采用「增量复制」的方式继续同步。听名字大概就可以猜到它和全量复制的不同:全量复制是同步所有数据,而增量复制只会把主从库网络断连期间主库收到的命令,同步给从库。

那么,增量复制时,主从库之间具体是怎么保持同步的呢?这里的奥妙就在于 repl_backlog_buffer 这个缓冲区。下面来看看其原理:

当主从库断连后,主库会把断连期间收到的写操作命令,写入 replication buffer,同时也会把这些操作命令也写入 repl_backlog_buffer 这个缓冲区。

repl_backlog_buffer 是一个环形缓冲区,主库会记录自己已经写到的位置,从库则会记录自己已经读到的位置。

刚开始的时候,主库和从库的写读位置在一起,这是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常用偏移量来衡量这个偏移距离的大小,对主库来说,对应的偏移量就是 master_repl_offset。主库接收的新的写操作越多,这个值就会越大。

同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已复制的偏移量 slave_repl_offset 也在不断增加。正常情况下,这两个偏移量基本相等。

主从库的连接恢复之后,从库首先会给主库发送 psync 命令,并把自己当前的 slave_repl_offset 发给主库,主库会判断自己的 master_repl_offset 和 slave_repl_offset 之间的差距。

在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset 会大于 slave_repl_offset。此时,主库只用把 master_repl_offset 和 slave_repl_offset 之间的命令操作同步给从库就行。

如下图的中间部分,主库和从库之间相差了 put e f 和 put d e 两个操作,在增量复制时,主库只需要把它们同步给从库,就行了。

下面这张图显示了 Redis 增量复制的流程:

有一个地方需要强调,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致

因此,我们要想办法避免这一情况,一般而言,我们可以调整 repl_backlog_size 这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2,这也就是 repl_backlog_size 的最终值。

举个例子,如果主库每秒写入 2000 个操作,每个操作的大小为 2KB,网络每秒能传输 1000 个操作,那么,有 1000 个操作需要缓冲起来,这就至少需要 2MB 的缓冲空间。否则,新写的命令就会覆盖掉旧操作了。为了应对可能的突发压力,我们最终把 repl_backlog_size 设为 4MB。

这样一来,增量复制时主从库的数据不一致风险就降低了。不过,如果并发请求量非常大,连两倍的缓冲空间都存不下新操作请求的话,此时,主从库数据仍然可能不一致。

针对这种情况,一方面,你可以根据 Redis 所在服务器的内存资源再适当增加 repl_backlog_size 值,比如说设置成缓冲空间大小的 4 倍,另一方面,你可以考虑使用切片集群来分担单个主库的请求压力。

总结

Redis 主从库在第一次同步时,全量复制是不可避免的,为了减少 RDB 文件生成、传输和重新加载的开销,一个 Redis 实例的数据库最好不要太大。

由于多个从库可能同时和主库进行全量复制,给主库过大的同步压力,可以考虑使用 “主 - 从 - 从”这一级联模式。

在主从库正常运行后的常规同步阶段使用的是长连接复制,若此阶段遭到了网络断开的问题,就要使用增量复制了,其中需要重点关注repl_backlog_size 这个配置参数。如果它配置得过小,在增量复制阶段,可能会导致从库的复制进度赶不上主库,进而导致从库重新进行全量复制。所以,通过调大这个参数,可以减少从库在网络断连时全量复制的风险。

master 选举

一般来说,哨兵选择新主库的过程可看成 ”筛选 + 打分“ 两个环节。简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:

筛选

一般情况下,我们肯定要先保证所选的从库仍然在线运行。不过,在选主时从库正常在线,这只能表示从库的现状良好,并不代表它就是最适合做主库的。

设想一下,如果在选主时,一个从库正常运行,我们把它选为新主库开始使用了。可是,很快它的网络出了故障,此时,我们就得重新选主了。这显然不是我们期望的结果。

所以,在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就可以判断,这个从库的网络状况并不是太好,就可以把这个从库筛掉了。

具体的,可以使用配置项 down-after-milliseconds * 10。其中,down-after-milliseconds 是我们认定主从库断连的最大连接超时时间。如果在 down-after-milliseconds 毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。如果发生断连的次数超过了 10 次,就说明这个从库的网络状况不好,不适合作为新主库。

到这里,我们就过滤掉了不适合做主库的从库,完成了筛选工作。

打分

接下来就要给剩余的从库打分了。我们可以按照三个规则依次进行三轮打分,这三个规则分别是「从库优先级」、「从库复制进度」以及「从库 ID 号」。只要在某一轮中,有从库得分最高,那么它就是主库了,否则,若没有出现得分最高的从库,那么就继续进行下一轮。

第一轮:优先级最高的从库得分高。

用户可以通过 slave-priority 配置项,给不同的从库设置不同优先级。比如,如果有两个从库,它们的内存大小不一样,那就可以手动给内存大的实例设置一个高优先级。在选主时,哨兵会给优先级高的从库打高分,如果有一个从库优先级最高,那么它就是新主库了。如果从库的优先级都一样,那么哨兵开始第二轮打分。

第二轮:和旧主库同步程度最接近的从库得分高。

这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。

如何判断从库和旧主库间的同步进度呢?

主从库同步时,主库会用 master_repl_offset 记录当前的最新写操作在 repl_backlog_buffer 中的位置,而从库会用 slave_repl_offset 记录当前的复制进度。

此时,我们要找出一个从库,使它的 slave_repl_offset 最接近 master_repl_offset。如果在所有从库中,有从库的 slave_repl_offset 最接近 master_repl_offset,那么它的得分就最高,可以作为新主库。

第三轮:ID 号小的从库得分高。

每个实例都会有一个 ID,这个 ID 就类似于这里的从库的编号。Redis 在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库

到这里,新的 master 就被选出来了。

总结

再回顾下这个流程。首先,哨兵会按照在线状态、网络状态,筛选过滤掉一部分不符合要求的从库,然后,依次按照优先级、复制进度、ID 号大小再对剩余的从库进行打分,只要有得分最高的从库出现,就把它选为新主库。

哨兵(Leader)选举

”哨兵选举“,即确定由哪个哨兵执行主从切换的过程,和主库被判断为 “客观下线” 的过程类似,也是一个 “投票仲裁” 的过程。在具体了解这个过程前,我们再来看下,判断 “客观下线” 的仲裁过程。

假设有 $N$ 个哨兵实例,哨兵集群要判定主库 “客观下线”,需要有 $\frac{N}{2} + 1$ 个实例都认为该主库已经 “主观下线”,才能最终判定主库为 ”客观下线“。具体的判断过程如下:

每个哨兵会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对 PING 命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。

任何一个哨兵只要自身判断主库 “主观下线”后,就会给其他实例发送 is-master-down-by-addr 命令。接着,其他实例会根据自己和主库的连接情况,做出 Y 或 N 的响应,Y 相当于赞成票,N 相当于反对票。

一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的 quorum 配置项设定的。例如,现在有 5 个哨兵,quorum 配置的是 3,那么,一个哨兵需要 3 张赞成票,就可以标记主库为 “客观下线” 了。这 3 张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。

此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为 “Leader 选举”。因为最终执行主从切换的哨兵称为 Leader,投票过程就是确定 Leader。

在投票过程中,任何一个想成为 Leader 的哨兵,要满足两个条件:

  • 第一,拿到半数以上的赞成票;
  • 第二,拿到的票数同时还需要大于等于哨兵配置文件中的 quorum 值。以 3 个哨兵为例,假设此时的 quorum 设置为 2,那么,任何一个想成为 Leader 的哨兵只要拿到 2 张赞成票,就可以了。

下面是当有 3 个哨兵、quorum 为 2 的选举过程:

在 T1 时刻,S1 判断主库为 “客观下线”,它想成为 Leader,就先给自己投一张赞成票,然后分别向 S2 和 S3 发送命令,表示要成为 Leader。

在 T2 时刻,S3 判断主库为 “客观下线”,它也想成为 Leader,所以也先给自己投一张赞成票,再分别向 S1 和 S2 发送命令,表示要成为 Leader。

在 T3 时刻,S1 收到了 S3 的 Leader 投票请求。因为 S1 已经给自己投了一票 Y,所以它不能再给其他哨兵投赞成票了,所以 S1 回复 N 表示不同意。同时,S2 收到了 T1 时 S1 发送的 Leader 投票请求。因为 S2 之前没有投过票,它会给第一个向它发送投票请求的哨兵回复 Y,给后续再发送投票请求的哨兵回复 N。所以,在 T3 时,S2 回复 S1,同意 S1 成为 Leader。

在 T4 时刻,S2 才收到 T2 时 S3 发送的投票命令。因为 S2 已经在 T3 时同意了 S1 的投票请求,此时,S2 给 S3 回复 N,表示不同意 S3 成为 Leader。

最后,S1 得到的票数是来自它自己和 S2 的两票 Y 和来自 S3 的一票 N。而 S3 除了自己的赞成票 Y 以外,还收到了来自 S1、S2 的两票 Y。此时,S1 不仅获得了半数以上的 Leader 赞成票,也达到预设的 quorum 值(quorum 为 2),所以它最终成为了 Leader。接着,S1 会开始执行选主操作,而且在选定新主库后,会给其他从库和客户端通知新主库的信息。

如果 S1 没有拿到 2 票 Y,那么这轮投票就不会产生 Leader。哨兵集群会等待一段时间(也就是哨兵故障转移超时时间的 2 倍),再重新选举。

这是因为,哨兵集群能够进行成功投票,很大程度上依赖于选举命令的正常网络传播。如果网络压力较大或有短时堵塞,就可能导致没有一个哨兵能拿到半数以上的赞成票。所以,等到网络拥塞好转之后,再进行投票选举,成功的概率就会增加。

需要注意的是,如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader,必须获得 2 票,而不是 1 票。所以,如果有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。因此,通常我们至少会配置 3 个哨兵实例。

总结

在进行 Leader 选举时,想成为 Leader 的哨兵会向其它哨兵发送投票请求,只有在它得到的票数,超过半数以上的赞成票且不小于 quorum 值时, 才能成为 Leader。否则需要隔一段时间重新投票,直至选出新 Leader 为止。

可以说一说 Redis 并发竞争如何解决吗?

其实我们在使用 Redis 时,不可避免地会遇到并发竞争的问题,比如说,如果多个用户同时下单,就会对Redis 缓存中的商品库存并发更新。一旦有了并发写操作,此时数据会被多个请求修改,如果我们没有对并发写请求做好控制,就可能导致数据被改错,影响到业务的正常使用(例如库存数据错误,导致下单异常)。

而「并发访问控制」,是指对多个客户端访问操作同一份数据的过程进行控制,以保证任何一个客户端发送的操作在 Redis 实例上执行时具有互斥性。例如,客户端 A 的访问操作在执行时,客户端 B 的操作不能执行,需要等到 A 的操作结束后,才能执行。

具体的,Redis 提供了两种方法,分别是「分布式锁」和「原子操作」。

分布式锁,可以保证在多个客户端在读取数据前,需要先申请获得锁,否则就无法进行操作。当一个客户端获得锁后,就会一直持有这把锁,直到客户端完成数据更新,才释放这把锁。

原子操作是另一种提供并发访问控制的方法,它是指请求在执行过程保持原子性的操作,而且原子操作执行时并不需要再加锁,实现了无锁操作。这样一来,既能保证并发控制,还能减少对系统并发性能的影响。

接下来重点介绍第二种方法(第一种方法见 Redis 实现分布式锁):

先弄清两个概念:“RMW操作” 和 ”临界区代码“。“RMW操作” 是指 ”读取 - 修改 - 写回” 操作。在 Redis 中可以体现为:当客户端需要修改数据时,基本流程分成两步:

  • 客户端先把数据读取到本地,在本地进行修改;

  • 客户端修改完数据后,再写回 Redis。

当有多个客户端要对同一份数据执行 RMW 操作的话,我们需要让 RMW 操作涉及的代码都以原子的方式执行。这个访问同一份数据的 RMW 操作代码,就叫做 “临界区代码”。当有多个客户端并发执行临界区代码时,就会出现并发竞争问题。

接下来看看,为了实现临界区代码互斥执行,Redis 的两种原子操作方法:

  • 把多个操作在 Redis 中实现成一个操作,也就是单命令操作;
  • 把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。

Redis 本身的单命令操作

Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这相当于命令操作是互斥执行的。

当然,Redis 的快照生成、AOF 重写这些操作,可以使用后台线程或者是子进程执行,也就是和主线程的操作并行执行。不过,这些操作只是读取数据,不会修改数据,所以,我们并不需要对它们做并发控制。

但是,虽然 Redis 的单个命令操作可以原子性地执行,但是在实际应用中,比如 RMW 操作就包括「读数据」、「数据增减」、「写回数据」三个操作,这显然就不是单个命令操作了,那该怎么办呢?

对此,Redis 提供了 INCR/DECR 命令,把这三个操作转变为一个原子操作了。INCR/DECR 命令可以对数据进行 "增值 / 减值操作",而且它们本身就是单个命令操作,Redis 在执行它们时,本身就具有互斥性。

比如说,在刚才的库存扣减例子中,客户端可以使用下面的代码,直接完成对商品 id 的库存值减 1 操作。即使有多个客户端执行下面的代码,也不用担心出现库存值扣减错误的问题。

DECR id

所以,如果我们执行的 RMW 操作是对数据进行增减值的话,Redis 提供的原子操作 INCR 和 DECR 可以直接帮助我们进行并发控制。

但是,如果我们要执行的操作不是简单地增减数据,而是有更加复杂的判断逻辑或者是其他操作,那么,Redis 的单命令操作已经无法保证多个操作的互斥执行了。这个时候,我们需要使用第二个方法,也就是 Lua 脚本。

Lua 脚本在 Redis 中的使用

Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。如果我们有多个操作要执行,但是又无法用 INCR/DECR 这种命令操作来实现,就可以把这些要执行的操作编写到一个 Lua 脚本中。然后,我们可以使用 Redis 的 EVAL 命令来执行脚本。这样一来,这些操作在执行时就具有了互斥性。

下面是 Lua 脚本在 Redis 使用的一个例子:

当一个业务应用的访问用户增加时,我们有时需要限制某个客户端在一定时间范围内的访问次数,比如爆款商品的购买限流、社交网络中的每分钟点赞次数限制等。

具体的,我们可以把客户端 IP 作为 key,把客户端的访问次数作为 value,保存到 Redis 中。客户端每访问一次后,我们就用 INCR 命令增加访问次数。

不过,在这种场景下,客户端限流其实同时包含了对「访问次数」和「时间范围」的限制,例如每分钟的访问次数不能超过 20。所以,我们可以在客户端第一次访问时,给对应键值对设置过期时间,例如设置为 60s 后过期。同时,在客户端每次访问时,我们读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。

下面的这段 java 代码,实现了对客户端每分钟访问次数不超过 20 次的限制。

Integer current = Visit.get(ip);  // 获取ip对应的访问次数
if (current != null && current > 20) {  // 如果超过访问次数超过20次,则报错
  // 抛出异常
} else {
  Integer value = redis.incr(ip);  //如果访问次数不足20次,增加一次访问计数
  if (value == 1) {  //如果是第一次访问,将键值对的过期时间设置为60s后
    redis.expire(ip, 60);
  }
  // do things
}

在这个例子中,我们已经使用了 INCR 来原子性地增加计数。但是,客户端限流的逻辑不只有计数,还包括访问次数判断和过期时间设置

对于这些操作组成的整体,我们同样需要保证它们的原子性。否则,如果客户端使用多线程访问,访问次数初始值为 0,第一个线程执行了INCR(ip) 操作后,第二个线程紧接着也执行了 INCR(ip),此时,ip 对应的访问次数就被增加到了 2,我们就无法再对这个 ip 设置过期时间了,这显然是不行的。

此时,我们就可以使用 Lua 脚本来保证并发控制。我们可以把访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作写入一个 Lua 脚本,如下所示:

local current
current = redis.call("incr", KEYS[1])
if tonumber(current) == 1 then
    redis.call("expire", KEYS[1],60)
end

假设我们编写的这个脚本名称为 lua.script,那就可以在启动 Redis 客户端时,带上 eval 选项来执行该脚本。脚本所需的参数将通过以下命令中的 keys 和 args 进行传递。

redis-cli  --eval lua.script  keys , args

这样一来,访问次数加 1、判断访问次数是否为 1,以及设置过期时间这三个操作就可以原子性地执行了。即使客户端有多个线程同时执行这个脚本,Redis 也会依次串行执行脚本代码,避免了并发操作带来的数据错误。

发表评论

后才能评论