生產問題記錄之重復訂單以及Redisson分布式鎖的使用和拓展


問題分析

先說一下背景,最近我們的系統出現了少部分用戶對於限購一單的商品出現了重復下單的問題,造成了一定的損失,因此領導讓我去排查一下下單接口為什么會出現這個問題。

再說說拿到代碼后分析了一下下單接口,確實比較頭疼,因為歷史遺留原因,下單接口存在大量的冗余代碼和遠程調用。導致下單接口的執行時間非常的長,性能極低。另外分析數據庫后發現出現重復訂單的用戶,其實都是非正常用戶,就是通過下單接口存在的漏洞,用腳本刷下單才造成的重復訂單問題。另外我們的下單接口加的鎖有極大的缺陷。

就是因為上述兩個原因的疊加。以至於后面的刷單重復請求過來,下單接口執行時間,超過了鎖設置的時間,鎖超時釋放了。導致在第二次重復請求過來之前,第一次請求的代碼還沒有執行完畢,沒有入庫,而校驗重復的判斷,又依賴於上一次請求的入庫。通俗來說就是查詢是否重復的代碼,是去數據庫里查詢該用戶是否下單過。但此時第一次請求的訂單由於接口執行時間原因還沒入庫,所以第二次請求的重復檢驗就失效了。所以導致了重復訂單的問題

所以現在原因找到了,主要原因有二:

  • 訂單接口性能過低,運行時間長。且代碼邏輯不合適,校驗重復依賴於上一次請求的入庫
  • 公司使用的redis鎖存在較大缺陷,導致鎖時間失效之后就會被釋放,請求重復涌入

先來說說第一個問題,根據代碼分析,我們的下單接口存在大量的遠程調用以及查詢數據庫操作,還有各種冗余代碼。導致了下單接口奇慢無比,性能非常的低。另外判斷的邏輯不合理。最開始的判斷重復下單的邏輯要依賴於接口的最后的數據入庫,一但接口執行速度過慢,高並發的情況下,就會導致校驗無效,一般來說驗重的邏輯其實可以放到redis來做的。以上說的這些都是歷史遺留問題,在我來公司之前就已經存在了。並且因為時間緊迫,我又是剛接手,加上代碼邏輯復雜,短時間內要完成訂單接口的重構優化並不現實。並且下單接口的優化也不在本篇文章的討論序列,這篇文章重心還是放在討論鎖上。

那既然重構和優化下單接口的速度在短時間內不可能完成,因此只好研究第二個問題。其實理論上來說,只要鎖是有效的,即便前面的下單接口再慢再慢也不會出現重復的問題。但是問題偏偏就出現在這里,公司之前使用的redis鎖的工具類一直是存在問題的,只為鎖設置了過期時間,但是如果鎖過期時間內代碼還沒有執行完,鎖就失效了,后面的請求也就進來了。並且從各方面來說,替換redis鎖的成本是比較低和省時的。

解決方案

既然找到了問題,和適合當前形式的解決方案,即替換掉有問題的redis鎖。那接下來我們應該考慮技術選型,之前的redis鎖為什么會出問題,很大程度上是因為之前寫的redis鎖的工具類都是前人自己寫的工具類,主要思路是結合setnx和expire這兩個命令,為鎖設置過期時間的方式。並且由於單個人的思路是有局限性的,難免有考慮不周到的地方,所以就爆出來現在這個生產的問題。所以我為了盡可能的避免之前的問題,打算采用redis目前比較成熟的分布式鎖解決方案redission,這種成熟的方案,出問題的概率小,並且場景配套解決方案齊全。

解決過程

說干就干,配置好redission相關的配置和環境,(相應的配置文件什么的可以去百度,本文主要是講問題解決而不是環境搭建,故不贅述)。萬事俱備,代碼一寫准備上測試壓測.。

	//錯誤代碼示例
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
		lock.lock(5, TimeUnit.SECONDS);
		//業務代碼
		lock.unlock();
	}

壓測結果讓我比較傻眼,居然還是有重復的,並且看日志,頻繁的有一個報錯

attempt to unlock lock, not locked by current thread by node id: 9fb9c5fb-c505-4907-b01a-2cbc931a9d4f thread-id: 84

因此只好去翻文檔。在查閱文檔之后。發現之前對於redission的幾個api有幾個誤解的地方,因此我在這里總結一下

redission三個常用api

無參lock()

首先是lock方法,無參的lock()方法應該是用的最多的一個方法。無參的lock()一定要配合unlock來使用。否則很可能會導致死鎖。其實看到這里可能有人會問,這個鎖不用設置過期時間,那我怎么知道它什么時候過期呢。其實不用擔心這個問題。因為無參lock方法雖然沒有傳過期時間。但是會給對應的鎖的key一個默認的過期時間,我自己親測的話設置的過期時間是10秒。那么問題又來了,方法自動設置的過期時間太短,代碼沒有執行完,導致別的線程進來了怎么辦。這個問題也不用擔心。因為無參的lock方法有一個 “看門狗機制”,即“watch dog”,當他發現你的線程並沒有中止,會自動的給你的鎖續期。大概是會在時間還剩7秒的時候,重新續期到10s。通過這種續期的方式來保證鎖的安全性。

但是,使用這個lock方法一定要配合unlock來使用,這一點非常重要,不然的話,不解鎖,就會導致鎖一直在被續期,從而導致除非redis宕機,否則鎖一直不會被釋放。導致死鎖

	//最常用的方法,到期未執行完成可以自動續期
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
        try{
           lock.lock();
            //業務代碼
        }finally{
            //必須unlock 否則會死鎖
         lock.unlock();   
        }
	}

lock(long var1, TimeUnit var3)

這個方法就是我在上面代碼最開始用的方法,一般用的不多,該方法無需續期,到期自動解鎖,所以也無需調用unlock方法。由於他的到期自動解鎖,所以這個方法需要謹慎的使用,假設鎖到期之前,業務代碼還沒有執行完畢,那就比較危險了,意味着后面的線程可以拿到這把鎖,進入到業務代碼中。我上面的代碼就是用錯了這個方法,由於業務5s內沒有執行完畢,然后鎖自動被釋放了,導致后面的線程進入到了業務代碼中,又導致了訂單的重復。而那個報錯則是因為,業務代碼沒有5s沒有執行完,鎖被釋放了,所以報錯告訴你這個鎖不存在,所以沒有鎖需要被解開。

從以上兩個方法來看。雖然這兩個方法名稱一樣,都是區別還是挺大的,並且,如果不是特殊的業務要求,一般不建議使用帶參的lock方法,因為可能會存在安全問題,。除非是那種到期自動釋放鎖也不會影響后來線程的業務,才允許使用。但是這個方法的優點在於,可以避免死鎖,,即便是代碼執行期間所在的服務器掛了,因為無需手動解鎖,所以到期鎖自動被釋放

	//到期自動釋放 不會死鎖,但是存在安全問題
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
		lock.lock(5, TimeUnit.SECONDS);
		//業務代碼
		//鎖會自動釋放,所以無需手動unlock
	}

tryLock

這個方法也是用的比較多的一個方法,方法的完整參數為

	//第一個參數 等待鎖的時間
    //第二個參數 鎖的有效時間
    //第三個參數為單位
boolean tryLock(long var1, long var3, TimeUnit var5)
    

對於這個方法 ,假設我們這樣調用

boolean isAccquire  = lock.trylok(15,60,TimeUnit.SECONDS)

那這個意思就是 最多設置一個鎖有效期為60s,最多等待鎖15s,如果獲取到了返回true,15秒還沒有獲取到鎖返回false。這個方法適用於,如果超時沒有獲取到鎖,則做相應的處理,拋異常或者執行其他的代碼都行。例如

	//也比較常用,如果沒有獲取到鎖可做特殊處理 由於鎖有過期時間,所以不會造成死鎖
	@GetMapping("/order")
	public void test(@RequestParam String id, @RequestParam String test) throws InterruptedException {
		RLock lock = redissonClient.getLock("lock1" + id);
        
		boolean isAccquire  = lock.trylok(15,60,TimeUnit.SECONDS);
        if(!isAccquire){
            throw new Exception()
        }
        //獲取到了繼續執行下面的業務代碼
        //解鎖需要到finally里執行
        lock.unlock();
	}


總結

本篇文章通過記錄這次解決的生產問題的過程為切入點,拓展了redission分布式鎖的一些比較常規的使用,相應的機制以及使用的一些誤區。同時也為排查相似的生產問題,提供了出現問題的原因和相關解決方案------即分布式鎖。所以有問題還是需要多思考,多看文檔。最后總結一下redission分布式鎖三個常用方法

  • lock()常用,有自動續期機制,所以不會出現鎖到期多個線程進入業務代碼的情況。需要手動解鎖,否則會死鎖,效率上比trylok高。但是沒有trylok靈活
  • lock(long var1, TimeUnit var3)不常用, 到期自動解鎖,會出現鎖到期多個線程進入業務代碼的情況,但是不會死鎖,因為到期自動解鎖
  • trylok(long var1,long var2,TimeUnit t)常用,靈活度高,可以設置獲取不到鎖做相應的代碼處理或異常處理,需要手動解鎖


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM