【總結系列】互聯網服務端技術體系:高性能之併發(Java)

分而合之,並行不悖。html


技術人首先應當擁有良好的技術素養。技術素養首先是一絲不苟地嚴謹探索與求知的能力和精神,其次是對一個事物可以構建自底向上的邏輯,具備宏觀清晰的視野和對細節的把握。java

要設法閱讀第一手的資料,論文、官方文檔、源碼、經典著做,與發明者交流;要努力追溯本質與本源。知識是相對客觀的,不能浮誇和造假。redis

技術人還應當保持謙遜,要明白本身所知的永遠是冰山一角,以冰山一角去評判另外一個冰山一角,只是五十步比百步。

算法

引子

併發,就是在同一時間段內有多個任務同時進行着。這些任務或者互不影響互不干擾,或者共同協做來完成一個更大的任務。編程

好比我在作項目 A,修改工程 a ; 你在作項目 B, 修改工程 b 。咱們各自作完本身的項目後上線。我和你作的事情就是併發的。若是我和你修改同一個工程,就可能須要協調處理衝突。併發是一種高效的運做方式,但每每也要處理併發帶來的衝突和協做。後端

世界自然是併發的。本文總結併發相關的知識和實踐。緩存

總入口見:「互聯網應用服務端的經常使用技術思想與機制綱要」

安全

基礎

計算機中實現併發的方式有:多核、多進程、多線程;共享內存模型。基本方法是分而治之、劃分均衡任務、獨立工做單元、隔離訪問共享資源。能夠將一個大任務劃分爲多個互相協做的子任務,將一個大數據集劃分爲多個小的子數據集,分別處理後合併起來完成整個任務。併發須要解決執行實體之間的資源共享和通訊機制。服務器

  • 多核:有多個 CPU 核心,每一個 CPU 核心都擁有專享的寄存器、高速緩存。多個 CPU 核心能夠分別處理不一樣的指令和數據集。多核心之間的通訊機制是系統總線和共享內存。多核是併發的硬件基礎。網絡

  • 多進程模型:進程是程序的一次執行實例,具備私有地址空間,由內核調度。進程有父子關係。進程間通訊方式:管道(無名管道和命名管道 FIFO)、消息隊列、共享內存、套接字、信號機制。進程建立和切換的開銷都比較大。多進程是多任務執行的上下文基礎。

  • 多線程模型:線程是運行在進程上下文中的共享同一個進程私有地址空間的執行單元,亦由內核調度。線程之間是對等的。線程通訊方式:消息隊列、共享內存。線程的建立和切換開銷比進程要小不少。多線程是多任務的調度基礎。

在 Java 應用語境中,執行實體對應着線程。如下涉及到執行實體的時候,直接以線程代替。併發能有效利用多個線程同時工做,大幅提高性能。同時,也是有必定代價的:線程阻塞與上下文切換(5000-10000 CPU 時鐘)、內存同步開銷(使 CPU 緩存失效、禁止編譯器優化等)。不良的併發設計,可能致使大量線程等待、阻塞、切換,反而不如串行的執行效率高。

分析

如何去思考和分析併發問題呢? 併發的難點在於,(不一樣線程裏的)任務執行的不一樣順序會引起不一樣的結果,而這些順序都是有必定機率性存在的。

所以,併發的關鍵點在於如何在合理的程度上協調任務執行順序產生預期結果,同時又不對任務的進展產生過大的干預。就像宏觀調控之於市場經濟。市場經濟是很是有活力的經濟形式,但聽憑市場經濟的自由發展,會有失衡的風險。此時,就須要必定的宏觀調控來干預一下。而宏觀調控也不能過分,不然會抑制市場經濟的活力。

注意,是協調執行順序而不是控制。實際上,執行順序是難以控制的。大多數時候,能作的是對少數步驟執行施加一些影響,使執行順序符合某些前後約束,從而可以產生預期結果。絕大多數的步驟執行,仍是任之天然進行。

資源依賴

要正確協調執行順序,先得弄清楚要協調哪些任務,或者說,任務執行受什麼影響:

  • 有限共享的同一資源。好比兩我的去爭用僅有的一臺打印機,只有一我的用完釋放後,才能讓另外一我的用。
  • 資源之間的依賴。好比任務 A 讀變量 x , 任務 B 寫變量 y ,x = y + 1, 則 A 讀和 B 寫的前後順序不一樣,會產生不一樣的結果。假設 x = 3, y= 2 。B 要寫入 y = 5 。若 A 先讀 B 再寫, 則 A 讀到的是 x = 3; 若 B 先寫再 A 讀,則 A 讀到的是 x = 6。當兩個變量是同一個時,是一種「寫後讀」的依賴,姑且稱之爲「變化依賴」。

好比,同一個訂單的下單過程,兩個線程去分別讀寫訂單數據(假設都是讀 DB 主庫):

  • 共享的資源:訂單數據行、網絡帶寬、請求處理池、DB 鏈接;
  • 變化依賴:訂單狀態的讀強依賴於訂單狀態的寫。

所以,任務執行受有限共享的資源及資源依賴影響。若是多個任務併發執行,首先要理清楚這些任務所依賴的資源以及資源之間的依賴。資源類型包括:變量、數據行(記錄)、文件句柄、網絡鏈接、端口等。若是兩個任務沒有資源依賴,則各自執行便可;若是有共享資源依賴,則須要在合適的時候自動調節彼此獲取共享資源的順序。

值得說起的是,有一種隱式的資源依賴。好比一個大的任務拆分爲 A,B,C 三個任務,A 和 B 都執行完成後,才能執行 C。此時 C 的執行依賴於 A,B 的執行完成狀態(也極可能依賴 A,B 的執行結果集)。 這種隱式的資源依賴,也稱爲任務協做。

邏輯時鐘

如何判斷併發執行結果是準確的呢?好比 x = 1 。任務 A 在 t 時刻讀 x ,任務 B 在 t+1 時刻寫 x =5, 任務 C 在 t+2 讀 x,按理 A 讀到是 1 ,C 讀到是 5 。 但因爲網絡延時,可能 C 的讀請求在 B 的寫請求提交以前就到達了,所以 C 也可能讀到 1。因爲網絡的不可靠及機器各自的時鐘是有細微不一樣步的,所以,執行讀寫 x 的服務器沒法判斷 B, C 請求的前後性。

須要有一個邏輯時鐘,給任務進行順序編號,根據任務編號以及讀寫的因果性,就能判斷 C 讀到 1 的結果是錯誤的了。

happen-before

定一些基本的準則是必要的。就像歐幾里得幾何首先定義了五條公理而後纔開始推導同樣。

happen-before 是可見性判斷的基本準則:符合準則的兩個操做,前面的操做必然先行於/可見於後面的操做。換句話說,就是關於併發的基本定理。若是定理都不成立,那麼併發的肯定性結果就無從談起了。 happen-before 的具體細則:

  • 在同一個線程的順序控制流中,有依賴關係的前面操做可見於後續操做;
  • 同一個鎖的 unlock 可見於 lock 操做,即 lock 時總能看到前一個 unlock 操做;
  • 同一個 volatile 變量的寫可見於讀操做;
  • 同一線程 start 先行於該線程內的全部操做,線程內的全部操做先行於該線程的 exit ;
  • 對象的構造器方法結束先行於對象的全部操做,對象的全部操做先行於對象的 finalize 方法開始;
  • 傳遞性。A 可見於 B, B 可見於 C ,則 A 可見於 C 。

思路

要正確協調任務的執行順序,須要解決任務之間的協做與同步。任務之間的協做與同步方式主要有:快照機制、原子操做、指令屏障、鎖機制、信號機制、消息/管道機制。

快照機制

生成某個時間點的歷史版本的不可變的快照數據,以必定策略去生成新的快照;直接讀快照而不是讀最新數據。將數據與版本號綁定,根據版本號來讀取對應的數據;更新時不會修改已有的快照,而是生成新的版本號和數據。快照機制能夠用來回溯歷史數據。Git 是運用快照機制的典範。

快照機制並無對任務的天然進展施加影響,只是記錄了某個數據集的某個時刻的狀態。應用能夠根據須要去讀取不一樣時刻的狀態,作進一步處理。快照機制通常用來提高併發讀的吞吐量。

原子操做

將多個操做封裝爲一個不可分割的總體操做,其它操做不可能在這個總體操做之間插入更新相關變量。

實現原子操做有兩種方式:

  • 對變量更新加鎖。但加鎖會致使線程阻塞和等待,且須要釋放鎖,開銷很大。
  • CAS 操做。對於單個簡單變量的讀寫同步,加鎖的開銷可能遠高於變量更新的開銷。能夠採用輪詢式的 CAS 原子操做。CAS 是封裝了變量的「比較相等-更新」的原子操做。

指令屏障

指令屏障是在普通指令中插入特殊指令,從而在讀寫指令的執行之間加以執行順序的前後約束,控制某些指令必須在另外一些指令以前執行且執行結果可見,禁止 CPU 經過指令重排序來優化內存讀寫(有性能損失)。最經常使用的指令屏障是內存屏障 Memory Barrier。

鎖機制

鎖機制用於有限共享資源的保護性訪問,每次只容許一個執行體來訪問可得到的共享資源。

鎖機制的基礎是 P-V 原語和阻塞/喚醒機制:

  • P(s) 操做:若是 s 是非零的,那麼 P 將 s 減一,並當即返回,若是 s 爲零,就掛起該線程。
  • V(s) 操做:將 s 加一,若是有任何線程阻塞在 P 操做等待 s 變成非零,則 V 操做會重啓這些線程中的一個,重啓以後,P 將 s 減一,並將控制返回給調用者。

信號機制

信號機制是發出特定的信號,讓接受信號的任務作相應的處理。中斷是信號機制的一種典型場景。中斷由某個中斷源發出一個信號給某個線程,當線程收到這個信號時,能夠作一些特定的動做。

Java 線程有一箇中斷標誌位。處於不一樣狀態時,線程對於中斷有不一樣的反應。處於 New 和 Terminated 時,無心義;處於 Running 和 Blocked 時,只是設置中斷標誌位,不會影響線程狀態; 處於 Time Sleep 時,會拋出異常並清空中斷標誌位。Java 將中斷的具體處理的權力交給了應用。

消息/管道

經過在兩個任務之間傳遞消息或者創建管道,來串聯起兩個任務的順序執行。消息機制經常使用於解耦服務,而管道經常使用於 Pipeline 流水線模式中。

模式

從併發思路中能夠推導出一些經常使用的同步模式,來確保併發訪問的安全性。主要有:Immutable、Unshared Copies、Monitor Locks 、Memory Barrier、Protected Lock、CAS。

Immutable

不可變數據。典型的不可變數據有字符串、快照。ES 分片裏的倒排索引就是不可變的。ES 會將不可變的倒排索引與更新的倒排索引進行查詢合併,獲得最終的查詢結果。

Unshared Copies

每一個線程都有一份本身的拷貝,不共享,互不影響。ThreadLocal 便是應用 Unshared Copies 模式。

Monitor locks

Java synchronized 塊應用 Monitor locks 模式,基於 object monitor 和 monitorenter/2 monitorexit 實現,由編譯器和 JVM 共同協做實現。JVM 規範指明:每一個對象關聯一個 object monitor ,當線程執行 monitorenter 時會去獲取 monitor 的 ownership ,而執行 monitorexit 則會釋放 monitor 的 ownership。第二個 monitorexit 是爲了在異常退出時與 monitorenter 匹配。在 hotSpot 虛擬機中,monitor 是由 ObjectMonitor 實現的。其源碼位於 hotSpot 虛擬機源碼 ObjectMonitor.hpp 文件中。

synchronized 方法是基於方法常量池中的方法表結構中的 ACC_SYNCHRONIZED 標識符實現。synchronized 是可重入的,同一線程連續屢次獲取同一個鎖,不須要每次都加鎖,只需記錄加鎖次數。同步容器 SynchronizedList, SynchronizedMap 是基於 synchronized(mutex) { // target.operation(); } 實現的對應容器的簡單併發版。

Memory Barrier

Memory Barrier 內存屏障。當插入內存屏障後,其後的指令不會當即放在 CPU 緩存裏,而是和內存屏障一塊兒放在 FIFO 隊列裏,待 CPU 緩存裏的指令都執行完成後,從 FIFO 中取出內存屏障後的指令來執行。

內存屏障主要有兩種:

  • 寫內存屏障(Store Barrier):處理器將 CPU 緩存值寫回主存(阻塞方式);
  • 讀內存屏障(Load Barrier): 處理器處理失效隊列(阻塞方式)。

兩兩組合,有四種:StoreStoreBarrier, StoreLoadBarrier, LoadStoreBarrier, LoadLoadBarrier。 XYBarrier 是指,在 XYBarrier 以前的全部 X 操做都必須在 XYBarrier 以後的任一 Y 操做以前執行完成。而且寫操做對全部處理器可見。

volatile 關鍵字在寫操做以後插入 StoreStore Barrier, 在讀操做以前操做 LoadLoad Barrier。volatile 適合作單個簡單狀態標識符的更新、生命週期裏的初始化或退出。volatile 是不加鎖的。

Protected Lock

輕量級更靈活的鎖。形式一般以下:

Lock lock = lock.lock(lockKey, time, timeUnit);
        try {
            // doBizLogic;
        } finally {
            lock.unlock();
        }

CAS

Compare-And-Swap 。CAS(V,E,N) 操做便是「先將 V 與 E 比較是否相等,若是相等,則更新到指定值 N ,不然什麼都不作」。CAS 是無鎖的非阻塞的,沒有線程切換開銷,所以在併發程度不高的狀況下性能更優。Java 併發包裏的絕大多數同步工具都有 CAS 的影子。 Java CAS 操做是經過 Unsafe 類的 native 方法支持的。

CAS 操做的原子語義是經過底層硬件和指令來支持的。相關指令以下:

  • 測試並設置(Tetst-and-Set)
  • 獲取並增長(Fetch-and-Increment)
  • 交換(Swap)
  • 比較並交換(Compare-and-Swap)
  • 加載連接/條件存儲(Load-Linked/Store-Conditional)

在IA64,x86 指令集中有 cmpxchg 指令完成 CAS 功能。CPU 的原子操做在底層能夠經過總線鎖定和緩存鎖定來實現。總線鎖定是 CPU 在總線上輸出一個 LOCK# 信號,阻塞其它處理器操做該共享變量的緩存的請求,獨佔內存;緩存鎖定是經過 MESI 緩存一致性機制來保證操做的原子性。在以下狀況下只能使用總線鎖定:當操做的數據不能被緩存在處理器內部,或者操做的數據跨多個緩存行時,或者 CPU 不支持緩存鎖定。

CAS 有兩個問題:

  • 併發度高時的空耗問題:CAS 是須要消耗 CPU 週期的。若是併發激烈,則可能陷入空耗 CPU 週期的 CAS 循環中。此時,能夠採用分段的方式,將要更新的變量分爲多段,對不一樣的段進行 CAS ,而後合併。好比 LongAdder 。
  • A-B-A 問題:先更新爲 A,再更新爲 B,又更新爲 A。能夠採用版本號/時間戳來區分兩次相同值。好比 AtomicStampedReference。

工具

理解了併發的模型、思路和模式以後,再來看併發工具如何實現。Java 併發包裏的絕大多數同步工具都是基於 CAS 和 AQS 的。所以,深刻理解 CAS 和 AQS 是很是重要的。

AQS

實現 Java 同步工具的基本框架,也是整個 Java 併發包的核心基礎類。AQS 實現了「根據某種許可獲取的狀況將線程入隊/出隊以及相應的線程阻塞/喚醒」的通用機制,而將什麼時候入隊/出隊(是否可以得到許可)的控制權交給了庫的使用者。AQS 支持按照中斷(互斥)或者超時兩種模式來獲取/釋放許可,協調線程執行順序。

AQS 包含一個同步隊列和一個條件隊列。兩個隊列都是基於鏈表實現的。

  • 同步隊列:CLH 變體,雙向鏈表實現的隊列,從尾部入隊,從頭部出隊。入隊是針對尾節點 tail 的 CAS 操做,將 tail 賦給爲入隊線程建立的新節點;出隊則是更新首節點 head。同步隊列初始化時,須要對 head 進行 CAS 操做。head 節點至關於一個哨兵元素,head 節點沒有 prev 和 thread ,且 waitStatus 不會爲 CANCELLED。同步隊列的遍歷每每是從 tail 開始往前遍歷。
  • 條件隊列:採用單鏈表,互斥模式。ConditionObject 對象實現了條件等待/通知機制。調用 await 方法時會從同步隊列轉移到條件隊列,調用 signal 喚醒方法時則從條件隊轉移到同步隊列。
  • 二者聯繫:同步隊列和條件隊列複用了相同的鏈表節點,經過鏈表節點上的節點指針來標識節點在哪一個隊列上。同步用來獲取鎖,而條件隊列在獲取鎖的基礎上用來實如今特定條件的等待/喚醒,二者能夠配合使用。

鏈表節點包含以下成員:

  • 被阻塞或等待喚醒的線程 Thead 。
  • 節點狀態 waitStatus 。 CANCELLED -- 超時或中斷取消,一旦進入, 就不可改變; SIGNAL -- 須要喚醒後繼節點 , CONDITION -- 線程等待喚醒, PROPAGATE -- acquireShare 須要無條件傳播下去;處於 CANCELLED 的節點是不可被阻塞或喚醒的,所以在阻塞或喚醒時須要遍歷跳過 CANCELLED 節點。
  • 實現同步隊列的節點指針 head, tail ; prev, next 。prev 用來處理取消,經過 prev 的節點狀態來判斷當前線程該如何處理;next 當前節點釋放時須要喚醒它的 next 節點,CANCELLED 節點的 next 是它自身。因爲前驅或後繼節點可能由於超時或中斷已被取消,所以須要遍從來找到第一個沒有取消的前驅或後繼節點。
  • 實現條件隊列的節點指針 firstWaiter, lastWaiter ; nextWaiter 。nextWaiter 在條件隊列中指向下一個節點,或者指向 Share 節點。

如前所述,AQS 實現了通用的入隊/出隊以及相應的阻塞/喚醒機制,那麼什麼時候會入隊/出隊呢?這就是自定義方法的做用了。使用 AQS 開發同步工具,須要定義好 state 的同步語義,實現以下方法:tryAcquire/tryRelease,tryAcquireShared/tryReleaseShared,isHeldExclusively。

AtomicXXX

原子類,提供基本數據類型的原子化更新操做。經過 volatile variable + offset (字段的固定的內存地址偏移量) + Unsafe 來獲取的狀態字段的可見值,CAS 實現原子操做,適用於計數、安全引用更新等。可閱讀 AtomicInteger 和 LongAdder 的實現。

ReentrantLock

Protected Lock 模式的一種實現。基於 CAS 和 AQS 實現,提供公平鎖 FairSync 和非公平鎖 NonfairSync。默認非公平鎖。非公平鎖吞吐量更高,公平鎖傾向於訪問授予等待時間最長的線程,吞吐量可能較低,適合防線程飢餓上波動小一點。

  • ReentrantLock 的 lock 實現默認委託給 NonfairSync,該類繼承 AQS 來實現鎖機制。
  • nonfairTryAcquire: 分爲兩種狀況處理 -- 線程第一次獲取鎖和已經獲取鎖。
  • tryRelease:分兩種狀況處理 -- 最後一次釋放鎖和屢次獲取鎖後的某一次釋放。
  • state 同步語義: state > 0 表示已有線程獲取鎖的許可數,只有獲取鎖的線程可以繼續獲取鎖或者釋放鎖; state = 0 表示線程能夠去獲取鎖。

ReentrantLock 能夠返回一個ConditionObject 對象,用做條件等待阻塞和喚醒。

  • CopyOnWriteArrayList 基於 array + ReentrantLock + System.arraycopy 實現,讀多寫少場景。讀列表不加鎖,更新列表使用 ReentrantLock 進行保護性訪問。
  • ArrayBlockingQueue 使用一個 ReentrantLock 及一對 Condition ( notEmpty & notFull ) 對隊列進行保護性訪問,並在隊列空/滿時阻塞相應線程,在隊列非空/非滿時喚醒相應線程。

ConcurrentHashMap

HashMap 的併發加鎖版。要點以下:

  • 爲保證高併發,使用了分段鎖機制,每一個桶關聯一個鎖;
  • 定位桶索引時使用 CAS ,由於定位桶索引是一個輕量操做;
  • 訪問某個桶的數據時使用分段鎖(synchronized(tab)) ;
  • 鏈表衝突轉換爲紅黑樹時,插入新節點後將樹轉平衡時使用 CAS 。
  • ConcurrentHashMap 可用於併發環境中的緩存實現。

ConcurrentHashMap 體現了一些提高併發性能的技巧:減小串行化部分的耗時、減小持鎖邏輯耗時(下降鎖粒度)、減小鎖競爭程度(數據分段及分段鎖)。使用多個細粒度鎖交互時要注意防止死鎖。

ThreadLocal

ThreadLocal 類裏維護了一個哈希表 ThreadLocalMap[ThreadLocal, Value] ,每一個線程都持有一個對 ThreadLocalMap 的引用,在該線程裏調用 ThreadLocal.setInitialValue 方法時被初始化。當調用某個 ThreadLocal 對象的 set 方法時,會先獲取當前線程,而後將當前線程的 TheadLocal 對象及對應的值寫入所持有的 ThreadLocalMap 中。ThreadLocal 對象的哈希碼值是經過一個 AtomicInteger 每次自增 0x61c88647 獲得的。0x61c88647 是斐波那契乘數,可保證哈希散列分佈均勻一些。

ThreadLocal 在一個長流程中存儲須要的 Context 。ThreadLocal 使用要注意的問題:

  • 內存泄露。因爲 ThreadLocalMap 的 key 是弱引用,ThreadLocalMap 是強引用對象,當 key 被回收時,對應的 value 可能不會被回收,會形成內存泄露;
  • ThreadLocal 與線程池聯合使用時,退出線程前必須清除殘留的 ThreadLocal 變量數據。

線程池

線程池是受控的可執行多任務的線程管理器。Java 線程池實現是 ThreadPoolExecutor。 線程池的主要組成部分以下:

  • 一個阻塞任務隊列,用來存放待處理的任務 BlockingQueue[Runnable] workQueue;關聯任務拒絕策略 RejectedExecutionHandler handler,當隊列滿時如何處理後面的任務請求。
  • 一個線程工廠 threadFactory,用來生產和標識線程,能夠作一點線程定製化的事情;
  • 一組可控的複用和回收的工做線程 Set[Worker] works;關聯訪問工做線程的可重入鎖 ReentrantLock mainLock;
  • 線程池的配置:核心線程數、最大容許線程數、最小容許線程數、最大線程空閒時間。

線程池的實現要點以下:

  • 線程池總狀態控制 ctl : (3位 runState rs, 29 位 workCount wc)。 ctl = rs | wc。rs 用來表示線程池的狀態:RUNNING-- 運行,能夠接受新任務; SHUTDOWN -- 關閉,不接受新任務,但能夠運行隊列中任務;STOP -- 中止,不接受新任務,中斷全部正運行的任務;TIDYING -- 線程池已空,將運行 terminated 鉤子方法;TERMINATED -- terminated 方法執行完成,線程池完全終止。技巧:1. 將多個值打包到一個值的技巧;2. 狀態值遞增,有利於狀態的判斷。
  • worker:用來執行任務。同時繼承 AQS 根據 0 & 1 狀態實現了簡單的非重入互斥鎖,這能夠防止某些中斷,這些中斷旨在喚醒等待任務的工做線程,而不是中斷正在運行的任務。
  • workers & mainLock : 配合起來訪問工做線程集合,用來作線程統計,以及線程池終止時防止中斷風暴。技巧:輕量級併發訪問容器裏的對象。
  • 任務運行: run(Worker) 方法。使用 Protected Lock 模式。
  • 線程池終止:SHUTDOWN -> STOP -> TIDYING -> TERMINATED。
  • 擴展:能夠繼承 ThreadPoolExecutor,並覆寫 beforeExecute 和 afterExecute 方法,定義在任務執行以前和執行以後的行爲。能夠用來申請/釋放資源、打日誌等。

陷阱

要作到併發的準確與安全,須要很是當心地避免一些常見陷阱:

應用

InnoDB鎖

  • InnoDB 使用鎖機制實現事務隔離性級別。避免:髒讀(讀到未提交數據)、不可重複讀(兩次查詢讀到不一致的數據)、幻讀(兩次查詢讀到不同的行)。丟失更新問題須要應用層來控制。InnoDB 鎖主要有行鎖、頁鎖和表鎖。
  • 表鎖:開銷小,加鎖快,鎖粒度最大,衝突機率高,併發度低,不會死鎖。使用表鎖的狀況:沒有索引時,更新數據會鎖表;串行化隔離級別會鎖表; 部分 DDL 會鎖表或者阻塞寫,不要在業務高峯期進行。
  • 行鎖:開銷小,加鎖慢,鎖粒度最小,衝突機率較低,併發度較高,會死鎖。InnoDB 行鎖是經過給索引樹上的索引項加鎖來實現的。有索引時,鎖定讀會鎖行,更新數據行會鎖行。行鎖可分爲共享鎖(讀鎖、S 鎖)和排他鎖(寫鎖、X 鎖)。 S 鎖與 S 鎖能夠併發,其它都須要等待已有鎖的釋放。
  • 鎖定讀:select … for update (X 鎖), select … lock in share mode(S 鎖)。 自增加鍵的鎖使用 X 鎖定讀;外鍵列的 SELECT 會生成 S 鎖定讀。
  • 自增加鍵的鎖。AUTO-INC Locking --- 含自增加鍵的表逐漸插入記錄時,會生成 select for update 的加鎖讀。 特殊表鎖機制,鎖在完成 SQL 插入語句以後當即釋放,而不是等事務執行完成後釋放。MySQL 5.1.22 以後提供了一種輕量級互斥量的機制,來實現自增加值插入的性能提高。innodb_autoinc_lock_mode 參數能夠選擇使用何種機制。默認值爲 1 ,對於插入前能夠肯定插入行數的 simple inerts ,使用互斥量機制,不能肯定行數的使用 AUTO-INC Locking 機制。innodb_autoinc_lock_mode = 2 時,始終使用 AUTO-INC Locking 機制,性能最優,但容易致使不一致問題。
  • 外鍵列:外鍵列的 SELECT 會對父表中的相應行加 S 鎖。若是父表中的相應行已經有 X 鎖,則外鍵的 SELECT 須要等待鎖釋放後才能執行。
  • 行鎖算法:Record 鎖、Gap 鎖、Next-Key 鎖。Record 鎖一般是索引列的讀寫引發,鎖定行記錄自己;Gap 鎖定範圍邊界但不鎖定記錄;Next-Key 鎖是 Record + Gap 的結合,右閉區間,鎖定範圍內的記錄以及範圍右端的記錄,但不鎖定左端的記錄,能夠防止幻讀。InnoDB 默認隔離級別是 Repeatable Read ,該級別下,輔助索引的默認行鎖是 Next-key 鎖;若查詢列爲惟一索引列時,Next-Key 鎖會降級爲 Record 鎖。

分佈式鎖

  • 鎖的要求: 容易替換實現、可重入、高性能、高可用。實現時要考慮異常(應用宕機、網絡延時與中斷、集羣節點宕機等)。
  • 基本思想: 鎖 + 超時 + 持鎖線程的惟一標識 + 加鎖/釋放鎖的必要檢測。一般使用 Redis, ZK 實現。
  • 鎖釋放: 1. 須要加超時,避免線程不響應時沒法釋放鎖; 2. 加鎖時必須加該線程的標識信息,避免釋放鎖時釋放錯誤。考慮這樣一種狀況:線程 A 申請了帶超時的鎖 l ,因某種緣由被阻塞或者不響應,鎖 l 因超時被釋放,被線程 B 申請到; 接着 A 從阻塞或不響應中恢復過來,釋放原來申請的鎖,若是鎖沒有線程標識的信息,就極可能把 B 申請的鎖給釋放掉了。這就是說,釋放鎖時須要嚴格的檢測。
  • Redis: 加鎖 -- SET NX key unique_value EXPIRE_TIME ,若已持有鎖則加 EXPIRE_TIME,若是 NX 和 EXPIRE_TIME 不一樣時在一塊兒,當進程加鎖後就崩潰,則該鎖將沒法釋放;釋放鎖 -- get-and-del 使用 Lua 腳本保證原子性, if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) 。 可使用 Redisson API 。
  • ZK: 先建立一個持久化節點,當第一個客戶端須要加鎖時,在持久化節點下建立一個最小編號的臨時順序節點;後續要加鎖的客戶端依次建立編號次小的臨時順序節點,並對臨近的前一個節點建立 watch 監放任務。 每一個客戶端釋放鎖時,都須要檢測本身的節點編號是不是最小的那個,每次僅能釋放編號最小的那個節點。當釋放成功後,釋聽任務會觸發下一個節點所關聯的任務和客戶端,這個客戶端就能夠拿到鎖進行操做。當客戶端崩潰時,這個節點也會被刪除,後面的節點則依次往前挪一位。不一樣業務的鎖採用不一樣的前綴。可以使用 Curator SDK。
  • 實現注意事項:1. 最好提供一個分佈式鎖的接口,隔離應用程序對具體實現的直接依賴; 2. 在加鎖時考慮釋放,避免使用者忘記釋放鎖; 3. 降級處理,好比 Redis 鎖不可用時可降級爲 DB 鎖 或 ZK 鎖; 4. 加鎖和釋放鎖的監控(加鎖和釋放鎖的時間、鎖中業務執行時間、次數、併發量、失敗次數等)。

挑戰

大流量

併發大流量是引發應用不穩定甚至將應用擊潰的常見殺手之一。應對併發大流量的措施:1. 緩存,減小對後端存儲壓力;2. 降級,暫時移除非核心鏈路;3. 限流; 4. 架構升級,作到動靜分離、冷熱分離、讀寫分離、服務器分離、服務分離、分庫分表、負載均衡、NoSQL 技術、(多機房)冗餘、容器化、上雲。

不一致

因爲任務順序的不肯定性及腦力思考的侷限性,加上大流量,在少量情形下,可能會觸發程序的細微 BUG, 引發數據的不一致。

因爲人力的有限性,對於高併發引發的不一致,最好能構建準實時的監控、對帳、補償和對帳報表。

死鎖

多個線程同時要獲取多個類型的共享資源時,申請鎖的順序不當,可能致使死鎖。

  • 四要件:1. 互斥、請求與保持、不可剝奪、循環等待;
  • 解決方案: 1. 加鎖超時釋放,破壞不可剝奪; 2. 加鎖順序控制,破壞循環等待; 3. 使用等待圖來檢測死鎖。

小結

世界自然是併發的。併發既是一種高效的運做方式,亦是一種符合天然的設計。本文總結了併發的基礎知識、思路、模式、工具、陷阱、應用、挑戰。

PS: 當我回過頭來看寫下的這些知識時,發現大部分都是描述性的,只有少部分是原理性的。或者說,在某個層次上是原理性的知識,在更底層看來是描述性的知識。從描述性的知識中,應當提煉出原理與思想。

我忽然感到:不只要對整個模型和機制有宏觀清晰的視野,也要能紮下去,研究第一手的論文和資料。如此,才能自下而上地融會貫通。而這樣的探索,纔是技術的精神本質。

參考資料

相關文章
相關標籤/搜索