- 一億個用戶遍膜,有的用戶頻繁登錄爵赵,也有不經(jīng)常登錄的等恐。
- 如何記錄用戶的登錄信息洲劣?
- 如何查詢活躍用戶?[如一周內(nèi) 登錄三次的]
我們可以使用Redis的bitmap
(位圖)來存儲數(shù)據(jù)课蔬。
1. 什么叫做Redis的bitmap
即:操作String
數(shù)據(jù)結(jié)構(gòu)的key
所存儲的字符串指定偏移量上的位囱稽,返回原位置的值
1.1 優(yōu)點(diǎn):
節(jié)省空間:通過一個bit
位來表示某個元素對應(yīng)的值或者狀態(tài),其中key
就是對應(yīng)元素的值二跋。實(shí)際上8個bit
可以組成一個Byte
粗悯,所以是及其節(jié)省空間的。
效率高:setbit
和getbit
的時間復(fù)雜度都是O(1)同欠,其他位運(yùn)算效率也高样傍。
1.2 缺點(diǎn):
本質(zhì)上位只有0
和1
的區(qū)別,所以用位做業(yè)務(wù)數(shù)據(jù)記錄铺遂,就不需要在意value
的值衫哥。
1.3 使用場景
- 可作為簡單的布爾過濾器來判斷用戶是否執(zhí)行過某些操作;
- 可以計(jì)算用戶日活襟锐、月活撤逢、留存率的統(tǒng)計(jì);
- 可以統(tǒng)計(jì)用戶在線狀態(tài)和人數(shù);
2. Redis的bitmap命令
2.1 setbit命令
設(shè)置或修改key
上的偏移量(offset)
的位(value)
的值蚊荣。
- 語法:
setbit key offset value
- 返回值:指定偏移量
(offset)
原來存儲的值初狰。
2.2 getbit命令
查詢key
所存儲的字符串值,獲取偏移量上的位互例。
- 語法:
getbit key offset
- 返回值:返回指定
key
上的偏移量奢入,若key
不存在,那么返回0媳叨。
2.3 bitcount命令
計(jì)算給定key的字符串值中腥光,被設(shè)置為1的位bit
的數(shù)量
- 語法:
bitcount key [start] [end]
- 返回值:1比特位的數(shù)量
注意:setbit
是設(shè)置或者清除bit位置。這個是統(tǒng)計(jì)key出現(xiàn)1的次數(shù)糊秆。
(小胖友情提示:)需要注意的是:[start][end](單位)實(shí)際是byte
武福,這是什么意思呢?進(jìn)入redis實(shí)際上是乘以8痘番。
2.4 bitop命令
對一個或多個保存二進(jìn)制的字符串key
進(jìn)行元操作捉片,并將結(jié)果保存到destkey
上。
- 語法:
operation
可以是and
汞舱、or
伍纫、not
、xor
的一種兵拢。 -
bitop and destkey key [key...]
,對一個或多個key
邏輯并逾礁,結(jié)果保存到destkey
说铃。 -
bitop or destkey key [key...]
,對一個或多個key
邏輯或嘹履,結(jié)果保存到destkey
腻扇。 -
bitop xor destkey key [key...]
,對一個或多個key
邏輯異或砾嫉,結(jié)果保存到destkey
幼苛。 -
bitop xor destkey key
,對一個或多個key
邏輯非焕刮,結(jié)果保存到destkey
舶沿。
除了NOT之外,其他操作多可以接受一個或多個key作為輸入配并。
BITOP的時間復(fù)雜度是O(N)括荡,當(dāng)處理大型矩陣或者大量數(shù)據(jù)統(tǒng)計(jì)時,最好將任務(wù)指派到附屬節(jié)點(diǎn)(slave)
進(jìn)行溉旋,避免阻塞主節(jié)點(diǎn)畸冲。
3. SpringBoot中使用
@Component
public class SpringUtils implements ApplicationContextAware {
private static ApplicationContext applicationContext;
public static ApplicationContext getApplicationContext() {
return applicationContext;
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
if (SpringUtils.applicationContext == null) {
SpringUtils.applicationContext = applicationContext;
}
}
public static Object getBean(String name) {
return getApplicationContext().getBean(name);
}
public static <T> T getBean(Class<T> c) {
return getApplicationContext().getBean(c);
}
public static <T> T getBean(String name, Class<T> c) {
return getApplicationContext().getBean(name, c);
}
}
工具類:
mport com.google.common.hash.Funnels;
import com.google.common.hash.Hashing;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.nio.charset.Charset;
/**
* 工具類-提供靜態(tài)方法
*/
public class RedisTemplateUtil {
private static StringRedisTemplate stringRedisTemplate = SpringUtils.getBean(StringRedisTemplate.class);
/*********************************************************************************
*
* 對bitmap的操作
*
********************************************************************************/
/**
* 將指定param的值設(shè)置為1,{@param param}會經(jīng)過hash計(jì)算進(jìn)行存儲。
*
* @param key bitmap結(jié)構(gòu)的key
* @param param 要設(shè)置偏移的key邑闲,該key會經(jīng)過hash運(yùn)算算行。
* @param value true:即該位設(shè)置為1,否則設(shè)置為0
* @return 返回設(shè)置該value之前的值苫耸。
*/
public static Boolean setBit(String key, String param, boolean value) {
return stringRedisTemplate.opsForValue().setBit(key, hash(param), value);
}
/**
* 將指定param的值設(shè)置為0州邢,{@param param}會經(jīng)過hash計(jì)算進(jìn)行存儲。
*
* @param key bitmap結(jié)構(gòu)的key
* @param param 要移除偏移的key鲸阔,該key會經(jīng)過hash運(yùn)算偷霉。
* @return 若偏移位上的值為1咏雌,那么返回true捕透。
*/
public static Boolean getBit(String key, String param) {
return stringRedisTemplate.opsForValue().getBit(key, hash(param));
}
/**
* 將指定offset偏移量的值設(shè)置為1鬼佣;
*
* @param key bitmap結(jié)構(gòu)的key
* @param offset 指定的偏移量枣接。
* @param value true:即該位設(shè)置為1纳决,否則設(shè)置為0
* @return 返回設(shè)置該value之前的值盹舞。
*/
public static Boolean setBit(String key, Long offset, boolean value) {
return stringRedisTemplate.opsForValue().setBit(key, offset, value);
}
/**
* 將指定offset偏移量的值設(shè)置為0娄昆;
*
* @param key bitmap結(jié)構(gòu)的key
* @param offset 指定的偏移量泽西。
* @return 若偏移位上的值為1晃痴,那么返回true残吩。
*/
public static Boolean getBit(String key, long offset) {
return stringRedisTemplate.opsForValue().getBit(key, offset);
}
/**
* 統(tǒng)計(jì)對應(yīng)的bitmap上value為1的數(shù)量
*
* @param key bitmap的key
* @return value等于1的數(shù)量
*/
public static Long bitCount(String key) {
return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
}
/**
* 統(tǒng)計(jì)指定范圍中value為1的數(shù)量
*
* @param key bitMap中的key
* @param start 該參數(shù)的單位是byte(1byte=8bit),{@code setBit(key,7,true);}進(jìn)行存儲時倘核,單位是bit泣侮。那么只需要統(tǒng)計(jì)[0,1]便可以統(tǒng)計(jì)到上述set的值。
* @param end 該參數(shù)的單位是byte紧唱。
* @return 在指定范圍[start*8,end*8]內(nèi)所有value=1的數(shù)量
*/
public static Long bitCount(String key, int start, int end) {
return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes(), start, end));
}
/**
* 對一個或多個保存二進(jìn)制的字符串key進(jìn)行元操作活尊,并將結(jié)果保存到saveKey上。
* <p>
* bitop and saveKey key [key...]漏益,對一個或多個key邏輯并蛹锰,結(jié)果保存到saveKey。
* bitop or saveKey key [key...]绰疤,對一個或多個key邏輯或铜犬,結(jié)果保存到saveKey。
* bitop xor saveKey key [key...]轻庆,對一個或多個key邏輯異或癣猾,結(jié)果保存到saveKey。
* bitop xor saveKey key余爆,對一個或多個key邏輯非煎谍,結(jié)果保存到saveKey。
* <p>
*
* @param op 元操作類型龙屉;
* @param saveKey 元操作后將結(jié)果保存到saveKey所在的結(jié)構(gòu)中呐粘。
* @param desKey 需要進(jìn)行元操作的類型满俗。
* @return 1:返回元操作值。
*/
public static Long bitOp(RedisStringCommands.BitOperation op, String saveKey, String... desKey) {
byte[][] bytes = new byte[desKey.length][];
for (int i = 0; i < desKey.length; i++) {
bytes[i] = desKey[i].getBytes();
}
return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitOp(op, saveKey.getBytes(), bytes));
}
/**
* 對一個或多個保存二進(jìn)制的字符串key進(jìn)行元操作作岖,并將結(jié)果保存到saveKey上唆垃,并返回統(tǒng)計(jì)之后的結(jié)果。
*
* @param op 元操作類型痘儡;
* @param saveKey 元操作后將結(jié)果保存到saveKey所在的結(jié)構(gòu)中辕万。
* @param desKey 需要進(jìn)行元操作的類型。
* @return 返回saveKey結(jié)構(gòu)上value=1的所有數(shù)量值沉删。
*/
public static Long bitOpResult(RedisStringCommands.BitOperation op, String saveKey, String... desKey) {
bitOp(op, saveKey, desKey);
return bitCount(saveKey);
}
/**
* guava依賴獲取hash值渐尿。
*/
private static long hash(String key) {
Charset charset = Charset.forName("UTF-8");
return Math.abs(Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asInt());
}
}
4. bitmap導(dǎo)致大key
4.1 原因
網(wǎng)上的文章,均是簡單的介紹bitmap的用法矾瑰,但是都存在一個很大的風(fēng)險的砖茸,導(dǎo)致bitmap占用大內(nèi)存。
例如判斷活躍用戶量殴穴,使用bitmap實(shí)現(xiàn):網(wǎng)上介紹很簡單凉夯,bitset 將偏移量設(shè)置為1,但是id如何轉(zhuǎn)換為偏移量并沒有一篇文章進(jìn)行介紹采幌。
bitmap推薦的偏移量是從1一直累加的劲够,但是計(jì)算出的hash值為10位(10億級別),那么占用的內(nèi)存大小為239MB休傍,若是計(jì)算出的hash值為7位(百萬級別)占用的內(nèi)存大小為124KB征绎。
所以計(jì)算偏移量的時候不能無腦的進(jìn)行hash得到,而是要根據(jù)系統(tǒng)情況(百萬級別的日活磨取、十萬級別日活)人柿,進(jìn)行取余計(jì)算,得到合適的偏移量寝衫。
4.2 解決方案
對數(shù)據(jù)進(jìn)行分組在分片:
- 不僅可以避免bitmap導(dǎo)致大key顷扩。
- 避免出現(xiàn)范圍用戶太多導(dǎo)致查詢時出現(xiàn)熱key拐邪。
public class TestBitMap {
public static void main(String[] args) {
System.out.println(ONE_BITMAP_SIZE);
System.out.println(1024*1024);
long r1=223456679;
BitMapKey u1 = computeUserGroup(r1, ONE_BITMAP_SIZE, SHARD_COUNT);
System.out.println(u1);
long r2=1234566777;
BitMapKey u2 = computeUserGroup(r2, ONE_BITMAP_SIZE, SHARD_COUNT);
System.out.println(u2);
String redisKey = u2.generateKeyWithPrefix("ttt");
System.out.println(redisKey);
}
// 單個bitmap占用1M內(nèi)存
// 如果useId < 100億慰毅, 則會分到7000個分組里
private static final int ONE_BITMAP_SIZE = 1 << 20;
// 同一個分組里的的useId劃分到20個bitmap里
// 避免出現(xiàn)范圍用戶太多導(dǎo)致查詢時出現(xiàn)熱key
private static final int SHARD_COUNT = 20;
// 計(jì)算用戶的 raw, shard扎阶, 和對應(yīng)的offset
public static BitMapKey computeUserGroup(long userId, int oneBitMapSize, int shardCount) {
//獲取組
long groupIndex = userId / oneBitMapSize;
//獲取分片位置
int shardIndex = Math.abs((int) (hash(userId+"") % shardCount));
//獲刃谖浮(組-分片)下的offset位置
int bitIndex = (int) (userId - groupIndex * oneBitMapSize);
//獲取到對象
return new BitMapKey((int) groupIndex, shardIndex, bitIndex);
}
@Data
public static class BitMapKey {
/**
* 組
*/
private final int groupIndex;
/**
* 組中分片
*/
private final int shardIndex;
/**
*
*/
private final int bitIndex;
public BitMapKey(int groupIndex, int shardIndex, int bitIndex) {
this.groupIndex = groupIndex;
this.shardIndex = shardIndex;
this.bitIndex = bitIndex;
}
public int getBitIndex() {
return bitIndex;
}
public String generateKeyWithPrefix(String prefix) {
return String.join(":", prefix, groupIndex + "", shardIndex + "");
}
}
/**
* guava依賴獲取hash值。
*/
private static long hash(String key) {
Charset charset = Charset.forName("UTF-8");
return Math.abs(Hashing.murmur3_128().hashObject(key, Funnels.stringFunnel(charset)).asInt());
}
}
依賴類:
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
5. 計(jì)算日活东臀、月活着饥、留存率的具體方法
具體實(shí)施:使用redis的bitmap
- 設(shè)置一個key專門用來記錄用戶日活的,可以使用時間來翻滾比如1號的key為active01.
- 使用每個用戶的唯一標(biāo)識映射一個偏移量惰赋,比如使用id宰掉,這里可以把id換算成一個數(shù)字或直接使用id的二進(jìn)制值作為該用戶在當(dāng)天是否活躍偏移量
- 用戶登錄則把該用戶偏移量上的位值設(shè)置為1
- 每天按日期生成一個位圖(bitmap)
- 計(jì)算日活則使用bitcount即可獲得一個key的位值為1的量
- 計(jì)算月活(一個月內(nèi)登陸的用戶去重總數(shù))即可把30天的所有bitmap做or計(jì)算呵哨,然后再計(jì)算bitcount
- 計(jì)算留存率(次日留存=昨天今天連續(xù)登錄的人數(shù)/昨天登錄的人數(shù)) 即昨天的bitmap與今天的bitmap做and計(jì)算就是連續(xù)登錄的再做bitcount就得到連續(xù)登錄人數(shù),再bitcount得到昨天登錄人數(shù)轨奄,就可以通過公式計(jì)算出次日留存孟害。
文章參考:
Redis:Bitmap的setbit,getbit,bitcount,bitop等使用與應(yīng)用場景