- 一亿个用户,有的用户频繁登录,也有不经常登录的。
- 如何记录用户的登录信息?
- 如何查询活跃用户?[如一周内 登录三次的]
我们可以使用Redis的bitmap
(位图)来存储数据。
1. 什么叫做Redis的bitmap
即:操作String
数据结构的key
所存储的字符串指定偏移量上的位,返回原位置的值
1.1 优点:
节省空间:通过一个bit
位来表示某个元素对应的值或者状态,其中key
就是对应元素的值。实际上8个bit
可以组成一个Byte
,所以是及其节省空间的。
效率高:setbit
和getbit
的时间复杂度都是O(1),其他位运算效率也高。
1.2 缺点:
本质上位只有0
和1
的区别,所以用位做业务数据记录,就不需要在意value
的值。
1.3 使用场景
- 可作为简单的布尔过滤器来判断用户是否执行过某些操作;
- 可以计算用户日活、月活、留存率的统计;
- 可以统计用户在线状态和人数;
2. Redis的bitmap命令
2.1 setbit命令
设置或修改key
上的偏移量(offset)
的位(value)
的值。
- 语法:
setbit key offset value
- 返回值:指定偏移量
(offset)
原来存储的值。
2.2 getbit命令
查询key
所存储的字符串值,获取偏移量上的位。
- 语法:
getbit key offset
- 返回值:返回指定
key
上的偏移量,若key
不存在,那么返回0。
2.3 bitcount命令
计算给定key的字符串值中,被设置为1的位bit
的数量
- 语法:
bitcount key [start] [end]
- 返回值:1比特位的数量
注意:setbit
是设置或者清除bit位置。这个是统计key出现1的次数。
(小胖友情提示:)需要注意的是:[start][end](单位)实际是byte
,这是什么意思呢?进入redis实际上是乘以8。
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)
进行,避免阻塞主节点。
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;
/**
* 工具类-提供静态方法
*/
public class RedisTemplateUtil {
private static StringRedisTemplate stringRedisTemplate = SpringUtils.getBean(StringRedisTemplate.class);
/*********************************************************************************
*
* 对bitmap的操作
*
********************************************************************************/
/**
* 将指定param的值设置为1,{@param param}会经过hash计算进行存储。
*
* @param key bitmap结构的key
* @param param 要设置偏移的key,该key会经过hash运算。
* @param value true:即该位设置为1,否则设置为0
* @return 返回设置该value之前的值。
*/
public static Boolean setBit(String key, String param, boolean value) {
return stringRedisTemplate.opsForValue().setBit(key, hash(param), value);
}
/**
* 将指定param的值设置为0,{@param param}会经过hash计算进行存储。
*
* @param key bitmap结构的key
* @param param 要移除偏移的key,该key会经过hash运算。
* @return 若偏移位上的值为1,那么返回true。
*/
public static Boolean getBit(String key, String param) {
return stringRedisTemplate.opsForValue().getBit(key, hash(param));
}
/**
* 将指定offset偏移量的值设置为1;
*
* @param key bitmap结构的key
* @param offset 指定的偏移量。
* @param value true:即该位设置为1,否则设置为0
* @return 返回设置该value之前的值。
*/
public static Boolean setBit(String key, Long offset, boolean value) {
return stringRedisTemplate.opsForValue().setBit(key, offset, value);
}
/**
* 将指定offset偏移量的值设置为0;
*
* @param key bitmap结构的key
* @param offset 指定的偏移量。
* @return 若偏移位上的值为1,那么返回true。
*/
public static Boolean getBit(String key, long offset) {
return stringRedisTemplate.opsForValue().getBit(key, offset);
}
/**
* 统计对应的bitmap上value为1的数量
*
* @param key bitmap的key
* @return value等于1的数量
*/
public static Long bitCount(String key) {
return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
}
/**
* 统计指定范围中value为1的数量
*
* @param key bitMap中的key
* @param start 该参数的单位是byte(1byte=8bit),{@code setBit(key,7,true);}进行存储时,单位是bit。那么只需要统计[0,1]便可以统计到上述set的值。
* @param end 该参数的单位是byte。
* @return 在指定范围[start*8,end*8]内所有value=1的数量
*/
public static Long bitCount(String key, int start, int end) {
return stringRedisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes(), start, end));
}
/**
* 对一个或多个保存二进制的字符串key进行元操作,并将结果保存到saveKey上。
* <p>
* bitop and saveKey key [key...],对一个或多个key逻辑并,结果保存到saveKey。
* bitop or saveKey key [key...],对一个或多个key逻辑或,结果保存到saveKey。
* bitop xor saveKey key [key...],对一个或多个key逻辑异或,结果保存到saveKey。
* bitop xor saveKey key,对一个或多个key逻辑非,结果保存到saveKey。
* <p>
*
* @param op 元操作类型;
* @param saveKey 元操作后将结果保存到saveKey所在的结构中。
* @param desKey 需要进行元操作的类型。
* @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));
}
/**
* 对一个或多个保存二进制的字符串key进行元操作,并将结果保存到saveKey上,并返回统计之后的结果。
*
* @param op 元操作类型;
* @param saveKey 元操作后将结果保存到saveKey所在的结构中。
* @param desKey 需要进行元操作的类型。
* @return 返回saveKey结构上value=1的所有数量值。
*/
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导致大key
4.1 原因
网上的文章,均是简单的介绍bitmap的用法,但是都存在一个很大的风险的,导致bitmap占用大内存。
例如判断活跃用户量,使用bitmap实现:网上介绍很简单,bitset 将偏移量设置为1,但是id如何转换为偏移量并没有一篇文章进行介绍。
bitmap推荐的偏移量是从1一直累加的,但是计算出的hash值为10位(10亿级别),那么占用的内存大小为239MB,若是计算出的hash值为7位(百万级别)占用的内存大小为124KB。
所以计算偏移量的时候不能无脑的进行hash得到,而是要根据系统情况(百万级别的日活、十万级别日活),进行取余计算,得到合适的偏移量。
4.2 解决方案
对数据进行分组在分片:
- 不仅可以避免bitmap导致大key。
- 避免出现范围用户太多导致查询时出现热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内存
// 如果useId < 100亿, 则会分到7000个分组里
private static final int ONE_BITMAP_SIZE = 1 << 20;
// 同一个分组里的的useId划分到20个bitmap里
// 避免出现范围用户太多导致查询时出现热key
private static final int SHARD_COUNT = 20;
// 计算用户的 raw, shard, 和对应的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. 计算日活、月活、留存率的具体方法
具体实施:使用redis的bitmap
- 设置一个key专门用来记录用户日活的,可以使用时间来翻滚比如1号的key为active01.
- 使用每个用户的唯一标识映射一个偏移量,比如使用id,这里可以把id换算成一个数字或直接使用id的二进制值作为该用户在当天是否活跃偏移量
- 用户登录则把该用户偏移量上的位值设置为1
- 每天按日期生成一个位图(bitmap)
- 计算日活则使用bitcount即可获得一个key的位值为1的量
- 计算月活(一个月内登陆的用户去重总数)即可把30天的所有bitmap做or计算,然后再计算bitcount
- 计算留存率(次日留存=昨天今天连续登录的人数/昨天登录的人数) 即昨天的bitmap与今天的bitmap做and计算就是连续登录的再做bitcount就得到连续登录人数,再bitcount得到昨天登录人数,就可以通过公式计算出次日留存。