Redis面试题

目录

1.在项目中如何利用 Redis 实现分布式 Session?Redis 的主要优势是什么?

Redis 是一个开源的、基于内存的 K/V 存储中间件。由于基于内存,其读写性能非常高,很适用于缓存。此外,Redis 支持多种数据结构、各类编程语言的客户端、支持持久数据,其生态也非常广泛。

本项目中,我使用 Redis 分布式 Session 来代替 Tomcat 本地的 Session 存储,能够在分布式多机场景下保证获取登录用户信息的一致性。用 Redis 实现分布式 Session 的优点是非常简单方便,只需要引入 Redis 和 spring-session-data-redis 依赖,然后在配置文件中指定 Redis 的地址和 session 的 store-type 为 redis,即可自动生效,不用自己额外编码。

2.在 Redis 中,使用 Hash 代替 String 存储用户信息的好处是什么?Hash 与 String 存储方式有何区别?

Redis 的 Hash 结构采用 key / value 键值对的形式存储数据,使用 Redis 的 Hash 来存储用户信息后,能够很方便地对用户每个属性进行独立的更新和查询操作,而不是更新和返回整个 JSON 字符串,性能会更高。

举个例子,你想要获取用户的昵称(就 4 个字符串),但是用户的简介有 100 KB 的大小。如果用 Hash 结构,可以只获取昵称,网络传输的内容大小就很小;而如果用 String 结构整体存储,网络传输数据时会把所有的用户信息都返回出来,增加传输开销。

此外,相比于直接在 Spring Boot 中使用 String 类型存储用户信息,使用 Hash 结构不用额外存储序列化对象信息,可以一定程度上节省内存。

9.使用 Redis 缓存时可能出现的常见问题有哪些?你是如何解决这些问题的?

建议先列举使用缓存可能出现的常见问题,然后再挑其中一点举例。

使用 Redis 缓存可能的常见问题:

1)缓存击穿: 缓存击穿指的是某个热门的缓存键在过期后,同时有大量并发请求到达,导致所有请求都穿透缓存直接访问数据库,造成数据库压力激增。解决方法包括:

  • 使用互斥锁来保护缓存访问,只允许一个线程重新生成缓存。
  • 针对缓存失效时的并发请求使用分布式锁,确保只有一个线程重新生成缓存。

2)缓存雪崩: 缓存雪崩指的是大量缓存键在相同时间失效,导致大量请求落到数据库上,造成数据库压力激增。解决方法包括:

  • 为缓存键设置不同的失效时间,使失效时间分散。
  • 使用热点数据预热,提前加载热门数据到缓存。

3)缓存过期问题: 缓存中的数据过期后可能会导致数据不一致或数据不可用。解决方法包括:

  • 设置合理的缓存失效时间,避免缓存数据长时间不更新。
  • 使用缓存的时候检查数据是否过期,如果过期则重新生成缓存。

4)缓存内存问题: 如果缓存数据量很大,可能会导致内存占用过多。解决方法包括:

  • 设置合理的内存限制,避免缓存数据过多。
  • 使用LRU(Least Recently Used)策略或淘汰算法来淘汰不常用的缓存数据。

5)缓存数据一致性问题: 缓存数据和数据库数据不一致。解决方法包括:

  • 使用缓存更新策略,当数据库数据发生变化时,及时更新缓存。
  • 使用双写策略,即同时更新数据库和缓存,确保数据一致性。

6)缓存安全问题: 某些敏感数据可能不应该被缓存,如果被缓存可能引发安全问题。解决方法包括:

  • 避免缓存敏感数据。
  • 使用加密或其他安全措施来保护缓存数据。

7)缓存监控和调优问题: 缓存需要监控和调优,以确保性能和稳定性。解决方法包括:

  • 使用监控工具来监测缓存的命中率、内存占用等性能指标。
  • 定期调整缓存配置,优化性能。

在本项目中,我通过给不同的缓存设置不同的随机过期时间(N + n)来解决缓存雪崩问题。

129.Redis 是什么?Redis 的特点和常见应用场景有哪些?

Redis(Remote Dictionary Server)是一个开源的高性能键值存储系统,也被称为数据结构服务器。它支持多种类型的数据结构,如字符串、哈希、列表、集合、有序集合等,并提供了丰富的操作这些数据结构的命令。

Redis的特点包括:

  • 高性能:Redis使用内存来存储数据,并且数据存储在单一的进程中,因此速度非常快。

  • 多样的数据类型:Redis 支持多种数据结构,包括字符串、哈希、列表、集合、有序集合等。

  • 持久化:Redis 支持多种持久化方式,包括 RDB 快照和 AOF 日志。

  • 分布式:Redis 支持分布式部署,可以将数据分布在多个节点上。

  • 简单易用:Redis 提供了丰富的命令,使得操作数据非常方便。 Redis 的常见应用场景包括:

  • 缓存:Redis 可以作为缓存使用,加速数据读取和响应速度。

  • 消息队列:Redis 提供了列表和发布/订阅功能,可以用来实现消息队列。

  • 计数器:Redis 的计数器功能非常高效,可以用来实现页面访问量、点击量等的计数。

  • 排行榜:Redis 的有序集合功能可以用来实现排行榜。

  • 分布式锁:Redis 可以用来实现分布式锁,保证多个进程之间的互斥访问。

  • 实时数据分析:Redis 可以作为实时数据分析的缓存层,加速数据分析速度。

总之,Redis 具有高性能、多样的数据类型、分布式、简单易用等特点,可以应用于各种场景,特别适合用来解决读写频繁的问题。

131.Redis 基础类型中的 String 底层实现是什么?

Redis 中的 String 类型底层实现是一个简单动态字符串SDS,Simple Dynamic String)。

它有如下特点:

  1. 动态扩展:SDS 支持动态扩展,不需要手动管理内存。当字符串长度增加时,SDS 会自动扩展以适应新的长度。
  2. 常数时间获取长度:SDS 会缓存字符串的长度,获取长度的时间复杂度为 O(1)。
  3. 二进制安全:SDS 可以存储二进制数据,不会因为遇到 \0 字符而截断。
  4. 防止缓冲区溢出:SDS 通过空间预分配和惰性空间释放技术,减少了内存分配的频率并防止缓冲区溢出。
  5. 预分配空间:当增加字符串长度时,SDS 会预分配额外的空间,以减少频繁的内存分配操作。
  6. 惰性释放:当字符串缩短时,SDS 不会立即释放多余的内存,而是通过 free 字段记录未使用的空间。

SDS 的结构定义:

1
2
3
4
5
struct sdshdr {
    int len;      // 记录已使用的字符串长度
    int free;     // 记录未使用的预分配空间长度
    char buf[];   // 字符数组,用于保存字符串
};
  • len:表示当前字符串的长度。
  • free:表示分配的但未使用的空间长度。
  • buf:实际存储字符串的字符数组。

扩展:小整数的特殊优化

为了提高效率,Redis 对小整数有特殊的处理。如果一个 string 值可以被解析为整数并且在一定范围内(64 位带符号整数范围),那么 Redis 会将这个 string 以整数形式存储

这样在对整数进行操作时,可以直接使用整数值而不是进行字符串解析和转换。

扩展:字符串编码

Redis 的 string 类型可以采用以下三种不同的编码方式,具体取决于字符串的内容和长度:

  1. RAW:直接使用 SDS 实现的字符串。当字符串长度超过 32 字节时,使用 RAW 编码。
  2. EMBSTR:嵌入式字符串编码,适用于长度不超过 44 字节的小字符串。EMBSTR 将 SDS 头和字符数组分配在连续的内存块中,提高了内存分配和释放的效率。
  3. INT:整数编码,适用于可以表示为 64 位带符号整数的字符串。

注意:

  • 字符串长度为 32 字节到 44 字节之间:在这个范围内,Redis 会优先使用 EMBSTR 编码来优化内存使用。但如果有某些特殊的条件(如内存分配的碎片、存储优化等其他因素),Redis 可能会选择 RAW 编码,即使字符串长度在 32 到 44 字节之间。

132.如何使用 Redis 实现一个排行榜?

使用 Redis 可以很方便地实现一个排行榜,以下是一种实现方法:

使用**有序集合(Sorted Set)**来存储排行榜数据,以用户得分作为分数(score),用户 ID 作为成员(member)。

当用户得分改变时,使用 Redis 的 ZADD 命令将用户的分数更新到有序集合中。

获取排行榜数据时,使用 Redis 的 ZREVRANGE 命令按分数倒序获取有序集合中的成员。

可以使用 Redis 的 ZSCORE 命令获取某个用户的分数,或使用 ZREVRANK 命令获取某个用户的排名。

以下是一个简单的 Node.js 实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
const redis = require('redis');
const client = redis.createClient();

// 将用户得分更新到排行榜中
function updateScore(userId, score) {
  client.zadd('leaderboard', score, userId, (err, res) => {
    if (err) {
      console.error('Error updating score:', err);
    }
  });
}

// 获取排行榜数据
function getLeaderboard() {
  return new Promise((resolve, reject) => {
    client.zrevrange('leaderboard', 0, -1, 'WITHSCORES', (err, res) => {
      if (err) {
        reject(err);
      } else {
        const leaderboard = [];
        for (let i = 0; i < res.length; i += 2) {
          leaderboard.push({
            userId: res[i],
            score: parseInt(res[i + 1])
          });
        }
        resolve(leaderboard);
      }
    });
  });
}

// 获取某个用户的分数
function getScore(userId) {
  return new Promise((resolve, reject) => {
    client.zscore('leaderboard', userId, (err, res) => {
      if (err) {
        reject(err);
      } else {
        resolve(parseInt(res));
      }
    });
  });
}

// 获取某个用户的排名
function getRank(userId) {
  return new Promise((resolve, reject) => {
    client.zrevrank('leaderboard', userId, (err, res) => {
      if (err) {
        reject(err);
      } else {
        resolve(res === null ? null : res + 1);
      }
    });
  });
}

// 示例代码
updateScore('user1', 100);
updateScore('user2', 200);
updateScore('user3', 300);

getLeaderboard().then((leaderboard) => {
  console.log('Leaderboard:', leaderboard);
});

getScore('user1').then((score) => {
  console.log('User1 score:', score);
});

getRank('user1').then((rank) => {
  console.log('User1 rank:', rank);
});

在实际应用中,还可以根据需要进行排行榜数据的缓存和更新策略等优化。

133.Redis 的持久化机制有哪些?它们的优缺点和应用场景是什么?

Redis 的持久化机制主要分为 RDBAOF 两种。

RDB 持久化机制

RDB 持久化机制是指将 Redis 在内存中的数据以快照的形式写入磁盘中,可以手动或自动执行快照操作,将数据集的状态保存到一个 RDB 文件中

RDB 机制的优点在于:

  • RDB 机制适合在数据集比较大时进行备份操作,因为它可以生成一个非常紧凑、经过压缩的数据文件,对于备份、恢复、迁移数据都很方便。

RDB 机制在 Redis 重启时比 AOF 机制更快地将 Redis 恢复到内存中。

RDB 机制的缺点在于:

  • RDB 机制可能会出现数据丢失,因为数据是周期性地进行备份,一旦 Redis 出现问题并且上一次备份之后还没有进行过数据变更,那么这部分数据将会丢失。
  • RDB 机制会造成一定的 IO 压力,当数据集比较大时,进行备份操作可能会阻塞 Redis 服务器进程。

AOF 持久化机制

AOF 持久化机制是指将 Redis 在内存中的操作命令以追加的形式写入到磁盘中的 AOF 文件,AOF 文件记录了 Redis 在内存中的操作过程,只要在 Redis 重启后重新执行 AOF 文件中的操作命令即可将数据恢复到内存中。

AOF 机制的优点在于:

  • AOF 机制比 RDB 机制更加可靠,因为 AOF 文件记录了 Redis 执行的所有操作命令,可以确保数据不丢失。
  • AOF 机制在恢复大数据集时更加稳健,因为 AOF 文件记录了数据的操作过程,可以确保每一次操作都被正确地执行。

AOF 机制的缺点在于:

  • AOF 机制生成的 AOF 文件比 RDB 文件更大,当数据集比较大时,AOF 文件会比 RDB 文件占用更多的磁盘空间。
  • AOF 机制对于数据恢复的时间比 RDB 机制更加耗时,因为要重新执行 AOF 文件中的所有操作命令。

综上所述,RDB 适合用于数据集较大、备份、恢复数据和迁移数据等场景,AOF 适合用于数据可靠性要求高、数据恢复稳健等场景。

134.如何用 Redis 实现分布式 Session?

在分布式系统中,通常会将 Session 存储在 Redis 中来实现分布式 Session,这样就可以在多台服务器之间共享 Session 数据。

实现分布式 Session 的方式有多种,其中一种常用的方式是使用 Redis 的数据结构 Hash。具体实现步骤如下:

  1. 在用户登录成功后,将 Session 数据存储在 Redis 中。
  2. 将 Redis 中的 Session 数据的 Key 设置为一个全局唯一的 ID,一般使用类似于“session:token”这样的格式,其中 token 是一个随机生成的字符串,用来标识这个 Session 数据。
  3. 在客户端返回响应的同时,将 Session ID(即 token)以 Cookie 的形式返回给客户端。客户端在后续的请求中都会携带这个 Cookie。
  4. 在后续的请求中,服务器会从客户端传递过来的 Cookie 中获取 Session ID,然后根据这个 ID 从 Redis 中获取对应的 Session 数据。如果 Redis 中没有找到对应的 Session 数据,那么就表示这个请求无法通过认证。
  • 在用户退出登录或 Session 失效时,需要将 Redis 中的对应 Session 数据删除。

可以使用 Redis 的 EXPIRE 命令来设置 Session 数据的过期时间,这样可以自动删除已经过期的 Session 数据。

同时,还需要注意保护 Redis 中的 Session 数据不被恶意攻击者窃取,一般可以通过设置 Session 数据的前缀和使用随机的 Session ID 等方式来提高安全性。

鱼皮的补充:这题可以结合 spring-session-data-redis 的实现去说

135.Redis 的内存淘汰机制是什么?有哪些内存淘汰策略?

Redis 是一种基于内存的键值数据库,由于内存有限,当 Redis 占用的内存达到上限时,就需要进行内存淘汰,以腾出一些内存空间。

Redis 中的内存淘汰机制包括:

  1. 定期删除:Redis 可以设置一个定时器,定期扫描键空间中的键,并删除已经过期的键。
  2. 惰性删除:当一个键过期时,Redis 不会立即删除该键,而是等到该键被访问时再删除。
  3. 内存淘汰策略:当 Redis 内存占用达到上限时,会根据内存淘汰策略来选择一些键进行删除,以腾出更多的内存空间。

Redis 中的内存淘汰策略包括:

  1. noeviction:禁止删除键,即不做任何操作。
  2. allkeys-lru:从所有的键中选择最近最少使用的键进行删除。
  3. allkeys-random:从所有的键中随机选择一些键进行删除。
  4. volatile-lru:从已设置过期时间的键中选择最近最少使用的键进行删除。
  5. volatile-random:从已设置过期时间的键中随机选择一些键进行删除。
  6. volatile-ttl:从已设置过期时间的键中选择剩余时间最短的键进行删除。

其中,noeviction 策略是最简单的策略,但可能会导致 Redis 内存占满,并导致 Redis 无法正常工作。其他策略则会根据不同的算法进行键的选择和删除,以尽可能地保留重要的键。

总之,Redis 中的内存淘汰机制是保证 Redis 正常运行的重要机制之一,内存淘汰策略则根据不同的场景选择合适的策略来删除不必要的键,以腾出更多的内存空间。

137.Redis 中有哪些数据类型?基础数据结构有几种?有哪些高级数据结构?

回答重点

Redis 支持多种数据类型,不同的数据类型可以满足不同的需求。下面是 Redis 中常用的数据类型:

String(字符串)

  • Redis 中最基本的数据类型,可以存储任何形式的数据,例如整数、浮点数、二进制数据等。

Hash(哈希)

  • Redis 中的一种键值对类型,可以存储多个键值对,每个键值对又是一个键值对结构。

List(列表)

  • Redis 中的一个有序列表类型,可以存储多个元素,每个元素都有一个索引,支持多种列表操作,例如插入、删除、查找等。

Set(集合)

  • Redis 中的一种无序集合类型,可以存储多个元素,每个元素都是唯一的,支持多种集合操作,例如交集、并集、差集等。

Sorted Set(有序集合)

  • Redis 中的一种有序集合类型,可以存储多个元素,每个元素都有一个分值,支持根据分值进行排序和查询等操作。

Redis 的基础数据结构有三种:字符串、列表和哈希,其他的数据类型都是基于这三种数据结构进行扩展和衍生的。例如,Redis 的 Set 数据类型就是基于字符串实现的。

知识扩展

除了基础数据结构之外,Redis 还提供了多种高级数据结构,例如:

  • HyperLogLog:一种基数估计算法,用于估计一个数据集合的基数。
  • GeoHash:一种地理位置编码算法,可以对地理位置信息进行编码和查询。
  • Pub/Sub:一种消息队列机制,可以实现消息的订阅和发布。
  • Bitmaps:一种位图数据结构,可以进行高效的位运算,用于统计用户在线时长、网站访问量等。
  • Lua 脚本:Redis 中可以使用 Lua 脚本进行扩展和定制,可以实现一些复杂的业务逻辑和算法。

139.如何使用 Redis 实现分布式锁?

使用 Redis 实现分布式锁的基本思路是使用 Redis 的原子性操作来确保在多个客户端之间只有一个客户端可以成功获取锁。以下是使用 Redis 实现分布式锁的步骤:

  1. 获取锁:客户端尝试获取锁,可以使用 Redis 的 SET 命令结合NX和EX选项来实现。SET 命令用于设置一个键值对,NX 选项表示只有在键不存在时才设置,EX 选项表示设置一个过期时间(单位为秒)。 示例:SET lock_key unique_value NX EX 30。这个命令表示如果 lock_key 不存在,就设置它的值为 unique_value,并设置 30 秒的过期时间。如果设置成功,表示获取锁成功;如果设置失败,表示锁已被其他客户端持有,需要等待或重试。
  2. 持有锁:在持有锁的客户端执行临界区代码。在此期间,其他客户端无法获取锁。
  3. 释放锁:在临界区代码执行完毕后,需要释放锁以供其他客户端使用。为了避免误解锁的情况(例如,由于执行时间过长导致锁过期,然后被其他客户端获取),在释放锁时需要检查锁的值是否与获取锁时设置的值相同。这可以通过 Redis 的 EVAL 命令来实现,使用 Lua 脚本进行原子性操作。 示例:EVAL “if redis.call(‘get’, KEYS[1]) == ARGV[1] then return redis.call(‘del’, KEYS[1]) else return 0 end” 1 lock_key unique_value。这个命令表示检查lock_key的值是否为unique_value,如果是,则删除 lock_key 以释放锁;否则不执行任何操作。 通过以上步骤,可以实现一个基本的 Redis 分布式锁。在实际应用中,还需要考虑一些其他因素,例如获取锁的重试策略、锁的续期等。

题解更倾向于以原生命令的方式去实现,其实还有很多现成的第三方库,比如 Redisson。这里要关注分布式锁可能出现的各种异常情况。

140.如何使用 Redis 的 HyperLogLog 统计页面 UV?

在 Redis 中,HyperLogLog 是一种基数估算算法,可以用于快速计算一个集合中的不同元素数量的近似值。

在使用 HyperLogLog 统计页面 UV 时,我们可以将每个访问者的 IP 地址作为一个元素加入到 HyperLogLog 中,然后通过计算 HyperLogLog 中元素数量的近似值来估计页面的唯一访问者数量。

以下是一个示例代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import redis

# 连接 Redis 服务器
r = redis.Redis(host='localhost', port=6379)

# 记录访问者 IP 地址
ip_address = '192.168.0.1'

# 将 IP 地址加入到 HyperLogLog 中
r.pfadd('page_views', ip_address)

# 获取 HyperLogLog 中元素数量的近似值
uv = r.pfcount('page_views')

# 输出页面 UV
print('页面 UV:', uv)

在上面的示例代码中,我们使用 Redis 的 pfadd() 方法将每个访问者的 IP 地址加入到名为 page_views 的 HyperLogLog 中。然后,我们使用 pfcount() 方法获取 page_views 中元素数量的近似值。

要注意 HyperLogLog 是一种基数估算算法,其结果是一个近似值,并不是精确值。

扩展 HyperLogLog 的特性

1)内存高效:

  • 无论集合中有多少元素,HyperLogLog 结构的大小固定为 12 KB 左右。这种内存效率使得它非常适合用于处理大数据集的基数估计。

2)概率估计:

  • HyperLogLog 并不存储实际的元素,而是通过散列函数(hash function)将元素映射到一个位图中,从而进行基数估计。
  • 由于使用了概率算法,结果并不是精确的,但在大多数情况下具有很高的准确度。

它适用于需要快速计算基数但对准确度要求不高的场景,例如网络流量分析、独立访问用户计数等。

扩展 HyperLogLog 的内部原理(了解即可)

HyperLogLog 的工作原理主要如下:

1)哈希函数:

使用哈希函数将输入元素映射为一个大的二进制数。例如,将一个字符串通过哈希函数转换为一个 64 位的二进制数。

2)前导零计数:

计算哈希值中最左边的连续零的数量。这个数量越大,意味着元素的分布范围越大。

3)存储最大值:

使用一个位图(buckets)来存储不同哈希值的前导零计数的最大值。位图的大小通常是 2^14(16,384 个桶)。

4)基数估计:

根据位图中的最大值和桶的数量,使用 HyperLogLog 算法公式来估计集合的基数。

扩展 HyperLogLog 准确度(了解即可)

HyperLogLog 的标准误差公式是 1.04/sqrt(m),m 是桶的个数,因为 Redis 中用了 16384 个桶。

所以,HyperLogLog 的标准误差是 0.81%。

315.Redis 分布式 Session 实现用户登录的原理是什么?

实现步骤如下:

  1. 用户登录过程:用户登录成功后,在后端生成一个唯一的 Session 标识(通常是一个SessionID),用于标识该用户的登录状态。
  2. 用户信息存储:用户信息包括用户 id、昵称、角色等,封装到一个 Java 对象中,然后将其存储到 Redis。
  3. 生成 SessionID:生成一个唯一的 SessionID,通常是一个随机字符串,用于关联用户和 Session信息。
  4. Session 信息存储到 Redis:存储时使用 SessionID 作为键,将用户信息序列化为字符串或其他适当的格式作为值,存储的时效性通常由 Session 的过期时间决定。
  5. SessionID 返回给前端:将生成的 SessionID 返回给前端,通常通过 HTTP 的 Cookie 或其他方式返回(这一步由框架帮你做了)
  6. 后续请求:用户的后续请求会携带 SessionID,后端通过 SessionID 从 Redis 中获取用户信息,以便进行用户身份验证。
  7. Session 过期管理:一定要设置 Session 的过期时间,确保 Session 在用户不活动的一段时间后自动失效,以释放资源并提高安全性。

397.Redis 中的三种高效缓存读写策略是什么?

引言:在某一天面试的时候,小 x 被问到 Redis 三种缓存读写的策略,他懵了,原因是简历上明明是写着熟悉 Redis,因此面试官可以随意向任何一个方向进行开火,大家要注意从小点切入,除非自己是完全能够掌握这门技术的,本文将带你去了解三种常用的缓存读写策略的优缺点和使用场景。

题目

Redis 三种高效缓存读写策略你了解吗?

推荐解析

旁路缓存模式(Cache Aside Pattern)

旁路缓存是最常见的缓存读写模式,适用于读多写少的使用经常。服务端以数据库比如 MySQL 为主,Redis 为辅,进行存储。

写操作流程

1)先更新数据库

2)删除 Redis 中的缓存

读操作流程

1)尝试从缓存中读取数据,读取到数据就直接返回

2)缓存中读取不到,从数据库中读取数据

3)读取完毕后,将数据放入缓存

缓存不一致可能的场景(先删后更)

假如先删除缓存,再更新数据库,大概率会造成缓存不一致。线程 1 把 Redis 中 x 数据删除,此时线程 2 发现缓存中没有数据,从数据库读取,而线程以此时又把数据库中的 x 数据更新,因此线程 2 读取到的就是旧数据,造成了缓存不一致的情况。

缓存不一致发生概率小

被推荐的作法,就是上文讲过的,先更新数据库,再删除缓存。

可能不一致的场景如下

1)缓存中 X(数据) 不存在(数据库 X = 1)

2)线程 1 读取数据库,得到旧值(X = 1)

3)线程 2 更新数据库(X = 2)

4)线程 2 删除缓存

5)线程 1 将旧值写入缓存(X = 1)

6)最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),也发生不一致。

此场景需要满足:1)缓存失效 2) 读写请求同步对一个数据进行并发操作 3)更新数据库+删除缓存的时间大于读取和写入缓存的时间,也就是说写操作时间大于度操作时间,因为缓存这块可以不计入,理论发生概率是很小的。

旁路缓存优缺点

优点

1)提高数据访问速度

2)减少主存访问

3)提高并发性

缺点

1)存在缓存数据库不一致情况

2)首次请求数据一定不在缓存(可以缓存预热结合定时任务)

3)写操作频繁会导致缓存被频繁删除,影响缓存命中率。(可以加分布式锁,保证更新数据库同时更新缓存。或者直接设置一个较短的过期时间)

旁路缓存示例代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
import java.util.HashMap;
import java.util.Map;

public class CacheAsideExample {
    // 模拟缓存
    private static Map<String, String> cache = new HashMap<>();

    // 模拟数据库或数据源
    private static Map<String, String> dataSource = new HashMap<>();

    // 从缓存中获取数据
    public static String getDataFromCache(String key) {
        return cache.get(key);
    }

    // 从数据源中获取数据
    public static String getDataFromDataSource(String key) {
        return dataSource.get(key);
    }

    // 将数据存入缓存
    public static void putDataIntoCache(String key, String value) {
        cache.put(key, value);
    }

    // 删除缓存中的数据
    public static void deleteDataFromCache(String key) {
        cache.remove(key);
    }

    // 从数据源中加载数据,并存入缓存
    public static String loadData(String key) {
        String data = getDataFromDataSource(key);
        if (data != null) {
            putDataIntoCache(key, data);
        }
        return data;
    }

    public static void main(String[] args) {
        // 设置数据源
        dataSource.put("key1", "value1");
        dataSource.put("key2", "value2");

        // 从缓存中获取数据,如果不存在则从数据源中加载
        String data1 = getDataFromCache("key1");
        if (data1 == null) {
            data1 = loadData("key1");
        }
        System.out.println("Data1: " + data1);

        // 从缓存中获取数据,如果不存在则从数据源中加载
        String data2 = getDataFromCache("key2");
        if (data2 == null) {
            data2 = loadData("key2");
        }
        System.out.println("Data2: " + data2);

        // 删除缓存中的数据
        deleteDataFromCache("key1");

        // 从缓存中获取数据,如果不存在则从数据源中加载
        String data3 = getDataFromCache("key1");
        if (data3 == null) {
            data3 = loadData("key1");
        }
        System.out.println("Data3: " + data3);
    }
}

读写穿透(Read/Write Through Pattern)

读写穿透策略将 Redis/Memcached 视为数据存储的主要地方,也就是说将缓存充当原本的数据库,利用 Cache 服务负责将数据读取并写入数据库(MySQL、Oracle等)。

写操作流程

1)先查询缓存,缓存不存在,更新数据库

2)缓存存在,先更新缓存,利用 Cache 服务同步更新数据库。

读操作流程

1)从缓存读取数据,读取到返回

2)缓存读取不到,从数据库加载后写入缓存并返回。

和旁路缓存相反,读写穿透,主缓存从数据库。

异步缓存写入(Write Behind Pattern)

只更新缓存,不利用 Cache 服务更新数据库,可以利用消息队列,先存放要消费的信息,然后可以异步批量的更新数据库,一般不使用,但数据库的缓冲池机制是这种策略的一个实现,

适用场景:数据经常变化,一致性要求不高(可以延时同步),比如 PV、UV、点赞量。

其他补充

鱼聪明 AI 的回答:

鱼聪明 AI 地址:https://www.yucongming.com/

1)旁路缓存(Cache Aside)

1.1)优点

  • 简单易实现,适用于小规模系统。
  • 缓存数据不会过期,不会出现缓存雪崩。

1.2)缺点

  • 数据一致性问题,需要应用程序主动更新缓存。
  • 缓存数据可能过期,导致缓存击穿。

1.3)使用场景

  • 适用于读多写少的场景。
  • 数据更新频率不高,对数据实时性要求不高的场景。

2)读写穿透(Cache Through)

2.1)优点

  • 数据一致性较好,不会出现数据不一致的情况。
  • 缓存数据不会过期,不会出现缓存雪崩。

2.2)缺点

  • 需要保证数据源的可靠性和性能。
  • 对于大规模系统,可能增加数据源的压力。

2.3)使用场景

  • 适用于数据源更新频率高,对数据实时性要求高的场景。
  • 数据源具有较好的性能和可靠性。

3)异步缓存写入(Write Behind)

3.1)优点

  • 减少对数据源的频繁写入,提高性能。
  • 可以缓解瞬时写入压力,提高系统稳定性。

3.2)缺点

  • 数据一致性可能受影响,存在一定的数据丢失风险。
  • 需要额外的机制来处理数据更新失败的情况。

3.3)使用场景

  • 适用于写入频率高,对数据实时性要求不高的场景。
  • 对数据丢失一定容忍度的场景。

在实际应用中,根据系统的特点和需求,可以选择合适的缓存读写策略来提高系统性能和稳定性。

欢迎交流

在阅读完本篇文章后,你应该对 Redis 的三种缓存读写策略有了一定了解,一般采用第一个策略进行读写,其他两种策略了解即可,在文末有三个问题将会检验本章的学习,欢迎在评论区发表意见。

1)旁路缓存策略中,如何解决缓存数据过期和缓存击穿的问题?

2)读写穿透策略中,如何确保数据源的可靠性和性能,以及如何处理数据源故障的情况?

3)在实际应用中,如何选择合适的缓存读写策略,考虑到系统的特点和需求?

398.Redis 的五种基本数据类型及其优化技巧是什么?

引言:最近小 X 去面试,面试问了这样一个问题,Redis 的基本数据类型和它们的底层原理介绍一下,可以结合你的项目进行讲解。小 X 不知道这道题该从哪里下手,因为大多数同学只知道 5 种基本数据类型的名称,用过 2、3 种已经是不错的了,而对于底层原理基本一窍不通,本文就会详细地去讲解各种数据类型的一个优化手段以及各自的使用场景和版本迭代情况。

题目

探索 Redis 5 种基本数据类型,轻松掌握优化技巧!

推荐解析

5 种基本数据类型概览

1)String 字符串

2)List 列表

3)Set 集合

4)Hash 散列

5)Zset 有序集合

String 字符串

适用场景:缓存、计数器、分布式锁,图片等等。

底层原理:使用 SDS (简单动态字符串)。对比 C 语言的原生字符串,Redis 可以安全保存二进制数据(比如音频、图片、视频),并且可以 O(1) 获取字符串长度。可以动态扩容,动态增加内存,避免静态数组大小限制。杜绝缓冲区溢出问题,SDS 会检查内存是否足够。减少修改字符串的内存重分配,惰性空间释放+空间预分配策略。

Redis 3.2 以前

1
2
3
4
5
6
7
struct sdshdr {
    // 已经适用的长度
    unsigned int len;
    // 剩余长度
    unsigned int free;
    char buf[];
};

Redis 3.2 以后(为了避免 int 浪费空间)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/* Note: sdshdr5 is never used, we just access the flags byte directly.
 * However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

List 列表

适用场景:队列、栈、消息队列。

举个例子,实现用户最近浏览记录等功能,可以使用 List 来存储用户的浏览记录,并通过 lpush/ltrim 操作来实现最近浏览记录的管理。

Redis 的 List 采用双向链表支持反向查找和遍历。

Redis 3.2 以前 List 底层采用 LinkedList(双向链表)和 ZipList(压缩列表),Redis 3.2 后引入了 LinkedList 和 ZipList 的结合 QuickList。

Set 集合

适用场景:求交集,并集,适用与好友列表、投票、社交网络、网页 UV(单个IP 访问量,数据量大用 hyperLogLog)、抽奖系统、随机点名等等。

Set 特点无序,不重复。

举个例子,在社交网络平台中,用户可以添加好友、删除好友,查看好友列表等操作。这种场景下,可以使用 Redis 的 Set 集合来存储用户的好友列表。通过使用 Redis 的 Set 集合来存储用户的好友关系,可以实现快速的添加、删除好友操作,并且支持高效的好友列表查询和好友关系分析。同时,Redis 的 Set 集合还支持去重功能,保证每个用户的好友列表中不会出现重复的好友。

Set 底层实现是由哈希表或者整数集合实现的。

1)如果集合的元素都是整数,并且元素个数小于 512 (默认值,可以自定义设置),Redis 会使用**整数集合(intSet)**作为 Set 类型的底层数据结构,主要目的为了减少内存空间消耗。

2)如果集合元素不满足上述条件,Redis 使用哈希表作为 Set 类型的底层数据结构。

注意事项:大数据量下作交集、差集、并集会导致 Redis 实例阻塞,防范大 Key 问题。

如果是集群模式,建议防止主库在大数据库执行大 Key 被阻塞,选择一个从库完成聚合统计,或者把数据返回客户端,由客户端来完成聚合。

Hash 哈希

哈希由键值对组成,支持添加、删除、查找、求交集、求并集等操作。

适用场景:存储对象的多个属性信息,存储用户信息,购物车等场景。

举个例子,假设我们需要存储每个用户的姓名、年龄和邮箱地址,可以使用 Hash 数据结构来存储这些信息。在 Redis 中,可以使用一个 Hash 对象来表示每个用户的信息,其中 Hash 的键是用户的唯一标识,如用户ID,键值对则存储用户的各个字段信息。

例如

用户ID为 1 的用户信息:姓名:Alice 年龄:25 邮箱地址:alice@example.com

1
2
3
HSET user:1 name Alice
HSET user:1 age 25
HSET user:1 email alice@example.com

Hash 底层实现由压缩列表(ZipList 或者哈希表 Dict)实现。

当哈希类型元素小于 512 个,并且所有元素的值大小(小于64个字节),Redis 使用压缩列表作为底层数据结构。

如果不满足上述条件,采用哈希表作为底层数据结构。

Zset 有序集合

Zset 有序集合相比于 Set 多了一个排序属性 Score 分值,对于有序集合 Zset 来说,每个元素有两个值组成,一个是元素值,一个是排序值(分值)。

使用场景

1)排行榜:可以将用户的分数作为 Zset 中的分数,用户的 ID 作为成员,这样可以根据用户的分数来对用户进行排名。

2)带权重的任务调度:可以将任务的执行时间作为 Zset 中的分数,任务的 ID 作为成员,这样可以按照任务的执行时间对任务进行排序并调度执行。

3)范围查询:可以根据分数的范围来获取一定范围内的成员,如获取分数在某个区间的用户或任务。

举个例子:假设有一个学生社团组织,需要记录学生参加社团活动的积分,并根据积分对学生进行排名。可以使用 Zset 数据结构来存储学生的积分信息,学生的学号作为成员,积分作为分数。

1
2
3
ZADD studentscore 80 1001
ZADD studentscore 75 1002
ZADD studentscore 90 1003

获取学生积分排名

1
ZREVRANGE studentscore 0 -1 WITHSCORES

Zset 类型底层实现由压缩列表(ZipList)或者跳表(SkipList)

如果有序集合的元素小于 128 个,并且每个元素的值小于 64 字节,采用压缩列表作为 Zset 类型的底层数据结构。

如果不满足上述条件,采用跳表(加快查询效率)作为 Zset 类型的底层数据结构。

总结

1)String(字符串):最简单的数据类型,常用于存储文本、数字等简单数据,适用于缓存、计数器、状态标记等场景。

2)List(列表):有序的字符串元素集合,支持头部和尾部的插入、删除操作,适用于消息队列、最新消息列表等场景。

3)Set(集合):不重复的字符串元素集合,支持交集、并集、差集等操作,适用于去重、共同好友、排重等场景。

4)Hash(哈希):键值对集合,适用于存储对象属性、用户信息、配置信息等场景。

5)Zset(有序集合):每个元素都关联一个分数,支持按分数排序,适用于排行榜、带权重的任务调度、范围查询等场景。

其他补充

1. 字符串 (String)

  • 概述:字符串是Redis中最简单的数据类型,可以存储文本或二进制数据,最大支持512MB。
  • 底层原理:Redis内部使用简单的字节数组来存储字符串数据。由于其简单性,字符串在Redis中的操作都非常高效。
  • 使用场景:适用于缓存数据、计数器、存储短小的文本等场景。

2. 列表 (List)

  • 概述:列表是一个有序的字符串集合,可以包含重复的元素。
  • 底层原理:Redis的列表底层使用双向链表实现,支持在两端插入和删除元素,因此在读写两端的操作效率较高。
  • 使用场景:适用于实现消息队列、发布订阅系统、实现简单的日志系统等场景。

3. 集合 (Set)

  • 概述:集合是一组唯一的字符串集合,不允许重复元素。
  • 底层原理:Redis的集合底层使用哈希表实现,因此添加、删除、查找元素的时间复杂度都是O(1)。
  • 使用场景:适用于实现好友关系、标签系统、数据去重等场景。

4. 哈希表 (Hash)

  • 概述:哈希表是一种键值对的集合,类似于Java中的Map,其中的值也是一个键值对。
  • 底层原理:Redis的哈希表底层使用哈希表实现,每个哈希表可以存储多个键值对,对于小哈希表,Redis会使用压缩列表来存储键值对。
  • 使用场景:适用于存储对象的属性、缓存对象等场景。

5. 有序集合 (Sorted Set)

  • 概述:有序集合是一种有序的字符串集合,每个元素都会关联一个分数,通过分数进行排序。
  • 底层原理:Redis的有序集合底层使用跳跃表和哈希表实现,其中跳跃表用于保持元素的有序性,哈希表用于维护元素与分数之间的映射关系。
  • 使用场景:适用于实现排行榜、范围查询等场景。

底层原理总结

  • Redis的基本数据类型都是在内存中存储的,因此具有高效的读写性能。
  • Redis通过使用不同的数据结构来实现不同的数据类型,以满足不同的需求。

使用场景总结

  • 字符串适用于存储简单的键值对数据、计数器等。
  • 列表适用于实现简单的消息队列、发布订阅系统等。
  • 集合适用于实现好友关系、数据去重等。
  • 哈希表适用于存储对象的属性、缓存对象等。
  • 有序集合适用于实现排行榜、范围查询等。

总的来说,Redis提供了丰富的数据类型和高效的底层实现,能够满足各种不同的应用场景需求。

401.Redis 中的跳表是什么?你了解多少?

引言:你知道 Redis 的 Zset 为什么要用跳表,而不用红黑树或者 B+ 树呢?这是一个经常会被问到的问题,和数据库为什么要设计成 B+ 树,而不是红黑树和平衡树有异曲同工之妙,本文将用带大家展示跳表在 Redis 中的运用。

推荐解析

Zset 的两种底层实现

首先我们需要知道跳表并不是 Zset 的唯一实现,它是可选项,当满足条件时候,就会使用跳表进行效率的提升。

1)Zset 键值对数量少于 128 个

2)每个元素的长度小于 64 字节

如果同时满足上面的两个条件,此时就会采用 ZipList,如果不满足就会采用 SkipList。

普通链表 VS 跳表

1)结构

1.1)有序链表:由一个个节点组成,每个节点包含数据和指向下一个节点的指针。

1.2)跳表:由多层级组成,每一层都是一个有序链表,每一层的节点指向下一层的节点。

2)时间复杂度

2.1)有序链表:插入、删除和查找的平均时间复杂度为O(n),其中n是链表的长度。

2.2)跳表:插入、删除和查找的平均时间复杂度为O(log n),其中n是元素的个数。

3)空间复杂度

3.1)有序链表:空间复杂度为O(n),其中n是链表的长度。

3.2)跳表:空间复杂度为O(n log n),其中n是元素的个数。

4)插入和删除操作

4.1)有序链表:插入和删除操作需要遍历链表找到插入或删除位置,时间复杂度较高。

4.2)跳表:通过建立多层级索引,插入和删除操作只需更新索引节点,时间复杂度较低。

5)查找操作

5.1)有序链表:查找操作需要遍历整个链表,时间复杂度较高。

5.2)跳表:通过多层级索引,可以快速跳跃到目标位置,查找操作时间复杂度较低。

理想清理,每一层的索引是下一层元素个数的二分之一。

一级索引:16 二级索引:8 三级索引:4 四级索引:2

递推公式可得:H = log2^n-1

理论上 Redis 的一个类型可以放大约 40 亿个实例,2^32-1个。因此索引高度不能超过 32。

每次创建一个新跳跃表节点的时候,程序都会根据幂次定律(zslRandomLevel,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。 节点层高确定之后便不会在修改。

1
2
3
4
5
6
7
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

根据概率论,计算期望,当 p =0.25,期望层高为 1/(1-0.25)=1.33。

红黑树 VS 跳表

1)结构:红黑树是一种二叉搜索树,而跳表是一种多层级的有序链表。

2)平衡性:红黑树通过颜色和规则来保持平衡,而跳表通过多层级索引来提高查询效率。

3)动态性:红黑树和跳表都支持高效的动态操作,包括插入、删除和查找。

4)空间复杂度:红黑树和跳表的空间复杂度都为O(n),但是跳表的额外索引层级会增加一些额外的空间开销。

主要原因:跳表实现比较简单,并且对于区间查找的效率比较高,红黑树的高度不好控制。

B+ 树 VS 跳表

B+树

1)可以是多叉树(区别二叉树),每个节点可包含多个子节点,减少树的高度,查询效率较高。

2)叶子节点存放真正的 Value,非叶子节点存放多个 Key。

3)平衡性强,高度差控制的不大。

4)因为叶子节点是有一条链表相连的,因此范围查询的效率很高。

主要原因:因为 B+ 树是给数据库用的,数据库存放的数据量比较大,而缓存不需要存储大量数据,跳表不需要节点分裂和合并。跳表会更节省内存,更容易实现和进行 Debug。

跳表插入过程

1)首先定义了一个函数 zslRandomLevel(),用于生成随机的插入元素层高,该函数会根据概率 ZSKIPLIST_P 来确定插入元素的层高,同时限制最大层高为 ZSKIPLIST_MAXLEVEL 。

2)在 zslInsert() 函数中,首先声明了需要更新的节点数组 update[] 和 rank[] ,以及变量 i 和 level 。然后从跳表的头节点开始向下遍历,找到插入元素的位置,并记录每层的跨越节点数量 rank[] 。

3)在获取到插入元素的位置后,判断插入元素的随机层高是否大于当前跳表的层高,如果是则需要补全需要更新的节点,并更新跳表的层高。

4)接着创建新节点 x,并根据随机层高分层插入节点,同时更新同层前一节点的步长信息。最后更新新增节点未涉及层节点的步长信息,以及更新跳表的相关信息。

5)最后将插入节点的前后指针指向正确的节点,并增加跳表的长度。最终返回插入的节点 x 。整个过程保证了插入操作的正确性和效率。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
int zslRandomLevel(void) { 
    int level = 1;
    // (random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF) 概率为1/4
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    // update存放需要更新的节点
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            rank[i] += x->level[i].span;
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    level = zslRandomLevel();
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            rank[i] = 0;
            update[i] = zsl->header;
            update[i]->level[i].span = zsl->length;
        }
        zsl->level = level;
    }
    x = zslCreateNode(level,score,ele);
    for (i = 0; i < level; i++) {
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }
    /* increment span for untouched levels */
    for (i = level; i < zsl->level; i++) {
        update[i]->level[i].span++;
    }

    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    zsl->length++;
    return x;
}

其他补充

  1. 压缩列表:当有序集合元素数量较少时,Redis会使用压缩列表进行存储。压缩列表是一种紧凑的线性结构,可以节省内存空间。每个元素在压缩列表中占用连续的内存空间,元素之间没有额外的指针等开销。
  2. 跳表:当有序集合元素数量较多时,Redis会使用跳表进行存储。跳表是一种有序的数据结构,可以快速地进行查找、插入和删除操作。跳表通过在每个级别添加索引节点来加速查找元素的过程,使得查找的时间复杂度可以达到O(log n)。

实战场景:假设我们需要存储一个社交平台的用户关注列表,其中每个用户关注的人数不定,但是需要按照关注人数的多少进行排序。这时可以使用有序集合来存储用户ID和对应的关注人数,通过有序集合的排序功能可以快速地获取关注人数最多的用户。

跳表的优点:

  1. 查询效率高:跳表通过添加索引节点来加速查找元素的过程,使得查询的时间复杂度可以达到O(log n),效率比较高。
  2. 支持范围查找:由于跳表是有序的数据结构,所以可以支持范围查找,例如查找某个范围内的元素。
  3. 空间利用率高:跳表通过添加索引节点来加速查找,相比于普通链表来说,空间利用率更高,内存占用更少。

总的来说,跳表在有序集合这种需要快速查找、有序存储的场景下具有明显的优势。

CSDN 某同学的回答

跳表(skiplist)是一种有序的数据结构,它可以通过维护指向每个节点中其他节点的多个指针来快速访问节点。简单来说,跳表是通过向双向链接列添加多个索引而形成的。

与双向链接列表相比,它支持快速搜索、更新和删除,因此适用于需求灵活的场景。

对于双向链表,即使存储在链接列表中的数据是有序的,如果我们想在其中找到某个数据,它也只能从头到尾遍历链接列表,反之亦然。这样搜索效率将非常低,时间复杂度将非常高,达到O(n)。 跳表在搜索某个数据时,首先在索引中找到一个较大的范围,然后再放到原始链接列表中进行精确搜索。因为在添加一层索引后,搜索节点的次数减少了,因此搜索效率大大提高,是典型的空间换时间。

当链表长度相对较大时,索引构建效率将显著提高。第一级链表不是单向链表,而是有序的双向链表。为了以相反的顺序获得范围中的元素。

634.Redis通常应用于哪些场景?

回答重点

1)缓存:Redis 最常用的场景是作为缓存层,以减少数据库的负载,提高数据读取速度。例如,常用的用户会话数据和页面渲染结果可以存储在 Redis 中。

2)实时系统:Redis 支持快速的数据写入和读取,非常适合用于实时分析,如网站点击统计、实时排行榜等。

3)消息队列:利用 Redis 的 List 和 Pub/Sub 功能,可以实现轻量级的消息队列,适用于任务处理和异步消息传递。

4)分布式锁:Redis 可以用作分布式锁的实现,确保在分布式系统中资源的安全访问,避免竟态条件。

5)计数器:Redis 的原子性操作非常适合用作计数器。例如,可以使用 Redis 来统计页面访问量、点赞数、评论数等。通过 INCR 命令可以轻松实现高效的计数。

image-20250525230312619-1748185398888-1.png
Redis用途

扩展知识

Redis 的用途有很多,这里再介绍下回答重点提到的几个常见的场景,更多场景需要结合项目去进行拓展。

1.Redis 缓存

因为 Redis 是基于内存的,其读写速度比 MySQL 基于磁盘的方式要快很多,所以其作为热点数据的缓存是非常合适的。使用 Redis 缓存可以极大地提高应用的响应速度和吞吐量

举一个例子,如下图所示,用户访问 Web 服务的时候,可以先查询缓存,如果缓存未命中的话,去查询数据库(这里建议去查询数据库的时候加铁,防止多个请求同时打到数据库,导致数据库查询效率不高),如果数据库中有对应的数据,则写入 Redis 缓存中,并返回给用户,如果数据库没有对应的数据的话,可以写入 null 值到缓存中,应对缓存穿透问题。

image-20250525230514350.png
Redis缓存

2.分布式锁

本地锁(synchronized、lock)在很多时候已经满足不了我们的需求,特别是现在大部分企业都使用了微服务框架,不同实例之间的锁需要依赖外部系统进行一致性锁定,因此就需要用上分布式锁。Redis 就是很好的一个外部系统,它基于缓存使得加锁非常高效,天然的过期机制可以很好地避免死锁的发生,且配合 redission 这种封装好的类库,使用起来也非常简便:

image-20250525230639758.png
Redis分布式锁

3.计数器

Redis 由于其单线程执行命令的特性,实现计数器非常方便,不会有锁的竞争。像文章的点赞数量就可以使用 Redis 实现。

再如一些海量数据的统计,例如大网站的访问统计、日活月活等,适合使用 Redis 提供的高级数据结构 HyperLogLog。

它基于基数估算算法实现。优点就是所需的内存不会随着集合的大小而改变,因此很适合大规模数据集统计的场景,不过它的统计值是不精确的,有一定的误差,但是在海量数据场景,这些误差一般是可以接受的。

4.消息队列

在一些简单场景,也可以利用 Redis 来实现消息队列功能。例如使用列表的 lpush 实现消息的发布,rpop 实现消息的消费。也可以使用Redis 5.0 之后引入的 stream 这个数据结构来实现消息功能。但是要注意,用 Redis 来实现消息队列肯定比不上正常的消息队列中间件,例如无法保证消息的持久性,即使有 aof 和 rdb 也无法保证消息一定不会丢。

在大型系统或正常的业务场景下,如果要使用消息队列还是得用 RocketMQ、Kafka等,不推荐使用 Redis 来实现。

5.实时系统的构建

由于 Redis 高性能的特性,其经常被用于构建实时系统,比如抽奖、秒杀等,最常见的还有排行榜的实现,其可以使用 Redis 的 Zset 数据结构,根据用户的分数、时间等参数构建一个实时的排行榜。

主要就列举这些了,更多的场景可以结合你的项目去拓展,比如登录鉴权、限流、会话等,都是可以的。

635.Redis 为什么这么快?

回答重点

主要有 3 个方面的原因,分别是存储方式、优秀的线程模型以及 IO 模型、高效的数据结构。

  • Redis 将数据存储在内存中,提供快速的读写速度,相比于传统的磁盘数据库,内存访问速度快得多。
  • Redis 使用单线程事件驱动模型结合 I/O 多路复用,避免了多线程上下文切换和竞争条件,提高了并发处理效率。
  • Redis 提供多种高效的数据结构(如字符串、哈希、列表、集合等),这些结构经过优化,能够快速完成各种操作

扩展知识

存储方式

Redis 的存储是基于内存的,直接访问内存的速度是远远大于访问磁盘的速度的。

image.png

一般情况下,计算机访问一次 SSD 磁盘的时间大概是 50 ~ 150 微秒,如果是传统硬盘的话,需要的时间会更长,可能需要 1 ~ 10 毫秒,而你访问一次内存,其需要的时间大概是 120 纳秒。由此可见,其访问的速度差了快一千倍左右。

除了一些场景,比如说持久化,Redis 很少需要与磁盘进行交互,大多数时候 Redis 的读写是基于内存的,因此其效率较高。

优秀的线程模型以及 IO 模型

Redis 使用单个主线程来执行命令,不需要进行线程切换,避免了上下文切换带来的性能开销,大大提高了 Redis 的运行效率和响应速度。

Redis 采用了 I/O 多路复用技术,实现了单个线程同时处理多个客户端连接的能力,从而提高 Redis 的并发能力。

不过 Redis 并不是一直都是单线程的,自 4.0 开始,Redis 就引入了 Unlink 这类命令,用于异步执行删除等操作,还有在 6.0 之后,Redis 为了进一步提升 I/O 的性能,引入了多线程的机制,利用多线程的机制并发处理网络请求,从而减少 Redis 由于网络 I/O 等待造成的影响。

image.png

高效的数据结构

Redis 本身提供了丰富的数据结构,比如字符串、哈希、Zset 等,这些数据结构大多操作的时间复杂度都为 O(1)。

image.png

备注:答案中的图来自 ByteByteGo:https://blog.bytebytego.com/p/why-is-redis-so-fast

五种 I/O 模型

Select、Poll、Epoll

Redis 中常见的数据类型(数据结构)

636.为什么 Redis 设计为单线程?6.0 版本为何引入多线程?

回答重点

单线程设计原因

  1. Redis 的操作是基于内存的,其大多数操作的性能瓶颈主要不是 CPU 导致的
  2. 使用单线程模型,代码简便的同时也减少了线程上下文切换带来的性能开销
  3. Redis 在单线程的情况下,使用 I/O 多路复用模型就可以提高 Redis 的 I/O 利用率了

6.0 版本引入多线程的原因

  1. 随着数据规模的增长、请求量的增多,Redis 的执行瓶颈主要在于⽹络 I/O。引入多线程处理可以提高网络 I/O处理速度。

扩展知识

我们所说的 Redis 单线程,主要指的是 Redis 网络 I/O 和键值对读写这些操作是由一个线程完成的。(持久化、集群等机制其实是有后台线程执行的)

不过 Redis 并不是一直都单线程的,在 4.0 之后就开始引入了多线程指令,6.0 之后便正式引入了多线程的机制,不过 这里的多线程其只是针对网络请求过程使用多线程,其对于数据读写命令的处理依旧是单线程的

为什么 Redis 前期不使用多线程的方式,等到 6.0 却又引入呢?

主要是因为我们对 Redis 的性能有了更高的要求,因为随着业务愈加复杂,公司需要的 QPS 就越高了,为了提升 QPS ,最直接的做法就是搭建 Redis 的集群,即提高 Redis 的机器数,但是这种做法的资源消耗是巨大的。

而 Redis 单线程执行命令的性能瓶颈在网络 I/O ,虽然它采用了多路复用技术,但 I/O 多路复用模型本质还是同步I/O

Snipaste_2024-06-22_19-13-28.jpg

从上面这个图中我们可以看到,I/O 多路复用在处理网络请求的时候,其调用 select(或是 epoll 等)后,如果来数据了,那么将数据从内核拷贝到用户空间这一步是同步的(关于 I/O 场景,阻塞、同步等名词意义请看扩展知识),即用户线程需要等待数据拷贝完成(在 redis 场景就是无法处理命令了),如果并发量很高的话,这个过程可能会成为瓶颈

综上所示,我们可以发现,多路复用+单线程的设计并不能很好地解决网络 I/O 瓶颈的问题,这个时候就可以考虑利用 CPU 的多核优势,即利用多线程处理网络请求的方式来提高效率,然后对于读写命令, Redis 依旧采用单线程命令。

Redis 引入多线程之后,有没有带来什么线程安全问题呢

没有,因为 Redis 6.0 只有针对网络请求模块采用的是多线程,对于读写命令部分还是采用单线程,所以所谓的线程安全问题就不存在了。

Redis 6.0 的多线程默认是禁⽤的,只使⽤主线程,因为大部分公司并发量实际上还是用不上这个。

如果要开启需要配置 io-threads-do-reads参数为 yes

I/O 模型

五种 I/O 模型

同步、异步、阻塞、非阻塞的I/O的区别?

637.Redis中常见的数据类型有哪些?

回答重点

Redis 常见的数据结构主要有五种,这五种类型分别为:String(字符串)、List(列表)、Hash、Set(集合)、Zset(有序集合,也叫sorted set)。

String

字符串是 Redis 中最基本的数据类型,可以存储任何类型的数据,包括文本、数字和二进制数据。它的最大长度为 512MB。

使用场景:

  • 缓存:存储临时数据,如用户会话、页面缓存。
  • 计数器:用于统计访问量、点赞数等,通过原子操作增加或减少。

Hash

哈希是一个键值对集合,适合存储对象的属性。Redis内部使用哈希表实现,适合小规模数据。

使用场景:

  • 商品详情:存储商品的各个属性,方便快速检索。

List

列表是有序的字符串集合,支持从两端推入和弹出元素,底层实现为双向链表。

使用场景:

  • 消息队列:用于简单任务调度、消息传递等场景,通过LPUSH 和 RPOP 操作实现生产者消费者模式。
  • 历史记录:存储用户操作的历史记录,便于快速访问。

Set

集合是无序且不重复的字符串集合,使用哈希表实现,支持快速查找和去重操作。

使用场景:

  • 标签系统:存储用户的兴趣标签,避免重复。
  • 唯一用户集合:记录访问过某个页面的唯一用户,方便进行分析。

Sorted Set

有序集合类似于集合,但每个元素都有一个分数(score),用于排序。底层使用跳表实现,支持快速的范围查询。

使用场景:

  • 排行榜:存储用户分数,实现实时排行榜。
  • 任务调度:根据任务的优先级进行排序,方便调度执行。

image-20250525231557173.png
Redis基本数据类型

扩展知识:四种高级数据类型

随着 Redis 版本的更新,后面又增加 BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。

BitMap

BitMap 是一种以位为单位存储数据的高效方式,适合用来表示布尔值(如存在性、状态等)。每个 bit 可以表示一个状态(0或 1),使用空间少且操作快速。 使用示例:假设要统计每天用户的在线状态,可以用 Bitmap 记录每个用户是否在线:

1
2
SETBIT user:online:2024-09-27 12345 1  #用户 ID 12345 在 2024-09-27 在线
GETBIT user:online:2024-09-27 12345    #获取用户 ID 12345 在该日期的在线状态

HyperLogLog

HyperLogLog 是一种概率性数据结构,主要用于估算基数(不同元素的数量),内存占用固定,适合处理大规模数据的去重和计数。

使用示例:假设要估算访问网站的独立用户数量:

1
2
PFADD unique:visitors userl user2 user3  # 添加用户 ID
PFCOUNT unique:visitors                  # 估算独立用户数量

GEO

GEO 是 Redis 提供的一种用于存储地理位置信息的数据结构,可以存储经纬度信息并支持空间查询,例如计算距离和获取范围内的坐标。

使用示例:假设要存储城市的位置并查找距离某个城市在一定范围内的其他城市:

1
2
3
4
GEOADD cities 13.361389 38.115556 "Palermo"  #添加城市
GEOADD cities 15.087269 37.502669 "Catania"  #添加城市
GEODIST cities "Palermo" "Catania" "km"      #计算两个城市之间的距离
GEORADIUs cities 15.0 37.5 100 km            #查找指定范围内的城市

Stream

Stream 是 Redis 提供的一种日志数据结构,适合于存储时间序列数据或消息流。支持高效的消息生产和消费模式,具有持久性和序列化特性

使用示例:假设要存储传感器数据流,可以使用 Stream 进行数据插入和消费

1
2
3
XADD sensor:data * temperature 22.5 humidity60 # 向 Stream 添加传感器数据
XRANGE sensor:data - +                         # 获取 stream 中的所有数据
XREAD COUNT 10 STREAMs sensor:data $           # 读取新的传感器数据

Redis9 种常见的基本数据类型应用场景汇总

  • String:缓存对象、计数器、分布式锁、分布式 session 等
  • List:阻塞队列、消息队列(但是有两个问题:1.生产者需要自行实现全局唯一ID;2.不能以消费组形式消费数据)等
  • Hash:缓存对象、购物车等
  • Set:集合聚合计算(并集、交集、差集)的场景,如点赞、共同关注、收藏等
  • Zset:最典型的就是排行榜,这个也是面试中经常问到的点
  • BitMap(2.2 版新增):主要有0和1两种状态,可以用于签到统计、用户登录态判断等
  • HyperLogLog(2.8 版新增):海量数据基数统计的场景,有一定的误差,可以根据场景选择使用,常用于网页 PV、UV 的统计
  • GEO(3.2 版新增):存储地理位置信息的场景,比如说百度地图、高德地图、附近的人等
  • Stream(5.0 版新增):这个主要就是消息队列了,可以实现一个简单的消息,其相比 list 多了两个特性,分别是自动生成全局唯一消息ID以及支持以消费组形式消费数据(同一个消息可被分发给多个单消费者和消费者组),相比 pub/sub 它是可以被持久化。

638.Redis中跳表的实现原理是什么?

回答重点

跳表主要是通过 多层链表来实现,底层链表保存所有元素,而每一层链表都是下一层的子集。

  • 插入时:首先从最高层开始查找插入位置,然后随机决定新节点的层数,最后在相应的每一层中插入节点并更新指针。
  • 删除时:同样从最高层开始查找要删除的节点,并在各层中更新指针,以保持跳表的结构。
  • 查找时:从最高层开始,逐层向下,直到找到目标元素或确定元素不存在。查找效率高,时间复杂度为 O(logn)。

扩展知识

跳表原理详细解析

跳表,一句话概括:就是一个多层索引的链表,每一层索引的元素在最底层的链表中可以找到的元素。如下图所示,这就是一个简单的跳表实现了,每个颜色代表一层,绿色的就是链表的最底层了。

image-20250526001305871.png
跳表结构

接下来我们通过查询和添加元素来了解其功能流程:

1)查询元素:这里我们与传统的链表进行对比,来了解跳表查询的高效。

假设我们要查找 50 这个元素,如果通过传统链表的话(看最底层绿色的查询路线),需要查找 4 次,查找的时间复杂度为 O(n)。

但如果使用跳表的话,其只需要从最上面的 10 开始,首先跳到 40,发现目标元素比 40 大,然后对比后一个元素比 70 小,于是就到下一层进行查找,然后 40 的下一个 50 刚好符合目标,就直接返回就可以了,这个过程的跳转次数是 3 次,即 10 -> 40(顶层)-> 40(第二层)-> 50(第二层),其流程如下图所示:

image-20250526001359839.png
跳表流程图

跳表的平均时间查询复杂度是 O(logn),最差的时间复杂度是 O(n)。

2)插入元素:我们插入一条 score 为 48 的数据

  • 先需要定位到第一个比 score 大的数据。如图所示,一下子就可以定位到 50 了,这里和查询的过程(上文所示)是一样的。
  • 在定位到对应节点之后,具体是在当前节点的前驱数据还是增加一个层级这个是随机的,这里假设设置为 2 层。
  • 定位层级后,再将每一层的链表节点进行补齐,就是在 40 与 50 之间插入一个新的链表节点 48,插入过程与链表插入是一样的。 最终实现的效果如下图所示:

Redis 中的跳表实现解析

Redis 的跳表相对于普通的跳表多了一个回退指针,且 score 可以重复

我们先来看一下 Redis 中关于跳表实现的源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
typedef struct zskiplistNode {
    //set 对象的元素值
    robj *ele;
    //元素权重值
    double score;
    //后退指针
    struct zskiplistNode *backward;

    //节点的 level 数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

这里我们分析一下每个字段的含义:

  • ele:这里用到了 Redis 字符串底层的一个实现 sds,其主要作用是用来存储数据。
  • score:节点的分数,double 即浮点型数据。
  • backward:我们可以看到其是 zskiplistNode 结构体指针类型,即代表着向前一个跳表节点
  • level:这个就是 zskiplistNode 的结构体数组了,数组的索引代表着层级索引,这里注意与 hashtable 中的结构进行区分,那个使用的是联合体,一个是 forward,其代表下一个跳转的跳表节点,注意一个点,其是跳转到同一层,span 主要作用代表距离下一个节点的步数。

到这里分析就差不多了,为了方便大家理解,这里放一个相关的图,下图的红色箭头就代表回退指针。

Redis 跳表的实现大致如下图所示:

image-20250526001527970.png
跳表实现图示

补充插入的随机层级

新插入的节点位于哪一层在 Redis 中是采用随机的概率函数来决定的。

(以下源码来自 redis7.0)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist p = 1/4 */

/* Returns a random level for the new skiplist node we are going to create.
 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
 * (both inclusive), with a power-law-like distribution where higher
 * levels are less likely to be returned. */
int zslRandomLevel(void) {
    static const int threshold = ZSKIPLIST_P*ZSKIPLIST_MAXLEVEL;
    int level = 1;
    while (random() < threshold) {
        level += 1;
        return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
    }
    return level;
}

通过调用 zslRandomLevel 方法来决定新插入的节点所在的层数(level),可以看到 level 从 1 开始,通过 while 循环,当产生的随机数小于 RAND_MAX 的最大值的 0.25 倍时 level + 1,即有 25% 的概率累加 level,反之退出循环。

  • 节点被分配到第 1 层的概率为 0.75
  • 节点被分配到第 2 层的概率为 0.25
  • 节点被分配到第 3 层的概率为 0.25 * 0.25

以此类推,节点被分配到第 n 层的概率为 0.25^(n-1)。

所以跳表每一个节点能否新增一层的概率是 25%(最多的层数在 Redis 5.0 中是 64 层,Redis 7.0 中是 32 层)。

为什么 Redis 跳表实现多了一个回退指针(前驱指针)

回退指针主要是为了提高跳表的操作效率和灵活性

假如删除节点时,通过前驱指针可以在一次遍历中找到并记录所有关联的前驱节点,无需在变更时再次查找前驱节点。这种设计避免了重复查找过程,简化了操作逻辑,大幅提高了删除的执行效率。

尤其是在频繁插入和删除的场景中,回退指针减少了节点之间指针的更新复杂度,提升性能。

639.Redis 的 hash 是什么?

回答重点

Redis 的 hash 是一种键值对集合,可以将多个字段和值存储在同一个键中,便于管理一些关联数据。 特点如下:

  1. 适合存储小数据,使用哈希表实现,能够在内存中高效存储和操作。
  2. 支持快速的字段操作(如增、删、改、查),非常适合存储对象的属性。

扩展知识

hash 常用命令

HSET:设置 hash 中指定字段的值。

1
HSET user:1001 name "mianshiya"

HGET:获取 hash 中指定字段的值。

1
HGET user:1001 name

HMSET:一次性设置多个字段的值。

1
HMSET user:1001 name "mianshiya" age "18" city "shanghai"

HMGET:一次性获取多个字段的值。

1
HMGET user:1001 name age

HDEL:删除 hash 中的一个或多个字段。

1
HDEL user:1001 age

HINCRBY:为 hash 中的字段加上一个整数值。

1
HINCRBY user:1001 age 1

Hash 底层实现解析

Hash 是 Redis 中的一种数据基础数据结构,类似于数据结构中的哈希表,一个 Hash 可以存储 2 的 32 次方 - 1 个键值对(约 40 亿)。底层结构需要分成两个情况:

  • Redis 6 及之前,Hash 的底层是压缩列表加上哈希表的数据结构(ziplist + hashtable)
  • Redis 7 之后,Hash 的底层是紧凑列表(Listpack) 加上哈希表的数据结构(Listpack + hashtable)

ziplist 和 listpack 查找 key 的效率是类似的,时间复杂度都是 O(n),其主要区别就在于 listpack 解决了 ziplist 的级联更新问题。

那么 hash 在什么时候使用 ziplist 和 listpack,什么时候使用 Hashtable 呢?

Redis 内有两个值,分别是 hash-max-ziplist-entrieshash-max-listpack-entries)以及 hash-max-ziplist-valuehash-max-listpack-value),即 Hash 类型键的字段个数(默认512)以及每个字段名和字段值的长度(默认64)。

redis 7.0 为了兼容早期的版本,并没有把 ziplist 相关的值删掉。

Snipaste_2024-05-15_21-42-23.jpg

当 hash 小于这两个值的时候,会使用 listpack 或者 ziplist 进行存储。

大于这两个值的时候会使用 hashtable 进行存储。

这里需要注意一个点,在使用 hashtable 结构之后,就不会再退化成 ziplist 或 listpack,之后都是使用 hashtable 进行存储。

这里需要注意,这两个值是可以修改的。如下图所示,将其值修改为 4399 以及 2024 并且查询后效果如下图所示:

Snipaste_2024-05-15_21-41-14.jpg

聊聊 Redis 中的 Hashtable?

面试官可能会细问 Hashtable 相关的知识点。

Hashtable 就是哈希表实现,查询时间复杂度为 O(1),效率非常快。

看下 HashTable 的结构:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
typedef struct dictht {
    //哈希表数组
    dictEntry **table;
    //哈希表大小
    unsigned long size;  
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希表已有的节点数量
    unsigned long used;
} dictht;

dictht 一共有 4 个字段:

  • table:哈希表实现存储元素的结构,可以看成是哈希节点(dictEntry)组成的数组。
  • size:表示哈希表的大小。
  • sizemask:这个是指哈希表大小的掩码,它的值永远等于 size-1,这个属性和哈希值一起约定了哈希节点所处的哈希表的位置,索引的值 index = hash(哈希值) & sizemask。
  • used:表示已经使用的节点数量。
Snipaste_2024-05-15_22-27-32.jpg

我们再看下哈希节点(dictEntry)的组成,它主要由三个部分组成,分别为 key,value 以及指向下一个哈希节点的指针,其源码中结构体的定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
typedef struct dictEntry {
    //键值对中的键
    void *key;
  
    //键值对中的值
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;

结合以上图片,我们可以发现,哈希节点中的 value 值是由一个联合体组成的。因此,这个值可以是指向实际值的指针、64位无符号整数、64 为整数以及 double 的值。

这样设计的主要目的就是为了节省 Redis 的内存空间,当值是整数或者浮点数的时候,可以将值的数据存在哈希节点中,不需要使用一个指针指向实际的值,从一定程度上节省了空间。

渐进式 rehash

还需要了解另一个面试重点,Redis 的渐进式 rehash。

从字面意思上来说,就是一点点地扩容,而不是直接一次性完成扩容

我们再来看这个图:

wecom-temp-170726-8d24749bd759fb281def9afc0a25e3f2.png

dict 有两个 dictht 组成,为什么需要 2 个哈希表呢?主要原因就是为了实现渐进式。

在平时,插入数据的时候,所有的数据都会写入 ht[0] 即哈希表 1,ht [1] 哈希表 2 此时就是一张没有分配空间的空表。

但是随着数据越来越多,当 dict 的空间不够的时候,就会触发扩容条件,其扩容流程主要分为三步:

1)首先,为哈希表 2 即分配空间。新表的大小是第一个大于等于原表 2 倍 used 的 2 次方幂。举个例子,如果原表即哈希表 1 的值是 1024,那个其扩容之后的新表大小就是 2048。

分配好空间之后,此时 dict 就有了两个哈希表了,然后此时字典的 rehashidx 即 rehash 索引的值从 -1 暂时变成 0 ,然后便开始数据转移操作。

2)数据开始实现转移。每次对 hash 进行增删改查操作,都会将当前 rehashidx 的数据从在哈希表 1 迁移到 2 上,然后 rehashidx + 1,所以迁移的过程是分多次、渐进式地完成

注意:插入数据会直接插入到 2 表中。

3)随着操作不断执行,最终哈希表 1 的数据都会被迁移到 2 中,这时候进行指针对象进行互换,即哈希表 2 变成新的哈希表 1,而原先的哈希表 1 变成哈希表 2并且设置为空表,最后将 rehashidx 的值设置为 -1。

就这样,渐进式 rehash 的过程就完成了。

Hash 扩容以及缩容的条件

Redis 具体在什么时候会进行扩容和缩容呢?

我们先来说一下扩容,这里涉及到一个概念,即负载因子,redis 中 hash 的负载因子计算有一条公式:

1
负载因子 = 哈希表已保存节点的数量 /  哈希表的大小

Redis 会根据负载因子的情况决定是否采取扩容:

  1. 负载因子大于等于 1,这个时候说明空间非常紧张,新数据是在哈希节点的链表上找到的,这个时候如果服务器没有执行 RDB 快照或者 AOF 重写这两个持久化机制的时候,就会进行 rehash 操作。
  2. 当负载因子大于等于 5,这个时候说明哈希冲突非常严重了,这个时候无论有没有进行 AOF 重写或者 RDB 快照,都会强制执行rehash 操作。

缩容也和负载因子有关,当负载因子小于 0.1 的时候,就会进行缩容操作。这个时候新表大小是老表的 used 的最近的一个 2 次方幂。例如老表的 used = 1000,那么新表的大小就是 1024。如果没有执行 RDB 快照和 AOF 重写的时候就会进行缩容,反之不会进行。

640.Redis和Memcached有哪些区别?

回答重点

从数据结构上:

  • Redis:支持多种数据结构,如字符串、列表、集合、有序集合和哈希表,适合存储复杂数据类型。
  • Memcached:仅支持简单的键值对存储,数据结构较为单一。

从持久化上:

  • Redis:支持持久化功能,可以将数据保存在磁盘上,通过 RDB 和 AOF 两种方式实现数据的持久化。
  • Memcached:不支持数据持久化,重启后数据会丢失,适合临时数据存储。

从分布式架构上:

  • Redis:内置支持主从复制和集群分片(Redis Cluster),能在分布式环境中提供高可用性和扩展性。
  • Memcached:没有内置分片支持,需要在客户端代码中实现分布式逻辑。

从功能上:

  • Redis:支持发布订阅、Lua 脚本等特性。
  • Memcached:特性较少。

扩展知识

内存管理

  • Redis:使用高效的内存管理算法,支持内存回收策略(如 LRU、LFU 和 TTL)来管理内存使用。
  • Memcached:使用 slab 分配器管理内存,以避免内存碎片,但对内存使用的控制不如 Redis 灵活。

Redis 内存淘汰策略

641.Redis支持事务吗?如何实现?

回答重点

Redis 支持事务,但它的事务与 MySQL 中的事务有所不同。MySQL 中的事务主要支持 ACID 的特性,而 Redis 中的事务主要保证的是多个命令执行的原子性,即所有的命令在一个原子操作中执行,不会被打断。

还有一个很重要的点,就是 MySQL 中的事务是支持回滚的,而 Redis 中的事务是不支持回滚的

扩展知识

Redis 的事务

Redis 支持事务,通过使用 MULTI、EXEC、WATCH 和 DISCARD 等命令来实现。事务中的命令会被排队并在调用 EXEC 时一次性执行,保证了事务的原子性。

具体实现流程如下:

  1. 开始事务:使用 MULTI 命令开始一个事务,之后的所有命令都会被排队。
  2. 添加命令:在事务中添加命令,这些命令不会立即执行,而是存储在队列中。
  3. 执行事务:使用 EXEC 命令执行队列中的所有命令,确保原子性。
  4. 取消事务:使用 DISCARD 命令可以放弃事务,清空命令队列。
  5. 监视键:使用 WATCH 命令可以监视一个或多个键,如果在事务执行前这些键被修改,则 EXEC 将不会执行,确保数据一致性。

Redis 事务不支持回滚功能

Redis 事务一旦执行,所有命令都会被应用到数据中,即使其中某个命令失败。因此,在设计时需充分考虑可能的错误。

Redis 官网也进行了相关描述,如下图所示:

image-20250527211855180.png
Redis事务官网描述

从 Redis 2.6.5 开始,服务器会在累计命令的过程中检测到错误,此时执行 EXEC 会拒绝执行事务,并且返回一个错误,同时丢弃该事务。

但如果事务执行的过程中发生了错误,Redis 会继续执行剩下的命令,并不会对事务进行回滚,这个是 Redis 和 MySQL 最不一样的地方,并且也不支持多种隔离级别的设置,因为 Redis 是单线程执行,只能是串行隔离级别。

可以认为 Redis 的事务是一个残血的事务,更多只是一个噱头,不是我们平时理解的事务

642.Redis数据过期后的删除策略是什么?

回答重点

Redis 数据过期主要有两种删除策略,分别为定期删除惰性删除两种:

  • 定期删除:Redis 每隔一定时间(默认是 100 毫秒)会随机检查一定数量的键,如果发现过期,则将其删除。这种方式能够在后台持续清理过期数据,防止内存膨胀。
  • 惰性删除:在每次访问键时,Redis 检查该键是否已过期,如果已过期,则将其删除。这种策略保证了在使用过程中只删除不再需要的数据,但在不访问过期键时不会立即清除。

扩展知识

定期删除细节

定期删除策略是 Redis 内部的一个定时任务,周期性(每 100ms)地扫描一些设置了过期时间的键。

要注意,Redis 并不会一次性扫描所有设置了过期时间的键,因为这样会消耗大量的 CPU 资源。它会在每次扫描时限制扫描的时间和数量,以避免对性能产生过大的影响,因此可能会有部分键过期了没有被及时删除。

每次获取 20 个 key 判断是否过期,如果发现过期的 key 占比超过 25% 则继续再拉 20 个,如果小于 25% 则停止。这里还有一个时间限制,即一次删除时间不超过 25ms,即如果发现占比超过 25% 的时候,需要判断目前是否已经花了 25ms,如果已经用了这么多时长也结束。

惰性删除优缺点

  • 优点:可以减少 CPU 的占用,因为只有查询到了相关的数据才执行删除操作,不需要主动去定时删除。
  • 缺点:如果一直没有查询一个 Key,就有可能不会被删除,这样就容易造成内存泄漏问题。

内存回收机制

实际上,除了这两个删除,还有一个机制也会淘汰 key,即当 Redis 内存使用达到设置的 maxmemory 限制时,会触发内存回收机制。此时会主动删除一些过期键和其他不需要的键,以释放内存。具体的删除策略有以下几种:

  • volatile-lru:从设置了过期时间的键中使用 LRU(Least Recently Used,最近最少使用)算法删除键。
  • allkeys-lru:从所有键中使用 LRU 算法删除键。
  • volatile-lfu:从设置了过期时间的键中使用 LFU(Least Frequently Used,最少使用频率)算法删除键。
  • allkeys-lfu:从所有键中使用 LFU 算法删除键。
  • volatile-random:从设置了过期时间的键中随机删除键。
  • allkeys-random:从所有键中随机删除键。
  • volatile-ttl:从设置了过期时间的键中根据 TTL(Time to Live,存活时间)删除键,优先删除存活时间短的键。
  • noeviction:不删除键,只是拒绝写入新的数据。

Redis 在正常情况下对过期键的处理就是惰性删除 + 定期删除一起使用,主动删除(内存回收)其实属于异常的兜底处理了。

Redis 键过期时间的设置

  • EXPIRE:设置键的过期时间(以秒为单位)。
  • PEXPIRE:设置键的过期时间(以毫秒为单位)。
  • SETEX:在设置键值的同时定义过期时间。
  • PSETEX:类似于 SETEX,但支持毫秒级的过期时间。

643.Redis中有哪些内存淘汰策略?

回答重点

Redis 的内存淘汰策略一共有 8 种。8 种里面可以细分为两大类,即开启数据淘汰不开启数据淘汰这两大类,然后开启数据的那一类又可以进行细分,分为基于过期时间的淘汰策略以及全部数据的淘汰策略

不淘汰数据(默认):

  • noeviction:当运行内存超过最大设置内存的时候,不会淘汰数据,而是直接返回报错禁止写入

设置了过期时间的数据淘汰:

  • volatile-random:随机淘汰掉设置了过期时间的 key
  • volatile-ttl:优先淘汰掉较早过期的 key
  • volatile-lru(redis3.0之前默认策略):淘汰掉所有设置了过期时间的,然后最久未使用的 key
  • volatile-lfu(redis4.0后新增):与上面类似,不过是淘汰掉最少使用的 key

所有数据的数据淘汰:

  • allkeys-random:随机淘汰掉任意的 key
  • allkeys-lru:淘汰掉缓存中最久没有使用的 key
  • allkeys-lfu(redis4.0后新增):淘汰掉缓存中最少使用的 key

image-20250527211054927.png
Redis内存淘汰策略

扩展知识

不同淘汰策略适用场景

  • noeviction:适用于所有数据都被持久化或避免丢失任何数据的场景。
  • allkeys-lru:适用于希望保证最常访问的数据在内存中的场景。如电商促销活动中,某些商品会在短时间内被频繁访问,此时不会被淘汰。
  • allkeys-lfu:适用于希望保证访问频率最高的数据在内存中的场景。如社交媒体或直播类应用中,某些热点内容访问次数高,不能被淘汰。
  • allkeys-random:适用于对数据没有严格的优先级需求。
  • volatile-lru:同 allkeys-lru,仅只关心设置了过期时间的键。
  • volatile-random:同 allkeys-random,仅只关心设置了过期时间的键。
  • volatile-ttl:适用于对实时性较敏感的场景,比如存储用户会话等。
  • volatile-lfu:同 allkeys-lru,仅只关心设置了过期时间的键。

noeviction 演示

通过 config set maxmemory 先将其内存大小修改至 2 字节,然后设置一个超过 2 字节的字符串值 99999,其会报一个 OOM 的错误,并且不允许写入。

image-20250527211221364.png
image-20250527211221364

不过需要注意一个点,虽然其不支持写入数据,但是已有的数据并不会删除,其还是可以进行查询和删除操作,如下图所示,这里演示了 keys * 指令,发现其执行结果正常。

image-20250527211308258.png
keys * 指令

这里补充一个知识点,如果不设置最大内存大小或者设置最大内存大小为 0 的话,在 64 位操作系统下不限制内存大小,在 32 位操作系统下最多使用 3 GB 内存。

内存淘汰配置

可以通过修改 Redis 配置文件(redis.conf)中的 maxmemory-policy 参数来设置不同的内存淘汰策略(配置后需要重启 redis,重启重启后配置不会丢失)。

例如:

1
maxmemory-policy allkeys-lru

或者通过 Redis 命令动态设置(立即生效,不需要重启,但是重启后就失效了):

1
CONFIG SET maxmemory-policy allkeys-lru

644.Redis的Lua脚本功能是什么?如何使用?

回答重点

Redis 的 Lua 脚本功能允许用户在 Redis 服务器端执行自定义的 Lua 脚本,以实现原子操作和复杂逻辑。其核心点包括:

  • 原子性:Lua 脚本的所有命令在执行过程中是原子的,避免了并发修改带来的问题。
  • 减少网络往返次数:通过在服务器端执行脚本,减少了客户端和服务器之间的网络往返次数,提高了性能。
  • 复杂操作:可以在 Lua 脚本中执行复杂的逻辑,比如批量更新、条件更新等,超过了单个 Redis 命令的能力。

例如常见基于 Redis 实现分布式锁就需要结合 lua 脚本来实现。

lua 本身是不具备原子性的,但由于 Redis 的命令是单线程执行的,它会把整个 lua 脚本作为一个命令执行,会阻塞其间接受到的其他命令,这就保证了 lua 脚本的原子性。

扩展知识

Lua 脚本的基本结构

调用 Redis 命令:

  • 使用 redis.call 调用 Redis 原生命令。
  • 使用 redis.pcall 进行安全调用,如果脚本中的某些命令失败,可以捕获错误,而不是直接中断执行。这样可以在脚本内处理错误。

参数传递:

  • keys 表示传入的键名数组。
  • Argv 表示传入的参数数组。

Lua 脚本使用示例

基本的 GET 和 SET 操作
1
EVAL "local value = redis.call('get', KEYS[1]) if not value then redis.call('set', KEYS[1], ARGV[1]) end return value" 1 mykey "default_value"

分析: 这段脚本首先获取键 mykey 的值。

  • 如果 mykey 不存在,则设置其值为 default_value
  • 返回 mykey 的当前值。

步骤

  1. redis.call('get', KEYS[1]) 获取键的值。
  2. if not value then redis.call('set', KEYS[1], ARGV[1]) end 检查值是否存在,不存在则设置默认值。
  3. return value 返回当前值。
原子递增计数器
1
EVAL "local current = redis.call('INCR', KEYS[1]) return current" 1 counter_key

分析: 这段脚本使用 INCR 原子性地递增 counter_key 的值。

步骤

  1. redis.call('INCR', KEYS[1]) 直接递增。
  2. 返回递增后的值。
限流控制

假设我们要实现一个简单的限流器,每分钟允许访问 100 次。

1
2
3
4
5
6
7
8
if current < ARGV[1] then
    redis.call('INCR', KEYS[1])
    redis.call('EXPIRE', KEYS[1], 60)
    return true
else
    return false
end
]] 1 rate_limit_key 100

分析

  • 该脚本检查当前的访问次数是否超过限制。
  • 如果未超过限制,则增加计数并设置过期时间。
  • 返回布尔值表示是否允许访问。

步骤

  1. local current = tonumber(redis.call('GET', KEYS[1]) or 0) 获取当前计数,默认为 0。
  2. 判断是否小于限制值 ARGV[1]
  3. 若未超过,则 INCR 当前计数并设置过期时间。
  4. 返回布尔值表示是否允许访问。
Lua 预加载脚本

可以使用 SCRIPT LOAD 将 Lua 脚本预加载到 Redis,以提高执行效率:

1
SCRIPT LOAD "return redis.call('GET', KEYS[1])"

此命令返回脚本的 SHA1 哈希值,后续可用 EVALSHA 命令通过该哈希值执行。

Lua 脚本使用注意点

由于 Redis 执行 Lua 脚本期间,无法处理其他命令,因此如果 Lua 脚本的业务过于复杂,则会产生长时间的阻塞,因此编写 Lua 脚本时应尽量保持简短和高效。

Redis 默认限制 Lua 执行脚本时间为 5s,如果超过这个时间则会终止且抛错,可以通过 lua-time-limit 调整时长。

如果 Lua 脚本中的部分命令执行失败会怎样?

例如以下这段 lua 脚本:

1
2
3
4
5
-- 正确的 SET 命令
redis.call("SET", "my_key", "mianshiya")

-- 错误的 HSET 命令
redis.call("HSET", "my_hash", "field_mianshiya") -- 缺少值参数

此时,执行上述的 lua 脚本,my_key 会被成功执行,由于 HSET 缺少参数,会报错,而 my_key 并不会被回滚。

所以实际上 Lua 脚本并不能完全保证原子性,但是官方认为这种错误正常情况下不可能发生,属于开发者的人为因素(语法错误,或者执行了不应该执行的命令)导致的,他们不背这个锅。

Lua 知识点

Lua 语言是一门轻量级的脚本语言,使用 C 语言编写,具有简洁的语法,易于学习和使用,广泛应用于游戏开发、嵌入式系统等领域。它设计的主要目的是为了嵌入其他程序,实现灵活的扩展和定制功能,并且具备快速的执行速度,能够满足各种开发需求。

Lua 语言具有以下优点:

  1. 轻量级:占用资源少,易于嵌入其他程序。
  2. 简洁高效:语法简单,执行效率高。
  3. 跨平台:可在多种操作系统上运行。
  4. 扩展性强:方便与其他语言集成。
  5. 快速开发:减少开发时间和成本。
  6. 灵活性高:适应各种不同的项目需求。
  7. 易于学习:入门难度低,容易掌握。
  8. 解释执行:无需编译,便于调试。
  9. 资源占用少:对系统资源的需求相对较低。
  10. 社区活跃:有丰富的文档和工具支持。

645.Redis 的Pipeline功能是什么?

回答重点

Redis 的 Pipeline 功能允许客户端在一次网络请求中批量发送多个命令,以减少网络延迟并提高性能。通过将多个命令打包发送,客户端可以在不等待每个命令响应的情况下继续发送其他命令,从而显著提高吞吐量。

好处:

  • 节省了网络传输时间
  • 减少了 Redis 服务端上下文切换带来的开销

扩展知识

与事务的区别

  • Pipeline 并不保证命令的原子性,也不支持事务特性。如果某个命令失败,其他命令仍会继续执行。
  • Redis 的事务是通过 MULTI、EXEC 命令实现的,可以保证一组命令的原子性。

使用注意事项

需要注意 Pipeline 不宜包装过多的命令,因为会导致客户端长时间的等待,且服务器需要使用内存存储响应,所以官方推荐最多一次 10k 命令。

image-20250527213540473.png
Pipeline官方说明

还有一点需要注意 pipeline 命令执行的原子性不能保证,如果要保证原子性则使用 lua 脚本或者事务。

Pipeline 示例代码

Java 的 Jedis 库实现
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;

public class RedispipelineExample {
    public static void main(String[] args) {
        // 创建 Redis 连接
        Jedis jedis = new Jedis("localhost", 6379);
        // 开始 pipeline
        Pipeline pipeline = jedis.pipelined();
        // 批量设置键值对
        for (int i = 0; i < 1000; i++) {
            pipeline.set("key" + i, "value" + i);
        }
        // 执行所有命令
        pipeline.sync();
        // 关闭连接
        jedis.close();
    }
}
使用 Python 的 redis-py 库:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import redis

r = redis.StrictRedis(host='localhost', port=6379, db=0)
pipeline = r.pipeline()

for i in range(1000):
    pipeline.set(f'key{i}', f'value{i}')

# 执行所有命令
pipeline.execute()

性能测试

使用 pipeline 可以在性能测试中显著减少与 Redis 的交互延迟,适合在高负载情况下进行优化。参考网络上的一个执行实验结果,使用 Pipeline 性能提升了 100 倍。

  • 非 pipeline 操作 10000 次字符串数据类型 set 写入,耗时:1073 毫秒
  • pipeline 操作 10000 次字符串数据类型 set 写入,耗时:10 毫秒

Pipeline 进一步分析

正常情况下,如果要执行多条命令,那么操作如下:

image-20250527213848287.png
Redis执行多条命令

而 Redis pipeline(管道)使得客户端可以一次性将要执行的多条命令封装成块一起发送给服务端,具体过程如下图所示:

image-20250527213921431.png
Redis pipeline

Redis 服务器在收到来自管道发送的多条命令之后,会先把这些命令按序执行,并将执行结果保存到缓存中,直到最后一条命令执行完成,再把命令执行的结果一起返回给客户端。

Redis 使用 pipeline 主要有以下好处:

1)节省了 RTT

RTT(Round Trip Time)即往返时间。Redis 客户端将要执行的多条命令一次性给服务器,显然减少了往返时间。

拿快递来理解,将所有快递都装进卡车在两地一次性配送和将快递分批装进面包车,多次往返两地送完快递。很显然一次性用卡车送完花在两地往返的时间最短。

2)减少了上下文切换带来的开销

当服务器需要从网络中读取数据时,都会产生一次系统调用,系统调用是非常耗时的操作。其中涉及到程序由用户态切换到内核态,再从内核态切换回用户态的过程。当我们执行 100 条 Redis 指令的时候,就发生 100 次用户态到内核态之间上下文的切换,但是如果使用管道的话,虽然多条命令一同发送给服务器,就只需要进行一次上下文切换就好了,这样就可以节省性能。

646.Redis中的Big Key问题是什么?如何解决?

回答重点

Redis 中的 “big Key” 是指一个内存空间占用比较大的键(Key),它有什么危害呢?

  • 内存分布不均:在集群模式下,不同 slot 分配到不同实例,如果大 key 被映射到一个实例,则分布不均,查询效率也会受影响。
  • 由于 Redis 单线程执行命令,操作大 Key 时耗时较长,从而导致 Redis 出现其它命令阻塞的问题。
  • 大 Key 对资源的占用大,在你进行网络 I/O 传输的时候,导致你获取过程中产生的网络流量较大,从而产生网络传输的延迟长甚至网络传输发现阻塞的现象。例如一个 key 2MB,请求 1000 次 2000 MB。
  • 客户端超时。因为操作大 Key 时耗时较长,可能导致客户端等待超时。

如何解决 big key 问题?

可以从以下几个方向进行入手:

开发方面

  • 对要存储的数据进行压缩,压缩之后再进行存储
  • 大化小,即把大对象拆分成小对象,即将一个大 Key 拆分成若干个小 Key,降低单个 Key 的内存大小
  • 使相合适的数据结构进行存储,比如一些用 String 存储的场景,可以考虑使用 Hash、Set 等结构进行优化

业务层面

  • 可以根据实际情况,调整存储策略,只存一些必要的数据。比如用户的不常用信息(地址等)不存储,仅存储用户 ID、姓名、头像等。
  • 优化业务逻辑,从根源上避免大 Key 的产生。比如一些可以不展示的信息,直接移除等。

数据分布方面

  • 采用 Redis 集群方式进行 Redis 的部署,然后将大 Key 拆分散落到不同的服务器上面,加快响应速度

扩展知识

怎样算 big key?

大 Key 可能出现在不同的场景中,不过其本质都是相同的,就是对应的值其所占内存非常大。例如:

1)大型字符串

1
set key "面试鸭xxxxxx······(此处省略无数个字符)"

2)大型哈希表

1
HMSET student:8927 name "yupi" nickname "面试鸭" 

这个时候可能有同学会问了,到底占用多少的存才算得上是 big key 呢? 其实这个没有一个很绝对的说法,主要还是取决于具体的场景以及应用需求。参考阿里云redis文档:

image-20250527214440746.png
阿里云redis-大key说明

当然,这些标准并不是绝对的,具体的情况还是需要根据应用场景和实际情况来进行调整,这个需要你在面试前说明清楚。

使用 bigkeys 命令查询 bigkeys

这个是 Redis 内置的指令,直接在 Redis 的客户端 redis-cli 中就可以调用使用,该命令可以获取 Redis 的整体信息,并且显示各种类型数据中最大的 Key。 如下所示,在命令行终端输入 redis-cli --bigkeys 就可以看到(docker 版本的还可能会出现查找失败的情况)。

image-20250527214606332.png
redis-cli –bigkeys查询bigkeys

这种方法有优点,就是其是 Redis 内置的,其统计非常方便。

它的原理就是 Redis 内部执行 scan 命令,遍历整个实例所有的 Key,针对 key 的类型执行 strlen、hlen、scard 等命令获取字符串长度或集合的元素个数。

所以在线上执行这个命令的需要,需要关注性能问题,可以通过 -i 命令控制扫描的休息间隔,时间单位是秒。

还有一点,元素多不一定代表占用的内存多,需要实际评估下。

647.如何解决 Redis 中的热点 key 问题?

回答重点

Redis 中的热点 Key 问题是指某些 Key 被频繁访问,导致 Redis 的压力过大,进而影响整体性能甚至导致集群节点故障。

解决热点 Key 问题的主要方法包括:

  • 热点 key 拆分:将热点数据分散到多个 Key 中,例如通过引入随机前缀,使不同用户请求分散到多个 Key,多个 key 分布在多实例中,避免集中访问单一 Key。
  • 多级缓存:在 Redis 前增加其他缓存层(如 CDN、本地缓存),以分担 Redis 的访问压力。
  • 读写分离:通过 Redis 主从复制,将读请求分发到多个从节点,从而减轻单节点压力。
  • 限流和降级:在热点 Key 访问过高时,应用限流策略,减少对 Redis 的请求,或者在必要时返回降级的数据或空值。

扩展知识

热点 key 的定义

热点 Key 与 big key 一样,没有一个很明确的定义来约定什么样的 key 叫做热点 key。

我们可以参考阿里云 Redis 对热 key 的定义:

image.png

可以看到,如果一个 key 的访问频率占比过大,或带宽占比过大,都属于热点 key。

由于 Redis 的读写是单线程执行的,所以热点 key 可能会影响 Redis 的整体效率,消耗大量的 CPU 资源,从而降低 Redis 的整体吞吐量。集群环境下会使得流量不均衡,从而导致读写热点倾斜问题的发生。

如何发现热 key?

1)根据业务经验进行分析

这个主要就是依据业务场景进行分析,通过经验判断哪些 key 可能成为热门 key,比如某明星的花边新闻、秒杀活动,演唱会门票等。

  • 优点:这个方案实现起来简单直接,只有直接进行判断就可以了,基本没有什么成本。
  • 缺点:这个主要依据业务能力,对于业务能力有一定的要求,并且不是所有的业务都能判断出来是否是热 key 的。且有些突发事情是无法预测的。

2)redis 集群监控

这种方式主要依据与 Redis 集群,我们只需要查看集群中哪个 Redis 出现 QPS 倾斜,而出现 QPS 倾斜的实例有极大的概率存在热点 Key。

  • 优点:这种方案和上面差不多,由于企业的 Redis 大多数是集群部署,所以使用起来非常简单。
  • 缺点:每次发生状况都需要排查,因为不一定所有的 QPS 倾斜都是热 Key 导致的。

3)使用 hotkey 监控

这个是 Redis 4.0 版本之后引入的一个新的指令,只需要在命令行执行 redis-cli 的时候加上 –hotkeys 的选项就可以了。它是通过 scan + object freq 实现的。

  • 优点:因为这个命令是 Redis 自带的,使用起来简单快捷
  • 缺点:需要扫描整个 keyspace,如果 Redis 中的 key 数量比较多的话,可能导致执行时间非常长,且实时性不好。

4)使用 monitor 命令

如下图所示,这个是 Redis 自 1.0 起就支持的功能。

image.png

当通过 MONITOR 命令开启监视器之后,Redis 只需要在执行之后结合一些日志和相关的分析工具就可以进行统计。

  • 优点:这个方案的优点在于这个是 Redis 原生支持的功能,使用起来简单快捷。
  • 缺点:monitor 非常消耗性能,单个客户端执行 monitor 就会损耗 50% 的性能!不推荐这个方式!

以下为 redis 官网的 benchmark:

image.png

5)客户端收集

在操作 Redis 之前,通过加上统计 Redis 键值查询频次的逻辑,将统计数据发送给一个聚合计算平台进行计算,计算之后查看相对应的结果

  • 优点:对性能损耗较低。
  • 缺点:成本较大,企业如果没有聚合计算平台还需要引入。

6)代理层收集

在代理层进行统一的收集,因为有些服务在请求 Redis 之前都会请求一个代理服务,这种场景可以使用在代理层收集 Redis 热 Key 数据,和在客户端收集比较类似。

  • 优点:客户端使用方便,不需要考虑 SDK 多语言异构差异和升级成本高的问题。
  • 缺点:需要给 Redis 定制一个代理层,进行转发等操作,构建代理成本不低,且转发有性能损耗。

应用程序中的多级缓存

在应用程序中,一般结合使用一级缓存和二级缓存:

  • 一级缓存:一般指的是应用程序本地缓存(如 JVM 内存中的缓存)。
  • 二级缓存:则为 Redis 缓存。当数据不在一级缓存中时,才会请求二级缓存。

通过这种多级缓存架构,可以有效减少 Redis 的访问次数,从而避免单 Key 的热点问题。

热点 key 的拆分

我们可以按照不同场景做不同的“拆分“。有些场景可以全量拷贝,即将 mianshiya 这个 key 复制成 mianshiya_1、mianshiya_2、mianshiya_3 ,它们之间的数据是一致的,这样不同用户都访问到全量的数据。

有些场景直接进行 key 的拆分,mianshiya_1、mianshiya_2、mianshiya_3 各存一部分的数据,不同用户仅需访问不同数据即可,例如一些推流信息,因为一个热点往往有很多发布者,大家看一部分,后续热度稍微降低下来,可以替换数据。

不同用户可以进行 hash,将用户 id 哈希之后取余得到后缀,拼上 mianshiya_ 即可组成一个 key。

648.Redis 的持久化机制有哪些?

回答重点

Redis 提供两种主要的持久化机制:

RDB(Redis Database)快照

  • RDB 是通过生成某一时刻的数据快照来实现持久化的,可以在特定时间间隔内保存数据的快照。
  • 适合灾难恢复和备份,能生成紧凑的二进制文件,但可能会在崩溃时丢失最后一次快照之后的数据。

AOF(Append Only File)日志

  • AOF 通过将每个写操作追加到日志文件中实现持久化,支持将所有写操作记录下来以便恢复。
  • 数据恢复更为精确,但文件体积较大,重写时可能会消耗更多资源。

Redis 4.0 新增了 RDB 和 AOF 的混合持久化机制。

扩展知识

RDB 持久化详解

RDB 持久化通过创建快照来获取内存某个时间点上的副本,利用快照可以进行方便地进行主从复制,默认快照文件为 dump.rdb。

redis.conf 文件可以配置在 x 秒内如果至少有 y 个 key 发生变化就会触发命令进行持久化操作。

它的优点

  • 快速加载:RDB 生成的快照文件是压缩的二进制文件,适合备份和灾难恢复。
  • 低资源占用:RDB 持久化在 Redis 主线程之外进行,不会对主线程产生太大影响。

它的缺点

  • 数据丢失风险:由于 RDB 是间隔性保存的快照,如果 Redis 崩溃,可能会丢失上次保存快照后的数据。

RDB 持久化命令:

  • save:在主线程生成 RDB 文件,因此生成其间,主进程无法执行正常的读写命令,需要等待 RDB 结束。
  • bgsave:利用 Fork 操作得到子进程,在子进程执行 RDB 生成,不会阻塞主进程,默认使用 bgsave。

bgsave 流程(重点):

1)检查子进程(检查是否存在 AOF/RDB 的子进程正在进行),如果有返回错误

2)触发持久化,调用 rdbSaveBackground

3)开始 fork,子进程执行 rdb 操作,同时主进程响应其他操作。

4)RDB 完成后,替换原来的旧 RDB 文件,子进程退出。

注意事项(重点):

1)Fork 操作会产生短暂的阻塞,微秒级别操作过后,不会阻塞主进程,整个过程不是完全的非阻塞。

2)RDB 由于是快照备份所有数据,而不是像 AOF 一样存写命令,因为 Redis 实例重启后恢复数据的速度可以得到保证,大数据量下比AOF 会快很多。

3)Fork 操作利用了写时复制,类似与 CopyOnWriteArrayList

小知识-写时复制:

在子进程创建时,它不会直接将主进程地址空间全部复制,而是共享同一个内存。

之后如果任意一个进程需要对内存进行修改操作,内存会重新复制一份提供给修改的进程单独使用。

更多可看:

AOF 持久化详解

AOF 持久化机制是指将 Redis 写命令以追加的形式写入到磁盘中的 AOF 文件,AOF 文件记录了 Redis 在内存中的操作过程,只要在 Redis 重启后重新执行 AOF 文件中的写命令即可将数据恢复到内存中。

AOF 机制的优点

  • AOF 机制比 RDB 机制更加可靠,因为 AOF 文件记录了 Redis 执行的所有写命令,可以在每次写操作命令执行完毕后都落盘存储。

AOF 机制的缺点

  • AOF 机制生成的 AOF 文件比 RDB 文件更大,当数据集比较大时,AOF 文件会比 RDB 文件占用更多的磁盘空间。
  • AOF 机制对于数据恢复的时间比 RDB 机制更加耗时,因为要重新执行 AOF 文件中的所有操作命令。

混合持久化

RDB 和 AOF 都有各自的缺点。

如果 RDB 备份的频率低,那么丢的数据多。备份的频率高,性能影响大。

AOF 文件虽然丢数据比较少,但是恢复起来又比较耗时。

因此 Redis 4.0 以后引入了混合持久化,通过 aof‐use‐rdb‐preamble 配置开启混合持久化。

当 AOF 重写的时候(注意混合持久化是在 aof 重写时触发的)。它会先生成当前时间的 RDB 快照,将其写入新的 AOF 文件头部位置。

这段时间主线程处理的操作命令会记录在重写缓冲区中,RDB 写入完毕后将这里的增量数据追加到这个新 AOF 文件中,最后再用新 AOF 文件替换旧 AOF 文件。

如此一来,当 Redis 通过 AOF 文件恢复数据时,会先加载 RDB,然后再重新执行指令恢复后半部分的增量数据,这样就可以大幅度提高数据恢复的速度!

AOF 写回策略

AOF 提供了三种写回策略,决定何时将数据同步到磁盘中:

  • always:每次写操作后立即调用 fsync,将数据同步到磁盘。这种策略保证了最高的数据安全性,但也会显著降低性能,因为每个写操作都要等待磁盘写入完成。
  • everysec:每秒调用一次 fsync,将数据同步到磁盘。这种策略在性能和数据安全性之间做了折中,默认情况下,Redis 使用这种策略。最多会丢失 1 秒的数据。
  • no:由操作系统决定何时将数据写入磁盘。通常,操作系统会在一定时间后或缓冲区满时同步数据到磁盘。这种策略具有最高的性能,但数据安全性较低,因为在 Redis 崩溃时可能会丢失较多的数据。

设置 always 能一定保证数据不丢失吗?

答案是不能!因为 Redis 是先执行命令再写入 aof,所以如果执行命令写入 aof 这段时间 Redis 宕机了,重启后也无法利用 aof 恢复!

所以 Redis 的持久化机制,并不能保证数据不丢失!

AOF 重写机制

AOF 文件随着写操作的增加会不断变大,过大的 AOF 文件会导致恢复速度变慢,并消耗大量磁盘空间。所以,Redis 提供了 AOF 重写机制,即对 AOF 文件进行压缩,通过最少的命令来重新生成一个等效的 AOF 文件。

拿 key A 举个例子,AOF 记录了每次写命令如 set A 1set A 2set A 3。实际上前面的 set A 1set A 2 是历史值,我们仅关心最新的值,因此 AOF 重写就是仅记录数据的最终值即可,即set A 3,这样 AOF 文件就“瘦身”了。

注意:AOF 重写并不是对现有的 AOF 文件进行修改,而是根据当前每个键的最新值转换为对应的写命令,写入新的 AOF 文件,形成一个新文件。

注意:后台 AOF 重写,也是需要 fork 子线程,因此也有写时复制的机制。

AOF 重写流程如下

  1. 创建子进程:Redis 使用 BGREWRITEAOF 命令创建一个子进程,负责 AOF 重写操作。
  2. 生成新的 AOF 文件:子进程根据当前数据库的状态,将每个键的最新值转换为对应的写命令,并写入新的 AOF 文件。例如,对于一个列表键,子进程会使用一条 RPUSH 命令将所有元素写入,而不是记录之前的多次操作。
  3. 处理新写入的命令:在重写过程中,主进程仍然处理新的写操作。为了避免数据不一致,主进程会将这些新的写命令同时追加到现有的 AOF 文件和一个缓冲区(aof_rewrite_buf)中。
  4. 合并新命令:当子进程完成新的 AOF 文件的写入后,主进程会将缓冲区中的新命令追加到新的 AOF 文件中,确保其包含所有最新的操作。
  5. 替换旧的 AOF 文件:最后,Redis 使用新的 AOF 文件替换旧的文件,实现 AOF 文件的重写。
image.png

AOF 重写可以通过手动触发或自动触发:

1)手动触发:使用 BGREWRITEAOF 命令可以手动触发 AOF 重写。

2)自动触发:通过配置文件中的参数控制自动触发条件,参数如下:

  • auto-aof-rewrite-min-size:AOF 文件达到该大小时允许重写(默认 64 MB)。
  • auto-aof-rewrite-percentage:当前 AOF 文件大小相对于上次重写后的增长百分比达到该值时触发重写。

Redis 7.0 MP-AOF(Multi-Part Append Only File)

7.0 之前的 AOF 重写有三大问题:

  1. 内存开销aof_bufaof_rewrite_buf 中大部分内容是重复的。
  2. CPU 开销:主进程需要花费 CPU 时间往 aof_rewrite_buf 写入数据,并向子进程发送 aof_rewrite_buf 中的数据。子进程需要消耗 CPU 时间将aof_rewrite_buf 写入新 AOF 文件。
  3. 磁盘开销aof_buf数据会写到当前的 AOF 文件,aof_rewrite_buf数据写到新的 AOF 文件,一份数据需要写两次磁盘。

针对以上问题 Redis 7.0 引入了 MP-AOF(Multi-Part Append Only File)机制。简单来说就是将一个 AOF 文件拆分成了多个文件:

  • 一个基础文件(base file),代表数据的初始快照
  • 增量文件(incremental files),记录自基础文件创建以来的所有写操作,可以有多个
  • 基础文件和增量文件都会存放在一个单独的目录中,并由一个清单文件(manifest file)进行统一跟踪和管理

大致流程如下image.png

可以看到,重写期间的数据变更直接写到 aof_buf 再到新的增量 AOF 文件中,避免了之前多个 buf 的写入。

且子进程独立重写基础的 AOF,于主进程无交互,节省了主进程的 CPU 开销。

当重写完毕后,仅需更新 manifest 文件,加入新的增量 AOF 文件和基础 AOF 文件,然后将之前的增量 AOF 文件和基础 AOF 文件标记为历史文件(会被异步删除)即可。更新完 manifest 就代表 AOF 重写结束。

AOF 文件修复

如果 AOF 文件因系统崩溃等原因损坏,可以使用 redis-check-aof 工具修复。该工具会截断文件中的不完整命令,使其恢复到一致状态。

649.Redis 在生成 RDB 文件时如何处理请求?

回答重点

在 Redis 生成 RDB 文件时是异步的(使用 bgsave 命令),采用了 fork 子进程的方式来进行快照操作。生成 RDB 文件的过程由子进程执行,主进程继续处理客户端请求,所以可以保证 Redis 在生成快照的过程中依然对外提供服务,不会影响正常请求。

扩展知识

生成 RDB 文件的时候,数据可以修改吗?

当然可以。主进程会正常处理客户端的请求,进行数据的修改。但数据被修改还叫快照吗?

此时就运用了写时复制的技术。

当主进程 fork 出一个子进程后,并不会把主进程的所有内存数据重新复制一份给子进程,而是让主进程和子进程共享相同的内存页面。

底层的实现仅仅复制了页表,但映射的物理内存还是同一个。这样做可以加快 fork 的速度,减少性能损耗(fork会阻塞主进程)。

image.png

此时,主进程收到写命令,需要修改数据,那么主进程会将对应数据所在的页复制一份,对复制的副本进行修改。此时子进程指向的还是老的页,因此数据没有变化,符合快照的概念。

image.png

通过在写的时候才触发内存的复制,可以显著地降低 Redis 实例的性能压力,最大限度的减少 RDB 对服务正常运行的影响。

避免高峰期生成 RDB

如果 RDB 时间长,且写并发高,此时会被系统产生比较大的影响。

原因是因为写时复制时,如果共享的每一页内存都被修改,就会使得内存极速膨胀,最大内存可以膨胀两倍,所以要注意内存的使用量,防止内存过载。

RDB 会产生大量的磁盘 I/O,要注意磁盘性能导致的影响。

还需要注意 CPU 负载,毕竟有大量的数据需要写入。

因此如果 RDB 在高峰期可能会影响到正常业务,需要合理安排生成 RDB 的时机。

650.Redis 的哨兵机制是什么?

回答重点

Redis 的哨兵机制(Sentinel) 是一种高可用性解决方案,用于监控 Redis 主从集群,自动完成主从切换,以实现故障自动恢复和通知。

主要功能包括:

  • 监控:哨兵不断监控 Redis 主节点和从节点的运行状态,定期发送 PING 请求检查节点是否正常。
  • 自动故障转移:当主节点发生故障时,哨兵会选举一个从节点提升为新的主节点,并通知客户端更新主节点的地址,从而实现高可用。
  • 通知:哨兵可以向系统管理员或其他服务发送通知,以便快速处理 Redis 实例的状态变化。

扩展知识

哨兵机制的由来

主从架构中,如果采用读写分离的模式,即主节点负责写请求,从节点负责读请求。假设这个时候主节点宕机了,没有新的主节点顶替上来的话,就会出现很长一段时间写请求没响应的情况。

针对这个情况,便出现了哨兵这个机制。它主要进行监控作用,如果主节点挂了,将从节点切换成主节点,从而最大限度地减少停机时间和数据丢失。

哨兵机制对应的架构图如下所示:

  • 哨兵节点(Sentinel): 主要作用是对 Redis 的主从服务节点进行监控,当主节点发生故障的时候,哨兵节点会选择一个合适的从节点升级为主节点,并通知其他从节点和客户端进行更新操作。
  • Redis 节点:主要包括 master 以及 slave 节点,就是 Redis 提供服务的实例。

一般哨兵需要集群部署,至少三台哨兵组成哨兵集群。

主观下线和客观下线

哨兵是如何判断 Redis 中主节点挂了的呢?主要涉及到了两个机制:主观下线以及客观下线

1)主观下线

Sentinel 每隔 1s 会发送 ping 命令给所有的节点。如果 Sentinel 超过一段时间还未收到对应节点的 pong 回复,就会认为这个节点主观下线。

“一段时间”是配置项 down-after-milliseconds 设定的。

2)客观下线

注意,只有主节点才有客观下线,从节点没有

假设目前有个主节点被一个 sentinel 的判断主观下线了,但可能主节点并没问题,只是因为网络抖动导致了一台哨兵的误判。所以此时哨兵需要问问它的队友,来确定这个主节点是不是真的出了问题!

因此,它会向其他哨兵发起投票,其他哨兵会判断主节点的状态进行投票,可以投赞成或反对。

如果认为下线的总投票数大于 quorum(一般为集群总数/2 + 1,假设哨兵集群有 3 台实例,那么 3 / 2 + 1 = 2),则判定该主节点客观下线,此时就需要进行主从切换,而只有哨兵的 leader 才能操作主从切换

Sentinel leader 是如何选举出来的?

Sentinel leader 节点的选举实际上涉及到分布式算法 raft,感兴趣的同学可以深入去了解一下,这里主要简单说一下哨兵集群选择 leader 的方式:

判断主节点主观下线的 sentinel 就是候选者,此时它想成为 leader。如果同时有两个 sentinel 判断主观下线,那么它们都是候选人,一起竞争成为 leader。

候选者们会先投自己一票,然后向其他 sentinel 发送命令让它们给自己投票。每个哨兵手里只有一票,投了一个之后就不能投别人了

最后,如果某个候选者拿到哨兵集群半数及以上的赞成票,就会成为 leader。这里有一个注意的点,为了保证 sentinel 选举的时候尽量避免出现平票的情况,sentinel 的节点个数一般都会是奇数,比如 3,5,7 这样。

Redis 主节点选举

选出哨兵 leader 之后,需要选出 Redis 主从集群中的新 master 节点。

首先需要把一些已经下线的节点全部剔除,然后从正常的从节点中选择主节点,其主要经过以下三个流程:

  1. 根据从节点的优先级进行选择,优先选择优先级的值比较小的节点(优先级的值越小优先级越高,优先级可通过 slave-priority 配置)。
  2. 如果节点的优先级相同,则查看进行主从复制的 offset 的值,即复制的偏移量,偏移量越大则表示其同步的数据越多,优先级越高。
  3. 如果 offset 也相同了,那只能比较 ID 号,选择 ID 号比较小的那个作为主节点(每个实例 ID 不同)。

选好主节点之后,哨兵 leader 会让其他从节点全部成为新 master 节点的 slave 节点。

最后利用 redis 的发布/订阅机制,把新主节点的 IP 和端口信息推送给客户端,此时主从切换就结束了。

可能还有同学关心旧主节点恢复了怎么办?实际上哨兵会继续监视旧的主节点,如果它上线了,哨兵集群会向它发送 slaveof 命令,让它成为新主节点的从节点。

651.Redis 主从复制的实现原理是什么?

回答重点

Redis 的主从复制是指一个 Redis 实例(主节点)可以将数据复制到一个或多个从节点(从节点),从节点从主节点获取数据并保持同步。

复制流程

  • 开始同步:从节点通过向主节点发送 PSYNC 命令发起同步。
  • 全量复制:如果是第一次连接或之前的连接失效,从节点会请求全量复制,主节点将当前数据快照(RDB文件)发送给从节点。
  • 增量复制:全量复制完毕后,主从之间会保持一个长连接,主节点会通过这个连接将后续的写操作传递给从节点执行,来保证数据的一致。

扩展知识

Redis 主从架构

下图就是一个 Redis 主从架构图:

Ry6PqUac_image_mianshiya.png
image.png

主从架构可以实现读写分离。写操作可以请求主节点,而读操作只请求从节点,这样就能减轻主节点的压力。

jJA9JNgt_image_mianshiya.png
image.png

整个主从集群仅主节点可以写入,其它从节点都通过复制来同步数据,这样就能保证数据的一致性。并且对读请求分散到多个节点,提高了 Redis 的吞吐量,从一定程度上也提高了 Redis 的可用性。

主从复制原理详解

Redis 之间主从复制主要有两种数据同步方式,分别是全量同步增量同步

1) 全量同步

zQyotf09_image_mianshiya.png
image.png

  • runid 指的是主服务器的 run ID,从节点第一次同步不知道主节点 ID,于是传递 “?"。
  • offset 为复制进度,第一次同步值为 -1。

文字版本的流程:

  • 从节点发送 psync ? -1,触发同步。
  • 主节点收到从节点的 psync 命令之后,发现 runid 没值,判断是全量同步,返回 fullresync 并带上主服务器的 runid 和当前复制进度,从服务器会存储这两个值。
  • 主节点执行 bgsave 生成 RDB 文件,在 RDB 文件生成过程中,主节点新接收到的写入数据的命令会存储到 replication buffer 中。
  • RDB 文件生成完毕后,主节点将其发送给从节点,从节点清空旧数据,加载 RDB 的数据。
  • 等到从节点中 RDB 文件加载完成之后,主节点将 replication buffer 缓存的数据发送给从节点,从节点执行命令,保证数据的一致性。

待同步完毕后,主从之间会保持一个长连接,主节点会通过这个连接将后续的写操作传递给从节点执行,来保证数据的一致。

2) 增量同步

主从之间的网络可能不稳定,如果连接断开,主节点部分写操作未传递给从节点执行,主从数据就不一致了。

此时有一种选择是再次发起全量同步,但是全量同步数据量比较大,非常耗时。因此 Redis 在 2.8 版本引入了增量同步(psync 其实就是 2.8 引入的命令),仅需把连接断开其间的数据同步给从节点就好了。

此时需要介绍下 repl_backlog_buffer

repl_backlog_buffer 是一个环形缓冲区,默认大小为 1m。主节点会将写入命令存到这个缓冲区中,但是大小有限,待写入的命令超过 1m 后,会覆盖之前的数据,因为是环形写入。

增量同步也是 psync 命令,如果主节点判断从节点传递的 runid 和主节点一致,且根据 offset 判断数据还在repl_backlog_buffer中,则说明可以进行增量同步。

于是就去 repl_backlog_buffer 查找对应 offset 之后的命令数据,写入到 replication buffer 中,最终将其发送给 slave 节点。slave 节点收到指令之后执行对应的命令,一次增量同步的过程就完成了。

9GIJIcHv_image_mianshiya.png

如果根据 offset 判断数据已经被覆盖了,此时只能触发全量同步!

因此可以调整 repl_backlog_buffer 大小,尽量避免出现全量同步。

replication buffer 和 repl_backlog_buffer 的区别

replication buffer

因为不同的从节点同步速度不一样,主节点会为每个从节点都创建一个 replication buffer它用于实时传输写命令,且大小是动态的,因为对于同步速度较慢的从服务器,需要更多的内存来缓存数据。

虽说 replication buffer 没有明确的大小限制,但是可以通过 client-output-buffer-limit 间接控制,该参数可以设置不同类型客户端(普通、从服务器、发布订阅)的输出缓冲区限制。当缓冲区大小超过限制时,Redis 会断开与客户端(从节点其实就是一个客户端)的连接。

client-output-buffer-limit slave 256mb 64mb 60

上述配置表示,如果从服务器的输出缓冲区大小超过 256 MB 或超过 64 MB 的时间达到 60s,Redis 将断开与从服务器的连接。

repl_backlog_buffer

repl_backlog_buffer 在主节点上只有一个,存储最近的写命令,用于从服务器重新连接时进行部分重同步。

652.Redis集群的实现原理是什么?

回答重点

Redis 集群(Redis cluster)是通过多个 Redis 实例组成的,每个实例存储部分的数据(即每个实例之间存储的数据是不重复的)

具体是采用哈希槽(Hash Slot)机制来分配数据,将整个键空间分为 16384 个槽(slot),每个 Redis 实例负责一定范围的哈希槽。数据的 key 经过哈希函数计算后可以得到 16384 取余即可定位到对应的节点。

客户端在发送请求时,会通过集群的任意节点进行连接,如果该节点存储了对应的数据则直接返回结果,反之该节点会根据请求的键值计算哈希槽并路由到正确的节点。

简单来说,集群就是通过多台机器分担单台机器上的压力。

(如果不理解集群原理,可以以下面两个图示为例)

扩展知识

Redis 集群中节点之间的信息如何同步?

Redis 集群内每个节点都会保存集群的完整拓扑信息,包括每个节点的 ID、IP 地址、端口、负责的哈希槽范围等。

节点之间使用 Gossip 协议进行状态交换,以保持集群的一致性和故障检测。每个节点会周期性地发送 PING 和 PONG 消息,交换集群信息,使得集群信息得以同步。

Gossip 的优点:

  • 快速收敛:Gossip 协议能够快速传播信息,确保集群状态的迅速更新。
  • 降低网络负担:由于信息是以随机节点间的对谈方式传播,避免了集中式的状态查询,从而降低了网络流量。

Gossip 协议

Gossip 主要特点:

  1. 分布式信息传播:每个节点定期向其他节点发送其状态信息,确保所有节点对集群的状态有一致的视图。
  2. 低延迟和高效率:Gossip 协议设计为轻量级的通信方式,能够快速传播信息,减少单点故障带来的风险。
  3. 去中心化:没有中心节点,所有节点平等地参与信息传播,提高了系统的鲁棒性。

工作原理:

  1. 状态更新:每个节点在给定的时间间隔内,向随机选择的其他节点发送其自身的状态信息,包括节点的主从关系、槽位分布等。
  2. 信息更新:接收到状态信息的节点会根据所接收到的数据更新自己的状态,并将更新后的状态继续传播给其他节点。
  3. 节点检测:通过周期性发送状态信息,节点可以检测到其他节点的存活状态。如果某个节点未能在预定时间内响应,则该节点会被标记为故障节点。
  4. 容错处理:在检测到节点故障后,集群中的其他节点可以采取措施(如重新分配槽)以保持系统的高可用性。

Redis 集群分片原理图示

Redis 集群会将数据分散到 16384(2^14)个哈希槽中。集群中的每个节点负责一定范围的哈希槽。在 Redis 集群中,使用 CRC16 哈希算法计算键的哈希值,以确定该键应存储在哪个节点。

集群结构如下所示:

image-20250525235928469.png
Redis集群结构

每个节点会有一部分的槽位,然后对应的键值会根据其本身的 key,映射到一个哈希槽中,其主要流程如下:

  • 根据键的 key,按照 CRC 16 算法计算一个 16 bit 的值,然后将 16 bit 的值对 16384 取模进行取余运算,最后得到一个对应的哈希槽编号。
  • 根据每个节点分配的哈希槽区间,对应编号的数据就在对应的区间上,就能找到对应的分片实例。

为了方便大家理解,我这里画一个对应的关系图,以三个节点为例:

image-20250526000058232.png
Redis集群中存储key示例

假设我们有一个 Redis 集群,包含三个主节点(Node1、Node2、Node3),它们分别负责以下哈希槽:

  • Node1:哈希槽 0-5460
  • Node2:哈希槽 5461-10922
  • Node3:哈希槽 10923-16383

现在要存储一个键为 user:10001 的数据。

计算哈希槽:

  1. 使用 CRC16 哈希算法计算 user:10001 的哈希值。
  2. 假设计算结果为 12345。
  3. 然后,计算该值对应的哈希槽:
    • 哈希槽 = 12345 % 16384 = 12345。

确定目标节点:

  • 12345 落在 Node3 的负责范围(10923-16383),因此,user:10001 会被存储在 Node3 中。

Redis 集群中请求 key 示例(客户端直接连接的并不是对应 key 的节点)

如果客户端连接的是集群中的 Node1,但需要访问存储在 Node3 的键 user:10001,查询过程如下:

查询过程:

  1. 计算哈希槽

    • 客户端使用 CRC16 算法计算 user:10001 的哈希值(假设为 12345)。
    • 计算哈希槽:12345 % 16384 = 12345。
  2. 查询请求

    • 因为客户端连接的是集群中的 node1,所以客户端发送查询命令 GET user:10001 到 Node1。
  3. Node1 响应

    • Node1 检测到请求的键 user:10001 属于 Node3,返回一个 moved 错误,指示客户端请求的键在另一个节点上,moved 错误会返回目标节点的信息(例如,Node3 的 IP 和端口)。
  4. 重新连接

    • 客户端根据返回的目标节点信息,建立与 Node3 的连接。
  5. 再次发送查询请求

    • 客户端向 Node3 发送 GET user:10001。
  6. 获取结果

    • Node3 查询到 user:10001 的值(假设为 {“name”: “查调峰”, “age”: 18}),并返回结果。

为什么 Redis 哈希槽节点的数目是 16384 呢?

github 上有人向作者提问过:

image-20250526000319688.png
github提问截图

简单总结分析一下:

1)首先是消息大小的考虑

正常的心跳包需要带上节点完整配置数据,心跳还是比较频繁的,所以需要考虑数据包的大小。如果使用 16384 数据包只要 2k,如果用了 65535 则需要 8k。

实际上槽位信息使用一个长度为 16384 位的数组来表示,节点拥有哪个槽位,就将对应位置的数据信息设置为1,否则为 0。

心跳数据包就包含槽位信息如下图所示:

image-20250526000538434.png
心跳数据包就包含槽位信息

这里我们看到一个重点,即在消息头中最占空间的是 myslots[CLUSTER SLOTS/8]。

  • 当槽位为 65536 时,这块的大小是:65536÷8÷1024=8kb
  • 当槽位为 16384 时,这块的大小是:16384÷8÷1024=2kb

如果槽位为 65536 ,这个 ping 消息的消息头就太大了,浪费带宽。

2)集群规模的考虑

集群不太可能会扩展超过 1000 个节点,16384 够用且使得每个分片下的槽又不会太少。

653.Redis 集群会出现脑裂问题吗?

回答重点

Redis 集群存在脑裂问题的风险,特别是在网络分区的情况下,可能会导致同一集群内出现多个主节点,导致数据不一致。

扩展知识

什么是脑裂

脑裂是指在分布式系统中,由于网络分区或其他问题导致系统中的多个节点(特别是主节点)误以为自己是唯一的主节点。这种情况会导致多个主节点同时提供写入服务,从而引起数据不一致。

分布式系统就像一个团队在干活,如果发生了脑裂,就好比这个团队突然因为某些原因,比如通信出了问题,分成了几个小团体。

每个小团体都以为自己是整个团队,都在按自己的方式工作,各自为政,对同一件事有不同的决策和做法,就像有的说要这么干,有的说要那么干。

这样一来,整个系统就乱套了,数据也可能变得不一致,服务也变得不正常了,这就是分布式系统中的脑裂。

导致脑裂出现原因主要是网络分区

Redis 中的脑裂

例如发生了网络分区,主节点与哨兵、从节点分区了。

ruaszolD_image_mianshiya.png
image.png

此时哨兵发现联系不上主节点,于是发起选举,选了新的主节点,此时 Redis 就出现了两个主节点:

I1aqgA6i_image_mianshiya.png
image.png

这就发生了脑裂,此时客户端写数据应该写到哪台上呢?写哪都会导致数据不一致

Redis 中如何避免脑裂问题的发生呢?

这里需要了解两个参数:

  • min-slaves-to-write:设置主节点在至少有指定数量的从节点确认写操作的情况下才执行写操作
  • min-salves-max-lag:设置从节点的最大延迟(以秒为单位),如果从节点的延迟超过这个值,则该从节点不会被计入 min-slaves-to-write 的计数中

举个例子:当 min-slaves-to-write 设置为 2,min-slaves-max-lag 设置为 10 秒时,主节点只有在至少有 2 个从节点延迟不超过 10 秒的情况下才会接受写操作

这两个参数就使得发生脑裂的时候,如果某个主节点跟随的从节点数量不够或延迟较大,就无法被写入,这样就能避免脑裂导致的数据不一致。

建议集群部署奇数个节点,例如集群数为 5,那么可以设置 min-slaves-to-write 为 3,min-slaves-max-lag 为 5-10 秒。

脑裂问题能完全避免吗?

并不能。即使配置了以上两个参数也可能会因为脑裂导致数据不一致。

举个例子,假设某个主节点临时出了问题,哨兵判断它主观下线,然后开始发起选举。

在选举进行的时候,主节点恢复了,此时它还是跟着很多从节点,假设 min-slaves-max-lag 配置了 10s,可能此时从节点和主节点延迟的时间才 6s,因此此时主节点还是可以被写入。

而等选举完毕了,选出新的主节点,旧的主节点被哨兵操作需要 salveof 新主,此时选举时间内写入的数据会被覆盖,因此就导致了数据不一致的问题。

654.Redis 的订阅发布功能是什么?你了解吗?

回答重点

Redis 的订阅发布功能(Publish/Subscribe,简称 Pub/Sub),是一种消息通信机制,用于在不同客户端之间实现消息的实时传递和广播。使用 Pub/Sub 模型,客户端可以订阅一个或多个频道,当有其他客户端向这些频道发布消息时,所有订阅了该频道的客户端都会立即收到消息。

主要功能包括:

  • 发布(Publish):某个客户端向指定的频道发布消息。
  • 订阅(Subscribe):一个或多个客户端订阅指定的频道,并在频道接收到消息时立刻获取该消息。

扩展知识

什么是发布/订阅模型?

发布/订阅其实属于消息队列中的一种消息通信模型,生产者(pub)发送消息,订阅者(sub)接收消息。

这里我们展示一下 Redis 的发布/订阅逻辑。如下图所示,发布者向一个或者多个频道发布消息,订阅者可以订阅一个或者多个频道所发布的消息。

当发布者发送新消息到对应的频道的时候,订阅者可以收到对应的信息。

Snipaste_2024-05-16_22-22-00_mianshiya.jpg
Snipaste_2024-05-16_22-22-00.jpg

Redis 实现发布/订阅

简单演示下 Redis 发布/订阅模型的使用。

首先我们先创建并发布一个 message 到 Redis 频道,这里我们用三条信息进行对应的测试,如图所示

1
publish channel message

Snipaste_2024-05-16_22-30-25_mianshiya.jpg
Snipaste_2024-05-16_22-30-25.jpg

然后实现对应的频道订阅

1
subscribe channel 

Snipaste_2024-05-16_22-32-55_mianshiya.jpg
Snipaste_2024-05-16_22-32-55.jpg

可以看到,频道发布的消息都被消费到了。

解析下上述结果。

首先是 subscribe 以及 channel,其分别表示执行订阅以及订阅的频道。

integer 1 表示订阅成功。

message 表示接收到的消息,之后出现的 channel 表示订阅频道,后面就是消息数据。

订阅成功之后,客户端会一直保持订阅状态,直到手动取消订阅或连接断开。

到此为止,一个基于 Redis 简单的发布/订阅模型就成功实现了。

它支持消费者阻塞式拉取消息,另外还提供了匹配订阅,消费者可以根据一定的规则,订阅多个队列。

例如subscribe mianshiya.*,此时如果生产者发布了 mianshiya.1mianshiya.2mianshiya.3 等队列的消息,消费者都可以接收到。

命令汇总

  • SUBSCRIBE channel:用于订阅某个频道。
  • PUBLISH channel message:用于向某个频道发布消息。
  • UNSUBSCRIBE channel:取消订阅某个频道。
  • PSUBSCRIBE pattern:支持使用模式匹配订阅(如通配符*)来动态地订阅多个频道。

Redis 发布/订阅模型的原理

Redis 是如何实现这个功能的呢?实际上很简单。

消费者订阅队列,实际上就是在 Redis 中保存了一个映射关系,即队列 x -> 消费者 1。

如果生产者往这个队列 x 发送一条消息,Redis 不会做任何的存储动作,而是查找映射关系,然后立马转发给消费者1,所以 Redis 实际上就是提供了一个“转发通道”。如果找到多个映射关系,那么就都转发,所以支持多消费者的实现。

因此,不论是 rdb 还是 aof 都不会存储消息

Redis 发布/订阅模型的缺点

会丢数据!

从原理我们就可以得知,Redis 并不会存储消息,那么如果生产者发布消息的时候,消费者宕机了,当服务恢复时,其间生产者发送的消息就丢失了。

不仅是宕机会导致消息丢失,如果消费者消费过慢也有可能导致消息丢失。

在面试鸭《[651. Redis 主从复制的实现原理是什么?](#651Redis 主从复制的实现原理是什么)》这题中,提到了 client-output-buffer-limit 参数,默认 pubsub 配置的是 32mb 8mb 60,即缓冲区一旦超过 32 MB 或持续 60s 超过 8M 直接把消费者下线。

Redis 虽然不会存储消息,但是消息会先写入这个缓冲区,供消费者拉取消费。如果消费者处理太慢,导致这个缓冲区溢出,那么消费者被强行下线,消息也就丢了。

Redis 发布/订阅模型应用场景

Redis 的哨兵集群和 Redis 实例通信用的就是发布/订阅模型。

一般业务上的即时通信场景也可以使用,但是由于消息会丢失,大部分情况下还是会使用消息队列中间件来实现发布/订阅。

655.Redis 中如何实现分布式锁?

回答重点

在 Redis 中实现分布式锁的常见方法是通过set ex nx 命令 + lua 脚本组合使用。确保多个客户端不会获得同一个资源锁的同时,也保证了安全解锁和意外情况下锁的自动释放。

扩展知识

理解 Redis 实现的分布式锁

如果基于 Redis 来实现分布锁,需要利用 set ex nx 命令 + lua 脚本。

1)加锁:SET lock_key uniqueValue EX expire_time NX 2)解锁:使用 lua 脚本,先通过 get 获取 key 的 value 判断锁是否是自己加的,如果是则 del。

lua 脚本如下:

1
2
3
4
5
6
if redis.call("GET",KEYS[1]) == ARGV[1]
then
    return redis.call("DEL",KEYS[1])
else
    return 0
end

我们来一一理解一下。

首先锁需要有过期机制。假设某个客户端加了锁之后宕机了,锁没有设置过期机制,会使得其他客户端都无法抢到锁。

EX expire_time 就是设置了锁的过期,单位是秒。还一个 PX, 也是过期时间,单位是毫秒。

然后,在 2.6.12 版本之前只有 SETNX 即 SET if Not eXists,它表示如果 key 已存在,则什么都不会做,返回 0,如果不存在则会设置它的值,返回 1。

那个时候 SETNX 和过期时间的设置就无法保证原子性,如果客户端在发送完 SETNX 之后就宕机了,还没来得及设置过期时间,一样会导致锁不会被释放。

因此 2.6.12 版本之后,优化了 SET 命令,使得可以执行 set ex px

最后,再解释下什么是 uniqueValue,翻译过来就是一个唯一的值。

之所以要设置唯一值是为了防止被别的客户端给释放了

我们来看下这个场景:

  1. 客户端 1 加锁成功,然后执行业务逻辑,但执行的时间超过了锁的过期时间
  2. 此时锁已经过期被释放了,客户端 2 加锁成功
  3. 客户端 2 执行业务逻辑
  4. 客户端 1 执行完了,执行释放锁的逻辑,即删除锁。

客户端 2 一脸懵,我还在执行着呢,怎么锁被人释放了??

所以每个客户端加锁(客户端可能是每个线程),需要是设置一个唯一标识,比如一个 uuid,防止锁被别的客户端误释放了。

因为需要先判断锁的值和唯一标识是否一致,一致后再删除释放锁,这里就涉及到两步操作,只有使用了 lua 脚本才能保证原子性,这也是为什么释放锁需要用 lua 脚本的原因。

单点故障问题

单台 Redis 实现分布式锁存在单点故障问题,如果采用主从读写分离架构,如果一个客户端在主节点上锁成功,但是主节点突然宕机,由于主从延迟导致从节点还未同步到这个锁,此时可能有另一个客户端抢到新晋升的主节点,此时会导致两个客户端抢到锁,产生了数据不一致。

基于这个情况,Redis 推出了 Redlock。

Redlock 红锁

Redlock 是 Redis 官方推荐的一种实现分布式锁的算法,适用于集群环境下。

Redlock 的基本思想

  • 部署多个 Redis 实例(通常为 5 个)。
  • 客户端在大多数实例(如至少 3 个)上请求锁,并在一定时间内获得成功,表示加锁成功。
  • 使用 Redlock 可以提供更高的容错性,即使部分 Redis 实例故障,仍然可以获得锁。

Redlock 的实现流程

  • 客户端尝试在每个 Redis 实例上加锁,必须在有限时间内(通常为锁的过期时间)完成所有实例的加锁。
  • 如果大多数实例(N/2 + 1)加锁成功,则表示加锁成功。
  • 否则,客户端将释放所有已经加锁的实例,重新尝试。

Redlock 的缺点包括

  • 复杂性:实现 Redlock 需要多个 Redis 实例,增加了系统的复杂性和维护成本。

  • 时间同步依赖:Redlock 依赖于多个节点的系统时间的一致性。如果节点之间的时间不同步(例如发生时间回拨),可能导致锁的有效性受到影响。

  • 不适用于高并发:在高并发场景下,因需要访问多个实例同时尝试获取锁,可能会导致锁获取的性能下降。

  • 锁的续期问题:在长时间的操作中,可能需要手动续期锁,因为涉及多个实例,增加了实现的复杂度和风险。

  • Redis 的 Red Lock 是什么?你了解吗?

Redis 分布锁注意事项以及其它常见分布式锁的实现

656.分布式锁在未完成逻辑前过期怎么办?

回答重点

若锁在未完成逻辑前就过期,此时可能会产生数据不一致的问题。因为锁过期了,此时如果再出现一个客户端争抢锁,即可拿到锁然后同时进行业务操作,这等于锁失效了。

Snipaste_2024-05-19_06-39-26_mianshiya.jpg
Snipaste_2024-05-19_06-39-26.jpg

此时可以在逻辑执行过程中定期续期锁,确保锁在处理过程中不会过期。

扩展知识

看门狗机制

业界出了一个看门狗机制来防止这种情况的产生。

理论很简单,在抢到锁之后,后台会有一个任务,定时向 redis 进行锁的续期。比如锁的过期时间是 30s,可以每过三分之一时长(30/3)10s 后就去 redis 重新设置过期时间为 30s。

在锁被释放的时候,就移除这个定时任务。

Snipaste_2024-05-19_06-52-47_mianshiya.jpg
Snipaste_2024-05-19_06-52-47.jpg

redisson

redisson 是一个类库,封装了很多 redis 操作,便于我们使用。

其实现的分布式锁就引入了看门狗机制,具体原理和上面所述的一致,基于 Netty 的时间轮实现的定时任务,关于时间轮的面试题解析可查看《时间轮》。

并且 redisson 支持可重入锁,即同一个线程可以多次获取同一个分布式锁,而不会导致死锁。实现方法如下:

  • 在获取锁时,检查当前锁的唯一标识是否已经属于当前线程。
  • 如果是,则增加一个重入计数器。
  • 释放锁时,减少重入计数器,只有当计数器为 0 时才真正释放锁。

657.Redis 的 Red Lock 是什么?你了解吗?

回答重点

Red Lock,又称为红锁,是一种分布式锁的实现方案,旨在解决在分布式环境中使用 Redis 实现分布式锁时的安全性问题。

一般情况下,我们在生产环境会使用主从+哨兵方式来部署 Redis。

如果我们正在使用 redis 分布式锁,此时发生了主从切换,但从节点上不一定已经同步了主节点的锁信息

所以新的主节点上可能没有锁的信息。此时另一个业务去加锁,一看锁还没被占,于是抢到了锁开始执行业务逻辑。

此时就发生了两个竞争者同时进入临界区操作临界资源的情况,可能就会发生数据不一致的问题。

所以 Redis 官方推出了红锁,避免这种状况产生。它主要解决的问题就是当部分节点发生故障也不会影响锁的使用和数据问题的产生。

扩展知识

红锁实现原理

首先要使用红锁需要集群部署 redis,官方推荐至少 5 个实例,不需要部署从库和哨兵,仅需主库。

这 5 个实例(可以更多,我们按 5 个来讲述)之间没有任何关系(不同于 redis cluster),它们之间不需要任何信息交互。

客户端会对这 5 个实例依次申请锁,如果最终申请成功的数量超过半数(>=3),则表明红锁申请成功,反之失败。

再来看下异常情况。假设有一台实例宕机了怎么办?实际上没任何影响,因为理论上能申请成功的数量可以达到 4,超过了半数。

也因为没有主从机制,不会有同步丢失锁的问题。

具体加锁流程如下

1)客户端获取当前时间(t1)。

2)客户端按照顺序依次对 N 个 Redis 节点利用 set 命令进行加锁操作,对每个节点加锁都会设置超时时间(远小于锁的总过期时间),如果当前节点请求超时,立马向下一个节点申请锁。

3)当客户端成功从半数的 Redis 节点获取到了锁,这个时候获取一下当前时间 t2,然后计算加锁过程的总耗时 t(t2 - t1)。如果 t < 锁的过期时间,这个时候就可以判断加锁成功,反之加锁失败。

4)加锁成功则执行业务逻辑,加锁失败则依次向全部节点发起释放锁的流程。

上述的 set 加锁命令和解锁命令都遵循正常的分布式锁的使用规范,例如设置过期时间、唯一标识、lua 脚本释放等等。更详细可看面试鸭《redis 如何实现分布式锁?》这题。

红锁一定安全吗?

不一定。

来看下面这个图,是一位大佬 Martin Kleppmann 画的:

3PzxWmKE_image_mianshiya.png
image.png

解释一下。如果 client 1 抢到了红锁,但是此时发生了 gc(垃圾回收),暂停(STW)了很久,与此同时 redis 中的锁过期了。

锁过期的同一时刻 gc 结束了,client 1 认为自己还持有锁,正常执行后续逻辑,而 client 2 也在此时拿到了锁,开始执行后续逻辑。

这不就有问题了吗?

除了 gc,如果出现时钟漂移,例如几个 redis 实例时间跳跃,导致锁提前过期了,也可能会造成别的 client 抢到锁。

所以,从理论上看红锁并不是无懈可击的,还是有概率出现问题,只不过这个概率非常小

业务上

从上面的内容大家也可以知道红锁的实现成本其实不低,需要有至少 5 个实例,而且因为要依次加锁,所以性能来说也比不上单实例的 redis 加锁,且极端环境下还是有问题。

所以一般业务上还是使用主从+哨兵来实现分布式锁。

658.Redis 实现分布式锁时可能遇到的问题有哪些?

回答重点

  1. 业务未执行完,锁已到期

  2. 单点故障问题

  3. 主从问题不同步问题

  4. 网络分区问题

  5. 时钟漂移问题

  6. 锁的可重入性问题

  7. 误释放锁问题

扩展知识

业务未执行完,锁已到期

为了避免持有锁的客户端崩溃或因网络问题断开连接时,锁无法被正常释放,需要给锁设置过期时间。

那么就有可能出现业务还在执行,锁已到期的情况。

可以设置一种续约机制(Redisson 中的看门狗机制),线程 a 在执行的时候,设置一个超时时间,并且启动一个守护线程,守护线程每隔一段时间就去判断线程 a 的执行情况,如果 a 还没有执行完毕并且 a 的时间快过期了,就重新设置一下超时时间,即继续续约。

锁的过期时间的设置也需要好好评估一下。

如果设置太长,业务结束了它还在阻塞的话,会影响 Redis 的性能。如果设置太短,就出现刚说的问题,因此要保证是设置一个合理的时间使得在大多数情况下任务能够在锁过期之前完成

单点故障问题

如果 Redis 单机部署,当实例宕机或不可用,整个分布式锁服务将无法正常工作,阻塞业务的正常执行。

主从问题

如果线上 Redis 是主从+哨兵部署的,则分布式锁可能会有问题。

因为 Redis 的主从复制过程是异步实现的,如果 Redis 主节点获取到锁之后,还没同步到其他的从节点,此时 Redis 主节点发生宕机了,这个时候新的主节点上没锁的数据,因此其他客户端可以获取锁,就会导致多个应用服务同时获取锁。

网络分区

在网络不稳定的情况下,客户端与 Redis 之间的连接可能中断,如果未设置锁的过期时间,可能会导致锁无法正常释放。如果有多个锁,还可能引发锁的死锁情况。

时钟漂移

因为 Redis 分布式锁依赖于实例的时间来判断是否过期,如果时钟出现漂移,很可能导致锁直接失效。

可以让所有节点的系统时钟通过 NTP 服务进行同步,减少时钟漂移的影响。

659.Redis 中的缓存击穿、缓存穿透和缓存雪崩是什么?

回答重点

缓存击穿:指某个热点数据在缓存中失效,导致大量请求直接访问数据库。此时,由于瞬间的高并发,可能导致数据库崩溃。

缓存穿透:指查询一个不存在的数据,缓存中没有相应的记录,每次请求都会去数据库查询,造成数据库负担加重。

缓存雪崩:指多个缓存数据在同一时间过期,导致大量请求同时访问数据库,从而造成数据库瞬间负载激增。

解决方案

缓存击穿

  • 使用互斥锁,确保同一时间只有一个请求可以去数据库查询并更新缓存。
  • 热点数据永不过期。

缓存穿透

  • 使用布隆过滤器,过滤掉不存在的请求,避免直接访问数据库。
  • 对查询结果进行缓存,即使是不存在的数据,也可以缓存一个标识,以减少对数据库的请求。

缓存雪崩

  • 采用随机过期时间策略,避免多个数据同时过期。
  • 使用双缓存策略,将数据同时存储在两层缓存中,减少数据库直接请求。

扩展知识

缓存雪崩解析

Snipaste_2024-06-11_14-36-10.png

缓存雪崩是指在某个时间点,大量缓存同时失效或被清空,导致大量请求直接打到数据库或后端系统,造成系统负载激增,甚至引发系统崩溃。

这通常是由于缓存中的大量数据在同一时间失效引起的。

想象一个电商系统,用户量很大,假设某一系列的商品缓存突然同一时间失效,那就会造成我们的缓存雪崩,导致服务全部打到了数据库,引发严重后果。

解决办法

缓存键同时失效:

1)过期时间随机化:设置缓存的过期时间,加上一个随机值,避免同一时间大量缓存失效。

2)使用多级缓存:引入多级缓存机制,如本地缓存和分布式缓存相结合,减少单点故障风险。

3)缓存预热:系统启动时提前加载缓存数据,避免大量请求落到冷启动状态下的数据库。

4)加互斥锁:使得没缓存或缓存失效的情况下,同一时间只有一个请求来构建缓存,防止数据库压力过大。

缓存中间件故障:

1)服务熔断:暂停业务的返回数据,直接返回错误。

2)构建集群:构建多个 Redis 集群保证其高可用。

缓存击穿解析

Snipaste_2024-06-11_14-36-32.png

缓存击穿指的是某一热点数据缓存失效,使得大量请求直接打到了数据库,增加数据库负载。

想象一下大家都在抢茅台,但在某一时刻茅台的缓存失效了,大家的请求打到了数据库中,这就是缓存击穿。

缓存雪崩是多个 key,大量数据同时过期,而缓存击穿是某个热点 key 崩溃,可以认为缓存击穿是缓存雪崩的子集。

解决办法

1)加互斥锁:保证同一时间只有一个请求来构建缓存,跟缓存雪崩相同。

2)热点数据永不过期:不要给热点数据设置过期时间,在后台异步更新缓存。

缓存穿透解析

Snipaste_2024-06-11_14-36-38.png

缓存穿透是指查询一个不存在的数据,由于缓存中肯定不存在,导致每次请求都直接访问数据库,增加数据库负载。

攻击者可以通过构造不存在的 key 发起大量请求,对数据库造成很大的压力,可能会造成系统宕机。

解决办法

1)防止非法请求:检查非法请求,封禁其 IP 以及账号,防止它再次为非作歹。

2)缓存空值:将数据库中不存在的结果(例如空值)也缓存起来,并设置一个较短的过期时间,避免频繁查询数据库。

3)使用布隆过滤器:使用布隆过滤器来快速判断一个请求的数据是否存在,如果布隆过滤器判断数据不存在,则直接返回,避免查询数据库。

互斥锁实现示例(Java)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class CacheService {
    private Jedis jedis = new Jedis("localhost");
    private Lock lock = new ReentrantLock();

    public String getData(String key) {
        // 尝试从缓存获取数据
        String value = jedis.get(key);
        
        // 如果缓存不存在
        if (value == null) {
            // 加锁以防止并发请求
            lock.lock();
            try {
                // 再次检查缓存,避免重复查询
                value = jedis.get(key);
                if (value == null) {
                    // 查询数据库
                    value = queryDatabase(key);
                    // 将结果放入缓存
                    jedis.set(key, value);
                }
            } finally {
                // 释放锁
                lock.unlock();
            }
        }
        return value;
    }
}

说明

  1. 缓存查询:首先尝试从 Redis 中获取数据。
  2. 加锁:如果缓存中没有数据,使用 ReentrantLock 加锁,确保只有一个线程可以查询数据库。
  3. 二次检查:在加锁后再次检查缓存,避免重复查询。
  4. 数据库查询:如果缓存仍然没有数据,查询数据库并将结果存入缓存。
  5. 释放锁:确保锁在查询结束后被释放,以防止死锁。

这种方式有效地防止了缓存击穿,因为即使在高并发的情况下,只有一个请求会去数据库查询数据,其他请求则会等待锁释放。如果后端是多实例部署,一般实例数量也不多,即使使用本地锁也行,因为并发也不高。

布隆过滤器

660.Redis 中如何保证缓存与数据库的数据一致性?

回答重点

缓存和数据库的同步可以通过以下几种方式:

1)先更新缓存,再更新数据库

2)先更新数据库存,再更新缓存

3)先删除缓存,再更新数据库,后续等查询把数据库的数据回种到缓存中

4)先更新数据库,再删除缓存,后续等查询把数据库的数据回种到缓存中

5)缓存双删策略。更新数据库之前,删除一次缓存;更新完数据库后,再进行一次延迟删除

6)使用 Binlog 异步更新缓存,监听数据库的 Binlog 变化,通过异步方式更新 Redis 缓存

以上就是实现数据库与缓存一致性的六种方式,这里前面三种都不太推荐使用,后面三种的话其主要根据实际场景:

  • 如果是要考虑实时一致性的话,先写 MySQL,再删除 Redis 应该是较为优的方案,虽然短期内数据可能不一致,不过其能尽量保证数据的一致性。
  • 如果考虑最终一致性的话,推荐的是使用 binlog + 消息队列的方式,这个方案其有重试和顺序消费,能够最大限度地保证缓存与数据库的最终一致性。

Snipaste_2024-05-18_11-11-29_mianshiya.jpg
Snipaste_2024-05-18_11-11-29.jpg

扩展知识

我们以 MySQL 和 Redis 为主要实现的案例对上述几个方案再进行展开分析。

先更新缓存,再更新数据库

Snipaste_2024-05-18_11-24-36_mianshiya.jpg
Snipaste_2024-05-18_11-24-36.jpg

由于网络原因,请求顺序无法保证,可能出现先更新缓存的请求,后更新数据库,而后更新缓存的请求反而先更新了数据库,这样就出现了缓存数据为 20,数据库数据为 10,即数据不一致的情况。

先写数据库,再写缓存

Snipaste_2024-05-18_16-27-02_mianshiya.jpg
Snipaste_2024-05-18_16-27-02.jpg

这个问题上面其实一样,都是因为并发和网络问题导致的数据库与缓存不一致。

先删除缓存再写数据库

Snipaste_2024-05-18_16-36-07_mianshiya.jpg
Snipaste_2024-05-18_16-36-07.jpg

盘一下流程:

  1. 请求 A 先对缓存中的数据进行删除操作。
  2. 请求 B 这个时候来执行查询,发现缓存中数据为空,就去数据库进行查询并回写缓存。
  3. 这个时候请求 A 删除缓存中的数据之后,进行数据库数据的更新。
  4. 但此时请求 B 已经把从数据库查询到的原始数据回写缓存了,这个时候就出现了上图的情况,数据库中查询的值是 20,而缓存中的数据是 10 。

读操作获取到的数据是过时的数据,虽然写操作已经完成了,但是因为缓存被删除了,读操作就必须从数据库中读取到旧值,并不是最新的数据。

缓存双删(先删除缓存,再写数据库,然后过一段时间再删除缓存)

Snipaste_2024-05-18_16-59-05_mianshiya.jpg
Snipaste_2024-05-18_16-59-05.jpg

这个方案为了避免旧数据被回种,等待一段时间后再延迟删除缓存。

也可以使用消息队列、定时任务或者延迟任务等方式去实现延迟删除:

Snipaste_2024-05-18_17-19-34_mianshiya.jpg
Snipaste_2024-05-18_17-19-34.jpg

先写数据库,再删除缓存

oHkdUP16_image_mianshiya.png
image.png

先把数据库的信息修改了,然后再删除对应的缓存,然后在修改数据库期间,可以允许一定时间的缓存不一致,保证缓存的最终一致性。

不过这种模型也有一定的问题,如下图所示:

Snipaste_2024-05-18_21-31-46_mianshiya.jpg
Snipaste_2024-05-18_21-31-46.jpg

其主要原因在于有一个写操作,此时刚好缓存失效,又在同一时刻刚好有一个并发读请求过来,且回写缓存的请求晚于缓存删除,导致数据库与缓存的不一致。

从上面的表述可以知道,这个发生的概率比较低,一般而言业务上都会使用这个方案。

先写数据库,通过 Binlog ,异步更新缓存

Snipaste_2024-05-18_22-45-52_mianshiya.jpg
Snipaste_2024-05-18_22-45-52.jpg

先修改数据库,然后通过 Canal 监听数据库的 binlog 日志,记录数据库的修改信息,然后通过消息队列异步修改缓存的数据。

这里需要注意需要保证顺序消费,保证缓存中数据按顺序更新,然后再加上重试机制,避免因为网络问题导致更新失败。

强一致性补充

可以使用分布式读写锁实现强一致性。读写锁中读和读操作不互斥,读写互斥,写写互斥。

写操作流程

  • 获取写锁。
  • 更新数据库。
  • 删除缓存。
  • 释放写锁。

读操作流程

  • 获取读锁。
  • 查询缓存:如果命中缓存,释放读锁,返回结果。如果缓存未命中,读取数据库,并将数据更新到缓存。
  • 释放读锁。

661.Redis String 类型的底层实现是什么?(SDS)

回答重点

Redis 中的 String 类型底层实现主要基于 SDS(Simple Dynamic String 简单动态字符串)结构,并结合 int、embstr、raw 等不同的编码方式进行优化存储。

扩展知识

C 语言字符串的缺陷

Redis 为什么没有使用 C 标准库提供的字符串,而是实现了一种动态字符串?因为 C 语言的字符串本质上就是 char* 的字符数组,存在一定缺陷:

  • C 语言字符数组的结尾位置就用“\0”表示,意思是指字符串的结束
  • C 语言字符数组获取长度只能通过遍历获得,时间复杂度是 O(n)
  • 字符串操作函数不高效且不安全,比如缓冲区溢出,其可能导致程序异常终止

对比 C 语言字符串

特性 SDS(Redis 动态字符串) C语言原生字符串
结构 自定义结构,包含长度、分配空间等元信息 \0 结尾的字符数组
长度记录 独立记录字符串长度,获取长度为 O(1) 通过遍历计算字符串长度,获取长度为 O(n)
二进制安全 支持任意二进制数据,包括 \0 不支持,遇到 \0 时认为字符串结束
动态扩展 自动扩展或缩减内存,减少内存分配次数 无动态扩展能力,需手动管理内存
内存分配策略 使用 预分配策略,预留额外空间以减少扩展时的内存重新分配次数 每次重新分配内存需要拷贝数据
内存碎片 减少碎片,扩展时预留空间,缩减时可以主动释放多余内存 手动分配内存,易造成碎片
API安全性 提供丰富且安全的操作接口(避免越界、溢出) 使用不当易越界或内存溢出
多种数据存储 支持存储普通字符串、二进制数据或其他自定义结构 仅支持以 \0 结尾的字符序列
内存浪费 可能浪费少量预分配的内存 没有预分配,可能节省内存

sds 进一步解析

SDS 底层结构是 sdshdr,在 redis 4.x 及以上版本引入了 sdshdr 变种,如 sdshdr16、sdshdr64 等,根据字符串长度动态选择不同的实现,进一步优化内存使用。

例如以下的 sdshdr64

1
2
3
4
5
6
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

1)len (长度):记录了 SDS 字符串数组的长度,当需要获取字符串长度的时候,只需要返回这个成员变量的值就可以了,时间复杂度是 O(1)。

2)alloc(分配空间长度):这个字段的主要作用是指分配给字符数组的存储的空间大小,当需要计算剩余空间大小的时候,只需要 alloc - len 就可以直接进行计算,然后判断空间大小是否符合修改需求,如果不满足需求的话,就执行相应的修改操作,这样的话就可以很好地解决我们上面所说的缓冲区溢出问题。

3)flags(表示 SDS 的类型):一共设计了五种类型的 SDS,分别是 sdshdr 5、sdshdr 8、sdshdr 16、sdshdr 32、sdshdr 64(这个的记忆也很简单,就是 32 开始,128,即 2 的多少次方去记忆就可以了),通过使用不同存储类型的结构题,灵活保存不同大小的字符串,从而节省内存空间。

4)buf(存储数据的字符数组):主要起到保存数据的作用,如字符串、二进制数据(二进制安全就是一个重要原因)等。

不同编码的选择

1)int 编码:如果一个字符串可以被解析为整数,并且整数值比较小,Redis 会直接使用整数编码。

1
2
3
4
5
struct redisObject {
    unsigned type:4;      // 数据类型(字符串、哈希等)
    unsigned encoding:4;  // 编码类型(int、embstr、raw等)
    int64_t ptr;          // 实际的数据指针,这里直接存储整数值
};

比如直接存储 123,则:

GDJxwzQe_image_mianshiya.png
image.png

2)embstr 编码:当字符串长度比较短(小于等于 44 字节),Redis 会使用 embstr 编码,这种编码将所有的字符串相关结构体和字符数据存放在连续的内存块中,分配内存的时候,只需要分配一次,减少内存分配和管理的开销。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct redisObject {
    unsigned type:4;       // 数据类型
    unsigned encoding:4;   // 编码类型,这里是 embstr
    void *ptr;             // 指向 sdshdr 结构
};

struct sdshdr {
    uint32_t len;          // 当前字符串长度
    uint32_t alloc;        // 已分配的内存大小
    unsigned char flags;   // 编码类型
    char buf[];            // 实际字符串数据
};

srXXjrt6_image_mianshiya.png
image.png

3)raw 编码:当字符串长度超过 44 字节时,Redis 会使用 raw 编码,这种编码方式将结构体和实际字符串数据分开存储,以便处理更长的数据。

L9ccodYq_image_mianshiya.png
image.png

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct redisObject {
    unsigned type:4;       // 数据类型
    unsigned encoding:4;   // 编码类型,这里是 raw
    void *ptr;             // 指向 sdshdr 结构
};

struct sdshdr {
    uint32_t len;          // 当前字符串长度
    uint32_t alloc;        // 已分配的内存大小
    unsigned char flags;   // 编码类型
    char buf[];            // 实际字符串数据
};

redis 4.0 版本及之后的版本,这个界限是 44,前面版本是 39,详情见:827. Redis 中 EMBSTR 对象的阈值设置为何为 44?其调整历史是什么?

小结

  • int 编码:用于存储可以解析为整数的字符串,内存消耗最小,适合数字值。
  • embstr 编码:用于存储较短的字符串,将元数据和内容存储在同一块内存中,适合读多写少的场景。
  • raw 编码:用于存储较长的字符串,元数据和内容分开存储,适合需要频繁操作的大字符串。

662.如何使用 Redis 快速实现排行榜?

回答重点

使用 Redis 实现排行榜的方式主要利用 Sorted Set(有序集合),它可以高效地存储、更新、以及获取排名数据。

实现排行榜的主要步骤:

1)使用 Sorted Set 存储分数和成员

  • 使用 Redis 的 ZADD 命令,将用户和对应的分数添加到有序集合中。例如:ZADD leaderboard 1000 user1,将用户 user1 的分数设置为 1000。

2)获取排名

  • 使用 ZRANK 命令获取某个用户的排名。例如:ZRANK leaderboard user1,返回用户 user1 的排名(从 0 开始)。

3)获取前 N 名

  • 使用 ZREVRANGE 命令获取分数最高的前 N 名。例如:ZREVRANGE leaderboard 0 9 WITHSCORES,获取排行榜前 10 名用户及其分数。

4)更新分数

  • 如果用户的分数需要更新,可以使用 ZINCRBY 命令对其分数进行加减操作。例如:ZINCRBY leaderboard 500 user1,将用户 user1 的分数增加 500。

扩展知识

Sorted Set 的特点

  • Sorted Set 是 Redis 中的一个数据结构,内部使用 跳表(Skip List) 来实现,提供按分数排序的功能。每个成员有一个唯一的 score(分数),根据分数进行排序。
  • Redis 的 Sorted Set 通过 O(logN) 的时间复杂度进行插入、更新和删除操作,且可以通过范围查找快速获取指定区间的数据。
  • 使用 Sorted Set 可以确保成员唯一性,因为 Redis 的 Sorted Set 中每个成员都是唯一的。如果添加相同的成员,ZADD 将更新其分数而不是重复插入。

Sorted Set 相关 Redis 命令

  • ZADD:向有序集合中添加成员并设置分数。如果成员已经存在,则更新其分数。
  • ZRANK / ZREVRANK:返回指定成员的排名。ZRANK 是按从小到大的排名,ZREVRANK 是从大到小(即逆序)。
  • ZREVRANGE:按分数从高到低返回指定区间内的成员列表,常用于获取排行榜的前 N 名。
  • ZINCRBY:对有序集合中指定成员的分数进行增减操作。

Java 示例代码

以下是一个完整的 Java 示例代码,供大家参考:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
import redis.clients.jedis.Jedis;

import java.util.Set;
import java.util.Map;
import java.util.HashMap;

public class RedisLeaderboard {

    private static final String LEADERBOARD_KEY = "leaderboard";
    private Jedis jedis;

    public RedisLeaderboard() {
        // 连接到 Redis
        this.jedis = new Jedis("localhost", 6379);
    }

    // 添加或更新用户分数
    public void addOrUpdateUserScore(String user, double score) {
        jedis.zadd(LEADERBOARD_KEY, score, user);
    }

    // 获取排行榜前 N 名用户
    public Set<String> getTopUsers(int topN) {
        return jedis.zrevrange(LEADERBOARD_KEY, 0, topN - 1);
    }

    // 获取用户的排名
    public Long getUserRank(String user) {
        return jedis.zrevrank(LEADERBOARD_KEY, user);
    }

    // 获取用户的分数
    public Double getUserScore(String user) {
        return jedis.zscore(LEADERBOARD_KEY, user);
    }

    // 删除用户
    public void removeUser(String user) {
        jedis.zrem(LEADERBOARD_KEY, user);
    }

    // 获取排行榜分页查询
    public Set<String> getUsersByPage(int page, int pageSize) {
        int start = (page - 1) * pageSize;
        int end = start + pageSize - 1;
        return jedis.zrevrange(LEADERBOARD_KEY, start, end);
    }

    // 关闭连接
    public void close() {
        jedis.close();
    }

    public static void main(String[] args) {
        RedisLeaderboard leaderboard = new RedisLeaderboard();

        // 添加用户及其分数
        leaderboard.addOrUpdateUserScore("user1", 100);
        leaderboard.addOrUpdateUserScore("user2", 200);
        leaderboard.addOrUpdateUserScore("user3", 150);

        // 更新用户分数
        leaderboard.addOrUpdateUserScore("user1", 250);

        // 获取排行榜前 3 名用户
        Set<String> topUsers = leaderboard.getTopUsers(3);
        System.out.println("Top 3 users: " + topUsers);

        // 获取用户的排名
        Long rank = leaderboard.getUserRank("user1");
        System.out.println("User1 rank: " + rank);

        // 获取用户的分数
        Double score = leaderboard.getUserScore("user1");
        System.out.println("User1 score: " + score);

        // 获取排行榜第 2 页,每页 2 个用户
        Set<String> pageUsers = leaderboard.getUsersByPage(2, 2);
        System.out.println("Page 2 users: " + pageUsers);

        // 删除用户
        leaderboard.removeUser("user1");

        // 关闭连接
        leaderboard.close();
    }
}

663.如何使用 Redis 快速实现布隆过滤器?

回答重点

布隆过滤器是一种高效的概率数据结构,常用于检测一个元素是否在一个集合中,可以有效减少数据库的查询次数,解决缓存穿透等问题。

可以通过使用 位图(Bitmap) 或使用 Redis 模块 RedisBloom

1)使用位图实现布隆过滤器

  • 使用 Redis 的位图结构 SETBITGETBIT 操作来实现布隆过滤器。位图本质上是一个比特数组,用于标识元素是否存在。
  • 对于给定的数据,通过多个 哈希函数 计算位置索引,将位图中的相应位置设置为 1,表示该元素可能存在。

2)使用 RedisBloom 模块

  • Redis 提供了一个官方模块 RedisBloom,封装了哈希函数、位图大小等操作,可以直接用于创建和管理布隆过滤器。
  • 使用 BF.ADD 来向布隆过滤器添加元素,使用 BF.EXISTS 来检查某个元素是否可能存在。

扩展知识

布隆过滤器原理

布隆过滤器是由一个 位数组k 个独立的哈希函数 组成。

添加元素时,通过 k 个哈希函数将元素映射到位数组的 k 个位置上,将这些位置设置为 1。

检查元素是否存在时,同样计算 k 个位置,如果所有位置都是 1,则说明元素可能存在;只要有一个位置为 0,就可以确定元素一定不存在。

例如某个 key 通过 hash-1 和 hash-2 两个哈希函数,定位到数组中的值都为 1,则说明它存在。

g11g5XEF_image_mianshiya.png
image.png

如果布隆过滤器判断一个元素不存在集合中,那么这个元素一定不在集合中,如果判断元素存在集合中则不一定是真的,因为哈希可能会存在冲突。因此布隆过滤器有误判的概率

V9mSKzUv_image_mianshiya.png
image.png

而且它不好删除元素,只能新增,如果想要删除,只能重建。

布隆过滤器优缺点

1)优点

  • 高效性:插入和查询操作都非常高效,时间复杂度为 O(k),k 为哈希函数的数量。
  • 节省空间:相比于直接存储所有元素,布隆过滤器大幅度减少了内存使用。
  • 可扩展性:可以根据需要调整位数组的大小和哈希函数的数量来平衡时间和空间效率。

2)缺点

  • 误判率:可能会误认为不存在的元素在集合中,但不会漏报(不存在的元素不会被认为存在)。
  • 不可删除:一旦插入元素,不能删除,因为无法确定哪些哈希值是由哪个元素设置的。
  • 需要多个哈希函数:选择合适的哈希函数并保证它们独立性并不容易。

使用 Redis 位图(bitmap)实现布隆过滤器

bitmap 基本操作:

  • SETBIT key offset value:将 key 的值在 offset 位置上的位设置为 value(0 或 1)。
  • GETBIT key offset:获取 key 的值在 offset 位置上的位的值(0 或 1)。
  • BITCOUNT key [start end]:计算字符串中设置为 1 的位的数量。
  • BITOP operation destkey key [key …]:对一个或多个 key 进行位运算,并将结果存储在 destkey 中。支持的操作包括 AND、OR、XOR 和 NOT。

实现布隆过滤器流程:

  • 位图使用单个位来表示某个位置的状态,通过 Redis 的 SETBIT key offset value 操作可以设置位图中某个偏移位置的值。
  • 假设有 k 个哈希函数 H1, H2, ..., Hk,对于每个新加入的元素 x,通过这些哈希函数计算位置,将相应位置的比特位设置为 1。
  • 查询元素是否存在时,计算相同的 k 个位置并用 GETBIT key offset 来判断这些位置是否都是 1。

以下 Java 中为利用 bitmap 实现布隆过滤器的例子,供大家参考:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import redis.clients.jedis.Jedis;
import java.nio.charset.StandardCharsets;
import java.util.BitSet;
import java.util.List;
import java.util.ArrayList;

public class RedisBloomFilter {

    private static final String BLOOM_FILTER_KEY = "bloom_filter";
    private static final int BITMAP_SIZE = 1000000; // 位图大小
    private static final int[] HASH_SEEDS = {3, 5, 7, 11, 13, 17}; // 多个哈希函数的种子

    private Jedis jedis;
    private List<SimpleHash> hashFunctions;

    public RedisBloomFilter() {
        this.jedis = new Jedis("localhost", 6379);
        this.hashFunctions = new ArrayList<>();
        for (int seed : HASH_SEEDS) {
            hashFunctions.add(new SimpleHash(BITMAP_SIZE, seed));
        }
    }

    // 添加元素到布隆过滤器
    public void add(String value) {
        for (SimpleHash hashFunction : hashFunctions) {
            jedis.setbit(BLOOM_FILTER_KEY, hashFunction.hash(value), true);
        }
    }

    // 检查元素是否可能存在于布隆过滤器中
    public boolean mightContain(String value) {
        for (SimpleHash hashFunction : hashFunctions) {
            if (!jedis.getbit(BLOOM_FILTER_KEY, hashFunction.hash(value))) {
                return false;
            }
        }
        return true;
    }

    // 关闭连接
    public void close() {
        jedis.close();
    }

    // 简单哈希函数
    public static class SimpleHash {
        private int cap;
        private int seed;

        public SimpleHash(int cap, int seed) {
            this.cap = cap;
            this.seed = seed;
        }

        public int hash(String value) {
            int result = 0;
            byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
            for (byte b : bytes) {
                result = seed * result + b;
            }
            return (cap - 1) & result;
        }
    }

    public static void main(String[] args) {
        RedisBloomFilter bloomFilter = new RedisBloomFilter();

        // 添加元素到布隆过滤器
        bloomFilter.add("user1");
        bloomFilter.add("user2");
        bloomFilter.add("user3");

        // 检查元素是否可能存在
        System.out.println("Does user1 exist? " + bloomFilter.mightContain("user1")); // 输出: true
        System.out.println("Does user4 exist? " + bloomFilter.mightContain("user4")); // 输出: false

        // 关闭连接
        bloomFilter.close();
    }
}

在 Java 中的 redisson 提供了 bloomFilter 类,可直接使用这个类提供的布隆过滤器实现。

RedisBloom 模块的使用

RedisBloom 是 Redis 官方提供的插件,简化了布隆过滤器的实现,提供了更好的性能和更少的误判率控制。

常用命令:

  • BF.RESERVE key error_rate capacity:创建一个布隆过滤器,指定误判率和容量。
  • BF.ADD key item:向布隆过滤器中添加一个元素。
  • BF.EXISTS key item:检查一个元素是否可能存在。
    • RedisBloom 可以自动调整底层数据结构大小以适应不断增加的数据量,用户可以指定误判率。

RedisBloom实现布隆过滤器的步骤示例

1)创建布隆过滤器(基于 RedisBloom 模块):

1
BF.RESERVE myBloomFilter 0.01 1000000

上述命令创建了一个误判率为 1% 且容量为 100 万的布隆过滤器。

2)添加元素

1
BF.ADD myBloomFilter "item1"

3)检查元素是否存在

1
2
BF.EXISTS myBloomFilter "item1"    ## 返回 1(可能存在)
BF.EXISTS myBloomFilter "item2"    ## 返回 0(一定不存在)

布隆过滤器适用场景

布隆过滤器一般都在海量数据判断场景,且可以允许误判。

1)爬虫:

对已经爬取过的海量 URL 去重。

2)黑名单:

例如反垃圾邮件,用于判断一个邮件地址是否在黑名单中,提高垃圾邮件过滤的效率(可能会误杀)。

3)分布式系统:

用于判断数据是否在某个节点上,减少网络请求,提高系统性能。例如,Hadoop 和 Cassandra 都使用布隆过滤器来优化数据分布和查找。

4)推荐系统:

用于判断用户是否已经看过某个推荐内容,避免重复推荐。

664.如何使用 Redis 统计大量用户唯一访问量(UV)?

回答重点

Redis 中 HyperLogLog 结构,可以快速实现网页 UV 、PV 等统计场景。它是一种基数估算算法的概率性数据结构,可以用极少的内存统计海量用户唯一访问量的近似值。

Set 也可以实现,用于精确统计唯一用户访问量,但是但当用户数非常大时,内存开销较高。

扩展知识

HyperLogLog 使用介绍

HyperLogLog 具有极小的内存占用(每个 HyperLogLog 结构约 12 KB),而允许一定的误差(通常在 0.81% 以内)。即使统计数百万用户,内存开销依然是恒定的(约 12 KB),非常适合海量数据场景。

HyperLogLog 的基本操作:

  • PFADD key element [element …]:将一个或多个元素添加到 HyperLogLog 数据结构中。
  • PFCOUNT key [key …]:返回 HyperLogLog 结构中不重复元素的近似数量。
  • PFMERGE destkey sourcekey [sourcekey …]:将多个 HyperLogLog 合并为一个。

以下是一个示例代码,展示在 Java 中如何使用 Redis 的 HyperLogLog 实现页面 UV 统计:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import redis.clients.jedis.Jedis;

public class RedisHyperLogLogUV {

    private static final String UV_KEY = "uv";

    private Jedis jedis;

    public RedisHyperLogLogUV() {
        // 连接到 Redis
        this.jedis = new Jedis("localhost", 6379);
    }

    // 添加用户访问记录
    public void addUserVisit(String userId) {
        jedis.pfadd(UV_KEY, userId);
    }

    // 获取独立用户访问量
    public long getUniqueVisitorCount() {
        return jedis.pfcount(UV_KEY);
    }

    // 关闭连接
    public void close() {
        jedis.close();
    }

    public static void main(String[] args) {
        RedisHyperLogLogUV uvCounter = new RedisHyperLogLogUV();

        // 模拟用户访问
        uvCounter.addUserVisit("user1");
        uvCounter.addUserVisit("user2");
        uvCounter.addUserVisit("user3");
        uvCounter.addUserVisit("user1"); // 重复访问
        uvCounter.addUserVisit("user4");

        // 获取独立用户访问量
        long uniqueVisitors = uvCounter.getUniqueVisitorCount();
        System.out.println("Unique Visitors: " + uniqueVisitors); // 输出: 4

        // 关闭连接
        uvCounter.close();
    }
}

HyperLogLog 原理(简单了解即可)

HyperLogLog 基于概率算法来进行基数估算,其核心思想是通过哈希函数和最大零前缀长度来估算集合中唯一元素的数量。

以下是 HyperLogLog 的实现原理:

1. 哈希函数
  • HyperLogLog 通过一个哈希函数将输入元素映射到一个大的哈希空间。例如,将一个元素 x 使用哈希函数 h(x) 映射到一个非常大的数。
  • 这个哈希空间通常会是 2^32 或者更高。哈希函数的目的是将输入均匀地分布在哈希空间中。
2. 最大零前缀长度
  • HyperLogLog 的核心思想基于一个统计特性:哈希值中前导零的长度可以反映集合的基数。哈希值的前导零越长,意味着集合中元素的数量越多。
  • 假设有一系列元素被哈希,哈希结果是 0001...,则前导零是 3。通过记录这些哈希值的最大零前缀长度,可以推算出基数。
3. 分桶(Register Binning)
  • 为了提高估算的准确性,HyperLogLog 将哈希值划分到多个桶中,每个桶独立计算前导零的长度。
  • HyperLogLog 会将整个哈希空间划分为 2^p 个子集合,每个子集合称为一个 桶(register),并且每个桶都存储最大零前缀的长度(例如,哈希值的前 p 位用来决定应该存储在哪个桶中,后续的位用于计算前导零的长度)。
  • 通过分桶,可以减小估算误差。每个桶保存该桶中所有元素的最大前导零长度。
4. 基数估算(Harmonic Mean)
  • 一旦所有元素被哈希并存储在相应的桶中,HyperLogLog 就会通过这些桶中的前导零长度来计算集合的基数。
  • HyperLogLog 使用了一种称为 调和平均数(Harmonic Mean) 的方法来对这些前导零长度进行求平均,并通过一个复杂的数学公式(估算函数)来推算出整体集合的基数(这种方式可以有效减少由于单个极值而导致的偏差)。

HyperLogLog 的实现步骤:

1)数据哈希和映射

  • 每个输入元素通过哈希函数映射为一个 64 位的二进制值。
  • 前 p 位用于确定该元素映射到哪个桶,后续的位用于计算前导零。

2)前导零统计

  • 每个桶记录映射到该桶的元素的最大前导零的长度。
  • 对所有桶的数据进行调和平均来估算整体的基数。

3)基数估算

  • Redis 使用一些估算函数来根据调和平均的结果推算出集合的基数。

HyperLogLog 的优点和局限性

优点

1)内存占用小:HyperLogLog 的内存消耗几乎是固定的,为 12 KB,不管元素的数量有多大,这使它非常适合用于需要统计大规模数据集合的场景。

2)高效:对于插入和基数查询操作的时间复杂度都是 O(1),因为插入一个新元素只涉及到哈希和更新桶的操作。

3)适用于去重计数:HyperLogLog 非常适用于统计网站访问量、唯一用户数等需要去重的数据统计场景。

局限性

1)不支持精确计数:HyperLogLog 是一种近似算法,存在一定的误差,误差率为 0.81%。它不适用于需要精确统计的场景。

2)无法删除元素:HyperLogLog 无法从集合中删除元素,因为它只记录了桶中的最大前导零数,因此删除操作会使得统计失去意义。

3)只能用于基数估算:HyperLogLog 只适合用于统计集合的基数(也就是唯一元素的数量),无法获取集合的具体内容或其他信息。

665.Redis 中的 Geo 数据结构是什么?

回答重点

Redis 中的 Geo(Geolocation 的简写形式,代表地理坐标) 数据结构主要用于地理位置信息的存储。通过这个结构,可以方便地进行地理位置的存储、检索、以及计算地理距离等操作。Geo 数据结构底层使用了 Sorted Set,并且结合了 Geohash 编码算法来对地理位置进行处理。

它是 Redis 2.2 版本后新增的数据类型

扩展知识

Geo 常用命令

GEOADD key longitude latitude member

  • 将地理坐标信息添加到 Redis 中。例如:GEOADD locations 13.361389 38.115556 “mianshiya”。

GEODIST key member1 member2 [unit]

  • 计算两个地理位置之间的距离,单位可以是 m(米)、km(公里)、mi(英里)等。例如:GEODIST locations “mianshiya” “mianshiya1” km。

GEORADIUS key longitude latitude radius unit

  • 查找指定位置附近指定范围内的所有点。例如:GEORADIUS locations 15 37 200 km 查找在坐标(15, 37)200 公里范围内的所有地点。

GEORADIUSBYMEMBER key member radius unit

  • 以某个已存在位置为中心,查找指定范围内的地点。例如:GEORADIUSBYMEMBER locations “mianshiya” 100 km。
GEORADIUS 和 GEORADIUSBYMEMBER 的替代

自 Redis 6.2 开始,GEORADIUSGEORADIUSBYMEMBER 被标记为弃用(deprecated),推荐使用新的命令组合。

  • GEOSEARCH:功能上替代 GEORADIUS,可以支持按长方形或圆形范围查找位置。
  • GEOSEARCHSTORE:可以将搜索结果直接存储到一个新的 key 中,便于后续操作。

Geo 底层实现

  • Redis 的 Geo 数据结构使用 Sorted Set 作为底层数据结构。每个地理位置点被编码为一个 Geohash 值,并使用 Geohash 的排序特性来存储在 Redis 的 Sorted Set 中,分数则是通过 Geohash 生成的。
  • Geohash 是一种空间编码方法,将二维地理坐标(纬度和经度)编码成一个字符串。这样在对地理坐标排序时,可以通过 Geohash 生成的值来方便地实现近似的范围查找。

GeoHash 编码

Geohash 是一种将二维地理坐标(经度和纬度)编码为一维字符串的方法。

它将地理区域递归地划分成更小的网格,并为每个网格分配一个唯一的标识符。

Geohash 的基本思想如下:

  • 区域划分:将整个地球分成一个矩形,并不断地将每个矩形划分成更小的矩形。
  • 编码:根据划分区域的递归顺序,为每个区域分配一个唯一的二进制编码。编码的长度越长,表示的位置越精确。

当拿到一个坐标的时候,Geo 会先根据这组坐标转换为 Geohash 编码,这个编码其实就代表了某块区域,将这个编码值作为 Sorted Set 中相应元素的权重分数。

这样一来,我们就可以实现经纬度信息存储到 Sorted Set 中这个需求,并且利用 Sorted Set 提供的 “按分数进行有序范围查找的特性”,实现坐标等地理位置信息的搜索以及查询。

Geo 数据结构的应用场景

  • 位置服务:在提供基于地理位置的服务时,例如查找附近的餐馆、商店、加油站等,Geo 数据结构可以帮助高效地完成这些查询。
  • 物流系统:用于计算配送点之间的距离,找出最优的配送路线。
  • 社交推荐:可以用于基于用户位置查找附近的朋友或者同类用户。

666.你在项目中使用的Redis客户端是什么?

回答要点

常见在项目中使用的客户端有以下三种:

  • Jedis 适用于简单的同步操作和单线程环境。
  • Lettuce 适用于高并发、高性能和多线程环境,尤其是需要异步和响应式编程的场景。
  • Redisson 适用于复杂的分布式系统,提供丰富的分布式对象和服务,简化开发。

Jedis

Jedis 是一款比较经典的 Java 客户端了,里面提供了比较全面的 Redis 命令,也是使用最为广泛的 Redis 客户端。

Jedis 是一个用 Java 实现的 Redis 客户端库,它具有以下优点:

  • 简单易用:提供了直观的 API,使得开发者能够方便地与 Redis 进行交互。
  • 使用广泛:在 Java 社区中被广泛采用,有丰富的文档和示例可供参考。
  • 性能良好:在大多数情况下能够提供高效的 Redis 操作。
  • 功能丰富:支持常见的 Redis 操作,如字符串、列表、哈希、集合等数据结构的操作。

然而,Jedis 也存在一些缺点:

  • 线程安全问题:线程不安全,每个线程需独立使用 Jedis 实例。
  • 不支持自动重连:在网络异常或 Redis 服务器重启时,需要手动处理重连。
  • 阻塞操作:同步的 API,因此高并发下可能会发生阻塞。

Lettuce

Lettuce 其相对于 Jedis,其最突出的点就是线程安全,且其扩展性较高,从 SpringBoot 2.X 开始,Lettuce 逐渐取代 Jedis 成为 SpringBoot 默认的 Redis 客户端。它支持异步和响应式 API,底层基于 Netty 实现。

Lettuce 的优点包括:

  • 多线程安全:在多线程环境中可以安全使用。
  • 高性能:提供了高效的 Redis API 操作性能。
  • 自动重连:当网络连接出现问题时,能够自动重新连接。
  • 支持多种编程模型:同步、异步、响应式,适应不同的应用场景

缺点:

  • 学习曲线较陡:API 相对复杂,学习曲线较高。
  • 资源消耗:异步和响应式 API 可能会消耗更多的资源,需要仔细调优。

Redisson

Redisson 是一个高级的 Redis 客户端,提供分布式和并行编程的支持,提供了丰富的分布式对象和服务,底层也是基于 Netty 实现通信。

优点:

  • 易用性:简化了 Redis 操作,提供了简洁的 API。
  • 高级特性:支持分布式锁、缓存、队列等常见场景。
  • 支持集群:支持 Redis 集群模式,适应大规模分布式应用。
  • 线程安全:无需手动处理多线程问题。
  • 高性能:优化的底层实现,提高性能。
  • 稳定性:经过广泛使用和验证。

缺点:

  • 学习成本:需要一定时间来熟悉其 API 和特性。
  • 可能的依赖问题:与其他库的兼容性可能需要注意。

667.Redis 字符串类型的最大值大小是多少?

回答重点

Redis 字符串能存储的最大容量是 512MB 的数据,可以查看官方文档:

image-20250525233330845.png
image-20250525233330845

无论是网络传输、内存分配还是字符串操作,大字符串都会增加 Redis 服务器的负载。

且过大的字符串在 GET、SET、APPEND、STRLEN 等操作都会导致性能瓶颈。

所以官方给字符串的大小做了限制,防止单个键值对占用过多的内存,影响整体性能和稳定性。

668.Redis 性能瓶颈时如何处理?

回答重点

如果 Redis 无法承受当前的负载的话,可以考虑从以下几个解决方法去解决:

首先想到的是扩容,比如增加 Redis 的配置,容纳更多的内存等。

如果超过单机配置了,那么可以上 redis 主从,通过从服务分担读取数据的压力,利用哨兵自动进行故障转移。

还可以利用 redis 集群 进行数据分片,比如 Redis Cluster。

也可以增加本地内存,通过多级缓存分担 redis 的压力。

扩展知识

redis 主从复制和读写分离

通过配置 Redis 主从复制,可以实现读写分离,将写操作交给主节点进行处理,而读操作可以交给从节点,从而减轻主节点的压力。

主从架构的优势在于可以横向扩展读取能力,但是存在主从同步延迟的问题,如果从节点落后较多,可能会出现读取旧数据的情况。

redis 集群

827.Redis 中 EMBSTR 对象的阈值设置为何为 44?其调整历史是什么?

为什么 EMBSTR 的阈值大小是 44 个字节?

这个问题有几个关键点:

1)Redis 使用的是 jemalloc 作为内存分配器。

2)jemalloc 是以 64 字节作为内存单位进行内存分配的,如果超过了 64 字节,即超过了一个内存单元,使用的就是 raw 编码,反之使用的就是 EMBSTR 编码。

3)核心就是这个 64 字节,围绕 64 字节这个关键点来分析。Redis 的字符串对象是 redisObject 和 sdshdr 这两个部分组成的,redisObject 大小为

4 + 4 + 24 + 32 + 64 = 128bits = 16 bytes(16 字节)

这个是一直没有改变的,其计算的来源如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
   // from Redis 3.9.5
#define LRU_BITS 24

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    /* LRU time or LFU data */
    unsigned lru:LRU_BITS; 
    
    int refcount;
    void *ptr;
} robj;

然后我们再来看一下 sdshdr 的结构:

1
2
3
4
5
6
7
// from Redis 3.9.5
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

sdshdr 占用的内存大小:3 byte + 字符数组的大小,由于字符数组内部保留的一个’\0’的占位符,所以剩下能用的空间就只有 44 个字节了。

那为什么之前版本的阈值是 39 呢?

其主要还是因为 sds 结构的版本差异,在 3.2 以前 sdshdr 的版本结构如下:

1
2
3
4
5
struct SDS {
  unsigned int capacity; // 4byte
  unsigned int len; // 4byte
  byte[] content; // 内联数组,长度为 capacity
}

我们可以看到,非数据字段就占用了 8 个字节,为了节约内存,3.2 版本之后的sds不再使用 sdshdr5 这个结构了,就剩下 sdshdr8、sdshdr16、sdshdr32、sdshdr64 这 4 个结构。

然后 EMBSTR 使用 sdsjdr8 节约了 6 个字节,然后多引入一个 flags 字段占用 1 字节,所以现版本的 EMBSTR 相比 3.2 版本之前的 sds 多了 5 个字节。

总结

主要是因为 Redis 使用 jemalloc 内存分配器,jemalloc 以 64 字节作为阈值区分大小字符串 raw 和 EMBSTR。

然后 redisObject 固定占用 16 个字节,然后 sdshdr 中已分配、已申请、标记这 3 个字段各自占用 1 个字节,’\0’占用 1 个字节,最终剩余 44 个字节。

因为 3.2 前后 Redis 关于 sdshdr 结构的差异,3.2 之后的版本 EMBSTR 使用 sdshdr8 这个结构,总容量和已使用容量字段减少了 6 个字节,但是 3.2 之后的版本增加了一个 flags 字段,所以最终 3.2 版本之前的 EMBSTR 结构少了 5 个字节。

837.Redis 中原生批处理命令(MSET、MGET)与 Pipeline 的区别是什么?

回答重点

原生批处理命令(MSET、MGET)Pipeline 都可以用于一次性处理多个命令,但它们在实现方式和应用场景上有所不同:

1)MSET / MGET(原生批处理命令)

  • MSETMGET 是 Redis 提供的原生批处理命令,用于批量设置和获取多个键值。
  • 它们是 单个命令,可以一次操作多个键值对,因此只需要进行一次网络往返,适合对多个键值进行原子性的读写操作。

2)Pipeline

  • Pipeline 是 Redis 提供的一种机制,允许客户端一次发送多个命令,Redis 会批量处理这些命令,然后将结果依次返回。Pipeline 可以大幅度减少网络延迟,因为它一次性发送多个命令,避免了每次命令都等待回复的网络往返时间。
  • Pipeline 并不是一个单一的 Redis 命令,而是一种机制,适用于任意类型的 Redis 操作,非原子性。

扩展知识

性能对比

  • 在执行批量操作时,MSET / MGET 的执行速度可能比 Pipeline 更快,因为它们是原生支持的批处理命令,并且对于特定任务(批量读写键值)进行了优化。
  • Pipeline 的优势在于减少客户端和 Redis 之间的网络延迟,在需要执行多种操作(不仅仅是 MSET / MGET)时,Pipeline 可以提供更高的灵活性和更好的吞吐量。

使用示例

MSET / MGET

1
2
MSET key1 value1 key2 value2 key3 value3
MGET key1 key2 key3

Pipeline(伪代码示例):

1
2
3
4
5
pipeline = redis_client.pipeline()
pipeline.set('key1', 'value1')
pipeline.set('key2', 'value2')
pipeline.get('key3')
results = pipeline.execute()

在上述代码中,多个命令被批量发送,Redis 一次性执行并返回所有结果,减少了网络往返的时间。

注意事项

  • MSET / MGET 的局限性:它们只能用于批量的写入和读取键值对,无法用于执行其他类型的 Redis 操作。
  • Pipeline 的内存消耗:当使用 Pipeline 时,如果命令数量特别大(如上百万条),可能会导致 Redis 服务端或客户端的内存消耗增加,因为 Pipeline 会将所有的命令暂存在内存中等待发送或处理。
  • 错误处理:在 Pipeline 中,如果某些命令失败,开发者需要通过返回值判断哪些命令执行成功,哪些失败。由于 Pipeline 是将多个命令批量发送,因此无法在中途停止执行,这使得错误处理相对复杂。

实际应用场景

1)原生批处理命令适用于一次性处理多个键值对或获取多个键的值的场景,通过减少单独请求的次数来提高效率。

2)Pipeline 更适用于需要一次性处理多个命令的场景。通过将多个命令打包发送,可以减少网络通信开销,并在一次调用中获取多个命令的结果。

838.Redis 主从复制的常见拓扑结构有哪些?

回答重点

Redis 主从的几种常见拓扑结构如下(忽略哨兵):

1)一主多从

这是最基本的拓扑结构,包含一个主节点和多个从节点。所有写操作都在主节点上执行,而读操作可以在从节点上进行,以提高读取速度和负载均衡。

Snipaste_2024-06-07_09-45-00_mianshiya.png
Snipaste_2024-06-07_09-45-00.png

2)树状主从结构(级联)

从节点也可以作为其他从节点的主节点。这样形成了一个层次结构,主节点负责写操作,而从节点负责读操作,并将数据再次复制到更下一级的从节点。

Snipaste_2024-06-07_09-46-10_mianshiya.png
Snipaste_2024-06-07_09-46-10.png

因为主从复制对主节点有压力,所以这样的结构可以减轻主节点的压力。

3)主主结构(双主或多主)

在这种拓扑中,有两个或多个主节点,它们之间相互复制数据。这种结构提高了系统的写能力和容错性。

Snipaste_2024-06-07_09-46-16_mianshiya.png
Snipaste_2024-06-07_09-46-16.png

但需要处理多主节点之间的数据同步和冲突解决,管理复杂度高,Redis 默认不支持主主复制

扩展知识

880.Redis List 类型的常见操作命令有哪些?

回答重点

Redis 中的 List 类型是一个字符串列表,这里是一些常见的操作命令:

1)lpush:将一个或多个值插入到列表头部。列表不存在,一个新的列表会被创建。

2)rpush:将一个或多个值插入到列表尾部。

3)lpop:移除并返回列表头部的元素。

4)rpop: 移除并返回列表尾部的元素。

5)lrange:获取列表指定范围内的元素。

6)lindex: 通过索引获取列表中的元素。

7)llen: 获取列表长度。

8)lset: 将列表中指定索引的元素设置为另一个值。

9)lrem: 移除列表中与参数匹配的元素。

10)ltrim: 修剪(裁剪)一个已存在的 list,使其只包含指定范围的元素。

示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
lpush mylist a    ## 在列表 'mylist' 的头部插入元素 'a'
rpush mylist b    ## 在列表 'mylist' 的尾部插入元素 'b'
lpop mylist       ## 移除并返回 'mylist' 的第一个元素
rpop mylist       ## 移除并返回 'mylist' 的最后一个元素
lrange mylist 0 -1  ## 返回 'mylist' 中的所有元素
lindex mylist 0     ## 获取 'mylist' 中索引为 0 的元素
llen mylist         ## 返回 'mylist' 的长度
lset mylist 0 x     ## 将 'mylist' 中索引为 0 的元素设置为 'x'
lrem mylist 1 a     ## 从 'mylist' 中移除第一个 'a'
ltrim mylist 1 2   ## 保留 'mylist' 中索引从 1 到 2 的元素,其他的删除

扩展知识

列表存储结构

Redis List 类型的底层实现有两种数据结构,Redis 会根据列表的长度和每个元素的大小自动选择使用哪种结构:

List 的使用场景

  • 消息队列:Redis 的 LPUSHRPOP 组合可以用来实现生产者-消费者模型,将 LPUSH 用于生产者,RPOP 用于消费者,这种方式可实现简单的消息队列。
  • 任务调度:可以使用 LPUSHBRPOP 来实现任务调度,将任务放入队列中,消费者通过阻塞方式从队列中取任务进行处理。
  • 聊天记录:可以使用 Redis List 存储用户的聊天记录,利用 RPUSH 添加消息,使用 LRANGE 获取指定范围内的消息。

列表操作性能问题

  • 大列表操作:当列表非常大时,某些操作(如 LRANGELREM)可能会导致 Redis 阻塞,因为 Redis 是单线程的,因此对大列表的操作应该尽量避免。
  • 列表裁剪:使用 LTRIM 命令对列表进行裁剪,以控制列表的大小,避免列表无限增长导致内存占用过高。例如:LTRIM mylist 0 99,只保留前 100 个元素。

881.如何在 Redis 中实现队列和栈数据结构?

回答重点

可以通过 List 类型 来实现 队列

实现队列(FIFO)

  • 队列是一种 先进先出(FIFO) 的数据结构。在 Redis 中,可以使用 LPUSHRPOP 命令组合来实现队列。
  • LPUSH 向列表的左侧推入元素,而 RPOP 从列表的右侧弹出元素,这样可以保证最先进入的元素最先被弹出。

实现栈(LIFO)

  • 栈是一种 后进先出(LIFO) 的数据结构。在 Redis 中,可以使用 LPUSHLPOP 命令组合来实现栈。
  • LPUSH 向列表的左侧推入元素,而 LPOP 从列表的左侧弹出元素,这样可以保证最后进入的元素最先被弹出。

扩展知识

队列的实现与应用场景

FIFO 队列的实现通过 LPUSHRPOP,或者 RPUSHLPOP,确保数据按插入顺序出队。 例如:

1
2
3
LPUSH myqueue "task1"
LPUSH myqueue "task2"
RPOP myqueue   ## 弹出 "task1"

应用场景

  • 可以用来实现简单的任务队列,例如,将任务按顺序插入到队列中,由多个消费者按照插入顺序依次处理任务。
  • 在消息队列中,可以通过阻塞操作 BLPOPBRPOP 来实现消费等待,使得当队列为空时消费者会阻塞直到新任务加入。

栈的实现与应用场景

LIFO 栈的实现通过 LPUSHLPOP,这样确保最后加入的元素最先被弹出。 例如:

1
2
3
LPUSH mystack "item1"
LPUSH mystack "item2"
LPOP mystack   ## 弹出 "item2"

应用场景

  • 栈的操作可以用于实现回溯功能,例如浏览器中的页面后退功能,每次访问新页面时将页面压入栈,后退时从栈顶弹出最后访问的页面。

队列使用注意点

这里需要注意,如果 redis list 内没数据,则执行 rpop/lpop 操作的时候,会返回 null 值。

所以业务代码消费逻辑只能使用死循环来消费队列,伪代码如下:

1
2
3
4
5
6
7
while(true) {
    msg = redis.rpop("queue");
    if (msg == null) {
        continue;
    }
    process(msg);
}

如果队列里长时间没有消息,这样会导致应用 cpu 空转,疯狂消耗 cpu 资源,还会频繁请求 redis,给缓存上上压力

所以需要改造下,比如加个睡眠时间:

1
2
3
4
5
6
7
8
while(true) {
    msg = redis.rpop("queue");
    if (msg == null) {
        sleep(1s);
        continue;
    }
    process(msg);
}

但是这样消息又会有延迟了,上面就会导致消息延迟 1s,如果缩短睡眠的时间,可能又会导致空转太频繁了。

所以 redis 提供了阻塞式消费 list 的接口,即 brpop/blpop,这里的 b 指的就是 block,并且还支持超时时间,即等待一段时间还未接收到消息,先返回 null,给执行线程处理别的操作的机会。

1
2
3
4
5
6
7
8
while(true) {
    msg = redis.brpop("queue", 5);
    if (msg == null) {
        sout("写一些日志,或者其他什么动作")
        continue;
    }
    process(msg);
}

优先级队列的实现

Redis 的 List 类型适合用于基本的队列和栈,但如果需要实现优先级队列,List 类型并不是最优的选择。

可以考虑使用 Sorted Set 来实现优先级队列,通过分数来表示优先级,然后使用 ZRANGEZPOPMIN 命令获取优先级最高的元素。

例如,使用 ZADD 为任务设置优先级:

1
2
3
ZADD priority_queue 1 "low_priority_task"
ZADD priority_queue 0 "high_priority_task"
ZPOPMIN priority_queue  ## 弹出 "high_priority_task"

List 实现队列和栈的优势与劣势

优势

  • Redis 的 List 提供了高效的插入和删除操作(O(1) 时间复杂度),适用于对队列和栈的快速操作需求。
  • 支持阻塞操作,可以实现生产者-消费者模式,适合任务调度和消息传递。

劣势

  • Redis List 并不适合过大的数据量,尤其是在高并发场景中,List 的长度过大会影响操作效率。
  • 对于需要在队列中查找、删除特定元素的场景,List 的效率不高,因为 Redis List 的随机访问和删除操作时间复杂度为 O(N)。

882.Redis 中的 Ziplist 和 Quicklist 数据结构的特点是什么?

回答重点

Ziplist

  • 简单、紧凑、连续存储,适用于小数据量场景,但对大量数据或频繁的修改操作不太友好。
  • 适合小数据量场景,例如短列表、小哈希表等,因为它的内存紧凑,可以大幅减少内存使用

Quicklist

  • 通过将链表和 Ziplist 结合,既实现了链表的灵活操作,又能节省内存,在 Redis 3.2 之后成为 List 的默认实现。
  • Quicklist 是为了替代纯链表而设计的,适用于需要频繁对列表进行插入、删除、查找等操作的场景,并且数据量可能较大。它在存储多个元素时,既保留了链表的灵活性,又具备压缩列表的内存优势。

扩展知识

Ziplist

Ziplist(压缩列表)是一种紧凑的数据结构,它将所有元素紧密排列存储在单个连续内存块中,十分节省空间。

这些元素可以是字符串或整数,且每个元素的存储都紧凑地排列在一起。

以下是 ziplist 的具体结构:

4F2usTIn_image_mianshiya.png

  • zlbytes:记录整个 ziplist 所占用的字节数。
  • zltail:记录 ziplist 中最后一个节点距离 ziplist 起始地址的偏移量。
  • zllen:记录 ziplist 中节点的个数。
  • entry:各个节点的数据。
  • zlend:特殊值 0xFF,用于标记 ziplist 的结束。

entry 更详细的结构如下,会记录前一个节点的长度和编码:

I3GZ5P84_image_mianshiya.png

也因为 entry 需要记录前一个元素的大小,如果前面插入的元素很大,则已经存在的 entry 的 pre_entry_length 字段需要变大,它又变大后续的节点也需要变,所以可能导致连锁更新的情况,影响性能。

查询需按顺序遍历所有元素,逐个检查是否匹配查询条件。

特点:

  • 紧凑性:所有元素紧密排列在一起,没有额外的内存开销,适合存储少量数据。
  • 顺序访问:由于元素是按顺序存储的,顺序访问性能较好,但随机访问性能较差。

在 Redis 中的应用:

  • 列表(List):当列表长度小于一定阈值(默认 512 个元素)且每个元素的长度小于 64 字节时,Redis 会使用 Ziplist 存储列表。
  • 哈希表(Hash):当哈希表中键值对的数量少于一定阈值(默认 512 对)且每个键和值的长度都小于 64 字节时,Redis 会使用 Ziplist 存储哈希表。

Quicklist

Quicklist 结合了 Ziplist 和双端链表的优点,每个 Quicklist 节点都是一个 Ziplist,它限制了单个 Ziplist 的大小,降低级联更新产生的影响

dCNHJ06V_image_mianshiya.png

特点:

  • 高效内存利用:结合了 Ziplist 的内存紧凑性和双端链表的快速插入、删除操作。
  • 降低级联更新产生的影响。

在 Redis 中的应用:

  • 列表(List):当列表长度超过 Ziplist 的阈值时,Redis 会使用 Quicklist 存储列表。

883.Redis 复制延迟的常见原因有哪些?

回答重点

Redis 的复制延迟是指从节点同步主节点数据时可能出现时间延迟。在读写分离场景,这个延迟会导致明明写入了数据,但是去从节点查的时候没查到。

可能原因如下:

1)网络原因

可能是带宽不足,或者网络抖动导致同步的延迟。

不过一般内网情况下不会产生这个问题。

2)主节点负载过高

主节点接收到大量的写操作,在处理客户端请求的同时,还需向从节点发送复制数据。如果主节点负载较高时,来不及处理从服务的复制请求,就会导致复制延迟。

大量写操作无法避免。但是我们可优化下写入的结构,精简数据,降低单条数据的大小。

3)复制缓存区溢出

复制缓存区暂存当前主节点接收到的写命令,待传输给从节点。如果从节点处理过慢,写入的命令又过多,则会导致复制缓冲区溢出,此时从节点就需要重新执行全量复制,导致延迟。

可通过 client-output-buffer-limit 间接控制缓冲区大小(详细看扩展知识内的主从复制原理)。

4)主节点持久化,无法及时响应复制请求

生成 RDB 快照或 AOF 文件重写都会占用大量的 CPU 和 I/O 资源,可能会影响复制的速度。

避免在高峰期触发持久化动作。

5)从节点配置太差

因为从节点需要接收、处理和存储主节点发送的数据。如果从节点性能较低,处理数据的速度会慢,从而导致延迟。

此时需要升配。

扩展知识

884.Redis 事务与关系型数据库事务的主要区别是什么?

回答重点

Redis 的事务跟严格意义上的关系型数据库事务不一样,先复习下数据库事务 ACID 的定义:

  • 原子性(Atomicity):事务中的所有操作要么全部成功,要么全部失败,且可以回滚到事务开始前的状态。
  • 一致性(Consistency):事务执行前后,数据库必须保持一致的状态。
  • 隔离性(Isolation):事务的执行是隔离的,事务之间不会相互干扰。支持不同的隔离级别(如读未提交、读提交、可重复读、序列化)。
  • 持久性(Durability):一旦事务提交,数据变更就会永久保存在数据库中。

我们一一对比下 Redis 中的事务是否符合 ACID:

  • 原子性:Redis 事务提供部分原子性,但不是完整的ACID事务。如果事务中的一个命令失败,其他命令仍然会继续执行,事务不会自动回滚
  • 一致性:无法回滚,无法保证一致性。
  • 隔离性:Redis 事务使用的是单线程模型,在执行事务的过程中,其他命令不会插入到事务执行的过程中,但无法设置不同隔离级别。
  • 持久性:Redis 事务的持久性依赖于配置(如 RDB 快照和 AOF),但理论上还是会丢数据。

所以, Redis 的事务根本就不是我们理解的传统事务。

扩展知识

Redis 事务简单介绍

1)Redis 的事务是通过 MULTI、EXEC、DISCARD 和 WATCH 命令来控制的。

事务开始时用 MULTI 命令,此后的所有命令都不会被立即执行,而是被放入一个队列。要执行 exec 命令才会原子性连续执行,其间不会被插入其他命令。

2)事务的执行不支持回滚,如果中间命令出错后续的命令还是会继续执行,且不会回滚之前的执行。

3)Redis 使用 WATCH 命令实现乐观锁机制。如果被 WATCH 监控的键改变,则直接中断事务,并不会回滚。

885.Redis Cluster 模式与 Sentinel 模式的区别是什么?

回答重点

1)Redis Cluster 是 Redis 集群,提供自动分片功能,将数据自动分布在多个节点上,支持自动故障转移。如果一个节点失败,集群会自动重新配置和平衡,不需要外部介入,因为它内置了哨兵逻辑

2)Sentinel 是哨兵,主要用于管理多个 Redis 服务器实例来提高数据的高可用性。当主节点宕机,哨兵会将从节点提升为主节点,它并不提供数据分片功能。

如果需要处理大量数据并进行数据分片,应选择 Redis Cluster,它支持水平扩展,适用于大规模数据、高吞吐量场景。

如果只是为了提高 Redis 实例的可用性,并不需要数据分片,应选择主从 + Sentinel,它主要关注故障转移和实例高可用,适用于高可用性、读写分离场景。

扩展知识

886.Redis 的 ListPack 数据结构是什么?

回答重点

ListPack 是 Redis 内部的一种数据结构,用于高效存储短小的字符串或整数集合。它是一种紧凑型的序列化数据结构,旨在减少内存占用和提升性能。为了尽可能紧凑地存储数据,因此它没有使用 Redis 常见的对象模型,而是直接以字节序列的形式存储数据。

ListPack 是 Redis 6.0 引入的新数据类型,在 List、Hash 和 ZSet 的内部实现中使用。

ListPack 的优点:

  • 内存高效:通过变长编码和紧凑存储减少内存占用。
  • 高性能:由于数据紧凑存储,可以更快地进行读取和写入操作。
  • 简单实现:结构简单,便于实现和维护。

扩展知识

为什么引入了 ListPack

很多人对 ListPack 比较陌生,它其实是用来替代 ziplist 的。

在 ListPack 中,Redis 作者描述了下面这段话:

tQer0vtL_image_mianshiya.png

因为发现了一个 bug,怀疑了 ziplist 连锁更新导致的,所以就设计了 ListPack。

它采用一种紧凑的格式来存储多个元素(本质上仍是一个字节数组),并且使用多种编码方式来表示不同长度的数据。

ACcIDfw1_image_mianshiya.png

  • header:整个 listpack 的元数据,包括总长度和总元素个数。
  • elements:实际存储的元素,每个元素包括长度和数据部分。
  • end:标识 listpack 结束的特殊字节。

element 的内部结构如下:

udzx3DUu_image_mianshiya.png

  • encoding-type:元素的编码类型。
  • element-data:实际存放的数据。
  • element-tot-len:encoding-type + element-data 的总长度,不包含自己的长度。

之所以设计它来替换 ziplist 就是因为 ziplist 连锁更新的问题,因为 ziplist 的每个 entry 会记录之前的 entry 长度

如果前面的 entry 长度变大,那么当前 entry 记录前面 entry 的字段所需的空间也需要扩大,而当前的变大了,可能后面的 entry 也得变大,这就是所谓的连锁更新,比较影响性能。

而 ListPack 的每个元素,仅记录自己的长度,这样一来修改会新增不会影响后面的长度变大,也就避免了连锁更新的问题。

Ziplist 和 Quicklist

Redis 的 Ziplist 和 Quicklist

937.Redis 中的内存碎片化是什么?如何进行优化?

回答重点

Redis 的内存碎片化是指内存使用中出现小块空间被闲置,无法被有效利用的现象。

Redis 默认使用 jemalloc 作为内存分配器,它是按照固定大小来分配内存的,比如实际需要 8kb 的内存,分配器给了 12kb。

那么多余的 4kb 其实就无法被利用上了,它就叫内存碎片。

且频繁创建和删除大量数据的时候,会导致内存块大小和位置不连续,内存碎片会变多。

可以通过 INFO memory 命令查看内存碎片率(mem_fragmentation_ratio):

1
2
3
4
5
6
## Memory
used_memory:1000000  ### 实际申请的内存空间
used_memory_human:977.54K
used_memory_rss:1200000  ##表示实际占用的物理内存空间(含内存碎片)
used_memory_rss_human:1.14M
mem_fragmentation_ratio:1.20
  • used_memory:Redis 实际使用的内存,单位是字节。
  • used_memory_rss:从操作系统的角度来看,Redis 占用的总内存量(含内存碎片),单位是字节。

mem_fragmentation_ratio = used_memory_rss / used_memory,所以大于 1 就代表有内存碎片。

如果值较大,就需要考虑内存碎片的清理了。

如果小于 1 问题也很大,说明 redis 已经使用了 swap 用上磁盘空间了,性能会变得很差。

扩展知识

如何解决内存碎片

1)最简单的解决方法是定期重启 Redis 服务,这样可以消除内存碎片并优化内存的布局,但是会导致服务不可用。
2)Redis 4.0 及以上版本引入了内存碎片整理功能。通过配置 activedefrag 选项,Redis 可以在运行时尝试整理内存碎片,将小的内存块合并为更大的块。

3)通过优化数据存储结构和类型。如:使用 ListPack 替代 ziplist。

4)利用 MEMORY PURGE 命令手动清理碎片,但是这个命令会阻塞主线程

939.Redis 的虚拟内存(VM)机制是什么?

回答重点

Redis 的 VM 机制(Virtual Memory)曾经是 Redis 早期版本(2.0 之前)的一部分,用于将部分数据存储在磁盘上,以扩展内存数据库的容量。当内存不足时,Redis 会将冷数据(不经常访问的数据)移到磁盘,并将热数据(经常访问的数据)保留在内存中。

通过这种方式,Redis 可以处理比实际物理内存更多的数据。

虽然能使用的数据变多了,但是数据存到磁盘在获取会显著的使得性能下降!满足不了高并发的场景,因此 Redis 在 2.0 版本之后放弃了 VM 机制,转而推荐使用更高效的内存淘汰策略来管理内存。

详细的内存淘汰策略可以看面试鸭的《redis 的内存淘汰策略有哪些?》这题。

抛弃 VM 具体原因如下:

  • 性能考虑:Redis 的设计初衷是作为一个高性能的内存数据库,频繁的磁盘 I/O 操作违背了这一目标。
  • 简化架构:去掉 VM 机制后,Redis 的架构变得更为简单,易于维护和优化。
  • 现代硬件的发展:随着内存价格的下降和容量的增加,服务器可以上更大的内存。

扩展知识

VM 工作原理(仅了解即可)

  1. 分页:Redis 将数据分成多个页面,每个页面的大小固定(默认为 4 KB)。
  2. 冷热数据区分:Redis 维护一个 LRU(Least Recently Used)列表,用于跟踪数据的访问频率。冷数据会被移动到磁盘,而热数据会保留在内存中。
  3. 交换数据:当 Redis 需要更多内存时,它会将冷数据页面写入磁盘,并在内存中释放这些页面。当需要访问这些数据时,Redis 会从磁盘读取相应页面并加载到内存中。

984.在 Redis 集群中,如何根据键定位到对应的节点?

回答重点

Redis 集群将数据分布到 16384 个哈希槽(slots) 中,每个键通过哈希函数计算出一个槽位编号,然后根据槽位编号定位到具体的节点,具体是使用 CRC16 哈希函数计算键的哈希值,然后对 16384 取模,得到哈希槽编号(范围是 0 到 16383)。

扩展知识

Redis 集群节点的通信

Redis 集群节点在一开始的时候,只知道自己的槽位,不知道其他节点槽位的,然后它们之间会通过 Gossip 协议使用 ping 命令和 pong 响应进行通信,彼此之间交换槽位的信息。

这样之后,Redis 集群的节点之间就互相知道彼此的槽位。

访问 Redis 集群时,如何得知 key 所对应的节点

Redis 客户端通过 Key 访问数据的时候,可以将请求打在 Redis 集群中的任意一个节点,在得知这个前提之后,我们来对以下这个流程进行分析:

1)Redis 客户端会使用 CRC16 算法计算出 Key 的哈希值,然后将哈希值对 16384 取模,从而计算 key 最终要落到的槽位。

2)一般客户端在启动时会从集群中获取哈希槽到节点的映射关系,因此可以选择对应的节点。

3)如果节点上有数据则直接返回,如果访问的 key 不在连接的节点上时,Redis 会返回一个重定向命令 MOVEDASK

  • 当客户端收到 MOVED 响应时,表示 key 所在的哈希槽已经被移动到另一个节点,客户端需要更新哈希槽映射并重试操作。
  • 当客户端收到 ASK 响应时,表明 Redis 集群进行伸缩(扩容 / 缩容)。

4)Redis 客户端根据 MOVED/ASK 指令重定向到正确的 Redis 节点。

下图演示 MOVED 情况:

Snipaste_2024-06-12_05-20-06.jpg

Redis 集群更详细的介绍,可以查看面试鸭《652. Redis 集群的实现原理是什么?

ASK 重定向的工作原理

1)客户端请求:

客户端发送一个命令来访问某个 key。

2)哈希槽迁移中的源节点:

如果该 key 所在的哈希槽正在从源节点迁移到目标节点,源节点会返回一个 ASK 重定向指令。

3)客户端处理 ASK 重定向:

客户端收到 ASK 重定向后,首先发送一个 ASKING 命令到目标节点,随后重新发送原始命令到目标节点。

为什么客户端需要先发送一个 ASKING 命令到目标节点,然后再发送实际的请求?

因为集群扩容还未完成,所以理论新的节点还未完全拥有这个槽,而 ASKING 命令其实是一个临时授权,告诉目标节点即使该节点还没有正式拥有该哈希槽,也要暂时处理这个请求。

如果没有先发送 ASKING 命令,目标节点可能会因为还没有正式接管哈希槽而拒绝处理请求。

哈希标签(Hash Tag)机制

为了让多个键可以存储在同一个槽位中,Redis 提供了哈希标签功能。

如果键包含大括号 {},则只对括号内的部分进行哈希计算。例如键 "user:{1001}:name""user:{1001}:age" 会被映射到同一个哈希槽,因为 {1001} 的内容相同。

通过这种方式,可以确保具有相同标签的键都落在同一个节点上,从而简化分布式场景下的操作,例如执行多键事务或管道操作。

4967.实现游戏积分排行榜时,应该选择 Redis 的哪种数据结构?理由是什么?

回答重点

可以选择 Redis 的 Sorted Set(有序集合)数据结构来实现游戏积分排行榜。这是因为 Sorted Set 能够根据分数对玩家进行排序,并允许我们进行范围查询,非常适合用于积分排行榜的需求。

扩展知识

选择 Redis 的 Sorted Set 还有以下几个原因:

1)自动排序:Sorted Set 会根据元素的分数自动对元素进行排序,不需要额外的排序操作。这意味着,当玩家的分数更新时,只需更新 Sorted Set,排行榜会自动调整位置。

2)快速查询:Redis 的 Sorted Set 提供 O(log(N)) 的插入、删除和更新操作,并支持 O(log(N)+M) 的范围查询操作(M 是结果集的大小)。这使得可以高效地进行各种操作,例如获取前10名玩家。

3)分数范围查询:我们可以利用 ZRANGEBYSCORE 命令来查询指定分数范围内的玩家。这对于实现如“前百分之十玩家”这类需求非常方便。

4)排名查询:通过 ZRANKZREVRANK 命令,可以获得指定玩家的排名位置,从而实现玩家查询自己在排行榜中的位置。

5)成员分数获取:通过 ZSCORE 命令,可以快捷获取某个玩家的分数,用于显示玩家的最新成绩。

除此之外,以下几个 Redis 的命令和细节在实现积分排行榜时尤为重要:

1)ZADD:添加成员并设置分数。如果成员已经存在,会更新其分数。

2)ZRANGE:返回指定区间内的成员,按分数从小到大排序。一般用于获取排行榜。

3)ZREVRANGE:返回指定区间内的成员,按分数从大到小排序。通常用于获取前N名玩家。

5206.Redis 源码中有哪些巧妙的设计,举几个典型的例子?

回答重点

Redis 作为一款高性能的内存数据库,其源码中包含了许多巧妙的设计。这些设计不仅体现了高效的数据处理和管理能力,也为后续的系统扩展和性能优化奠定了基础。

巧妙的设计主要包括:线程模型、数据结构、共享对象池、过期设计、数据持久化设计等。

线程模型

Redis 使用单线程模型来处理所有的客户端请求。

虽然看似单线程简单,但这种设计减少了上下文切换和锁的开销,避免了多线程编程中的复杂性。

并且通过多路复用(epoll、select 等)+ 事件驱动机制,Redis 仍然能够处理大量的并发连接。

数据结构

Redis 对很多数据结构进行了优化,例如 sds、ziplist,还有哈希表的扩容机制。

SDS(Simple Dynamic String)

SDS 对比 C 语言的字符串,不仅提升了安全性,还大大简化了开发中的内存管理问题。高效地处理字符串的同时,避免许多传统 C 语言中常见的内存管理错误。

关键设计如下:

1)动态扩展与空间预分配:

SDS 支持自动扩展。当字符串长度增加时,SDS 会自动分配更大的内存空间,并且通常会额外预留一部分空间,减少频繁的内存分配操作,这样就避免了每次修改字符串时都需要重新分配内存的开销。

2)二进制安全:

与 C 字符串不同,SDS 支持存储二进制数据,因为它不会依赖于空字符(’\0’)来标识字符串的结束。这使得 SDS 可以安全地存储和处理任意二进制数据。

3)常数时间获取长度:

SDS 结构中会额外存储字符串的当前长度,这使得获取字符串长度的操作可以在 O(1) 时间内完成,而不像 C 字符串那样需要遍历整个字符串来计算长度。

4)防止缓冲区溢出:

SDS 通过维护分配空间的总长度和已用长度,能够有效防止缓冲区溢出问题。这在使用 C 字符串时是一个常见的安全隐患,但在 SDS 中得到了很好解决。

5)惰性空间释放:

当 SDS 中的字符串长度缩短时,并不会立即缩减内存空间,而是会保留部分缩短后的空间,这样可以避免频繁的内存重新分配操作。这种策略也是 Redis 的内存优化的一部分。

ziplist(压缩列表)

压缩列表(ziplist)是 Redis 用于实现列表和哈希表的底层数据结构之一。

当列表或哈希表中的数据量较小,且元素长度较短时,Redis 会选择使用压缩列表来存储数据,达到节省内存消耗的目的

关键设计如下:

  • 内存紧凑:压缩列表将所有元素紧凑地存储在一段连续的内存空间中,没有指针等额外开销。每个元素前都有一个前向编码和后向编码,用于指示前一个元素的长度,这样可以快速地进行前后遍历,避免了链表中指针的额外开销。
  • 灵活性:压缩列表根据存储的元素长度动态调整编码方式,小于 1 字节的整数、1 字节的整数、3 字节的整数等不同长度的编码方式,能够极大地节省内存。
  • 性能与空间的平衡:当列表或哈希表变得很大时,Redis 会自动切换到更高效的链表或哈希表结构。这种按需调整的数据结构设计,确保了在不同场景下的性能最优。
1
2
3
4
5
6
7
8
/* 压缩列表中的一个节点结构 */
struct zlentry {
    unsigned int prevrawlensize, prevrawlen; /* 前一个元素的长度 */
    unsigned int lensize, len;               /* 当前元素的长度 */
    unsigned int headersize;                 /* 元素的头大小 */
    unsigned char encoding;                  /* 编码方式 */
    unsigned char *p;                        /* 指向实际内容的指针 */
};
渐进式 rehash

Redis 的哈希表在扩容或缩容时不会一次性进行全量 rehash。

相反,它采取了渐进式 rehash 的方式,将 rehash 操作分摊到后续的增删改查操作中,从而避免了集中的性能抖动。

共享对象池

Redis 中有许多常用的小整数,例如 0 到 9999。这些整数频繁地出现在各种命令和数据操作中。

为了减少内存分配和释放的开销,Redis 使用了 共享对象池(Shared Object Pool),实现了:

  • 对象复用:对于常用的小整数,Redis 会在启动时预先创建这些对象,并将它们存储在一个共享对象池中。当系统需要这些整数对象时,直接从池中获取,而不需要重复分配内存。这大大减少了内存分配和释放的开销,提高了系统的效率。
  • 内存节省:共享对象池有效地节省了内存,尤其是在大规模使用这些常量的场景中。通过对象复用,避免了频繁的内存申请和垃圾回收。

过期设计

Redis 支持为键设置过期时间。当键过期后,它应该被自动删除。

然而,过期键的处理需要在性能和准确性之间找到平衡。Redis 采用了一种 惰性删除与定期删除相结合的策略 来处理过期键:

  • 惰性删除:当客户端访问一个键时,Redis 会检查该键是否已经过期。如果过期,则立即删除。这种方式不会增加系统负担,因为只在访问时才检查键的状态。
  • 定期删除:Redis 每隔一段时间会随机抽取一部分键进行过期检查,并删除其中已过期的键。通过这种方式,Redis 可以避免系统中大量存在过期键而无法及时清理的情况。

惰性删除确保访问性能,定期删除避免内存泄漏。通过惰性删除和定期删除相结合,Redis 实现了过期键的高效管理。

数据持久化:AOF 重写机制

AOF(Append Only File) 是 Redis 的一种数据持久化方式。

Redis 会将所有写操作以追加的方式记录到 AOF 文件中,以保证数据的安全性。然而,随着时间推移,AOF 文件可能会变得非常大,影响恢复速度。

为了解决这个问题,Redis 引入了 AOF 重写(AOF Rewrite) 机制:

  • 增量重写:AOF 重写并不会中断正常的写操作。在重写期间,Redis 仍然可以将新的写操作继续追加到旧的 AOF 文件中,确保了高并发写入时的持久化性能。
  • 空间优化:AOF 重写过程中,Redis 并不会简单地复制所有命令,而是根据当前的数据状态,生成最精简的命令集。即使一个键值经过多次修改,最终的 AOF 文件中只会保留一条最有效的命令,从而大幅减少了文件大小。
  • 异步执行:AOF 重写是通过子进程异步执行的,主进程不会受到影响,这保证了在执行重写的同时,Redis 仍然可以提供高效的服务。

扩展知识

Redis 为什么这么快?

Redis 的 Ziplist 和 Quicklist

Redis 的过期策略

Redis 持久化机制

Redis Hash

高级数据结构:

HyperLogLog

BitMap

6305.说说 Redisson 分布式锁的原理?

回答重点

Redisson 是基于 Redis 实现的分布式锁,实际上是使用 Redis 的原子操作来确保多线程、多进程或多节点系统中,只有一个线程能获得锁,避免并发操作导致的数据不一致问题。

1)锁的获取

  • Redisson 使用 Lua 脚本,利用 exists + hexists + hincrby 命令来保证只有一个线程能成功设置键(表示获得锁)。
  • 同时,Redisson 会通过 pexpire 命令为锁设置过期时间,防止因宕机等原因导致锁无法释放(即死锁问题)。

2)锁的续期

  • 为了防止锁在持有过程中过期导致其他线程抢占锁,Redisson 实现了锁自动续期的功能。持有锁的线程会定期续期,即更新锁的过期时间,确保任务没有完成时锁不会失效。

3)锁的释放

  • 锁释放时,Redisson 也是通过 Lua 脚本保证释放操作的原子性。利用 hexists + del 确保只有持有锁的线程才能释放锁,防止误释放锁的情况。
  • Lua 脚本同时利用 publish 命令,广播唤醒其它等待的线程。

4)可重入锁

  • Redisson 支持可重入锁,持有锁的线程可以多次获取同一把锁而不会被阻塞。具体是利用 Redis 中的哈希结构,哈希中的 key 为线程 ID,如果重入则 value +1,如果释放则 value -1,减到 0 说明锁被释放了,则 del 锁。

扩展知识

Redisson 分布式锁 lua 相关源码解析

加锁的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    <T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        return commandExecutor.syncedEval(getRawName(), LongCodec.INSTANCE, command,
                "if ((redis.call('exists', KEYS[1]) == 0) " +
                            "or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " +
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                        "return nil; " +
                    "end; " +
                    "return redis.call('pttl', KEYS[1]);",
                Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
    }

hincrby: 如果哈希表的 key 不存在,会创建新的哈希表并执行 hincrby 命令

if + or 的逻辑可能不好理解,我把上述的 lua 脚本拆开来看(以下脚本的效果同上):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
if (redis.call('exists', KEYS[1]) == 0) then 
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
    end;
    
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;

return redis.call('pttl', KEYS[1]);

上述的 lua 脚本含义如下

1)若锁不存在,则新增锁,并设置锁重入计数为 1 且设置锁过期时间

1
2
3
4
5
if (redis.call('exists', KEYS[1]) == 0) then 
    redis.call('hincrby', KEYS[1], ARGV[2], 1); 
    redis.call('pexpire', KEYS[1], ARGV[1]); 
    return nil; 
    end;

2)若锁存在,且唯一标识(线程id相关)也匹配,则当前加锁请求为锁的重入请求,哈希的重入计数+1,并再次设置锁过期时间

1
2
3
4
5
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return nil;
    end;

3)若锁存在,但唯一标识不匹配,则说明锁被其他线程占用,当前线程无法解锁,直接返回锁剩余过期时间(pttl)

1
return redis.call('pttl', KEYS[1]);

上述脚本中,几个参数的含义如下:

1
2
3
KEYS[1] = Collections.singletonList(getRawName())
ARGV[1] = unit.toMillis(leaseTime)
ARGV[2] = getLockName(threadId)

释放锁的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    protected RFuture<Boolean> unlockInnerAsync(long threadId) {
        return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
              "if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
                        "return nil;" +
                    "end; " +
                    "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                    "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                    "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call('publish', KEYS[2], ARGV[1]); " +
                        "return 1; " +
                    "end; " +
                    "return nil;",
                Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
    }

上述的 lua 脚本含义如下

1)若锁不存在,直接返回不需要解锁

1
2
3
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then 
    return nil;
end; 

2)若锁存在,且唯一标识(线程id相关)也匹配,计数 - 1,如果此时计数还大于 0 ,再次设置锁过期时间

1
2
3
4
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then 
    redis.call('pexpire', KEYS[1], ARGV[2]);
return 0; 

3)若计数小于等于 0 ,则删除 key ,且通过广播通知其它等待锁的线程,此时释放锁了

1
2
3
4
else
    redis.call('del', KEYS[1]);
    redis.call('publish', KEYS[2], ARGV[1]);
return 1;

上述脚本中,几个参数的含义如下:

Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId)

1
2
3
4
5
KEYS[1] = getRawName()
KEYS[2] = getChannelName()
ARGV[1] = LockPubSub.UNLOCK_MESSAGE
ARGV[2] = internalLockLeaseTime
ARGV[3] = getLockName(threadId)

Redisson 的锁类型

  • 公平锁:与可重入锁类似,公平锁确保多个线程按请求锁的顺序获得锁。
  • 读写锁:支持读写分离。多个线程可以同时获得读锁,而写锁是独占的。
  • 信号量与可数锁:允许多个线程同时持有锁,适用于资源的限流和控制。

Redis分布式锁

关于上述的 SETNX、Lua 脚本释放锁等,看参考下面这个面试题

锁过期和网络分区补充

  • 锁过期问题:在 Redis 中,通过 SETNX 获取的锁通常带有过期时间。如果业务逻辑执行时间超过锁的过期时间,可能会出现锁被其他线程重新获取的问题。Redisson 通过锁的续期机制解决了这个问题。
  • 网络分区问题:Redis 是基于主从复制的,在主从切换或发生网络分区时,锁可能出现不一致的情况(如主节点锁还存在,副节点却因为主从切换失去了锁)。为解决这一问题,业界提出了 Redlock 算法,Redisson 也可以使用此算法确保锁的高可用性。

10389.Redis Zset的实现原理是什么?

回答重点

Redis 中的 ZSet(有序集合,Sorted Set) 是一种由 跳表(Skip List)哈希表(Hash Table) 组成的数据结构。ZSet 结合了集合(Set)的特性和排序功能,能够存储具有唯一性的成员,并根据成员的分数(score)进行排序。

ZSet 的实现由两个核心数据结构组成:

  1. 跳表(Skip List):用于存储数据的排序和快速查找。
  2. 哈希表(Hash Table):用于存储成员与其分数的映射,提供快速查找。

当 Zset 元素数量较少时,Redis 会使用压缩列表(Zip List)来节省内存

  • 即元素个数 ≤ zset-max-ziplist-entries(默认 128)
  • 元素成员名和分值的长度 ≤ zset-max-ziplist-value(默认 64 字节)

如果任何一个条件不满足,Zset 将使用 跳表 + 哈希表 作为底层实现。

扩展知识

跳表的实现原理

哈希表

Redis 使用哈希表来存储 ZSet 中的成员和分数之间的映射关系。哈希表的键是 ZSet 的成员,值是该成员的分数。哈希表提供了 O(1) 的查找、更新和删除操作。

通过哈希表,Redis 可以快速查找某个成员的分数,避免每次查询时都需要遍历跳表

ZSet 的插入、删除和查找操作

插入操作(ZADD):插入操作的时间复杂度为 O(log N),因为在跳表中查找插入位置的时间复杂度是 O(log N),哈希表的更新操作是 O(1)。

  1. 使用哈希表存储成员和分数的映射关系,分数作为哈希表中的值。
  2. 同时,将成员和分数插入跳表,跳表会根据分数排序。
  3. 如果是更新操作,Redis 会在哈希表中更新成员的分数,然后在跳表中更新该成员的位置。

删除操作(ZREM):删除操作的时间复杂度为 O(log N),其中 N 是跳表中元素的数量。

  1. 使用哈希表删除成员的映射。
  2. 同时在跳表中删除该成员。

查找操作(ZSCORE)

  1. 在哈希表中查找成员的分数,时间复杂度为 O(1)

范围查询操作(ZRANGE、ZREVRANGE、ZRANGEBYSCORE)

  1. 在跳表中根据分数区间查找成员,查找时间复杂度为 O(log N + M),其中 M 是返回的成员数量。
  2. 哈希表用于快速查找成员的分数。

时间复杂度

ZSet 操作的核心时间复杂度是 O(log N),因为大多数操作(如插入、删除、查找等)都需要通过跳表查找元素的位置。

对于范围查询操作,由于返回的数据量可能很大,时间复杂度为 O(log N + M),其中 M 是返回的元素数量。

通过成员分数查询(如 ZSCORE)的时间复杂度为 O(1),因为 Redis 使用哈希表来存储成员与分数的映射。

ZSet 的应用场景

Redis 的 ZSet 常用于实现需要排序和快速查询的场景,比如:

  • 排行榜:例如游戏中的积分排行榜。
  • 延迟队列:基于 ZSet 按时间戳排序,可以实现任务调度和延迟任务。
  • 社交网络中的关注/粉丝数排序:如微博、Twitter 中的用户粉丝数排名。
  • 662. 如何使用 Redis 快速实现排行榜?

10739.为什么 Redis Zset 用跳表实现而不是红黑树?B+树?

回答重点

为什么不用红黑树?

1)相比红黑树而言实现简单

跳表基于多层链表实现,通过概率算法动态生成索引层级,没有左旋右旋等操作,逻辑理解上更为简单。而红黑树需要复杂的平衡操作(旋转)来维护结构,代码实现复杂度较高,理解门槛更高。

2)范围查询更高效

范围查询跳表可以通过 O(logn) 的时间复杂度定位起点,然后在原始的链表中往后遍历即可。

红黑树从结构上不支持范围查询。

3)结构更灵活

跳表的层数和节点结构是动态的,可以基于概率分布调整层数,灵活的适应不同的数据量(数据量大层级可以多一些,小的话层级少一些)。

红黑树则无法调整。

为什么不用 B+ 树?

B+ 树节点更新比较复杂,涉及页合并和分裂,会导致额外的计算。

B+ 树节点理论上占用内存也比跳表节点大。因为控制层级的情况下,大部分跳表节点仅需维护自身的值和一个指针(可能还有一个回退指针,redis的实现有回退指针),而 B+ 树是多叉树,一个节点需要多指针,且节点内部还有若干指针。每个元素在叶子节点有一份完整数据内容,在非叶子节点还需要存储键的数据,所以内存开销相比跳表大。

B+树其实更适合磁盘存储,特别是大规模存储数据。因为 B+树完整数据都存储在叶子节点中,而非叶子节点只起到索引作用,这样内存中就能存放更多的索引,便于海量数据的快速检索。

扩展知识

redis 作者对 zset 用跳表实现的原因解释

Is there any particular reason you chose skip list instead of btrees except for simplicity? Skip lists consume more memory in pointers and are generally slower than btrees because of poor memory locality so traversing them means lots of cache misses.

之前有人询问作者 antirez,不用 b 树而用跳表来实现 zset 除了简单之外还有什么特别原因吗?跳表在指针上比 b 树消耗的更多的内存,并且通常因为内存局部性差,可能有大量缓存未命中索引而访问起来比 b 树慢。

作者 antirez 回复如下:

There are a few reasons:

  1. They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.

  2. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.

  3. They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.

以下是翻译内容:

有以下几个原因:

1)它们对内存的占用不是很大。这基本上取决于你的设置。通过调整节点拥有特定层数的概率参数,可以使跳表比 B 树更少占用内存。

2)排序集合经常会被用于许多 ZRANGE 或 ZREVRANGE 操作,也就是以链表的方式遍历跳表。在这种操作中,跳表的缓存局部性至少与其他类型的平衡树一样好。

3)跳表实现起来更简单,调试等操作也更方便。例如,由于跳表的简单性,我收到了一个补丁(已经在 Redis 的主分支中实现),它使用增强的跳表在 (O(log(N))) 的时间复杂度内实现了 ZRANK。这只需要对代码进行很少的修改。

其他

10745.Redisson 看门狗(watch dog)机制了解吗?

回答重点

Redisson 的看门狗(watchdog)主要用来避免 Redis 中的锁在超时后业务逻辑还未执行完毕,锁却被自动释放的情况。它通过定期刷新锁的过期时间来实现自动续期。

主要原理

  1. 定时刷新:如果当前分布式锁未设置过期时间,Redisson 基于 Netty 时间轮启动一个定时任务,定期向 Redis 发送命令更新锁的过期时间,默认每 10s 发送一次请求,每次续期 30s。
  2. 释放锁:当客户端主动释放锁时,Redisson 会取消看门狗刷新操作。如果客户端宕机了,定时任务自然也就无法执行了,此时等超时时间到了,锁也会自动释放。

扩展知识

Redisson 看门狗的核心源码分析

定时续期的核心涉及以下两个方法

  • scheduleExpirationRenewal:这个方法用于在客户端获取到锁后,启动锁的过期续期机制。
  • renewExpiration:这个方法的作用是定期刷新锁的过期时间(续期),确保锁不会因过期而被其他客户端抢占。
scheduleExpirationRenewal 方法

1)创建 ExpirationEntry:首先创建一个新的 ExpirationEntry 对象,用于存储锁的过期信息。

2)putIfAbsent 添加条目:使用 EXPIRATION_RENEWAL_MAP.putIfAbsent() 方法将新的 ExpirationEntry 添加到续期 map 中。如果该条目已存在,说明已有其他线程在续期该锁,将当前线程 ID 添加到现有条目中。

3)首次设置续期任务:如果是第一次创建该条目(即没有其他线程正在续期),则调用 renewExpiration() 方法启动定时任务,开始进行锁的续期。

4)异常处理:在执行续期操作时,若当前线程被中断,则会调用 cancelExpirationRenewal(threadId) 取消续期操作。

renewExpiration 方法

1)获取锁的过期续期条目ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());

  • 通过 getEntryName() 获取锁的唯一标识,再从 EXPIRATION_RENEWAL_MAP 中查找这个锁对应的 ExpirationEntry,该条目保存了该锁的相关信息,包括持锁的线程 ID 等。
  • 如果没有找到对应的条目(即 ee == null),则退出,表示没有需要续期的锁。

2)定时任务:通过 commandExecutor.getServiceManager().newTimeout() 创建一个定时任务,每隔一段时间执行一次续期操作。

  • 任务会在 internalLockLeaseTime / 3 毫秒后执行,定时刷新锁的过期时间。这个时间间隔通常设置为锁超时时间的三分之一,确保锁的过期时间在锁持有过程中得到续期。
  • internalLockLeaseTime 默认时间是 30s

J5B0RBVv_image_mianshiya.png
image.png

3)任务执行内容

  1. 重新获取 ExpirationEntry:每次定时任务执行时,都会再次从 EXPIRATION_RENEWAL_MAP 获取锁的过期条目。
  2. 检查线程 ID:获取到的 ExpirationEntry 中包含了持有锁的线程 ID。如果该线程 ID 为空,表示没有线程持有该锁,直接返回。
  3. 异步续期:通过调用 renewExpirationAsync(threadId) 异步续期锁的过期时间。
  4. 续期结果处理:在 whenComplete 中处理续期结果:如果续期成功(restrue),则重新调度续期任务。如果续期失败,调用 cancelExpirationRenewal(null) 来取消续期操作,并清除相关的过期条目。

中文注释版源码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 续期锁的过期时间
private void renewExpiration() {
    // 从过期续期映射中获取锁的过期条目
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (ee == null) {
        return; // 如果找不到条目,说明没有锁需要续期
    }

    // 创建一个定时任务,用于定期续期锁的过期时间
    Timeout task = commandExecutor.getServiceManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            // 重新获取过期条目
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return; // 如果条目已被移除,结束任务
            }
            // 获取持有锁的线程 ID
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return; // 如果没有线程 ID,说明没有线程持有该锁,结束任务
            }

            // 异步续期锁的过期时间
            CompletionStage<Boolean> future = renewExpirationAsync(threadId);
            future.whenComplete((res, e) -> {
                if (e != null) {
                    // 如果续期过程中发生错误,记录日志并移除续期条目
                    log.error("Can't update lock {} expiration", getRawName(), e);
                    EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                    return;
                }

                if (res) {
                    // 如果续期成功,重新调度续期任务
                    renewExpiration();
                } else {
                    // 如果续期失败,取消续期操作
                    cancelExpirationRenewal(null);
                }
            });
        }
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS); // 定时任务每 internalLockLeaseTime / 3 毫秒执行一次
    
    // 设置定时任务到过期条目中
    ee.setTimeout(task);
}

// 启动续期操作,首次获取锁时会调用此方法
protected void scheduleExpirationRenewal(long threadId) {
    // 创建新的过期条目
    ExpirationEntry entry = new ExpirationEntry();
    // 尝试将新的条目加入到续期映射中
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    if (oldEntry != null) {
        // 如果条目已存在,说明已有其他线程在续期,添加当前线程 ID 到条目中
        oldEntry.addThreadId(threadId);
    } else {
        // 如果是首次添加,开始进行续期操作
        entry.addThreadId(threadId);
        try {
            // 启动锁过期续期任务
            renewExpiration();
        } finally {
            // 如果当前线程被中断,取消续期操作
            if (Thread.currentThread().isInterrupted()) {
                cancelExpirationRenewal(threadId);
            }
        }
    }
}

核心流程是

  1. 客户端在获取锁时,启动一个定时任务,定期刷新锁的过期时间。
  2. 定时任务会在过期时间到期之前执行,通过异步方法续期锁的过期时间。
  3. 如果续期成功,定时任务会重新调度自己;如果失败,则取消续期操作。

实际续期是使用 lua 脚本实现,具体代码在 renewExpirationAsync 方法内,可以看到续期时间也是 30s:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 异步续期锁的过期时间
protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
    // 执行 Redis Lua 脚本进行锁的过期时间续期
    return commandExecutor.syncedEval(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // Lua 脚本,检查锁的持有者并延长过期时间
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            // 锁的名称,传入 Redis 脚本
            Collections.singletonList(getRawName()),
            // 续期时间(单位:毫秒)和当前线程 ID
            internalLockLeaseTime, getLockName(threadId));
}

再补充下取消续期的方法 cancelExpirationRenewal 的源码分析

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 取消锁的续期操作
protected void cancelExpirationRenewal(Long threadId, Boolean unlockResult) {
    // 获取锁的过期续期条目
    ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (task == null) {
        return; // 如果找不到过期条目,退出方法
    }
    
    // 如果提供了线程 ID,移除该线程 ID
    if (threadId != null) {
        task.removeThreadId(threadId);
    }

    // 如果没有线程 ID 或没有线程持有该锁,取消续期任务
    if (threadId == null || task.hasNoThreads()) {
        Timeout timeout = task.getTimeout();
        if (timeout != null) {
            timeout.cancel(); // 取消定时续期任务
        }
        EXPIRATION_RENEWAL_MAP.remove(getEntryName()); // 移除过期条目
    }
}

从上面定时任务续期的逻辑我们可以知道,从 EXPIRATION_RENEWAL_MAP 中移除锁的过期条目,就可以结束续期。

在调用 unlock 解锁的时候,就会触发 cancelExpirationRenewal 的调用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
    @Override
    public void unlock() {
        try {
            get(unlockAsync(Thread.currentThread().getId()));
        } catch (RedisException e) {
            if (e.getCause() instanceof IllegalMonitorStateException) {
                throw (IllegalMonitorStateException) e.getCause();
            } else {
                throw e;
            }
        }
    }

    @Override
    public RFuture<Void> unlockAsync(long threadId) {
        return commandExecutor.getServiceManager().execute(() -> unlockAsync0(threadId));
    }

    private RFuture<Void> unlockAsync0(long threadId) {
        CompletionStage<Boolean> future = unlockInnerAsync(threadId);
        CompletionStage<Void> f = future.handle((opStatus, e) -> {
            cancelExpirationRenewal(threadId);

            if (e != null) {
                if (e instanceof CompletionException) {
                    throw (CompletionException) e;
                }
                throw new CompletionException(e);
            }
            if (opStatus == null) {
                IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                        + id + " thread-id: " + threadId);
                throw new CompletionException(cause);
            }

            return null;
        });

        return new CompletableFutureWrapper<>(f);
    }

unlockAsync0 可以看到,即使出了异常 e!=null 也不影响 cancelExpirationRenewal 的调用,即使解锁出现异常,续期的定时任务也会被取消,防止无限续期

锁未设置过期时间才会续期

并不是 redisson 分布式锁都会有看门狗续期机制,不设置 leaseTime 即超时时间,才会有自动续期。

我们看下 redisson 加锁方法,例如 tryLock,实际会触发 tryAcquireAsync 方法,而里面会有对 leaseTime 的判断:

IkBBF12K_image_mianshiya.png
image.png

scheduleExpirationRenewal 方法上面已经分析了,应该很清晰了。

如果获取锁的客户端挂了怎么办?

从上面的分析我们可以得知,续期是通过定时任务执行的,如果当前客户端宕机,那么定时任务就没了,不会再执行、按照默认每 10s 续期 30s 的情况来看,锁不可能无限续期,等 30s 时间一到,集群中的其他客户端就可以获取锁,不会阻碍正常业务的执行。

客户端宕机还需等待 30s 时间太长怎么办?

1)紧急情况可以直接在 redis 中删除对应的 key,这样对应的锁就释放了

2)可以通过 lockWatchdogTimeout 参数修改看门狗机制的超时时间

1
2
3
4
5
6
7
8
9
Config config = new Config();
config.useSingleServer()
      .setAddress("redis://127.0.0.1:6379")
      .setConnectionPoolSize(10);

// 设置 Redisson 锁的看门狗超时时间为 15 秒
config.setLockWatchdogTimeout(15000);  // 时间单位:毫秒

RedissonClient redisson = Redisson.create(config);
0%