全網最全的Java SpringBoot點贊功能實現

JoeyHua發表於2022-01-21

前言

最近公司在做一個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
原始碼裡有一些功能沒有實現,比如定時同步功能,需要根據業務需求自行實現

相關文章