签到系统的设计与实现

介绍

运营配置签到奖励规则,如“连续签到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());
    }
}

参考资料

Redis Bitmap 学习和使用

  • 4
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值