前言

最近公司在做一个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点赞功能实现的更多相关文章

  1. 基于SpringBoot如何实现一个点赞功能?

    基于SpringBoot如何实现一个点赞功能? 解析: 基于 SpringCloud, 用户发起点赞.取消点赞后先存入 Redis 中,再每隔两小时从 Redis 读取点赞数据写入数据库中做持久化存储 ...

  2. 全栈项目|小书架|微信小程序-点赞功能实现

    微信小程序端的点赞功能其实没什么好介绍的,无非就是调用接口改变点赞状态和点赞数量.需要注意的是取消点赞时的处理,我这里为了减少服务器接口的调用,直接本地存一个变量,修改这里的变量值即可. 由于源码都相 ...

  3. 全栈项目|小书架|服务器端-NodeJS+Koa2 实现点赞功能

    效果图 接口分析 通过上面的效果图可以看出,点赞入口主要是在书籍的详情页面. 而书籍详情页面,有以下几个功能是和点赞有关的: 获取点赞状态 点赞 取消点赞 所以项目中理论上与点赞相关的接口就以上三个. ...

  4. 学习SpringBoot,整合全网各种优秀资源,SpringBoot基础,中间件,优质项目,博客资源等,仅供个人学习SpringBoot使用

    学习SpringBoot,整合全网各种优秀资源,SpringBoot基础,中间件,优质项目,博客资源等,仅供个人学习SpringBoot使用 一.SpringBoot系列教程 二.SpringBoot ...

  5. Mybatis系列全解(五):全网最全!详解Mybatis的Mapper映射文件

    封面:洛小汐 作者:潘潘 若不是生活所迫,谁愿意背负一身才华. 前言 上节我们介绍了 < Mybatis系列全解(四):全网最全!Mybatis配置文件 XML 全貌详解 >,内容很详细( ...

  6. Mybatis系列全解(四):全网最全!Mybatis配置文件XML全貌详解

    封面:洛小汐 作者:潘潘 做大事和做小事的难度是一样的.两者都会消耗你的时间和精力,所以如果决心做事,就要做大事,要确保你的梦想值得追求,未来的收获可以配得上你的努力. 前言 上一篇文章 <My ...

  7. 史上最全阿里 Java 面试题总结

    以下为大家整理了阿里巴巴史上最全的 Java 面试题,涉及大量 Java 面试知识点和相关试题. JAVA基础 JAVA中的几种基本数据类型是什么,各自占用多少字节. String类能被继承吗,为什么 ...

  8. 全网最全的Windows下Anaconda2 / Anaconda3里Python语言实现定时发送微信消息给好友或群里(图文详解)

    不多说,直接上干货! 缘由: (1)最近看到情侣零点送祝福,感觉还是很浪漫的事情,相信有很多人熬夜为了给爱的人送上零点祝福,但是有时等着等着就睡着了或者时间并不是卡的那么准就有点强迫症了,这是也许程序 ...

  9. 【Other】最近在研究的, Java/Springboot/RPC/JPA等

    我的Springboot框架,欢迎关注: https://github.com/junneyang/common-web-starter Dubbo-大波-服务化框架 dubbo_百度搜索 Dubbo ...

随机推荐

  1. js(JQuery)引入select2

    官方项目地址:https://select2.org/ 引入css和js <link href="https://cdnjs.cloudflare.com/ajax/libs/sele ...

  2. 【九度OJ】题目1181:遍历链表 解题报告

    [九度OJ]题目1181:遍历链表 解题报告 标签(空格分隔): 九度OJ http://ac.jobdu.com/problem.php?pid=1181 题目描述: 建立一个升序链表并遍历输出. ...

  3. 菜鸟物流的运输网络(计蒜客复赛F)

    菜鸟物流有自己的运输网络,网络中包含 nn 个城市物流集散中心,和 mm 对城市之间的运输线路(线路是双向的).菜鸟物流允许淘宝卖家自行确定包裹的运输路径,但只有一条限制规则:不允许经过重复的城市.淘 ...

  4. 第十九个知识点:Shamir密钥交换场景

    第十九个知识点:Shamir密钥交换场景 Shamir密钥交换场景是一个被Adi Shamir提出的算法.算法允许多方分割一个密码,例如一个密钥.当足够多的秘密结合起来,整个密钥就被计算出来了. 正式 ...

  5. MongoDB笔记:windows环境安装及连接本地数据库

    下载MongoDB 2.4.9版 mongodb官网下载:http://www.mongodb.org/downloads 直接下载地址:http://fastdl.mongodb.org/win32 ...

  6. Linux_at任务调度

    基本介绍 一次性定时计划任务,由守护进程atd以后台模式执行,检查作业队列来进行 默认情况下,atd每60s检查一次作业队列 在使用at命令时,要确保atd进程的启动,用指令来查看 ps -ef | ...

  7. 基于Spring MVC + Spring + MyBatis的【野生动物保护系统】

    资源下载:https://download.csdn.net/download/weixin_44893902/45603787 练习点设计: 模糊查询.删除.新增.修改 一.语言和环境 实现语言:J ...

  8. Java初学者作业——定义一个计算器类, 实现计算器类中加、 减、 乘、 除的运算方法, 每个方法能够接收2个参数。

    返回本章节 返回作业目录 需求说明: 定义一个计算器类, 实现计算器类中加. 减. 乘. 除的运算方法, 每个方法能够接收2个参数. 实现思路: 定义计算器类. 定义计算器类中加.减.乘.除的方法. ...

  9. Ranger-AdminServer安装

    Ranger-AdminServer安装,对应的Ranger版本1.0.0. RangerAdmin安装依赖如下组件: mysql solr IP/机器名 安装软件 运行进程 zdh-245 Rang ...

  10. Solon 1.6.12 发布,类似 Spring 的生态体系

    关于官网 千呼万唤始出来: https://solon.noear.org .整了一个月多了,总体样子有了...还得不断接着整! 关于 Solon Solon 是一个轻量级应用开发框架.支持 Web. ...