PHP實現Bitmap的探索 - GMP擴充套件使用

orlion發表於2020-10-23

原文地址:https://blog.fanscore.cn/p/22/

一、背景

公司當前有一個使用者群的系統,核心功能是根據不同的條件組去不同的業務線中get符合條件的uid列表,然後存到redis中的bitmap中。

舉個?,如果一個使用者群中有兩個使用者: 3和7,即[3,7],用bitmap表示那就是:00010001

最後利用redis提供的bitOp命令: bitOp AND \ bitOp XOR \ bitOp OR對各個條件組對應的uid列表bitmap做交併差集計算,得出最終的使用者群並儲存到redis bitmap中。

二、問題

對於上面描述的系統,如果使用者群人數較多的那我們就需要執行較多次的setBit {uid} 1命令,而且如果使用者群中的第一個uid是一個特別大的值比如10億的話,就可能會一次malloc 1000000000/1024/1024/8 ~= 120M的記憶體,這可能會導致redis卡住一段時間,在高併發的redis例項上執行這個操作是相當危險的。而且可以預想到對於兩個較大的bitmap key執行bitOp也是非常消耗CPU的,應該儘量避免在儲存型的redis例項中做這種十分消耗CPU的計算操作。

三、解決方案

針對上述的問題,可以將bitmap的計算挪到應用程式中來,只將最終統計出來的bitmap儲存到redis中即可。
  如果最終結果使用者群中的第一個uid是一個特別大的值的話,可以先set 1K再設定2K..3K...這樣快取的增加bitmap的大小避免redis卡住。

四、PHP實現Bitmap

由於該系統目前是使用的PHP,所以下面記錄下PHP實現Bitmap的”心路歷程“。

由於要操作PHP變數的某一位,所以就要藉助位運算來實現,但是又由於PHP的位運算只能作用在整型數上,所以我們無法使用字串或者浮點數來實現,所以最先考慮的就是使用整型陣列來實現。

為什麼是陣列呢?因為在64位機器上一個整型變數最多隻能使用64位,又由於PHP的整型是有符號的,所以最高位無法供我們使用,所以一個整型變數能儲存的最大的uid就是63,這真是太雞肋了-_-||,所以只能搞個用多個整型變數了實現了。

OK,到此為止貌似找到一個看起來不錯的解決方案。但是我們再思考這樣一個問題:假設我們系統中最大的uid是63x100萬=3.6千萬(對主流網際網路公司來說這很正常吧?),那為了儲存所有uid,我們需要1百萬個整數才行,即我們需要一個擁有1百萬個元素的陣列,那麼如果我在程式中製造了一個這樣的陣列會佔用多少記憶體呢?會是64 * 1百萬 / 1024 / 1024 / 8 ~= 7.6M嗎?答案是否定的,因為php陣列是由HashTable實現的,這是一個複雜的結構體,除了陣列元素佔用的記憶體外,還有其他的佔用。(這裡先不做展開,有興趣可以自行檢視下php陣列的實現)
眼見為實:

<?php
ini_set('memory_limit','4G');
$arr = [];
for ($i = 0; $i < 64 * 1000000; $i++)
{
    $arr[] = PHP_INT_MAX;
}

echo "done\n";
while(1){
}

檢視記憶體佔用

image.png

可以看到大概是1.5G,比我們上面預計的大的多,這太可怕了,必須優化下我們的記憶體佔用,才能真正在生產環境中使用。

這裡需要提一句,我的機器只有8G,所以程式可能會用到swap分割槽,而ps命令結果中的RSS不統計swap分割槽的佔用,在我實際實現中發現ps結果中RSS一列顯示佔用的記憶體會隨著時間慢慢減少,但是我的程式中arr變數佔用的記憶體是不可能被回收的,所以推測是實體記憶體中佔用的部分記憶體被置換到了swap分割槽中。如果你要進行這個實驗的話建議關閉swap分割槽,這樣你能得到一個更準確的結果。

五、繼續優化

基於上面的經驗,如果我們要佔用盡可能小的記憶體,那我們必須能夠操作一段近乎無限長的記憶體且不能產生其他額外佔用才可以。幸運的是PHP給我們提供了這樣一個擴充套件:GMP,這個擴充套件可以讓我們使用一個任意長度的整數。OK現在我們擁有了獲得一塊連續的記憶體而不會產生其他額外佔用的手段,再寫一段程式碼使用下並驗證下記憶體佔用情況:

<?php

$gmp = gmp_init(0);
gmp_setbit($gmp, 64 * 1000000, true);
echo "done\n";
while(1){}

image.png

Awesome,這次只使用了15M的記憶體。更加興奮的是這個擴充套件提供了諸如:gmp_andgmp_orgmp_xor這樣進行位運算的函式,極大的方便了我們的使用。

到此為止我們似乎找到了一個完美的解決方案,但是真的完美嗎?No!其實還可以再優化一下,想象下如果我們有一個使用者群,裡面只有一個uid:64000000(表示為陣列的話就是:[64000000]),為了儲存這個使用者我們需要佔用7.6M記憶體,而這個使用者群中僅僅只有一個元素,這真是極大的浪費啊!

為了優化這個問題可以擁抱上面被我們唾棄的陣列?,一個大的bitmap拆分為一個個小bitmap的陣列,這一個個小的bitmap我們限制大小為1Kw位。
image.png

回到上面的問題,如果我們要儲存[64000000]這個使用者群的話只需要在陣列的第6個元素中設定一個little bitmap: 1即可。這樣我們就由一開始的佔用7.6M記憶體優化為了佔用1位記憶體。

OK,到此為止我們找到一個還不錯的解決方案?。

後言

為了在Mac中安裝GMP擴充套件又耗費了很多時間,當然,這又是另外一個故事了。有時間我會分享Mac中安裝GMP擴充套件的過程中我遇到的問題。

參考資料

  1. GNU Multiple Precision
  2. Process Memory Management in Linux
  3. 從原始碼看 PHP 7 陣列的實現

相關文章