浏览量场景:
| 策略 | 描述 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 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);
}
}
}
2252

被折叠的 条评论
为什么被折叠?



