使用Bitmap來實現用戶標簽系統
https://leriou.github.io/2017-12-29-user-tag-sys-on-bitmap/
用戶標簽的存儲方案
背景
我們在日常的工作中經常遇到這種場景
對一個用戶添加許多的標簽信息方便對用戶身份進行搜索和精細化運營
ps:本文我們不考慮用戶身上的標簽是怎么來的,只討論用戶已經擁有標簽的情況下怎么進行存儲
需求分析
我們給用戶做標簽的目的是為了支持更加精細化的運營,算是用戶畫像的一部分,用戶的標簽來源可能跟消費,登錄,瀏覽等記錄都有關系,我們不具體去解釋怎么得到用戶標簽
我們要做的是可以根據用戶身上已經存在的標簽,篩選出來符合我們需求的用戶
我們可以在大量的標簽中查找具有某一些標簽的用戶,或者獲取某用戶身上的所有標簽
我們如果要滿足以上的需求, 需要提供以下幾個基本接口來方便進行數據查找
- 查找某標簽的所有用戶以及非該標簽的用戶
- 查找某個用戶身上的所有標簽
- 判斷某個用戶是否有某個標簽
一般來說對以上需求,對於用戶和用戶身上的標簽數據,如果我們采用數據庫來進行存儲
可能會采用以下方式(為了方便我們模擬了7個用戶,7個標簽,以下測試都基於該假數據),例如:
- 使用字段標識標簽信息
id | name | vip | mobile | male | mac | supervip | lost | |
---|---|---|---|---|---|---|---|---|
1 | 小明 | 1 | 1 | 0 | 1 | 0 | 1 | 0 |
2 | 小花 | 0 | 1 | 0 | 0 | 0 | 0 | 1 |
3 | 小江 | 0 | 0 | 0 | 1 | 1 | 0 | 1 |
4 | 小紅 | 1 | 1 | 0 | 0 | 0 | 0 | 1 |
5 | 小九 | 0 | 0 | 1 | 0 | 1 | 1 | 0 |
6 | 小七 | 0 | 1 | 0 | 1 | 1 | 1 | 0 |
7 | 小四 | 1 | 0 | 1 | 1 | 0 | 0 | 1 |
或者是這樣
- 使用記錄標識標簽信息
tag | uid | result |
---|---|---|
vip | 1 | 1 |
mobile | 1 | 1 |
1 | 0 | |
male | 1 | 1 |
mac | 1 | 0 |
supervip | 1 | 1 |
lost | 1 | 0 |
vip | 2 | 0 |
mobile | 2 | 1 |
2 | 0 | |
male | 2 | 0 |
以上兩種方式功能上都可以達到我們想要的效果,但第一種方式在標簽數量非常多的時候明顯是不合適的,我們不可能給每個標簽都添加一個字段,那樣性能和擴展性都損失非常大
在上面的兩個表中第二個表相當於對第一個表進行了拆分,增強了標簽的擴展性.如果我們采用第二種方式存儲,對於上面的需求 1,2,3 都能很好的滿足
但是方式2依然有兩個可能遇到的問題
-
我們要查找在某一些標簽的用戶需要使用如下sql
select uid from tag_table where result = 1 and tag in (‘vip’,’mobile’,’email’,’male’,’supervip’,’lost’)
這樣的語句在標簽數萬甚至數十萬的時候對性能影響會非常大
- 存儲: 因為每個行記錄同時標明了用戶,標簽,和結果, 所以其中的重復數據非常的多,對數據庫存儲是個極大地浪費
Bitmap
Bitmap的概念
Bitmap 翻譯做中文稱為”位圖”, 其核心里面是充分利用一部分數據本身就存在的元屬性(空間/位置/容量)信息,我們這李主要是使用其中的每一位的位置信息,達到使用一個信息表達兩種含義的作用
其實就也是一種特殊的編碼(coding)過程(或者叫多工(multiplex))
解決的問題
bitmap可以用來有效解決兩類問題
- 存儲大量值可以用布爾值標識的數據
- 部分有用到交,並,差等集合運算的數據
第一個特性主要是利用位存儲的節省空間的特性,第二個是利用計算機位運算比較快速的特性
eg:
-
以前的搜索引擎爬蟲在處理網頁爬取的時候需要給已經爬取過的網頁做標記,避免陷入死循環的重復爬取,當時的搜索網站的爬蟲就有一些采用過bitmap來給爬取過的網頁做標記,大致就是取頁面的url取hash,然后處理成數字,把對應的數字位置為1
-
微博里面你關注的A也關注了B, 使用B的粉絲列表和你的關注列表進行交集運算就可以了,同樣 購買這件商品的人也購買了M,也可以用 購買這件商品的用戶列表里面取某個用戶購買過的某個商品即可
以上應用確實能有效的減少數據的存儲容量和提高集合計算速度, 如果我們用這種方法來存儲用戶標簽信息也能大量減少存儲容量
但是怎么把用戶標簽的表信息數據轉換成bitmap形式的數據呢?
數據處理
我們如果要記錄一個用戶對應的一個標簽的信息,假如我們知道5號用戶是小九,而她是一位超級會員用戶(我們可以在上面的表中查到該信息)
我們要如何使用bitmap來表示這條信息呢
存儲用戶和標簽的關系
我們可以這樣:
- 使用一個鍵
user:supervip
來記錄所有用戶是否是超級會員的信息,這個值最初是空的字符串值,表明沒有超級會員用戶 - 我們為了標明 5 號用戶是超級會員 可以使用這個鍵中對應位置的二進制位來表明會員的身份,將這個鍵的第 5 位置為1, 這樣這個
user:supervip
值現在是’000001’(從第0位開始計算) - 同樣,如果
user:supervip
的值現在是’01001010’ 我們就可以知道 1,4,6號用戶都是超級會員用戶
我們根據這個數據可以做到2點:
-
我們可以根據該標簽數據鍵的對應位置的二進制位的值來判斷以該位置為id的用戶的標簽結果
-
也可以查詢某個標簽下的所有用戶
這樣我們存儲上萬個標簽也只需要上萬個鍵
存儲所有用戶
但是我們如果需要查找不屬於某個標簽的用戶怎么辦啊,如果直接對上一個例子取反肯定是不行的
為了解決這個問題我們需要一個存儲所有用戶的鍵
我們知道了所有用戶,知道了擁有某標簽的用戶
不含某標簽的用戶 = 總用戶 - 含有某標簽的用戶
用二進制的操作方法就是使用異或
, 舉例:
我們有7個用戶(編號1-7),5號用戶是超級vip,我們要查找所有不是vip的用戶可以使用下面的運算
1 |
01111111 ^ 00001000 = 01110111 // 127 ^ 8 = 119 |
以上操作我們就能得到所有不是超級會員的用戶
存儲某用戶的所有標簽
我們如果要獲得用戶的所有標簽,也可以將用戶擁有的標簽id在用戶標簽鍵中所對應的位置置為1,這樣每一個用戶的表示所有標簽的鍵的最大位長度就是固定的,比如:
我們可以用如下方式存儲用戶的所有標簽
1 |
usertag:all:1 => 01101010 // 1號用戶的所有標簽 |
這樣我們就能使用bitmap來滿足以上基本查詢需求
同樣我們也可以將所有標簽存儲成一個usertag:alltag
鍵, 再使用異或運算計算某用戶不含有的標簽
實現方案
我們如果自己來對位運算做管理就有點麻煩了,我們可以借助redis
redis
原生提供了可以對字符串進行位操作的命令,具體如下
1 |
SETBIT key pos value // 將 key 的第 pos 位設為 value(只能取1/0) |
我們就直接使用redis
來存儲數據了,這樣方便點
預處理
我們這邊為了方便直接使用redis提供的setbit
,getbit
和bitop
來進行字符串的位操作
因為我們要存儲用戶標簽,所以我們首先需要對用戶和標簽進行編號,這樣我們需要兩個表
用戶表:
uid | name |
---|---|
1 | 小明 |
2 | 小花 |
3 | 小江 |
4 | 小紅 |
5 | 小九 |
6 | 小七 |
7 | 小四 |
標簽表:
tid | name | 備注 |
---|---|---|
1 | vip | 是否vip |
2 | mobile | 是否綁定手機 |
3 | 是否綁定郵箱 | |
4 | male | 是否男性 |
5 | mac | 是否使用Mac |
6 | supervip | 是否年費會員 |
7 | lost | 是否易流失用戶 |
存儲
我們這里為了性能考慮使用redis來進行存儲
我們將最上面的表格數據轉換成以下鍵值對
1 |
|
查詢操作
我們可以使用redis的命令getbit
來查詢某個鍵的某個位置的值
比如,我們要查詢5號用戶是否具有vip標簽,可以使用以下命令
1 |
$ getbit user:vip 5 // 返回 0 |
要查詢某用戶身上的所有標簽可以使用如下
1 |
$ get usertag:all:1 // 獲取用戶1的所有標簽 返回'01101010' |
我們如果要獲取某標簽下的所有用戶可以使用如下命令
1 |
$ get user:vip // 返回一個二進制字符串 類似 '01001001' |
查詢不具有某個標簽的用戶
1 |
$ bitop xor user:not_vip user:all user:vip // 根據所有用戶和具有標簽的用戶進行異或運算,得到不含有某標簽的用戶 |
我們現在知道如何快速的獲取我們想要的數據了,但是我們發現有時候我們獲取到的都是二進制的數據例如 00001000
這種,而群毆們想從這樣的數據中獲取的是 [5]
這樣的比較易讀的信息
我們需要有一個將二進制字符串 轉化為對應位置為1的位置數組的形式
如: function(01001010
) => [1,4,6]
結果解析
這里我們提供兩個函數來進行這樣的操作
- 遍歷法
我們遍歷二進制字符串中的每一位, 每遇到一個為1的位置就將該位置放入數組
這種方法比較慢,不建議使用,這里貼一個示例代碼
1 |
def key2array(self, key): # 將二進制('\x05'->'0b00000101')變為數組[5,7], 表示第五位和第七位為1 |
- 查表
我們可以觀察一下redis返回的二進制數據的特點, 每8個二進制位屬於一個字節,每個字節都可以表示成具體的數字(如:0,23,127)這個數字最大也只能到255,而且同一個數字有可能出現非常多次,而每個數字所對應的轉換過后的位置數組都是固定的,比如: 100(二進制:1100100) => [1,2,5]
我們可以利用這一點,提前制作一個 0-255
的所對應的位置表,然后每次處理8位,處理完把當前處理的位數加上新表中對應的值就可以快速的得到這個值了
ps: 我們也可以擴大這個表的容量以提高速度
貼下示例代碼:
1 |
def build_bit_table(self): # 生成0-255的表 |
ps:jdk中的BitSet就是對bitmap的一種簡單實現
如果標簽過於稀疏會不會浪費空間?
如果我們在一個很長的bitmap中只存除了極少量的數據是不是會對空間造成浪費呢?
例如: 在bitmap的第40000位置為1,那存儲的數據大概就類似: 00000000000…0000000001
這樣的數據前面的39999位都是0,不會浪費空間嗎
Google的EWAHCompressedBitmap
Google的EWAHCompressedBitmap就對這種情況做了優化
EWAHCompressedBitmap 將整個的二進制數據分成每64位一個的word
一個空的Bitmap默認擁有 4 個word 也就是 4*64
位
其中 word0 存儲bitmap的頭信息
當我們改變對應位置的比特位的值時 word 會跟着變化
當我們插入的值非常大的時候(例如:40000), 算法會根據當前的值 創建兩個新的word
一個用於存儲第40000個數據所在的word的信息(LW), 還有一個存儲跨度信息(稱為:跨度word /RLW )
假如說我們給一個空的bitmap,我們插入40000的話正常情況下會有6個word,前4個是頭信息word+3個空word,第6個中保存40000這個數字所在的位置信息,第5個word中保存從第 4-625 word的跨度信息,第626word中存儲有 40000 這個數據
ps: 第一個word存儲頭信息, 625 = floor( (40000 + 1) / 64 )
存儲跨度信息的word和普通的存儲數據的word雖然空間一樣但是存儲的內容不一樣
存儲跨度信息的word大概內容這樣
前32位存儲 `當前跨度word(RLW)橫跨了多少空word`
后32位存儲 `當前跨度word(RLW)后方有多少個連續的LW`
當我們存儲 位置在跨度word(RLW)之中的數據(例如:20000), RLW會進行分裂
變成3個word,中間一個存儲20000所在的LW信息,前后各有一個RLW保存新的跨度信息
EWAHCompressedBitmap對應的maven依賴如下:
<dependency>
<groupId>com.googlecode.javaewah</groupId>
<artifactId>JavaEWAH</artifactId>
<version>1.1.0</version>
</dependency>
- Post link: https://leriou.github.io/2017-12-29-user-tag-sys-on-bitmap/
- Copyright Notice: All articles in this blog are licensed under BY-NC-SA unless stating additionally.