1、什么是Hash
Hash也稱散列、哈希,對應的英文都是Hash。基本原理就是把任意長度的輸入,通過Hash算法變成固定長度的輸出。這個映射的規則就是對應的Hash算法,而原始數據映射后的二進制串就是哈希值。活動開發中經常使用的MD5和SHA都是歷史悠久的Hash算法。
echo md5("這是一個測試文案"); // 輸出結果:2124968af757ed51e71e6abeac04f98d
在這個例子里,這是一個測試文案
是原始值,2124968af757ed51e71e6abeac04f98d
就是經過hash算法得到的Hash值。整個Hash算法的過程就是把原始任意長度的值空間,映射成固定長度的值空間的過程。
2、Hash的特點
一個優秀的hash算法,需要什么樣的要求呢?
- a)、從hash值不可以反向推導出原始的數據
這個從上面MD5的例子里可以明確看到,經過映射后的數據和原始數據沒有對應關系 - b)、輸入數據的微小變化會得到完全不同的hash值,相同的數據會得到相同的值
echo md5("這是一個測試文案");
// 輸出結果:2124968af757ed51e71e6abeac04f98d
echo md5("這是二個測試文案");
// 輸出結果:bcc2a4bb4373076d494b2223aef9f702
可以看到我們只改了一個文字,但是整個得到的hash值產生了非常大的變化。 - c)、哈希算法的執行效率要高效,長的文本也能快速地計算出哈希值
- d)、hash算法的沖突概率要小
由於hash的原理是將輸入空間的值映射成hash空間內,而hash值的空間遠小於輸入的空間。根據抽屜原理,一定會存在不同的輸入被映射成相同輸出的情況。那么作為一個好的hash算法,就需要這種沖突的概率盡可能小
3、Hash碰撞的解決方案
前面提到了hash算法是一定會有沖突的,那么如果我們如果遇到了hash沖突需要解決的時候應該怎么處理呢?比較常用的算法是鏈地址法
和開放地址法
。
3.1 鏈地址法
鏈表地址法是使用一個鏈表數組,來存儲相應數據,當hash遇到沖突的時候依次添加到鏈表的后面進行處理。
鏈地址在處理的流程如下:
一個優秀的hash算法,需要什么樣的要求呢?
- a)、從hash值不可以反向推導出原始的數據
這個從上面MD5的例子里可以明確看到,經過映射后的數據和原始數據沒有對應關系 - b)、輸入數據的微小變化會得到完全不同的hash值,相同的數據會得到相同的值
echo md5("這是一個測試文案");
// 輸出結果:2124968af757ed51e71e6abeac04f98d
echo md5("這是二個測試文案");
// 輸出結果:bcc2a4bb4373076d494b2223aef9f702
可以看到我們只改了一個文字,但是整個得到的hash值產生了非常大的變化。 - c)、哈希算法的執行效率要高效,長的文本也能快速地計算出哈希值
- d)、hash算法的沖突概率要小
由於hash的原理是將輸入空間的值映射成hash空間內,而hash值的空間遠小於輸入的空間。根據抽屜原理,一定會存在不同的輸入被映射成相同輸出的情況。那么作為一個好的hash算法,就需要這種沖突的概率盡可能小。
桌上有十個蘋果,要把這十個蘋果放到九個抽屜里,無論怎樣放,我們會發現至少會有一個抽屜里面放不少於兩個蘋果。這一現象就是我們所說的“抽屜原理”。抽屜原理的一般含義為:“如果每個抽屜代表一個集合,每一個蘋果就可以代表一個元素,假如有n+1個元素放到n個集合中去,其中必定有一個集合里至少有兩個元素。” 抽屜原理有時也被稱為鴿巢原理。它是組合數學中一個重要的原理
3、Hash碰撞的解決方案
前面提到了hash算法是一定會有沖突的,那么如果我們如果遇到了hash沖突需要解決的時候應該怎么處理呢?比較常用的算法是鏈地址法
和開放地址法
。
3.1 鏈地址法
鏈表地址法是使用一個鏈表數組,來存儲相應數據,當hash遇到沖突的時候依次添加到鏈表的后面進行處理。

鏈地址在處理的流程如下:
添加一個元素的時候,首先計算元素key的hash值,確定插入數組中的位置。如果當前位置下沒有重復數據,則直接添加到當前位置。當遇到沖突的時候,添加到同一個hash值的元素后面,行成一個鏈表。這個鏈表的特點是同一個鏈表上的Hash值相同。java的數據結構HashMap使用的就是這種方法來處理沖突,JDK1.8中,針對鏈表上的數據超過8條的時候,使用了紅黑樹進行優化。由於篇幅原因,這里不深入討論相關數據結構,有興趣的同學可以參考這篇文章:
3.2 開放地址法
開放地址法是指大小為 M 的數組保存 N 個鍵值對,其中 M > N。我們需要依靠數組中的空位解決碰撞沖突。基於這種策略的所有方法被統稱為“開放地址”哈希表。線性探測法,就是比較常用的一種“開放地址”哈希表的一種實現方式。線性探測法的核心思想是當沖突發生時,順序查看表中下一單元,直到找出一個空單元或查遍全表。簡單來說就是:一旦發生沖突,就去尋找下 一個空的散列表地址,只要散列表足夠大,空的散列地址總能找到。
線性探測法的數學描述是:h(k, i) = (h(k, 0) + i) mod m,i表示當前進行的是第幾輪探查。i=1時,即是探查h(k, 0)的下一個;i=2,即是再下一個。這個方法是簡單地向下探查。mod m表示:到達了表的底下之后,回到頂端從頭開始。
對於開放尋址沖突解決方法,除了線性探測方法之外,還有另外兩種比較經典的探測方法,二次探測(Quadratic probing)和雙重散列(Double hashing)。但是不管采用哪種探測方法,當散列表中空閑位置不多的時候,散列沖突的概率就會大大提高。為了盡可能保證散列表的操作效率,一般情況下,我們會盡可能保證散列表中有一定比例的空閑槽位。我們用裝載因子
(load factor)來表示空位的多少。
散列表的裝載因子=填入表中的元素個數/散列表的長度。裝載因子越大,說明沖突越多,性能越差。
3.3 兩種方案的demo示例
假設散列長為8,散列函數H(K)=K mod 7,給定的關鍵字序列為{32,14,23,2, 20}
當使用鏈表法時,相應的數據結構如下圖所示:

當使用線性探測法時,相應的數據結果如下圖所示:
這里的兩種算法的區別是2這個元素,在鏈表法中還是在節點2的位置上,但是在線性探測法遇到沖突時會將沖突數據放到下一個空的位置下面。
4、hash算法在日常活動中的應用
在日常運營活動中,我們活動開發經常遇到的應用場景是信息加密、數據校驗、負載均衡。下面分別對這三種應用場景進行講解。
4.1 信息加密
首先我們看一下信息加密的應用。2011年CSDN脫庫事件,導致超過600W的用戶的密碼泄露,讓人失望的是,CSDN是明文存儲用戶的注冊郵箱和密碼的。作為用戶的非常隱私的信息,最簡單的保護措施就是對密碼進行hash加密。在客戶端對用戶輸入的密碼進行hash運算,然后在服務端的數據庫中保存用戶密碼的hash值。由於服務器端也沒有存儲密碼的明文,所以目前很多網站也就不再有找回密碼的功能了。
- 這里也友情提示一下大家:如果在使用中發現某網站還有提供找回密碼的功能,就要好好擔心下這個網站的安全性了。
看到這里有些同學會覺得那么我們是不是對用戶輸入的密碼進行一次MD5加密就可以了呢,這樣就算惡意用戶知道了hash值,也沒有辦法拿到用戶的真實密碼。假設用戶的密碼是123456789
,經過一次md5以后得到的值是:
25f9e794323b453885f5181f1b624d0b
那么是不是使用了這個加密后的字符串來存密碼就萬無一失了呢,理想總是很豐滿,而現實總是很骨感的。
那么一般針對這種問題,我們的解決之道就是引入salt(加鹽),即利用特殊字符(鹽)和用戶的輸入合在一起組成新的字符串進行加密。通過這樣的方式,增加了反向查詢的復雜度。但是這樣的方式也不是萬無一失,如果發生了鹽被泄露的問題,就需要所有用到的地方來重置密碼。
針對salt泄露的問題,其實還有一種解決辦法,即使用HMAC進行加密(Hash-based Message Authentication Code)。這種算法的核心思路是加密使用的key是從服務器端獲取的,每一個用戶的是不一樣的。如果發生了泄露,那么也就是這一個用戶的會被泄露,不會影響到全局
4.2 數據校驗
- git commit id
使用過git的同學都應該清楚,每次git提交后都有一個commit id,比如:
19d02d2cc358e59b3d04f82677dbf3808ae4fc40
就是一次git commit的結果,那么這個id是如何生成出來的呢?查閱了相關資料,使用如下代碼可以進行查看:
printf "commit %s\0" $(git cat-file commit HEAD | wc -c); git cat-file commit HEAD
git的commit id主要包括了以下幾部分內容:Tree 哈希,parent哈希、作者信息和本次提交的備注。

針對這些信息進行SHA-1 算法后得到值就是本次提交的commit id。簡單來講,就是對於單次提交的頭信息的一個校驗和。
4.3 負載均衡
活動開發同學在應對高星級業務大用戶量參與時,都會使用分庫分表,針對用戶的openid進行hashtime33取模,就可以得到對應的用戶分庫分表的節點了。
如上圖所示,這里其實是分了10張表,openid計算后的hash值取模10,得到對應的分表,在進行后續處理就好。對於一般的活動或者系統,我們一般設置10張表或者100張表就好。
下面我們來看一點復雜的問題,假設我們活動初始分表了10張,運營一段時間以后發現需要10張不夠,需要改到100張。這個時候我們如果直接擴容的話,那么所有的數據都需要重新計算Hash值,大量的數據都需要進行遷移。如果更新的是緩存的邏輯,則會導致大量緩存失效,發生雪崩效應
,導致數據庫異常。造成這種問題的原因是hash算法本身的緣故,只要是取模算法進行處理,則無法避免這種情況。針對這種問題,我們就需要利用一致性hash
進行相應的處理了
一致性hash
的基本原理是將輸入的值hash后,對結果的hash值進行2^32取模,這里和普通的hash取模算法不一樣的點是在一致性hash算法里將取模的結果映射到一個環上。將緩存服務器與被緩存對象都映射到hash環上以后,從被緩存對象的位置出發,沿順時針方向遇到的第一個服務器,就是當前對象將要緩存於的服務器,由於被緩存對象與服務器hash后的值是固定的,所以,在服務器不變的情況下,一個openid必定會被緩存到固定的服務器上,那么,當下次想要訪問這個用戶的數據時,只要再次使用相同的算法進行計算,即可算出這個用戶的數據被緩存在哪個服務器上,直接去對應的服務器查找對應的數據即可。這里的邏輯其實和直接取模的是一樣的。如下圖所示:

初始情況如下:用戶1的數據在服務器A里,用戶2、3的數據存在服務器C里,用戶4的數據存儲在服務器B里
下面我們來看一下當服務器數量發生變化的時候,相應影響的數據情況:
- 服務器縮容

服務器B發生了故障,進行剔除后,只有用戶4的數據發生了異常。這個時候我們需要繼續按照順時針的方案,把緩存的數據放在用戶A上面。
- 服務器擴容
同樣的,我們進行了服務器擴容以后,新增了一台服務器D,位置落在用戶2和3之間。按照順時針原則,用戶2依然訪問的是服務器C的數據,而用戶3順時針查詢后,發現最近的服務器是D,后續數據就會存儲到d上面。
- 虛擬節點
虛擬節點
的方案。
虛擬節點
是
實際節點
(實際的物理服務器)在hash環上的
復制品
,一個實際節點可以對應多個虛擬節點。虛擬節點越多,hash環上的節點就越多,數據被均勻分布的概率就越大。

如右圖所示,B、C、D 是原始節點復制出來的虛擬節點,原本都要訪問機器D的用戶1、4,分別被映射到了B,D。通過這樣的方式,起到了一個服務器均勻分布的作用。