一、為什麼需要瞭解鎖 1.1 死鎖問題 1.2 併發問題導致的不正確數據的讀取和存儲,破壞數據一致性的 丟失更新:當兩個或多個事務選擇同一行,然後基於最初選定的值更新該行時,由於每個事務都不知道其他事務的存在,就會發生丟失更新問題--最後的更新覆蓋了由其他事務所做的更新。例如,兩個編輯人員製作了同一 ...
一、為什麼需要瞭解鎖
1.1 死鎖問題
1.2 併發問題導致的不正確數據的讀取和存儲,破壞數據一致性的
- 丟失更新:當兩個或多個事務選擇同一行,然後基於最初選定的值更新該行時,由於每個事務都不知道其他事務的存在,就會發生丟失更新問題--最後的更新覆蓋了由其他事務所做的更新。例如,兩個編輯人員製作了同一文檔的電子副本。每個編輯人員獨立地更改其副本,然後保存更改後的副本,這樣就覆蓋了原始文檔。最後保存其更改副本的編輯人員覆蓋另一個編輯人員所做的更改。如果在一個編輯人員完成並提交事務之前,另一個編輯人員不能訪問同一文件,則可避免此問題
- 臟讀:一個事務正在對一條記錄做修改,在這個事務完成並提交前,這條記錄的數據就處於不一致狀態;這時,另一個事務也來讀取同一條記錄,如果不加控制,第二個事務讀取了這些“臟”數據,並據此做進一步的處理,就會產生未提交的數據依賴關係。這種現象被形象地叫做”臟讀”。
- 不可重覆讀:一個事務在讀取某些數據後的某個時間,再次讀取以前讀過的數據,卻發現其讀出的數據已經發生了改變、或某些記錄已經被刪除了!這種現象就叫做“不可重覆讀”。
- 幻讀:一個事務按相同的查詢條件重新讀取以前檢索過的數據,卻發現其他事務插入了滿足其查詢條件的新數據,這種現象就稱為“幻讀”。
二、鎖的分類
2.1 資料庫維度
- 共用鎖:用於不更改或不更新數據的操作(只讀操作)。共用鎖允許併發事務讀取同一個資源,數據資源上存在共用鎖時,任何其他事務不允許修改數據
- 排它鎖: 用於數據修改,確保不會同時多重更新同一數據。資源上存在排他鎖時,其他任何事務不允許給資源上鎖,當資源上有其他鎖時,也無法對其加上排它鎖
PS:只有共用鎖與共用鎖相互相容,共用鎖與排它鎖、排它鎖之間都互不相容
- 更新鎖
- 用於可更新的資源中,防止多個會話在讀取、鎖定以及隨後可能進行的資源更新時發生的死鎖問題。
- 通常形式的死鎖:一般一個更新模式由一個事務組成,此事務讀取記錄,獲取資源的共用鎖,然後修改行,此操作要求鎖轉換為排它鎖。如果兩個事務同時獲得了資源上的共用鎖,然後試圖同時更新數據,則一個事務嘗試將鎖轉換為排它鎖。由於一個事務的排它鎖與另一事務的共用鎖的不相容,從共用鎖到排它鎖的轉換必須要等待一段時間,發送鎖等待。而第二個事務同時也試圖獲取排它鎖進行更新。由於兩個事務都要轉化為排它鎖,並且每個事務都要等待另一個事務釋放掉共用鎖,因此發生死鎖
- 更新鎖一次只有一個事務可以獲取資源的更新鎖。如果事務修改資源,那麼更新鎖轉化為排它鎖,否則轉化為共用鎖。當資源上存在更新鎖時,允許資源被讀取(即更新鎖與共用鎖相容),但不允許資源被修改
- 一般來說,在執行UPDATE操作時,SQL SERVER會使用到更新鎖而不是依次加上共用鎖和排它鎖,已經迴避了這種通常形式的死鎖,更新鎖與意向鎖相互相容
2.2 資料庫鎖機制
DBMS | SELECT | UPDATE | INSERT | DELETE |
MySQL(InnoDB) | 不加鎖 | 排它鎖 | 排它鎖 | 排它鎖 |
SQL SERVER | 共用鎖 | 更新鎖 | 排它鎖 | 排它鎖 |
這兩種鎖機制的區別在於MySQL的查詢與更新操作互相不阻塞;而SQL SERVER的更新鎖轉化成排它鎖之前,其查詢與更新操作互相不阻塞,當更新鎖要轉化為排它鎖時,需要等待共用鎖的釋放,當更新鎖轉化為排它鎖後,查詢數據需要等待排它鎖的釋放。
參考:
[資料庫鎖機制](http://blog.csdn.net/samjustin1/article/details/52210125)
[InnoDB鎖機制](http://blog.chinaunix.net/uid-24111901-id-2627857.html)
[SQL SERVER鎖機制](http://blog.itpub.net/13651903/viewspace-1091664/)
2.3 程式員思想維度
- 悲觀鎖
- 悲觀併發控制(又名“悲觀鎖”,Pessimistic Concurrency Control,縮寫“PCC”)是一種併發控制的方法。它可以阻止一個事務以影響其他用戶的方式來修改數據。如果一個事務執行的操作都某行數據應用了鎖,那隻有當這個事務把鎖釋放,其他事務才能夠執行與該鎖衝突的操作。悲觀鎖的實現,往往依靠資料庫提供的鎖機制。悲觀併發控制主要用於數據爭用激烈的環境,以及發生併發衝突時使用鎖保護數據的成本要低於回滾事務的成本的環境中。
- 悲觀併發控制實際上是“先取鎖再訪問”的保守策略,為數據處理的安全提供了保證。但是在效率方面,處理加鎖的機制會讓資料庫產生額外的開銷,還有增加產生死鎖的機會;另外,在只讀型事務處理中由於不會產生衝突,也沒必要使用鎖,這樣做只能增加系統負載;還有會降低了並行性,一個事務如果鎖定了某行數據,其他事務就必須等待該事務處理完才可以處理那行數
- 樂觀鎖
- 樂觀併發控制(又名“樂觀鎖”,Optimistic Concurrency Control,縮寫“OCC”)是一種併發控制的方法。它假設多用戶併發的事務在處理時不會彼此互相影響,各事務能夠在不產生鎖的情況下處理各自影響的那部分數據。在提交數據更新之前,每個事務會先檢查在該事務讀取數據後,有沒有其他事務又修改了該數據。如果其他事務有更新的話,正在提交的事務會進行回滾。
- 樂觀併發控制相信事務之間的數據競爭(data race)的概率是比較小的,因此儘可能直接做下去,直到提交的時候才去鎖定,所以不會主動產生任何鎖和死鎖。但是在併發量高的情況下,可能導致某次數據修改多次重試,影響單次成功操作的時間。
- 數據版本實現樂觀鎖:實現數據版本有兩種方式,第一種是使用版本號,第二種是使用時間戳。使用版本號時,可以在數據初始化時指定一個版本號,每次對數據的更新操作都對版本號執行+1操作。並判斷當前版本號是不是該數據的最新的版本號。
update table set date=1,version=version+1 where id=#{id} and version=#{version};
參考:
[樂觀鎖與悲觀鎖](http://www.open-open.com/lib/view/open1452046967245.html)
2.4 樂觀鎖另一種實現方式CAS
CAS是項樂觀鎖技術,當多個線程嘗試使用CAS同時更新同一個變數時,只有其中一個線程能更新變數的值,而其它線程都失敗,失敗的線程並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。
CAS 操作包含三個操作數 —— 記憶體位置(V)、預期原值(A)和新值(B)。如果記憶體位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新為新值。否則,處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前返回該位置的值。CAS 有效地說明瞭“我認為位置 V 應該包含值 A;如果包含該值,則將 B 放到這個位置;否則,不要更改該位置,只告訴我這個位置現在的值即可。”這其實和樂觀鎖的衝突檢查+數據更新的原理是一樣的。
java.util.concurrent(J.U.C)就是建立在CAS之上的。相對於對於synchronized這種阻塞演算法,CAS是非阻塞演算法的一種常見實現。所以J.U.C在性能上有了很大的提升。
public class AtomicInteger extends Number implements java.io.Serializable { private volatile int value; public final int get() { return value; } public final int getAndIncrement() { for (;;) { int current = get(); int next = current + 1; if (compareAndSet(current, next)) return current; } } public final boolean compareAndSet(int expect, int update) { return unsafe.compareAndSwapInt(this, valueOffset, expect, update); } }
參考:
[樂觀鎖的一種實現方式—CAS](http://www.importnew.com/20472.html)
三、案例分析
3.1 初審統計數據遷移
- 遷移背景:原有的統計方式採用的是實時count的方法獲取統計數據,造成的問題是查詢慢且無法獲取長時間段的統計數據(sql超時)、無法獲取某日統計數據的快照(前一天的待審核數據會變成今天的審核通過數據)
- 採用遷移方式:使用raptor遷移平臺,掃描審核記錄表,取出累計統計數據後進行加1操作,然後更新到統計表中。由於平臺特性,數據遷移過程具有高併發性,由於強行採用先讀取後更新的方式,會造成丟失更新的情況,於是這裡考慮採用CAS
step1:
select id,passCount,rejectCount,hideCount,warnCount,waitCount from book.TradeItemAuditCount where type = #{type} and date = #{date} and editor = #{editor} and isDeleted = 0 limit 1
step 2:【失敗重試】
update book.TradeItemAuditCount set passCount = #{passCount} , rejectCount = #{rejectCount} , hideCount = #{hideCount} , warnCount = #{warnCount} , waitCount = #{waitCount} , updated = #{updated} where id = #{id} and passCount = #{oldPassCount} and rejectCount = #{oldRejectCount} and hideCount = #{oldHideCount} and warnCount = #{oldWarnCount} and waitCount = #{oldWaitCount} and isDeleted = 0 limit 1
- 處理結果 :一共掃描審核結果4335668條數據,對重試次數超過100的更新操作進行記錄,發現更新操作出現大部分的重試,任務本身DB寫操作的qps較低【都不需要通過控制台限制速率..】
3.2 商品庫存
- 商品庫存與上述案例1一致,都是對數據記錄進行加減操作,發現庫存的更新方式如下:
update 庫存表 set stock=stock-1 where id=#{id}
- 直接使用資料庫的排它鎖就簡單的避免了併發導致的丟失更新問題,之前提到的一次只有一個事務擁有資源的排它鎖,併發的更新操作都試圖占有資源的排它鎖,當資源上存在排它鎖時,其他更新操作需要等待鎖的釋放
- 相比案例1的解決方案,案例2的解決方式直接使用了MySQL InnoDB更新操作本身就擁有的排它鎖,不需要額外的開銷,而案例1不必要的查詢操作以及多次的重試操作嚴重影響到了數據遷移的性能,所以案例1是反面例子..
3.3 商品打標
- 隨著上打標的qps上漲,出現達標更新數據丟失的情況
{"tags":"16,32,233,22","itemState":1,"hd":"ai:4|nd:18","au":"baoming"}
- 處理方案
1.樂觀鎖:採用CAS
update TradeItem set extra=#{extra} where tradeItemId=#{tradeItemId} and extra=#{oldExtra}
這裡使用長字元串做更新條件,會影響到SQL性能
2.樂觀鎖:採用數據版本 表中新增version欄位標識數據版本,作為數據更新的檢查方式
update TradeItem set extra=#{extra} , version=version+1 where tradeItemId=#{tradeItemId} and version=#{version}
此方案改造較大,還需要為表新增欄位,而且採用樂觀鎖擁有這一律的弊端:重試帶來的時間代價,一旦併發量上漲,某次更新操作的重試次數也會隨之上漲,直接影響到暴露服務的響應時間。【限制重試次數能夠一定程度上控制更新操作的響應時間,但是仍然會出現更新丟失的現象(讓調用方進行重試操作,分攤單次請求的響應時間?)】
3. 悲觀鎖:更新丟失的根本原因是執行查詢、修改兩個操作之間數據被另一事務修改了,單純的UPDATE操作其實也是進行著先查詢後修改的操作,沒有產生更新丟失是因為數據上存在排它鎖(sql server則是更新鎖),在執行期間並不允許其他修改。同理我們將要打標的商品記錄加上排它鎖或者更新鎖就能解決問題。
MySQL:
start transaction; SELECT extra FROM TradeItem WHERE tradeItemId=#{tradeItemId} FOR UPDATE; UPDATE TradeItem SET extra = bdo.AddTag(tag,extra) WHERE tradeItemId=#{tradeItemId}; commit;
SQL SERVER:
BEGIN TRANSACTION --開始一個事務 SELECT extra FROM TradeItem WITH (UPDLOCK) WHERE tradeItemId=#{tradeItemId} UPDATE TradeItem SET extra = bdo.AddTag(tag,extra) WHERE tradeItemId=#{tradeItemId} COMMIT TRANSACTION --提交事務
該方案避免了重試帶來的開銷,同時使用排它鎖(更新鎖)也沒有額外增加鎖的開銷