非常全面的Java SpringBoot点赞功能实现

目录
  • 前言
  • 解决方案
    • 青铜版
    • 白银版
    • 黄金版
  • 源码
  • 总结

前言

最近公司在做一个NFT商城的项目,大致就是一个只买卖数字产品的平台,项目中有个需求是用户可以给商品点赞,还需要获取商品的点赞总数,类似下图

起初感觉这功能很好实现,无非就是加个点赞表嘛,后来发现事情并没有这么简单。

一开始的设计是这样的,一共有三张表:商品表、用户表、点赞表,用户点赞的时候把用户id和商品id加到点赞表中,并给对应的商品点赞数+1。看起来没什么问题,逻辑也比较简单,但是测试的时候缺发现了奇怪的bug,点赞数量有时候会不正确,结果会比预期的大。

下面贴下关键代码(项目使用了Mybatis-Plus):

public boolean like(Integer userId, Integer productId) {
        // 查询是否有记录,如果有记录直接返回
        Like like = getOne(new QueryWrapper<Like>().lambda()
                .eq(Like::getUserId, userId)
                .eq(Like::getProductId, productId));
        if(like != null) {
            return true;
        }

        // 保存并商品点赞数加1
        save(Like.builder()
                .userId(userId)
                .productId(productId)
                .build());
        return productService.update(new UpdateWrapper<Product>().lambda()
                .setSql("like_count = like_count + 1")
                .eq(Product::getId, productId));
}

看上去没什么问题,但是测试后数据却不正确,为什么呢?

实际上这是一个并发问题,只要在并发的情况下就会出现问题,我们知道Spring Mvc是基于servlet的,servlet在接收到用户请求后会从线程池中拿一个线程分配给它,每个请求都是一个单独的线程。试想一下,如果A线程在执行完查询操作后,发现没有记录,随后由于CPU调度,把控制权让了出去,然后B线程执行查询,也发现没有记录,这时候A和B线程都会执行保存并商品点赞数加1这个操作,导致数据不正确。

CPU操作顺序:A线程查询 -> B线程查询 -> A线程保存 -> B线程保存

下面使用JMeter模拟一下并发的情况,模拟用户在1秒内对商品执行100次点赞请求,结果应该是1,但得到的结果却是28(实际结果不一定是28,可能是任何数字)。

解决方案

青铜版

使用synchronized关键字锁住读写操作,操作完成后释放锁

public boolean like(Integer userId, Integer productId) {
        String lock = buildLock(userId, productId);
        synchronized (lock) {
            // 查询是否有记录,如果有记录直接返回
            Like like = getOne(new QueryWrapper<Like>().lambda()
                    .eq(Like::getUserId, userId)
                    .eq(Like::getProductId, productId), false);
            if(like != null) {
                return true;
            }

            // 保存并商品点赞数加1
            save(Like.builder()
                    .userId(userId)
                    .productId(productId)
                    .build());
            return productService.update(new UpdateWrapper<Product>().lambda()
                    .setSql("like_count = like_count + 1")
                    .eq(Product::getId, productId));
        }
}

private String buildLock(Integer userId, Integer productId) {
        StringBuilder sb = new StringBuilder();
        sb.append(userId);
        sb.append("::");
        sb.append(productId);
        String lock = sb.toString().intern();

        return lock;
}

这里要注意一点,使用String作为锁时一定要调用intern()方法,intern()会先从常量池中查找有没有相同的String,如果有就直接返回,没有的话会把当前String加入常量池,然后再返回。如果不调用这个方法锁会失效。

JMeter性能数据

优点:

保证了正确性

缺点:

性能太差,并发低的情况下还可以应付,并发高时用户体验极差

白银版

点赞表user_id和product_id加上联合索引,并使用try catch捕获异常,防止报错。由于使用了联合索引,所以不需要在新增前查询了,mysql会帮我们做这件事。

public boolean like(Integer userId, Integer productId) {
        try {
            // 保存并商品点赞数加1
            save(Like.builder()
                    .userId(userId)
                    .productId(productId)
                    .build());
            return productService.update(new UpdateWrapper<Product>().lambda()
                    .setSql("like_count = like_count + 1")
                    .eq(Product::getId, productId));
        }catch (DuplicateKeyException exception) {

        }

        return true;
}

JMeter性能数据

优点:

性能比上一个方案好

缺点:

中规中矩,没什么大的缺点

黄金版

使用Redis缓存点赞数据(点赞操作使用lua脚本实现,保证操作的原子性),然后定时同步到mysql。

注意:Redis需要开启持久化,最好aof和rdb都开启,不然重启数据就丢失了

public boolean like(Integer userId, Integer productId) {
        List<String> keys = new ArrayList<>();
        keys.add(buildUserRedisKey(userId));
        keys.add(buildProductRedisKey(productId));

        int value1 = 1;

        redisUtil.execute("lua-script/like.lua", keys, value1);

        return true;
}

private String buildUserRedisKey(Integer userId) {
        return "userId_" + userId;
}

private String buildProductRedisKey(Integer productId) {
        return "productId_" + productId;
}

lua脚本

local userId = KEYS[1]
local productId = KEYS[2]
local flag = ARGV[1] -- 1:点赞 0:取消点赞

if flag == '1' then
  -- 用户set添加商品并商品点赞数加1
  if redis.call('SISMEMBER', userId, productId) == 0 then
    redis.call('SADD', userId, productId)
    redis.call('INCR', productId)
  end
else
  -- 用户set删除商品并商品点赞数减1
  redis.call('SREM', userId, productId)
  local oldValue = tonumber(redis.call('GET', productId))
  if oldValue and oldValue > 0 then
    redis.call('DECR', productId)
  end
end

return 1

JMeter性能数据

优点:

  • 性能非常好

缺点:

  • 数据量多了内存占用较高总结

如果对性能没有要求,可以使用白银版的实现方式,如果有要求,就使用黄金版的方式,内存占用大的问题也可以通过一些手段来解决,比如可以根据业务需求定期删除一些不常用的缓存数据,但是相对应的,查询的时候就需要在查询失败时再去查数据库。

源码

源码地址:https://github.com/huajiayi/like-demo

源码里有一些功能没有实现,比如定时同步功能,需要根据业务需求自行实现

总结

到此这篇关于Java SpringBoot点赞功能实现的文章就介绍到这了,更多相关Java SpringBoot点赞功能内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • SpringBoot实现物品点赞功能

    前后端分离项目–二手交易平台小程序 SpringBoot----物品收藏功能实现 SpringBoot----评论回复功能实现(数据库设计) SpringBoot----文件(图片)上传与显示(下载) 点赞 这个功能耗费了我挺多时间,简单实现很简单,就++ – .但是还是感觉这种点赞是一个高频率的请求,而且搜的时候我看都是使用redis做缓存.b站也搜到一个视频来着,也是一样的. 效果: 功能: 首先还是一个先发请求返回数据,但是先数据存到了redis中,然后使用springboot定时任务每隔

  • 非常全面的Java SpringBoot点赞功能实现

    目录 前言 解决方案 青铜版 白银版 黄金版 源码 总结 前言 最近公司在做一个NFT商城的项目,大致就是一个只买卖数字产品的平台,项目中有个需求是用户可以给商品点赞,还需要获取商品的点赞总数,类似下图 起初感觉这功能很好实现,无非就是加个点赞表嘛,后来发现事情并没有这么简单. 一开始的设计是这样的,一共有三张表:商品表.用户表.点赞表,用户点赞的时候把用户id和商品id加到点赞表中,并给对应的商品点赞数+1.看起来没什么问题,逻辑也比较简单,但是测试的时候缺发现了奇怪的bug,点赞数量有时候会

  • 非常全面的Java异常处理(全文干货,值得收藏)

    一.初始Java异常 1.对异常的理解:异常:在Java语言中,将程序执行中发生的不正常情况称为"异常".(开发过程中的语法错误和逻辑错误不是异常) 2.Java程序在执行过程中所发生对异常事件可分为两类: Error:Java虚拟机无法解决的严重问题.如:JVM系统内部错误.资源耗尽等严重情况.比如:StackOverflowError和OOM.一般不编写针对性 的代码进行处理. Exception: 其它因编程错误或偶然的外在因素导致的一般性问题,可以使用针对性的代码进行处理.例如

  • java实现点赞功能

    本文实例为大家分享了java实现点赞功能的具体代码,供大家参考,具体内容如下 实现思路: 将点赞的数据先保存到redis中,然后定时同步到数据库 第一步: 在redis中创建二个hash 用于存储 用户点赞记录及记录点赞数MAP_USER_LIKED :用户点赞的记录 key:记录id::用户id value:1MAP_USER_LIKED_COUNT:记录点赞数 key: 记录id value:数量 第二步: 创建枚举类 @Getter public enum LikeStatusEnum {

  • 必须详细与全面的Java开发环境搭建图文教程

    在项目产品开发中,开发环境搭建是软件开发的首要阶段,也是必须阶段,只有开发环境搭建好了,方可进行开发,良好的开发环境搭建,为后续的开发工作带来极大便利. 对于大公司来说,软件开发环境搭建工作一般是由运维来做,然而,对于小公司来说,这个工作就交给开发人员来做了,如开发经理.不管这个工作是交给运维人员做,还是 交给开发人员做,能确定的是:做这件事的人,一定是个资深的人,如此,方可让开发环境稳定运行,从而为后续的开发提供便利. 现实中,只有极少部分开发人员接触服务器(能接触的人,基本都是开发组长及其以

  • 超全面的SpringBoot面试题含答案

    1. 什么是 Spring Boot? Spring Boot 是 Spring 开源组织下的子项目,是 Spring 组件一站式解决方案,主要是简化了使用Spring 的难度,简省了繁重的配置,提供了各种启动器,使开发者能快速上手. 2. 为什么要用SpringBoot 快速开发,快速整合,配置简化.内嵌服务容器 3. SpringBoot与SpringCloud 区别 SpringBoot是快速开发的Spring框架,SpringCloud是完整的微服务框架,SpringCloud依赖于Sp

  • Springboot+ElementUi实现评论、回复、点赞功能

    目录 1.概述 2.前端代码 1.html 2.css 3.js 4.api调用后台接口 3.后端代码 1.数据库SQL 2.实体类 3.daoMapper 4.daoMapper实现 5.service接口 6.service接口实现 7.controller 1.概述 做一个项目,突然需要实现回复功能,所依记录一下此次的一个实现思路,也希望给别人分享一下,估计代码还是不够完善,有空在实现分页功能.话不多说直接看效果图.主要实现了评论,回复,点赞,取消点赞,如果是自己评论的还可以删除,删除的规

  • java实现简单点赞功能

    本文实例为大家分享了java实现简单点赞功能的具体代码,供大家参考,具体内容如下 需求分析 分析: 1.必须先登录,否则提示2.第一次点赞(顶),点赞操作,点赞数+1,提示顶成功3.第二次点赞(顶),没有操作,提示今天顶过了 核心问题: 1>怎么区分当前请求时顶成功操作(第一次顶)还是今天已经顶过(第二次顶)2>怎么考虑今天已顶过 ----------------------------------------------核心问题需要区分是第一次顶还是的二次顶,这种请求操作属于有状态请求操作,

  • java微信支付功能实现源码

    提示:仅微信支付功能模块类,可供参考,可点赞 一.java后台实现源码 package cn.xydx.crowdfunding.controller; import cn.xydx.crowdfunding.util.HttpRequest; import cn.xydx.crowdfunding.util.WXPayUtil; import org.json.JSONObject; import org.springframework.stereotype.Controller; impor

  • Java Springboot之Spring家族的技术体系

    一.Why Spring Boot 在传统 Spring 框架的基础上做了创新和优化,将开发人员从以往烦琐的配置工作中解放出来,并提供了大量即插即用的集成化组件,从而解决了各种组件之间复杂的整合过程,大大提高了开发效率,降低了维护成本. 比如, 原本使用的是 Spring MVC 框架, 在整个开发过程中,除了需要编写一大堆配置文件.针对每个层次引入专门的开发组件外,还需要独立部署和管理应用服务器.最后,为了对系统的运行状态进行有效监控,还需要引入一些并不好用的外部框架. 而使用了 Spring

随机推荐