Redis计数器统计小程序用户停留时长

业务需求

统计小程序的用户停留时长
不需要实时统计,所以按照天为维度
使用Redis的hash形式存并使用计数器累加时长,凌晨定时持久化前一天的数据到DB
注:一些其它统计也可以使用此种方式来
使用Redis实现的优点,速度快,减少数据库压力,使用计数器特性已经对数据做了累加。利用Redis有序集合可以达到分页处理的效果。

表设计

CREATE TABLE user_stand_info (
id BIGINT ( 20 ) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT ‘ID’,
user_id VARCHAR ( 64 ) NOT NULL COMMENT ‘用户id’,
stand_count_total BIGINT ( 20 ) NOT NULL COMMENT ‘今日统计总时长(单位秒)’,
stand_count_date BIGINT ( 20 ) NOT NULL COMMENT ‘统计日期’,
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT ‘新建时间’,
PRIMARY KEY ( id ),
UNIQUE KEY uniq_idx ( stand_count_date, user_id ) USING BTREE
) ENGINE = INNODB AUTO_INCREMENT = 1
DEFAULT CHARSET = utf8mb4 COMMENT = ‘用户小程序停留时长记录表’;

接口实现说明

上报用户停留接口入参
userId:人员id
Long standStart:停留开始时间戳
Long standEnd:停留结束时间戳

逻辑图示
Redis计数器统计小程序用户停留时长_第1张图片

Redis 存储实现

使用Hash形式存储,这里还用到了Hash形式的计数器
外层Key: 自定义前缀+yyyyMMdd
Hash内层Key:用户id
value:累加增量的时长毫秒数
每一天的访问用户均存储在这个Hash中,但是Hash缺点不能分页提取
为了防止数据量大一次获取所有hash造成redis锁死。影响其它功能
所以同时存储有序集合,以便做分页提取

2.同时存储有序集合 sorted set
使用命令 zadd
key:自定义固定字符串常量key
value:userId
排序值sore :默认0即可
注:使用有序集合而非集合的好处是
根据相同的value(这里就是userId),集合会执行更新。避免相同用户重复记录多条

接口代码

上报到Redis实现

/**
 * 用户小程序停留时长记录接口
 */
public interface UserStandPutService {

    /**
     * 上报用户停留时间至 redis
     *
     * @param userId     人员id
     * @param standStart 开始时间戳
     * @param standEnd   结束时间戳
     */
    void putUserStand(String userId, Long standStart, Long standEnd);
    }
import com.boot.redis.constant.DemoConstant;
import com.boot.redis.jredis.RedisCache;
import com.boot.redis.service.UserStandPutService;
import com.boot.redis.util.DemoDateUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
/**
 * 用户小程序停留时长记录接口 实现
 */
@Service
public class UserStandPutServiceImpl implements UserStandPutService {
    private Logger LOGGER = LoggerFactory.getLogger(UserStandPutServiceImpl.class);
    @Autowired
    private RedisCache redisCache;

    /**
     * 上报用户停留时间至 redis
     *
     * @param userId     人员id
     * @param standStart 开始时间戳
     * @param standEnd   结束时间戳
     */
    @Override
    public void putUserStand(String userId, Long standStart, Long standEnd) {
        //处理时间
        Date startDateTime = DemoDateUtil.parseDateTime(standStart);
        Date endDateTime = DemoDateUtil.parseDateTime(standEnd);
        //判断是否是同一天
        boolean isSameDate = DemoDateUtil.isSameDate(startDateTime, endDateTime);
        if (isSameDate) {
            LOGGER.info("用户停留时间上报起止时间是同一天 userId={}", userId);
            this.sameDateSave(startDateTime, endDateTime, userId);
        } else {
            this.doSaveNotSameDay(startDateTime, endDateTime, userId);
            LOGGER.info("用户停留时间上报起止时间是不是同一天 userId={}", userId);
        }
    }


    /**
     * 时间在同一天
     *
     * @param startDateTime
     * @param endDateTime
     */
    private void sameDateSave(Date startDateTime, Date endDateTime, String userId) {
        //使用截止时间格式化为 yyyyMMdd
        //注:因为可能存在跨天所以使用 截止时间
        String reqDate = DemoDateUtil.formatReqDate(endDateTime);
        LOGGER.info("当前统计时间区间 reqDate => {}", reqDate);
        //计算间隔秒数
        long second = DemoDateUtil.betweenMs(startDateTime, endDateTime) / 1000;
        LOGGER.info("计算间隔秒数 second => {}", second);
        //hash缓存key
        String hashKey = DemoConstant.USER_STAND_HASH_KEY.concat(reqDate);
        LOGGER.debug("用户小程序停留时长统计Hash Key => {}", hashKey);
        redisCache.hashIncrBy(hashKey, userId, second);

        //list缓存key
        String listKey = DemoConstant.USER_STAND_LIST_KEY.concat(reqDate);
        LOGGER.debug("用户小程序停留时长统计List Key => {}", listKey);
        redisCache.zAddByScore(listKey, userId, 0);
    }

    /**
     * 时间非同一天
     *
     * @param startDateTime
     * @param endDateTime
     */
    private void doSaveNotSameDay(Date startDateTime, Date endDateTime, String userId) {
        // 计算起点的结束时间 当天的 23:59:59
        Date startDayEndDate = DemoDateUtil.endOfDay(startDateTime);
        // 保存前一天的停留时长
        this.sameDateSave(startDayEndDate, endDateTime, userId);
        // 保存当天的停留时长
        this.sameDateSave(startDateTime, startDayEndDate, userId);
    }
}

定时持久化到DB

import com.boot.redis.constant.DemoConstant;
import com.boot.redis.persistence.entity.UserStandInfo;
import com.boot.redis.jredis.RedisCache;
import com.boot.redis.service.UserStandProcessService;
import com.boot.redis.util.DemoDateUtil;
import com.boot.redis.util.PageUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 用户小程序停留时长处理接口
 * 实际应用中做定时凌晨执行
 */
@Service
public class UserStandProcessServiceImpl implements UserStandProcessService {
    private Logger LOGGER = LoggerFactory.getLogger(UserStandProcessServiceImpl.class);
    @Autowired
    private RedisCache redisCache;
    /**
     * 分页页容量
     */
    private static Integer MAX_BATCH_PAGE_SIZE = 10;
    /**
     * 定时处理用户停留时间
     * 持久化到数据库
     * 注:非自定义执行区间,默认定时凌晨统计前一天的
     * 此处我未添加定时逻辑,请自行根据逻辑添加
     */
    @Override
    public void scheduledRunUserStand() {
        //时间往前偏移一天
        Date lastDay = DemoDateUtil.offsetDay(new Date(), -1);
        //格式化时间为 yyyyMMdd格式
        String reqDate = DemoDateUtil.formatReqDate(lastDay);
        //定时处理用户数据
        this.scheduledRunUserStand(reqDate);
    }

    /**
     * 定时处理用户停留时间
     * 持久化到数据库
     *
     * @param reqDate 自定义执行区间 yyyyMMdd格式
     */
    @Override
    public void scheduledRunUserStand(String reqDate) {
        //拼装缓存key
        String hashKey = DemoConstant.USER_STAND_HASH_KEY.concat(reqDate);
        LOGGER.info("缓存 hashKey={}", hashKey);
        String listKey = DemoConstant.USER_STAND_LIST_KEY.concat(reqDate);
        LOGGER.info("缓存 listKey={}", listKey);
        //查询缓存集合大小
        int zSize = (int) redisCache.zCard(listKey);
        LOGGER.info("总容量 zSize={}", zSize);
        //计算总页数
        int totalPage = PageUtil.getTotalPage(zSize, MAX_BATCH_PAGE_SIZE);
        //从第一页开始
        int pageNo = 1;

        LOGGER.info("执行开始");
        while (pageNo <= totalPage) {
            System.out.println("******华丽的分割线 " + pageNo + " ******");
            LOGGER.info(" 当前页开始 paeNo={},totalPage={}", pageNo, totalPage);
            //计算当前页开始于结束位置
            int start = PageUtil.getStart(pageNo, MAX_BATCH_PAGE_SIZE);
            int end = PageUtil.getEnd(start, MAX_BATCH_PAGE_SIZE);
            LOGGER.info(" 当前页开始 start={}", start);
            List<String> stringList = redisCache.zRange(listKey, String.class, start, end);

            List<UserStandInfo> userStandInfoList = new ArrayList<>(stringList.size());
            stringList.forEach(sKey -> {
                //根据 key 获取 hash 中的 统计值
                Integer hashValue = redisCache.getHash(hashKey, sKey, Integer.class);
                System.out.println("key=" + sKey + "  hashValue=" + hashValue);

                //构建统计记录信息
                UserStandInfo userStandInfo = new UserStandInfo();
                userStandInfo.setUserId(sKey);
                userStandInfo.setStandCountTotal(hashValue);
                userStandInfo.setStandCountDate(Integer.valueOf(reqDate));
                userStandInfo.setCreateTime(new Date());
                userStandInfoList.add(userStandInfo);

            });
            LOGGER.info("此处模拟批量新增到数据库操作");

            LOGGER.info(" 当前页结束 paeNo={},totalPage={}", pageNo, totalPage);
            pageNo++;
        }
        //执行完毕设置效期过期时间 3 天
        //设置效期
        redisCache.expire(hashKey, 3, TimeUnit.DAYS);
        //设置效期
        redisCache.expire(listKey, 3, TimeUnit.DAYS);
        LOGGER.info("执行结束");
    }
}

你可能感兴趣的