我們可以使用Redis的bitmap(位圖)來存儲數據。
1. 什麼叫做Redis的bitmap
即:操作String數據結構的key所存儲的字符串指定偏移量上的位,返回原位置的值
1.1 優點:
節省空間:通過一個bit位來表示某個元素對應的值或者狀態,其中key就是對應元素的值。實際上8個bit可以組成一個Byte,所以是及其節省空間的。
效率高:setbit和getbit的時間複雜度都是O(1),其他位運算效率也高。
1.2 缺點:
本質上位只有0和1的區別,所以用位做業務數據記錄,就不需要在意value的值。
2. Redis的bitmap命令
2.1 setbit命令
設置或修改key上的偏移量(offset)的位(value)的值。
語法:setbit key offset value
返回值:指定偏移量(offset)原來存儲的值。
注意:如果offset過大,則會在中間填充0
offset最大到2^32-1,即可推出最大的字符串爲512M
bitmap的setkey指令
2.2 getbit命令
查詢key所存儲的字符串值,獲取偏移量上的位。
語法:getbit key offset
返回值:返回指定key上的偏移量,若key不存在,那麼返回0。
bitmap的getbit指令
2.3 bitcount 命令
計算給定key的字符串值中,被設置爲1的位bit的數量
語法:bitcount key [start] [end]
返回值:1比特位的數量
注意:setbit是設置或者清除bit位置。這個是統計key出現1的次數。
需要注意的是:[start][end](單位)實際是byte,這是什麼意思呢?進入redis實際上是乘以8。
// 計算長度爲 count 的二進制數組指針 s 被設置爲 1 的位數量
// 這個函數只能在最大爲 512 MB 的字符串上使用
size_t redisPopcount(void *s, long count) {
size_t bits = 0;
unsigned char *p = s;
uint32_t *p4;
// 通過查表來計算,對於 1 字節所能表示的值來說
// 這些值的二進制表示所帶有的 1 的數量
// 比如整數 3 的二進制表示 0011 ,帶有兩個 1
// 正好是查表 bitsinbyte[3] == 2
static const unsigned char bitsinbyte[256] = {0,1,1,2,1,2,2,3,1,2,2,3,2,3,3,4,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,1,2,2,3,2,3,3,4,2,3,3,4,3,4,4,5,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,2,3,3,4,3,4,4,5,3,4,4,5,4,5,5,6,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,3,4,4,5,4,5,5,6,4,5,5,6,5,6,6,7,4,5,5,6,5,6,6,7,5,6,6,7,6,7,7,8};
/* Count initial bytes not aligned to 32 bit. */
while((unsigned long)p & 3 && count) {
bits += bitsinbyte[*p++];
count--;
}
/* Count bits 16 bytes at a time */
// 每次統計 16 字節
// 關於這裏所使用的優化算法,可以參考:
// http://yesteapea.wordpress.com/2013/03/03/counting-the-number-of-set-bits-in-an-integer/
p4 = (uint32_t*)p;
while(count>=16) {
uint32_t aux1, aux2, aux3, aux4;
aux1 = *p4++;
aux2 = *p4++;
aux3 = *p4++;
aux4 = *p4++;
count -= 16;
aux1 = aux1 - ((aux1 >> 1) & 0x55555555);
aux1 = (aux1 & 0x33333333) + ((aux1 >> 2) & 0x33333333);
aux2 = aux2 - ((aux2 >> 1) & 0x55555555);
aux2 = (aux2 & 0x33333333) + ((aux2 >> 2) & 0x33333333);
aux3 = aux3 - ((aux3 >> 1) & 0x55555555);
aux3 = (aux3 & 0x33333333) + ((aux3 >> 2) & 0x33333333);
aux4 = aux4 - ((aux4 >> 1) & 0x55555555);
aux4 = (aux4 & 0x33333333) + ((aux4 >> 2) & 0x33333333);
bits += ((((aux1 + (aux1 >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24) +
((((aux2 + (aux2 >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24) +
((((aux3 + (aux3 >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24) +
((((aux4 + (aux4 >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24);
}
/* Count the remaining bytes. */
// 不足 16 字節的,剩下的每個字節通過查表來完成
p = (unsigned char*)p4;
while(count--) bits += bitsinbyte[*p++];
return bits;
}
bitcount指令的使用
2.4 bitop命令
對一個或多個保存二進制的字符串key進行元操作,並將結果保存到destkey上。
語法:operation可以是and、or、not、xor的一種。
bitop and destkey key [key...]
,對一個或多個key邏輯並,結果保存到destkey。
bitop or destkey key [key...]
,對一個或多個key邏輯或,結果保存到destkey。
bitop xor destkey key [key...]
,對一個或多個key邏輯異或,結果保存到destkey。
bitop xor destkey key
,對一個或多個key邏輯非,結果保存到destkey。
除了NOT之外,其他操作多可以接受一個或多個key作爲輸入。
(敲黑板,劃重點)BITOP的時間複雜度是O(N),當處理大型矩陣或者大量數據統計時,最好將任務指派到附屬節點(slave)進行,避免阻塞主節點。
優勢
1.基於最小的單位bit進行存儲,所以非常省空間。
2.設置時候時間複雜度O(1)、讀取時候時間複雜度O(1),操作是非常快的。
3.二進制數據的存儲,進行相關計算的時候非常快。
4.方便擴容
限制
redis中bit映射被限制在512MB之內,所以最大是2^32位。
3. 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. 在不密集分佈中,長度過長,會有大量的0無用值佔用內存資源 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一樣分片的方式。幾十萬或者以下,可無需分片省的業務變複雜。
其他 類似情況:
key:日期;
offset:用戶id【數字或者二進制】;
value:是否登錄/做任意操作;
按日期生成一個位圖(bitmap)
計算月活
:可把30天的所有bitmap做or計算,在進行bitcount計算;
計算留存率
:昨日留存=昨天今天連續登錄的人數/昨天登錄的人數,即昨天的bitmap與今天的bitmap進行and計算,在除以昨天bitcount的數量。
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的數量的,注意是字節,第一個字節也就是0,1,2,3,4,5,6,7這八個位置上。
4.使用 set 和 BitMap 存儲的對比、
通過上面的對比,我們可以看到,如果獨立用戶數量很多,使用 BitMap 明顯更有優勢,能節省大量的內存。但如果獨立用戶數量較少,還是建議使用 set 存儲,BitMap 會產生多餘的存儲開銷。
使用經驗
type = string,BitMap 是 sting 類型,最大 512 MB。
注意 setbit 時的偏移量,可能有較大耗時
位圖不是絕對好。