8、用户签到(BitMap)
很多APP为了拉动用户活跃度,往往都会做一些活动,比如连续签到领积分/礼包等等
传统做法:用户每次签到时,往是数据库插入一条签到数据,展示的时候,把本月(或者指定周期)的签到数据获取出来,用于判断用户是否签到、以及连续签到情况;此方式,简单,理解容易;
Redis做法:由于签到数据的关注点就2个:是否签到(0/1)、连续性,因此就完全可以利用BitMap(位图)来实现;
如上图所示,将一个月的31天,用31个位(4个字节)来表示,偏移量(offset)代表当前是第几天,0/1表示当前是否签到,连续签到只需从右往左校验连续为1的位数;
由于String类型的最大上限是512M,转换为bit则是2^32个bit位。
所需命令:
-
SETBIT key offset value:向指定位置offset存入一个0或1 -
GETBIT key offset:获取指定位置offset的bit值 -
BITCOUNT key [start] [end]:统计BitMap中值为1的bit位的数量 -
BITFIELD: 操作(查询,修改,自增)BitMap中bit 数组中的指定位置offset的值这里最不容易理解的就是:BITFIELD,详情可参考:https://deepinout.com/redis-cmd/redis-bitmap-cmd/redis-cmd-bitfield.html 而且这部分还必须理解了,否则,该需求的核心部分就没办法理解了;
需求:假如当前为8月4号,检测本月的签到情况,用户分别于1、3、4号签到过
Redis-cli 操作:
# 8月1号的签到 127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 0 1 (integer) 1 # 8月3号的签到 127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 2 1 (integer) 1 # 8月4号的签到 127.0.0.1:6379> SETBIT RangeId:Sign:1:8899 3 1 (integer) 1 # 查询各天的签到情况 # 查询1号 127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 0 (integer) 1 # 查询2号 127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 1 (integer) 0 # 查询3号 127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 2 (integer) 1 # 查询4号 127.0.0.1:6379> GETBIT RangeId:Sign:1:8899 3 (integer) 1 # 查询指定区间的签到情况 127.0.0.1:6379> BITFIELD RangeId:Sign:1:8899 get u4 0 1) (integer) 11
是否签到、连续签到判断
签到功能中,最不好理解的就是是否签到、连续签到的判断,在下面SpringBoot代码中,就是通过这样的:signFlag >> 1 << 1 != signFlag
来判断的,稍微有一点不好理解,在这里提前讲述一下;
上面测试了1-4号的签到情况,通过BITFIELD
获取出来signFlag = 11(十进制) = 1011(二进制);
连续签到的判断依据就是:从右往左计算连续为1的BIT个数,二进制 1011 表示连续签到的天数就是2天,2天的计算过程如下:
-
第一步,获取signFlag -
第二步,循环天数,以上测试用例是4天的签到情况,for循环也就是4次 -
第三步,从右往左循环判断连续签到:遇到第一个false的时候,终止并得到连续天数签到详情:循环所有天数,true就表示当前签到了,false表示当天未签到; 第一次循环
signFlag = 1011 signFlag >> 1 结果: 101 signFlag << 1 结果:1010 1010 != signFlag(1011) 结果:true //4号已签到,说明连续签到1天 signFlag >>= 1 结果: 101 // 此时signFlag = 101
第二次循环
signFlag = 101 // 前一次循环计算的结果 signFlag >> 1 结果: 10 signFlag << 1 结果:100 100 != signFlag(101) 结果:true //3号已签到,说明连续签到2天 signFlag >>= 1 结果: 10 // 此时signFlag = 10
第三次循环
signFlag = 10 // 前一次循环计算的结果 signFlag >> 1 结果: 1 signFlag << 1 结果:10 10 != signFlag(10) 结果:false //2号未签到,说明连续签到从这里就断了 signFlag >>= 1 结果: 1 // 此时signFlag = 1
到这一步,遇到第一个false,说明连续签到中断;
第四次循环:
signFlag = 1 // 前一次循环计算的结果 signFlag >> 1 结果: 0 signFlag << 1 结果: 0 0 != signFlag(1) 结果:true //1号已签到
到此,根据
BITFIELD
获取出来11(十进制),就能得到1、3、4号已签到,2号未签到;连续签到2天;理解上面的逻辑之后,再来看下面的SpringBoot代码,就会容易很多了;
SpringBoot实现签到
签到的方式一般就两种,按月(周)/ 自定义周期,下面将两种方式的签到全部列举出来,以供大家参考:
按月签到
签到工具类:
import lombok.extern.slf4j.Slf4j; import org.joda.time.DateTime; import org.joda.time.format.DateTimeFormat; import org.joda.time.format.DateTimeFormatter; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.connection.BitFieldSubCommands; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import java.util.HashMap; import java.util.List; import java.util.Map; /** * @author 一行Java * @title: 按月签到 * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/18 18:28 */ @Slf4j @Service public class SignByMonthServiceImpl { @Autowired StringRedisTemplate stringRedisTemplate; private int dayOfMonth() { DateTime dateTime = new DateTime(); return dateTime.dayOfMonth().get(); } /** * 按照月份和用户ID生成用户签到标识 UserId:Sign:560:2021-08 * * @param userId 用户id * @return */ private String signKeyWitMouth(String userId) { DateTime dateTime = new DateTime(); DateTimeFormatter fmt = DateTimeFormat.forPattern("yyyy-MM"); StringBuilder builder = new StringBuilder("UserId:Sign:"); builder.append(userId).append(":") .append(dateTime.toString(fmt)); return builder.toString(); } /** * 设置标记位 * 标记是否签到 * * @param key * @param offset * @param tag * @return */ public Boolean sign(String key, long offset, boolean tag) { return this.stringRedisTemplate.opsForValue().setBit(key, offset, tag); } /** * 统计计数 * * @param key 用户标识 * @return */ public long bitCount(String key) { return stringRedisTemplate.execute((RedisCallback<Long>) redisConnection -> redisConnection.bitCount(key.getBytes())); } /** * 获取多字节位域 */ public List<Long> bitfield(String buildSignKey, int limit, long offset) { return this.stringRedisTemplate .opsForValue() .bitField(buildSignKey, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(limit)).valueAt(offset)); } /** * 判断是否被标记 * * @param key * @param offest * @return */ public Boolean container(String key, long offest) { return this.stringRedisTemplate.opsForValue().getBit(key, offest); } /** * 用户今天是否签到 * * @param userId * @return */ public int checkSign(String userId) { DateTime dateTime = new DateTime(); String signKey = this.signKeyWitMouth(userId); int offset = dateTime.getDayOfMonth() - 1; int value = this.container(signKey, offset) ? 1 : 0; return value; } /** * 查询用户当月签到日历 * * @param userId * @return */ public Map<String, Boolean> querySignedInMonth(String userId) { DateTime dateTime = new DateTime(); int lengthOfMonth = dateTime.dayOfMonth().getMaximumValue(); Map<String, Boolean> signedInMap = new HashMap<>(dateTime.getDayOfMonth()); String signKey = this.signKeyWitMouth(userId); List<Long> bitfield = this.bitfield(signKey, lengthOfMonth, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTimeFormatter fmt = DateTimeFormat.forPattern("yyyy-MM-dd"); for (int i = lengthOfMonth; i > 0; i--) { DateTime dateTime1 = dateTime.withDayOfMonth(i); signedInMap.put(dateTime1.toString(fmt), signFlag >> 1 << 1 != signFlag); signFlag >>= 1; } } return signedInMap; } /** * 用户签到 * * @param userId * @return */ public boolean signWithUserId(String userId) { int dayOfMonth = this.dayOfMonth(); String signKey = this.signKeyWitMouth(userId); long offset = (long) dayOfMonth - 1; boolean re = false; if (Boolean.TRUE.equals(this.sign(signKey, offset, Boolean.TRUE))) { re = true; } // 查询用户连续签到次数,最大连续次数为7天 long continuousSignCount = this.queryContinuousSignCount(userId, 7); return re; } /** * 统计当前月份一共签到天数 * * @param userId */ public long countSignedInDayOfMonth(String userId) { String signKey = this.signKeyWitMouth(userId); return this.bitCount(signKey); } /** * 查询用户当月连续签到次数 * * @param userId * @return */ public long queryContinuousSignCountOfMonth(String userId) { int signCount = 0; String signKey = this.signKeyWitMouth(userId); int dayOfMonth = this.dayOfMonth(); List<Long> bitfield = this.bitfield(signKey, dayOfMonth, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTime dateTime = new DateTime(); // 连续不为0即为连续签到次数,当天未签到情况下 for (int i = 0; i < dateTime.getDayOfMonth(); i++) { if (signFlag >> 1 << 1 == signFlag) { if (i > 0) break; } else { signCount += 1; } signFlag >>= 1; } } return signCount; } /** * 以7天一个周期连续签到次数 * * @param period 周期 * @return */ public long queryContinuousSignCount(String userId, Integer period) { //查询目前连续签到次数 long count = this.queryContinuousSignCountOfMonth(userId); //按最大连续签到取余 if (period != null && period < count) { long num = count % period; if (num == 0) { count = period; } else { count = num; } } return count; } }
测试类:
import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.data.redis.core.StringRedisTemplate; import java.util.Map; /** * @author 一行Java * @title: SignTest2 * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/19 12:06 */ @SpringBootTest @Slf4j public class SignTest2 { @Autowired private SignByMonthServiceImpl signByMonthService; @Autowired private StringRedisTemplate redisTemplate; /** * 测试用户按月签到 */ @Test public void querySignDay() { //模拟用户签到 //for(int i=5;i<19;i++){ redisTemplate.opsForValue().setBit("UserId:Sign:560:2022-08", 0, true); //} System.out.println("560用户今日是否已签到:" + this.signByMonthService.checkSign("560")); Map<String, Boolean> stringBooleanMap = this.signByMonthService.querySignedInMonth("560"); System.out.println("本月签到情况:"); for (Map.Entry<String, Boolean> entry : stringBooleanMap.entrySet()) { System.out.println(entry.getKey() + ": " + (entry.getValue() ? "√" : "-")); } long countSignedInDayOfMonth = this.signByMonthService.countSignedInDayOfMonth("560"); System.out.println("本月一共签到:" + countSignedInDayOfMonth + "天"); System.out.println("目前连续签到:" + this.signByMonthService.queryContinuousSignCount("560", 7) + "天"); } }
执行日志:
c.e.r.bitmap_sign_by_month.SignTest2 : 560用户今日是否已签到:0 c.e.r.bitmap_sign_by_month.SignTest2 : 本月签到情况: c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-12: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-11: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-10: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-31: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-30: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-19: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-18: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-17: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-16: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-15: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-14: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-13: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-23: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-01: √ c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-22: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-21: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-20: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-09: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-08: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-29: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-07: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-28: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-06: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-27: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-05: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-26: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-04: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-25: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-03: √ c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-24: - c.e.r.bitmap_sign_by_month.SignTest2 : 2022-08-02: - c.e.r.bitmap_sign_by_month.SignTest2 : 本月一共签到:2天 c.e.r.bitmap_sign_by_month.SignTest2 : 目前连续签到:1天
指定时间签到
签到工具类:
package com.ehang.redis.bitmap_sign_by_range; import lombok.extern.slf4j.Slf4j; import org.joda.time.DateTime; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.connection.BitFieldSubCommands; import org.springframework.data.redis.core.RedisCallback; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.List; import java.util.Map; /** * @title: SignByRangeServiceImpl * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/19 12:27 */ @Slf4j @Service public class SignByRangeServiceImpl { @Autowired StringRedisTemplate stringRedisTemplate; /** * 根据区间的id 以及用户id 拼接key * * @param rangeId 区间ID 一般是指定活动的ID等 * @param userId 用户的ID * @return */ private String signKey(Integer rangeId, Integer userId) { StringBuilder builder = new StringBuilder("RangeId:Sign:"); builder.append(rangeId).append(":") .append(userId); return builder.toString(); } /** * 获取当前时间与起始时间的间隔天数 * * @param start 起始时间 * @return */ private int intervalTime(LocalDateTime start) { return (int) (LocalDateTime.now().toLocalDate().toEpochDay() - start.toLocalDate().toEpochDay()); } /** * 设置标记位 * 标记是否签到 * * @param key 签到的key * @param offset 偏移量 一般是指当前时间离起始时间(活动开始)的天数 * @param tag 是否签到 true:签到 false:未签到 * @return */ private Boolean setBit(String key, long offset, boolean tag) { return this.stringRedisTemplate.opsForValue().setBit(key, offset, tag); } /** * 统计计数 * * @param key 统计的key * @return */ private long bitCount(String key) { return stringRedisTemplate.execute((RedisCallback<Long>) redisConnection -> redisConnection.bitCount(key.getBytes())); } /** * 获取多字节位域 * * @param key 缓存的key * @param limit 获取多少 * @param offset 偏移量是多少 * @return */ private List<Long> bitfield(String key, int limit, long offset) { return this.stringRedisTemplate .opsForValue() .bitField(key, BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(limit)).valueAt(offset)); } /** * 判断是否签到 * * @param key 缓存的key * @param offest 偏移量 指当前时间距离起始时间的天数 * @return */ private Boolean container(String key, long offest) { return this.stringRedisTemplate.opsForValue().getBit(key, offest); } /** * 根据起始时间进行签到 * * @param rangeId * @param userId * @param start * @return */ public Boolean sign(Integer rangeId, Integer userId, LocalDateTime start) { int offset = intervalTime(start); String key = signKey(rangeId, userId); return setBit(key, offset, true); } /** * 根据偏移量签到 * * @param rangeId * @param userId * @param offset * @return */ public Boolean sign(Integer rangeId, Integer userId, long offset) { String key = signKey(rangeId, userId); return setBit(key, offset, true); } /** * 用户今天是否签到 * * @param userId * @return */ public Boolean checkSign(Integer rangeId, Integer userId, LocalDateTime start) { long offset = intervalTime(start); String key = this.signKey(rangeId, userId); return this.container(key, offset); } /** * 统计当前月份一共签到天数 * * @param userId */ public long countSigned(Integer rangeId, Integer userId) { String signKey = this.signKey(rangeId, userId); return this.bitCount(signKey); } public Map<String, Boolean> querySigned(Integer rangeId, Integer userId, LocalDateTime start) { int days = intervalTime(start); Map<String, Boolean> signedInMap = new HashMap<>(days); String signKey = this.signKey(rangeId, userId); List<Long> bitfield = this.bitfield(signKey, days + 1, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); for (int i = days; i >= 0; i--) { LocalDateTime localDateTime = start.plusDays(i); signedInMap.put(localDateTime.format(fmt), signFlag >> 1 << 1 != signFlag); signFlag >>= 1; } } return signedInMap; } /** * 查询用户当月连续签到次数 * * @param userId * @return */ public long queryContinuousSignCount(Integer rangeId, Integer userId, LocalDateTime start) { int signCount = 0; String signKey = this.signKey(rangeId, userId); int days = this.intervalTime(start); List<Long> bitfield = this.bitfield(signKey, days + 1, 0); if (!CollectionUtils.isEmpty(bitfield)) { long signFlag = bitfield.get(0) == null ? 0 : bitfield.get(0); DateTime dateTime = new DateTime(); // 连续不为0即为连续签到次数,当天未签到情况下 for (int i = 0; i < dateTime.getDayOfMonth(); i++) { if (signFlag >> 1 << 1 == signFlag) { if (i > 0) break; } else { signCount += 1; } signFlag >>= 1; } } return signCount; } }
测试工具类:
package com.ehang.redis.bitmap_sign_by_range; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Map; /** * @title: SignTest * @projectName ehang-spring-boot * @description: TODO * @date 2022/7/18 16:11 */ @SpringBootTest @Slf4j public class SignTest { @Autowired SignByRangeServiceImpl signByRangeService; @Test void test() { DateTimeFormatter isoDateTime = DateTimeFormatter.ISO_DATE_TIME; // 活动开始时间 LocalDateTime start = LocalDateTime.of(2022, 8, 1, 1, 0, 0); Integer rangeId = 1; Integer userId = 8899; log.info("签到开始时间: {}", start.format(isoDateTime)); log.info("活动ID: {} 用户ID: {}", rangeId, userId); // 手动指定偏移量签到 signByRangeService.sign(rangeId, userId, 0); // 判断是否签到 Boolean signed = signByRangeService.checkSign(rangeId, userId, start); log.info("今日是否签到: {}", signed ? "√" : "-"); // 签到 Boolean sign = signByRangeService.sign(rangeId, userId, start); log.info("签到操作之前的签到状态:{} (-:表示今日第一次签到,√:表示今天已经签到过了)", sign ? "√" : "-"); // 签到总数 long countSigned = signByRangeService.countSigned(rangeId, userId); log.info("总共签到: {} 天", countSigned); // 连续签到的次数 long continuousSignCount = signByRangeService.queryContinuousSignCount(rangeId, userId, start); log.info("连续签到: {} 天", continuousSignCount); // 签到的详情 Map<String, Boolean> stringBooleanMap = signByRangeService.querySigned(rangeId, userId, start); for (Map.Entry<String, Boolean> entry : stringBooleanMap.entrySet()) { log.info("签到详情> {} : {}", entry.getKey(), (entry.getValue() ? "√" : "-")); } } }
输出日志:
签到开始时间: 2022-08-01T01:00:00 活动ID: 1 用户ID: 8899 今日是否签到: √ 签到操作之前的签到状态:√ (-:表示今日第一次签到,√:表示今天已经签到过了) 总共签到: 3 天 连续签到: 2 天 签到详情> 2022-08-01 : √ 签到详情> 2022-08-04 : √ 签到详情> 2022-08-03 : √ 签到详情> 2022-08-02 : -
文章评论