前言
列舉日常工作開發中最容易犯的併發錯誤,並基於這些錯誤,跟大家聊聊併發與一致性。
併發與一致性概念
併發與並行有什麼區別?
併發: 是指同一個時間段內多個任務同時都在執行,並且都沒有執行結束.
並行: 是說在單位時間 內多個任務同時在執行。
併發任務強調在一個時間段內同時執行,而一個時間段由多個單位時間累積而成,所以說併發的多個任務在單位時間內不一定同時在執行。在這裏,我舉一個生活的例子,來比喻併發與並行。
現時生活中的併發與並行:假設公路有四條道路,四輛汽車可以同一時刻通過同一位置,我把它看做並行。而一條單行道路,同一時刻只有一輛汽車可以通過同一個地方,但是其他的汽車可以陸續的通過,這就是併發的一個縮影。
一致性是什麼?
一致性指的就是最終的結果是否和設定的規則保持一致,一般指數據保持一致,如果在分佈式系統中,可以理解爲多個節點中數據的值是一致的。
強一致性
這種一致性級別是最符合用戶直覺的,它要求系統寫入什麼,讀出來的也會是什麼,用戶體驗好,但實現起來往往對系統的性能影響大
弱一致性
這種一致性級別約束了系統在寫入成功後,不承諾立即可以讀到寫入的值,也不承諾多久之後數據能夠達到一致,但會儘可能地保證到某個時間級別(比如秒級別)後,數據能夠達到一致狀態
最終一致性
最終一致性是弱一致性的一個特例,系統會保證在一定時間內,能夠達到一個數據一致的狀態。這裏之所以將最終一致性單獨提出來,是因爲它是弱一致性中非常推崇的一種一致性模型,也是業界在大型分佈式系統的數據一致性上比較推崇的模型
日常代碼中的併發問題
下面列舉大家平時在工作中最容易犯的併發錯誤,都是在實際項目代碼中看到的鮮活例子。
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對吧,好的,我們再看運行結果 :
運行結果出現了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();
}
}
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兌換現金,執行順序如下:
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)操作,先操作緩存,在操作數據庫。
1)線程A發起一個寫操作,第一步del cache
2)線程A第二步寫入新數據到DB
3)線程B發起一個讀操作,cache miss,
4)線程B從DB獲取最新數據
5)請求B同時set cache
這樣看,沒啥問題。我們再看第二個流程圖,如下:
1)線程A發起一個寫操作,第一步del cache
2)此時線程B發起一個讀操作,cache miss
3)線程B繼續讀DB,讀出來一個老數據
4)然後老數據入cache
5)線程A寫入了最新的數據
OK,醬紫,就有問題了吧,老數據入到緩存了,每次讀都是老數據啦,緩存與數據與數據庫數據不一致。
緩存維護方案二
雙寫操作,先操作緩存,在操作數據庫。
1)線程A發起一個寫操作,第一步set cache
2)線程A第二步寫入新數據到DB
3)線程B發起一個寫操作,set cache,
4)線程B第二步寫入新數據到DB
這樣看,也沒啥問題。我們再看第二個流程圖,如下:
1)線程A發起一個寫操作,第一步set cache
2)線程B發起一個寫操作,第一步setcache
3)線程B寫入數據庫到DB
4)線程A寫入數據庫到DB
執行完後,緩存保存的是B操作後的數據,數據庫是A操作後的數據,緩存和數據庫數據不一致。
緩存維護方案三
一寫(線程A)一讀(線程B)操作,先操作數據庫,再操作緩存。
1)線程A發起一個寫操作,第一步write DB
2)線程A第二步del cache
3)線程B發起一個讀操作,cache miss
4)線程B從DB獲取最新數據
5)線程B同時set cache
這種方案沒有明顯的併發問題,但是有可能步驟二刪除緩存失敗,雖然概率比較小,優於方案一和方案二,平時工作中也是使用方案三。
綜上對比,我們一般採用方案三,但是有沒有完美全解決方案三的弊端的方法呢?
緩存維護方案四
這個是方案三的改進方案,我們來看一下流程圖:通過數據庫的binlog來異步淘汰key,以mysql爲例 可以使用阿里的canal將binlog日誌採集發送到MQ隊列裏面,然後通過ACK機制 確認處理 這條更新消息,刪除緩存。
但是呢還有個問題,如果是主從數據庫呢?
緩存維護方案五
主從DB問題:因爲主從DB同步存在同時延時時間如果刪除緩存之後,數據同步到備庫之前已經有請求過來時,會從備庫中讀到髒數據,如何解決呢?解決方案如下流程圖:
緩存維護總結
(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理論
一致性(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
最終一致,最終數據是一致的就可以了,而不是時時保持強一致
經典案例分析:銀行跨行轉賬
1、匯豐銀行賬戶A申請匯款100元到渣打銀行賬戶B上
2、匯豐銀行執行以下事務操作:
a、記錄操作流水,生成流水單號,此流水可以理解爲A的轉賬憑證
b、執行A賬號扣除100元的操作
3、匯豐銀行通知渣打銀行A的轉賬請求
4、渣打銀行收到通知後,執行以下事務操作:
a、插入到日誌流水(即判斷個憑證是否處理過)
b、執行B賬號增加100元操作
5、兩個銀行中每日或者定時對賬,處理異常的流水訂單
一句話總結:將分佈式事務轉換爲多個本地事務,然後依靠重試等方式達到最終一致性
經典案例分析:兩階段提交2PC
舉例分析:
第一階段,張老師作爲“協調者”,給小強和小明(參與者、節點)發微信,組織他們倆明天8點在學校門口集合,一起去爬山,然後開始等待小強和小明答覆。
第二階段,如果小強和小明都回答沒問題,那麼大家如約而至。如果小強或者小明其中一人回答說“明天沒空,不行”,那麼張老師會立即通知小強和小明“爬山活動取消”。
這個過程中可能有很多問題的。如果小強沒看手機,那麼張老師會一直等着答覆,小明可能在家裏把爬山裝備都準備好了卻一直等着張老師確認信息。更嚴重的是,如果到明天8點小強還沒有答覆,那麼就算“超時”了,那小明到底去還是不去集合爬山呢?大家茶餘飯後,思考以下這個問題吧。
結語
謝謝閱讀,希望本文對你有幫助。