分布式缓存系统是为了解决高并发场景中数据访问的速度瓶颈,将频繁访问的数据存储在内存中,以便迅速响应应用程序的查询请求。
缓存类型:分布式缓存分为进程内缓存和进程外缓存。
分布式缓存的特点:
缓存节点和缓存代理:
twemproxy
、Redis Cluster
等负责缓存分片、节点路由以及负载均衡。主从节点结构:为提高缓存的可用性和容错性,每个主缓存节点会有一个或多个从节点:
数据过期和快照:
通过这种分布式缓存架构,系统可以在高并发环境下保持低延迟、高性能的数据访问能力,同时具备扩展性和容灾能力。
import java.util.HashMap;
import java.util.Map;
public class HashShardingCache {
// 模拟缓存节点,key为节点ID,value为存储的数据
private Map<Integer, Map<Integer, String>> cacheNodes;
private int nodeCount;
public HashShardingCache(int nodeCount) {
this.nodeCount = nodeCount;
this.cacheNodes = new HashMap<>();
for (int i = 0; i < nodeCount; i++) {
cacheNodes.put(i, new HashMap<>());
}
}
// 添加数据到缓存,数据根据ID % nodeCount的结果选择节点
public void put(int id, String value) {
int nodeId = getNode(id);
cacheNodes.get(nodeId).put(id, value);
System.out.println("数据ID " + id + " 存储到缓存节点 " + nodeId);
}
// 从缓存获取数据
public String get(int id) {
int nodeId = getNode(id);
return cacheNodes.get(nodeId).get(id);
}
// 计算数据应该放入的节点
private int getNode(int id) {
return id % nodeCount;
}
// 模拟新增节点导致的数据迁移
public void addNode() {
System.out.println("\n== 新增缓存节点 ==");
nodeCount++;
cacheNodes.put(nodeCount - 1, new HashMap<>());
// 迁移数据:重新计算每条数据的节点位置
Map<Integer, Map<Integer, String>> newCacheNodes = new HashMap<>();
for (int i = 0; i < nodeCount; i++) {
newCacheNodes.put(i, new HashMap<>());
}
for (Map<Integer, String> nodeData : cacheNodes.values()) {
for (Map.Entry<Integer, String> entry : nodeData.entrySet()) {
int newNodeId = getNode(entry.getKey());
newCacheNodes.get(newNodeId).put(entry.getKey(), entry.getValue());
}
}
cacheNodes = newCacheNodes;
}
// 打印当前缓存节点的数据分布
public void printCacheNodes() {
System.out.println("\n当前缓存节点数据分布:");
for (Map.Entry<Integer, Map<Integer, String>> entry : cacheNodes.entrySet()) {
System.out.println("节点 " + entry.getKey() + " 包含数据:" + entry.getValue().keySet());
}
}
public static void main(String[] args) {
HashShardingCache cache = new HashShardingCache(3);
// 添加数据
for (int i = 1; i <= 10; i++) {
cache.put(i, "Value" + i);
}
cache.printCacheNodes();
// 新增节点,观察数据迁移情况
cache.addNode();
cache.printCacheNodes();
}
}
假设有10条数据(ID从1到10),并有3个缓存节点。通过对数据ID取模运算,分配数据到相应的节点上。例如,ID % 3
运算后:
- 结果为0的数据分配给缓存节点0(如ID 3、6、9)。
- 结果为1的数据分配给缓存节点1(如ID 1、4、7、10)。
- 结果为2的数据分配给缓存节点2(如ID 2、5、8)。
ID % 3
到ID % 4
),导致大量缓存数据失效,增加数据库的访问压力。2^32
)上,实现对缓存节点和数据的均匀分布。每日一博 - 一致性哈希:分布式系统的数据分配利器
一致性Hash算法适合于对稳定性要求较高、可能会频繁增加或减少节点的场景,例如电商秒杀系统等高并发应用中,减少缓存失效和数据库压力。
Redis 集群的分布式缓存方案通过将数据分片并分配到不同的缓存节点来实现扩展性和高可用性。Redis 集群主要基于槽(Slot)概念,将存储空间分为 16,384 个槽(即 ID 0 到 16,383),然后通过 Hash 算法将数据键映射到这些槽中。槽作为分片的最小单位,每个槽可以分配给不同的节点,从而实现数据的均匀分布和负载均衡。
分片机制:Redis 集群利用 CRC16 校验算法将键值对映射到槽上。具体操作是将键值对的键进行 CRC16 校验,然后对 16,384 取模,以得到对应的槽号。这个槽号决定了该键值对的存储位置。
槽到节点的映射:Redis 集群将这 16,384 个槽分配给不同的节点。例如,在一个拥有 3 个节点的集群中,可以将槽按以下规则分配:
数据定位:当 Redis 客户端请求一个键的值时,Redis 集群首先根据 CRC16(key) % 16384
计算出槽号,再根据槽到节点的映射找到对应的节点,然后访问该节点中的键值对并返回数据。例如,如果通过公式计算出槽号是 5002,则数据应该存放在节点 2,因为槽 5002 落在节点 2 负责的槽范围(5001 ~ 10,000)内。
重新分片:当节点增加或减少时,Redis 集群可以通过移动槽到不同节点来重新分配数据。这样可以动态调整每个节点的负载,减少单个节点的压力,提高集群的扩展性。
假设我们有一个简化的集群,使用 TreeMap 来模拟槽到节点的映射。
import java.util.NavigableMap;
import java.util.TreeMap;
public class Demo {
private final int SLOT_COUNT = 16384;
private final TreeMap<Integer, String> slotToNodeMap = new TreeMap<>();
// 初始化 Redis 集群,分配槽到不同节点
public Demo () {
// 初始化节点
addNode("节点1", 0, 5000);
addNode("节点2", 5001, 10000);
addNode("节点3", 10001, SLOT_COUNT - 1);
}
// 添加节点并分配槽区间
public void addNode(String node, int startSlot, int endSlot) {
for (int slot = startSlot; slot <= endSlot; slot++) {
slotToNodeMap.put(slot, node);
}
}
// 根据键获取对应的 Redis 节点
public String getNodeForKey(String key) {
int slot = getSlot(key);
System.out.println("键 " + key + " slot " + slot);
// 获取最近的槽(TreeMap 提供的 ceilingEntry 方法)
NavigableMap<Integer, String> tailMap = slotToNodeMap.tailMap(slot, true);
int targetSlot = tailMap.isEmpty() ? slotToNodeMap.firstKey() : tailMap.firstKey();
return slotToNodeMap.get(targetSlot);
}
// 根据 CRC16 校验值取模计算槽号
private int getSlot(String key) {
int crc16 = crc16(key);
return crc16 % SLOT_COUNT;
}
// CRC16 校验计算(简化实现)
private int crc16(String key) {
int crc = 0xFFFF;
for (char c : key.toCharArray()) {
crc ^= c;
for (int i = 0; i < 8; i++) {
if ((crc & 1) != 0) {
crc = (crc >> 1) ^ 0xA001;
} else {
crc >>= 1;
}
}
}
return crc & 0xFFFF;
}
public static void main(String[] args) {
Demo cluster = new Demo();
// 测试键到节点的分布
String[] keys = {"小工匠", "KeyXX", "Redis", "Cluster"};
for (String key : keys) {
System.out.println("键 " + key + " 存储在 " + cluster.getNodeForKey(key));
}
}
}
addNode
方法:用来将特定槽区间分配给指定节点。getNodeForKey
方法:计算键的槽,并使用 TreeMap 查找最接近的槽映射的节点。Redis 集群方案通过使用槽来管理和分片数据,有效解决了分布式缓存的一致性和负载均衡问题。每次访问键时,通过 CRC16 和槽号的映射表定位到具体的节点。这种方法不仅减少了数据迁移(因为槽是较小的迁移单元),而且当集群规模变化时也能高效地分配数据。
在分布式缓存方案中,数据的拆分和分布式存储往往通过分片算法实现,比如 Redis 集群使用的虚拟槽算法,可以高效地将数据均匀分布到多个节点上。然而,这只是分布式缓存的一部分挑战。
在一个多节点的分布式缓存系统中,各节点之间的通信同样至关重要。它不仅关系到数据的同步,还直接影响到系统的高可用性和稳定性。
那么,Redis 集群中的各个缓存节点是如何进行通信的呢?
在 Redis 集群中,节点之间使用一种名为 Gossip 协议 的方式进行通信。Gossip(八卦)协议是一种轻量级的协议,广泛应用于分布式系统中,用于节点之间交换和共享信息。Redis 集群的各个节点通过 Gossip 协议,彼此分享状态数据,实时保持对整个集群的健康状况和拓扑结构的了解。
在 Redis 集群中,节点之间的通信主要用于维护集群的元数据信息(即每个节点包含哪些数据、节点是否出现故障等)。节点之间定期交换这些信息,确保集群内所有节点都对集群状态有一致的认知。这个过程可以理解为集群中的节点在“八卦”彼此的状态,最终每个节点都“听说”了其他节点的状态。
其通信过程可以分为以下几个步骤:
独立的 TCP 通道:Redis 集群中的每个节点都开通了一个独立的 TCP 通道,专门用于与其他节点的通信。
定时发送 Ping 消息:每个节点都有一个定时任务,每隔一段时间(比如每秒 5 次)会从系统中随机选出一个其他节点,向其发送 Ping 消息。
接收并回复 Pong 消息:被 Ping 的节点在接收到 Ping 消息后,会礼貌性地回复一个 Pong 消息。这样,Ping 和 Pong 消息的往返可以实现节点之间的状态同步。
循环执行,保持通信:上述 Ping-Pong 消息的传递是一个持续的过程,使得集群内的所有节点都能不断获取其他节点的最新状态。
这种定期的通信机制保证了节点之间的同步和实时性,及时获知集群中节点的健康状况和数据分布。
在 Redis 集群的 Gossip 协议中,不同类型的消息传递不同的信息,下面是四种常用的消息类型:
Meet 消息:当集群中新增节点时,老节点会收到新节点发送的 Meet 消息。Meet 消息通知了已有节点:集群中来了一个新伙伴。老节点接收到 Meet 消息后,礼貌性地回复一个 Pong 消息,并开始与新节点进行后续的 Ping-Pong 通信。
Ping 消息:这是最常见的消息类型。每个节点会定期向其他节点发送 Ping 消息,Ping 消息包含了节点的状态数据、负责的槽信息等。当接收节点收到 Ping 消息后,便能更新对该节点的状态认知。
Pong 消息:Pong 消息通常是对 Ping 消息的回应,包含节点的状态信息。一个节点在接收到 Meet 或 Ping 消息时,会回复 Pong 消息,通知对方自己的最新状态。
Fail 消息:当一个节点检测到其他节点出现故障时,会向集群内的其他节点广播 Fail 消息。这种消息在节点失效检测和故障恢复方面起着关键作用。通过 Fail 消息,整个集群可以在短时间内同步故障节点的信息,以便于及时处理故障。
在 Redis 集群的 Gossip 协议中,消息传递的格式相对简单但富有信息量。以下是消息结构体 clusterMsg
的简化定义:
typedef struct
{
char sig[4]; /* 信号标识 */
uint32_t totlen; /* 消息总长度 */
uint16_t ver; /* 协议版本 */
uint16_t port; /* TCP 端口号 */
uint16_t type; /* 消息类型(如 Meet、Ping、Pong、Fail) */
uint16_t count; /* 消息体包含的节点数 */
uint64_t currentEpoch; /* 当前发送节点的配置纪元 */
uint64_t configEpoch; /* 主从节点的配置纪元 */
uint64_t offset; /* 复制偏移量 */
char sender[CLUSTER_NAMELEN]; /* 发送节点的节点名称 */
unsigned char myslots[CLUSTER_SLOTS/8]; /* 发送节点的槽信息 */
char slaveof[CLUSTER_NAMELEN];
char myip[NET_IP_STR_LEN]; /* 发送节点的 IP 地址 */
uint16_t flags; /* 发送节点的标识,区分主从角色 */
unsigned char state; /* 发送节点的集群状态 */
unsigned char mflags[3]; /* 消息标识 */
union clusterMsgData data; /* 消息正文 */
} clusterMsg;
结构体中包含了多种字段,其中:
这些字段和数据类型的设计确保了消息能够携带充足的信息,使得节点间的通信不仅能传递基本状态,还能包含数据分布、节点角色等关键信息。
Redis 集群采用 Gossip 协议来维持节点状态,确保各节点能够对集群状态保持一致的认知。通过定期的 Ping-Pong 通信和故障广播(Fail 消息),即使某些节点发生故障,集群中的其他节点也能够快速感知,并对数据分布或角色进行调整,以保障服务的高可用性。
Redis 集群中的 Gossip 协议使得每个节点能够实时了解集群的整体状况,实现了高效的分布式管理和数据同步。通过 Meet、Ping、Pong、Fail 等消息类型,各节点之间不断地“八卦”彼此的状态。这种机制不仅提高了 Redis 集群的容错能力,还为集群的扩展和动态调整提供了基础。
Redis 集群的 Gossip 通信机制具有以下优势:
通过 Gossip 协议,Redis 集群实现了高效的数据同步和节点管理,确保了系统在分布式场景下的高可用性和稳定性。
在 Redis 集群中,每个节点通过 Gossip 协议相互通信,实时共享集群状态,确保所有节点对集群的结构和状态保持一致。而对于客户端来说,要查询 Redis 集群中的数据,首要问题就是要知道数据存放在哪个节点上。
接下来我们看看Redis 集群如何通过槽信息与路由规则,实现客户端在多节点环境中准确访问数据,即使在数据迁移过程中,也能高效地完成重定向。
Redis 集群将整个键空间划分为 16,384 个槽。通过对键的哈希值计算,每个键都会被映射到特定的槽中,这样可以让不同的槽分布在不同的节点上,形成分布式存储。槽分配的细节如下:
myslots 数组:每个 Redis 节点会使用一个 myslots
二进制位数组(bit array)来管理槽信息。总长度为 2,048 字节(即 16,384 位),每位表示一个槽。例如,若某节点负责 1, 2, 3 号槽,则其数组中的相应位会被置为 1。
CLUSTER_SLOTS的取值是 16384,表示这个数组的长度是 16384/8=2048B,由于 1B=8bit,所以数组共包含 16 384 个 bit 位(二进制位)。每个节点分别用 1 个 bit 位来标记自己是否拥有某个槽的数据。
快速判断槽的归属:借助位数组的结构,Redis 可以在 O(1) 的时间复杂度下判断某个节点是否存放了特定槽的数据。比如,只需检查二进制数组的第 2 位是否为 1,就能判断该节点是否负责槽编号为 2 的数据。
clusterState
结构与节点的槽映射每个 Redis 节点会通过 Gossip 协议定期接收其他节点的槽信息,并将这些信息存储在本地的 clusterState
结构中。这个结构记录了整个集群的槽分布:
typedef struct clusterState {
clusterNode *myself; // 指向当前节点的指针
clusterNode *slots[CLUSTER_SLOTS]; // 槽映射表
} clusterState;
slots
数组包含 16,384 个元素,每个元素代表一个槽,槽中存放的是该槽对应的 clusterNode
(即缓存节点)的信息。clusterState
,存放集群中所有槽和节点的对应关系。这样,集群中的每个节点都可以为客户端提供准确的路由信息。当 Redis 客户端请求数据时,路由过程如下:
计算槽号:客户端通过 CRC16(key) % 16384
计算出键对应的槽号。
定位节点:客户端将请求发送至目标节点(假设为节点 1),并尝试从该节点中获取数据。
MOVED 重定向:若该槽数据已迁移至其他节点(例如节点 2),节点 1 会向客户端返回 MOVED
重定向请求,告知客户端目标节点的地址。
重新请求:客户端根据 MOVED
请求提供的节点地址,直接访问节点 2,从而成功获取数据。
Redis 支持在不下线服务的情况下进行槽数据迁移,即将槽从一个节点迁移至另一个节点。
此时,Redis 集群会通过 ASK 重定向机制来确保数据访问不中断:
ASK 重定向:在迁移数据的过程中,如果客户端请求的槽数据暂未迁移至目标节点,原节点会返回 ASK
重定向,告知客户端目标节点地址。
Asking 命令:客户端收到 ASK
请求后,向目标节点发送 Asking
命令,确认目标节点是否已存储该槽数据。
数据返回:目标节点接收 Asking
命令后,确认数据状态并返回给客户端。
这种机制确保了即便在数据迁移过程中,客户端也能顺利找到所需数据。
Redis 集群的路由和重定向机制在分布式环境中具备以下优势:
CRC16
哈希算法快速定位槽,结合 MOVED
和 ASK
重定向机制,Redis 集群能高效地支持客户端路由请求。clusterState
结构,记录着集群的槽分布关系,保证所有节点的状态一致。这些设计让 Redis 集群在复杂的分布式环境中也能实现高效的路由与重定向,确保数据的稳定可用。
在分布式 Redis 集群中,缓存节点扩展和收缩的过程是通过数据槽的重新分配和迁移来实现的。
在 Redis 集群中扩展缓存节点的常见原因包括业务增长、并发请求量增加或现有节点容量不足。当新节点加入集群时,集群需要重新分配槽,以使新的节点能承担一部分存储任务。这一过程主要涉及以下步骤:
新节点加入时无法直接与集群通信。此时,需要现有节点通过 cluster meet
命令来让新节点加入集群。例如,如果新节点的 IP 是 192.168.1.1
,端口是 5002
,可以在老节点上执行以下命令:
192.168.1.1:5003> cluster meet 192.168.1.1 5002
这样,新节点被集群“接纳”后,可以正式开始与其他节点通信。
新节点上线后需要分配槽和数据。Redis 集群通过客户端指令让老节点将一部分槽的数据迁移到新节点上。
具体的迁移过程包括:
cluster setslot {slot} importing {sourceNodeId}
cluster setslot {slot} migrating {targetNodeId}
cluster getkeysinslot {slot} {count}
这里的 count
表示将要迁移的数据条数。migrate
命令将数据迁移到目标节点。例如:migrate {targetIP} {targetPort} "" 0 {timeout} keys {keys}
这个命令可以通过流水线(pipeline)批量迁移数据,提高数据迁移效率。cluster setslot {slot} node {targetNodeId}
缓存节点的收缩通常发生在节点故障或集群需要缩减规模时。下线节点会将其负责的槽分配给其他节点,并通知其他节点停止与该节点通信。
与扩容类似,移除节点的过程也涉及数据迁移,不过方向相反:从待下线节点向其他主节点迁移数据。
迁移步骤基本相同,只是在数据迁移完成后,待下线节点还需要通知其他节点将自己标记为下线节点。
在下线操作中,其他节点需要“遗忘”这个即将下线的节点。可以使用 cluster forget
命令通知全网的其他节点。例如:
cluster forget {downNodeId}
这个命令会在集群内广播,使所有节点不再将下线节点列入通信名单中。
在 Redis 集群的分布式缓存系统中,节点故障检测与恢复是确保系统高可用性的关键手段。当集群中的某个节点因硬件或网络故障导致无法访问时,Redis 会通过检测机制自动标记下线节点,并进行故障恢复,以此维持系统的稳定性。
Redis 集群通过两种方式确定节点是否故障:主观下线与客观下线。
当一个节点未能在设定时间内响应 Ping 请求,则发送 Ping 的节点会将该节点标记为主观下线(Subjective Down)。
具体来说:
cluster-node-timeout
),节点 A 将节点 B 视为主观下线。需要注意的是,主观下线是个体判断结果,仅代表节点 A 的“个人意见”。实际上,节点 B 可能只是因为与节点 A 的网络中断,而与其他节点保持正常通信。因此,主观下线不代表节点 B 真的下线。
Redis 采用投票机制来确定节点的客观下线。当多个节点都认为某节点主观下线时,就会触发客观下线(Objective Down)流程:
客观下线仅对主节点有效,不影响从节点。通过这种方式,集群能有效排除网络分区等非故障因素的干扰,确保故障检测的准确性。
在标记主节点客观下线后,Redis 集群会从其从节点中选出一个节点替代它,完成故障恢复流程。以下是具体的恢复步骤。
在发生故障后,原主节点的所有从节点都需要通过资格检查。具体标准如下:
cluster-node-timeout * cluster-slave-validity-factor
(默认值为 10),则该从节点失去选举资格。资格检查的目的是防止因断开时长过长、数据不同步的从节点成为新的主节点,从而确保集群数据的正确性。
通过资格检查的从节点根据复制偏移量触发选举。复制偏移量表示从节点的数据同步进度,偏移量越大说明数据越新。在 Redis 中,复制偏移量更大的从节点会优先触发选举。
满足条件的从节点会发起选举请求并更新自己的配置纪元(configEpoch)。配置纪元是 Redis 集群的一个版本标识,随着集群状态的变化而递增。发起选举请求的从节点会将 FAILOVER_AUTH_REQUEST 消息广播到集群内,表示它准备竞选新的主节点。
只有主节点有投票权。当从节点发起选举请求后,集群中超过半数的主节点投票支持该从节点,该从节点便当选为新的主节点。如果在 cluster-node-timeout * 2
时间内未能获得足够的票数,选举作废,等待下一轮选举。
在投票过程中,偏移量较大的从节点通常会更早触发选举,因此更有可能赢得多数票。
新的主节点选出后,会将自己的角色变更信息广播给集群内的所有节点。新主节点负责故障节点的槽数据,并通知其他从节点完成数据同步。至此,故障主节点的恢复流程完成,新的主节点接替原主节点提供服务。
Redis 集群的故障检测与恢复机制依赖 Ping/Pong 通信、主观下线、客观下线及投票选举机制。通过主观与客观下线的分离判断,Redis 集群能有效避免误判,并在主节点故障时快速选出新的主节点,实现自动化的故障转移与服务恢复。
这种机制不仅提升了系统的高可用性,也减少了手动干预的复杂性,使 Redis 集群能够在复杂的分布式环境中保持稳定运行。