筆者在寫上一篇文章Java併發簡介 中腦子裡面同時也閃爍著,程式中有併發問題,那資料庫中也有類似問題嗎? 讓我們一起看一下吧! 事務是將一組讀寫操作組合在一起形成一個邏輯單元。這些操作要麼全部執行成功提交(commit),要麼全部中止失敗(abort,rollback),不會留下一個中間狀態的爛攤子 ...
筆者在寫上一篇文章Java併發簡介 中腦子裡面同時也閃爍著,程式中有併發問題,那資料庫中也有類似問題嗎? 讓我們一起看一下吧!
事務是將一組讀寫操作組合在一起形成一個邏輯單元。這些操作要麼全部執行成功提交(commit),要麼全部中止失敗(abort,rollback),不會留下一個中間狀態的爛攤子。所以,失敗後程式可以安全的重試,分析原因等。 相反,如果沒有對事務的支持,資料庫可能持久化很多中間狀態,留下無法解釋的業務,開發人員處理起來也很麻煩。所以,事務是為了簡化編程,提供數據安全/正確性/一致性。當然,任何便利都是有代價的,事務也有一些問題,所以NoSQL資料庫,分散式資料庫在某種程度上會弱化事務。有些甚至完全放棄事務。Let's dig into most of the aspects of transaction!
ACID特性
談到事務,都想到ACID。每個字母分別代表原子性(Atomicity),一致性(Consistency),隔離性(Isolation),持久性 (Durability)。搞清楚了ACID,就相當於搞清楚了事務的精髓。
原子性(Atomicity)
和JAVA併發中的原子性不同,程式中的原子性代表被規定的原子操作的中間狀態不會被別的併發線程看到。而這裡的原子性更多的是表達失敗和成功的結果。併發問題是在隔離性(Isolation)裡面談及的。當有多次寫入的時候,中間可能會出現各種問題(進程崩潰,斷電,網路故障,硬碟滿,違反約束等),如果這些操作被分配到一個事務中, 那麼提交(commit)動作會失敗,事務隨即中止,但是資料庫會保證故障之前對系統做的任何修改,寫入都被撤銷。隨後,可以安全地重試失敗的事務。 如果沒有原子性保障,而把這種失敗處理交給開發人員或者客戶端處理,那麼將是非常困難的,客戶端很難知道哪些被寫入了資料庫,而重試則更是雪上加霜,讓錯誤進一步擴大。
一致性(Consistency)
有人認為,一致性是強加在ACID里湊數的。因為這個東西不是資料庫要保證的,而是應用程式需要定義和關註的。有一些道理。
我們經常認為,主外鍵約束達成了某種一致性,你不能在子表裡面插入父表沒有的鍵值,這是資料庫給保證了一致性,但是這種一致性也是根據業務由開發人員定義的。如果你向資料庫插入違反業務邏輯的假數據,資料庫並沒有這種約束阻止你。所以,一致性是通過事務的其他特性(原子性,隔離性)達成的,它並不屬於資料庫和事務的屬性。
隔離性(Isolation)
當多個客戶端同時訪問資料庫的同一對象時,就會有併發問題,或者叫競態條件(race condition)。下圖1的簡單計數器例子說明瞭此問題,當兩個客戶端同時給計數器加1的時候,我們期望的結果如同他們串列化完成一樣,可是在併發環境中沒有一些保證的話,結果會像是丟失更新(lost update)一樣,實際只增加了一次。
隔離性保證同時執行的事務是相互隔離的,它們不能互相影響。簡言之,一個事務只能看到另一個事務開始之前或者結束之後的結果,不能看到任何中間狀態,反之亦然。結果就如同他們串列化(Serializability)完成一樣,儘管實際上它們是併發運行的。隔離性分好幾種級別,每一種隔離級別都在權衡性能和某種安全保障,This is a kind of trade-off. 我們在下一篇文章會分析這些隔離級別。
持久性(Durability)
持久性保證當用戶提交事務並完成後,數據最終會被永久安全地保存到磁碟中,而不管是否發生故障或者系統崩潰。在單節點的資料庫中,通過日誌,可以保證系統在崩潰之後起來,依然能夠自動完成一致性和持久化的要求(rollforward,rollback)。通過歸檔日誌,當磁碟損壞後,還能恢復到某個時間點。為了進一步保證持久性,對日誌和歸檔日誌可以進行多副本設置。
當然,沒有完美的持久性,如果由於機房起火,所有數據(備份,日誌等)都銷毀。所以,更嚴格的保證可以通過異地備份實現,但也不是完美的,不抬杠了。
單對象和多對象操作
我們舉例子來說明一下事務在單對象和多對象操作中的作用。
多對象操作
下圖(圖2)是一個關於未讀消息數量的例子,當用戶有新郵件時,則會使相應的計數器加1,用戶看了郵件後,則計數器減1。 消息和計數器為兩個不同對象。我們假設初始狀態沒有新郵件,計數器為0。 如果沒有事務的話,用戶2看到了自己有一份新郵件,但是未讀郵件數量卻是0. 因為用戶2看到了用戶1未提交的寫入(插入的新郵件),稱作臟讀。事務的隔離性可以解決這種問題。
如果新郵件到來時,用戶1在更新計數器的時候,發生某種崩潰而失敗,那麼郵箱和計數器則會不一致,失去同步(見圖3)。 事務的原子性可以保證:如果計數器更新失敗,事務會中止,插入的新郵件會被撤銷(回滾). 介於BEGIN TRANSACTION 和 COMMIT之間的代碼被認為在同一事務之中。
另一方面,許多非關係資料庫並沒有將多個操作組合一起的方法,即使存在錶面的多對象API(例如,鍵值存儲可能具有在一個操作中更新多個鍵的操作),但並不意味它具有事務的特性,該操作可能在一些鍵上更新成功,在其他鍵上失敗,這種部分更新也就體現在了資料庫端。
單對象寫入
對於單對象的寫入,原子性和隔離性是毋庸置疑的,如果你正在向資料庫寫入一個20KB的JSON文檔:
- 如果在發送第一個10KB之後網路中斷,資料庫是否存儲不可解析的10KB JSON片段?
- 如果在覆蓋前一個文檔的過程中斷電,是否最終將新舊值拼在一起?
- 如果另一個客戶端在寫入的過程中讀取文檔,是否看到部分更新的值?
這些問題非常令人困惑,所以存儲引擎幾乎都實現了:對單節點上的單個對象(比如鍵值對)上提供原子性和隔離性。原子性可以通過日誌來實現崩潰恢復,通過每個對象上的鎖來實現隔離(每次只允許一個線程訪問對象)。Apache HBase就是一個例子。
一些資料庫也提供了複雜一些的原子操作,如自增操作(定義的自增主鍵)。還有CAS操作(比較和設置),當值沒有被其他人修改過時,才允許執行寫操作。這些保證很有用,防止在多客戶端同時寫入一個對象時丟失更新。但它們不是通常意義上的事務。這些單一對象保證被稱作“輕量級事務”,甚至出於營銷目的被稱為“ACID”,是有誤導性的。事務通常被理解為:將多個對象上的多個操作合併為一個執行單元的機制。
仔細想想,如果從事務的角度去理解多線程編程中的計數器自增問題,更多的不是因為沒有原子性,而是因為沒有隔離性,所以才靠同步(鎖),volatile等機制。本質上,都是一樣的(併發引起的問題)。
在你的應用中,單對象的保證是否足夠,多對象操作的協同是否必須? 我們不能簡單地實現功能來交差,更重要的是要正確地實現。錯誤雖然不可避免,但許多軟體開發人員傾向於只考慮樂觀情況,而不是錯誤處理的複雜性。
事務中止重試
之前我們說過,事務中止會回滾事務開始到中止之前的寫入,因此可以安全地重試,但也不夠完美
- 如果事務執行成功,但是由於網路故障,造成成功消息沒有被返回給客戶端(客戶端認為事務沒有成功),那麼事務會被導致執行兩次。(通過應用排除)
- 如果錯誤是由於負載過大造成,重試會將問題變得更糟糕。可以限制重試次數,並單獨處理與過載相關的錯誤。
- 在發生永久性錯誤(例如違法約束)後重試是毫無意義的。僅在臨時性錯誤(死鎖,網路故障等)後才重試。
- 如果事務對外部系統有影響,比如發送郵件。 重試則很可能造成重覆給用戶發郵件
下一節我們討論隔離級別