介绍
运营配置签到奖励规则,如“连续签到N天送积分”、“累计签到N天送优惠券”,“指定日期签到送抽奖次数”等,对达成签到条件的用户发放奖励
技术要点
如何存储签到信息
常规的业务数据我们都存储在MySQL上。所以签到日志能放在MySQL上吗?
假设我们现在有100万日活用户,其中有20万会进行签到。如果直接放在MySQL,那么一个月就会新增600万的签到数据,一年新增7300万。而其中我们关心的数据一直只有20W条。
显然,这样的空间利用率很低。
那么,我们可以对每个签到用户只存储一条最新的签到记录吗?不记录签到历史?
我觉得不行。虽然这样空间利用率很高,也可以满足基础功能的数据需求,但是一般情况都会有用户查询签到记录的需求,完全不记录签到历史数据的话显然没法满足,而且也不利于后期扩展。起码要保留一个周期内的签到数据。
那么确定了只记录一个周期内的签到记录,我们如何继续提高空间利用率呢?比如说我们可以把历史签到数据存储在MongoDB中,MongoDb的空间效率要把MySQL高得多。我在项目中选择的是使用Redis中的Bitmap来存储签到信息,用MongoDB来保存签到历史数据。
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1
的设置,表示某个元素的值或者状态,时间复杂度为O(1)。由于bit是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景。
比如说我们可以用一个31位的Bitmap来记录用户一个月的签到数据,bit位为0时表示未签到,为1时表示已签到。当用户1号签到后把第1位bit标记为1,2号签到后把第2位bit标记为1。
常见的几个问题
- 如何计算连续签到天数
- 如何计算累计签到天数
- 如何判断指定日期是否签到
- 如何支持补签
代码参考
这是一个没有签到刷新周期的签到服务类示例
package com.example.service;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 用户签到服务
* @author ALVIN
*/
public interface UserSignInService {
/**
* 检查用户是否签到
*
* @param userId 用户ID
* @param date 日期
* @return 当前的签到状态
*/
boolean checkSign(long userId, LocalDate date);
/**
* 获取当天连续签到次数
*
* @param userId 用户ID
* @return 当月连续签到次数
*/
long getContinuousSignCount(long userId);
/**
* 获取连续签到次数
*
* @param userId 用户ID
* @param date 日期
* @return 当月连续签到次数
*/
long getContinuousSignCount(long userId, LocalDate date);
/**
* 获取当月首次签到日期
*
* @param userId 用户ID
* @param date 日期
* @return 首次签到日期
*/
LocalDate getFirstSignDate(long userId, LocalDate date);
/**
* 获取当月签到情况
*
* @param userId 用户ID
* @param date 日期
* @return Key为签到日期,Value为签到状态的Map
*/
Map<String, Boolean> getSignInfo(long userId, LocalDate date);
/**
* 签到
* @param userId 用户ID
* @param date 签到日期
*/
void signIn(long userId, LocalDate date);
/**
* 取消签到
* @param userId 用户ID
* @param nowDate 日期
*/
void cancelSignIn(Long userId, LocalDate nowDate);
/**
* 订阅签到提醒
* @param userId 用户ID
*/
void subscribeSignInTip(long userId);
/**
* 取消订阅签到提醒
* @param userId 用户ID
*/
void unsubscribeSignInTip(long userId);
/**
* 是否已订阅签到提醒
* @param userId 用户ID
* @return 是否
*/
boolean isSubscribed(long userId);
/**
* 获取未签到的订阅用户ID
* @return 用户ID列表
*/
List<Long> getUnSignInSubscribers();
}
package com.example.service.impl;
import com.example.service.UserSignInService;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* @author ALVIN
*/
@Service
public class UserSignInServiceImpl implements UserSignInService {
/**
* 用户月签到数据KEY模板
*/
private final static String MONTH_SIGN_IN_KEY_TEMPLATE = "u:sign:%d:%s";
/**
* 用户指定日期连续签到天数KEY模板
*/
private final static String CONTINUOUS_SIGN_IN_DAY_COUNT_KEY_TEMPLATE = "u:sign:%d:continuous:count:%s";
/**
* 保存当前连续签到排行榜
*/
private final static String CURR_SIGN_IN_RANKING_LIST_KEY = "u:sign:current:continuous:count";
/**
* 保存历史连续签到排行榜
*/
private final static String HIST_SIGN_IN_RANKING_LIST_KEY = "u:sign:history:max:continuous:count";
/**
* 订阅用户ID集合KEY
*/
private final static String SUBSCRIBERS_SET_KEY = "u:sign:subscription:users";
private final static DateTimeFormatter YYYYMM_FORMATTER = DateTimeFormatter.ofPattern("yyyyMM");
private final static DateTimeFormatter YYYYMMDD_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
public boolean checkSign(long userId, LocalDate date) {
int offset = date.getDayOfMonth() - 1;
String monthSignInKey = String.format(MONTH_SIGN_IN_KEY_TEMPLATE, userId, date.format(YYYYMM_FORMATTER));
Boolean isSignIn = redisTemplate.opsForValue().getBit(monthSignInKey, offset);
return isSignIn != null && isSignIn;
}
@Override
public long getContinuousSignCount(long userId) {
return getContinuousSignCount(userId, LocalDate.now());
}
@Override
public long getContinuousSignCount(long userId, LocalDate date) {
Long signInDays;
LocalDate targetDate = date;
if (!checkSign(userId, date)) {
targetDate = targetDate.minusDays(1);
}
String continuousSignInDayCountKey = String.format(CONTINUOUS_SIGN_IN_DAY_COUNT_KEY_TEMPLATE, userId, targetDate.format(YYYYMMDD_FORMATTER));
signInDays = (Long) redisTemplate.opsForValue().get(continuousSignInDayCountKey);
return signInDays == null ? 0 : signInDays;
}
@Override
public LocalDate getFirstSignDate(long userId, LocalDate date) {
return null;
}
@Override
public Map<String, Boolean> getSignInfo(long userId, LocalDate date) {
return null;
}
@Override
public void signIn(long userId, LocalDate date) {
// todo 改为用lua脚本,支持原子性
LocalDate previousDay = date.minusDays(1);
String monthSignInKey = String.format(MONTH_SIGN_IN_KEY_TEMPLATE, userId, date.format(YYYYMM_FORMATTER));
String continuousSignInDayCountAtPrevDayKey = String.format(CONTINUOUS_SIGN_IN_DAY_COUNT_KEY_TEMPLATE, userId, previousDay.format(YYYYMMDD_FORMATTER));
String continuousSignInDayCountAtCurrDayKey = String.format(CONTINUOUS_SIGN_IN_DAY_COUNT_KEY_TEMPLATE, userId, date.format(YYYYMMDD_FORMATTER));
int offset = date.getDayOfMonth() - 1;
redisTemplate.opsForValue().setBit(monthSignInKey, offset, true);
Long prevSignInDays = (Long) redisTemplate.opsForValue().get(continuousSignInDayCountAtPrevDayKey);
long nowSignInDays = 1;
if (prevSignInDays != null) {
nowSignInDays += prevSignInDays;
}
redisTemplate.opsForValue().set(continuousSignInDayCountAtCurrDayKey, nowSignInDays);
redisTemplate.opsForZSet().add(CURR_SIGN_IN_RANKING_LIST_KEY, userId, nowSignInDays);
redisTemplate.opsForZSet().add(HIST_SIGN_IN_RANKING_LIST_KEY, userId, nowSignInDays);
}
@Override
public void cancelSignIn(Long userId, LocalDate date) {
String monthSignInKey = String.format(MONTH_SIGN_IN_KEY_TEMPLATE, userId, date.format(YYYYMM_FORMATTER));
String continuousSignInDayCountAtCurrDayKey = String.format(CONTINUOUS_SIGN_IN_DAY_COUNT_KEY_TEMPLATE, userId, date.format(YYYYMMDD_FORMATTER));
int offset = date.getDayOfMonth() - 1;
redisTemplate.opsForValue().setBit(monthSignInKey, offset, false);
redisTemplate.delete(continuousSignInDayCountAtCurrDayKey);
}
@Override
public void subscribeSignInTip(long userId) {
redisTemplate.opsForSet().add(SUBSCRIBERS_SET_KEY, userId);
}
@Override
public void unsubscribeSignInTip(long userId) {
redisTemplate.opsForSet().remove(SUBSCRIBERS_SET_KEY, userId);
}
@Override
public boolean isSubscribed(long userId) {
Boolean isSub = redisTemplate.opsForSet().isMember(SUBSCRIBERS_SET_KEY, userId);
return isSub != null && isSub;
}
@Override
public List<Long> getUnSignInSubscribers() {
LocalDate nowDate = LocalDate.now();
Set<Object> userIds = redisTemplate.opsForSet().members(SUBSCRIBERS_SET_KEY);
if (userIds == null) {
return new ArrayList<>();
}
return userIds.stream().map(e -> (Long) e).filter(e -> !checkSign(e, nowDate)).collect(Collectors.toList());
}
}