日常開發中併發與一致性的一些坑

640?wx_fmt=png

前言

列舉日常工作開發中最容易犯的併發錯誤,並基於這些錯誤,跟大家聊聊併發與一致性。

併發與一致性概念

併發與並行有什麼區別?

併發: 是指同一個時間段內多個任務同時都在執行,並且都沒有執行結束.

並行: 是說在單位時間 內多個任務同時在執行。

併發任務強調在一個時間段內同時執行,而一個時間段由多個單位時間累積而成,所以說併發的多個任務在單位時間內不一定同時在執行。在這裏,我舉一個生活的例子,來比喻併發與並行。

現時生活中的併發與並行:假設公路有四條道路,四輛汽車可以同一時刻通過同一位置,我把它看做並行。而一條單行道路,同一時刻只有一輛汽車可以通過同一個地方,但是其他的汽車可以陸續的通過,這就是併發的一個縮影。

640?wx_fmt=png

一致性是什麼?

一致性指的就是最終的結果是否和設定的規則保持一致,一般指數據保持一致,如果在分佈式系統中,可以理解爲多個節點中數據的值是一致的。

強一致性

這種一致性級別是最符合用戶直覺的,它要求系統寫入什麼,讀出來的也會是什麼,用戶體驗好,但實現起來往往對系統的性能影響大

弱一致性

這種一致性級別約束了系統在寫入成功後,不承諾立即可以讀到寫入的值,也不承諾多久之後數據能夠達到一致,但會儘可能地保證到某個時間級別(比如秒級別)後,數據能夠達到一致狀態

最終一致性

最終一致性是弱一致性的一個特例,系統會保證在一定時間內,能夠達到一個數據一致的狀態。這裏之所以將最終一致性單獨提出來,是因爲它是弱一致性中非常推崇的一種一致性模型,也是業界在大型分佈式系統的數據一致性上比較推崇的模型

640?wx_fmt=png

日常代碼中的併發問題

下面列舉大家平時在工作中最容易犯的併發錯誤,都是在實際項目代碼中看到的鮮活例子。

First Blood

線上總是出現:ERROR 1062 (23000) Duplicate entry 'xxx' for key 'yyy',我們來看一下有問題的這段代碼:

UserBindInfo info = selectFromDB(${userId});	
if(info == null){	
    info = new UserBindInfo(${userId},${deviceId});	
    insertIntoDB(info);	
}else{	
    info.setDeviceId(${deviceId});	
    updateDB(info);	
    }

併發情況下,第二步判斷都爲空,就會有2個或者多個線程進入插入數據庫操作,這時候就出現了同一個ID插入多次, 正確處理姿勢:

insert into UserBindInfo values(#{userId},#{deviceId}) on duplicate key update deviceId=#{deviceId}多次的情況,導致插入失敗。

一般情況下,可以用insert...on duplicate key update... 解決這個問題。

注意: 如果UserBindInfo表存在主鍵以及一個以上的唯一索引,在併發情況下,使用insert...on duplicate key,可能會產生死鎖,可以這樣處理:

try{	
   UserBindInfoMapper.insertIntoDB(userBindInfo);	
}catch(DuplicateKeyException ex){	
    UserBindInfoMapper.update(userBindInfo);	
}

Double Kill

現在有如下業務:控制同一個用戶訪問某個接口的頻率不能小於5秒。一般很容易想到使用redis的 setnx操作來控制併發訪問,於是有以下代碼:

if(RedisOperation.setnx(${userId}, 1)){	
    RedisOperation.expire(${userId},5,TimeUnit.SECONDS));	
    //執行正常業務邏輯	
}else{	
    return “訪問過於頻繁”;	
}

假設執行完setnx操作,還沒來得及設置expireTime,機器重啓或者突然崩潰,將會發生死鎖。該用戶id,後面執行setnx永遠將爲false,這可能讓你永遠損失那個用戶

那麼怎麼解決這個問題呢,可以考慮用SET key value NX EX max-lock-time ,它是一種在 Redis 中實現鎖的方法,是原子性操作,不會像以上代碼分兩步執行,先set再expire,它是一步到位

客戶端執行以上的命令:

  • 如果服務器返回 OK ,那麼這個客戶端獲得鎖。

  • 如果服務器返回 NIL ,那麼客戶端獲取鎖失敗,可以在稍後再重試。

  • 設置的過期時間到達之後,鎖將自動釋放

Trible Kill

我們看一下有關ConcurrentHashMap的一段代碼,如下:

//全局變量	
Map<String, Integer> map = new ConcurrentHashMap(); 	
Integer value = count.get(k);	
if(value == null){	
       map.put(k,1);	
}else{	
    map.put(k,value+1);	
}

假設兩條線程都進入 value==null,這一步,得出的結果是不是會變小?OK,客官先稍作休息,閉目養神一會,我們驗證一下,請看一個demo:

  public static void main(String[] args)  {	
        for (int i = 0; i < 1000; i++) {	
            testConcurrentMap();	
        }	
    }	
    private static void testConcurrentMap() {	
        final Map<String, Integer> count = new ConcurrentHashMap<>();	
        ExecutorService executorService = Executors.newFixedThreadPool(2);	
        final CountDownLatch endLatch = new CountDownLatch(2);	
        Runnable task = ()->  {	
                for (int i = 0; i < 5; i++) {	
                    Integer value = count.get("k");	
                    if (null == value) {	
                        System.out.println(Thread.currentThread().getName());	
                        count.put("k", 1);	
                    } else {	
                        count.put("k", value + 1);	
                    }	
                }	
                endLatch.countDown();	
        };	
        executorService.execute(task);	
        executorService.execute(task);	
        try {	
            endLatch.await();	
            if (count.get("k") < 10) {	
                System.out.println(count);	
            }	
        } catch (Exception e) {	
            e.printStackTrace();	
        }

表面看,運行結果應該都是10對吧,好的,我們再看運行結果 :640?wx_fmt=png

運行結果出現了5,所以這樣實現是有併發問題的,那麼正確的實現姿勢是啥呢?

Map<K,V> map = new ConcurrentHashMap(); 	
V v = map.get(k);	
if(v == null){	
        V v = new V();	
        V old = map. putIfAbsent(k,v);	
        if(old != null){	
                  v = old;	
        }	
}

可以考慮使用putIfAbsent解決這個問題

(1)如果key是新的記錄,那麼會向map中添加該鍵值對,並返回null。

(2)如果key已經存在,那麼不會覆蓋已有的值,返回已經存在的值

我們再來看看以下代碼以及運行結果:

 public static void main(String[] args)  {	
        for (int i = 0; i < 1000; i++) {	
            testConcurrentMap();	
        }	
    }	
    private static void testConcurrentMap() {	
        ExecutorService executorService = Executors.newFixedThreadPool(2);	
        final Map<String, AtomicInteger> map = Maps.newConcurrentMap();	
        final CountDownLatch countDownLatch = new CountDownLatch(2);	
        Runnable task = ()->  {	
                AtomicInteger oldValue;	
                for (int i = 0; i < 5; i++) {	
                    oldValue = map.get("k");	
                    if (null == oldValue) {	
                        AtomicInteger initValue = new AtomicInteger(0);	
                        oldValue = map.putIfAbsent("k", initValue);	
                        if (oldValue == null) {	
                            oldValue = initValue;	
                        }	
                    }	
                    oldValue.incrementAndGet();	
                }	
            countDownLatch.countDown();	
        };	
        executorService.execute(task);	
        executorService.execute(task);	
        try {	
            countDownLatch.await();	
            System.out.println(map);	
        } catch (Exception e) {	
            e.printStackTrace();	
        }	
    }

640?wx_fmt=png

Quadra Kill

小心你的全局變量,如下面這段代碼:

@Component	
public class GlobalVariableConcurrentTest {	
    public static List<String> desc = new ArrayList<>();	
    public List<String> getDescByUserType(int userType) {	
        if (userType == 1) {	
            desc.add("普通會員不可以發送和查看郵件,請購買會員");	
            return desc;	
        } else if (userType == 2) {	
            desc.add("恭喜你已經是VIP會員,盡情的發郵件吧");	
            return desc;	
        }else {	
            desc.add("你的身份未知");	
            return desc;	
        }	
    }	
}

因爲desc是全局變量,在併發情況下,請求getDescByUserType方法,得到的並不是你想要的結果。

Penta Kill

現有如下業務場景:用戶手上有一張現金券,可以兌換相應的現金,

錯誤示範一

if(isAvailable(ticketId){	
    1、給現金增加操作	
    2、deleteTicketById(ticketId)	
}else{	
    return “沒有可用現金券”	
}

解析: 假設有兩條線程A,B兌換現金,執行順序如下:

640?wx_fmt=png

  • 1.線程A加現金

  • 2.線程B加現金

  • 3.線程A刪除票標誌

  • 4.線程B刪除票標誌

顯然,這樣有問題了,已經給用戶加了兩次現金了

錯誤示範2

if(isAvailable(ticketId){	
    1、deleteTicketById(ticketId)	
    2、給現金增加操作	
}else{	
    return “沒有可用現金券”	
}

併發情況下,如果一條線程,第一步deleteTicketById刪除失敗了,也會多添加現金。

正確處理方案

if(deleteAvailableTicketById(ticketId) == 1){	
    1、給現金增加操作	
}else{	
    return “沒有可用現金券”	
}

併發環境下數據庫緩存一致性

在這裏,我先問大家一個問題,有寫操作的時候,先操作數據庫還是先操作緩存呢? 你可以先思考一下,可能會存在哪些問題,再往下看。下面我分幾種方案闡述:

緩存維護方案一

一寫(線程A)一讀(線程B)操作,先操作緩存,在操作數據庫640?wx_fmt=png

1)線程A發起一個寫操作,第一步del cache

2)線程A第二步寫入新數據到DB

3)線程B發起一個讀操作,cache miss,

4)線程B從DB獲取最新數據

5)請求B同時set cache

這樣看,沒啥問題。我們再看第二個流程圖,如下:

640?wx_fmt=png

1)線程A發起一個寫操作,第一步del cache

2)此時線程B發起一個讀操作,cache miss

3)線程B繼續讀DB,讀出來一個老數據

4)然後老數據入cache

5)線程A寫入了最新的數據

OK,醬紫,就有問題了吧,老數據入到緩存了,每次讀都是老數據啦,緩存與數據與數據庫數據不一致

緩存維護方案二

雙寫操作,先操作緩存,在操作數據庫

640?wx_fmt=png

1)線程A發起一個寫操作,第一步set cache

2)線程A第二步寫入新數據到DB

3)線程B發起一個寫操作,set cache,

4)線程B第二步寫入新數據到DB

這樣看,也沒啥問題。我們再看第二個流程圖,如下:

640?wx_fmt=png

1)線程A發起一個寫操作,第一步set cache

2)線程B發起一個寫操作,第一步setcache

3)線程B寫入數據庫到DB

4)線程A寫入數據庫到DB

執行完後,緩存保存的是B操作後的數據,數據庫是A操作後的數據,緩存和數據庫數據不一致

緩存維護方案三

一寫(線程A)一讀(線程B)操作,先操作數據庫,再操作緩存

640?wx_fmt=png

1)線程A發起一個寫操作,第一步write DB

2)線程A第二步del cache

3)線程B發起一個讀操作,cache miss

4)線程B從DB獲取最新數據

5)線程B同時set cache

這種方案沒有明顯的併發問題,但是有可能步驟二刪除緩存失敗,雖然概率比較小,優於方案一和方案二,平時工作中也是使用方案三。

綜上對比,我們一般採用方案三,但是有沒有完美全解決方案三的弊端的方法呢?

緩存維護方案四

這個是方案三的改進方案,我們來看一下流程圖:640?wx_fmt=png通過數據庫的binlog異步淘汰key,以mysql爲例 可以使用阿里的canal將binlog日誌採集發送到MQ隊列裏面,然後通過ACK機制 確認處理 這條更新消息,刪除緩存。

但是呢還有個問題,如果是主從數據庫呢

緩存維護方案五

主從DB問題:因爲主從DB同步存在同時延時時間如果刪除緩存之後,數據同步到備庫之前已經有請求過來時,會從備庫中讀到髒數據,如何解決呢?解決方案如下流程圖:

640?wx_fmt=png

緩存維護總結

(1)讀取緩存中是否有相關數據

(2)如果緩存中有相關數據value,則返回

(3)如果緩存中沒有相關數據,則從數據庫讀取相關數據放入緩存中key->value,再返回

(4)如果有更新數據,則先更新數據,再刪除緩存

(5)爲了保證第四步刪除緩存成功,使用binlog異步刪除

(6)如果是主從數據庫,binglog取自於從庫

(7)如果是一主多從,每個從庫都要採集binlog,然後消費端收到最後一臺binlog數據才刪除緩存

更新緩存的Design Pattern

談到數據庫緩存一致性問題,我們再來看一下更新緩存的Design Pattern有四種:Cache aside, Read through, Write through, Write behind caching。

Cache Aside Pattern

最經典,同時也是最常用的緩存+數據庫讀寫的模式,就是 Cache Aside Pattern

  • 查詢: 先從緩存獲取數據,如果緩存無數據,就去查數據庫,將查詢結果放入緩存,同時返回數據給用戶。

  • 更新: 先把數據存到數據庫中,成功後,再讓緩存失效。

Read Through Pattern

在查詢操作中更新緩存。也就是說,當緩存失效的時候,Cache Aside pattern是由調用方負責把數據加載入緩存,而Read Through則用緩存服務自己來加載,從而對應用方是透明的。

Write Through Pattern

當有數據更新的時候,如果沒有命中緩存,直接更新數據庫,然後返回。如果命中了緩存,則更新緩存,然後再由Cache自己更新數據庫。

Write Behind Caching Pattern

在更新數據的時候,只更新緩存,不更新數據庫,而我們的緩存會異步地批量更新數據庫。

分佈式系統數據一致性

現實中分佈式一致性場景

我們來看一下幾個典型的分佈式一致性場景

1、銀行轉賬

在跨行轉賬過程中,我們經常會遇到這種情況:我本行的money已經扣除成功,但是對方銀行入賬可能需要在N個工作日後到賬!此時我們一般不擔心錢丟失問題:只要在給定的期限內到賬且錢不要少就好了!----這也成爲了幾乎所有用戶對於現代銀行系統最基本的需求

2、火車購票

K1314次列車,深圳-北京的臥鋪僅剩下最後一張車票了,可能在同一時刻,有很多乘客在不同地點的不同售票窗口都想買這一張車票,但是這張票只會賣給一位用戶,這就需要購票系統的每一個節點都要有強一致的剩餘車票數據

3、網上購物

我們經常會看到某個秒殺物品會在頁面上展示商品的剩餘數量,其實大家都知道這個數量絕大多數是緩存數據,不是實時更新的,但是在某一段時間後會同步最終剩餘數量。

CAP理論

640?wx_fmt=png

一致性(C:Consistency):

一致性是指數據在多個副本之間能否保持一致的特性。例如一個數據在某個分區更新之後,在其他分區讀出來的數據也是更新之後的數據

可用性(A:Availability):

可用性是指系統提供的服務必須一直處於可用的狀態,對於用戶的每一個操作請求總是能夠在有限的時間內返回結果。這裏的重點是"有限時間內"和"返回結果"。

分區容錯性(P:Partition tolerance):

分佈式系統在遇到任何網絡分區故障的時候,仍然需要能夠保證對外提供滿足一致性和可用性的服務

CAP理論:一個分佈式系統不可能同時滿足一致性C、可用性(A:Availability)和分區容錯性(P:Partition tolerance),最多隻能同時滿足其中兩項.

選擇聲明
CA放棄分區容錯性,加強一致性和可用性,其實就是傳統的單機數據庫的選擇
AP放棄一致性,分區容錯性和可用性,這是很多分佈式系統設計時的選擇
CP放棄可用性,追求一致性和分區容錯性,網絡問題會直接讓整個系統不可用

分佈式系統一致性解決方案

BASE理論:

1、BA:Basically Available

基本可用:通過支持局部故障而不是系統全局故障來實現的。如將用戶分區在 5 個數據庫服務器上,一個用戶數據庫的故障隻影響這臺特定主機那 20% 的用戶,其他用戶不受影響

2、S:Soft State

軟狀態,狀態可以有一段時間不同步

3、E:Eventually Consistent

最終一致,最終數據是一致的就可以了,而不是時時保持強一致

經典案例分析:銀行跨行轉賬

640?wx_fmt=png1、匯豐銀行賬戶A申請匯款100元到渣打銀行賬戶B上

2、匯豐銀行執行以下事務操作:

  a、記錄操作流水,生成流水單號,此流水可以理解爲A的轉賬憑證	
  b、執行A賬號扣除100元的操作

3、匯豐銀行通知渣打銀行A的轉賬請求

4、渣打銀行收到通知後,執行以下事務操作:

  a、插入到日誌流水(即判斷個憑證是否處理過)	
  b、執行B賬號增加100元操作

5、兩個銀行中每日或者定時對賬,處理異常的流水訂單

一句話總結:將分佈式事務轉換爲多個本地事務,然後依靠重試等方式達到最終一致性

經典案例分析:兩階段提交2PC

舉例分析:

第一階段,張老師作爲“協調者”,給小強和小明(參與者、節點)發微信,組織他們倆明天8點在學校門口集合,一起去爬山,然後開始等待小強和小明答覆。

第二階段,如果小強和小明都回答沒問題,那麼大家如約而至。如果小強或者小明其中一人回答說“明天沒空,不行”,那麼張老師會立即通知小強和小明“爬山活動取消”。

這個過程中可能有很多問題的。如果小強沒看手機,那麼張老師會一直等着答覆,小明可能在家裏把爬山裝備都準備好了卻一直等着張老師確認信息。更嚴重的是,如果到明天8點小強還沒有答覆,那麼就算“超時”了,那小明到底去還是不去集合爬山呢?大家茶餘飯後,思考以下這個問題吧。

結語

謝謝閱讀,希望本文對你有幫助。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章