文章目录
哈希表是什么
试想一下,在上世纪五十年代,计算机并没有高速的内存分配的时期,查找的方法就两种,一种是直接遍历寻找 ,在n个数中找到想要的那个数,就需要 O(n) 的时间复杂度,而另一种看似高效---二分查找 ,它通过二分的方法来对数据进行寻找,时间复杂度为:O(logn) ,但是它的前题是要排序才能用的,而且在删除时需要更大的成本,这个时候一种新的查找,删除,添加的利器-- 哈希表 应运而生,一开始它通过给定关键字数字 key,数组长度 M,地址 = key mod M;直接把数据存入数组对应下标,读取时再次取模一步定位,到如今对减少哈希冲突多次的优化,它大多数时候在查找,删除,添加都能做到 O(1) 的时间复杂度。
哈希表的原理-底层理解
提到哈希表的原理就要提到哈希方法,它是一种选定某个函数方法,来计算元素的存储位置的一类方法,常见的主要有以下几种:
1.直接定址法
Hash(key)=a*key+b(a,b为常数) ,以一个线性函数来计算哈希值,这种方法并不会有哈希冲突(即计算的两个不同元素的两个哈希值出现了相等的情况),但是如果原数组的元素值太过于稀疏,那这样算出来的哈希值就会太过于分散,而浪费内存空间。
2.除留余数法
Hash(key)=key mod p(p是一个整数) ,即对数组元素进行取模的操作,用他的余数来表示哈希值,一般来说,所选的模数p要小于等于哈希表的表长且为质数,但是这样的话余数就会有很多的相同情况,从而产生哈希冲突。
3.乘余取整法
Hash(key)=Hash(key)=⌊M×(key×A−⌊key×A⌋)⌋ ,其中:
M:哈希表容量;
A:常数,通常取 (0<A<1),黄金分割数 (A≈0.618) 效果最好;
流程:key 乘 A,取小数部分,再乘以 M,向下取整得到哈希下标。
它对分散的数据更加适应,但是依然有除留余数法的通病--会存在哈希冲突,因为在计算的时候还会有冲突,只是他的概率比方法二就要小些。
以上方法多是对于数字哈希值的计算而言,但在工业上有几种减小它发生哈希冲突的方法:
线性探测开放寻址法
其实顾名思义,就是算出哈希位置被占、出现冲突的时候,把这个冲突的数据往后挨个找下一个空位置存进去。所以哈希表得留够空余空间,不能填太满。另外如果我们要删掉表里的数据,不能直接把格子清空,不然会打断查找的一串数据,后面的内容就搜不到了,所以得给删掉的格子单独打个标记(一般称为墓碑标记),不能直接抹成空白,以下一个代码示意:
java
import java.io.*;
import java.util.*;
public class Main {
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
static class HashMap {
// 0-空无数据,1-占用,2-已删除状态
int[] hash;//存状态
int[] keys;//存键值
int[] values;//存value值
int size;//存储的元素个数
int capacity;//总容量
final double LOAD_FACTOR = 0.75;//负载因子,和工业级的一样为0.75
public HashMap() {
this.capacity = 31;//初始容量为31,一个质数
hash = new int[capacity];
keys = new int[capacity];
values = new int[capacity];
size = 0;
}
public void put(int key, int value) {//插入元素
if (size >= capacity * LOAD_FACTOR) {//如果容量已满,则扩容
resize(capacity * 2);
}
int index = find(key);//寻找插入的位置下标
if (hash[index] == 0 || hash[index] == 2) {//如果位置为空或者已删除状态,则插入
hash[index] = 1;//将哈希状态设为以占用
keys[index] = key;//赋入键值
values[index] = value;//赋入value值
size++;//有效长度加加
} else {//如果已经状态为被占用了,就覆盖
values[index] = value;
}
}
public int get(int key) {//查询元素
int index = find(key);//查找位置下标
if (hash[index] == 1) return values[index];//如果状态为占用,则返回value值
return -1;//如果状态为空或者已删除状态,则返回-1
}
public boolean containsKey(int key) {//判断是否包含某个键值
int index = find(key);//查找位置下标
return hash[index] == 1;//如果状态为占用,则返回true,否则返回false
}
public boolean containsValue(int value) {//判断是否包含某个value值
for (int i = 0; i < capacity; i++) {//遍历哈希表
if (hash[i] == 1 && values[i] == value) {//如果状态为占用且value值等于给定值,则返回true
return true;
}
}
return false;
}
public int remove(int key) {//删除元素
int index = find(key);//查找位置下标
if (hash[index] != 1) return -1;//如果不存在该元素,则返回-1
int res = values[index];//存储要删除的value值
hash[index] = 2; //将状态设为已删除
size--;//长度减减
return res;//返回删除的value值
}
public void print() {//打印哈希表
StringBuilder sb = new StringBuilder();
sb.append("{");
boolean first = true;
for (int i = 0; i < capacity; i++) {
if (hash[i] == 1) {//如果状态为占用,即有有效元素才输出打印
if (!first) sb.append(", ");
sb.append(keys[i]).append("=").append(values[i]);
first = false;
}
}
sb.append("}");
out.println(sb.toString());
}
private int find(int key) {//查找元素位置下标
int index = (key % capacity + capacity) % capacity;//利用同余原理计算哈希值(处理负数key,确保下标非负)
int count = 0;//记录探测次数,防止哈希表满时发生死循环
while (hash[index] != 0 && keys[index] != key && count < capacity) {//当前位置被占用,且不是目标key,且未遍历完整个数组
index = (index + 1) % capacity;//发生哈希冲突,线性探测到下一个位置
count++;//探测次数加1
}
return index;//返回最终找到的下标(若找到key则返回key所在下标,若未找到则返回空位或伪删除位的下标)
}
private int nextPrime(int n) {//在扩容的时候每次保证容量为质数
if (n <= 2) return 2;
if (n % 2 == 0) n++;//如果n为偶数,则加1
while (true) {
boolean isPrime = true;
for (int i = 3; i * i <= n; i += 2) {//判断是否为质数
if (n % i == 0) {
isPrime = false;
break;
}
}
if (isPrime) return n;//如果是质数,则返回该数
n += 2; //否则,加2,把它变为质数
}
}
private void resize(int newCapacity) {//扩容操作
int newCap = nextPrime(newCapacity);//扩容为一个质数
//将原哈希素表复制过来
int oldCap = capacity;
int[] oldHash = hash;
int[] oldKeys = keys;
int[] oldVals = values;
//创建新的哈希表
capacity = newCap;
hash = new int[capacity];
keys = new int[capacity];
values = new int[capacity];
size = 0;
for (int i = 0; i < oldCap; i++) {
if (oldHash[i] == 1) {//将原有的有效元素插入扩容后的哈希表
put(oldKeys[i], oldVals[i]);
}
}
}
}
public static void main(String[] args) throws IOException {//主函数测试
int n = Integer.parseInt(br.readLine());
HashMap hm = new HashMap();
for (int i = 0; i < n; i++) {
st = new StringTokenizer(br.readLine());
String op = st.nextToken();
if (op.equals("put")) {
int num1 = Integer.parseInt(st.nextToken());
int num2 = Integer.parseInt(st.nextToken());
hm.put(num1, num2);
} else if (op.equals("get")) {
int num1 = Integer.parseInt(st.nextToken());
out.println(hm.get(num1));
} else if (op.equals("containsKey")) {
int num1 = Integer.parseInt(st.nextToken());
out.println(hm.containsKey(num1));
} else if (op.equals("containsValue")) {
int num1 = Integer.parseInt(st.nextToken());
out.println(hm.containsValue(num1));
} else if (op.equals("remove")) {
int num1 = Integer.parseInt(st.nextToken());
out.println(hm.remove(num1));
} else if (op.equals("size")) {
out.println(hm.size);
} else if (op.equals("print")) {
hm.print();
}
}
out.flush();
br.close();
out.close();
}
}
对于以上代码,它的时间复杂度如下:
| 方法 | 平均时间复杂度 | 最坏时间复杂度 | 核心说明 |
|---|---|---|---|
| find(底层探测函数) | O(1) | O(n) | 负载因子0.75,正常探测次数为常数;极端哈希聚集需遍历整张哈希表 |
| get / containsKey / remove | O(1) | O(n) | 仅调用find定位,后续只有常数次判断、赋值操作 |
| put(未触发扩容) | O(1) | O(n) | find找到空位/墓碑后直接写入,无额外循环 |
| put(触发resize扩容) | 均摊O(1) | O(n) | 单次扩容O(n),容量近似翻倍,扩容成本分摊全部插入 |
| resize 扩容函数 | O(n) | O(n) | 遍历旧表所有槽位,有效元素全部重哈希插入新数组 |
| containsValue | O(n) | O(n) | 无value索引,必须完整遍历整个哈希数组比对数值 |
| O(n) | O(n) | 遍历全部数组槽位,拼接所有有效键值对输出 | |
| nextPrime 寻找质数 | O(1) | O(1) | 质数判断循环次数极少,属于常数级开销 |
空间复杂度如下:
| 运行场景 | 空间复杂度 | 渐进复杂度 | 核心说明 |
|---|---|---|---|
| 常规运行(非扩容阶段) | O(n) | O(n) | 开辟hash、keys、values三组等长数组,负载因子0.75,数组总长度约1.33n,渐进为线性空间 |
| 扩容瞬时阶段 | O(n) | O(n) | resize时新旧数组同时驻留内存,峰值内存约2~3倍,但渐进复杂度不变 |
| 局部临时变量 | O(1) | O(1) | IO流、StringBuilder、循环计数器、局部临时数字均为常数固定空间 |
以上是一个示意,利用了工业级的写法的简写,对于这种方法,它的有优缺点:
它的特点
1.这种写法会设有负载因子阈值,就是说它在哈希表中元素所占的空间如果大于他现在所开的空间的0.75倍,就会自动扩容(一般是扩容为原来的2倍)
2.这种写法基于一维数组来存放,是一种线性结构。
3.对于每个元素,它有三种状态:即占有,为空,已被删除标记等,表示不能够直接删去表里的数据,不能直接把格子清空,不然会打断查找的一串数据,后面的内容就搜不到了。
4.它在出现哈希冲突时算出的下标被占后,固定步长 +1 向后逐个找空位,循环到数组头部(模容量)
它的优点
1.只基于一维数组储存,在空间上比较节约,相对于其他方法更省内存。
2.它的逻辑更易懂,没有很复杂的逻辑引入,对我来说还能写得出来~~~
它的缺点
1.每次扩容会浪费空间,而且扩容的时候要创建一个新的空间,成本高
2.在大量删除的情况下会使他的效率衰退,且删除需要标记,操作起来不方便。
拉链法
其实顾名思义,拉链就是在下面拉出一条链,或者红黑树,规则就是:
1.当单个桶里链表节点数量 ≤ 8:继续用单向链表;
2.当链表长度超过阈值 8:链表转为红黑树;
3.当删除节点,树节点数量降到 6 以内:红黑树退化成链表。
这样算在查询的时候,就并不需要去遍历,可以提高速度
它的优点
1.删除,插入起来简单,因为它以链表和红黑树来进行的,在删除的时候,只要删除节点就行了,插入的时候加入节点就行,特别方便。
2.冲突元素不会影响其他部分的运行,因为冲突元素被挂为了链表和红黑树隔离起来的。
它的缺点
1.在出现冲突的时候就开出链表或红黑树会消耗大量的内存,占用空间较大
2.和链表红黑树的特性相关,在查询的时候比较慢数速度不如方法一
3.实际实现更复杂,要兼顾链表转红黑树、树退化逻辑。
在这里,用一个表格来比较这两种方法:
两种哈希实现方式对比
| 实现方式 | 冲突处理逻辑 | 适用场景 | 核心特点 |
|---|---|---|---|
| 线性探测开放寻址法 | 算出下标冲突后,固定+1向后寻找空位存储 | 读多写少、删除少、内存紧张、追求CPU缓存速度 | 数组连续存储,缓存友好省内存;存在主聚集,删除需墓碑标记 |
| 拉链法(链表+红黑树) | 同一哈希下标挂载链表,链表过长转为红黑树 | 频繁增删、数据量大、哈希冲突多、常规业务开发 | 无聚集问题,删除简单;节点分散,内存开销更大 |
4.多项式哈希法
这种方法使用于对于字符串的哈希计算中,公式为Hash=Hash*base+当前字符的ASCII值(base是常用的进制数,一般取质数最为常见) ,其实就是把字符串当成一个「base 进制的超大数字」,每一位字符是进制里的一位,最后对一个大质数取模,压缩成一个 long 数字,这个数字就是字符串的哈希值,常见的题目如下:
引用洛谷P3370 【模板】字符串哈希

题目链接如下:
基于上面的哈希方法,代码如下:
java
import java.io.*;
import java.util.*;
public class Main {
static BufferedReader in = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out=new PrintWriter(System.out);
public static final int MAXN = 10001;//表示所需要存的哈希值的个数
public static final int BASE = 131;//随机设置为一个质数,利于计算
public static long[] nums = new long[MAXN];//用于存哈希值
public static int n;//字符串的个数
public static long value(char[] s) {//将字符串拆解为字符数组
long hash = 0;
for (char c : s) {
hash = hash * BASE + c;
}
return hash;
}
public static int countDistinct() {
if (n == 0) return 0;//如果字符串个数为0,就直接输出0
Arrays.sort(nums, 0, n);//对算出的哈希值进行排序
int ans = 1;//初始不重复的哈希值为1
for (int i = 1; i < n; i++) {//遍历并记录不重复的哈希值的个数
if (nums[i] != nums[i - 1]) {//如果不相同就加加
ans++;
}
}
return ans;
}
public static void main(String[] args) throws Exception {
n = Integer.parseInt(in.readLine());
for (int i = 0; i < n; i++) {
char[] str = in.readLine().toCharArray();//将字符串转为字符数组
nums[i] = value(str);//将哈希值赋值给数组
}
out.println(countDistinct());
out.flush();
out.close();
in.close();
}
}
//对该方法的原理进行进一步的说明
//public static long value(char[] s) {//将字符串拆解为字符数组
// long hash = 0;
// for (char c : s) {
// hash = hash * BASE + c;
// }
// return hash;
// }
//对于公式hash=hash*BASE+c 它是一个滚动求哈希值的公式,即带入前一个字母的哈希值进行计算
//他的原理是来源于十进制数字的计算法:num=num*10+a(num是总数,a是循环数字的每一位数)
//即计算123:
//1.一开始num=1
//2.然后num=1*10+2=12
//3.最后num=12*10+3=123,这是很常见的求十进制数字的递推式
//所以有它的迁移,我们就有了求字符串哈希值的式子
//以字符串aba为例,查找ASCII码可知,a-97,b-98,则按照公式计算为:
//先第一个字符a进入,hash=0+131*97=12707
//然后第二个字符b进入,hash=12707+131*98=25545
//继续代入,hash=25545+131*97=38252
//实际上这样算出的哈希值会越来越大,所以在工业上我们会在算式中去取模,把数字变小,利于储存.
//基于这个算式在计算时依然会有哈希冲突的出现,举个例子:
//A="\x02\x05",B="\x010B",用以上公式算出来的267.
//所以这个式子在写题时可以使用但工业上就会有更进一步的计算了
对于这个式子所容易带来的哈希冲突,在工业上又有几点优化:
工业上对公式的优化
1.引入一个模数,对它取模,来防止数字过大而溢出:
hash=(hash×BASE+c)modMOD 代码如下:
java
static final long BASE = 131;
static final long MOD = 998244353L; // 大质数模数
public static long value(char[] s) {
long hash = 0;
for (char c : s) {
hash = (hash * BASE + c) % MOD;
}
return hash;
}
这样在base和MOG的值互质的情况下,算出的哈希值会分布更均匀,这样可以减小哈希冲突发生的概率(就是防止哈希值过多出现在某个区间里)
2.双重哈希或多重哈希相互验证
有多个Base计算出的计算出的哈希值在输入不同元素是如果相同了,才会判为发生了哈希冲突,这样算出的值出现哈希冲突的概率很小,代码如下:
java
// 两套独立参数
static final long B1 = 131, M1 = 998244353;
static final long B2 = 13331, M2 = 1000000007;
// 返回二元哈希对
static long[] getHash(char[] s) {
long h1 = 0, h2 = 0;
for (char c : s) {
h1 = (h1 * B1 + c) % M1;
h2 = (h2 * B2 + c) % M2;
}
return new long[]{h1, h2};
}
哈希表的应用
| 场景分类 | 实际应用 | 核心作用 |
|---|---|---|
| 语言底层容器 | Java HashMap、Python dict、HashSet | 提供全局键值映射、快速去重、查找能力 |
| 高性能缓存 | Redis、Memcached 底层实现 | 支撑高并发读写,减轻数据库压力 |
| 数据库优化 | MySQL 哈希索引、内存临时表 | 加速等值查询,避免低效全表扫描 |
| 数据去重 | 爬虫 URL 去重、网盘秒传、签到去重 | 依靠哈希唯一值,快速判断数据重复 |
| 数据统计 | 词频统计、日志统计、元素计数 | Key 存数据,Value 计数,高效完成统计分组 |
| 安全校验 | MD5 文件校验、密码哈希存储、防篡改 | 不存储明文,校验数据完整性与一致性 |
| 缓存淘汰算法 | LRU、LFU 缓存框架 | 哈希快速定位节点,链表维护访问顺序 |
| 计算机系统底层 | 路由表、IP 映射、进程 PID 映射 | 实现系统资源快速寻址与映射 |
| 算法刷题 | 两数之和、去重、异位词分组 | 将暴力 O(n²) 解法优化为 O(n) |
哈希的深入
布隆过滤器(掌握)
试想一下:你去设计一个黑名单系统(爬虫去重系统) ,有100亿 个url需要进入黑名单,每个url有100字节建立好黑名单系统后,要判断任何一个url在不在黑名单内,确只给你30GB的内存 该如何是好呢?
这个时候想到了啥?哈希表?那这样就要开好大的内存了:
由于哈希表单条就要 100 字节,纯存储需要约 931GB 内存,那这样内存早就爆了,那应该如何解决呢?,其实想要内存小就会想到在数据结构中一种小内存容器--位图 ,它以0和1来储存元素,对一个数字只要一个比特位,1代表存在,0代表不存在 ,可是我们保存的是url,它就不可能是数字,而是字符串,那能想到啥?,就是前面说的字符串的哈希值,那我们能不能根据字符串算出一个哈希值然后映射到位图中呢,当然可以,其实,基于这种思想,一个多哈希映射+位图 结合的过滤神器--布隆过滤器就诞生了,这样既可以轻量内存小,还能快捷过滤元素,在100亿个元素下,它就只要不到24G内存了,它的简易代码如下:
java
//双哈希映射布隆过滤器
import java.io.*;
import java.util.*;
public class Main {
static BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
static PrintWriter out = new PrintWriter(System.out);
static StringTokenizer st;
static class BloomFilter {
long[] bits;
int bitSize;
// 两组不同底数、不同大模数的多项式滚动哈希
final long BASE1 = 131, BASE2 = 13331;
final long MOD1 = (long)1e9 + 7, MOD2 = (long)1e9 + 9;
public BloomFilter(int size) {
bitSize = size;
bits = new long[(bitSize + 63) / 64];//这里和位图一样,这里在取空间时用到了向上取整。
}
//这里很上面的取字符串的哈希值逻辑一样,在最后取模,防止数据量过大
private long hash1(String s) {
long h = 0;
for (int i = 0; i < s.length(); i++) {
h = (h * BASE1 + s.charAt(i)) % MOD1;
}
return h % bitSize;
}
//计算两个情况下的哈希值,有利于互相映射,减小哈希冲突
private long hash2(String s) {
long h = 0;
for (int i = 0; i < s.length(); i++) {
h = (h * BASE2 + s.charAt(i)) % MOD2;
}
return h % bitSize;
}
public void add(String s) {
setBit(hash1(s));
setBit(hash2(s));
}
//在判断元素是否存在的时候,同时看两个模式下的状态是否为1
public boolean contains(String s) {
return getBit(hash1(s)) && getBit(hash2(s));
}
//这里和位图的逻辑是一样的,只不过用了是64位long的空间下
private void setBit(long idx) {
int i = (int) idx;
bits[i >>> 6] |= (1L << (i & 63));
//(1L << (i & 63)就是把需要的那一位二进制变为1,其余都变为0
//i >>> 6就是对i除以 64,定位对应 long 数组下标
}
private boolean getBit(long idx) {//同时校验两个哈希下标对应的比特,全部为 1 才返回 true
int i = (int) idx;
return (bits[i >>> 6] & (1L << (i & 63))) != 0;
//1L << (i & 63)就是把对应比特置 1;按位与判断比特是否为 1
}
}
public static void main(String[] args) throws IOException {//主函数测试部分
BloomFilter bf = new BloomFilter(100000);
int n = Integer.parseInt(br.readLine());
for (int i = 0; i < n; i++) {
st = new StringTokenizer(br.readLine());
String op = st.nextToken();
String s = st.nextToken();
if (op.equals("add")) {
bf.add(s);
} else if (op.equals("query")) {
out.println(bf.contains(s));
}
}
out.flush();
out.close();
br.close();
}
}
对于位图的理解,可以看我上一篇文章:
对于上面这个简易版的布隆过滤器:
它的时间复杂度为:
| 方法 | 平均时间复杂度 | 最坏时间复杂度 | 核心说明 |
|---|---|---|---|
| hash1 / hash2 | O(L) | O(L) | L 为字符串长度,逐字符遍历计算哈希 |
| add 插入 | O(L) | O(L) | 调用两次哈希,两次常数级位运算 |
| contains 查询 | O(L) | O(L) | 调用两次哈希,两次常数级位运算判断 |
| setBit / getBit | O(1) | O(1) | 纯位运算,无循环,常数开销 |
它的空间复杂度为:
| 运行场景 | 空间复杂度 | 渐进复杂度 | 核心说明 |
|---|---|---|---|
| 位图存储 | O(m) | O(m) | m 为初始化传入总比特位,底层 long 数组占用 m bit 内存 |
| 哈希常量 / 局部变量 | O(1) | O(1) | 固定常量、循环临时变量,无额外增长空间 |
在工业上,布隆过滤器还有以下几点优化:
| 对比维度 | 以上手写双哈希布隆过滤器 | 工业级生产布隆过滤器 |
|---|---|---|
| 哈希函数 | 固定 2 组多项式哈希,分布差,误判高 | Murmur/XXHash 多哈希 (8~16 个),分布均匀,可控低误判 |
| 容量管理 | 固定 bitSize,不支持扩容,需人工预估 | 分层动态扩容;输入 n、误判率自动计算最优容量 m、k |
| 删除能力 | 原生不支持删除,删数据会污染标记 | 计数布隆过滤器,计数器实现安全删除 |
| 部署范围 | 单机内存,重启丢失数据 | 支持持久化 mmap、Redis 分布式分片,跨机器共享 |
| 性能优化 | 无缓存、无批量操作 | 哈希缓存、批量读写、CPU 内存对齐优化 |
| 业务容错 | 无监控、无兜底逻辑 | 误判统计、数据库二次兜底、参数告警防御超长输入 |
| 适用量级 | 十万级小规模测试 Demo | 十亿 / 百亿级 URL、缓存穿透、分布式黑名单系统 |
布隆过滤器的缺点
其实布隆过滤器也并不是万能的,它也有缺点:
1.布隆过滤器也会有误判的可能:不同元素经过哈希映射后会共用比特位的可能,如果一个未插入元素对应的全部 k 个 bit 位,恰好被其他数据全部置 1,就会错误判定元素存在。
2.从以上的简写代码不难看出,原生的布隆过滤器是不支持删除的,因为底层仅 0/1 比特标记,一个 bit 会被数十上百个元素共享。如果直接把目标元素对应的 bit 置 0,所有共用该 bit 的元素都会丢失标记,导致大量已存在数据被误判为不存在,这个在工业上有专业的计数布隆过滤器 ,其实就是将 1bit 升级为 4bit 计数器,插入计数器 + 1、删除计数器 - 1,那样的话内存占用提升 4~8 倍 。
3.基于布隆过滤器的原理,它也存在位图的一些缺点 :
①仅能判断存在性,无法取出原始数据,从而无法实现无法实现遍历、精准匹配、数据导出等功能。
②存在位图中同样有的容量需提前预估,且扩容比较困难等问题,如果预估容量过小:数据填满后冲突爆炸,误判率急剧上升,如果预估容量过大:闲置大量 bit 位,白白消耗内存。在工业上则采用分层布隆过滤器,就是容量满了新建独立子过滤器,查询遍历所有分层,无需全量重哈希了。
一致性哈希(了解)
在实际的应用中,试想一下:我原有 3 台机器使用普通取模哈希做分片计算,分片规则为 hash(key) % 机器数量。此时所有 key 的哈希值会均匀落在 0、1、2 三个下标,分别对应 3 台机器。现在机器扩容增加到四台,分母从 3 变成 4,分片下标取值变为 0、1、2、3。
我们简单举例验证:任意一个 key 的哈希值记为 H
扩容前分配机器:H mod 3
扩容后分配机器:H mod 4
添加机器后只有同时满足
H mod 3=H mod 4的 key,才会留在原来的机器,满足该等式的数字占比极低,绝大多数 key 的分片下标都会发生改变。数学上可以算出,扩容后会有约 75% 的 key 需要迁移,大量缓存瞬间失效,海量请求直接穿透到底层数据库,极易触发缓存雪崩,拖垮整个存储服务。
那该如何解决这种大规模数据迁移的痛点?这时就需要引入一致性哈希 。
一致性哈希 不再用固定机器数做取模运算,而是将全部哈希值构成一个首尾相连的环形哈希空间 ,机器节点与数据 key 全部映射到环上。数据会交给沿哈希环顺时针遇到的第一个节点存储。当集群从 3 台扩容至 4 台时,新增节点只会分担它上一台相邻节点区间内的数据,仅约 25% 的数据发生迁移,其余 75% 的 key 映射关系完全不变,大幅降低缓存失效范围,规避数据库崩溃风险。同时基础一致性哈希还存在数据倾斜缺陷,通过引入虚拟节点 ,给每台物理机分配数百个虚拟节点铺满哈希环,就能均衡每台机器的数据存储量,实现集群负载均衡。
对于一致性哈希,总结来说就是:
| 知识点模块 | 核心内容 | 解决的问题 |
|---|---|---|
| 传统取模哈希缺陷 | 增减机器时,取模分母改变,几乎所有key映射节点变化,全量数据迁移 | 集群扩容/故障下线引发大规模缓存失效、DB击穿 |
| 哈希key选择要点 | 1. 避免前缀/后缀重复(如id_1、id_2)防止哈希聚集; 2. 选用均匀哈希函数(MurmurHash/XXHash); 3. 不使用自增数字单独作为key | 哈希值扎堆分布不均,单节点数据过载 |
| 哈希环基础结构 | 1. 哈希值域0~2³²-1首尾相连成闭环圆环; 2. 所有物理机器、数据key用同一哈希函数映射到环上点位; 3. key顺时针找第一个遇到的节点作为存储节点 | 实现扩缩容只迁移局部数据,不用全量重分布 |
| 虚拟节点优化技术 | 给每台物理机分配上百个虚拟节点,均匀散落在哈希环;查询时先匹配虚拟节点,再映射到真实机器 | 规避物理节点少导致的数据倾斜,实现集群负载均衡 |
它的优缺点如下:
| 缺陷分类 | 具体表现 | 业务影响 | 缓解方案 |
|---|---|---|---|
| 无虚拟节点时数据倾斜 | 物理节点少,环上节点分布稀疏,各机器存储量差距巨大 | 部分节点内存打满,部分节点闲置 | 每台物理机分配100~1000个虚拟节点均匀铺满哈希环 |
| 实现逻辑更复杂 | 需要维护有序哈希环、二分查找、虚拟节点管理 | 开发成本高于简单取模哈希 | 直接使用成熟开源组件(Guava、Redis Cluster内置实现) |
| 下线节点单节点压力突增 | 故障节点数据全部转移到相邻一台机器 | 相邻节点瞬间流量翻倍,有宕机连锁风险 | 增加副本机制,每条数据存多台备用节点;扩容分散压力 |
总结一下
这篇总结文章是基于听了左神的网课而写的,其实在日常中我更多地是直接调库使用的,但是对于底层的理解还是要延申到日常代码的学习和实际生活中,所以希望在此模式下我能够更上一层楼,也希望大佬们多提意见,更加有助于我后面的学习!