業(yè)務(wù)系統(tǒng)如何正確實現(xiàn)防重名功能

常見但是錯誤的實現(xiàn)

在業(yè)務(wù)系統(tǒng)中防重名是一個非常普遍的需求,例如用戶注冊時不允許用戶名重復(fù)、已登錄用戶不可以在自己的賬號范圍內(nèi)創(chuàng)建同名的某種實體等。很多人在實現(xiàn)的時候都是簡單的先判斷名字是否重復(fù),如果沒有則執(zhí)行插入操作,如下:

    public void register(User user) {
        // 判斷是否重復(fù) (1)
        if (userMapper.selectExist(user.getUsername())) {
            // 報錯
            throw new XXXException();
        }
        
        // 執(zhí)行注冊 (2)
        userMapper.insertSelective(xxxModel);
    }

實際上這個邏輯存在嚴(yán)重的漏洞,在"高并發(fā)"下會出現(xiàn)用戶名重復(fù)的情況,而且數(shù)據(jù)庫操作執(zhí)行的越慢,重復(fù)的概率就會越高。我們先來分析下原因:

假設(shè)有A、B兩個用戶都使用了同一個叫作"bruce"的用戶名,且恰好"同時"點擊了注冊按鈕,請求也恰好"同時"到達(dá)服務(wù)器。這時候你的web服務(wù)會啟動兩條線程來處理這兩個請求,于是A, B同時執(zhí)行數(shù)據(jù)庫查詢 (1) 來判斷是否重名,因為此時表中還不存在username列為bruce的行,所以A, B都會順利通過檢查,然后各自插入一條記錄,從而導(dǎo)致用戶名重復(fù)。

我相信在相當(dāng)多的系統(tǒng)中都會有類似的代碼,當(dāng)然不一定是用戶注冊的場景。在多數(shù)情況下問題并不會被暴露出來,因為你得承認(rèn),絕大多數(shù)人做的系統(tǒng)QPS都沒有"那么"高,畢竟中國BBATJ就這么幾家,小公司還是占大多數(shù)的。但是站在技術(shù)的角度上講這是一種錯誤,應(yīng)該避免。

不能把問題扔給數(shù)據(jù)庫

如何解決?在數(shù)據(jù)庫層面,最簡單的就是給username字段加唯一約束,這是最笨的方式,但唯一約束會嚴(yán)重制約擴(kuò)展能力(讀寫分離、分庫分表),同時降低系統(tǒng)的健壯性(通過數(shù)據(jù)庫報錯來反饋業(yè)務(wù)錯誤信息)。除了唯一索引,還可以鎖表,但是我覺得任何一個有責(zé)任心的程序員都不會這樣實現(xiàn)。存儲過程?No, 不存在的。所以這種業(yè)務(wù)問題根本就不是DB層應(yīng)該考慮的事情。

問題拋給業(yè)務(wù)層。現(xiàn)在的場景是要保證同一時刻,對于同一個用戶名只能有一個人注冊成功。我們首先想到的是加鎖了,synchronized?可以,但是只適用于單例模式,相信在互聯(lián)網(wǎng)公司沒人會這么干,所以我們需要分布式鎖。此外還要要注意你不能簡單粗暴的把整個注冊邏輯用一把鎖給鎖了,那樣會嚴(yán)重降低系統(tǒng)的吞吐量,不可取。想在問題被拆分成了兩個,如何實現(xiàn)分布式鎖 + 如何盡量減少鎖的粒度。

正確實現(xiàn)Redis鎖 && 降低鎖粒度

實現(xiàn)分布式鎖最簡單的方式就是用Redis了,但是Redis鎖坑很多,一不注意就會有漏洞。正確實現(xiàn)的第一個問題是,判斷key是否存在跟創(chuàng)建key(鎖)要原子性完成。這個原因非常簡單,用戶注冊就能說明問題。Redis中要想原子性有三種方式,一是利用Redis的單線程特性想辦法用一條指令完成任務(wù),如setnx;二是使用Pipeline連續(xù)執(zhí)行多條命令;三是執(zhí)行l(wèi)ua腳本。顯然第一種方法最簡單。在Jedis中,我們可以直接使用5個參數(shù)的set()方法,如:

// null 表示已經(jīng)存在
String ret = cmd.set(key, val, 'nx', 'ex', expireSecond);

這個方法能同時完成:

  • 判斷key是否存在,存在返回null
  • 創(chuàng)建key, 設(shè)置成指定值
  • 指定過期時間

那么問題來了,key和value應(yīng)該設(shè)置成啥才好?這里是要分場景的。

如果你是在做一把全局鎖讓多個實例都來爭搶的話,那么key應(yīng)該設(shè)置成一個固定的值,value設(shè)置成一個隨機(jī)數(shù)(nonce)。在釋放鎖的時候,程序要先判斷當(dāng)前redis里key的value值是不是自己之前設(shè)置的nonce, 如果是才能安全的刪除key,否則就啥也不干。這是為了防止某個實例因為業(yè)務(wù)邏輯處理超時而導(dǎo)致意外的釋放了別人的鎖。我們來分析一下,假如key的過期時間是5s, 然后你在某次執(zhí)行過程中用了10s才完成處理,那么此時鎖有可能會被被人給搶走。如果你在10s后只是簡單的刪除key來釋放鎖的話就會把別人的鎖給釋放了,對吧。

另一種場景是防重,比如用戶注冊。這種情況下不需要nonce, 即value可以忽略,但是key要設(shè)為 前綴 + 用戶標(biāo)識 的組合。這樣做的好處是除非多個人都用了同一個用戶名來注冊,否則他們之間不會產(chǎn)生競爭,從而降低了鎖的粒度。

這兩種場景應(yīng)該足夠用了。但是這里還有其他要考慮的問題: 如果Redis不可用了怎么辦?你是讓程序報錯等待人工干預(yù),還是進(jìn)行“降級”忽略問題?這就要看業(yè)務(wù)需求了,比如用戶注冊,如果系統(tǒng)的QPS沒那么高的話,redis鎖失效可以允許繼續(xù)注冊,因為出現(xiàn)重名的概率很低。無論如何一定要想著redis有連接失敗的可能性,這很重要。

End

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容