浏览量的更新

浏览量场景:

策略描述优点缺点适用场景
1. 每次点击都算用户每次打开攻略页面就 +1实现简单,数据量大容易被刷(F5 刷量),数据失真内部测试、对真实性要求低
2. 每用户仅计 1 次(永久)同一个用户一生只能贡献 1 次浏览数据真实反映“多少人看过”忽略复访价值(老用户回看不算)强调“触达人数”,如广告曝光
3. 每用户每天计 1 次 ✅(推荐)同一用户每天首次访问算 1 次平衡真实性与活跃度,防刷效果好实现稍复杂大多数内容平台(如小红书、马蜂窝、知乎)
4. 停留时间 + 行为判定需满足:停留 > X 秒 或 滚动 > Y%数据质量高,反映真实阅读实现复杂,需前端埋点对内容消费深度有要求(如新闻、长文)

策略3的实现(java + redis):

问题:当一批用户突然访问一批文章时,点击一次我就更新一次文章的浏览量,导致数据库的压力极大,例如1s内1000的浏览量,就要更新1000次,那太没必要,所以我定义一个定时任务和缓存,统计5分钟之内的浏览量,然后1次更新好数据库中,这样就避免很多次没必要更新。

实现思路:使用redis存储文章每天被访问的用户id、该攻略的有效浏览量、记录需要更新攻略id。当用户点击某篇文章时,调用lua脚本去更新缓存,然后从缓存里面拿到最新浏览量。通过定时任务(5分钟执行一次)获取需要更新攻略id,然后通过id去获取该攻略的有效浏览量,批量更新到数据库中。

设计四个key:
tg:daily:users:{tg_id}:{YYYYMMDD}   记录用户在某天是否已访问该攻略的key(使用set数据结构)。
user:views:{user_id}   用户浏览历史(Sorted Set),按时间排序。
tg:views:{tg_id}  该攻略的有效浏览量(每天每用户只计一次)。
tg:queue  待处理队列(用于保存需要更新到数据库的攻略id和该攻略的浏览量,用于定时任务的使用和避免冷数据)。

实现:
1.定义好存储的key

package com.travelguidesharing.redis;

import java.time.LocalDate;

public class RedisConstants {

    // ================== 时间定义(单位:秒)==================
    public static final long REDIS_TIME_1SECOND = 1L;
    public static final long REDIS_TIME_1MINUTE = 60L;
    public static final long REDIS_TIME_1HOUR = 60 * 60L;
    public static final long REDIS_TIME_1DAY = 24 * REDIS_TIME_1HOUR; // 86400

    // ================== 过期时间 ==================
    public static final long REDIS_KEY_TOKEN_EXPIRES = REDIS_TIME_1DAY * 7;        // 7天
    public static final long REDIS_KEY_CODE_EXPIRES = REDIS_TIME_1MINUTE * 5;      // 5分钟
    public static final long REDIS_KEY_USER_NAME_CHANGE_TIME_EXPIRES = REDIS_TIME_1DAY * 365; // 1年

    // 浏览记录相关过期时间
    public static final long REDIS_KEY_DAILY_TG_USER_EXPIRES = REDIS_TIME_1DAY * 3;     // 每日记录保留3天
    public static final long REDIS_KEY_USER_VIEWS_EXPIRES = REDIS_TIME_1DAY * 30;       // 用户浏览历史保留30天

    // ================== 路径统一前缀 ==================
    public static final String REDIS_KEY_PREFIX = "tgs:";

    // ================== 各类 Key 前缀 ==================

    // Token 相关
    public static final String REDIS_KEY_TOKEN_PREFIX = REDIS_KEY_PREFIX + "token:";

    // 验证码相关
    public static final String REDIS_KEY_CODE_PREFIX = REDIS_KEY_PREFIX + "code:";

    // 用户名修改时间
    public static final String REDIS_KEY_USER_NAME_CHANGE_TIME_PREFIX = REDIS_KEY_PREFIX + "userNameChangeTime:";

    // 旅游攻略浏览相关

    /**
     * 每日用户访问某攻略的去重集合
     * 格式: tgs:tg:daily:user:{tgId}:{yyyyMMdd}
     * 类型: Set
     * 作用: 判断用户当天是否已访问,保证“每天只计一次”
     * 过期: 3天
     */
    public static final String REDIS_KEY_TG_DAILY_USER_PREFIX = REDIS_KEY_PREFIX + "tg:daily:user:";

    /**
     * 用户浏览历史(按时间排序)
     * 格式: tgs:user:views:{userId}
     * 类型: ZSet (score = timestamp, value = tgId)
     * 作用: 存储用户最近浏览的攻略,支持去重+排序
     * 过期: 30天
     */
    public static final String REDIS_KEY_USER_VIEWS_PREFIX = REDIS_KEY_PREFIX + "user:views:";

    /**
     * 攻略“额定浏览量”计数器(每天每用户只算一次)
     * 格式: tgs:tg:views:{tgId}
     * 类型: String (数值,INCR)
     * 作用: 统计该攻略的有效总浏览量(非PV,是去重UV累计)
     * 过期: 永不过期
     */
    public static final String REDIS_KEY_TG_VIEWS_COUNT_PREFIX = REDIS_KEY_PREFIX + "tg:views:";

    /**
     * 攻略浏览量待同步队列(用于增量同步到数据库)
     * 格式: tgs:tg:sync:queue
     * 类型: ZSet (score = timestamp, member = tgId)
     * 作用: 记录哪些攻略的浏览量已更新、需同步到 DB
     * 过期: 成员可长期保留,建议定时清理超期数据(如7天前)
     */
    public static final String REDIS_KEY_TG_SYNC_QUEUE = REDIS_KEY_PREFIX + "tg:sync:queue";


    // ================== 工具方法 ==================

    /**
     * 获取每日去重 Key (含日期)
     * @param tgId 攻略ID
     * @param dateKey YYYYMMDD 格式的日期字符串
     * @return 完整 key
     */
    public static String getTgDailyUserKey(String tgId, String dateKey) {
        return REDIS_KEY_TG_DAILY_USER_PREFIX + tgId + ":" + dateKey;
    }

    /**
     * 获取用户浏览历史 Key
     * @param userId 用户ID
     * @return 完整 key
     */
    public static String getUserViewsKey(String userId) {
        return REDIS_KEY_USER_VIEWS_PREFIX + userId;
    }

    /**
     * 获取攻略浏览量计数器 Key
     * @param tgId 攻略ID
     * @return 完整 key
     */
    public static String getTgViewsCountKey(String tgId) {
        return REDIS_KEY_TG_VIEWS_COUNT_PREFIX + tgId;
    }

    /**
     * 获取当前日期字符串(YYYYMMDD)
     * @return 如 "20250405"
     */
    public static String today() {
        return LocalDate.now().format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
    }
}

2.定义好lua脚本(保存查询和增加浏览量是原子操作)

-- KEYS[1]: tg:daily:users:{tg_id}:{YYYYMMDD}
--         → 记录用户在某天是否已访问该攻略(用于去重)
-- KEYS[2]: user:views:{user_id}
--         → 用户浏览历史(Sorted Set),按时间排序
-- KEYS[3]: tg:views:{tg_id}
--         → 该攻略的有效浏览量(每天每用户只计一次)
-- KEYS[4]: tg:queue
--         → 待处理队列(用于保存需要更新到数据库的攻略id和该攻略的浏览量)

-- ARGV[1]: user_id        → 用户ID
-- ARGV[2]: tg_id          → 攻略ID
-- ARGV[3]: timestamp_str  → 时间戳字符串

local daily_set_key = KEYS[1]   -- 每日去重集合:判断是否首次点击
local user_zset_key = KEYS[2]   -- 用户浏览历史
local views_key = KEYS[3]   -- 有效浏览量计数器(每天每用户只增一次)
local queue_key = KEYS[4]   -- 待处理队列(用于保存需要更新到数据库的攻略id和该攻略的浏览量)

local user_id = ARGV[1]
local tg_id = ARGV[2]
local ts_str = ARGV[3]

-- === 参数校验 ===
if not user_id or not tg_id or not ts_str then
    return { 0, "missing required parameter" }
end

local ts = tonumber(ts_str)
if not ts or ts <= 0 then
    return { 0, "invalid timestamp" }
end

-- === 检查是否今天已点击过这篇攻略 ===
local already_viewed = redis.call('SISMEMBER', daily_set_key, user_id) == 1

-- === 无论是否首次,都刷新用户浏览历史中的时间 ===
redis.call('ZADD', user_zset_key, ts, tg_id)

-- === 如果是首次点击,则:增加“有效浏览量” + 加入今日记录 ===
if not already_viewed then
    redis.call('INCR', views_key)           -- 增加有效浏览量(额定值)
    redis.call('SADD', daily_set_key, user_id) -- 标记已访问
    redis.call('ZADD',queue_key, ts, tg_id) -- 记录需要更新的攻略id,ts是时间戳当做score使用
end

-- === 限制用户浏览历史长度(最多保留 100 条)===
local max_len = 100
local len = redis.call('ZCARD', user_zset_key)
if len > max_len then
    redis.call('ZREMRANGEBYRANK', user_zset_key, 0, len - max_len - 1)
end

-- === 返回结果:1=首次点击并计入浏览量;0=重复点击未计入 ===
if not already_viewed then
    return { 1, "first click today, view counted" }
else
    return { 0, "repeat click, not counted" }
end

3.使用lua脚本:

@Slf4j
@Component
public class RedisComponent {
    private final StringRedisTemplate stringRedisTemplate;

    //抑制编译器警告
    @SuppressWarnings("unchecked")
    private static final DefaultRedisScript<List<Object>> RECORD_TG_VIEW_SCRIPT;

    static {
        RECORD_TG_VIEW_SCRIPT = new DefaultRedisScript<>();
        RECORD_TG_VIEW_SCRIPT.setLocation(new ClassPathResource("record_tg_view.lua"));
        //脚本返回结果类型,(Class<List<Object>>) (Class<?>) 防止警告
        RECORD_TG_VIEW_SCRIPT.setResultType((Class<List<Object>>) (Class<?>) List.class);

        //看脚本内容是否能读出来
        try {
            String scriptContent = new String(
                    new ClassPathResource("record_tg_view.lua").getInputStream().readAllBytes()
            );
            log.info("Loaded Lua script:\n{}", scriptContent.substring(0, Math.min(200, scriptContent.length())));
        } catch (Exception e) {
            log.error("Failed to load Lua script!", e);
        }
    }

    //添加用户的浏览记录,并增加攻略的浏览数
    public void addUserBrowseRecord(Long userId, Long tgId) {
        if (userId == null || tgId == null) {
            log.warn("用户ID或攻略ID为空,跳过记录");
            return;
        }

        try {
            String dateKey = RedisConstants.today();

            // 构建 Key
            // 每日去重
            String dailyUserKey = RedisConstants.getTgDailyUserKey(tgId.toString(), dateKey);
            // 用户浏览记录
            String userViewsKey = RedisConstants.getUserViewsKey(userId.toString());
            // 攻略浏览量计数器
            String viewsCountKey = RedisConstants.getTgViewsCountKey(tgId.toString());
            // 同步队列(热数据)
            String queueKey = RedisConstants.REDIS_KEY_TG_SYNC_QUEUE;

            //显式构造 List<String>
            List<String> keys = new ArrayList<>();
            keys.add(dailyUserKey);
            keys.add(userViewsKey);
            keys.add(viewsCountKey);
            keys.add(queueKey);

            List<String> args = new ArrayList<>();
            args.add(userId.toString());  // 强制转 String,不然有问题(因为用的是stringRedisTemplate)
            args.add(tgId.toString());    // 强制转 String
            args.add(String.valueOf(System.currentTimeMillis() / 1000)); //时间戳

            // 执行脚本
            List<Object> result = stringRedisTemplate.execute(RECORD_TG_VIEW_SCRIPT, keys, args.toArray(new String[0]));

            if (result == null || result.isEmpty()) {
                log.warn("Lua script returned null or empty: userId={}, tgId={}", userId, tgId);
                return;
            }

            int isCounted = ((Number) result.get(0)).intValue();
            String message = result.get(1).toString();

            // 设置过期时间(尽量完成)
            try {
                stringRedisTemplate.expire(dailyUserKey, Duration.ofDays(3));
                stringRedisTemplate.expire(userViewsKey, Duration.ofDays(30));
            } catch (Exception e) {
                log.debug("设置过期时间失败,可忽略", e);
            }

            if (isCounted == 1) {
                log.debug("浏览量计入成功 - 用户[{}]浏览攻略[{}]", userId, tgId);
            } else {
                log.debug("重复点击未计入 - 用户[{}]今日已浏览攻略[{}], reason: {}", userId, tgId, message);
            }

        } catch (Exception e) {
            // ⚠️ 异步任务绝不抛异常!只打日志
            log.error("异步记录用户浏览行为失败,userId={}, tgId={}. 忽略错误.", userId, tgId, e);
        }
    }
}

4.使用(当用户查询攻略详情时):

@Slf4j
@Service
public class TravelGuideServiceImpl extends ServiceImpl<TravelGuideMapper, TravelGuide> implements TravelGuideService{ 

    private final RedisComponent redisComponent;

    public TravelGuideServiceImpl(RedisComponent redisComponent) {
        this.redisComponent = redisComponent;
    }
    /**
     * 通过id获取攻略文章
     * @param tgId
     */
    @Override
    public TravelGuideDTO getTravelGuide(Long tgId) throws BusinessException {
        try {
            //1.获取攻略
            TravelGuide travelGuide = this.getById(tgId);
            if(travelGuide == null){
                throw new BusinessException("攻略不存在,id为%s".formatted(tgId));
            }

            //10.添加用户的浏览记录,并增加攻略的浏览数和标记用户今日已访问过
            redisComponent.addUserBrowseRecord(currentUser.getId(),tgId);
            //11.获取该文章的浏览量
            Integer tgViewsCount = redisComponent.getTgViewsCount(tgId);
            travelGuideDTO.setViewCount(tgViewsCount);
            //12.返回数据
            return travelGuideDTO;
        } catch (BusinessException e) {
            log.error("获取攻略异常,报错:%s".formatted(e.getMessage()));
            throw e;
        } catch (Exception e){
            log.error("获取攻略异常,报错:%s".formatted(e.getMessage()));
            throw new BusinessException(ResponseCodeEnum.CODE_500);
        }
    }
}

5.定时任务(启动类上记得添加@EnableScheduling注解):

package com.travelguidesharing.scheduling;

import com.travelguidesharing.entity.TravelGuide;
import com.travelguidesharing.mapper.TravelGuideMapper;
import com.travelguidesharing.redis.RedisConstants;
import com.travelguidesharing.service.TravelGuideService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.*;
import java.util.stream.Collectors;

/**
 * 定时任务:同步攻略的浏览量
 * @author xyq
 */
@Component
@Slf4j
public class SyncHotTgViewsTask {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private TravelGuideService travelGuideService;

    @Scheduled(cron = "0 0/5 * * * ?", zone = "Asia/Shanghai")
    public void syncHotTgViews() {
        String queueKey = RedisConstants.REDIS_KEY_TG_SYNC_QUEUE;

        // 直接取前 1000 条待同步的攻略(按加入顺序)
        Set<String> tgIds = stringRedisTemplate.opsForZSet()
                .range(queueKey, 0, 999); // 0 到 999 共 1000 个

        if (tgIds == null || tgIds.isEmpty()) {
            log.debug("无待同步的攻略");
            return;
        }

        // 批量获取浏览量(使用 Pipeline)
        List<String> viewKeys = tgIds.stream()
                .map(RedisConstants::getTgViewsCountKey) // 获取攻略浏览量计数器
                .collect(Collectors.toList());

        List<String> viewCounts = stringRedisTemplate.opsForValue().multiGet(viewKeys);

        // 构建更新List
        List<TravelGuide> updateList = new ArrayList<>();
        int i = 0;
        for (String tgId : tgIds) {
            String countStr = viewCounts.get(i++);
            if (countStr != null) {
                try {
                    TravelGuide item = new TravelGuide();
                    // 设置 ID
                    item.setId(Long.parseLong(tgId));
                    // 设置浏览量
                    item.setViewCount(Integer.parseInt(countStr));
                    updateList.add(item);
                } catch (NumberFormatException e) {
                    log.warn("无效浏览量值: key=tgs:tg:views:{}, value={}", tgId, countStr);
                }
            }
        }

        if (!ObjectUtils.isEmpty(updateList)) {
            // 批量更新数据库
            travelGuideService.updateBatchById(updateList);

            // 从队列移除已处理的
            stringRedisTemplate.opsForZSet().remove(queueKey, tgIds.toArray(new String[0]));

            log.info("成功同步 {} 篇攻略的浏览量", updateList.size());
        }
    }
}
package com.travelguidesharing.scheduling;

import com.travelguidesharing.redis.RedisConstants;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

/**
 * 攻略同步队列清理任务
 * 每天凌晨清理 tgs:tg:sync:queue 中 7 天前的超期数据
 *
 * @author xyq
 */
@Slf4j
@Component
public class TgSyncQueueCleanupTask {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 每天凌晨 2 点执行(避开业务高峰)
     */
    @Scheduled(cron = "0 0 2 * * ?", zone = "Asia/Shanghai")
    public void cleanupExpiredSyncQueue() {
        try {
            String queueKey = RedisConstants.REDIS_KEY_TG_SYNC_QUEUE;
            
            // 计算 7 天前的时间戳(秒)
            long sevenDaysAgo = System.currentTimeMillis() / 1000 - 7 * 24 * 3600;
            
            // 删除 score <= sevenDaysAgo 的所有成员
            Long removedCount = stringRedisTemplate.opsForZSet().removeRangeByScore(queueKey, 0, sevenDaysAgo);

            if (removedCount > 0) {
                log.info("清理同步队列超期数据完成,共删除 {} 条", removedCount);
            } else {
                log.debug("同步队列无超期数据需要清理");
            }
        } catch (Exception e) {
            log.error("清理攻略同步队列超期数据失败", e);
        }
    }
}



 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值