併發場景下資料寫入功能的實現

Gevin發表於2022-05-26

enter image description here

1. 準備工作

1.1 理論基礎

在併發場景下,實現資料的正確寫入,主要需理解“鎖”相關的原理和技術。

併發時寫資料,需要考慮要不要上鎖,根本原因是,資料存在共享且資料會發生變化,即多執行緒會同時讀寫同一資料。 若資料不存在共享,即不同的執行緒讀寫不同的資料,不需要上鎖; 若資料共享,所有執行緒對資料只讀不寫,也不需要上鎖 若資料共享,有的執行緒讀資料,有的執行緒寫資料,則需要上鎖。

當多個執行緒同時訪問同一資料,並且至少有一個執行緒會寫這個資料,這種情況被稱之為“資料競爭”。

併發場景下,鎖的作用,是併發改為序列,以保證資料的一致性(更具體而言,透過上鎖,解決了併發執行程式時的原子性、可見性和有序性問題,有興趣的同學可以近一步深入相關理論,本文以實戰為主,不在展開)。

故,在併發場景下讀寫資料,首先要分析是否存在“資料競爭”問題,若存在,需要“上鎖”。資料競爭如果發生在本地應用中,則用本地鎖;如果發生在分散式服務間中,則使用分散式鎖;本地鎖和分散式鎖在原理上相同,不用分別討論。

鎖相關技術,有“悲觀鎖”和“樂觀鎖”兩種。

(1)悲觀鎖

我們通常說的鎖,如無特殊說明,就是指“悲觀鎖”。它是透過一些技術手段,實現執行緒或服務間的互斥和同步,其使用時,有顯示(或隱式)的鎖持有/釋放操作。 由於上鎖本身要損耗效能,上鎖後併發處理變成序列,故上鎖是比較影響系統效能的操作;且鎖的應用不當,會潛在死鎖/活鎖風險;故悲觀鎖的使用要慎重。

(2)樂觀鎖

樂觀鎖通常又叫“無鎖技術”,它不是透過“上鎖”把併發改序列的方式保證資料一致性,而是透過CAS(Compare And Swap)方式來實現,由於CAS通常很快,該過程也不用“上鎖”,效能損耗少。不過,透過CAS併發寫資料時,通常伴有“自旋”,即當出現多個寫併發時,只有一個能寫入成功,其他要自旋後再次寫入,直至寫入成功或因超時/超過重試次數失敗。 自旋會帶來效能開銷,頻繁自旋的效能開銷會超過上鎖。故樂觀鎖通常用在併發不太激烈的場景中,且在該場景下效能比悲觀鎖要好,而在高併發場景下,建議使用悲觀鎖。

1.2 業務場景及分析

本文主題是介紹併發寫資料的幾種方案,為此,我們先確立幾個常用的業務場景,並做簡單分析。

寫資料,我們討論最常見的把資料寫入資料庫的場景,主要包括新建資料和修改資料兩種具體場景,兩個場景不完全一致,分別討論。

(1) 往資料庫寫新資料

往資料庫裡寫信資料時,如果資料直接相互獨立,即不存在“資料競爭”,則按照1.1節的理論,此時不需要考慮鎖的問題。

對於存在“資料競爭”的場景,我們考慮寫入流水碼的場景:假設建立資料有個編碼欄位,形如“CON_0001”,其後半段的“0001”是流水碼,需要根據當前最大的流水碼+1 來計算待建立資料的流水碼。這裡存在資料範圍的競爭,併發建立資料時,如果不做併發控制,會建立多個編碼相同的資料。

(2)更新資料庫記錄

併發更新資料庫記錄時,如果可以確保各併發請求要更新的資料各不相同,則不存在“資料競爭”,不需要上鎖;而併發更新同一資料記錄時,如果不做併發控制,可能出現一個寫請求覆蓋另一個寫請求的情況,導致最終結果錯誤。

這裡我們考慮“訪問量+1”的場景,即設計一個訪問量表,每次請求給訪問量+1,如當前訪問量為5,若5個併發請求同時為該記錄+1,正確的結果為10,但如果不加併發控制,結果通常會<10。

2. 併發插入流水碼的實現方案

2.1 業務邏輯分析

業務邏輯如下:

  1. 取出當前資料庫中流水碼最大的一條記錄
  2. 從編碼欄位中解析出當前最大流水碼
  3. 流水碼+1,建立新紀錄入庫

程式碼實現即:

private Integer addEntity() {
        ConcurrentEntity entity = dataMapper.getLatestConcurrentEntity();
        int nextNumber = entity == null ? 1 : getNextNumberByCode(entity.getCode());
        String code = String.format("CON_%04d", nextNumber);
        return dataMapper.insertConcurrentEntity(new ConcurrentEntity(code));
    }

private int getNextNumberByCode(String code) {
        int index = code.lastIndexOf("_");
        String number = code.substring(index + 1);
        return Integer.parseInt(number) + 1;
    }

該業務在併發場景下,主要存在原子性隱患,即addEntity()中的程式碼,需要作為一個整體全部執行完,若多個執行緒交替執行執行逐行程式碼,某個執行緒讀取到最新流水碼後,該碼被其他執行緒改了,本執行緒不可知,導致寫入錯誤資料。

故本業務場景的併發中,主要避免原子性和可見性問題,最直接的方式,是透過上鎖解決。

2.2 實現方案

2.2.1 方案1:在程式碼中上鎖

private final Lock lock = new ReentrantLock(false);


    public Integer addEntityByLock() {
        synchronized (this) {
            return addEntity();
        }
    }

    public Integer addEntityByLock2() {
        lock.lock();
        try {
            return addEntity();
        } finally {
            lock.unlock();
        }
    }

在分散式系統中,lock可以用redisson或curator提供的分散式鎖進行例項化。

2.2.2 方案2: 在資料庫中上鎖

鎖除了可以在程式碼中用,也可以直接用到資料庫上,select ... for update語句即可在資料庫中上寫鎖,另由於業務執行的原子性問題,需要把addEntity()中的邏輯放到同一個事務中去。

@Transactional(isolation = Isolation.REPEATABLE_READ)
    public Integer addEntityByTransactionWithLock() {
        return addEntity();
    }

這裡的事務隔離級別,選擇RC或RR均可。

注,這個實現中,addEntity()中對ConcurrentEntity的查詢,改成了加鎖讀的方式:

@Select("SELECT * FROM concurrent_entity\n" +
            "ORDER BY code DESC\n" +
            "LIMIT 1\n" +
            "FOR UPDATE;")
    ConcurrentEntity getLatestConcurrentEntityWithWriteLock();

2.2.3 效能對比

對於1000個併發請求,三種方法效能對比如下:

executeConcurrentAddByLock1: 1000 併發,花費時間 1179 ms
executeConcurrentAddByLock2: 1000 併發,花費時間 863 ms
executeConcurrentAddByTransactionWithLock: 1000 併發,花費時間 1284 ms

2.3 其他方案

本業務場景中,由於流水碼的計算,存在資料競爭問題,所以併發時需要上鎖,如果能避免資料競爭,就可以避免併發問題。針對本案例,可以把流水碼獲取的邏輯放到redis中去,redis本身是單執行緒的,避免了流水碼的資料競爭,進而避免了上鎖的開銷,而redis本身又是高效能的,故這個方案理論上比上述方案的效能只高不低。

3. 併發更新訪問量的實現方案

3.1 業務分析

併發更新資料庫中的訪問量時,存在的“資料競爭”問題,也是“原子性”隱患。如果更新本身是一個原子操作,則不存在併發問題;如果更新操作分兩步,先讀取當前資料,然後+1後重新寫入,則該操作不是原子的,需要上鎖。

3.2 實現方案

3.2.1 方案1: 原子更新

public Integer increaseVisitCountAtomically(int id) {
        return dataMapper.increaseConcurrentVisitAtomic(id);
    }

@Update("UPDATE concurrent_visit\n" +
            "SET visit = visit + 1, update_time = NOW()\n" +
            "WHERE id = #{id};")
    Integer increaseConcurrentVisitAtomic(int id);

3.2.2 方案2: 程式碼中上鎖

public Integer increaseVisitCountByLock(int id) {
        synchronized (this) {
            return increaseVisitCount(id);
        }
    }

 private Integer increaseVisitCount(int id) {
        ConcurrentVisit concurrentVisit = dataMapper.getConcurrentVisitObject(id);
        concurrentVisit.increaseVisit().updateUpdateTime();
        return dataMapper.updateConcurrentVisit(concurrentVisit);
    }

3.2.3 方案3: 資料庫中上鎖

    @Transactional()
    public Integer increaseVisitCountByTransaction(int id) {
        return increaseVisitCount(id, true);
    }

    private Integer increaseVisitCount(int id, boolean withLock) {
        ConcurrentVisit concurrentVisit = withLock ? dataMapper.getConcurrentVisitObjectWithLock(id)
                : dataMapper.getConcurrentVisitObject(id);
        return dataMapper.increaseConcurrentVisit(concurrentVisit.increaseVisit().updateUpdateTime());
    }

    @Select("SELECT * FROM concurrent_visit\n" +
            "WHERE id = #{id}\n" +
            "FOR UPDATE;")
    ConcurrentVisit getConcurrentVisitObjectWithLock(int id);

3.2.4 方案4: 使用樂觀鎖

使用樂觀鎖時,需要一個遞增的版本欄位(version),每次update 成功時,version都要 +1,version要作為更新前的compare欄位,若當前讀到的version與資料庫中的version不一致,則更新失敗。

public Integer increaseVisitCountOptimistically(int id) {
        ConcurrentVisit concurrentVisit = dataMapper.getConcurrentVisitObject(id);
        return dataMapper.increaseConcurrentVisitOptimistically(concurrentVisit.increaseVisit().updateUpdateTime());
    }

    @Update("UPDATE concurrent_visit\n" +
            "SET visit = #{visit}, update_time = #{updateTime}, version = #{version} + 1\n" +
            "WHERE id = #{id} and version = #{version};")
    Integer increaseConcurrentVisitOptimistically(ConcurrentVisit concurrentVisit);

使用樂觀鎖時,compare不一致,會更新失敗,這時需要自旋重試,故上述程式碼可以最佳化為:

public Integer increaseVisitCountOptimisticallyWithRetry(int id) {
        int result = 0;
        int maxRetry = 5;
        long interval = 20L;

        for (int i = 0; i < maxRetry; i++) {
            result = increaseVisitCountOptimistically(id);
            if (result > 0) {
                break;
            }
            interval = interval + i * 50;
            helper.sleep(interval);

        }
        return result;
    }

3.2.5 效能比較

發起10000個併發更新操作,結果如下:


executeConcurrentAddAtomically: 10000 併發,花費時間 2112 ms
executeConcurrentAddByLock: 10000 併發,花費時間 5796 ms
executeConcurrentAddByTransaction: 10000 併發,花費時間 3902 ms
executeConcurrentAddOptimisticallyWithRetry: 10000 併發,花費時間 5998 ms

mysql> select * from concurrent_visit;
+----+-------------+-------+---------+---------------------+---------------------+
| id | resourceKey | visit | version | create_time         | update_time         |
+----+-------------+-------+---------+---------------------+---------------------+
|  1 | resource1   | 39925 |    9925 | 2022-03-31 11:42:54 | 2022-04-01 12:06:36 |
+----+-------------+-------+---------+---------------------+---------------------+

可以看到:

  1. 併發比較激烈時,樂觀鎖效能最差,而且有些請求,即使超過了最大重試次數,也沒更新成功
  2. 把鎖上在資料庫中,效能比所在程式碼上還要好,原因是,資料庫上的鎖是經過充分的效能最佳化的,而且鎖的顆粒度更小,而我們這個業務場景下,程式碼中鎖的顆粒度已經很難再縮小了。鎖的顆粒度,決定了併發程度,併發場景下,鎖的顆粒度越小越好

What's More

本文相關程式碼,可以在我GitHub的concurrent-write-db 中檢視

本文同步發表於我的微信公眾號,歡迎關注。


注:轉載本文,請與Gevin聯絡




如果您覺得Gevin的文章有價值,就請Gevin喝杯茶吧!

|

歡迎關注我的微信公眾賬號

併發場景下資料寫入功能的實現

相關文章