用户签到使用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去获取上个月最大的天数,不断循环去获取最大的连续不中断的签到次数。

# 用户217号签到
SETBIT u:sign:1000:201902 16 1 # 偏移量是从0开始,所以要把171

# 检查217号是否签到
GETBIT u:sign:1000:201902 16 # 偏移量是从0开始,所以要把171

# 统计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: -
  • 1
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
使用Redis实现签到功能,可以考虑使用Redis中的Set和Sorted Set数据结构。 首先,我们可以使用Set来存储用户每日签到的记录,每个用户对应一个Set。在签到时,只需要将当前日期作为元素加入到该用户的Set中即可。 接下来,我们可以使用Sorted Set来记录用户连续签到天数。每个用户对应一个Sorted Set,键名可以以“user:连续签到天数”来命名。在用户签到时,我们可以使用Redis的INCRBY命令来将该用户连续签到天数加1,并将该用户当前的连续签到天数作为Sorted Set中该用户的分值。 下面是一个使用Redis实现签到功能的示例代码: ```python import redis from datetime import datetime, timedelta # 连接Redis redis_client = redis.Redis(host='localhost', port=6379, db=0) # 用户签到函数 def checkin(user_id): # 获取当前日期 today = datetime.now().strftime('%Y-%m-%d') # 将用户签到记录存入Set中 redis_client.sadd(f'user:{user_id}:checkin', today) # 获取用户连续签到天数 continuous_days = redis_client.zscore(f'user:{user_id}:continuous_days', user_id) # 如果用户从未签到过,则将其连续签到天数初始化为1 if not continuous_days: redis_client.zadd(f'user:{user_id}:continuous_days', {user_id: 1}) else: # 获取昨天的日期 yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d') # 如果用户昨天已经签到,则将其连续签到天数加1 if redis_client.sismember(f'user:{user_id}:checkin', yesterday): new_continuous_days = int(continuous_days) + 1 redis_client.zadd(f'user:{user_id}:continuous_days', {user_id: new_continuous_days}) else: # 如果用户昨天未签到,则将其连续签到天数重置为1 redis_client.zadd(f'user:{user_id}:continuous_days', {user_id: 1}) ``` 在上面的示例代码中,我们通过`sadd`命令将用户签到记录存入Set中,通过`zscore`命令获取用户连续签到天数,通过`zadd`命令将用户连续签到天数更新到Sorted Set中。同时,为了方便判断用户连续签到情况,我们还使用`sismember`命令判断用户昨天是否已经签到过。 需要注意的是,上述示例代码仅为演示使用,实际使用时还需要考虑并发情况下的数据安全性问题。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值