非常全面的Java?SpringBoot點(diǎn)贊功能實(shí)現(xiàn)
前言
最近公司在做一個(gè)NFT商城的項(xiàng)目,大致就是一個(gè)只買(mǎi)賣(mài)數(shù)字產(chǎn)品的平臺(tái),項(xiàng)目中有個(gè)需求是用戶(hù)可以給商品點(diǎn)贊,還需要獲取商品的點(diǎn)贊總數(shù),類(lèi)似下圖
起初感覺(jué)這功能很好實(shí)現(xiàn),無(wú)非就是加個(gè)點(diǎn)贊表嘛,后來(lái)發(fā)現(xiàn)事情并沒(méi)有這么簡(jiǎn)單。
一開(kāi)始的設(shè)計(jì)是這樣的,一共有三張表:商品表、用戶(hù)表、點(diǎn)贊表,用戶(hù)點(diǎn)贊的時(shí)候把用戶(hù)id和商品id加到點(diǎn)贊表中,并給對(duì)應(yīng)的商品點(diǎn)贊數(shù)+1??雌饋?lái)沒(méi)什么問(wèn)題,邏輯也比較簡(jiǎn)單,但是測(cè)試的時(shí)候缺發(fā)現(xiàn)了奇怪的bug,點(diǎn)贊數(shù)量有時(shí)候會(huì)不正確,結(jié)果會(huì)比預(yù)期的大。
下面貼下關(guān)鍵代碼(項(xiàng)目使用了Mybatis-Plus):
public boolean like(Integer userId, Integer productId) { // 查詢(xún)是否有記錄,如果有記錄直接返回 Like like = getOne(new QueryWrapper<Like>().lambda() .eq(Like::getUserId, userId) .eq(Like::getProductId, productId)); if(like != null) { return true; } // 保存并商品點(diǎn)贊數(shù)加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)); }
看上去沒(méi)什么問(wèn)題,但是測(cè)試后數(shù)據(jù)卻不正確,為什么呢?
實(shí)際上這是一個(gè)并發(fā)問(wèn)題,只要在并發(fā)的情況下就會(huì)出現(xiàn)問(wèn)題,我們知道Spring Mvc是基于servlet的,servlet在接收到用戶(hù)請(qǐng)求后會(huì)從線程池中拿一個(gè)線程分配給它,每個(gè)請(qǐng)求都是一個(gè)單獨(dú)的線程。試想一下,如果A線程在執(zhí)行完查詢(xún)操作后,發(fā)現(xiàn)沒(méi)有記錄,隨后由于CPU調(diào)度,把控制權(quán)讓了出去,然后B線程執(zhí)行查詢(xún),也發(fā)現(xiàn)沒(méi)有記錄,這時(shí)候A和B線程都會(huì)執(zhí)行保存并商品點(diǎn)贊數(shù)加1這個(gè)操作,導(dǎo)致數(shù)據(jù)不正確。
CPU操作順序:A線程查詢(xún) -> B線程查詢(xún) -> A線程保存 -> B線程保存
下面使用JMeter模擬一下并發(fā)的情況,模擬用戶(hù)在1秒內(nèi)對(duì)商品執(zhí)行100次點(diǎn)贊請(qǐng)求,結(jié)果應(yīng)該是1,但得到的結(jié)果卻是28(實(shí)際結(jié)果不一定是28,可能是任何數(shù)字)。
解決方案
青銅版
使用synchronized關(guān)鍵字鎖住讀寫(xiě)操作,操作完成后釋放鎖
public boolean like(Integer userId, Integer productId) { String lock = buildLock(userId, productId); synchronized (lock) { // 查詢(xún)是否有記錄,如果有記錄直接返回 Like like = getOne(new QueryWrapper<Like>().lambda() .eq(Like::getUserId, userId) .eq(Like::getProductId, productId), false); if(like != null) { return true; } // 保存并商品點(diǎn)贊數(shù)加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; }
這里要注意一點(diǎn),使用String作為鎖時(shí)一定要調(diào)用intern()方法,intern()會(huì)先從常量池中查找有沒(méi)有相同的String,如果有就直接返回,沒(méi)有的話會(huì)把當(dāng)前String加入常量池,然后再返回。如果不調(diào)用這個(gè)方法鎖會(huì)失效。
JMeter性能數(shù)據(jù)
優(yōu)點(diǎn):
保證了正確性
缺點(diǎn):
性能太差,并發(fā)低的情況下還可以應(yīng)付,并發(fā)高時(shí)用戶(hù)體驗(yàn)極差
白銀版
點(diǎn)贊表user_id和product_id加上聯(lián)合索引,并使用try catch捕獲異常,防止報(bào)錯(cuò)。由于使用了聯(lián)合索引,所以不需要在新增前查詢(xún)了,mysql會(huì)幫我們做這件事。
public boolean like(Integer userId, Integer productId) { try { // 保存并商品點(diǎn)贊數(shù)加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性能數(shù)據(jù)
優(yōu)點(diǎn):
性能比上一個(gè)方案好
缺點(diǎn):
中規(guī)中矩,沒(méi)什么大的缺點(diǎn)
黃金版
使用Redis緩存點(diǎn)贊數(shù)據(jù)(點(diǎn)贊操作使用lua腳本實(shí)現(xiàn),保證操作的原子性),然后定時(shí)同步到mysql。
注意:Redis需要開(kāi)啟持久化,最好aof和rdb都開(kāi)啟,不然重啟數(shù)據(jù)就丟失了
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:點(diǎn)贊 0:取消點(diǎn)贊 if flag == '1' then -- 用戶(hù)set添加商品并商品點(diǎn)贊數(shù)加1 if redis.call('SISMEMBER', userId, productId) == 0 then redis.call('SADD', userId, productId) redis.call('INCR', productId) end else -- 用戶(hù)set刪除商品并商品點(diǎn)贊數(shù)減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性能數(shù)據(jù)
優(yōu)點(diǎn):
- 性能非常好
缺點(diǎn):
- 數(shù)據(jù)量多了內(nèi)存占用較高總結(jié)
如果對(duì)性能沒(méi)有要求,可以使用白銀版的實(shí)現(xiàn)方式,如果有要求,就使用黃金版的方式,內(nèi)存占用大的問(wèn)題也可以通過(guò)一些手段來(lái)解決,比如可以根據(jù)業(yè)務(wù)需求定期刪除一些不常用的緩存數(shù)據(jù),但是相對(duì)應(yīng)的,查詢(xún)的時(shí)候就需要在查詢(xún)失敗時(shí)再去查數(shù)據(jù)庫(kù)。
源碼
源碼地址:https://github.com/huajiayi/like-demo
源碼里有一些功能沒(méi)有實(shí)現(xiàn),比如定時(shí)同步功能,需要根據(jù)業(yè)務(wù)需求自行實(shí)現(xiàn)
總結(jié)
到此這篇關(guān)于Java SpringBoot點(diǎn)贊功能實(shí)現(xiàn)的文章就介紹到這了,更多相關(guān)Java SpringBoot點(diǎn)贊功能內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Maven添加Tomcat插件實(shí)現(xiàn)熱部署代碼實(shí)例
這篇文章主要介紹了Maven添加Tomcat插件實(shí)現(xiàn)熱部署代碼實(shí)例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-04-04java通過(guò)jni調(diào)用opencv處理圖像的方法
今天小編就為大家分享一篇java通過(guò)jni調(diào)用opencv處理圖像的方法,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2018-08-08SpringBoot實(shí)現(xiàn)人臉識(shí)別等多種登錄方式
本文主要介紹了SpringBoot實(shí)現(xiàn)人臉識(shí)別等多種登錄方式,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2022-05-05spring boot如何基于JWT實(shí)現(xiàn)單點(diǎn)登錄詳解
這篇文章主要介紹了spring boot如何基于JWT實(shí)現(xiàn)單點(diǎn)登錄詳解,用戶(hù)只需登錄一次就能夠在這兩個(gè)系統(tǒng)中進(jìn)行操作。很明顯這就是單點(diǎn)登錄(Single Sign-On)達(dá)到的效果,需要的朋友可以參考下2019-06-06Java RMI詳細(xì)介紹及簡(jiǎn)單實(shí)例
這篇文章主要介紹了Java RMI詳細(xì)介紹及簡(jiǎn)單實(shí)例的相關(guān)資料,需要的朋友可以參考下2017-02-02SpringBoot如何使用Scala進(jìn)行開(kāi)發(fā)的實(shí)現(xiàn)
這篇文章主要介紹了SpringBoot如何使用Scala進(jìn)行開(kāi)發(fā)的實(shí)現(xiàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2019-12-12MyBatis中的SQL映射文件配置結(jié)果映射的操作指南
MyBatis?是一款優(yōu)秀的?ORM?框架,它提供了多種配置方式來(lái)定義?SQL?語(yǔ)句以及結(jié)果映射規(guī)則,本文將介紹?MyBatis?中的?SQL?映射文件如何配置結(jié)果映射,包括常規(guī)類(lèi)型、集合類(lèi)型等多種情況,需要的朋友可以參考下2023-07-07