一、并發控制
當程序中可能出現并發的情況時,就需要保證在并發情況下數據的準確性,以此確保當前用戶和其他用戶一起操作時,所得到的結果和他單獨操作時的結果是一樣的。這就叫做并發控制。并發控制的目的是保證一個用戶的工作不會對另一個用戶的工作產生不合理的影響。
沒有做好并發控制,就可能導致臟讀、幻讀和不可重復讀等問題。
常說的并發控制,一般都和數據庫管理系統(DBMS)有關。在 DBMS 中并發控制的任務,是確保多個事務同時增刪改查同一數據時,不破壞事務的隔離性、一致性和數據庫的統一性。
實現并發控制的主要手段分為樂觀并發控制和悲觀并發控制兩種。
無論是悲觀鎖還是樂觀鎖,都是人們定義出來的概念,可以認為是一種思想。其實不僅僅是關系型數據庫系統中有樂觀鎖和悲觀鎖的概念,像 hibernate、tair、memcache 等都有類似的概念。所以,不應該拿樂觀鎖、悲觀鎖和其他的數據庫鎖等進行對比。樂觀鎖比較適用于讀多寫少的情況(多讀場景),悲觀鎖比較適用于寫多讀少的情況(多寫場景)。
二、悲觀鎖(Pessimistic Lock)
1??理解
當要對數據庫中的一條數據進行修改的時候,為了避免同時被其他人修改,最好的辦法就是直接對該數據進行加鎖以防止并發。這種借助數據庫鎖機制,在修改數據之前先鎖定,再修改的方式被稱之為悲觀并發控制【Pessimistic Concurrency Control,縮寫“PCC”,又名“悲觀鎖”】。
悲觀鎖,具有強烈的獨占和排他特性。它指的是對數據被外界(包括本系統當前的其他事務,以及來自外部系統的事務處理)修改持保守態度。因此,在整個數據處理過程中,將數據處于鎖定狀態。悲觀鎖的實現,往往依靠數據庫提供的鎖機制(也只有數據庫層提供的鎖機制才能真正保證數據訪問的排他性,否則,即使在本系統中實現了加鎖機制,也無法保證外部系統不會修改數據)。
之所以叫做悲觀鎖,是因為這是一種對數據的修改持有悲觀態度的并發控制方式。總是假設最壞的情況,每次讀取數據的時候都默認其他線程會更改數據,因此需要進行加鎖操作,當其他線程想要訪問數據時,都需要阻塞掛起。悲觀鎖的實現:
- 傳統的關系型數據庫使用這種鎖機制,比如行鎖、表鎖、讀鎖、寫鎖等,都是在操作之前先上鎖。
- Java 里面的同步 synchronized 關鍵字的實現。
2??悲觀鎖主要分為共享鎖和排他鎖:
- 共享鎖【shared locks】又稱為讀鎖,簡稱 S 鎖。顧名思義,共享鎖就是多個事務對于同一數據可以共享一把鎖,都能訪問到數據,但是只能讀不能修改。
- 排他鎖【exclusive locks】又稱為寫鎖,簡稱 X 鎖。顧名思義,排他鎖就是不能與其他鎖并存,如果一個事務獲取了一個數據行的排他鎖,其他事務就不能再獲取該行的其他鎖,包括共享鎖和排他鎖。獲取排他鎖的事務可以對數據行讀取和修改。
3??說明
悲觀并發控制實際上是“先取鎖再訪問”的保守策略,為數據處理的安全提供了保證。但是在效率方面,處理加鎖的機制會讓數據庫產生額外的開銷,還有增加產生死鎖的機會。另外還會降低并行性,一個事務如果鎖定了某行數據,其他事務就必須等待該事務處理完才可以處理那行數據。
三、樂觀鎖(Optimistic Locking)
1??理解
樂觀鎖是相對悲觀鎖而言的,樂觀鎖假設數據一般情況不會造成沖突,所以在數據進行提交更新的時候,才會正式對數據的沖突與否進行檢測,如果沖突,則返回給用戶異常信息,讓用戶決定如何去做。樂觀鎖適用于讀多寫少的場景,這樣可以提高程序的吞吐量。
樂觀鎖采取了更加寬松的加鎖機制。也是為了避免數據庫幻讀、業務處理時間過長等原因引起數據處理錯誤的一種機制,但樂觀鎖不會刻意使用數據庫本身的鎖機制,而是依據數據本身來保證數據的正確性。樂觀鎖的實現:
- CAS 實現:Java 中java.util.concurrent.atomic包下面的原子變量使用了樂觀鎖的一種 CAS 實現方式。
- 版本號控制:一般是在數據表中加上一個數據版本號 version 字段,表示數據被修改的次數。當數據被修改時,version 值會 +1。當線程 A 要更新數據時,在讀取數據的同時也會讀取 version 值,在提交更新時,若剛才讀取到的 version 值與當前數據庫中的 version 值相等時才更新,否則重試更新操作,直到更新成功。
2??說明
樂觀并發控制相信事務之間的數據競爭(data race)的概率是比較小的,因此盡可能直接做下去,直到提交的時候才去鎖定,所以不會產生任何鎖和死鎖。
四、具體實現
1??悲觀鎖實現方式
悲觀鎖的實現,往往依靠數據庫提供的鎖機制。在數據庫中,悲觀鎖的流程如下:
- 在對記錄進行修改前,先嘗試為該記錄加上排他鎖(exclusive locks)。
- 如果加鎖失敗,說明該記錄正在被修改,那么當前查詢可能要等待或者拋出異常。具體響應方式由開發者根據實際需要決定。
- 如果成功加鎖,那么就可以對記錄做修改,事務完成后就會解鎖了。
- 期間如果有其他對該記錄做修改或加排他鎖的操作,都會等待解鎖或直接拋出異常。
以 MySql Innodb 引擎舉例,說明 SQL 中悲觀鎖的應用
要使用悲觀鎖,必須關閉 MySQL 數據庫的自動提交屬性set autocommit=0
。因為 MySQL 默認使用 autocommit 模式,也就是說,當執行一個更新操作后,MySQL 會立刻將結果進行提交。
在對 id = 1 的記錄修改前,先通過 for update 的方式進行加鎖,然后再進行修改。這就是比較典型的悲觀鎖策略。
如果發生并發,同一時間只有一個線程可以開啟事務并獲得 id=1 的鎖,其它的事務必須等本次事務提交之后才能執行。這樣可以保證當前的數據不會被其它事務修改。
使用 select…for update 鎖數據,需要注意鎖的級別,MySQL InnoDB 默認行級鎖。行級鎖都是基于索引的,如果一條 SQL 語句用不到索引是不會使用行級鎖的,會使用表級鎖把整張表鎖住,這點需要注意。
2??樂觀鎖實現方式樂觀鎖不需要借助數據庫的鎖機制
主要就是兩個步驟:沖突檢測和數據更新。比較典型的就是 CAS (Compare and Swap)。
CAS 即比較并交換。是解決多線程并行情況下使用鎖造成性能損耗的一種機制,CAS 操作包含三個操作數——內存位置(V)、預期原值(A)和新值(B)。如果內存位置的值(V)與預期原值(A)相匹配,那么處理器會自動將該位置值更新為新值(B)。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。CAS 有效地說明了“我認為位置(V)應該包含值(A)。如果包含該值,則將新值(B)放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可”。Java 中,sun.misc.Unsafe 類提供了硬件級別的原子操作來實現這個 CAS。java.util.concurrent包下大量的類都使用了這個 Unsafe.java 類的 CAS 操作。
當多個線程嘗試使用 CAS 同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗,失敗的線程并不會被掛起,而是被告知這次競爭中失敗,并可以再次嘗試。比如前面的扣減庫存問題,通過樂觀鎖可以實現如下:在更新之前,先查詢一下庫存表中當前庫存數(quantity),然后在做 update 的時候,以庫存數作為一個修改條件。當提交更新的時候,判斷數據庫表對應記錄的當前庫存數與第一次取出來的庫存數進行比對,如果數據庫表當前庫存數與第一次取出來的庫存數相等,則予以更新,否則認為是過期數據。
以上更新語句存在一個比較嚴重的問題,即ABA問題:
- 比如說線程一從數據庫中取出庫存數 3,這時候線程二也從數據庫中取出庫存數 3,并且線程二進行了一些操作變成了 2。
- 然后線程二又將庫存數變成 3,這時候線程一進行 CAS 操作發現數據庫中仍然是 3,然后線程一操作成功。
- 盡管線程一的 CAS 操作成功,但是不代表這個過程就是沒有問題的。
一個比較好的解決辦法,就是通過一個單獨的可以順序遞增的 version 字段。優化如下:
樂觀鎖每次在執行數據修改操作時,都會帶上一個版本號,一旦版本號和數據的版本號一致就可以執行修改操作并對版本號執行 +1 操作,否則就執行失敗。因為每次操作的版本號都會隨之增加,所以不會出現 ABA 問題。除了 version 以外,還可以使用時間戳,因為時間戳天然具有順序遞增性。
以上 SQL 其實還是有一定的問題的,就是一旦遇上高并發的時候,就只有一個線程可以修改成功,那么就會存在大量的失敗。對于像淘寶這樣的電商網站,高并發是常有的事,總讓用戶感知到失敗顯然是不合理的。所以,還是要想辦法減少樂觀鎖的粒度。一個比較好的建議,就是減小樂觀鎖力度,最大程度的提升吞吐率,提高并發能力!如下:
以上 SQL 語句中,如果用戶下單數為 1,則通過quantity - 1 > 0
的方式進行樂觀鎖控制。在執行過程中,會在一次原子操作中查詢一遍 quantity 的值,并將其扣減掉 1。
高并發環境下鎖粒度把控是一門重要的學問。選擇一個好的鎖,在保證數據安全的情況下,可以大大提升吞吐率,進而提升性能。
五、理解 CAS 底層
假如說有 3 個線程并發的要修改一個 AtomicInteger 的值,底層機制如下:
- 首先,每個線程都會先獲取當前的值,接著走一個原子的 CAS 操作。原子的意思就是這個 CAS 操作一定是自己完整執行完的,不會被別人打斷。
- 然后 CAS 操作里,會比較一下,現在的值是不是剛才獲取到的那個值。如果是,說明沒人改過這個值,然后設置成累加 1 之后的一個值。
- 同理,如果有人在執行 CAS 的時候,發現之前獲取的值跟當前的值不一樣,會導致 CAS 失敗。失敗之后,進入一個無限循環,再次獲取值,接著執行 CAS 操作。
六、CAS 典型應用
java.util.concurrent.atomic包下的類大多是使用 CAS 操作來實現的,比如 AtomicInteger、AtomicBoolean、AtomicLong。一般在競爭不是特別激烈的時候,使用該包下的原子操作性能比使用 synchronized 關鍵字的方式高效的多(查看 getAndSet(),可知如果資源競爭十分激烈的話,這個 for 循環可能會持續很久都不能成功跳出。不過這種情況可能需要考慮降低資源競爭才是)。
在較多的場景都可能會使用到這些原子類操作。一個典型應用就是計數了,在多線程的情況下需要考慮線程安全問題。
1??支持計數功能 Demo 實現
public class Increment {
private int count = 0;
public void add() {
count++;
}
}
在并發環境下對 count 進行自增運算是不安全的,為什么不安全以及如何解決這個問題呢?
2??為什么并發環境下的 count 自增操作不安全?因為 count++ 不是原子操作,而是三個原子操作的組合:
- 讀取內存中的 count 值賦值給局部變量 temp;
- 執行 temp+1 操作;
- 將 temp 賦值給 count。
所以如果兩個線程同時執行 count++ 的話,不能保證線程一按順序執行完上述三步后線程二才開始執行。
3??并發環境下 count++ 不安全問題的解決方案
方案①:synchronized 加鎖。同一時間只有一個線程能加鎖,其他線程需要等待鎖,這樣就不會出現 count 計數不準確的問題了:
public class Increment {
private int count = 0;
public synchronized void add() {
count++;
}
}
但是引入 synchronized 會造成多個線程排隊的問題,相當于讓各個線程串行化了,一個接一個的排隊、加鎖、處理數據、釋放鎖,下一個再進來。同一時間只有一個線程執行,這樣的鎖有點“重量級”了。這類似于悲觀鎖的實現,需要獲取這個資源,就給它加鎖,別的線程都無法訪問該資源,直到操作完后釋放對該資源的鎖。雖然隨著 Java 版本更新,也對 synchronized 做了很多優化,但是處理這種簡單的累加操作,仍然顯得“太重了”。
方案②:Atomic 原子類。對于 count++ 的操作,完全可以換一種做法,Java 并發包下面提供了一系列的 Atomic 原子類,比如說 AtomicInteger:
//import java.util.concurrent.atomic.AtomicInteger;
public static void main(String[] args) {
public static AtomicInteger count = new AtomicInteger(0);
public static void increase() {
count.incrementAndGet();
}
}
多個線程可以并發的執行 AtomicInteger 的 incrementAndGet(),意思就是把 count 的值累加 1,接著返回累加后最新的值。實際上,Atomic 原子類底層用的不是傳統意義的鎖機制,而是無鎖化的 CAS 機制,通過 CAS 機制保證多線程修改一個數值的安全性。
七、CAS 性能優化
從流程圖可以看出來,大量的線程同時并發修改一個 AtomicInteger,可能有很多線程會不停的自旋,進入一個無限重復的循環中。這些線程不停地獲取值,然后發起 CAS 操作,但是發現這個值被別人改過了,于是再次進入下一個循環,獲取值,發起 CAS 操作又失敗了,再次進入下一個循環。在大量線程高并發更新 AtomicInteger 的時候,這種問題可能會比較明顯,導致大量線程空循環,自旋轉,性能和效率都不是特別好。那么如何優化呢?
Java8 有一個新的類,LongAdder,它就是嘗試使用分段 CAS 以及自動分段遷移的方式來大幅度提升多線程高并發執行 CAS 操作的性能,這個類具體是如何優化性能的呢?如圖:
LongAdder 核心思想就是熱點分離,這一點和 ConcurrentHashMap 的設計思想相似。就是將 value 值分離成一個數組,當多線程訪問時,通過 hash 算法映射到其中的一個數字進行計數。而最終的結果,就是這些數組的求和累加。這樣一來,就減小了鎖的粒度。
LongAddr 的兄弟類如下:八、如何選擇
在樂觀鎖與悲觀鎖的選擇上面,主要看下兩者的區別以及適用場景就可以了。
1??響應效率:如果需要非常高的響應速度,建議采用樂觀鎖方案,成功就執行,不成功就失敗,不需要等待其他并發去釋放鎖。樂觀鎖并未真正加鎖,效率高。一旦鎖的粒度掌握不好,更新失敗的概率就會比較高,容易發生業務失敗。
2??沖突頻率:如果沖突頻率非常高,建議采用悲觀鎖,保證成功率。沖突頻率大,選擇樂觀鎖會需要多次重試才能成功,代價比較大。
3??重試代價:如果重試代價大,建議采用悲觀鎖。悲觀鎖依賴數據庫鎖,效率低。更新失敗的概率比較低。
4??樂觀鎖如果有人在你之前更新了,你的更新應當是被拒絕的,可以讓用戶從新操作。悲觀鎖則會等待前一個更新完成。這也是區別。
隨著互聯網三高架構(高并發、高性能、高可用)的提出,悲觀鎖已經越來越少的被應用到生產環境中了,尤其是并發量比較大的業務場景。