网站内容浏览量功能设计与实践

个人项目:社交支付项目(小老板)

作者:三哥,j3code.cn

文档系统:admire.j3code.cn/note

预览地址(未开发完):admire.j3code.cn/small-boss

  • 内网穿透部署,第一次访问比较慢

现在我们的项目首页已经提供了商品列表和商品详情的功能,那,现在是不是可以给商品加个浏览量的功能,毕竟这是常规操作。

ok,那开始实现功能之前,我们先来修改一下表结构和实体,向其中加入浏览量字段。

sql 复制代码
SQL:
ALTER TABLE `sb_commodity`   
	ADD COLUMN `views` INT DEFAULT 0 NULL COMMENT '浏览量' AFTER `content`;
实体:
private Integer views;

1、设计

先来定义一下,何为浏览,即,用户点击了商品详情的时候视为一次浏览。

所以按照上面的说法,是不是每次用户点击了商品详情就给数据库字段 + 1 即可,就像下面这样:

sql 复制代码
UPDATE sb_commodity SET views = views + 1 WHERE id = ?

理论上,这个做法是可以的,但是这不是实现浏览量功能的全部,这仅仅只是最后一步罢了。为什么这么说,大家看我下面的思考:

  1. 用户每刷新一次商品详情,update 语句就执行一次吗?
  2. 如果用户未登录,查看商品详情是否需要给浏览量 + 1?

总结一下就是,是否每次刷新页面,浏览量就 + 1 和用户未登录,浏览量是否需要 + 1。当然,由这两个考虑我们又可以引申出很多个问题:

  1. 浏览量 + 1 每次都 update 数据库吗
  2. 如果不是每次刷新一次浏览量就 + 1,那如何区分用户是否已经浏览了
  3. 如果按用户 id 来区分是否浏览,那未登录的用户呢
  4. 如果按照用户的 IP 来区分是否浏览,那该如何存呢
  5. 用户 IP 是可以变化的,那如果同一个用户 IP 变化了,浏览量是否需要 + 1 呢

那,在分析出具体的实现方案之前,我们需要明白一件事情就是浏览量是一个大概的数字,也即可以不需要非常的精确,记住浏览量是一个大概的数字

分析到这里,我得出如下方案:

IP + 商品 id,作为唯一 key 存 Redis

key 带有过期时间,如果在过期时间内同 IP + 同商品 id 的 key 存在,则忽略

定时任务定时统计 IP + 商品 id 的 key 个数,存入数据库中

方案流程图:

最后咱们对着这个方案,来细品一下,Redis 用什么数据结构存储数据?

  • 咱们的数据结构为,一个商品,对应很多个用户 IP

第一种:List,通过前缀 + 商品 ID 为 key,用户 IP 为 value 向集合中添加数据,当我们需要获取商品的浏览量时只需要获取集合 size 即可。但你们又没想过一个问题,list 可是不去重的,也即用户可以不同的刷新页面导致 list 集合中重复的 ip 不同的增加,所以这个结构不行。

第二种:Set,这个就非常可以了,估计 set 的特性 value 不会存在重复的数据,也即用户不同的刷新页面同一个ieIP 只会记录一次。理论上,这个 set 就可以了,但是我们考虑一下,如果定时任务执行之前, 100 个商品,每个商品增长了 1 W 浏览量,这个内存占用,有没有考虑过?约 300 MB 的占用,这有点大啊,如果不止 100 个商品,那内存增长量更大。所以,还需要重新考虑一个即不重复又不怎么占用内存空间的数据结构。

第三种HyperLogLog,是的,这个数据结构就是我们最终的解决方案,它的优点在于,元素不充分且在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的值(约 12 k)。

2、实践

终于到动手编码环节了,此次我们需要改动的点有如下三块:

  1. 查看商品时,增加浏览量记录功能
  2. 获取商品列表时,回查商品浏览量功能
  3. 定时同步 Redis 中的浏览量到 MySQL 中

2.1 Redis 添加浏览量记录

Redis 中我们的 key 结构为:前缀 + 商品 ID,value 为用户 IP。

在用户查看商品信息的时候,我们记录一下该商品的浏览量,也即把下面的方法,放入查看商品信息之中。

java 复制代码
// 添加商品浏览量
addViews(commodity.getId());

实现:

java 复制代码
private void addViews(Long commodityId) {
    // 获取请求 request
    HttpServletRequest request = ((ServletRequestAttributes) (RequestContextHolder.currentRequestAttributes())).getRequest();
    // 获取 ip 和生成 key
    String ip = IpUtil.getClientIp(request);
    String key = SbUtil.getCommodityViewsKey(commodityId);

    redisTemplate.opsForHyperLogLog().add(key, ip);
}

2.2 商品列表回查浏览量

再获取商品列表的时候,我们商品的浏览量不止是数据库的 view 字段了,还需要加上 Redis 中缓存的浏览量。所以在获取商品列表的时候,我们需要补充一下商品的浏览量,具体实现如下。

java 复制代码
// 回填一下 redis 中的商品浏览量
fillViews(page.getRecords());

实现:

java 复制代码
private void fillViews(List<HomeCommodityVO> commodityList) {
    // 空则不处理
    if (CollectionUtils.isEmpty(commodityList)) {
        return;
    }
    for (HomeCommodityVO commodityVO : commodityList) {
        // 存在 key 则补充浏览量
        if (Boolean.TRUE.equals(redisTemplate.hasKey(SbUtil.getCommodityViewsKey(commodityVO.getId())))) {
            Long size = redisTemplate.opsForHyperLogLog().size(SbUtil.getCommodityViewsKey(commodityVO.getId()));
            commodityVO.setViews(commodityVO.getViews() + size.intValue());
        }
    }
}

2.3 定时同步浏览量到数据库

下面来到我们功能的最关键一步了,同步浏览量数据到数据库。

先说一下做这一步的目前是为了让数据能写到磁盘,因为 Redis 毕竟是内存数据库,断电即失,而且 Redis 也不宜一直占用过多的内存,所以这个数据落盘是一定要做的。

具体实现步骤:

  1. 获取商品浏览量的所有 key
  2. 循环获取出 key 对应的 value 值
  3. 删除所有 key
  4. 遍历并分解 key 中的商品 id ,将商品的原有浏览量和获取 Redis 的浏览量相加,存入数据库,完成同步操作

编码之前,在来看看该功能的详细流程图:

该功能的需要注意的点我已经写在了流程图中,下面就开始编码吧!

1)schedule 编写

位置:cn.j3code.merchant.schedule

java 复制代码
@Slf4j
@Component
@AllArgsConstructor
public class CommodityViewsSchedule {

    private final CommodityService commodityService;

    /**
     * 11,27,43,57 分钟行一次,商品的浏览量,在该时间内应该增加不了多少
     */
    @DistributedLock
    @Scheduled(cron = "45 11,27,43,57 * * * ? ")
    public void fillCommodityViews() {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start("同步商品浏览量");
        try {
            commodityService.fillCommodityViews();
        } catch (Exception e) {
            log.error("同步商品浏览量出错:", e);
        } finally {
            stopWatch.stop();
            log.info("同步商品浏览量执行时间:{}", stopWatch.getTotalTimeSeconds());
        }
    }
}

2)service 编写

位置:

java 复制代码
public interface CommodityService extends IService<Commodity> {
    void fillCommodityViews();
}

@Slf4j
@AllArgsConstructor
@Service
public class CommodityServiceImpl extends ServiceImpl<CommodityMapper, Commodity>
    implements CommodityService {
    private final RedisTemplate<String, Object> redisTemplate;
    private final TransactionTemplate transactionTemplate;
    
    @Override
    public void fillCommodityViews() {
        // 获取所有浏览量 key
        Set<String> keys = redisTemplate.keys(SbUtil.getCommodityViewsKey(null) + "*");
        if (CollectionUtils.isEmpty(keys)) {
            return;
        }
        // key 和 浏览量 ,一一对应
        Map<String, Long> viewMap = keys.stream().collect(Collectors
                                                          .toMap(key -> key, value -> redisTemplate.opsForHyperLogLog().size(value)));

        List<Commodity> updateCommodityList = viewMap.entrySet().stream().map(entry -> {
            Commodity commodity = new Commodity();
            // 分解 key,取出 id
            commodity.setId(Long.parseLong(entry.getKey().substring(entry.getKey().lastIndexOf(":") + 1)));
            commodity.setViews(entry.getValue().intValue());
            return commodity;
        }).collect(Collectors.toList());

        // 获取数据库中的原始浏览量
        Map<Long, Integer> commodityIdToViewMap = lambdaQuery().in(Commodity::getId, updateCommodityList.stream().map(Commodity::getId).collect(Collectors.toList()))
            .list().stream().collect(Collectors.toMap(Commodity::getId, Commodity::getViews));

        // redis + MySQL 等于 总浏览量
        updateCommodityList.forEach(item -> item.setViews(commodityIdToViewMap.get(item.getId()) + item.getViews()));

        /**
         * 在一个事务中执行修改 MySQL 和 删除 Redis 操作
         */
        MyTransactionTemplate.execute(transactionTemplate, accept -> {
            // 分割集合,每次批量更新 100 条数据
            CollUtil.split(updateCommodityList, 100).forEach(list -> {
                // 批量修改
                updateBatchById(updateCommodityList);
            });

            // 移除 redis key
            redisTemplate.delete(keys);
        }, "同步浏览量失败!");
    }
}

我相信代码写的已经很详细了,但需要注意一点就是,修改 MySQL 和删除 Redis 尽量放在一个事务中执行,防止出现执行失败,丢失部分浏览量或者多计算浏览量的情况。

那,本片内容就到此结束了,咱们下回见。

相关推荐
Penge6661 小时前
Go 接口编译期断言
后端
我是一颗柠檬1 小时前
【MySQL全面教学】MySQL面试高频考点汇总Day15(2026年)
数据库·后端·mysql·面试
橙淮2 小时前
并发编程(六)
java·jvm
拽着尾巴的鱼儿2 小时前
springboot openfeign 自定义feign 接口重试机制
java·spring boot·后端
白露与泡影2 小时前
2026大厂Java面试题大全!牛客网最新版
java·开发语言
Ceelog2 小时前
久坐党自救指南:屏幕前 8 小时,身体到底在经历什么
前端·后端
EntyIU3 小时前
JVM内存与GC笔记
java·jvm·笔记
XS0301063 小时前
并发编程 六
java·后端
yaoxin5211233 小时前
419. 现代 Java IO 最佳实践 - 写入文本文件
java·windows·python
雪宫街道3 小时前
synchronized 锁的范围:对象锁、类锁与代码块锁
java·jvm·后端·面试