Redis位图实现用户签到功能
Posted androidstarjack
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Redis位图实现用户签到功能相关的知识,希望对你有一定的参考价值。
点击上方蓝色“终端研发部”,选择“设为星标”
学最好的别人,做最好的我们
背景
会员积分体系,实现前端按照日历进行签到。连续签到的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: -
BAT等大厂Java面试经验总结 想获取 Java大厂面试题学习资料扫下方二维码回复「BAT」就好了回复 【加群】获取github掘金交流群回复 【电子书】获取2020电子书教程回复 【C】获取全套C语言学习知识手册回复 【Java】获取java相关的视频教程和资料回复 【爬虫】获取SpringCloud相关多的学习资料回复 【Python】即可获得Python基础到进阶的学习教程回复 【idea破解】即可获得intellij idea相关的破解教程关注我gitHub掘金,每天发掘一篇好项目,学习技术不迷路!
回复 【idea激活】即可获得idea的激活方式
回复 【Java】获取java相关的视频教程和资料
回复 【SpringCloud】获取SpringCloud相关多的学习资料
回复 【python】获取全套0基础Python知识手册
回复 【2020】获取2020java相关面试题教程
回复 【加群】即可加入终端研发部相关的技术交流群
阅读更多
为什么HTTPS是安全的
因为BitMap,白白搭进去8台服务器...
《某厂内部SQL大全 》.PDF
字节跳动一面:i++ 是线程安全的吗?
大家好,欢迎加我微信,很高兴认识你!
在华为鸿蒙 OS 上尝鲜,我的第一个“hello world”,起飞!
相信自己,没有做不到的,只有想不到的在这里获得的不仅仅是技术!
就给个“在看”
以上是关于Redis位图实现用户签到功能的主要内容,如果未能解决你的问题,请参考以下文章