Redis 知识汇总

简介

Redis 是一款基于内存的数据库, 即 NoSQL. 采用单线程模型, 但是能10w 的并发. 底层使用 IO 多路复用模型(Java 中的 NIO, 即在进行 IO 是, 线程不阻塞可以处理其他事).

常用的数据结构: String, List(可用作队列), Set, ZSet(sorted set有序集合), Hash

持久化两种方式: RDB(快照) 和 AOF(日志追加)

过期策略: 惰性删除 / 定时删除 / 定期删除

内存淘汰: 八种淘汰方式

分布式锁: 单节点 setnx ex, 集群基于 Red Lock实现 (开源框架 Redisson)

集群方式: 主从, Sentinel 和 Redis Cluster

Keys 和 Scan: 都用用来获取匹配的元素

数据结构

基础结构

1
2
3
4
5
6
7
struct RedisObject {
int4 type; // 4bits
int4 encoding; // 4bits
int24 lru; // 24bits
int32 refcount; // 4bytes
void *ptr; // 8bytes, 64-bit system
} robj;

String(sds)

String 底层结构叫做 SDS(Simple Dynamic String). 最大支持 512M 长度, 在长度小于 44 时, 采用emb形式保存(连续空间), 当大于 44 是采用raw形式(不连续), 为什么是 44, 因为分配空间是 2 ^ n 字节来分配, 当内存分配 64 字节时, 去掉RedisObject, 和尾部的\0, 总共可使用 44 长度

扩容: 小于 1M 时, 采用加倍策略, 大于 1M 时, 每次扩容只会分配 1M

1
2
3
4
5
6
struct SDS<T> {
T capacity; // 数组容量
T len; // 数组长度
byte flags; // 特殊标识位, 不理睬它
byte[] content; // 数组内容
}

Hash(dict)

Redis 中的 Hash 和 Java 1.7 的 Hash 结构一样, 都是数组 + 链表

Set 和 ZSet 的底层也是 Hash, 只不过 value 为 null

Hash的内部结构有两个 HashTable, 通常是只会有一个有值. 由于采用正常的扩容方式的时间复杂度为 O(n), 因此 Redis 的 Hash 采用渐进式扩容. 当需要扩容时会在执行命令时扩容一部分, 也会有定时任务对需要扩容的 Hash 进行扩容, 每次扩容一部分, 当扩容结束时, 就删除旧的 HashTable

扩容条件: 当 hash 的个数等于第一位数组的长度是就触发扩容, 扩容的新数组为原来的 2 倍. 如果扩容时刚好赶上 bgsave, 为了减少页分离(bgsave 采用fork 线程 + copy on write), 会尽量不扩容, 如果 hash 的个数已经达到一维数组的五倍, 则会强制扩容

缩容条件: 如果 hash 的个数小于一维数组的 10%, 则会触发缩容, 并且不会考虑 bgsave

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 一维数组
struct dictht {
dictEntry** table; // 二维
long size; // 第一维数组的长度
long used; // hash 表中的元素个数
...
}
// 链表
struct dictEntry {
void* key;
void* val;
dictEntry* next; // 链接下一个 entry
}

压缩链表(ziplist)

Redis 为了节约内存空间使用, list, zset 和 hash 容器对象在元素个数较少的时候, 采用压缩列表 (ziplist) 进行存储. 压缩列表是一块连续的内存空间, 元素之间紧挨着存储, 没有任何冗余空隙. 增加 zltail_offset 便于定位到最后一个元素实现双向遍历

如果元素小于 254 字节, 则 prevlen 用 1 字节保存, 否则使用 5 字节保存. 也就是由于如果该字节修改前小于 254 字节, 修改后大于 254 字节, 就会导致连锁更新

优点: 节省内存
缺点: 连锁更新可能会导致内存拷贝, 效率低

hash-max-ziplist-entries 512 当 hash 的键值对数量小于 512 时使用压缩列表, 否则使用字典
hash-max-ziplist-value 64 当 hash 的键和值的长度小于 64 时使用压缩列表, 否则使用字典
list-max-ziplist-entries 512 当 list 的数量小于 512 时使用压缩列表, 否则使用快速列表
list-max-ziplist-value 64 当 list 的值小于 64 时使用压缩列表, 否则使用快速列表

1
2
3
4
5
6
7
8
9
10
11
12
struct ziplist<T> {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量, 用于快速定位到最后一个节点
int16 zllength; // 元素个数
T[] entries; // 元素内容列表, 挨个挨个紧凑存储
int8 zlend; // 标志压缩列表的结束, 值恒为 0xFF
}
struct entry {
int<var> prevlen; // 前一个 entry 的字节长度 只能为 1 字节或 5 字节
int<var> encoding; // 元素类型编码
optional byte[] content; // 元素内容
}

快速列表(quicklist)

快速列表为 list 的实现, 3.2 之前的版本为 linkedlist. 为什么通过 quicklist 替换掉是因为 linkedlist的 prev 和 next要占用 16 字节, 并且每个节点的内存都是单独分配, 会加剧内存的碎片化, 影响内存管理效率.

list-max-ziplist-size -2 每个快速列表的节点的压缩列表长度

-1 每个quicklistNode节点的ziplist字节大小不能超过4kb. (建议)
-2 每个quicklistNode节点的ziplist字节大小不能超过8kb. (默认配置)
-3 每个quicklistNode节点的ziplist字节大小不能超过16kb. (一般不建议)
-4 每个quicklistNode节点的ziplist字节大小不能超过32kb. (不建议)
-5 每个quicklistNode节点的ziplist字节大小不能超过64kb. (正常工作量不建议)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct ziplist {
...
}
struct ziplist_compressed {
int32 size;
byte[] compressed_data;
}
struct quicklistNode {
quicklistNode* prev;
quicklistNode* next;
ziplist* zl; // 指向压缩列表
int32 size; // ziplist 的字节总数
int16 count; // ziplist 中的元素数量
int2 encoding; // 存储形式 2bit, 原生字节数组还是 LZF 压缩存储
...
}
struct quicklist {
quicklistNode* head;
quicklistNode* tail;
long count; // 元素总数
int nodes; // ziplist 节点的个数
int compressDepth; // LZF 算法压缩深度
...
}

跳跃列表

跳跃列表是 ZSet (Sorted Set) 的实现. Redis 的跳跃列表就是数据结构中的跳表, 在 Java 中的实现参考: ConcurrentSkipListSet 和 ConcurrentSkipListMap. 插入, 删除和修改的时间平均复杂度为O(log(N)), 最坏为 O(N). 之所以 Redis 使用跳表而不使用红黑树的原因是因为跳表实现更简单, 在插入删除是, 红黑树进行 rebalance 时需要影响整个树, 而跳表只影响局部数据

索引即为 ZSet 的score, 如果 score 相同, Redis 会使用 key 进行排序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
typedef struct zskiplistNode {
//节点对应成员的对象,一般是SDS
robj *obj;
//分数,跳跃表的顺序按照分值进行排列
double score;
//存储后继节点的指针
struct zskiplistNode *backward;
// 层级
struct zskiplistLevel {
// 存储前驱节点的指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;

typedef struct zskiplist {
// 跳跃表的表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 跳跃表中节点的数量
unsigned long length;
// 表中层数最大的节点层数
int level;
} zskiplist;

image-20201108220715910

持久化

Redis 的数据全部放在内存中 如果没有一种良好的持久化日志, 则会在宕机时丢失数据.

目前 Redis 通过两种方式实现持久化
RDB: 即时快照
AOF: 日志追加, 每执行一条命令, 就在日志后追加该条命令

默认 Redis 读取方式: 存在 AOF 加载 AOF, 如果不存在则判断是否存在 RDB 进行加载

RDB

Redis 在持久化是 fork 一个子线程, 通过 COW(Copy On Write)机制实现生成 RDB 文件保证持久化. 配置: save 60 1 表示 60s 内发生发生 1 次写入命令就触发生成 RDB

COW: 在多线程情况下, 拷贝一份用来自己写, 保证线程并发安全问题. 可参考 Java: CopyOnWriteArrayList
但是 Java 的实现是每当写的时候, 都会上锁拷贝出一份, 然后再拷贝的新的去执行写操作, 然后将拷贝的赋值给原始值. 但是在 Linux 不一样. Linux COW 在读时并不会 copy, 只有在写时, 对于写影响的页进行拷贝.

流程: fork 一个子线程, 子线程和父线程共享数据. 子线程遍历数据段来生成 RDB, 父线程当发生写操作是, 拷贝一根新的数据段, 在新的数据段进行写. 父线程的数据段为最新的数据. 子线程的数据不会受到影响, 也就是说子线程拿到的是当时的数据

优点: 服务宕机或重启恢复数据效率高
缺点: . 因为使用 COW机制, 因此如果在 fork 子线程之后, 父线程继续响应写事件, 但是此时宕机了, 就会导致数据丢失

操作命令: save(阻塞) / bgsave(非阻塞)

AOF

AOF 就是一个日志把所有的操作命令记录下来(去掉读), 然后再宕机或者重启时将日志的命令重新执行一遍即可, 是持久化数据最为准确的一种方式. 配置: appendonly yes. appendfsync everysec 每秒同步一次

AOF 重写: 显而易见长期运行 AOF 的数据会越来越多, 如果重启则会需要非常长时间. 因此 Redis 提供 bgrewriteaof 命令优化掉update. delete等操作, 全部优化为 insert
原理: fork 一个子线程, 通过 COW 遍历数据, 生成对应的操作指令

刷盘: 因为 AOF 是需要磁盘 IO. 那么如果写一条命令就同步, 就会导致性能极其差.
解决方式: 利用 mmap 方式 (mmap 是内存映射文件的一种方式, 内核开辟一块空间映射到磁盘文件. 操作该文件直接操作内存, 依赖于 OS 来刷新内核空间的数据到磁盘)来处理 . 默认 Redis 是 1s 刷新一次

优点: 持久化效率最高, 并且数据不会丢失
缺点: 服务宕机或重启加载数据效率非常低

命令: bgrewriteaof

RDB + AOF 混合(4.0+)

定时生成 RDB, fork 子线程之后的操作指令会记录至 AOF 日志. 这样既能保证宕机或重启恢复效率高, 又能保证数据不丢失

过期策略

对于一个 key 设置过期时间之后, Redis 会将对应的 key 放入到一个字典中

定时删除: 为该 key 创建一个定时器, 到时间之后自动删除
优点: 对内存友好, 不存在废弃数据占用内存
缺点: 对 CPU 极其不友好, 如果 key 比较多, 则会非常吃 CPU

定期删除: 每隔十秒对每个库进行一次随机扫描 20 个 key, 删除已过期的 key, 如果过期的比例低于 1/4, 则重新扫描. 为了防止随机扫描时间过长, 默认不会超过 25ms
优点: 对 CPU 友好
缺点: 对内存不友好, 会导致垃圾数据没有被回收

惰性删除: 当访问该 key 时, 如果该 key 已过期, 则删除
优点: 简单
缺点: 对内存不友好

默认: 定期删除 + 惰性删除

内存淘汰机制

当内存达到 maxmemory 配置时, 就会进行内存淘汰. Redis 提供 7 种淘汰机制

volatile-lru: 根据最近最少使用算法, 淘汰带有 有效期 属性的key及其数据
allkeys-lru: 同样根据最近最少使用算法, 但是淘汰范围的key是所有的key
volatile-lfu: 根据最不经常使用算法, 淘汰带有 有效期 属性的key及其数据 4.0 版本支持
allkeys-lfu, 与第二种的淘汰范围相同, 不过使用的算法是最不经常使用算法 4.0 版本支持
volatile-random: 随机淘汰带有有效期属性的key及其数据
allkeys-random: 所有key都随机淘汰
volatile-ttl: 淘汰剩余寿命最短的key及其数据, ttl是 Time To Live的缩写
noeviction: 不再进行写操作, 只能读 默认配置

分布式锁

分布式锁其实个人觉得现在并没有一个非常完美的解决方案. 原因是因为对于锁的时间和业务时间无法完美的解决. 例: 如果业务时间超过了锁的时间, 如果继续锁, 那么这个分布式锁的时间设置就没有了意义, 如果不继续锁, 就可能会同时两个线程在操作相同的业务

setnx ex

最简单的实现方式, 即没有该 key 是才去保存并且设置过期时间(2.8 之前没有该命令, 只能通过 lua实现). 而且仍然会有一个问题就是业务时间超时了如何处理. 这个时候新的线程持锁, 旧的线程执行完删除便会把新的线程的锁给删除. 所以相对安全的方式是: 设置锁时, 对 value 设置一个随机数, 这个随机数由本线程生成, 删除时校验该 value 是否为本线程的随机数. 这种方式也只是相对减少出错几率, 导致出错只会存在于超时的线程. 这种方式只能通过 Lua 脚本实现.

但是这种方式仅能使用在单节点的 Redis 中. 在集群的模式中, 因为主从同步延迟问题, 会出现一个线程明明已经上锁, 但是从库查不到又重新上锁的问题

1
2
3
4
5
6
7
8
9
10
11
# 上锁
tag = random.nextint() # 随机数
if redis.set(key, tag, nx=True, ex=5):
do_something()

# 锁释放 (lua 脚本)
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

Red Lock (算法, 具体实现参考 Redisson)

Red Lock 是目前最佳 Redis 集群实现的一种算法. 具体实现可参考 Redisson

  1. 获取当前时间 (单位是毫秒)

  2. 轮流用相同的key和随机值在N个节点上请求锁, 在这一步里, 客户端在每个master上请求锁时, 会有一个和总的锁释放时间相比小的多的超时时间. 比如如果锁自动释放时间是10秒钟, 那每个节点锁请求的超时时间可能是5-50毫秒的范围, 这个可以防止一个客户端在某个宕掉的master节点上阻塞过长时间, 如果一个master节点不可用了, 我们应该尽快尝试下一个master节点

  3. 客户端计算第二步中获取锁所花的时间, 只有当客户端在大多数master节点上成功获取了锁 (在这里是3个), 而且总共消耗的时间不超过锁释放时间, 这个锁就认为是获取成功了

  4. 如果锁获取成功了, 那现在锁自动释放时间就是最初的锁释放时间减去之前获取锁所消耗的时间

  5. 如果锁获取失败了, 不管是因为获取成功的锁不超过一半 (N/2+1)还是因为总消耗时间超过了锁释放时间, 客户端都会到每个master节点上释放锁, 即便是那些他认为没有获取成功的锁

主从复制

Redis 的集群是满足 AP 理论, 选择最终一致性. Redis 支持主从同步和从从同步(减少主节点压力).

  1. 设置主服务器的地址和端口
  2. 建立套接字连接
  3. 发送PING命令
  4. 身份验证
  5. 发送端口信息
  6. 同步
  7. 命令传播

epl-timeout :从节点超时时间(复制) 默认 60s
超时了, 从节点会直接重新同步一份主节点的完整数据. 没有超时, 则根据其他参数还可能同步增量数据
repl-ping-slave-period: 心跳间隔, 默认 10s
cluster-node-timeout: 集群中的节点最大不可用时长(如果超过则进行主从切换) 默认 15000ms

主从同步(命令传播): master 节点维护长连接, 写命令会同时发送给 slave 节点, 同时也会放到 backlog 缓冲池. 如果断链则执行全量同步或增量同步

redis master 节点接收到 写命令执行操作:

  1. 将命令写入 AOF 缓冲池
  2. 将命令发送给slave 节点
  3. 将命令写入backlog(复制积压缓冲池)

增量同步

主节点内存中会有指令 buffer, 是一种环形结构, 如果 buffer 满了, 新的指令无论从库是否同步, 都会覆盖. 异步将 buffer 中的指令同步到从节点, 从节点反馈自己同步的偏移量. 如果从节点需要同步偏移量在 buffer 中已经不存在, 则需要执行快照同步

相关属性

  1. 复制偏移量
  2. 复制缓冲区
  3. 运行ID

快照同步

快照同步是一种比较耗时的同步方式. 快照同步会执行一次 bgsave, 然后异步把 rdb 发送给从节点, 从节点再 load. 因此主节点应该配置合适的 buffer size, 尽量避免快照同步

img

集群

原生集群

原生仅支持一主多从, slave of 即为该种方式. 这种方式的缺点就是主节点宕机, 只能人工手动主从切换, 而无法自动主动切换, 无法实现高可用

Sentinel

这种方式俗称哨兵模式. 该模式相当于是对 Redis 主从集群加了一层监控集群 监控集群如果检测到主节点不可用, 立即切换最优从节点为主节点

Sentinel 的主要功能包括 主节点存活检测、主从运行情况检测、自动故障转移 (failover)、主从切换. Redis 的 Sentinel 最小配置是 一主一从

// 主节点必须至少有一个从节点在进行正常复制, 否则就停止对外写服务, 丧失可用性
min-slaves-to-write 1
// 10s 没有收到从节点的反馈, 就意味着从节点同步不正常
min-slaves-max-lag 10

image-20230413223022232

监控

Sentinel 会不断的检查 主服务器 和 从服务器 是否正常运行

自动故障转移

当 主节点 不能正常工作时, Sentinel 会开始一次 自动的 故障转移操作, 它会将与 失效主节点 是 主从关系 的其中一个 从节点 升级为新的 主节点, 并且将其他的 从节点 指向 新的主节点

默认情况下, 每个 Sentinel 节点会以 每秒一次 的频率对 Redis 节点和 其它 的 Sentinel 节点发送 PING 命令, 并通过节点的 回复 来判断节点是否在线

主观下线

主观下线 适用于所有 主节点 和 从节点. 如果在 down-after-milliseconds 毫秒内, Sentinel 没有收到 目标节点 的有效回复, 则会判定 该节点 为 主观下线

客观下线

客观下线 只适用于 主节点. 如果 主节点 出现故障, Sentinel 节点会通过 sentinel is-master-down-by-addr 命令, 向其它 Sentinel 节点询问对该节点的 状态判断. 如果超过 quorum 个数的节点判定 主节点 不可达, 则该 Sentinel 节点会判断 主节点 为 客观下线

工作原理

  1. 每个 Sentinel 以 每秒钟 一次的频率, 向它所知的 主服务器、从服务器 以及其他 Sentinel 实例 发送一个 PING 命令
  2. 如果一个 实例(instance)距离 最后一次 有效回复 PING 命令的时间超过 down-after-milliseconds 所指定的值, 那么这个实例会被 Sentinel 标记为 主观下线
  3. 如果一个 主服务器 被标记为 主观下线, 那么正在 监视 这个 主服务器 的所有 Sentinel 节点, 要以 每秒一次 的频率确认 主服务器 的确进入了 主观下线 状态
  4. 如果一个 主服务器 被标记为 主观下线, 并且有 足够数量 的 Sentinel(至少要达到 配置文件 指定的数量)在指定的 时间范围 内同意这一判断, 那么这个 主服务器 被标记为 客观下线
  5. 在一般情况下, 每个 Sentinel 会以每 10 秒一次的频率, 向它已知的所有 主服务器 和 从服务器 发送 INFO 命令. 当一个 主服务器 被 Sentinel 标记为 客观下线 时, Sentinel 向 下线主服务器 的所有 从服务器 发送 INFO 命令的频率, 会从 10 秒一次改为 每秒一次
  6. Sentinel 和其他 Sentinel 协商 主节点 的状态, 如果 主节点 处于 SDOWN 状态, 则投票自动选出新的 主节点. 将剩余的 从节点 指向 新的主节点 进行 数据复制
  7. 当没有足够数量的 Sentinel 同意 主服务器 下线时, 主服务器 的 客观下线状态 就会被移除. 当 主服务器 重新向 Sentinel 的 PING 命令返回 有效回复 时, 主服务器 的 主观下线状态 就会被移除

分片

上述的两种都是主节点保存全量数据的方式. 在应当当下大数据的情况下, 一旦网络抖动或宕机导致全量同步, 就会极其影响效率甚至陷入全量同步死循环. 因此在考虑高可用的情况下, 自然而然就少不了分片

分片的作用是将全量数据分散到各个节点, 各个节点再通过集群保证高可用. 每个集群只维护各自的局部数据

客户端分区方案

客户端 就已经决定数据会被 存储 到哪个 redis 节点或者从哪个 redis 节点 读取数据。其主要思想是采用 哈希算法 将 Redis 数据的 key 进行散列,通过 hash 函数,特定的 key会 映射 到特定的 Redis 节点上

优点: 不使用 第三方中间件,分区逻辑 可控,配置 简单,节点之间无关联,容易 线性扩展,灵活性强
缺点: 客户端 无法 动态增删 服务节点,客户端需要自行维护 分发逻辑,客户端之间 无连接共享,会造成 连接浪费

代理分区方案

客户端 发送请求到一个 代理组件,代理 解析 客户端 的数据,并将请求转发至正确的节点,最后将结果回复给客户端. 相当于客户端分片由代理层来处理

优点: 简化 客户端 的分布式逻辑,客户端 透明接入,切换成本低,代理的 转发 和 存储 分离
缺点: 多了一层 代理层,加重了 架构部署复杂度 和 性能损耗

查询路由方案

客户端随机地 请求任意一个 Redis 实例,然后由 Redis 将请求 转发 给 正确 的 Redis 节点。Redis Cluster 实现了一种 混合形式 的 查询路由,但并不是 直接 将请求从一个 Redis 节点 转发 到另一个 Redis 节点,而是在 客户端 的帮助下直接 重定向( redirected)到正确的 Redis 节点

优点: 无中心节点,数据按照 槽 存储分布在多个 Redis 实例上,可以平滑的进行节点 扩容/缩容,支持 高可用 和 自动故障转移,运维成本低
缺点: 缺乏 监控管理,需要维护连接,缓存路由表,MultiOp 和 Pipeline 支持。Failover 节点的 检测过慢,不如 中心节点 ZooKeeper 及时。无法根据统计区分 冷热数据

数据分布

哈希分区

离散程度好,数据分布与业务无关,无法顺序访问. Redis Cluster 使用此种方式

  1. 节点取余分区: 使用特定的数据,如 Redis 的 键 或 用户 ID,再根据 节点数量 N 使用公式:hash(key)% N 计算出 哈希值,用来决定数据 映射 到哪一个节点上
    1. 优点: 简单, 常用于 数据库 的 分库分表规则。一般采用 预分区 的方式,提前根据 数据量 规划好 分区数,比如划分为 512 或 1024 张表,保证可支撑未来一段时间的 数据容量,再根据 负载情况 将 表 迁移到其他 数据库 中。扩容时通常采用 翻倍扩容,避免 数据映射 全部被 打乱,导致 全量迁移 的情况
    2. 缺点: 当 节点数量 变化时,如 扩容 或 收缩 节点,数据节点 映射关系 需要重新计算,会导致数据的 重新迁移
  2. 一致性哈希分区: 一致性哈希 可以很好的解决 稳定性问题,可以将所有的 存储节点 排列在 收尾相接 的 Hash 环上,每个 key 在计算 Hash 后会 顺时针 找到 临接 的 存储节点 存放。而当有节点 加入 或 退出 时,仅影响该节点在 Hash 环上 顺时针相邻 的 后续节点
    1. 优点: 一致性哈希 可以很好的解决 稳定性问题,可以将所有的 存储节点 排列在 收尾相接 的 Hash 环上,每个 key 在计算 Hash 后会 顺时针 找到 临接 的 存储节点 存放。而当有节点 加入 或 退出 时,仅影响该节点在 Hash 环上 顺时针相邻 的 后续节点
    2. 缺点: 加减节点 会造成 哈希环 中部分数据 无法命中。当使用 少量节点 时,节点变化 将大范围影响 哈希环 中 数据映射,不适合 少量数据节点 的分布式方案。普通 的 一致性哈希分区 在增减节点时需要 增加一倍 或 减去一半 节点才能保证 数据 和 负载的均衡
  3. 虚拟槽分区: 虚拟槽分区 巧妙地使用了 哈希空间,使用 分散度良好 的 哈希函数 把所有数据 映射 到一个 固定范围 的 整数集合 中,整数定义为 槽(slot)。这个范围一般 远远大于 节点数,比如 Redis Cluster 槽范围是 0 ~ 16383。槽 是集群内 数据管理 和 迁移 的 基本单位。采用 大范围槽 的主要目的是为了方便 数据拆分 和 集群扩展。每个节点会负责 一定数量的槽
    1. 由于从一个节点将 哈希槽 移动到另一个节点并不会 停止服务,所以无论 添加删除 或者 改变 某个节点的 哈希槽的数量 都不会造成 集群不可用 的状态

image-20230413230057053

hash

image-20230413225056120

顺序分区

离散程度易倾斜,数据分布与业务相关,可以顺序访问. BigTable,HBase,Hypertable 大数据一般使用此种方式

Redis Cluster

Redis Cluster 是一个多主多从的架构. 每一个主节点对应多个从节点. 当检测到主节点宕机时, 自动切换其从节点为主节点. 并且是去中心架构

Redis Cluster 采用虚拟槽分区, 所有的key根据哈希函数映射到0~16383槽. 当收到一条指令后, 任何一主节点会计算该指令应保存的槽, 如果不是当前节点, 则会转发到对应的主节点进行处理

FailOver (故障转移): 分为主观下线和客观下线
主观下线: 集群中每个节点都会定期向其他节点发送ping消息, 接收节点回复pong消息作为响应. 如果在cluster-node-timeout时间内通信一直失败, 则发送节点会认为接收节点存在故障, 把接收节点标记为主观下线(pfail)状态
客观下线: 当某个节点判断另一个节点主观下线后, 相应的节点状态会跟随消息在集群内传播. 通过Gossip消息传播, 集群内节点不断收集到故障节点的下线报告. 当半数以上持有槽的主节点都标记某个节点是主观下线时, 触发客观下线流程

  1. 下线节点的集群投票选出新的主节点
  2. 选出的新主节点(从节点)取消复制变为主节点
  3. 执行clusterDelSlot操作撤销故障主节点负责的槽, 并执行clusterAddSlot把这些槽委派给自己
  4. 向集群广播自己的pong消息, 通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息

image-20230413230432376

数据分区

Redis Cluster 采用 虚拟槽分区,所有的 键 根据 哈希函数 映射到 0~16383 整数槽内,计算公式:slot = CRC16(key)& 16383。每个节点负责维护一部分槽以及槽所映射的 键值数据

image-20230413225330704

虚拟槽分区的特点

  1. 解耦 数据 和 节点 之间的关系,简化了节点 扩容 和 收缩 难度
  2. 节点自身 维护槽的 映射关系,不需要 客户端 或者 代理服务 维护 槽分区元数据
  3. 支持 节点、槽、键 之间的 映射查询,用于 数据路由、在线伸缩 等场景

功能限制

以下操作需要客户端处理

  1. key 批量操作 支持有限: 类似 mset、mget 操作,目前只支持对具有相同 slot 值的 key 执行 批量操作。对于 映射为不同 slot 值的 key 由于执行 mget、mget 等操作可能存在于多个节点上,因此不被支持
  2. key 事务操作 支持有限: 只支持 多 key 在 同一节点上 的 事务操作,当多个 key 分布在 不同 的节点上时 无法 使用事务功能
  3. key 作为 数据分区 的最小粒度: 不能将一个 大的键值 对象如 hash、list 等映射到 不同的节点
  4. 不支持 多数据库空间: 单机 下的 Redis 可以支持 16 个数据库(db0 ~ db15),集群模式 下只能使用 一个 数据库空间,即 db0
  5. 复制结构 只支持一层: 从节点 只能复制 主节点,不支持 嵌套树状复制 结构

Keys 和 Scan

Keys 和 Scan 都是用来获取匹配的 key, 时间复杂度都是 O(n)

Keys: 阻塞主线程, 并且不支持分页, 对大数据量效率极低又因为 Redis 是单线程会阻塞主线程. 因此线上统一禁止使用

Scan: 支持分页, 但是返回的数据可能会重复. 通过游标分布进行, 不会阻塞线程 (设计很复杂). 遍历顺序非常特别, 它不是从第一维数组的第 0 位一直遍历到末尾, 而是采用了高位进位加法来遍历

缓存

缓存雪崩

缓存雪崩是指多缓存在同一时间失效, 此时高并发请求打到 db

解决方式: 缓存失效时间随机分布

缓存击穿

缓存击穿是指热点数据缓存失效, 此时高并发请求达到 db

解决方式:

  1. 热点数据永不国过期
  2. 互斥锁

缓存穿透

缓存穿透是指缓存和数据库中都没有的数据, 而用户不断发起请求

解决方式:

  1. 布隆过滤器
  2. 没有的数据也缓存, value 为 null

数据库缓存一致性

没有最佳的方式, 都会有各种各样的问题, 但是一般先更新 db, 再删除缓存

  1. 为什么不是更新缓存的原因
    1. 缓存可能需要其他业务数据, 避免复杂业务逻辑影响主流程
    2. 事物提交失败, 但是缓存已更新
  2. 删除缓存有什么问题
    1. 删除缓存后, 更新事务未提交, 另外一个线程从 db 中获取了旧数据放入缓存
  3. 异步队列
    1. 有延迟, 导致会有异步执行之前读取的是旧数据

引用

Redis哨兵模式与高可用集群

Redis集群模式搭建与原理详解


Redis 知识汇总
https://gallrax.github.io/2020/02/15/Redis知识汇总/
作者
Gallrax
发布于
2020年2月15日
许可协议