Redis 在许多公司中被广泛用作缓存。单个 Redis 实例可以处理数百万个读/写 qps,因为所有操作都在内存中。除了作为类似于memcached的简单内存内缓存外,Redis还提供了许多其他功能。例如,在许多情况下,Redis 也可以用作持久存储,因为 Redis 具有内置的 AOF(仅追加文件)机制,可以启用该机制以持久化对磁盘的每个写入操作。AOF有两种模式,可以在设置Redis时进行配置。
- 同步 AOF:同步 AOF 意味着每次写入都需要在返回到客户端(也称为。“阻止”写入)。在这种模式下,Redis 不再是纯粹的“内存内”存储。同步 AOF 可确保持久性,但需要牺牲高写入延迟。在我们的生产环境中,我们观察到,平均而言,同步 AOF 对于单个写入操作需要 ~1 秒。
- 异步 AOF:在此模式下,写入是非阻塞的,这意味着 Redis 仍然可以被视为客户端的“内存中”,只是内存内数据在后台定期持久化到磁盘上,这对客户端是透明的。此模式提供非常低的写入延迟,因为它仍然是客户端的内存,但需要权衡数据丢失。如果 Redis 节点发生故障或重新启动,则写入此 Redis 节点但尚未持久保存到磁盘的数据将永久丢失。
Redis集群
当数据量、读/写qps超过单个Redis实例的容量时,我们需要使用 Redis 集群来存储 kv 数据。在集群中,密钥分发到多个实例。通常,有三种方法可以将密钥分片到多个实例:
客户端分片
假设我们有一个广告排名引擎,它本质上是一种推荐服务,可以从广告数据库中调用相关广告,并为每个ad_request对广告进行排名。排名引擎需要检索每个广告的实时出价才能执行广告竞价。由于业务对延迟的高度敏感性,所有广告的实时出价都会被计算并预加载到 Redis 集群中。广告排名引擎在其二进制文件中内置了 Redis 客户端,以便从 Redis 读取。
在客户端分片中,Redis 客户端包含分片和路由逻辑。换句话说,这是一个非常厚的客户端。优点是此体系结构不依赖于任何中间件。只有两方:Redis 客户端和 redis 节点。
使用集中式代理(也称为中间件)的服务器端分片
中间件用作代理。来自广告排名引擎的请求将命中代理。代理包含分片和路由逻辑,用于确定访问哪个 Redis 实例来检索数据。在这种情况下,redis 客户端可以是一个非常瘦的客户端,因为它不再包含分片或路由逻辑。
去中心化服务器端分片
去中心化分片是官方 Redis 集群中实际使用的。集群中的每个节点都维护着“路由表”的本地副本,并通过gossip协议不断更新其路由表。换句话说,不再有集中式代理。相反,每个 Redis 节点都包含充当代理所需的完整信息集。
请求可以命中任何 Redis 节点。每个节点都知道集群中的所有其他节点,包括网络地址、存储的密钥等。处理请求的节点将首先检查自身或其他节点是否具有请求的数据,如果数据存储在其他地方,则将请求重定向到相应的节点。
分片算法
一致的哈希
一致哈希是一种经典的分片算法。本质上,所有事物,包括 Redis 集群中的每个节点和每个数据记录的键,都映射到 0 ~2³²-1(或 0 ~2⁶⁴-1)的哈希环。当客户端想要检索 k-v 记录时,它首先计算密钥的哈希,然后在哈希环上顺时针行走以查找下一个 Redis 节点,并从该节点获取密钥。
添加/删除节点时,一致哈希可确保只有相邻节点需要迁移数据,而集群中的大多数其他节点不受影响。好处是集群总体上保持稳定,这意味着服务堆栈延迟峰值的原因更少。
不过,一致的哈希有一个很大的缺点:分片不平衡或偏度。通常,缓存群集可能只有数十到数百个节点。在极端情况下,假设只有 2 个节点,则可能大多数键和关联值位于节点 A 上,而只有少量位于节点 B 上。即使对于最初具有平衡分片的集群,随着系统的发展,例如添加/删除节点、密钥过期、添加新密钥等,集群最终也会出现不平衡的分片。
为了最大化分片平衡,常用的方法包括:
- 介绍微分片的概念。例如,每个物理节点都映射到 n 个虚拟节点。如果群集有 m 个节点,则总共会有 m*n 个虚拟节点。虚拟节点越多,数据分布在统计上就越平衡。
- 定期重新分片。例如,每月重新计算每个分片上的密钥分配,并在所有节点上执行全局数据迁移,以使集群恢复到平衡分片状态。
Redis 集群中使用的哈希槽分片
Redis 集群没有使用上面描述的一致哈希。相反,使用哈希槽。密钥空间中的所有键都按照公式散列到整数范围 0 ~ 16383 中。每个节点负责存储一组插槽,以及该插槽的关联 k-v 对。slot = CRC16(key) & 16383
本质上,哈希槽是另一层抽象。它将数据记录(KV 对)和节点解耦。每个节点只需要知道应该在其上存储哪些插槽。插槽编码为位数组。此数组的大小字节。换句话说,一个 2048 字节的数组完全包含节点的信息,以确定 16384 个插槽中的特定插槽是否存储在其中。例如,对于插槽 1,节点只需要检查第二个位是否为 1(因为插槽索引从 0 16384/8 = 2048
拥有一层哈希槽也使新节点的添加/删除变得非常简单。假设我们有一个由 3 个节点 A、B 和 C 组成的集群。每个节点将存储一系列插槽:
节点 A:插槽 0–5460
节点 B:插槽 5461–10922
节点 C:插槽 10923–16383
- 假设我们需要向群集添加新的节点 D。在这种情况下,来自 A、B 和 C 的一些插槽将被移动到 D.即插槽及其关联的键和值是不可分割的,并且在跨节点移动时显示为原子单元。
- 同样,当我们从集群中删除节点 A 时,只有 A 中的插槽应该迁移到 B 和 C。然后可以安全地删除节点 A。
总之,将哈希槽从一个节点移动到另一个节点不需要群集停止操作、添加和删除节点或更改节点持有的哈希槽百分比,不需要任何群集停机时间。
分片架构
原生 Redis 集群
原生集群的实现方式与上述算法完全相同。集群中集成了路由/分片、集群拓扑元数据、实例健康监控等所有功能。没有其他依赖项。实例使用八卦相互通信。
本机集群通常可以支持 ~300 - 400 个实例。每个实例可以处理 80K 读取 QPS,集群总共可以处理 20-3000 万个 QPS。
但是,如果需要处理更高的 QPS,那么在超过 400 个实例时添加更多 Redis 实例不再是一个好主意。原因是 Gossip 协议的资源占用也随着集群中实例数量的增加而迅速增加。当系统需要进一步扩展时,其他体系结构的使用范围会更广泛。
Twemproxy + 原生 Redis 集群
下图显示了Twemproxy在多个Redis实例环境中工作的基本架构。
Twemproxy 是上述使用集中式代理的服务器端分片的一个例子。Twemproxy是由Twitter开源的。Twemproxy 可以接受来自多个客户端服务的请求,并将请求定向到底层 Redis 节点,等待响应,然后直接响应客户端。Twemproxy还支持一组有用的功能:
- 自动删除失败的 Redis 节点
- 支持标签。例如,如果我们想确保一组键被哈希到同一个 Redis 节点实例,我们可以为这些键分配相同的主题标签。
- 支持多种哈希算法。
Twemproxy 可以自定义以使用原生 Redis 集群,如图所示:
在这种情况下,Twemproxy 不再处理路由/分片,而是用于存储元数据,例如高级 Redis 集群拓扑、访问控制列表,并可用于监控众所周知会导致原生 Redis 集群出现问题的热键、大键等。分片/路由仍由底层原生 Redis 集群处理。换句话说,Twemproxy 节点保持与所有 Redis 节点的连接,并且可以向任何底层 Redis 节点发送请求,并且 Redis 节点将在需要时处理到集群中另一个 Redis 节点的重新路由。
AWS ElasticCache就是以这种方式构建的。ElasticCache 由一个代理服务器和一个支持主副本复制的 Redis 集群组成,其中主集群主要用于写入,副本用于读取。
使用 Codis 进行集中式分片
Codis引入了群体的概念。每个组包含一个 Redis 主节点(Redis 主节点)和 1 到多个 Redis 副本节点(Redis 从节点)。如果主节点死亡,则可以将副本节点提升为新的主节点。
Codis还使用了预分片机制,类似于原生Redis集群中使用的哈希槽。所有密钥都分布到 1024 个插槽,这意味着总共可以有多达 1024个组。路由信息(即元数据)存储在强一致性数据存储中,例如 Etcd 或 Zookeeper。
每个 Redis 组映射到一个插槽范围,例如 0~127。映射信息保留在 Zookeeper 中。在请求处理路径中,首先使用 计算哈希槽,然后代理使用 Zookeeper 中的slot_id检索 Redis 组的地址。crc32(key) % 1024
Codis 与 Twemproxy + Redis 集群之间有两个主要区别:
- 在Codis中,代理是无状态的。它不再存储有关底层 Redis 节点的任何状态。相反,所有这些元数据的存储都委托给Zookeeper。由于所有的路由/分片功能都是由代理和 Zookeeper 处理的,每个 Redis 节点只存储 KV 对,不需要通过八卦与其他 Redis 节点通信。
- 相反,当使用 Twemproxy + Redis 集群时,此类元数据存储在原生 Redis 集群中,即在每个 Redis 节点上。
热键和大键问题
热键问题
热键非常常见,特别是对于基于内容的服务,例如Youtube,抖音,Twitter等。一组密钥(有时是单个密钥)可能吸引了大部分用户流量。例如,在抖音上,前5-10%的内容产生了90%的流量。
热键对 Redis 集群的影响在于,流量可能只集中在几个 Redis 实例上,有时只集中在一个存储热键的 Redis 实例上。因此,这些不幸的实例会变得高度过载,而集群中的大多数其他实例负载非常轻。简而言之,热键可能会破坏拥有集群的目的。
主要有三种方法通常用于处理缓存中的热键:
- 在客户端使用本地缓存。当热键缓存在 Redis 客户端时,服务器端根本没有流量来请求带有热键的记录。LFU(最不常用)通常在客户端缓存中用作逐出算法。当缓存达到容量时,LFU 会逐出最不常用的密钥,从而确保热密钥保留在缓存中。但是,由于客户端缓存本质上是另一层缓存,因此远程缓存(Redis 集群)将存在缓存一致性问题。
- 人为地将热键“分片”为许多键。我们可以在热键后附加或预置一个随机数。假设我们有一个热键字符串(Redis 大多使用字符串作为键,而不是整数),那么在 Redis 端,我们将首先通过前缀 1-100 来生成 100 个键,这给了我们,,, ...,,换句话说,热键被人为复制了 100 次。它们将映射到多个哈希槽,并将存储在许多不同的 Redis 实例上。然后在查询时,每当客户端请求此热键时,我们都会在热键前面附加一个 [1, 100] 范围内的随机数。可以在代理服务器上完成预置逻辑。通过这种方式,热键流量将分发到集群中的更多实例,而不是集中在几个甚至单个实例上。xoxogossipgirlxoxogossipgirl1xoxogossipgirl2xoxogossipgirl3xoxogossipgirl100xoxogossipgirlxoxogossipgirl
- 单独读取和写入。对于内容应用,通常热键的读取QPS非常高,而热键的写入QPS只是平均水平。还记得我们之前提到的小组吗?我们可以配置 Redis 组,以便主实例仅接收写入流量,而其他跟随实例处理读取流量。我们可以有一个写入副本,但只读副本需要的数量。
大键问题
大键表示 KV 对中的值需要大于平均内存空间来存储。大键可能与热键相关联,也可能并不总是与热键相关联。例如,在抖音上,热门图片通常会有更多的评论。假设在我们的 Redis 中,键是content_id,值是所有评论的列表。在这种情况下,热键也是一个大键。
大键的存在会导致偏斜。存储大键的实例将具有异常高的内存使用率,并且可能会因 OOM 而失败。这种故障很容易导致 Domino 效应,并使整个集群瘫痪。特别是对于具有不太智能的自动故障转移的集群,OOM-ed 实例的大键可能会迁移到另一个实例,并快速 OOM 并终止该实例。然后迁移到另一个实例,也杀死它,直到整个集群关闭。
如何减轻大键的影响?你可以猜到,只需“分片”键!
- 对于简单的键值类型,我们可以稍微重新设计一下数据模式。Redis 支持具有键-子键-值的哈希结构。换句话说,假设值是一个包含数百个字段的结构,我们可以将其划分为多个,其中这里的每个值都是早期大值结构的小分片,例如少于 10 个字段。Redis 原生支持修改命令,无需更改vanillacommand 中的
key-giantValueStructsubkey-smallValueStructhget/hsetsmallValueStructgiantValueStructget/set
- 如果使用 Redis Hash、Redis Set 后,仍然有大键怎么办?我们可以添加另一层哈希桶来进一步分片键子键。例如,对于普通的 Redis 哈希,我们确实
hget(hashKey, subkey)hset(hashKey, subkey, value)
要对大键进行分片,类似于热键问题中的选项 2,我们可以做
newHashKey = hashKey + (hash(field) % 10000);
hset(newHashKey, subkey, value);hget(newHashKey, subkey);
- 如果您预计会出现较大的关键问题,要避免使用 Zset,因为 Zset 包含排名的分数信息。
好了,有关Redis 集群的分片算法和架构,就介绍到这里,如果大家有什么好的想法,可以在评论区留言,共同交流。