业务场景
- 用户每天可签到,签到1天送1积分,签到2天送2积分等。
- 如果连续签到中断,则重置计数,月初重置计数
- 用户可查看每月签到情况及首次签到时间
设计思路
对于用户签到的数据,用key/value 存储则当用户量大的时候内存开销会非常大。redis的bitmap(位图)则非常合适此场景。bitmap由一组bit组成,每个bit对于0和1。内存开销非常小且效率高。
如一个用户签到一个月的数据,则只需要占用4个字节即32个bit
key的设计及常用命令
每月需重置连续签到次数,则使用每个月存一条签到数据
key的格式为*user:sign:uid:yyyyMM
*
value为位图数据结构,最大为32个bit,0代表未签到,1代表已签到
用户id为19在202012月的key则为user:sign:19:202012
##用户2020 12月 1号签到 偏移量从0才是 所以第一位则是1号
setbit user:sign:19:202012 0 1
##查看用户12月1号是否签到
getbit user:sign:19:202012 0
##查看用户12月签到次数
bitcount user:sign:19:202012
# 获取12月份前12天的签到数据
bitfleld u:sign:1000:201902 get u12 0
# 获取12月份首次签到的日期
bitpos u:sign:1000:201902 1 # 返回的首次签到的偏移量,加上1即为当月的某一天
Java实现
Redis连接实现redisTemplate
public class RedisServiceImpl implements RedisService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public Boolean setBit(String key, Long offset, Boolean value) {
return redisTemplate.opsForValue().setBit(key, offset, value);
}
@Override
public Boolean getBit(String key, Long offset) {
return redisTemplate.opsForValue().getBit(key, offset);
}
@Override
public List<Long> bitfield(String key, int limit, int offset) {
return redisTemplate.execute((RedisCallback<List<Long>>) con -> con.bitField(key.getBytes(),
BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(limit)).valueAt(offset)
));
}
}
package com.cyf.service;
import java.time.LocalDate;
import java.util.Map;
/**
* 签到服务类
*
* @author 陈一锋
* @date 2020/12/13.
*/
public interface SignService {
/**
* 签到
*
* @param userId/
* @param date/
*/
void checkIn(Long userId, LocalDate date);
/**
* 检查是否签到
*
* @param userId /
* @param date /
* @return /
*/
Boolean isCheckIn(Long userId, LocalDate date);
/**
* 获取当月签到总数
*
* @param userId /
* @param date /
* @return /
*/
Long getSignCount(Long userId, LocalDate date);
/**
* 获取当月连续签到总数
*
* @param userId /
* @param date /
* @return /
*/
long getContinuousSignCount(Long userId, LocalDate date);
/**
* 获取用户签到日历
*
* @param userId /
* @param date /
* @return /
*/
Map<String, Boolean> getSignInfo(Long userId, LocalDate date);
/**
* 获取当月首次签到的日期
*
* @param userId /
* @param date /
* @return /
*/
LocalDate getFirstSignDate(Long userId, LocalDate date);
}
实现类
package com.cyf.service.impl;
import cn.hutool.core.collection.CollUtil;
import com.cyf.service.RedisService;
import com.cyf.service.SignService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
/**
* 签到实现:
* 需求:1.用户每天可签到一次
* 2.可获取用户当月连续签到多少天
* 3.可以检查用户当天是否签到
* 4.获取当月的签到情况
* 5.获取当月签到次数
*
* @author 陈一锋
* @date 2020/12/13.
*/
@Service
@Slf4j
public class SignServiceImpl implements SignService {
@Autowired
private RedisService redisService;
@Autowired
private RedisTemplate redisTemplate;
@Override
public void checkIn(Long userId, LocalDate date) {
long offset = getOffset(date);
redisService.setBit(getKey(userId, date), offset, true);
}
@Override
public Boolean isCheckIn(Long userId, LocalDate date) {
Long offset = getOffset(date);
String key = getKey(userId, date);
return redisService.getBit(key, offset);
}
@Override
public Long getSignCount(Long userId, LocalDate date) {
Long result = (Long) redisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount(getKey(userId, date).getBytes()));
return Optional.ofNullable(result).orElse(0L);
}
/**
* 查询今天之前最近的连续签到的天数
*
* @param userId /
* @param date /
* @return 连续签到天数
*/
@Override
public long getContinuousSignCount(Long userId, LocalDate date) {
int signCount = 0;
List<Long> list = redisService.bitfield(getKey(userId, date), date.getDayOfMonth(), 0);
if (CollUtil.isNotEmpty(list)) {
// 取低位连续不为0的个数即为连续签到次数,需考虑当天尚未签到的情况
Long v = list.get(0) == null ? 0L : list.get(0);
for (int i = 0; i < date.getDayOfMonth(); i++) {
//先右移再左移 如果相等则低位为0
if (v >> 1 << 1 == v) {
// 低位为0且非当天 说明连续签到中断了
if (i > 0) {
break;
}
} else {
signCount += 1;
}
v >>= 1;
}
}
return signCount;
}
@Override
public Map<String, Boolean> getSignInfo(Long userId, LocalDate date) {
Map<String, Boolean> signMap = new TreeMap<>();
List<Long> list = redisService.bitfield(getKey(userId, date), date.lengthOfMonth(), 0);
if (CollUtil.isNotEmpty(list)) {
long v = list.get(0) == null ? 0 : list.get(0);
for (int i = date.lengthOfMonth(); i > 0; i--) {
LocalDate day = date.withDayOfMonth(i);
signMap.put(formatDate(day, "yyyy-MM-dd"), v >> 1 << 1 != v);
v >>= 1;
}
}
return signMap;
}
@Override
public LocalDate getFirstSignDate(Long userId, LocalDate date) {
Long result = (Long) redisTemplate.execute((RedisCallback<Long>) con -> con.bitPos(getKey(userId, date).getBytes(), true));
return result < 0 ? null : date.withDayOfMonth((int) (result + 1L));
}
private static String getKey(Long userId, LocalDate date) {
return String.format("user:sign:%d:%s", userId, formatDate(date));
}
private static Long getOffset(LocalDate date) {
int i = date.getDayOfMonth() - 1;
return Long.parseLong(String.valueOf(i));
}
private static String formatDate(LocalDate date) {
return formatDate(date, "yyyyMM");
}
private static String formatDate(LocalDate date, String pattern) {
return date.format(DateTimeFormatter.ofPattern(pattern));
}
}