Java SpringBoot点赞功能
JoeyHua 人气:0前言
最近公司在做一个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
源码里有一些功能没有实现,比如定时同步功能,需要根据业务需求自行实现
总结
加载全部内容