前言
這是一篇拖了很久的總結,項目中引入了redis的bitmap的用法,感覺挺高大上的,刨根問底,故留下總結一篇當作紀念。
說清楚幾個問題:
1.bitmap的原理、用法。
2.bitmap的優勢、限制。
3.bitmap空間、時間粗略計算方式。
4.bitmap的使用場景。
5.使用bitmap過程中可能會遇到的坑。
6.bitmap進階用法(思考)。
bitmap的原理、用法
原理
8bit = 1b = 0.001kb
bitmap就是通過最小的單位bit來進行0或者1的設置,表示某個元素對應的值或者狀態。
一個bit的值,或者是0,或者是1;也就是說一個bit能存儲的最多信息是2。
用法
setBit
說明:給一個指定key的值得第offset位 賦值爲value。
參數:key offset value: bool or int (1 or 0)
返回值:LONG: 0 or 1
getBit
說明:返回一個指定key的二進制信息
參數:key offset
返回值:LONG
bitCount
說明:返回一個指定key中位的值爲1的個數(是以byte爲單位不是bit)
參數:key start offset
返回值:LONG
bitOp
說明:對不同的二進制存儲數據進行位運算(AND、OR、NOT、XOR)
參數:operation destkey key [key …]
返回值:LONG
bitmap的優勢、限制
優勢
1.基於最小的單位bit進行存儲,所以非常省空間。
2.設置時候時間複雜度O(1)、讀取時候時間複雜度O(n),操作是非常快的。
3.二進制數據的存儲,進行相關計算的時候非常快。
4.方便擴容
限制
redis中bit映射被限制在512MB之內,所以最大是2^32位。建議每個key的位數都控制下,因爲讀取時候時間複雜度O(n),越大的串讀的時間花銷越多。
bitmap空間、時間粗略計算方式
在一臺2010MacBook Pro上,offset爲2^32-1(分配512MB)需要~300ms,offset爲2^30-1(分配128MB)需要~80ms,offset爲2^28-1(分配32MB)需要~30ms,offset爲2^26-1(分配8MB)需要8ms。<來自官方文檔>
大概的空間佔用計算公式是:($offset/8/1024/1024)MB
bitmap的使用場景
使用方式很多,根據不同的業務需求來,但是總的來說就兩種,以用戶爲例子:
1.一種是某一用戶的橫向擴展,即此個key值中記錄這當前用戶的各種狀態值,允許無限擴展(2^32內)
點評:這種用法基本上是很少用的,因爲每個key攜帶uid信息,如果存儲的key的空間大於value,從空間角度看有一定的優化空間,如果是記錄長尾的則可以考慮。
2.一種是某一用戶的縱向擴展,即每個key只記錄當前業務屬性的狀態,每個uid當作bit位來記錄信息(用戶超過2^32內需要分片存儲)
點評:基本上項目使用的場景都是基於這種方式的,按業務區分方便回收資源,key值就一個,將uid的存儲轉爲了位的存儲,十分巧妙的通過uid即可找到相應的值,主要存儲量在value上,符合預期。
1.視頻屬性的無限延伸
需求分析:
一個擁有億級數據量的短視頻app,視頻存在各種屬性(是否加鎖、是否特效等等),需要做各種標記。
可能想到的解決方案:
1.存儲在mysql中,肯定不行,一個是隨着業務增長屬性一直增加,並且存在有時間限制的屬性,直接對數據庫進行加減字段是非常不合理的做法。即使是存在一個字段中用json等壓縮技術存儲也存在讀效率的問題,並且對於大幾億的數據來說,廢棄的字段回收起來非常麻煩。
2.直接記錄在redis中,根據業務屬性+uid爲key來存儲。讀寫效率角度沒毛病,但是存儲的角度來說key的數據量都大於value了,太耗費空間了。即使是用json等壓縮技術來存儲。也存在問題,解壓需要時間,並且大幾億的數據回收也是難題。
設計方案:
使用redis的bitmap進行存儲。
key由屬性id+視頻分片id組成。value按照視頻id對分片範圍取模來決定偏移量offset。10億視頻一個屬性約120m還是挺划算的。
僞代碼:
function set($business_id , $media_id , $switch_status=1){
$switch_status = $switch_status ? 1 : 0;
$key = $this->_getKey($business_id, $media_id);
$offset = $this->_getOffset($media_id);
return $this->redis->setBit($key, $offse, $switch_status);
}
function get($business_id , $media_id){
$key = $this->_getKey($business_id,$media_id);
$offset = $this->_getOffset($media_id);
return $this->redis->getBit($key , $offset);
}
function _getKey($business_id, $media_id){
return 'm:'.$business_id.':'.intval($media_id/10000);
}
function _getOffset($media_id){
return $media_id % 10000;
}
這樣基本實現了屬性的存儲,後續增加新屬性也只是business_id再增加一個值。
至於爲什麼分片呢?分片的粒度怎麼衡量?
分片有兩個原因:1.讀取的時候時間複雜度是O(n)存儲越長讀取時間越多 2.bitmap有長度限制2^32。
分片粒度怎麼衡量:1.如果主鍵id存在的斷層那麼請儘可能選擇的粒度可以避開此段id範圍,防止空間浪費,因爲來一個00000..9999個0..01,那麼因爲存一個屬性而存了全部的,就浪費了。2.分片粒度可參考某一單位時間的增長值來判斷,這樣也有利於預算佔了多少空間,雖然空間不會佔很多。
2.用戶在線狀態
需求分析:
需要對子項目提供一個接口,來提供某用戶是否在線?
設計方案:
使用bitmap是一個節約空間效率又高的一種方法,只需要一個key,然後用戶id爲偏移量offset,如果在線就設置爲1,不在線就設置爲0,3億用戶只需要36MB的空間。
僞代碼:
$status = 1;
$redis->setBit('online', $uid, $status);
$redis->getBit('online', $uid);
需要加上如例子1一樣分片的方式。10億真的太多了。10w分一片。
3.統計活躍用戶
需求分析:
需要計算活躍用戶的數據情況。
設計方案:
使用時間作爲緩存的key,然後用戶id爲offset,如果當日活躍過就設置爲1。之後通過bitOp進行二進制計算算出在某段時間內用戶的活躍情況。
僞代碼:
$status = 1;
$redis->setBit('active_20170708', $uid, $status);
$redis->setBit('active_20170709', $uid, $status);
$redis->bitOp('AND', 'active', 'active_20170708', 'active_20170709');
上億用戶需要加上如例子1一樣分片的方式。幾十萬或者以下,可無需分片省的業務變複雜。
4.用戶簽到
需求分析:
用戶需要進行簽到,對於簽到的數據需要進行分析與相應的運運營策略。
設計方案:
使用redis的bitmap,由於是長尾的記錄,所以key主要由uid組成,設定一個初始時間,往後沒加一天即對應value中的offset的位置。
僞代碼:
$start_date = '20170708';
$end_date = '20170709';
$offset = floor((strtotime($start_date) - strtotime($end_date)) / 86400);
$redis->setBit('sign_123456', $offset, 1);
//算活躍天數
$redis->bitCount('sign_123456', 0, -1)
無需分片,一年365天,3億用戶約佔300000000*365/8/1000/1000/1000=13.68g。存儲成本是不是很低。
使用bitmap過程中可能會遇到的坑
1.bitcout的陷阱
如果你有仔細看前文的用法,會發現有這麼一個備註“返回一個指定key中位的值爲1的個數(是以byte爲單位不是bit)”,這就是坑的所在。
有圖有真相:
所以bitcount 0 0 那麼就應該是第一個字節中1的數量的,注意是字節,第一個字節也就是1,2,3,4,5,6,7,8這八個位置上。
bitmap進階用法(思考)
以下內容來自此文的筆記:http://www.infoq.com/cn/articles/the-secret-of-bitmap/
1.空間
redis的bitmap已經是最小單位的存儲了,有沒有辦法對二進制存儲的信息再進行壓縮呢?進一步省空間?
答案是有的。
可以對記錄的二進制數據進行壓縮。常見的二進制壓縮技術都是基於RLE(Run Length Encoding,詳見http://en.wikipedia.org/wiki/Run-length_encoding)。
RLE編碼很簡單,比較適合有很多連續字符的數據,比如以下邊的Bitmap爲例:
可以編碼爲0,8,2,11,1,2,3,11
其意思是:第一位爲0,連續有8個,接下來是2個1,11個0,1個1,2個0,3個1,最後是11個0(當然此處只是對RLE的基本原理解釋,實際應用中的編碼並不完全是這樣的)。
可以預見,對於一個很大的Bitmap,如果裏邊的數據分佈很稀疏(說明有很多大片連續的0),採用RLE編碼後,佔用的空間會比原始的Bitmap小很多。
2.時間
redis雖然是在內存操作,但是超過redis指定存儲在內存的閥值之後,會被搞到磁盤中。要是進行大範圍的計算還需要從磁盤中取出到內存在計算比較耗時,效率也不高,有沒有辦法儘可能內存中多放一些數據,縮短時間?
答案是有的。
基於第一點同時引入一些對齊的技術,可以讓採用RLE編碼的Bitmap不需要進行解壓縮,就可以直接進行AND/OR/XOR等各類計算;因此採用這類壓縮技術的Bitmap,加載到內存後還是以壓縮的方式存在,從而可以保證計算時候的低內存消耗;而採用word(計算機的字長,64位系統就是64bit)對齊等技術又保證了對CPU資源的高效利用。因此採用這類壓縮技術的Bitmap,保持了Bitmap數據結構最重要的一個特性,就是高效的針對每個bit的邏輯運算。
常見的壓縮技術包括BBC(有專利保護,WAH(http://code.google.com/p/compressedbitset/)和EWAH(http://code.google.com/p/javaewah/)
擴展閱讀
Bitmap的祕密
Redis內存壓縮實戰
Redis中bitmap的妙用
Redis中BitMap是如何儲存的,以及PHP如何處理
使用redis的setbit和bitcount來進行區間統計的坑