SpringBoot进阶-Redis亿级流量签到解决方案(五)

新类型

一般面试的时候问到redis的数据类型,相信很多同学都能答上来5种,string,hash,set,zset,list.但是新数据类型,估计没几个能答上来。如果你能把bitmap,hyperloglog,geo以及应用场景说出来,肯定能加分不少。

位图(bitmap)

位图严格意义上来说不算新类型,它底层是用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<Long>) 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<Long> signList = (List<Long>) redisTemplate.execute(new RedisCallback<List<Long>>() {
            @Override
            public List<Long> 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<String, Long> signMap = new TreeMap<>();
        LocalDate localDate = LocalDate.now();
        long mask = 0b1;
        //用bitfield命令取出第一天到当月最后一天的数据
        List<Long> signList = (List<Long>) redisTemplate.execute(new RedisCallback<List<Long>>() {
            @Override
            public List<Long> 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  

  • 2
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值