业务场景

  1. 用户每天可签到,签到1天送1积分,签到2天送2积分等。
  2. 如果连续签到中断,则重置计数,月初重置计数
  3. 用户可查看每月签到情况及首次签到时间

设计思路

对于用户签到的数据,用key/value 存储则当用户量大的时候内存开销会非常大。redis的bitmap(位图)则非常合适此场景。bitmap由一组bit组成,每个bit对于0和1。内存开销非常小且效率高。


如一个用户签到一个月的数据,则只需要占用4个字节即32个bit

https://s3-us-west-2.amazonaws.com/secure.notion-static.com/f67671bd-2f21-4328-ac86-11ed3e97b5af/Untitled.png

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));
    }
}