分布式缓存

对于经常使用的数据,我们一般会使用 Redis 作为缓存机制,为了实现高可用,使用了3台Redis(没有设置集群,集群至少要6台)。

使用hash算法,存储的时候根据公式 h = hash(key)%机器节点数,h 为 Redis 对应的编号,取数据的时候也根据相同的公式取,因此一定可以从存储的机器中拿到想要的数据。但是使用这种策略可能会存在以下问题:

  • 假设有一台 Redis 服务器宕机了,此时每个 key 就要按照 h = hash(key)%(机器节点数-1) 重新计算
  • 假设要新增一台 Redis 服务器,此时每个 key 就要按照 h = hash(key)%(机器节点数+1) 重新计算

也就是说,如果服务节点有变更,会导致缓存失效,大量的 key 需要重新计算,在这期间如果有请求进来,就会直接打到数据库上,导致缓存雪崩。

一致性哈希算法

一致性哈希是讲整个哈希空间组织成一个虚拟的圆环,假设哈希函数 H 的值空间为 [0,2^32-1](哈希值是32位无符号整形)。

把服务器按照 IP 或者主机名作为关键字进行哈希,确定服务器在哈希环中的位置。

再使用哈希函数把数据对象映射到环上,数据从顺时针方向找,遇到的第一个服务器就是它定位到的服务器。

image-20231228103401543

结论:数据1、2存储服务器B上,数据3存储在服务器C上,数据4存储在服务器A上

容错性和可扩展性

假如这时候有服务器C宕机了呢?那么只有原本在B和C之间的数据会失效,重新定位到服务器A,其他数据节点的服务器不会发生变化。

image-20231228103647790

或者我们想新增一台服务器D呢?那么只有C和D之间的数据会失效,重新定位到服务器D,而其他的数据节点的存储服务器也不会发生任何变化。

image-20231228103831559

可以看出,一致性哈希算法对于节点的增减只会有一部分数据需要重新定位,不会导致大量的缓存失效。

虚拟节点

现实的业务场景中,节点不会分布得那么均匀,如果节点较少,可能会出现数据倾斜的情况。

观察下图,所有的数据全都定位到服务B上,无法实现负载均衡了。

image-20231228104239560

为了解决这种数据存储不平衡的问题,一致性哈希算法引入了虚拟节点机制,即对每个节点计算多个哈希值,每个计算结果位置都放置在对应节点中,这些节点称为虚拟节点。

image-20231228112641899

增加了虚拟节点到实际节点的映射,这样就能解决服务节点少时数据不平均的问题了。在实际应用中,通常将虚拟节点数设置为32甚至更大,因此即使很少的服务节点也能做到相对均匀的数据分布。

手撕源码

介绍完一致性哈希算法的概念和规则,接下来我们从源码的角度分析一致性哈希算法是怎么实现的。

哈希算法

首先确定项目中要使用的哈希算法,其中服务器和数据的映射都依赖哈希算法。

非加密算法:MurMurHash算法

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
/**
* MurMurHash算法,是非加密HASH算法,性能很高,
* 比传统的CRC32,MD5,SHA-1(这两个算法都是加密HASH算法,复杂度本身就很高,带来的性能上的损害也不可避免)
* 等HASH算法要快很多,而且据说这个算法的碰撞率很低.
* http://murmurhash.googlepages.com/
*/
public static Long hash(String key) {
ByteBuffer buf = ByteBuffer.wrap(key.getBytes());
int seed = 0x1234ABCD;
ByteOrder byteOrder = buf.order();
buf.order(ByteOrder.LITTLE_ENDIAN);
long m = 0xc6a4a7935bd1e995L;
int r = 47;
long h = seed ^ (buf.remaining() * m);
long k;
while (buf.remaining() >= 8) {
k = buf.getLong();
k *= m;
k ^= k >>> r;
k *= m;
h ^= k;
h *= m;
}
if (buf.remaining() > 0) {
ByteBuffer finish = ByteBuffer.allocate(8).order(
ByteOrder.LITTLE_ENDIAN);
// for big-endian version, do this first:
// finish.position(8-buf.remaining());
finish.put(buf).rewind();
h ^= finish.getLong();
h *= m;
}
h ^= h >>> r;
h *= m;
h ^= h >>> r;
buf.order(byteOrder);
return h;
}

加密算法:md5

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
/**
* get hash code on 2^32 ring (md5散列的方式计算hash值)
* @param key
* @return long
*/
public static long hash2(String key) {
// md5 byte
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 not supported", e);
}
md5.reset();
byte[] keyBytes = null;
try {
keyBytes = key.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Unknown string :" + key, e);
}
md5.update(keyBytes);
byte[] digest = md5.digest();
// hash code, Truncate to 32-bits
long hashCode = ((long) (digest[3] & 0xFF) << 24)
| ((long) (digest[2] & 0xFF) << 16)
| ((long) (digest[1] & 0xFF) << 8)
| (digest[0] & 0xFF);
long truncateHashCode = hashCode & 0xffffffffL;
return truncateHashCode;
}

节点映射

以有序 Map 的形式在内存中缓存每个节点的 Hash 值对应的物理节点信息,所以引入了 TreeMap 进行存储。

为了增加一致性哈希算法中的虚拟节点,在初始化节点映射的过程中,将计算出 实际节点*虚拟节点 的hash值,以 Hash 值为 key,以物理节点标识为 value,以有序 Map 的形式在内存中缓存,作为后续计算数据对象对应的物理节点时的查询数据。代码如下,virtualHash2RealNode 中缓存着所有虚拟节点 Hash 值对应的物理节点信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 虚拟节点数
*/
private final int NODE_NUM = 1000;

/**
* 映射到哈希环上的 虚拟节点+真实节点 (使用 红黑树 排序)
*/
private TreeMap<Long, String> virtualHash2RealNode = new TreeMap<Long, String>();

/**
* 初始化节点(引入虚拟节点)
* init consistency hash ring, put virtual node on the 2^64 ring
*/
public void initVirtual2RealRing(List<String> shards) {
this.shardNodes = shards;
for (String node : shardNodes) {
for (int i = 0; i < NODE_NUM; i++){
long hashCode = hash("SHARD-" + node + "-NODE-" + i);
virtualHash2RealNode.put(hashCode, node);
}
}
}

数据定位节点

已知 virtualHash2RealNode 中存放着物理节点的信息,使用 tailMap() 方法寻找到比该数据大的范围内的所有物理节点,返回第一个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 寻找数据所对应节点
* 从顺时针遇到的第一个节点
* get real node by key's hash on the 2^64
*/
public String getShardInfo(String key) {
long hashCode = hash(key);
SortedMap<Long, String> tailMap = virtualHash2RealNode.tailMap(hashCode);
if (tailMap.isEmpty()) {
return virtualHash2RealNode.get(virtualHash2RealNode.firstKey());
}
return virtualHash2RealNode.get(tailMap.firstKey());
}

工具类

一般在项目中,会把一致性哈希算法包装成工具类使用。

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
public class ConsistencyHashUtil {

/**
* 实际节点
*/
private List<String> shardNodes;

/**
* 存储节点数
*/
private final int NODE_NUM = 1000;

/**
* 映射到哈希环上的 虚拟节点+真实节点 (使用 红黑树 排序)
*/
private TreeMap<Long, String> virtualHash2RealNode = new TreeMap<Long, String>();
/**
* 初始化节点(引入虚拟节点)
* init consistency hash ring, put virtual node on the 2^64 ring
*/
public void initVirtual2RealRing(List<String> shards) {
this.shardNodes = shards;
for (String node : shardNodes) {
for (int i = 0; i < NODE_NUM; i++){
long hashCode = hash("SHARD-" + node + "-NODE-" + i);
virtualHash2RealNode.put(hashCode, node);
}
}
}
/**
* 寻找数据所对应节点
* 从顺时针遇到的第一个节点
* get real node by key's hash on the 2^64
*/
public String getShardInfo(String key) {
long hashCode = hash(key);
SortedMap<Long, String> tailMap = virtualHash2RealNode.tailMap(hashCode);
if (tailMap.isEmpty()) {
return virtualHash2RealNode.get(virtualHash2RealNode.firstKey());
}
return virtualHash2RealNode.get(tailMap.firstKey());
}
/**
* 打印节点
* prinf ring virtual node info
*/
public void printMap() {
System.out.println(virtualHash2RealNode);
}
/**
* MurMurHash算法,是非加密HASH算法,性能很高,
* 比传统的CRC32,MD5,SHA-1(这两个算法都是加密HASH算法,复杂度本身就很高,带来的性能上的损害也不可避免)
* 等HASH算法要快很多,而且据说这个算法的碰撞率很低.
* http://murmurhash.googlepages.com/
*/
public static Long hash(String key) {
ByteBuffer buf = ByteBuffer.wrap(key.getBytes());
int seed = 0x1234ABCD;
ByteOrder byteOrder = buf.order();
buf.order(ByteOrder.LITTLE_ENDIAN);
long m = 0xc6a4a7935bd1e995L;
int r = 47;
long h = seed ^ (buf.remaining() * m);
long k;
while (buf.remaining() >= 8) {
k = buf.getLong();
k *= m;
k ^= k >>> r;
k *= m;
h ^= k;
h *= m;
}
if (buf.remaining() > 0) {
ByteBuffer finish = ByteBuffer.allocate(8).order(
ByteOrder.LITTLE_ENDIAN);
// for big-endian version, do this first:
// finish.position(8-buf.remaining());
finish.put(buf).rewind();
h ^= finish.getLong();
h *= m;
}
h ^= h >>> r;
h *= m;
h ^= h >>> r;
buf.order(byteOrder);
return h;
}
/**
* get hash code on 2^32 ring (md5散列的方式计算hash值)
* @param key
* @return long
*/
public static long hash2(String key) {
// md5 byte
MessageDigest md5;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 not supported", e);
}
md5.reset();
byte[] keyBytes = null;
try {
keyBytes = key.getBytes("UTF-8");
} catch (UnsupportedEncodingException e) {
throw new RuntimeException("Unknown string :" + key, e);
}
md5.update(keyBytes);
byte[] digest = md5.digest();
// hash code, Truncate to 32-bits
long hashCode = ((long) (digest[3] & 0xFF) << 24)
| ((long) (digest[2] & 0xFF) << 16)
| ((long) (digest[1] & 0xFF) << 8)
| (digest[0] & 0xFF);
long truncateHashCode = hashCode & 0xffffffffL;
return truncateHashCode;
}
public static void main(String[] args) {
List<String> shards = new ArrayList<String>();
shards.add("consumer-uuid-2");
shards.add("consumer-uuid-1");
ConsistencyHashUtil sh = new ConsistencyHashUtil();
sh.initVirtual2RealRing(shards);
sh.printMap();
int consumer1 = 0;
int consumer2 = 0;
for (int i = 0; i < 10000; i++) {
String key = "consumer" + i;
System.out.println(hash(key) + ":" + sh.getShardInfo(key));
if ("consumer-uuid-1".equals(sh.getShardInfo(key))) {
consumer1++;
}
if ("consumer-uuid-2".equals(sh.getShardInfo(key))) {
consumer2++;
}
}
System.out.println("consumer1:" + consumer1);
System.out.println("consumer2:" + consumer2);
/*long start = System.currentTimeMillis();
for (int i = 0; i < 1000 * 1000 * 1000; i++) {
if (i % (100 * 1000 * 1000) == 0) {
System.out.println(i + ":" + hash("key1" + i));
}
}
long end = System.currentTimeMillis();
System.out.println(end - start);*/
}
}

hutool 工具包也有封装好一致性哈希算法的工具类,只需要传入复制的节点个数和节点对象就能初始化节点映射。

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
88
89
90
91
/**
* 一致性Hash算法
* 算法详解:http://blog.csdn.net/sparkliang/article/details/5279393
* 算法实现:https://weblogs.java.net/blog/2007/11/27/consistent-hashing
* @author xiaoleilu
*
* @param <T> 节点类型
*/
public class ConsistentHash<T> implements Serializable{
private static final long serialVersionUID = 1L;

/** Hash计算对象,用于自定义hash算法 */
Hash32<Object> hashFunc;
/** 复制的节点个数 */
private final int numberOfReplicas;
/** 一致性Hash环 */
private final SortedMap<Integer, T> circle = new TreeMap<>();

/**
* 构造,使用Java默认的Hash算法
* @param numberOfReplicas 复制的节点个数,增加每个节点的复制节点有利于负载均衡
* @param nodes 节点对象
*/
public ConsistentHash(int numberOfReplicas, Collection<T> nodes) {
this.numberOfReplicas = numberOfReplicas;
this.hashFunc = key -> {
//默认使用FNV1hash算法
return HashUtil.fnvHash(key.toString());
};
//初始化节点
for (T node : nodes) {
add(node);
}
}

/**
* 构造
* @param hashFunc hash算法对象
* @param numberOfReplicas 复制的节点个数,增加每个节点的复制节点有利于负载均衡
* @param nodes 节点对象
*/
public ConsistentHash(Hash32<Object> hashFunc, int numberOfReplicas, Collection<T> nodes) {
this.numberOfReplicas = numberOfReplicas;
this.hashFunc = hashFunc;
//初始化节点
for (T node : nodes) {
add(node);
}
}

/**
* 增加节点<br>
* 每增加一个节点,就会在闭环上增加给定复制节点数<br>
* 例如复制节点数是2,则每调用此方法一次,增加两个虚拟节点,这两个节点指向同一Node
* 由于hash算法会调用node的toString方法,故按照toString去重
* @param node 节点对象
*/
public void add(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
circle.put(hashFunc.hash32(node.toString() + i), node);
}
}

/**
* 移除节点的同时移除相应的虚拟节点
* @param node 节点对象
*/
public void remove(T node) {
for (int i = 0; i < numberOfReplicas; i++) {
circle.remove(hashFunc.hash32(node.toString() + i));
}
}

/**
* 获得一个最近的顺时针节点
* @param key 为给定键取Hash,取得顺时针方向上最近的一个虚拟节点对应的实际节点
* @return 节点对象
*/
public T get(Object key) {
if (circle.isEmpty()) {
return null;
}
int hash = hashFunc.hash32(key);
if (false == circle.containsKey(hash)) {
SortedMap<Integer, T> tailMap = circle.tailMap(hash); //返回此映射的部分视图,其键大于等于 hash
hash = tailMap.isEmpty() ? circle.firstKey() : tailMap.firstKey();
}
//正好命中
return circle.get(hash);
}
}

传入复制的节点个数和实际物理节点信息,实现一致性哈希。

1
2
3
4
 public static ConsistentHash<Node> makeProxyPool(List<OpenaiProxy> openaiProxies) {
List<Node> realNodes = openaiProxies.stream().map(item -> new Node(item.getHost(), item.getToken())).collect(Collectors.toList());
return new ConsistentHash<>(500, realNodes);
}

一致性哈希局限性分析

核心特点

一致性哈希的核心特性决定了其适用边界:

1、 极低的数据迁移成本:扩容/缩容时仅影响相邻节点数据(约1/N),避免缓存雪崩

2、 虚拟节点均衡机制:通过多虚拟节点分散物理节点压力,解决数据倾斜

3、 固定哈希空间:基于2^32固定环,不随节点数变化

4、 无物理拓扑感知:无法识别机房、机架等物理位置

5、 数据无序分布:相邻Key可能分布在不同节点

适用场景

分布式缓存扩容

优势体现

  • 扩容时缓存命中率保持>90%
  • 虚拟节点自动均衡负载
  • 避免数据库穿透风险

典型系统

  • Memcached客户端分片
  • Redis集群前期的客户端分片方案

无状态服务负载均衡

场景案例

  • Dubbo的粘滞会话调用
  • API网关的用户会话路由
  • 分布式Session管理

核心价值:相同参数请求始终路由到同一实例

不适用场景

多机房拓扑感知场景

问题本质

无法感知物理拓扑,违背跨机房容灾原则

工业级解决方案

  • Ceph的CRUSH算法:层级拓扑感知
  • TiDB的PD调度:基于机架位置分配Region副本

数据库范围查询

1
2
3
SELECT * FROM orders 
WHERE create_time BETWEEN '2023-01-01' AND '2023-12-31'
ORDER BY amount DESC; -- 需要连续数据

一致性哈希缺陷

  • 时间相邻的数据可能分布在不同节点
  • 排序操作引发跨节点数据收集
  • 性能急剧下降

替代方案

  • TiDB的Range Sharding:按主键范围分片
  • HBase的Region划分:基于RowKey有序分布

超大规模集群(>1000节点)

1
2
3
# TreeMap查找时间复杂度
search_time = O(log N) # N为虚拟节点数
# 当虚拟节点达10万级时延迟显著

运维灾难

  • 虚拟节点管理成本指数增长
  • 数据迁移效率低下

架构升级方案

image-20251028173400371

Redis Cluster 为什么选择哈希槽而非一致性哈希

先说结论, Redis Cluster的哈希槽架构方案决策基于:

1、 数据均匀分布优先:哈希槽更加有利于数据在节点上的均衡分布

2、 控制数据迁移:固定槽位作为迁移单元更易管理

3、 提升运维灵活性:支持按节点性能手动分配槽位

4、 网络带宽优化:固定槽位数降低Gossip协议开销

这种设计使Redis Cluster在保证数据均匀分布的同时,兼顾了集群管理的灵活性和性能。

Redis Cluster 的分片设计

Redis Cluster采用固定16384个槽位(slots)进行数据分片,每个槽位对应一个数据子集。

键值对通过CRC16哈希算法映射到具体槽位:

1
slot = CRC16(key) % 16384

槽位与节点的映射采用静态分配机制,比如:

  • 节点A:槽位0-5460
  • 节点B:槽位5461-10922
  • 节点C:槽位10923-16383

Redis Cluster 动态扩容机制

当新增节点时,Redis Cluster 会重新分配槽位:

image-20251028173509876

通过CLUSTER ADDSLOTS命令手动调整槽位分布,实现数据均匀分布。

Redis Cluster 去中心化元数据管理

Redis Cluster采用Gossip协议实现去中心化元数据同步:

image-20251028173552609

节点间通过PING/PONG报文交换槽位映射信息,最终达成集群状态一致。

槽位多,传输元数据就很大。

redis采用bitmap机制管理元数据,也就是说 哈希槽 (2^14) / 8 = 16384 大概2K。

而 如果 redis采用 一致性哈希,也就是说 槽位数 2^32 / 8 会大很多,影响心跳传输效率。

Redis Cluster 为什么不是一致性哈希?

redis选择哈希槽,而不是一致性哈希,主要基于三方面考虑

  • 固定槽位数降低Gossip协议开销,槽位少心跳时传送的元数据就少,而且从实际场景中不需要那么多槽位,一般集群规模200节点以下,16348 / 200 =81足够
  • 提升运维灵活性,固定槽位支持手动分配槽位
  • 哈希槽更加利于数据均衡分布,而且可以手动分配节点槽位,性能好的节点可以多分配槽位

image-20251028173721146

数据分布优先策略

Redis Cluster选择哈希槽而非一致性哈希的核心原因在于其数据分布优先策略

通过固定16384个槽位与手动分配机制,优先保证数据均匀分布和精确控制,牺牲了部分扩容灵活性以换取更稳定的负载均衡,尤其适合中小规模集群(≤1000节点)。

维度 一致性哈希 Redis槽位
核心目标 最小化数据迁移 保证数据均匀分布
扩容影响 仅影响相邻节点 全局重新分配
数据倾斜处理 依赖虚拟节点 手动调整槽位分布
适用规模 超大规模集群 中小规模集群(≤1000节点)

16384(2^14)槽位的精妙设计

edis作者Salvatore Sanfilippo的解释:

  • 网络优化:心跳包携带全量槽位信息仅需2KB(16384/8)
  • 规模适配:千节点规模下每个节点仍持有足够槽位(16384/1000≈16)
  • 实践经验:生产环境集群通常不超过200个节点

TiDB 为什么不使用一致性哈希

TiDB 的定位是 “让分布式数据库像 MySQL 一样好用”,它兼容 MySQL 协议与 SQL 语法,支持 ACID 事务,同时解决传统 MySQL“分片难、扩容复杂” 的问题,属于 NewSQL(新型关系型数据库)范畴,适合需要关系型数据库特性但需水平扩展的场景。

TiDB 核心架构

TiDB 采用 “三层架构”,职责清晰且解耦:

  • TiDB Server:计算层(无状态),负责接收 SQL 请求、解析执行计划、优化查询,可横向扩展(增加节点即可提升并发能力),不存储数据。
  • PD(Placement Driver):集群调度中心,负责管理 TiKV 的分片(Region)、调度数据迁移(如扩容时的分片均衡)、维护集群元数据,类似 “大脑”。
  • TiKV:存储层(分布式 KV 存储),按 Range 分片存储数据(将 SQL 表映射为 KV 键值对),基于 Raft 协议实现副本(通常 3 副本),确保数据可靠。

TiDB 典型场景

  • 传统 MySQL 分片替代:当 MySQL 单库数据量超过 100GB、并发超过 1 万 QPS 时,分片运维复杂,TiDB 可直接替代并支持无缝扩容(如电商订单库、用户库)。
  • 金融核心业务:如银行转账、支付系统,需强一致性和 ACID 事务,TiDB 的 Raft 协议和分布式事务可满足需求。
  • HTAP 混合场景:如零售行业的 “实时订单处理 + 实时销售报表”,无需分别部署 MySQL 和 Hive,TiDB 可同时承载事务和分析负载。

TiDB 放弃一致性哈希的根本原因

  • 一致性哈希适合“只点查、不范围查、不讲究容灾”的场景(如缓存、对象存储);
  • 而 TiDB 作为分布式关系型数据库,必须支持范围查询 + 强一致性 + 容灾隔离
  • 所以一致性哈希的“拓扑盲区”和“范围查询失效”是架构级致命缺陷

拓扑盲区:无法感知物理拓扑,击穿数据容灾底线

一致性hash只关心:

  • 节点哈希到环上,数据按哈希值顺时针找最近的节点。
  • 增加/删除节点只影响局部数据,迁移量小。

一致性hash的拓扑盲区: 完全不知道“节点在哪” —— 不关心机房、机架、交换机、电力域。拓扑盲区:副本“瞎放”,容灾“白搭”

一致性哈希的核心问题是 “只认哈希值,不认物理位置”—— 它将节点抽象成环上的 “哈希点”,分配数据时仅根据 Key 的哈希值找环上最近的节点,完全忽略节点实际部署的机房、机架、机柜等物理拓扑信息。

这一 “拓扑盲区” 直接导致副本部署失控,最终引发数据不可用风险。

例如:

  • 假设将 3 个 TiDB 存储节点(Node1、Node2、Node3)哈希到环上,Node1 和 Node2 恰好被哈希到相邻位置,且二者实际部署在同一机房 A,Node3 部署在机房 B;
  • 当分配某条数据的 3 个副本时,一致性哈希会按 “环上最近” 原则,将副本 1 存 Node1、副本 2 存 Node2、副本 3 存 Node3—— 此时 2 个副本集中在机房 A,仅 1 个在机房 B。

TiDB 作为数据库,数据可用性是底线,必须通过 “跨机房 / 机架部署副本” 实现容灾(比如 1 个副本在机房 A、1 个在机房 B、1 个在机房 C),确保单一机房断电 / 断网时,仍有副本可用。

但一致性哈希的拓扑盲区会导致:

  • 物理集中风险:多个副本被分配到同一机房 / 机架,形成 “单点故障链”—— 若机房 A 断电,Node1 和 Node2 同时不可用,仅剩的 Node3 副本若再故障,数据直接丢失;
  • 无法主动控制副本拓扑:TiDB 无法强制要求 “副本必须跨机房部署”,因为一致性哈希的分配逻辑完全由哈希值决定,无法干预物理位置关联。

对 TiDB 而言,“数据不可用” 是致命故障,而拓扑盲区直接让容灾设计形同虚设,这是其放弃一致性哈希的首要原因。

范围查询失效:破坏 Key 自然顺序,拖垮关系型查询性能

一致性哈希的 范围查询 做法: 对 Key 做哈希(如 hash(user_id)),打散到整个环上。 结果:逻辑相邻的 Key,物理上可能天各一方

一致性哈希为了“负载均衡”牺牲了局部性原则,导致范围查询变成分布式全表扫描,这在 OLTP/HTAP 系统中是不可接受的。

TiDB 作为关系型数据库,“范围查询” 是高频核心场景(如SELECT * FROM order WHERE id BETWEEN 1000 AND 2000SELECT * FROM log WHERE create_time >= '2025-01-01')。

但一致性哈希的 “哈希分配逻辑” 会彻底破坏 Key 的自然顺序,导致相邻 Key 分散存储,最终让范围查询陷入 “多节点扫描 + 跨网合并” 的性能泥潭。

例如:

  • Key 的自然顺序是 id=100 → id=101 → id=102 → id=103
  • 经哈希计算后,可能的分配结果是:id=100存 Node1、id=101存 Node3、id=102存 Node2、id=103存 Node1;
  • 最终,“100~103” 这个连续的 Key 范围,被拆分成 Node1(100、103)、Node2(102)、Node3(101)三个节点存储,完全失去 “连续性”。

直接后果:范围查询需跨节点合并,性能雪崩

TiDB 的解法:Range-based 分片(Region)

iDB 的存储层 TiKV 采用按范围分片(Region)

  • 数据按主键范围划分为连续区间(如 [start, end))。
  • 每个 Region 默认 96MB,三副本,通过 PD(Placement Driver) 调度。
  • PD 感知拓扑(机房、机架、主机),强制副本隔离(如跨机房、跨机架)。
  • 范围查询只需访问少量 Region,性能线性扩展。

TiDB的Range分片机制

TiDB 集群由 PD + TiKV + TiDB Server 构成。

image-20251028174813356

关键设计

1、 动态分片:Region默认96MB,自动拆合

2、 负载感知:QPS/容量超阈值触发调度

3、 Raft副本:多副本保证高可用

TiDB分片流程:

image-20251028174845600

TiDB的拓扑感知

TiDB通过PD(Placement Driver)实现智能副本分布:

副本放置规则

(1) 跨机房部署(如A机房2副本,B机房1副本)

(2) 同机房跨机架

(3) 规避单点故障

image-20251028174917524

Ceph为什么不用一致性哈希

Ceph 的核心价值是打破存储类型壁垒,用一套系统同时提供对象存储(兼容 S3/Swift)、块存储(类似磁盘,供虚拟机 / 容器使用)、文件存储(兼容 POSIX,供应用直接挂载),避免企业部署多套存储系统的成本浪费。

Ceph 核心架构

Ceph 集群由三大核心组件构成,各司其职:

  • Monitor(监控节点):维护集群拓扑(CRUSH Map)、配置信息和集群状态,确保数据分配规则正确执行(无数据存储功能)。
  • OSD(对象存储节点):实际存储数据的单元(通常对应物理磁盘),负责数据的读写、复制和故障恢复,是集群的 “存储肌肉”。
  • MDS(元数据服务器,可选):仅用于文件存储场景,管理文件系统的元数据(如目录结构、文件权限),对象 / 块存储无需 MDS。

Ceph 典型场景

  • 云平台后端存储:为公有云 / 私有云提供虚拟机磁盘(块存储)、对象存储服务(如图片 / 视频存储),例如 OpenStack 默认集成 Ceph。
  • 海量数据备份归档:企业备份数据(如数据库备份、日志文件)、归档数据(如医疗影像、监控录像),支持按对象生命周期管理(自动冷热数据迁移)。
  • 混合云存储对接:因兼容 S3 API,可作为私有云存储与 AWS S3、阿里云 OSS 等公有云存储对接,实现数据同步或容灾。

一致性哈希在分布式存储的致命伤

要理解 Ceph 放弃一致性哈希的核心原因,需先明确二者的定位差异:一致性哈希为小规模、简单分布式场景(如 Memcached 缓存) 设计,核心目标是 “减少节点上下线时的数据迁移量”.

而 Ceph 是企业级大规模分布式存储系统,核心诉求是 “数据高可靠(跨拓扑容灾)、集群自动化运维(免人工干预)”。

维度 一致性哈希 CRUSH
输入 object-key object-key + 拓扑树 + 规则 + 权重
输出 一个节点 一组满足“隔离约束”的 OSD
是否感知机房/机架 ✅ 逐层强制隔离
是否可人工指定“别放同机架” ✅ 规则引擎
加节点是否自动再平衡 ❌ 需手动触发 ✅ 自动、仅迁移必要数据
副本故障是否重新计算 ❌ 需外部脚本 ✅ 客户端本地 5ms 内算出新 OSD