摘抄並用於自查筆記
ACID
Atomicity,原子性。指一組對數據庫的改變,要么最終成功執行完成,要不就全部回滾。這就要求數據庫系統要實現某種回滾機制,比如redo/undo log。與事務性數據庫相比,一些NoSQL數據庫也聲稱支持原子性,但是意義不同,比如Redis事務的原子性的意思更接近於“一組指令被執行時,不受其他指令的干擾”,而不是“可以回滾”。
Consistency,一致性。指在事務完成的前后,數據都是要在業務意義上是“正確的”。但是,保證業務是否正確是要業務代碼來最終保證的,數據庫能做的非常有限。目前數據庫里實現的約束檢查,比如唯一約束、外鍵約束、一些enum檢查、一些數據類型/長度/有效數字的檢查等等,對於簡單的場景還可以使用。對於復雜的業務約束檢查,很難或者不可能實現。真是復雜業務的數據正確性維護一般用 正確的業務代碼 + 合法性校驗 + 數據庫自身的簡單合法性防護一起實現。
Isolation,隔離性。指一組對數據庫的並發修改互相不影響。事實上,如果是並發修改的是相關聯的,或者就是同一份數據,就必然會相互影響。那么此時可以做的是區分那個優先級更高,高優先級的修改應該覆蓋低優先級的修改。實際並不好區分先后。另一種情況是“先讀取,再基於讀取結果數據進行修改”,比如,先找到可用的庫存,先讀取,再往上+1。這時,保證隔離性的主要問題並不在於隔離本身,而在於如果將讀取作為對數據修改的前提條件,之后在對數據進行修改的一剎那,讀取時的前提條件還是否滿足 。畢竟讀取和寫入是兩個分開的指令,而在這兩個指令中間可能夾雜其他事務對數據的修改。保持隔離性的一個簡單做法是保證對關聯數據的修改串行化,對應事務性數據庫的“Serializable”隔離級別。保證串行化的一種方案是鎖,通過鎖定可以徹底避免競爭條件。但是大家都能明白加鎖對數據庫並發性能負面影響很大,所以就衍生出幾種弱的隔離性保證——READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ。此外MVCC能夠解決一部分鎖帶來的問題。
Duration,持久性。指對數據的修改,一旦完成,該結果就應當永遠不丟失。
所以,綜上,事務性數據庫實現的是
1)支持未完成的數據修改回滾的機制,對應“原子性”
2)力所能及的數據合法性檢查,對應“一致性”
3)保證數據並發的修改的規則,對應“隔離性”
4)使用基於持久化存儲(磁盤、SSD)的方式對數據進行存儲,對應“持久性”
隔離級別和並發控制
SQL92標准定義了四種隔離級別——Read Uncommitted、Read Committed、Repeatable Read和Serializable。定義這4種隔離級別時,制定者主要圍繞着基於鎖的並發控制來說的。但是后來出現了MVCC,之后主流數據庫都開始支持MVCC。
最不嚴格的隔離級別
Dirty Read,臟讀。即一個事務沒提交之前的修改可以被另個事務看到。
Dirty Write,臟寫。即一個事務沒提交之前的修改可以被另一個事務的修改覆蓋掉。
臟讀有時可以被接受,但是臟讀不被允許。
如何避免臟寫呢 ..... 使用鎖。實際上,一般數據庫都會使用排他鎖來標記要修改的數據(update,delete,select ... for update)。鎖的存在可以保證——寫要block寫。這個規則永遠生效。
在Mysql InnoDB中,這種鎖被稱為“X鎖”。它的特性是,只要有一個事務獲取了一條數據的X鎖,其他事務如果也想獲取這個鎖,就必須等待,直到上個事務提交/回滾后釋放鎖,或者等到超時自動回滾。事務性數據庫對於寫操作永遠需要鎖來避免臟寫,即使是基於MVCC的數據庫,所謂某個隔離級別使用MVCC不需要鎖,僅僅是指在讀取的時候是否需要鎖。
所以,最不嚴格的隔離級別的隔離是 允許臟讀,但不允許臟寫。這種隔離級別被稱為 Read Uncommitted。這種隔離級別一般不建議使用。其雖然可以帶來性能上的優勢,但因為非常容易造成數據由於並發操作帶來的問題,所以可以用在不太在意數據正確性的場合。
Read Committed 和 Repeatable Read
這倆的基本原則一樣:讀不block讀和寫,寫不block讀。只不過是發生了並行讀寫的隔離效果不太一樣。此外,他們兩個對OLTP(傳統的關系型數據庫的主要應用)業務代碼的編寫的影響差不多——他們都無法解決“寫前提困境”。
Read Committed是指一個事務能看到另個事務對一條數據記錄已經提交的修改。
Repeatable Read是指一個事務一旦開始,反復讀取一條數據記錄,都會得到相同的結果。其直觀感覺仿佛是給事務做了一個整個數據庫的快照,所以很多時候這種隔離級別又被稱為Snapshot Isolation。“快照”的功能在一些場景下非常重要,如:
1)數據備份。例如數據庫S從數據庫M中復制數據,但是同時M數據庫又被持續修改。S需要拿到一個M的數據快照,但是又不能真的把M停了。
2)數據合法性檢查。例如有兩張數據表,一張記錄了當時的交易總額,另一張表記錄了每個交易的金額,那么在讀取數據時,如果沒有快照的存在,交易金額的總和可能與當時的交易總額對不上,因為隨着檢查事務的進行,新的交易記錄數據會被提交。這些新的提交會被檢查事務看到。
在基於MVCC的數據庫中,一般認為只實現了Read Committed和Repeatable Read兩個隔離級別。
此外,值得一提的是幻讀的問題。在SQL92標准中提到了Repeatable Read中是可以出現幻讀的——即一個事務盡管不能讀取到后續其他事務對現有數據的修改,但是能讀取插入的新數據。但是,基於MVCC的實現,Repeatable Read 可以完全避免幻讀。無論Mysql還是PostgreSQL在Repeatable Read隔離級別都不會出現幻讀。
MVCC
MVCC是“Multi-Version Concurrency Control”的縮寫,即,對數據庫的任何修改的提交都不會直接覆蓋之前的數據,而是產生一個新的版本與老版本共存,使得讀取時完全不加鎖。這個版本一般用進行數據操作的事務ID(單調遞增)來定義。MVCC大致可以這么實現:
每個數據記錄攜帶兩個額外的數據 created_by_txn_id 和 deleted_by_txn_id 。
當一個數據被 insert 時,created_by_txn_id 記錄下插入該數據的事務ID,deleted_by_txn_id 留空。
當一個數據被 delete 時,該數據的 delete_by_txn_id 記錄執行該刪除的事務。
當一個數據被 update 時,原有數據的 deleted_by_txn_id 記錄執行該更新的事務ID,並且新增一條新的數據記錄,其 created_by_txn_id 記錄下更新該數據的事務ID。
在另個事務進行讀取時,由隔離級別來控制到底取哪個版本。同時,在讀取過程中,完全不用加鎖(除非用 SELECT ... FOR UPDATE強行加鎖)。這樣可以極大降低數據讀取時因為沖突被Block的機會。
那么那些多出來的無用數據怎么處理呢?支持MVCC的數據庫一般會有一個背景任務來定時清理那些肯定沒用的數據。只要一個數據記錄的deleted_by_txn_id不為空,並且比當前還沒結束的事務ID中最小的一個還要小,該數據記錄就可以被清理掉。在MySQL InnoDB中,叫做 “purge”。
MySQL采用Undo Log的實現。這種實現下,用於存儲數據表的B+樹節點總是保留最新的數據,而老版本的數據被放在 Undo Log 里,並且以指針的形式關聯起來,形成一個鏈表。這樣,在查找老的版本時,需要按鏈表順序查找,直到找到 created_by_txn_id <= 當前事務ID的最新那條記錄即可。這種實現,在查詢時會在B+樹查找后多引入一個鏈表查詢,但是清理廢棄數據時很簡單,只要把Undo Log找到一個合適位置,一刀切了即可。
有了MVCC,Read Committed 和 Repeatable Read 就實現的的很直觀了:
對於 Read Committed,每次讀取時,總是取最新的,被提交的那個版本的數據記錄。
對於 Repeatable Read,每次讀取時,總是取 created_by_txn_id 小於等於當前事務ID的那些數據記錄。這個范圍內,如果某一數據多個版本都存在,則取最新的。
Intresting!!! 隔離級別可以是一個Session級別的配置。即每一個Session可以在運行時選擇自己希望使用什么隔離級別,也可以隨時修改(只要當前沒有尚未結束的事務)。每個Session的隔離級別和其他Session是什么隔離級別完全無關。Session只要根據自己的隔離級別,選擇用MVCC提供的合適版本即可。
MySQL InnoDB、PostgreSQL、Oracle(從版本4開始)、MS SQL Server(從版本2005開始)都實現了MVCC。注意,MySQL InnoDB盡管一開始就實現了MVCC,但是之前很多人還在使用MyIsam存儲引擎,而MyIsam根本不支持事務,更不用說MVCC。直到MySQL5.5.5,InnoDB才成為MySQL默認的存儲引擎。因此使用MySQL的隔離級別,先要看MySQL的版本和存儲引擎。
寫前提困境
盡管在MVCC的支持下 Read Committed 和 Repeatable Read 都可以得到很好的實現。但是對於某些業務代碼來說,在當前事務中看到/看不到其他的事務已經提交的修改,意義不大。這種業務代碼一般是這樣的:
1. 先讀取一段現有數據
2. 在這個數據基礎上做邏輯判斷或者計算
3. 將計算的結果寫會數據庫
這樣第三步的寫入就會依賴第一步的讀取。但是在1和3 之間,不管業務代碼離的有多近,都無法避免其他事務的並發修改。即,步驟1 的數據正確是步驟3能夠在業務上正確的前提。
在 Repeatable Read 下是解決不了這個問題的,因為在步驟3時,當前事務根本無法看到另外一個事務對數據的修改。這個問題被稱為 Lost Updates。
而 Read Committed 盡管能夠看到其他事務已經提交的修改,問題在於,Read Committed,你必須重復寫一句 select 語句才能拿到,而不管你反復讀取多少次,不管這個 select 離的下面的 update 多近,理論上都無法避免丟失其他事務的修改。
這個問題就是,在修改的事務提交時,無法確保這個修改的前提是否還可靠,這種問題稱寫前提困境。
解決這類問題有3種辦法:
數據庫支持某種代碼塊,這個代碼塊的執行是排他的;
加悲觀鎖,把期望依賴的數據獨占,在修改完成前不允許其他並發修改的發生;
加樂觀鎖,在事務提交的一剎那(commit 時),檢查修改的依賴是不是沒有被修改。
在事務性數據庫中,第一種被稱為 Actual Serial Execution,第二種是加鎖(手工或者自動);第三種被稱為 Serializable Snapshot Isolation,SSI。
1. Actual Serial Execution
Actual Serial Execution 是一種執行的效果,即一段代碼在數據庫服務端執行時不會受到其他並發控制的干擾。但要達成這個效果並不簡單。
最簡單的方案是整個數據庫都只單線程的跑,這樣什么並發隔離保護機制都可以不要,所有的數據不會有任何並發修改的問題。一些NoSQL的存儲,如Redis都是這樣實現的。但是他們這么實現是有原因的,因為他們都是基於內存的存儲,其數據操作的延遲相對於網絡IO幾乎可以忽略不計,所以即使是單線程,配合nonblocking IO,他們的並發性能也可以非常高。但是這個假設對事務性數據庫並不成立,因為事務性數據庫要操作磁盤/SSD。即便是SSD的寫入速度,也會在數量級上低於內存。所以事務性數據庫如果強行改成單線程,就會極大損害並發性能。
此外,單線程存儲因為只能使用單線程,所以一個實例只能使用一個CPU核心,在多核的機器上就會浪費資源。所以往往要單機啟用多個實例。而一旦啟用多個實例就意味着要提前對數據進行 Partition ,分配給多個實例。但是 Partition 會造成單 Partition 查詢方便,跨越多個 Partition 的查詢麻煩的問題。——比較有局限性,比較適合為特定業務做定制存儲。
另一種辦法是用存儲過程將業務邏輯包起來丟給數據庫執行。但是這樣做其實也不現實,因為存儲過程本身並不具備原子性和隔離性。為了讓存儲過程中的執行是排他的,依然需要在存儲過程中聲明一個事務。如果必要,可以聲明當前的事務隔離級別為Serializable以避免寫前提困境。——這種做法其實等價於Serializable 隔離級別。
還有一種是用單SQL語句的事務。比如:update tab1 set counter = counter+1 where id = xxx; 這樣寫的能保證排他性執行,因為這條語句自身可以成為一個事務,並且因為是UPDATE語句,所以必然會搶占X鎖。鎖的存在可以確保不會出現寫前提困境,但是這樣做的前提是有辦法把一個業務邏輯用一句修改類SQL表達。一個計數器的邏輯可以,但是復雜一些的業務就不行,或者在語法上可行,但是寫的多了調試和維護難。
2. 加鎖和基於鎖的Serializable
通過加鎖可以有效的排除所有可能的競爭的問題。在MySQL InnoDB中,Serializable 隔離級別是依靠MVCC + 鎖。
簡單來說,就是所有的讀取都要加上共享鎖。
數據庫中經典的加鎖過程被稱為兩階段加鎖:
加鎖階段:在事務過程中,根據不同的SQL指令加鎖。
釋放鎖階段:鎖定直到這個事務被提交或者回滾(包括等待超時造成回滾)時釋放。
基於鎖的 Serializable 的實現准則是:讀要block寫,寫也要block讀,讀不block讀。
實際上MySQL的Serializable除了鎖記錄,還會鎖記錄的間隙,避免意外的插入。這種鎖概念上被稱為區間鎖(Range Lock)。MySQL InnoDB中的叫法是 Gap Lock 和 Next-key Lock。
上文中有提到基於 MVCC 的 Repeatable Read 可以避免幻讀。在基於鎖的 Serializable 中做的更強硬,它會直接鎖定以避免插入。
在MySQL中,不同的隔離級別內部實現使用不同的 MVCC 讀取策略 + 不同種類的鎖來完成。
隔離級別可以自定義:
SELECT ... LOCK IN SHARE MODE ---- 嘗試將查詢符合條件的記錄加上共享鎖,如果鎖已經被占了就等待
SELECT ... FOR UPDATE ---- 嘗試將符合查詢條件的記錄加上與等價 UPDATE 語句一樣的鎖,包括排他鎖和區間鎖。
這些語句可以無視當前的隔離級別,完全按照你的心意來加鎖。
在MySQL中 SELECT ... FOR UPDATE 會打破當前的 Repeatable Read 隔離級別,拿到另外一個事務提交的最新的數據。
基於鎖的 Serializable 隔離級別,或者手工加鎖,是可以根除任何並發沖突的,但是這是有代價的——大大的增加了鎖的數量,同時也就增加了等待鎖的時間及死鎖的機會。
SSI和基於SSI實現的Serializable
相對於悲觀鎖的方案,相對應的樂觀鎖的方式就是SSI——Serialized Snapshot Isolation。他的大致意思是:本質上,整個事務還是 Snapshot Isolation,但事務在進行過程中,除了對數據進行操作外,還要對整個事務的“寫前提”——所有修改操作的依賴數據做追蹤。當事務被commit時,當前事務會檢查這個“寫前提”是否被其他事務修改過,如果是,則回滾掉當前事務。
那么,怎么偵測到這個修改已經發生了?
1)在一個事務進行提交時,對於所有修改的數據,查看MVCC中是否已經有其他的版本已經提交了但是本事務因為 snapshot 機制沒有讀取到。
2)事務進行時,標記自身所有讀取過的記錄(就好像是加共享鎖,但是並不真的鎖定什么)。另個事務如果提交了一個寫操作,則反查這個 寫操作影響到的數據有哪些被讀取中,並且讀取他們的事務還沒有提交。
實際應用中,為特定業務場景做優化
有全局數據需要增減。例如庫存數量/墊資額度要扣減。此時應該選擇Serializable隔離級別或者手工 SELECT ... FOR UPDATE 加鎖。但是要特別留意,因為這樣做會增加死鎖等待/並發修改造成失誤失敗的問題發生的幾率,所以盡量保證事務的粒度盡可能的小。避免一個巨大的事務長時間的執行。
需要讀取大量數據。例如業務清算時需要讀取一段時間所有的交易記錄和資金流水。這種場景不屬於OLTP,應該選擇 Repeatable Read 隔離級別得到一個“快照”,並標記事務為只讀 SET TRANSACTION READ ONLY。這樣會讓數據庫對事務的執行做優化,盡量避免沖突的發生。
海量數據插入到OLTP數據庫。比如交易系統把每天用戶的資產和收益計算后更新到OLTP數據庫讓用戶訪問。此時應該事先一個“業務事務”的概念。即不要依靠數據庫的業務,而是依靠一個標記。當一個用戶的數據正在更新時,應該避免用戶看到部分被更新的數據。只有當數據全部更新完了,最后更新一下標記,讓數據對用戶可見。同時,數據的更新應該拆解一個個小的事務,避免一個巨大的事務一次性完成更新。
簡單的數據讀取-更新場景。比如計數器。可以用單行UPDATE SQL 的方式實現。
避免糾結於 Repeatable Read 和 Read Committed 的區別。這兩個隔離級別都無法解決“寫前提困境”。糾結無用。
對並發沖突或者死鎖嘗試進行重試。
在基於鎖的實現中,可能會出現鎖等待超時回滾;而在基於SSI的實現中,事務提交時可能會檢測到並發修改,進而強制回滾事務。無論哪一種,都需要重試。需要編寫代碼來處理這種重試,並且需要根據業務需求確定重試的驅動者是誰——到底是后端代碼,還是前端代碼還是用戶需要。。
對於MySQL考慮樂觀鎖
因為MySQL的隔離級別不支持SSI,所以可以考慮手工實現樂觀鎖。即自己在數據表里面增加一個version列,並且在更新數據時總是將修改之前的version房子UPDATE語句的where條件里。
樂觀鎖的實現是有前提的,即修改的數據和修改前提是同一份數據。如果這個前提不滿足就不法實現。
注意監控數據庫事務的執行情況
一般監控都能做到監控數據庫的CPU、磁盤、IO等資源的占用情況。除此之外,應當注意對事務的執行時間和數量做監控。數據庫一般並不限制事務的執行時間(但是會限制事務等待一個鎖的時間)。一個執行數小時甚至數天的事務極大概率是有問題的,會帶來死鎖增加,MVCC垃圾得不到清理等問題。