一、介绍
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1
的设置,表示某个元素的值或者状态,时间复杂度为O(1)。
由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。

内部实现
Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。
String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组只是里面的内容只能为0或1而已(二进制位数组)。
二、常用命令
1.bitmap 基本操作:
# 设置值,为位数组指定偏移量上的二进制位设置值,偏移量从0开始计数,二进制位的值只能为0或1。返回原位置值。
SETBIT key offset value
# 获取指定偏移量上二进制位的值。
GETBIT key offset
# 统计位数组中值为1的二进制位数量。
# start 和 end 以字节为单位
BITCOUNT key start end
2.bitmap 运算操作:
# BitMap间的运算
# operations 位移操作符,枚举值
AND 与运算 &
OR 或运算 |
XOR 异或 ^
NOT 取反 ~
# result 计算的结果,会存储在该key中
# key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key
# 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。
BITOP [operations] [result] [key1] [keyn…]
# 返回指定key中第一次出现指定value(0/1)的位置
BITPOS [key] [value]
三、应用场景
Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在记录海量数据时,Bitmap 能够有效地节省内存空间。
1.签到统计
在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。
签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。
假设我们要统计 ID 100 的用户在 2023 年 10 月份的签到情况,就可以按照下面的步骤进行操作。
第一步,执行下面的命令,记录该用户 10 月 17 号已签到。
SETBIT USER_SIGN_KEY:100:202310 16 1
第二步,检查该用户10 月 17 日是否签到。
GETBIT USER_SIGN_KEY:100:202310 16 2
第三步,统计该用户在 10 月份的签到次数。
BITCOUNT USER_SIGN_KEY:100:202310
这样,我们就知道该用户在 10 月份的签到情况了。
如何统计这个月首次打卡时间呢?
Redis 提供了 BITPOS key bitValue [start] [end]
指令,返回数据表示 Bitmap 中第一个值为 bitValue
的 offset 位置。
在默认情况下, 命令将检测整个位图, 用户可以通过可选的 start
参数和 end
参数指定要检测的范围。(start 和 end 以字节为单位 一字节8位)所以我们可以通过执行这条命令来获取 userID = 100 在 2023年 10月份首次打卡日期:
BITPOS USER_SIGN_KEY:100:202310 1
需要注意的是,因为 offset 从 0 开始的,所以我们需要将返回的 value + 1 。
对应java代码如下:
/**
* 签到
*
* @param userId 用户id
* @param date 日期
*/
@RequestMapping("/sign")
public void sign(Integer userId, LocalDate date) {
//拼接key
String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "USER_SIGN_KEY:" + userId + keySuffix;
// 获取今天是本月的第几天
int dayOfMonth = date.getDayOfMonth();
// 写入redis setbit key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
}
/**
* 查询当天是否有签到
*
* @param userId 用户id
* @param date 日期
*/
@RequestMapping("/checkSign")
public Boolean checkSign(Integer userId, LocalDate date) {
String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "USER_SIGN_KEY:" + userId + keySuffix;
int dayOfMonth = date.getDayOfMonth();
return stringRedisTemplate.opsForValue().getBit(key, dayOfMonth - 1);
}
/**
* 获取本月累计签到数
*
* @param userId 用户id
* @param date 日期
*/
@RequestMapping("/getSumSignCount")
public Long getSumSignCount(Integer userId, LocalDate date) {
String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "USER_SIGN_KEY:" + userId + keySuffix;
return stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount(key.getBytes()));
}
/**
* 获取本月首次签到日期
*
* @param userId 用户id
* @param date 日期
*/
@RequestMapping("/getFirstSign")
public Long getFirstSign(Integer userId, LocalDate date) {
String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "USER_SIGN_KEY:" + userId + keySuffix;
Long execute = stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitPos(key.getBytes(), Boolean.TRUE));
if(Objects.nonNull(execute)&&execute>-1){
return execute+1;
}
return execute;
}
2.判断用户登陆状态
Bitmap 提供了 GETBIT、SETBIT
操作,通过一个偏移值 offset 对 bit 数组的 offset 位置的 bit 位进行读写操作,需要注意的是 offset 从 0 开始。
只需要一个 key = USER_LOG_IN_KEY 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT
判断对应的用户是否在线。50000 万 用户只需要 6 MB 的空间。
假如我们要判断 ID = 10086 的用户的登陆情况:
第一步,执行以下指令,表示用户已登录。
SETBIT USER_LOG_IN_KEY 10086 1
第二步,检查该用户是否登陆,返回值 1 表示已登录。
GETBIT USER_LOG_IN_KEY 10086
第三步,登出,将 offset 对应的 value 设置成 0。
SETBIT USER_LOG_IN_KEY 10086 0
对应java代码如下:
/**
* 登录
*
* @param userId 用户id
*/
@RequestMapping("/logIn")
public void logIn(Integer userId) {
// 写入redis setbit key offset 1
stringRedisTemplate.opsForValue().setBit("USER_LOG_IN_KEY", userId, true);
}
/**
* 查询用户是否登录
*
* @param userId 用户id
*/
@RequestMapping("/checkLogIn")
public Boolean checkLogIn(Integer userId) {
return stringRedisTemplate.opsForValue().getBit("USER_LOG_IN_KEY", userId);
}
/**
* 登出
*
* @param userId 用户id
*/
@RequestMapping("/checkLogIn")
public void checkLogIn(Integer userId) {
stringRedisTemplate.opsForValue().setBit("USER_LOG_IN_KEY", userId, false);
}
3.获取连续签到天数
什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可。
如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是18号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是18号,那么就是18位,去拿这段时间的数据,就能拿到所有的数据了,那么这18天里边签到了多少次呢?统计有多少个1即可。
如何从后向前遍历每个Bit位?
bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?
我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
对应java代码如下:
/**
* 签到
*
* @param userId 用户id
* @param date 日期
*/
@RequestMapping("/sign")
public void sign(Integer userId, LocalDate date) {
String keySuffix = date.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "USER_SIGN_KEYS:" + userId + keySuffix;
stringRedisTemplate.opsForValue().setBit(key, date.getDayOfMonth(), true);
}
/**
* 获取今天之前的的连续签到次数
*
* @return Integer
*/
@GetMapping("/signCount")
public Integer signCount(Integer userId) {
//1. 获取日期
LocalDateTime now = LocalDateTime.now();
//2. 拼接key
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "USER_SIGN_KEYS:" + userId + keySuffix;
//3. 获取今天是本月的第几天
int dayOfMonth = now.getDayOfMonth();
//4. 获取本月截至今天为止的所有的签到记录,返回的是一个十进制的数字 BITFIELD USER_SIGN_KEYS:100:202310 GET u18 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
//没有任务签到结果
if (CollectionUtils.isEmpty(result)) {
return 0;
}
//11 -> 1011
Long num = result.get(0);
if (num == null || num == 0) {
return 0;
}
//5. 循环遍历
int count = 0;
while (true) {
//6 让这个数字与1 做与运算,得到数字的最后一个bit位 判断这个数字是否为0
if ((num & 1) == 0) {
//如果为0,签到结束
break;
} else {
count++;
}
//右移一位
num >>>= 1;
}
return count;
}
测试:


4.连续签到用户总数
如何统计出这连续 7 天连续打卡用户总数呢?
我们把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1。
key 对应的集合的每个 bit 位的数据则是一个用户在该日期的打卡记录。
一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡。
结果保存到一个新 Bitmap 中,我们再通过 BITCOUNT
统计 bit = 1 的个数便得到了连续打卡7天的用户总数了。
Redis 提供了 **BITOP operation destkey key [key ...]
**这个指令用于对一个或者多个 key 的 Bitmap 进行位元操作。opration
可以是 and
、OR
、NOT
、XOR
。当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0
。空的 key
也被看作是包含 0
的字符串序列。
举个例子,比如将三个 bitmap 进行 AND 操作,并将结果保存到 destmap 中,接着对 destmap 执行 BITCOUNT 统计。
# 与操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
# 统计 bit 位 = 1 的个数
BITCOUNT destmap
即使一天产生一个亿的数据,Bitmap 占用的内存也不大,大约占 12 MB 的内存(10^8/8/1024/1024),7 天的 Bitmap 的内存开销约为 84 MB。同时我们最好给 Bitmap 设置过期时间,让 Redis 删除过期的打卡数据,节省内存。
对应java代码如下:
/**
* 签到
*
* @param userId 用户id
* @param date 日期
*/
@RequestMapping("/sign")
public void sign(Integer userId, LocalDate date) {
//拼接key
String key = "USER_SIGN_KEY:" + date;
// 写入redis setbit key offset 1
stringRedisTemplate.opsForValue().setBit(key, userId, true);
}
/**
* 连续签到用户总数
*
* @param startDate 开始日期
* @param endDate 结束日期
*/
@RequestMapping("/continuousLogIn")
public Long continuousLogIn(LocalDate startDate, LocalDate endDate) {
List<LocalDate> allBetweenDate = getAllBetweenDate(startDate, endDate);
List<byte[]> bytes = allBetweenDate.stream().map(s -> ("USER_SIGN_KEYS:" + s).getBytes()).collect(Collectors.toList());
stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitOp(RedisStringCommands.BitOperation.AND, "collect".getBytes(),
bytes.get(0), bytes.get(1), bytes.get(2), bytes.get(3), bytes.get(4), bytes.get(5), bytes.get(6)
));
return stringRedisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount("collect".getBytes()));
}
private List<LocalDate> getAllBetweenDate(LocalDate startDate, LocalDate endDate) {
long numOfDaysBetween = ChronoUnit.DAYS.between(startDate, endDate) + 1;
return IntStream.iterate(0, i -> i + 1).limit(numOfDaysBetween).mapToObj(startDate::plusDays).collect(Collectors.toList());
}
四、总结
本文详细介绍了如何使用Redis BitMap,并给出了实际开发使用的java代码用例,开箱即用!
Redis的Bitmap是操作String数据结构的key所存储的字符串指定偏移量上的位,返回原位置的值。
Bitmap是通过最小的单位bit来进行0或者1的设置,表示某个元素对应的值或者状态。一个bit的值,或者是0,或者是1;也就是说一个bit能存储的最多信息是2。
位(bit):是计算机内部数据储存的最小单位,11001100是一个八位二进制数。字节(byte):是计算机中数据处理的基本单位,习惯上用大写B来表示,1B(byte,字节)=8bit。
对于Bitmap的优点,首先是节省空间,通过一个bit位来表示某个元素对应的值或者状态,其中key就是对应元素的值。实际上8个bit可以组成一个Byte,所以是及其节省空间的。其次,效率高,setbit和getbit的时间复杂度都是O(1),其他位运算效率也高。
对于Bitmap的缺点,只能表示0和1两种状态,所以能映射的状态有限。
原文始发于微信公众号(明月予我):Redis BitMap原理及实际使用介绍
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/272821.html