靈活運用分散式鎖解決資料重複插入問題

vivo網際網路技術發表於2021-07-26

一、業務背景

許多面向使用者的網際網路業務都會在系統後端維護一份使用者資料,快應用中心業務也同樣做了這件事。快應用中心允許使用者對快應用進行收藏,並在服務端記錄了使用者的收藏列表,通過使用者賬號標識OpenID來關聯收藏的快應用包名。

為了使使用者在快應用中心的收藏列表能夠與快應用Menubar的收藏狀態打通,我們同時也記錄了使用者賬號標識OpenID與客戶端本地標識local_identifier的繫結關係。因為快應用Manubar由快應用引擎持有,獨立於快應用中心外,無法通過賬號體系獲取到使用者賬號標識,只能獲取到客戶端本地標識local_identifier,所以我們只能通過二者的對映關係來保持狀態同步。

在具體實現上,我們是在使用者啟動快應用中心的時候觸發一次同步操作,由客戶端將OpenID和客戶端本地標識提交到服務端進行繫結。服務端的繫結邏輯是:判斷OpenID是否已經存在,如果不存在則插入資料庫,否則更新對應資料行的local_identifier欄位(因為使用者可能先後在兩個不同的手機上登入同一個vivo賬號)。在後續的業務流程中,我們就可以根據OpenID查詢對應的local_identifier,反之亦可。

但是程式碼上線一段時間後,我們發現t_account資料表中居然存在許多重複的OpenID記錄。根據如上所述的繫結邏輯,這種情況理論上是不應該發生的。所幸這些重複資料並沒有對更新和查詢的場景造成影響,因為在查詢的SQL中我們加入了LIMIT 1的限制,因此針對一個OpenID的更新和查詢操作實際上都只作用於ID最小的那條記錄。

二、問題分析與定位

雖然冗餘資料沒有對實際業務造成影響,但是這種明顯的資料問題也肯定是不能容忍的。因此我們開始著手排查問題。

首先想到的就是從資料本身入手。先通過對t_account表資料進行粗略觀察,發現大約有3%的OpenID會存在重複的情況。也就是說重複插入的情況是偶現的,大多數請求的處理都是按照預期被正確處理了。我們對程式碼重新進行了走讀,確認了程式碼在實現上確實不存在什麼明顯的邏輯錯誤。

我們進一步對資料進行細緻觀察。我們挑選了幾個出現重複情況的OpenID,將相關的資料記錄查詢出來,發現這些OpenID重複的次數也不盡相同,有的只重複一次,有的則更多。但是,這時候我們發現了一個更有價值的資訊——這些相同OpenID的資料行的建立時間都是完全相同的,而且自增ID是連續的。

於是,我們猜測問題的產生應該是由於併發請求造成的!我們模擬了客戶端對介面的併發呼叫,確實出現了重複插入資料的現象,進一步證實了這個猜測的合理性。但是,明明客戶端的邏輯是每個使用者在啟動的時候進行一次同步,為什麼會出現同一個OpenID併發請求呢?

事實上,程式碼的實際執行並不如我們想象中的那麼理想,計算機的執行過程中往往存在一些不穩定的因素,比如網路環境、伺服器的負載情況。而這些不穩定因素就可能導致客戶端傳送請求失敗,這裡的“失敗”可能並不意味著真正的失敗,而是可能整個請求時間過長,超過了客戶端設定的超時時間,從而被人為地判定為失敗,於是通過重試機制再次傳送請求。那麼最終就可能導致同樣的請求被提交了多次,而且這些請求也許在中間某個環節被阻塞了(比如當伺服器的處理執行緒負載過大,來不及處理請求,請求進入了緩衝佇列),當阻塞緩解後這幾個請求就可能在很短的時間內被併發處理了。

這其實是一個典型的併發衝突問題,可以把這個問題簡單抽象為:如何避免併發情況下寫入重複資料。事實上,有很多常見的業務場景都可能面臨這個問題,比如使用者註冊時不允許使用相同的使用者名稱。

一般來說,我們在處理這類問題時,最直觀的方式就是先進行一次查詢,當判斷資料庫中不存在當前資料時才允許插入。

顯然,這個流程從單個請求的角度來看是沒有問題的。但是當多個請求併發時,請求A和請求B都先發起一次查詢,並且都得到結果是不存在,於是兩者都又執行了資料插入,最終導致併發衝突。

三、探索可行的方案

既然問題定位到了,接下來就要開始尋求解決方案了。面對這種情況,我們通常有兩種選擇,一種是讓資料庫來解決,另一種是由應用程式來解決。

3.1 資料庫層面處理——唯一索引

當使用MySQL資料庫及InnoDB儲存引擎時,我們可以利用唯一索引來保障同一個列的值具有唯一性。顯然,在t_account這張表中,我們最開始是沒有為open_id列建立唯一索引的。如果我們想要此時加上唯一索引的話,可以利用下列的ALTER TABLE語句。

ALTER TABLE t_account ADD UNIQUE uk_open_id( open_id );

一旦為open_id列加上唯一索引後,當上述併發情況發生時,請求A和請求B中必然有一者會優先完成資料的插入操作,而另一者則會得到類似錯誤。因此,最終保證t_account表中只有一條openid=xxx的記錄存在。

Error Code: 1062. Duplicate entry 'xxx' for key 'uk_open_id'

3.2 應用程式層面處理——分散式鎖

另一種解決的思路是我們不依賴底層的資料庫來為我們提供唯一性的保障,而是靠應用程式自身的程式碼邏輯來避免併發衝突。應用層的保障其實是一種更具通用性的方案,畢竟我們不能假設所有系統使用的資料持久化元件都具備資料唯一性檢測的能力。

那具體怎麼做呢?簡單來說,就是化並行為序列。之所以我們會遇到重複插入資料的問題,是因為“檢測資料是否已經存在”和“插入資料”兩個動作被分割開來。由於這兩個步驟不具備原子性,才導致兩個不同的請求可以同時通過第一步的檢測。如果我們能夠把這兩個動作合併為一個原子操作,就可以避免資料衝突了。這時候我們就需要通過加鎖,來實現這個程式碼塊的原子性。

對於Java語言,大家最熟悉的鎖機制就是synchronized關鍵字了。

public synchronized void submit(String openId, String localIdentifier){
    Account account = accountDao.find(openId);
    if (account == null) {
        // insert
    }
    else {
        // update
    }
}

但是,事情可沒這麼簡單。要知道,我們的程式可不是隻部署在一臺伺服器上,而是部署了多個節點。也就是說這裡的併發不僅僅是執行緒間的併發,而是程式間的併發。因此,我們無法通過java語言層面的鎖機制來解決這個同步問題,我們這裡需要的應該是分散式鎖。

3.3 兩種解決方案的權衡

基於以上的分析,看上去兩種方案都是可行的,但最終我們選擇了分散式鎖的方案。為什麼明明第一種方案只需要簡單地加個索引,我們卻不採用呢?

因為現有的線上資料已然在open_id列上存在重複資料,如果此時直接去加唯一索引是無法成功的。為了加上唯一索引,我們必須首先將已有的重複資料先進行清理。但是問題又來了,線上的程式一直持續執行著,重複資料可能會源源不斷地產生。那我們能不能找一個使用者請求不活躍的時間段去進行清理,並在新的重複資料插入之前完成唯一索引的建立?答案當然是肯定的,只不過這種方案需要運維、DBA、開發多方協同處理,而且由於業務特性,最合適的處理時間段應該是凌晨這種夜深人靜的時候。即便是採取這麼苛刻的修復措施,也不能百分之百完全保證資料清理完成到索引建立之間不會有新的重複資料插入。因此,基於唯一索引的修復方案乍看之下非常合適,但是具體操作起來還是略為麻煩。

事實上,建立唯一索引最合適的契機應該是在系統最初的設計階段,這樣就能有效避免重複資料的問題。然而木已成舟,在當前這個情景下,我們還是選擇了可操作性更強的分散式鎖方案。因為選擇這個方案的話,我們可以先上線加入了分散式鎖修復的新程式碼,阻斷新的重複資料插入,然後再對原有的重複資料執行清理操作,這樣一來只需要修改程式碼並一次上線即可。當然,待問題徹底解決之後,我們可以重新再考慮為資料表加上唯一索引。

那麼接下來,我們就來看看基於分散式鎖的方案如何實現。首先我們先來回顧一下分散式鎖的相關知識。

四、分散式鎖概述

4.1 分散式鎖需要具備哪些特性?

  • 在分散式系統環境下,同一時間只有一臺機器的一個執行緒可以獲取到鎖;

  • 高可用的獲取鎖與釋放鎖;

  • 高效能的獲取鎖與釋放鎖;

  • 具備可重入特性;

  • 具備鎖失效機制,防止死鎖;

  • 具備阻塞/非阻塞鎖特性。

4.2 分散式鎖有哪些實現方式?

分散式鎖實現主要有如下三種:

  • 基於資料庫實現分散式鎖;

  • 基於Zookeeper實現分散式鎖;

  • 基於Redis實現分散式鎖;

4.2.1 基於資料庫的實現方式

基於資料庫的實現方式就是直接建立一張鎖表,通過操作表資料來實現加鎖、解鎖。以MySQL資料庫為例,我們可以建立這樣一張表,並且對method_name進行加上唯一索引的約束:

CREATE TABLE `myLock` (
 `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
 `method_name` varchar(100) NOT NULL DEFAULT '' COMMENT '鎖定的方法名',
 `value` varchar(1024) NOT NULL DEFAULT '鎖資訊',
 PRIMARY KEY (`id`),
 UNIQUE KEY `uidx_method_name` (`method_name `) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COMMENT='鎖定中的方法';

然後,我們就可以通過插入資料和刪除資料的方式來實現加鎖和解鎖:

#加鎖
insert into myLock(method_name, value) values ('m1', '1');
 
#解鎖
delete from myLock where method_name ='m1';

基於資料庫實現的方式雖然簡單,但是存在一些明顯的問題:

  • 沒有鎖失效時間,如果解鎖失敗,就會導致鎖記錄永遠留在資料庫中,造成死鎖。

  • 該鎖不可重入,因為它不認識請求方是不是當前佔用鎖的執行緒。

  • 當前資料庫是單點,一旦當機,鎖機制就會完全崩壞。

4.2.2 基於Zookeeper的實現方式

ZooKeeper是一個為分散式應用提供一致性服務的開源元件,它內部是一個分層的檔案系統目錄樹結構,規定同一個目錄下的節點名稱都是唯一的。

ZooKeeper的節點(Znode)有4種型別:

  • 持久化節點(會話斷開後節點還存在)

  • 持久化順序節點

  • 臨時節點(會話斷開後節點就刪除了)

  • 臨時順序節點

當一個新的Znode被建立為一個順序節點時,ZooKeeper通過將10位的序列號附加到原始名稱來設定Znode的路徑。例如,如果將具有路徑/mynode的Znode建立為順序節點,則ZooKeeper會將路徑更改為/mynode0000000001,並將下一個序列號設定為0000000002,這個序列號由父節點維護。如果兩個順序節點是同時建立的,那麼ZooKeeper不會對每個Znode使用相同的數字。

基於ZooKeeper的特性,可以按照如下方式來實現分散式鎖:

  • 建立一個目錄mylock;

  • 執行緒A想獲取鎖就在mylock目錄下建立臨時順序節點;

  • 獲取mylock目錄下所有的子節點,然後獲取比自己小的兄弟節點,如果不存在,則說明當前執行緒順序號最小,獲得鎖;

  • 執行緒B獲取所有節點,判斷自己不是最小節點,設定監聽比自己次小的節點;

  • 執行緒A處理完,刪除自己的節點,執行緒B監聽到變更事件,判斷自己是不是最小的節點,如果是則獲得鎖。

由於建立的是臨時節點,當持有鎖的執行緒意外當機時,鎖依然可以得到釋放,因此可以避免死鎖的問題。另外,我們也可以通過節點排隊監聽機制實現阻塞特性,也可以通過在Znode中攜帶執行緒標識來實現可重入鎖。同時,由於ZooKeeper叢集的高可用特性,分散式鎖的可用性也能夠得到保障。不過,因為需要頻繁的建立和刪除節點,Zookeeper方式在效能上不如Redis方式。

4.2.3 基於Redis的實現方式

Redis是一個開源的鍵值對(Key-Value)儲存資料庫,其基於記憶體實現,效能非常高,常常被用作快取。

基於Redis實現分散式鎖的核心原理是:嘗試對特定key進行set操作,如果設定成功(key之前不存在)了,則相當於獲取到鎖,同時對該key設定一個過期時間,避免執行緒在釋放鎖之前退出造成死鎖。執行緒執行完同步任務後主動釋放鎖則通過delete命令來完成。

這裡需要特別注意的一點是如何加鎖並設定過期時間。有的人會使用setnx + expire這兩個命令來實現,但這是有問題的。假設當前執行緒執行setnx獲得了鎖,但是在執行expire之前當機了,就會造成鎖無法被釋放。當然,我們可以將兩個命令合併在一段lua指令碼里,實現兩條命令的原子提交。

其實,我們簡單利用set命令可以直接在一條命令中實現setnx和設定過期時間,從而完成加鎖操作:

SET key value [EX seconds] [PX milliseconds] NX

解鎖操作只需要:

DEL key

五、基於Redis分散式鎖的解決方案

在本案例中,我們採用了基於Redis實現分散式鎖的方式。

5.1 分散式鎖的Java實現

由於專案採用了Jedis框架,而且線上Redis部署為叢集模式,因此我們基於redis.clients.jedis.JedisCluster封裝了一個RedisLock類,提供加鎖與解鎖介面。

public class RedisLock {
 
    private static final String LOCK_SUCCESS = "OK";
    private static final String LOCK_VALUE = "lock";
    private static final int EXPIRE_SECONDS = 3;
 
    @Autowired
    protected JedisCluster jedisCluster;
 
    public boolean lock(String openId) {
        String redisKey = this.formatRedisKey(openId);
        String ok = jedisCluster.set(redisKey, LOCK_VALUE, "NX", "EX", EXPIRE_SECONDS);
        return LOCK_SUCCESS.equals(ok);
    }
 
    public void unlock(String openId) {
        String redisKey = this.formatRedisKey(openId);
        jedisCluster.del(redisKey);
    }
 
    private String formatRedisKey(String openId){
        return "keyPrefix:" + openId;
    }
}

在具體實現上,我們設定了3秒鐘的過期時間,因為被加鎖的任務是簡單的資料庫查詢和插入,而且伺服器與資料庫部署在同個機房,正常情況下3秒鐘已經完全能夠足夠滿足程式碼的執行。

事實上,以上的實現是一個簡陋版本的Redis分散式鎖,我們在實現中並沒有考慮執行緒的可重入性,也沒有考慮鎖被其他程式誤釋放的問題,但是它在這個業務場景下已經能夠滿足我們的需求了。假設推廣到更為通用的業務場景,我們可以考慮在value中加入當前程式的特定標識,並在上鎖和釋放鎖的階段做相對應的匹配檢測,就可以得到一個更為安全可靠的Redis分散式鎖的實現了。

當然,像Redission之類的框架也提供了相當完備的Redis分散式鎖的封裝實現,在一些要求相對嚴苛的業務場景下,我建議直接使用這類框架。由於本文側重於介紹排查及解決問題的思路,因此沒有對Redisson分散式的具體實現原理做更多介紹,感興趣的小夥伴可以在網上找到非常豐富的資料。

5.2 改進後的程式碼邏輯

現在,我們可以利用封裝好的RedisLock來改進原來的程式碼了。

public class AccountService {
 
    @Autowired
    private RedisLock redisLock;
 
    public void submit(String openId, String localIdentifier) {
        if (!redisLock.lock(openId)) {
            // 如果相同openId併發情況下,執行緒沒有搶到鎖,則直接丟棄請求
            return;
        }
 
        // 獲取到鎖,開始執行使用者資料同步邏輯
        try {
            Account account = accountDao.find(openId);
            if (account == null) {
                // insert
            } else {
                // update
            }
        } finally {
            // 釋放鎖
            redisLock.unlock(openId);
        }
    }
}

5.3 資料清理

最後再簡單說一下收尾工作。由於重複資料的資料量較大,不太可能手工去慢慢處理。於是我們編寫了一個定時任務類,每隔一分鐘執行一次清理操作,每次清理1000個重複的OpenID,避免短時間內大量查詢和刪除操作對資料庫效能造成影響。當確認重複資料已經完全清理完畢後就停掉定時任務的排程,並在下一次版本迭代中將此程式碼移除。

六、總結

在日常開發過程中難免會各種各樣的問題,我們要學會順藤摸瓜逐步分析,找到問題的根因;然後在自己的認知範圍內儘量去尋找可行的解決方案,並且仔細權衡各種方案的利弊,才能最終高效地解決問題。

相關文章