用户签到使用Redis位图实现(以月为周期获取无限连续签到次数)
以下代码复制可以直接使用,引用了一些大佬的文章:(https://www.cnblogs.com/liujiduo/p/10396020.html) 还有配置jedis的博客找不到了- -,请看到的大佬原谅。
背景
会员积分体系,实现前端按照日历进行签到。连续签到的7天及7天的倍数额外增加积分。可以获取之前连续签到的次数(理论上没有上限)
设计思路
如果存入到数据库中数据量巨大,且充斥很多无意义数据。了解到使用Redis的位图适合于大量存储布尔型的值。对于用户签到数据,如果每条数据都用K/V的方式存储,当用户量大的时候内存开销是非常大的。而位图(BitMap)是由一组bit位组成的,每个bit位对应0和1两个状态,虽然内部还是采用String类型存储,但Redis提供了一些指令用于直接操作位图,可以把它看作是一个bit数组,数组的下标就是偏移量。它的优点是内存开销小、效率高且操作简单,很适合用于签到这类场景。 按用户每月存一条签到数据(也可以每年存一条数据)。Key的格式为u:sign:uid:yyyyMM,Value则采用长度为4个字节(32位)的位图(最大月份只有31天)。位图的每一位代表一天的签到,1表示已签,0表示未签。 例如u:sign:1000:201902表示ID=1000的用户在2019年2月的签到记录。
实现难点
使用递归去获取最大的连续签到次数,在递归到合适的值时,在从最里面的递归方法中跳出。使用抛异常的方式去返回值,在调用时使用try catch去捕获最里面递归抛出的值。
// 当代码中使用递归时碰到了想中途退出递归,但是代码继续执行的情况,抛出异常上层捕获,避免跳出递归获取的值不正确问题 try { getSignCount(aid, signCount, offset, count, days); } catch (Exception e) { signCount = Integer.valueOf(e.getMessage()); }
Redis的无符号数最大只能取63位,也就是一次最多只能取63天的签到数据,例如:
List<Long> list = jedis.bitfield(buildSignKey(aid, date), "GET", "u63", "0");
超出长度就会报错:ERR Invalid bitfield type. Use something like i16 u8. Note that u64 is not supported but i64 is. 递归使用上个月的拼接的key去获取上个月最大的天数,不断循环去获取最大的连续不中断的签到次数。
# 用户2月17号签到 SETBIT u:sign:1000:201902 16 1 # 偏移量是从0开始,所以要把17减1 # 检查2月17号是否签到 GETBIT u:sign:1000:201902 16 # 偏移量是从0开始,所以要把17减1 # 统计2月份的签到次数 BITCOUNT u:sign:1000:201902 # 获取2月份前28天的签到数据 BITFIELD u:sign:1000:201902 get u28 0 # 获取2月份首次签到的日期 BITPOS u:sign:1000:201902 1 # 返回的首次签到的偏移量,加上1即为当月的某一天
实例代码
maven依赖
<dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>4.5.2</version> </dependency> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>3.1.0</version> </dependency>
public class JedisUtil { //Redis服务器IP private static String ADDR = "localhost"; //Redis的端口号 private static Integer PORT = 6379; //访问密码 private static String AUTH = "123"; //可用连接实例的最大数目,默认为8; //如果赋值为-1,则表示不限制,如果pool已经分配了maxActive个jedis实例,则此时pool的状态为exhausted(耗尽) private static Integer MAX_TOTAL = 1024; //控制一个pool最多有多少个状态为idle(空闲)的jedis实例,默认值是8 private static Integer MAX_IDLE = 200; //等待可用连接的最大时间,单位是毫秒,默认值为-1,表示永不超时。 //如果超过等待时间,则直接抛出JedisConnectionException private static Integer MAX_WAIT_MILLIS = 10000; private static Integer TIMEOUT = 10000; //在borrow(用)一个jedis实例时,是否提前进行validate(验证)操作; //如果为true,则得到的jedis实例均是可用的 private static Boolean TEST_ON_BORROW = true; private static JedisPool jedisPool = null; /** * 静态块,初始化Redis连接池 */ static { try { JedisPoolConfig config = new JedisPoolConfig(); /*注意: 在高版本的jedis jar包,比如本版本2.9.0,JedisPoolConfig没有setMaxActive和setMaxWait属性了 这是因为高版本中官方废弃了此方法,用以下两个属性替换。 maxActive ==> maxTotal maxWait==> maxWaitMillis */ config.setMaxTotal(MAX_TOTAL); config.setMaxIdle(MAX_IDLE); config.setMaxWaitMillis(MAX_WAIT_MILLIS); config.setTestOnBorrow(TEST_ON_BORROW); jedisPool = new JedisPool(config,ADDR,PORT,TIMEOUT,AUTH); } catch (Exception e) { e.printStackTrace(); } } /** * 获取Jedis实例 */ public synchronized static Jedis getJedis(){ try { if(jedisPool != null){ return jedisPool.getResource(); }else{ return null; } } catch (Exception e) { e.printStackTrace(); return null; } } public static void returnResource(final Jedis jedis){ //方法参数被声明为final,表示它是只读的。 if(jedis!=null){ // jedisPool.returnResource(jedis); //jedis.close()取代jedisPool.returnResource(jedis)方法将3.0版本开始 jedis.close(); } } }
/** * @Date: Created in 13:55 2020/2/26 * @Description: 基于Redis位图的用户签到功能实现类 * * <p> * * 实现功能: * * 1. 用户签到 * * 2. 检查用户是否签到 * * 3. 获取当月签到次数 * * 4. 获取当月连续签到次数 * * 5. 获取当月首次签到日期 * * 6. 获取当月签到情况 */ @Service public class SignInServiceIml implements SignInService { private Jedis jedis = JedisUtil.getJedis(); /** * 用户签到 * * @param aid 用户ID * @param date 日期 * @return 之前的签到状态 */ @Override public boolean doSign(int aid, LocalDate date) { int offset = date.getDayOfMonth() - 1; return jedis.setbit(buildSignKey(aid, date), offset, true); } /** * 检查用户是否签到 * * @param aid 用户ID * @param date 日期 * @return 当前的签到状态 */ @Override public boolean checkSign(int aid, LocalDate date) { int offset = date.getDayOfMonth() - 1; return jedis.getbit(buildSignKey(aid, date), offset); } /** * 获取用户当月签到次数 * * @param aid 用户ID * @param date 日期 * @return 当月的签到次数 */ @Override public long getSignCount(int aid, LocalDate date) { return jedis.bitcount(buildSignKey(aid, date)); } /** * 获取无限连续签到次数 * * @param aid 用户ID * @param date 日期 * @return 无限连续签到次数 */ @Override public long getContinuousSignCount(int aid, LocalDate date) { int signCount = 0; String type = String.format("u%d", date.getDayOfMonth()); List<Long> list = jedis.bitfield(buildSignKey(aid, date), "GET", type, "0"); if (CollUtil.isNotEmpty(list)) { // 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况 long v = list.get(0) == null ? 0 : list.get(0); for (int i = 0; i < date.getDayOfMonth(); i++) { if (v >> 1 << 1 == v) { // 低位为0且非当天说明连续签到中断了 if (i > 0) { break; } } else { signCount += 1; } v >>= 1; } } int offset = -1; int count = 1; int daysOfMonth = getDaysOfMonth(DateUtil.offsetMonth(new Date(), offset)); int days = date.getDayOfMonth() + daysOfMonth; if (signCount == date.getDayOfMonth()) { // 当代码中使用递归时碰到了想中途退出递归,但是代码继续执行的情况,抛出异常上层捕获,避免跳出递归获取的值不正确问题 try { getSignCount(aid, signCount, offset, count, days); } catch (Exception e) { signCount = Integer.valueOf(e.getMessage()); } } return signCount; } private int getSignCount(int aid, int signCount, int offset, int count, int days) throws Exception { // 上上个月 DateTime dateTime1 = DateUtil.offsetMonth(new Date(), offset * count); // 获取上上个月的天数 String lastDays = String.format("u%d", getDaysOfMonth(dateTime1)); List<Long> lastList = jedis.bitfield(buildSignKey(aid, dateToLocalDate(dateTime1)), "GET", lastDays, "0"); if (CollUtil.isNotEmpty(lastList)) { // 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况 long v = lastList.get(0) == null ? 0 : lastList.get(0); for (int i = 0; i < getDaysOfMonth(dateTime1); i++) { if (v >> 1 << 1 == v) { // 低位为0且非当天说明连续签到中断了 if (i > 0) { break; } } else { signCount += 1; } v >>= 1; } count += 1; } // 如果连续签到次数小于了当前月天数+多个整月天数,证明连续签到中断 if (signCount < days) { throw new Exception(String.valueOf(signCount)); } // 当前月总的天数+上个月的天数 days = days + getDaysOfMonth(DateUtil.offsetMonth(new Date(), offset * (count - 1))); getSignCount(aid, signCount, offset, count, days); return signCount; } /** * 获取当月首次签到日期 * * @param aid 用户ID * @param date 日期 * @return 首次签到日期 */ @Override public LocalDate getFirstSignDate(int aid, LocalDate date) { long pos = jedis.bitpos(buildSignKey(aid, date), true); return pos < 0 ? null : date.withDayOfMonth((int) (pos + 1)); } /** * 获取当月签到情况 * * @param aid 用户ID * @param date 日期 * @return Key为签到日期,Value为签到状态的Map */ @Override public Map<String, Boolean> getSignInfo(int aid, LocalDate date) { Map<String, Boolean> signMap = new HashMap<>(date.getDayOfMonth()); String type = String.format("u%d", date.lengthOfMonth()); List<Long> list = jedis.bitfield(buildSignKey(aid, date), "GET", type, "0"); if (CollUtil.isNotEmpty(list)) { // 由低位到高位,为0表示未签,为1表示已签 long v = list.get(0) == null ? 0 : list.get(0); for (int i = date.lengthOfMonth(); i > 0; i--) { LocalDate d = date.withDayOfMonth(i); signMap.put(formatDate(d, "yyyy-MM-dd"), v >> 1 << 1 != v); v >>= 1; } } return signMap; } /** * 构建指定类型的Redis的key:u:sign:10000:202001 */ private static String buildSignKey(int aid, LocalDate date) { return String.format("u:sign:%d:%s", aid, formatDate(date)); } /** * 获取Date类型的当月的天数 */ private static int getDaysOfMonth(Date date) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); return calendar.getActualMaximum(Calendar.DAY_OF_MONTH); } /** * 固定202001格式 */ private static String formatDate(LocalDate date) { return formatDate(date, "yyyyMM"); } /** * LocalDate按照指定格式进行转换字符串 */ private static String formatDate(LocalDate date, String pattern) { return date.format(DateTimeFormatter.ofPattern(pattern)); } /** * Date类型转换成LocalDate */ private static LocalDate dateToLocalDate(Date date) { Instant instant = date.toInstant(); ZoneId zoneId = ZoneId.systemDefault(); LocalDateTime localDateTime = instant.atZone(zoneId).toLocalDateTime(); return LocalDate.from(localDateTime); } public static void main(String[] args) { SignInServiceIml demo = new SignInServiceIml(); LocalDate today = LocalDate.now(); // todo 测试连续签到,循环添加三个月签到记录 再去查询 // DateTime dateTime1 = DateUtil.offsetDay(new Date(), -90); // LocalDate localDate = dateToLocalDate(dateTime1); // // for (int i = 0; i < localDate.getDayOfMonth(); i++) { // DateTime dateTime2 = DateUtil.offsetDay(new Date(), -i-90); // LocalDate localDate1 = dateToLocalDate(dateTime2); // // boolean signed = demo.doSign(1000, localDate1); // if (signed) { // System.out.println("您已签到:" + formatDate(localDate1, "yyyy-MM-dd")); // } else { // System.out.println("签到完成:" + formatDate(localDate1, "yyyy-MM-dd")); // } // } { // doSign boolean signed = demo.doSign(1000, today); if (signed) { System.out.println("您已签到:" + formatDate(today, "yyyy-MM-dd")); } else { System.out.println("签到完成:" + formatDate(today, "yyyy-MM-dd")); } } { // checkSign boolean signed = demo.checkSign(1000, today); if (signed) { System.out.println("您已签到:" + formatDate(today, "yyyy-MM-dd")); } else { System.out.println("尚未签到:" + formatDate(today, "yyyy-MM-dd")); } } { // getSignCount long count = demo.getSignCount(1000, today); System.out.println("本月签到次数:" + count); } { // getContinuousSignCount long count = demo.getContinuousSignCount(1000, today); System.out.println("无限签到次数:" + count); } { // getFirstSignDate LocalDate date = demo.getFirstSignDate(1000, today); System.out.println("本月首次签到:" + formatDate(date, "yyyy-MM-dd")); } { // getSignInfo System.out.println("当月签到情况:"); Map<String, Boolean> signInfo = new TreeMap<>(demo.getSignInfo(1000, today)); for (Map.Entry<String, Boolean> entry : signInfo.entrySet()) { System.out.println(entry.getKey() + ": " + (entry.getValue() ? "√" : "-")); } } }
public interface SignInService { boolean doSign(int aid, LocalDate date); boolean checkSign(int aid, LocalDate date); long getSignCount(int aid, LocalDate date); long getContinuousSignCount(int aid, LocalDate date); LocalDate getFirstSignDate(int aid, LocalDate date); Map<String, Boolean> getSignInfo(int aid, LocalDate date); }
运行结果
您已签到:2020-03-01 您已签到:2020-03-01 本月签到次数:1 无限签到次数:122 本月首次签到:2020-03-01 当月签到情况: 2020-03-01: √ 2020-03-02: - 2020-03-03: - 2020-03-04: - 2020-03-05: - 2020-03-06: - 2020-03-07: - 2020-03-08: - 2020-03-09: - 2020-03-10: - 2020-03-11: - 2020-03-12: - 2020-03-13: - 2020-03-14: - 2020-03-15: - 2020-03-16: - 2020-03-17: - 2020-03-18: - 2020-03-19: - 2020-03-20: - 2020-03-21: - 2020-03-22: - 2020-03-23: - 2020-03-24: - 2020-03-25: - 2020-03-26: - 2020-03-27: - 2020-03-28: - 2020-03-29: - 2020-03-30: - 2020-03-31: -