目前個人其中一個項目是車務通系統,屬於物聯網的車輛管理實現的一部分,監控車輛的GPS數據,並以此與司機互動相關信息。
因爲在系統下管理的車輛比較多,將每次註冊登陸數據去數據庫查詢確定是不現實的。因此,放在本地共享內存中成了一個比較好的緩衝,之因此沒有考慮一些分佈式的cache層,主要考量是機器成本(移動給的機器很少)。本地共享內存有一個好處是,沒有多餘的IO操做,數據不依賴進程而存在。
車務通項目的車輛數據,在程序啓動後,斷定共享內存是否存在,若是不存在則從數據庫取得數據放在共享內存中。
當初最先的實現,是將全部車輛數據(主鍵是MSISDN),先從數據庫中讀取出來,而後進行排序。排序後存入共享內存中。
在查找車輛的時候,提供二分查找的方式命中。
可是這樣的設計,在車輛頻繁添加的時候,會很慢,爲何?每次都必須將新數據從新排序全部的數據,隨着車輛的增長,這種排序的時間劣勢也就顯現出來了。
後來我改造了這個算法,全部的車輛數據入共享內存不須要排序,數據加載的時候,生成一個map圖去映射相關的數據位置。這個map圖也已一種形式,去存入共享內存中,將共享內存區分頭和體部分。分別存儲,可是在數據刪除和添加的時候,須要維護兩個內存的地方,在高併發的環境下,容易出現映射不對的問題。
這個也不是最優方案,可是比以前的好了不少。起碼速度快了一些。可是我仍是很不滿意的。
說到這個問題,實際也說明了本身當初的一個弱點,過於依賴STL容器。
後來無心間,看到暴雪關於《暗黑2》的資源加載代碼。
突然產生了靈感,它的代碼使用了Hash算法,生成資源包對應資源映射。
因而拿下來研究了一下,本身實現了一個Hash算法的數組封裝。結果發現它的算法沒有考慮到刪除key的邏輯。
在我模擬的反覆刪除下,有BUG。
那麼怎麼辦,那就本身去實現一下吧。
當然,全部的Hash算法最誘人的,就是o(1)命中。
這裏簡單的描述一下Hash算法,Hash其實沒有什麼神祕的,就是根據指定的key,生成一個與之匹配的數字ID,做爲下標索引。從而當查詢key的時候,你能夠得到這個數據的下標,從而實現一次映射。
那麼實時真的如此嗎?
千萬別被網上的文章忽悠了,Hash算法裏面有一個陷阱。那就是Hash衝突。
什麼是Hash衝突呢?
就拿你們都知道的MD5算法來講,它也是一個標準的Hash算法,它生成的是一個16字節的HashID數字。這是一個多大的數字呢?一個int是4個字節,也就是2的32次方。那麼一個MD5的hashID就是2的256次方。
在這個數據範圍量級下,它能夠保證任意字符生成的數據ID是惟一的。
可是,實際使用中,咱們沒有這麼大的內存。
有時候,對於有些數據,舉個極端的例子,咱們可能只須要2個hash數組的池。
那麼,採用Hash算法,就會很容易產生碰撞了。也就是你算出來的數字,極可能會和別的字符算出來的數據HashID徹底一致。
一般,處理Hash數組衝突的方法,是在一個數據下標下,若是發現已存在對應數據,那麼就啓動一個鏈表,記錄下這個ID下全部衝突的對象。
當搜索的時候,發現這個HashID下有數據的話,就在下面的鏈表中去查找,直到找到匹配的key爲止。
那麼,這樣的處理方式,實際依賴的是你衝突key的多少,好比,在一個固定的Hash數組中,一個key有10個衝突。那麼,最不幸的狀況下,你要付出o(10)次查找,才能定位一個惟一的數據。
這自己就打破了Hash o(1)命中的誘人神話,除非你的數組足夠大。
在實際個人使用中,這裏存在兩個困難。
(1)我使用的是共享內存,我不可能在衝突的時候new一個內存節點去存儲鏈表。若是把鏈表信息獨立出來存儲,就相似了之前的map頭 + 數據體的模式,同樣很慢,在固定內存下有很大隱患。
(2)我如何控制鏈表的深度?若是過深,就失去了hash命中優點。
針對這兩方面,我開始作了一些測試和研究。
共享內存大小是固定的,我決定融合鏈表的優點,在發現hashID衝突的時候,自動將數據順序存放在數據中以當前節點爲開始,下一個空餘的位置,並在以前的節點,記錄衝突數據的下一個訪問位置。
因而我定義了這麼一個通用結構體
//hash表結構
struct _Hash_Table_Cell
{
char m_cExists; //當前塊是否已經使用,1已經使用,0沒有被使用
char* m_szKey; //當前的key值,沒有則爲空
int m_nKeySize; //當前key數據長度
int m_nNextKeyIndex; //鏈表信息,若是主鍵有衝突,記錄下一個衝突主鍵的位置
int m_nProvKeyIndex; //鏈表信息,若是主鍵有衝突,記錄上一個衝突主鍵的位置
unsigned long m_uHashA; //第二次的hashkey值
unsigned long m_uHashB; //第三次的hashkey值
char* m_szValue; //當前數據體指針
int m_nValueSize; //當前數據體長度
_Hash_Table_Cell()
{
Init();
}
void Init()
{
m_cExists = 0;
m_nKeySize = 0;
m_nValueSize = 0;
m_uHashA = 0;
m_uHashB = 0;
m_nNextKeyIndex = -1;
m_nProvKeyIndex = -1;
m_szKey = NULL;
m_szValue = NULL;
}
void Set_Key(char* pKey, int nKeySize)
{
m_szKey = pKey;
m_nKeySize = nKeySize;
}
void Set_Value(char* pValue, int nValueSize)
{
m_szValue = pValue;
m_nValueSize = nValueSize;
}
void Clear()
{
m_cExists = 0;
m_uHashA = 0;
m_uHashB = 0;
m_nNextKeyIndex = -1;
m_nProvKeyIndex = -1;
if(NULL != m_szKey)
{
memset(m_szKey, 0, m_nKeySize);
}
if(NULL != m_szValue)
{
memset(m_szValue, 0, m_nValueSize);
}
}
};
m_nNextKeyIndex和m_nProvKeyIndex就是記錄衝突數據的下一個數組下標和前一個數組下標(實現的雙向鏈表功能)。
第二個問題實際是一個散列的問題。
咱們須要將任意key值數據計算hashID計算儘可能正太分佈。
這裏,普通的算法是直接將key(是一個字符串),按照第一個字節到最後一個字節與或。可是在大多狀況下,這樣的映射每每是很難足夠分散的。由於你沒法控制key值的攝入。
因而我在設計的時候,增長了一個符合徹底正太分佈的Hash數值下標,而後,在取得key值導出成對應下標的時候,再次在這個已有的hash散列中再次映射。儘可能保證數據分佈的屬性。我姑且稱這個徹底的Hash散列爲"祕鑰"。
那麼看看,祕鑰是怎麼生成的呢?
//生成祕鑰
void CHashTable::prepareCryptTable()
{
unsigned long seed = 0x00100001, index1 = 0, index2 = 0, i;
for(index1 = 0; index1 < 0x100; index1++)
{
for(index2 = index1, i = 0; i < 5; i++, index2 += 0x100)
{
unsigned long temp1, temp2;
seed = (seed * 125 + 3) % 0x2AAAAB;
temp1 = (seed & 0xFFFF) << 0x10;
seed = (seed * 125 + 3) % 0x2AAAAB;
temp2 = (seed & 0xFFFF);
m_cryptTable[index2] = (temp1 | temp2);
}
}
}
這段代碼,是我從暴雪的源代碼裏面截取出來的。一個標準的正太分佈數組。
由於個人實際項目中,有部分是C的,有部分是C++的。
因而,我寫了兩個版本。
通過測試,100萬條手機號爲key的數據中,key衝突深爲4。實際查詢中,94%都是o(1)命中。最差的也是4次就能命中。
共享內存不須要數據頭體的區分,徹底以數據體就能夠了。
進程加載共享內存也沒必要在遍歷共享內存生成相關映射關係。
數據的插入也不須要在排序。直接入根據key生成去hash數組就好了。刪除也是同樣。
查詢命中效率幾乎達到了o(1)。
代碼也簡潔了。
效果很不錯。
最後
在這裏,我把源代碼放上來讓你們隨意的玩耍,能夠任意數組大小,能夠支持共享內存和內存兩種方式。
https://github.com/freeeyes/HashTablePool
我作了一次比較,100萬次隨機查詢 C的查詢速度是C++的35%左右。整體來講速度很快,代碼也獲得了很大簡化。
最後祝你們玩的開心,若是有問題能夠反饋給我。git