哈希表的初步学习-底层理解-深入

文章目录

哈希表是什么

试想一下,在上世纪五十年代,计算机并没有高速的内存分配的时期,查找的方法就两种,一种是直接遍历寻找 ,在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索引,必须完整遍历整个哈希数组比对数值
print 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 【模板】字符串哈希

题目链接如下:

洛谷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内置实现)
下线节点单节点压力突增 故障节点数据全部转移到相邻一台机器 相邻节点瞬间流量翻倍,有宕机连锁风险 增加副本机制,每条数据存多台备用节点;扩容分散压力

总结一下

这篇总结文章是基于听了左神的网课而写的,其实在日常中我更多地是直接调库使用的,但是对于底层的理解还是要延申到日常代码的学习和实际生活中,所以希望在此模式下我能够更上一层楼,也希望大佬们多提意见,更加有助于我后面的学习!