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

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

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

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

相关推荐
bearpping32 分钟前
SpringBoot最佳实践之 - 使用AOP记录操作日志
java·spring boot·后端
一叶飘零_sweeeet34 分钟前
线上故障零扩散:全链路监控、智能告警与应急响应 SOP 完整落地指南
java·后端·spring
开心就好20252 小时前
不同阶段的 iOS 应用混淆工具怎么组合使用,源码混淆、IPA混淆
后端·ios
架构师沉默2 小时前
程序员如何避免猝死?
java·后端·架构
椰奶燕麦2 小时前
Windows PackageManager (winget) 核心故障排错与通用修复指南
后端
zjjsctcdl2 小时前
springBoot发布https服务及调用
spring boot·后端·https
zdl6863 小时前
Spring Boot文件上传
java·spring boot·后端
世界哪有真情3 小时前
哇!绝了!原来这么简单!我的 Java 项目代码终于被 “拯救” 了!
java·后端
RMB Player3 小时前
Spring Boot 集成飞书推送超详细教程:文本消息、签名校验、封装工具类一篇搞定
java·网络·spring boot·后端·spring·飞书
重庆小透明3 小时前
【搞定面试之mysql】第三篇 mysql的锁
java·后端·mysql·面试·职场和发展