0
点赞
收藏
分享

微信扫一扫

全网最全的Java SpringBoot点赞功能实现

Python微信订餐小程序课程视频

https://edu.csdn.net/course/detail/36074

Python实战量化交易理财系统

https://edu.csdn.net/course/detail/35475

前言

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

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

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

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

public boolean like(Integer userId, Integer productId) {
        // 查询是否有记录,如果有记录直接返回
        Like like = getOne(new QueryWrapper().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().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().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().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().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
源码里有一些功能没有实现,比如定时同步功能,需要根据业务需求自行实现

  • 本文链接: https://blog.csdn.net/joeyhua/p/15827149.html
  • 关于博主: 评论和私信会在第一时间回复。或者直接私信我。
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
  • 声援博主: 如果您觉得文章对您有帮助,可以点击文章右下角**【[推荐](javascript:void(0)😉】**一下。
举报

相关推荐

0 条评论