1. 程式人生 > >memcached中hash表相關操作

memcached中hash表相關操作

top this eof get 完整 啟動 哈希 作用 需要

  以下轉自http://blog.csdn.net/luotuo44/article/details/42773231

memcached源碼中assoc.c文件裏面的代碼是構造一個哈希表。memcached快的一個原因是使用了哈希表。現在就來看一下memcached是怎麽使用哈希表的。

哈希結構:

main函數會調用assoc_init函數申請並初始化哈希表。為了減少哈希表發生沖突的可能性,memcached的哈希表是比較長的,並且哈希表的長度為2的冪。全局變量hashpower用來記錄2的冪次。main函數調用assoc_init函數時使用全局變量settings.hashpower_init作為參數,用於指明哈希表初始化時的冪次。settings.hashpower_init可以在啟動memcached的時候設置。

  1. //memcached.h文件
  2. #define HASHPOWER_DEFAULT 16
  3. //assoc.h文件
  4. unsigned int hashpower = HASHPOWER_DEFAULT;
  5. #define hashsize(n) ((ub4)1<<(n))//這裏是1 左移 n次
  6. //hashsize(n)為2的冪,所以hashmask的值的二進制形式就是後面全為1的數。這就很像位操作裏面的 &
  7. //value & hashmask(n)的結果肯定是比hashsize(n)小的一個數字.即結果在hash表裏面
  8. //hashmask(n)也可以稱為哈希掩碼
  9. #define hashmask(n) (hashsize(n)-1)
  10. //哈希表數組指針
  11. static item** primary_hashtable = 0;
  12. //默認參數值為0。本函數由main函數調用,參數的默認值為0
  13. void assoc_init(const int hashtable_init) {
  14. if (hashtable_init) {
  15. hashpower = hashtable_init;
  16. }
  17. //因為哈希表會慢慢增大,所以要使用動態內存分配。哈希表存儲的數據是一個
  18. //指針,這樣更省空間。
  19. //hashsize(hashpower)就是哈希表的長度了
  20. primary_hashtable = calloc(hashsize(hashpower), sizeof(void *));
  21. if (! primary_hashtable) {
  22. fprintf(stderr, "Failed to init hashtable.\n");
  23. exit(EXIT_FAILURE);//哈希表是memcached工作的基礎,如果失敗只能退出運行
  24. }
  25. }

  說到哈希表,那麽就對應有兩個問題:哈希算法,怎麽解決沖突。

對於哈希函數(算法),memcached直接使用開源的MurmurHash3和jenkins_hash兩個中的一個。默認是使用jenkins,可以在啟動memcached的時候設置設置為MurmurHash3。memcached是直接把客戶端輸入的鍵值作為哈希算法的輸入,得到一個32位的無符號整型輸出(用變量hv存儲)。因為哈希表的長度沒有2^32- 1這麽大,所以需要一個函數將hv映射在哈希表的範圍之內。memcached采用了最簡單的取模運算作為映射函數,即hv%hashsize(hashpower)。對於CPU而言,取模運算是一個比較耗時的操作。所以memcached利用哈希表的長度是2的冪的性質,采用位操作進行優化,即: hv & hashmask(hashpower)。因為對哈希表進行增刪查操作都需要定位,所以經常本文的代碼中經常會出現hv & hashmask(hashpower)。

memcached使用最常見的鏈地址法解決沖突問題。從前面的代碼可以看到,primary_hashtable是一個的二級指針變量,它指向的是一個一維指針數組,數組的每一個元素指向一條鏈表(鏈表上的item節點具有相同的哈希值)。數組的每一個元素,在memcached裏面也稱為桶(bucket),所以後文的表述中會使用桶。下圖是一個哈希表,其中第0號桶有2個item,第2、3、5號桶各有一個item。item就是用來存儲用戶數據的結構體。

技術分享圖片

基本操作:

插入item:

接著看一下怎麽在哈希表中插入一個item。它是直接根據哈希值找到哈希表中的位置(即找到對應的桶),然後使用頭插法插入到桶的沖突鏈中。item結構體有一個專門的h_next指針成員變量用於連接哈希沖突鏈。

  1. static unsigned int hash_items = 0;//hash表中item的個數
  2. /* Note: this isn‘t an assoc_update. The key must not already exist to call this */
  3. //hv是這個item鍵值的哈希值
  4. int assoc_insert(item *it, const uint32_t hv) {
  5. unsigned int oldbucket;
  6. //使用頭插法 插入一個item
  7. //第一次看本函數,直接看else部分
  8. if (expanding &&
  9. (oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket)
  10. {
  11. ...
  12. } else {
  13. //使用頭插法插入哈希表中
  14. it->h_next = primary_hashtable[hv & hashmask(hashpower)];
  15. primary_hashtable[hv & hashmask(hashpower)] = it;
  16. }
  17. hash_items++;//哈希表的item數量加一
  18. return 1;
  19. }

查找item:

往哈希表插入item後,就可以開始查找item了。下面看一下怎麽在哈希表中查找一個item。item的鍵值hv只能定位到哈希表中的桶位置,但一個桶的沖突鏈上可能有多個item,所以除了查找的時候除了需要hv外還需要item的鍵值。

  1. //由於哈希值只能確定是在哈希表中的哪個桶(bucket),但一個桶裏面是有一條沖突鏈的
  2. //此時需要用到具體的鍵值遍歷並一一比較沖突鏈上的所有節點。雖然key是以‘\0‘結尾
  3. //的字符串,但調用strlen還是有點耗時(需要遍歷鍵值字符串)。所以需要另外一個參數
  4. //nkey指明這個key的長度
  5. item *assoc_find(const char *key, const size_t nkey, const uint32_t hv) {
  6. item *it;
  7. unsigned int oldbucket;
  8. //直接看else部分
  9. if (expanding &&
  10. (oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket)
  11. {
  12. it = old_hashtable[oldbucket];
  13. } else {
  14. //由哈希值判斷這個key是屬於那個桶(bucket)的
  15. it = primary_hashtable[hv & hashmask(hashpower)];
  16. }
  17. //到這裏,已經確定這個key是屬於那個桶的。 遍歷對應桶的沖突鏈即可
  18. item *ret = NULL;
  19. while (it) {
  20. //長度相同的情況下才調用memcmp比較,更高效
  21. if ((nkey == it->nkey) && (memcmp(key, ITEM_key(it), nkey) == 0)) {
  22. ret = it;
  23. break;
  24. }
  25. it = it->h_next;
  26. }
  27. return ret;
  28. }

刪除item:

下面看一下從哈希表中刪除一個item是怎麽實現的。從鏈表中刪除一個節點的常規做法是:先找到這個節點的前驅節點,然後使用前驅節點的next指針進行刪除和拼接操作。memcached的做法差不多,實現如下:

  1. void assoc_delete(const char *key, const size_t nkey, const uint32_t hv) {
  2. item **before = _hashitem_before(key, nkey, hv);//得到前驅節點的h_next成員地址
  3. if (*before) {//查找成功
  4. item *nxt;
  5. hash_items--;
  6. //因為before是一個二級指針,其值為所查找item的前驅item的h_next成員地址.
  7. //所以*before指向的是所查找的item.因為before是一個二級指針,所以
  8. //*before作為左值時,可以給h_next成員變量賦值。所以下面三行代碼是
  9. //使得刪除中間的item後,前後的item還能連得起來。
  10. nxt = (*before)->h_next;
  11. (*before)->h_next = 0; /* probably pointless, but whatever. */
  12. *before = nxt;
  13. return;
  14. }
  15. /* Note: we never actually get here. the callers don‘t delete things
  16. they can‘t find. */
  17. assert(*before != 0);
  18. }
  19. //查找item。返回前驅節點的h_next成員地址,如果查找失敗那麽就返回沖突鏈中最後
  20. //一個節點的h_next成員地址。因為最後一個節點的h_next的值為NULL。通過對返回值
  21. //使用 * 運算符即可知道有沒有查找成功。
  22. static item** _hashitem_before (const char *key, const size_t nkey, const uint32_t hv) {
  23. item **pos;
  24. unsigned int oldbucket;
  25. //同樣,看的時候直接跳到else部分
  26. if (expanding &&//正在擴展哈希表
  27. (oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket)
  28. {
  29. pos = &old_hashtable[oldbucket];
  30. } else {
  31. //找到哈希表中對應的桶位置
  32. pos = &primary_hashtable[hv & hashmask(hashpower)];
  33. }
  34. //遍歷桶的沖突鏈查找item
  35. while (*pos && ((nkey != (*pos)->nkey) || memcmp(key, ITEM_key(*pos), nkey))) {
  36. pos = &(*pos)->h_next;
  37. }
  38. //*pos就可以知道有沒有查找成功。如果*pos等於NULL那麽查找失敗,否則查找成功。
  39. return pos;
  40. }

擴展哈希表:

當哈希表中item的數量達到了哈希表表長的1.5倍時,那麽就會擴展哈希表增大哈希表的表長。memcached在插入一個item時會檢查當前的item總數是否達到了哈希表表長的1.5倍。由於item的哈希值是比較均勻的,所以平均來說每個桶的沖突鏈長度大概就是1.5個節點。所以memcached的哈希查找還是很快的。

遷移線程:

擴展哈希表有一個很大的問題:擴展後哈希表的長度變了,item哈希後的位置也是會跟著變化的(回憶一下memcached是怎麽根據鍵值的哈希值確定桶的位置的)。所以如果要擴展哈希表,那麽就需要對哈希表中所有的item都要重新計算哈希值得到新的哈希位置(桶位置),然後把item遷移到新的桶上。對所有的item都要做這樣的處理,所以這必然是一個耗時的操作。後文會把這個操作稱為數據遷移。

因為數據遷移是一個耗時的操作,所以這個工作由一個專門的線程(姑且把這個線程叫做遷移線程吧)負責完成。這個遷移線程是由main函數調用一個函數創建的。看下面代碼:

  1. #define DEFAULT_HASH_BULK_MOVE 1
  2. int hash_bulk_move = DEFAULT_HASH_BULK_MOVE;
  3. //main函數會調用本函數,啟動數據遷移線程
  4. int start_assoc_maintenance_thread() {
  5. int ret;
  6. char *env = getenv("MEMCACHED_HASH_BULK_MOVE");
  7. if (env != NULL) {
  8. //hash_bulk_move的作用在後面會說到。這裏是通過環境變量給hash_bulk_move賦值
  9. hash_bulk_move = atoi(env);
  10. if (hash_bulk_move == 0) {
  11. hash_bulk_move = DEFAULT_HASH_BULK_MOVE;
  12. }
  13. }
  14. if ((ret = pthread_create(&maintenance_tid, NULL,
  15. assoc_maintenance_thread, NULL)) != 0) {
  16. fprintf(stderr, "Can‘t create thread: %s\n", strerror(ret));
  17. return -1;
  18. }
  19. return 0;
  20. }

遷移線程被創建後會進入休眠狀態(通過等待條件變量),當worker線程插入item後,發現需要擴展哈希表就會調用assoc_start_expand函數喚醒這個遷移線程。

  1. static bool started_expanding = false;
  2. //assoc_insert函數會調用本函數,當item數量到了哈希表表長的1.5倍才會調用的
  3. static void assoc_start_expand(void) {
  4. if (started_expanding)
  5. return;
  6. started_expanding = true;
  7. pthread_cond_signal(&maintenance_cond);
  8. }
  9. static bool expanding = false;//標明hash表是否處於擴展狀態
  10. static volatile int do_run_maintenance_thread = 1;
  11. static void *assoc_maintenance_thread(void *arg) {
  12. //do_run_maintenance_thread是全局變量,初始值為1,在stop_assoc_maintenance_thread
  13. //函數中會被賦值0,終止遷移線程
  14. while (do_run_maintenance_thread) {
  15. int ii = 0;
  16. //上鎖
  17. item_lock_global();
  18. mutex_lock(&cache_lock);
  19. ...//進行item遷移
  20. //遍歷完就釋放鎖
  21. mutex_unlock(&cache_lock);
  22. item_unlock_global();
  23. if (!expanding) {//不需要遷移數據(了)。
  24. /* We are done expanding.. just wait for next invocation */
  25. mutex_lock(&cache_lock);
  26. started_expanding = false; //重置
  27. //掛起遷移線程,直到worker線程插入數據後發現item數量已經到了1.5倍哈希表大小,
  28. //此時調用worker線程調用assoc_start_expand函數,該函數會調用pthread_cond_signal
  29. //喚醒遷移線程
  30. pthread_cond_wait(&maintenance_cond, &cache_lock);
  31. mutex_unlock(&cache_lock);
  32. ...
  33. mutex_lock(&cache_lock);
  34. assoc_expand();//申請更大的哈希表,並將expanding設置為true
  35. mutex_unlock(&cache_lock);
  36. }
  37. }
  38. return NULL;
  39. }

逐步遷移數據:

為了避免在遷移的時候worker線程增刪哈希表,所以要在數據遷移的時候加鎖,worker線程搶到了鎖才能增刪查找哈希表。memcached為了實現快速響應(即worker線程能夠快速完成增刪查找操作),就不能讓遷移線程占鎖太久。但數據遷移本身就是一個耗時的操作,這是一個矛盾。

memcached為了解決這個矛盾,就采用了逐步遷移的方法。其做法是,在一個循環裏面:加鎖-》只進行小部分數據的遷移-》解鎖。這樣做的效果是:雖然遷移線程會多次搶占鎖,但每次占有鎖的時間都是很短的,這就增加了worker線程搶到鎖的概率,使得worker線程能夠快速完成它的操作。一小部分是多少個item呢?前面說到的全局變量hash_bulk_move就指明是多少個桶的item,默認值是1個桶,後面為了方便敘述也就認為hash_bulk_move的值為1。

逐步遷移的具體做法是,調用assoc_expand函數申請一個新的更大的哈希表,每次只遷移舊哈希表一個桶的item到新哈希表,遷移完一桶就釋放鎖。此時就要求有一個舊哈希表和新哈希表。在memcached實現裏面,用primary_hashtable表示新表(也有一些博文稱之為主表),old_hashtable表示舊表(副表)。

前面說到,遷移線程被創建後就會休眠直到被worker線程喚醒。當遷移線程醒來後,就會調用assoc_expand函數擴大哈希表的表長。assoc_expand函數如下:

  1. static void assoc_expand(void) {
  2. old_hashtable = primary_hashtable;
  3. //申請一個新哈希表,並用old_hashtable指向舊哈希表
  4. primary_hashtable = calloc(hashsize(hashpower + 1), sizeof(void *));
  5. if (primary_hashtable) {
  6. hashpower++;
  7. expanding = true;//標明已經進入擴展狀態
  8. expand_bucket = 0;//從0號桶開始數據遷移
  9. } else {
  10. primary_hashtable = old_hashtable;
  11. /* Bad news, but we can keep running. */
  12. }
  13. }

現在看一下完整一點的assoc_maintenance_thread線程函數,體會遷移線程是怎麽逐步數據遷移的。為什麽說完整一點呢?因為該函數裏面還是有一些東西本篇博文是沒有解釋的,但這並不妨礙我們閱讀該函數。後面還會有其他博文對這個線程函數進行講解的。

  1. static unsigned int expand_bucket = 0;//指向待遷移的桶
  2. #define DEFAULT_HASH_BULK_MOVE 1
  3. int hash_bulk_move = DEFAULT_HASH_BULK_MOVE;
  4. static volatile int do_run_maintenance_thread = 1;
  5. static void *assoc_maintenance_thread(void *arg) {
  6. //do_run_maintenance_thread是全局變量,初始值為1,在stop_assoc_maintenance_thread
  7. //函數中會被賦值0,終止遷移線程
  8. while (do_run_maintenance_thread) {
  9. int ii = 0;
  10. //上鎖
  11. item_lock_global();
  12. mutex_lock(&cache_lock);
  13. //hash_bulk_move用來控制每次遷移,移動多少個桶的item。默認是一個.
  14. //如果expanding為true才會進入循環體,所以遷移線程剛創建的時候,並不會進入循環體
  15. for (ii = 0; ii < hash_bulk_move && expanding; ++ii) {
  16. item *it, *next;
  17. int bucket;
  18. //在assoc_expand函數中expand_bucket會被賦值0
  19. //遍歷舊哈希表中由expand_bucket指明的桶,將該桶的所有item
  20. //遷移到新哈希表中。
  21. for (it = old_hashtable[expand_bucket]; NULL != it; it = next) {
  22. next = it->h_next;
  23. //重新計算新的哈希值,得到其在新哈希表的位置
  24. bucket = hash(ITEM_key(it), it->nkey) & hashmask(hashpower);
  25. //將這個item插入到新哈希表中
  26. it->h_next = primary_hashtable[bucket];
  27. primary_hashtable[bucket] = it;
  28. }
  29. //不需要清空舊桶。直接將沖突鏈的鏈頭賦值為NULL即可
  30. old_hashtable[expand_bucket] = NULL;
  31. //遷移完一個桶,接著把expand_bucket指向下一個待遷移的桶
  32. expand_bucket++;
  33. if (expand_bucket == hashsize(hashpower - 1)) {//全部數據遷移完畢
  34. expanding = false; //將擴展標誌設置為false
  35. free(old_hashtable);
  36. }
  37. }
  38. //遍歷完hash_bulk_move個桶的所有item後,就釋放鎖
  39. mutex_unlock(&cache_lock);
  40. item_unlock_global();
  41. if (!expanding) {//不再需要遷移數據了。
  42. /* finished expanding. tell all threads to use fine-grained(細粒度的) locks */
  43. //進入到這裏,說明已經不需要遷移數據(停止擴展了)。
  44. ...
  45. mutex_lock(&cache_lock);
  46. started_expanding = false; //重置
  47. //掛起遷移線程,直到worker線程插入數據後發現item數量已經到了1.5倍哈希表大小,
  48. //此時調用worker線程調用assoc_start_expand函數,該函數會調用pthread_cond_signal
  49. //喚醒遷移線程
  50. pthread_cond_wait(&maintenance_cond, &cache_lock);
  51. /* Before doing anything, tell threads to use a global lock */
  52. mutex_unlock(&cache_lock);
  53. ...
  54. mutex_lock(&cache_lock);
  55. assoc_expand();//申請更大的哈希表,並將expanding設置為true
  56. mutex_unlock(&cache_lock);
  57. }
  58. }
  59. return NULL;
  60. }

回馬槍:

現在再回過頭來再看一下哈希表的插入、刪除和查找操作,因為這些操作可能發生在哈希表遷移階段。有一點要註意,在assoc.c文件裏面的插入、刪除和查找操作,是看不到加鎖操作的。但前面已經說了,需要和遷移線程搶占鎖,搶到了鎖才能進行對應的操作。其實,這鎖是由插入、刪除和查找的調用者(主調函數)負責加的,所以在代碼裏面看不到。

因為插入的時候可能哈希表正在擴展,所以插入的時候要面臨一個選擇:插入到新表還是舊表?memcached的做法是:當item對應在舊表中的桶還沒被遷移到新表的話,就插入到舊表,否則插入到新表。下面是插入部分的代碼。

  1. /* Note: this isn‘t an assoc_update. The key must not already exist to call this */
  2. //hv是這個item鍵值的哈希值
  3. int assoc_insert(item *it, const uint32_t hv) {
  4. unsigned int oldbucket;
  5. //使用頭插法 插入一個item
  6. if (expanding &&//目前處於擴展hash表狀態
  7. (oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket)//數據遷移時還沒遷移到這個桶
  8. {
  9. //插入到舊表
  10. it->h_next = old_hashtable[oldbucket];
  11. old_hashtable[oldbucket] = it;
  12. } else {
  13. //插入到新表
  14. it->h_next = primary_hashtable[hv & hashmask(hashpower)];
  15. primary_hashtable[hv & hashmask(hashpower)] = it;
  16. }
  17. hash_items++;//哈希表的item數量加一
  18. //當hash表的item數量到達了hash表容量的1.5倍時,就會進行擴展
  19. //當然如果現在正處於擴展狀態,是不會再擴展的
  20. if (! expanding && hash_items > (hashsize(hashpower) * 3) / 2) {
  21. assoc_start_expand();//喚醒遷移線程,擴展哈希表
  22. }
  23. return 1;
  24. }

這裏有一個疑問,為什麽不直接插入到新表呢?直接插入到新表對於數據一致性來說完全是沒有問題的啊。網上有人說是為了保證同一個桶item的順序,但由於遷移線程和插入線程對於鎖搶占的不確定性,任何順序都不能通過assoc_insert函數來保證。本文認為是為了快速查找。如果是直接插入到新表,那麽在查找的時候就可能要同時查找新舊兩個表才能找到item。查找完一個表,發現沒有,然後再去查找另外一個表,這樣的查找被認為是不夠快速的。

如果按照assoc_insert函數那樣的實現,不用查找兩個表就能找到item。看下面的查找函數。

  1. //由於哈希值只能確定是在哈希表中的哪個桶(bucket),但一個桶裏面是有一條沖突鏈的
  2. //此時需要用到具體的鍵值遍歷並一一比較沖突鏈上的所有節點。因為key並不是以‘\0‘結尾
  3. //的字符串,所以需要另外一個參數nkey指明這個key的長度
  4. item *assoc_find(const char *key, const size_t nkey, const uint32_t hv) {
  5. item *it;
  6. unsigned int oldbucket;
  7. if (expanding &&//正在擴展哈希表
  8. (oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket)//該item還在舊表裏面
  9. {
  10. it = old_hashtable[oldbucket];
  11. } else {
  12. //由哈希值判斷這個key是屬於那個桶(bucket)的
  13. it = primary_hashtable[hv & hashmask(hashpower)];
  14. }
  15. //到這裏已經確定了要查找的item是屬於哪個表的了,並且也確定了桶位置。遍歷對應桶的沖突鏈即可
  16. item *ret = NULL;
  17. while (it) {
  18. //長度相同的情況下才調用memcmp比較,更高效
  19. if ((nkey == it->nkey) && (memcmp(key, ITEM_key(it), nkey) == 0)) {
  20. ret = it;
  21. break;
  22. }
  23. it = it->h_next;
  24. }
  25. return ret;
  26. }

刪除操作和查找操作差不多,這裏直接貼出,不多說了。刪除操作也是要進行查找操作的。

  1. void assoc_delete(const char *key, const size_t nkey, const uint32_t hv) {
  2. item **before = _hashitem_before(key, nkey, hv);//得到前驅節點的h_next成員地址
  3. if (*before) {//查找成功
  4. item *nxt;
  5. hash_items--;
  6. //因為before是一個二級指針,其值為所查找item的前驅item的h_next成員地址.
  7. //所以*before指向的是所查找的item.因為before是一個二級指針,所以
  8. //*before作為左值時,可以給h_next成員變量賦值。所以下面三行代碼是
  9. //使得刪除中間的item後,前後的item還能連得起來。
  10. nxt = (*before)->h_next;
  11. (*before)->h_next = 0; /* probably pointless, but whatever. */
  12. *before = nxt;
  13. return;
  14. }
  15. /* Note: we never actually get here. the callers don‘t delete things
  16. they can‘t find. */
  17. assert(*before != 0);
  18. }
  19. //查找item。返回前驅節點的h_next成員地址,如果查找失敗那麽就返回沖突鏈中最後
  20. //一個節點的h_next成員地址。因為最後一個節點的h_next的值為NULL。通過對返回值
  21. //使用 * 運算符即可知道有沒有查找成功。
  22. static item** _hashitem_before (const char *key, const size_t nkey, const uint32_t hv) {
  23. item **pos;
  24. unsigned int oldbucket;
  25. if (expanding &&//正在擴展哈希表
  26. (oldbucket = (hv & hashmask(hashpower - 1))) >= expand_bucket)
  27. {
  28. pos = &old_hashtable[oldbucket];
  29. } else {
  30. //找到哈希表中對應的桶位置
  31. pos = &primary_hashtable[hv & hashmask(hashpower)];
  32. }
  33. //到這裏已經確定了要查找的item是屬於哪個表的了,並且也確定了桶位置。遍歷對應桶的沖突鏈即可
  34. //遍歷桶的沖突鏈查找item
  35. while (*pos && ((nkey != (*pos)->nkey) || memcmp(key, ITEM_key(*pos), nkey))) {
  36. pos = &(*pos)->h_next;
  37. }
  38. //*pos就可以知道有沒有查找成功。如果*pos等於NULL那麽查找失敗,否則查找成功。
  39. return pos;
  40. }

由上面的討論可以知道,插入和刪除一個item都必須知道這個item對應的桶有沒有被遷移到新表上了。

memcached中hash表相關操作