非常全面的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點贊功能內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!
推薦閱讀:
- redis執行lua腳本的實現方法
- 帶你輕松掌握Redis分佈式鎖
- redis分佈式鎖的8大坑總結梳理
- redis lua腳本實戰秒殺和減庫存的實現
- SpringBoot RedisTemplate分佈式鎖的項目實戰