社交支付系统,商品浏览量设计与实践

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

作者:三哥,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 尽量放在一个事务中执行,防止出现执行失败,丢失部分浏览量或者多计算浏览量的情况。

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

相关推荐
2401_854391083 分钟前
城镇住房保障:SpringBoot系统功能概览
java·spring boot·后端
陈随易7 分钟前
兔小巢收费引发的论坛调研Node和Deno有感
前端·后端·程序员
聪明的墨菲特i13 分钟前
Django前后端分离基本流程
后端·python·django·web3
hlsd#1 小时前
go mod 依赖管理
开发语言·后端·golang
陈大爷(有低保)1 小时前
三层架构和MVC以及它们的融合
后端·mvc
亦世凡华、1 小时前
【启程Golang之旅】从零开始构建可扩展的微服务架构
开发语言·经验分享·后端·golang
河西石头1 小时前
一步一步从asp.net core mvc中访问asp.net core WebApi
后端·asp.net·mvc·.net core访问api·httpclient的使用
2401_857439691 小时前
SpringBoot框架在资产管理中的应用
java·spring boot·后端
怀旧6661 小时前
spring boot 项目配置https服务
java·spring boot·后端·学习·个人开发·1024程序员节
阿华的代码王国2 小时前
【SpringMVC】——Cookie和Session机制
java·后端·spring·cookie·session·会话