一般面试的时候问到redis的数据类型,相信很多同学都能答上来5种,string,hash,set,zset,list.但是新数据类型,估计没几个能答上来。如果你能把bitmap,hyperloglog,geo以及应用场景说出来,肯定能加分不少。
位图严格意义上来说不算新类型,它底层是用string来实现的,你可以把它理解成一个数组,不过这个数组比较特殊,每个元素只能存0或1.
位图占用空间的计算公式为offset/8/1024/1024(MB),由于string单个key最大能存512MB的数据,所以位图offset最大为2^32(2的32次方,四十多亿)
bitmap可以用于状态统计,像日活统计,签到统计等。
我们来看一个签到案例,可以每日签到,可在日历中查看当月签到记录,连续签到有奖励,每月重置连续签到天数。
接到这个需求我们第一个想到的就是用mysql来实现,先建个表,存用户id,签到日期,为了性能再加个冗余字段连续签到天数,很快就能实现,没啥难度。
但是对于大厂,这种实现很难落地,某东活跃用户3个亿,每天使用签到功能的用户就有数千万,按照3000W计算,一个月就要存9亿条数据,更别提高并发对关系型数据库的压力了。
用位图实现呢?用户id和日期作为key,存一个月的签到记录只需要4个字节,3000W用户只需要114MB内存。
我们理论加实践,直接上代码
public class SignController extends BaseController {
@Autowired
private RedisTemplate redisTemplate;
private DateTimeFormatter monthFormatter = DateTimeFormatter.ofPattern("yyyyMM");
private DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private String getKey() {
int userId = 1;//从session中取出用户id
LocalDate now = LocalDate.now();
String month = monthFormatter.format(now); //取当前月份
String key = "sign:" + userId + ":" + month;
return key;
}
@PostMapping
@ApiOperation(value = "签到")
public Result sign(int day) {
String key = getKey();
//day = now.getDayOfMonth() - 1; //正常情况取当前天数,为了测试方便前端传
//setBit返回原始值,返回true表示已签过
boolean originalValue = redisTemplate.opsForValue().setBit(key, day, true);
if (originalValue) {
return resultFail("您已签过了,不要太贪心哦");
}
return resultOk();
}
@GetMapping("monthTotalCount")
@ApiOperation(value = "当月总签到次数")
public Result monthTotalCount() {
String key = getKey();
//使用bitcount命令获取所有置为1的数量
long count = (long) redisTemplate.execute((RedisCallback) connection -> connection.bitCount(key.getBytes()));
return resultOk(count);
}
@GetMapping("continuousDays")
@ApiOperation(value = "查看连续签到天数")
public Result continuousDays() {
String key = getKey();
LocalDate localDate = LocalDate.now();
int signCount = 0;
long mask = 0b1;
//用bitfield命令取出第一天到当前天的数据
List signList = (List) redisTemplate.execute(new RedisCallback>() {
@Override
public List doInRedis(RedisConnection connection) throws DataAccessException {
return connection.bitField(key.getBytes(), BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(localDate.getDayOfMonth())).valueAt(0));
}
});
if (CollectionUtil.isNotEmpty(signList)) {
long sign = signList.get(0) == null ? 0 : signList.get(0);
for (int i = 0; i < localDate.getDayOfMonth(); i++) {
//判断低位为0表示没有签到
if ((sign & mask) == 0) {
//没有签到的情况,如果是当天则不处理,否则退出计数
if (i > 0) break;
} else {
signCount++;
}
//最低位前进一天
sign >>= 1;
}
}
return resultOk(signCount);
}
@GetMapping("currentMonthSign")
@ApiOperation(value = "当月签到日历")
public Result currentMonthSign() {
String key = getKey();
Map signMap = new TreeMap<>();
LocalDate localDate = LocalDate.now();
long mask = 0b1;
//用bitfield命令取出第一天到当月最后一天的数据
List signList = (List) redisTemplate.execute(new RedisCallback>() {
@Override
public List doInRedis(RedisConnection connection) throws DataAccessException {
return connection.bitField(key.getBytes(), BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(localDate.lengthOfMonth())).valueAt(0));
}
});
if (CollectionUtil.isNotEmpty(signList)) {
long sign = signList.get(0) == null ? 0 : signList.get(0);
for (int i = localDate.lengthOfMonth(); i > 0; i--) {
//从最后一天往前算
LocalDate d = localDate.withDayOfMonth(i);
//放入时间和最后一位签到记录
signMap.put(dateFormatter.format(d), sign & mask);
//最低位前进一天
sign >>= 1;
}
}
return resultOk(signMap);
}
}
相信很多同学看到签到时感觉很轻松,看到总签到次数时,也是so easy,但是看到连续签到,就要开始懵了,到了当月签到日历,估计就完全懵圈了。
因为里面涉及到bitfield这个冷门命令,还涉及位运算,很有难度。但这就是中低级程序员与高级程序员的区别,当别人嘲笑你是CRUD工程师的时候,你可以很自豪的说,我可是掌握亿级流量签到方案的工程师,所以深吸一口气,开始挑战下自己吧。
bitmap是位操作,比如1号签到了,就把第一位置为1,setbit key 0 1这样,查看当月总签到次数,一个bitcount命令就行了,没啥难度,难的是连续签到天数,有个笨办法就是getbit key offset可以看当天有没有签到,如果签到就继续往前找,就是这种方法最坏的情况要发送31次请求,很明显不是理想的办法。
这时我们可以用bitfield命名,它可以取出整段的数据,如我要取上图1号到4号的数据,就是bitfield key get u4 0,u4表示取出4位的无符号数,最后一个参数就是offset,表示从第一位开始取,取出来就是二进制0b1011。比如今天是4号,那只要从最低一位开始往前找连续的1就行了。
如果判断最后一位是1呢?就要用到位与( & )操作了,0b1011 & 0b1返回1,0b1010 & 0b1返回0,0b1做为mask可以过滤出最后一位来。
接下去就简单了,比如我过滤出4号是1已签到,接下去就要看倒数第二位了,那就0b1011 >> 1右移一位变成0b101,最后一位就是3号的签到数据。
签到日历其实跟连续签到用到的技术点差不多,看懂了第一个就能看懂第二个。
相信通过以上的解析,再看代码就会简单很多了。
以上连续签到天数只适用于第二个月重置的情况,如果不重置该怎么计算呢?这时就只能在redis中另外记录一个key来存天数,每次签到去计数,并且查询的时候也要判断下签到是否已中断,已中断的话重置为0这样。
参考项目(模块: SpringBoot-HelloWorld): https://gitee.com/huatin/java-test