單機事務

分布式相關的內容非常多, 本次分享主要是從分布式事務切入的, 所以主要講的是分布式的環(huán)境下, 怎么做事務; 要講分布式事務, 我覺得還是有必要提一下單機事務的, 單機事務的很多特性其實在分布式事務中也需要考慮到; 那么今天在這里, 我們主要從兩方面來解讀下單機事務:

  1. 是什么? : 事務是什么?
  2. 怎么做?: 事務是怎么實現的?

1. 事務是什么?

我們的系統大體能分為兩類:

a. 數據密集型;

b. 計算密集型;

我們日常工作中更多接觸到的是數據密集型, 也就無可避免的接觸到數據持久話的問題了, 現在來一起看下幾個場景:

  1. 在持久化時, 程序崩潰了;
  2. 在持久化時, 服務器崩潰了;
  3. 在持久化時, 程序與DB的鏈接斷開了;
  4. 持久化使用了多線程, 線程A把線程B的持久化數據覆蓋了, 甚至最終持久化的數據有1/3是線程A的, 2/3是線程B的, 形成了數據混淆;
  5. 在數據讀取時, 把持久化一半的記錄讀取出來了;
  6. etc;

正因為我們的程序運行的環(huán)境是不穩(wěn)定的, 所以上面的種種場景都在日常生產中可能遇到的;

而正因為系統的不穩(wěn)定了, 造成了原本預期結果是true | false, 結果返回的是未知狀態(tài), 因為程序奔潰了, 我也不知道持久化到哪部分數據了; 也就是形成了三態(tài)問題;

那業(yè)務系統為了數據的可靠性, 能不能處理這個三態(tài)問題呢? 答案是能的, 但是這個成本是很高的, 需要在業(yè)務代碼中嵌入許多非業(yè)務相關的代碼來保證數據的完整性, 同時也需要非常多的測試來驗證這個方案的可行性;

那么有沒有更好的解決方案呢?

業(yè)務系統原本的預期就是true | false, 那么只需要數據庫層面把中間態(tài)解決掉就可以了, 這也就引出了事務是什么以及做了什么:

事務把業(yè)務系統的同一批操作抽象為一個邏輯單元, 該邏輯單元的所有操作要么都成功(commit), 否則就是全部失敗(abort), 然后回滾(rollback), 不會出現中間態(tài);

這樣做的好處就是對業(yè)務系統的反饋只會是成功或者失敗;

2. 事務是怎么實現的?

說到事務就不得不提到他的四個特性了:

2.1 ACID

單機事務的特性:(詳見維基百科)

  • Atomicity(原子性):一個事務(transaction)中的所有操作,或者全部完成,或者全部不完成,不會結束在中間某個環(huán)節(jié)。事務在執(zhí)行過程中發(fā)生錯誤,會被回滾(Rollback)到事務開始前的狀態(tài),就像這個事務從來沒有執(zhí)行過一樣。即,事務不可分割、不可約簡。
  • Consistency(一致性):在事務開始之前和事務結束以后,數據庫的完整性沒有被破壞。這表示寫入的資料必須完全符合所有的預設約束、觸發(fā)器級聯回滾等。
  • Isolation(隔離性):數據庫允許多個并發(fā)事務同時對其數據進行讀寫和修改的能力,隔離性可以防止多個事務并發(fā)執(zhí)行時由于交叉執(zhí)行而導致數據的不一致。事務隔離分為不同級別,包括未提交讀(Read uncommitted)、提交讀(read committed)、可重復讀(repeatable read)和串行化(Serializable)。
  • Durability(持久性):事務處理結束后,對數據的修改就是永久的,即便系統故障也不會丟失。

2.1 原子性解讀

看到原子性第一反應其實多線程中的原子性: 保證一個操作是原子的, 該操作的中間態(tài)是對其他線程不可見的; 但是事務的原子性不是用來解決并發(fā)的, 并發(fā)性是由隔離性來保證;

原子性解決的是中間的態(tài)的問題, 事務中的一批寫操作在執(zhí)行到某一個寫時, 因為網絡或者其他原因, 寫失敗了, 那么該事務就應該被中止, 并且數據庫必須撤銷該事務迄今為止所有的寫入;

所以原子性更多的是約束了事務可以中止的, 并且中止的時候會撤銷所有該事務未提交的寫入操作;

2.2 一致性解讀

一致性必須要說一下, 一致性這個詞在不同的語境中, 被賦予了不同含義:

  1. 在討論數據庫HA的時候主從復制的一致性, 或者異步復制的最終一致性, 這里強調的是兩個數據的值是相同的;
  2. 在CAP中的一致性, 指的是線性一致, 這個后面會講;

而事務的一致性怎么解讀呢?

維基百科中的解釋是: 在事務開始之前和事務結束以后,數據庫的完整性沒有被破壞;

數據庫的完整性怎么理解? 就是持久的數據是符合我們的一定的預期的, 而這個預期在后續(xù)的寫入中沒有被破壞掉, 也就是我們對持久化的數據的預期是始終成立的; 而維基百科中提到的約束, 觸發(fā)器等都是來保證我們的這種預期始終成立;

看到這里, 是不是發(fā)現數據庫的一致性其實是由應用程序來定義的, 什么數據是有效的, 而什么數據是無效的, 數據庫純粹只是用來持久化數據的;

2.3 持久性解讀

數據庫的持久性其實就是找個地方存儲數據, 保證事務完成以后, 這部分已經持久化的數據不會丟失; 在單機事中, 持久性說的是,只有當該事務中的所有寫入操作的數據都已經落盤了, 事務才會提交成功; 如果是數據庫集群, 表示這部分數據已經復制到了一些節(jié)點上;

但是單機事務, 并不能完美保證這部分數據不丟失, 比如你這機房被炸了, 你的數據也就丟了, 所有才有多副本存儲的; 但是多副本也只是降低了數據丟失的概率;

2.4 隔離性解讀

隔離性放到最后是因為這個比較復雜, 并且內容也比較多; 隔離性可以簡單的分為兩類: 弱隔離級別和強隔離級別; 我們先來說說日常生產中用的比較多的弱隔離級別:

2.4.1 弱隔離級別

為什么說弱隔離性在生產中比較常見的呢? 因為我們日常接觸的大部分都是讀多寫少的業(yè)務; 也就是兩個事務大部分情況下是并行執(zhí)行的, 不會產生數據競爭, 這種場景下弱隔離級別就十分有用了;

2.4.1.1讀已提交

讀已提交的隔離級別是我們最常用到的, 因為他保證了:

  1. 單個事務只能讀取其他事務已經提交的數據(保證了沒有臟讀);
  2. 單個事務只能覆蓋其他事務已經提交的數據(保證了沒有臟寫);

說一下臟讀和臟寫:

  1. 臟讀很好理解: 就是讀取了其他事務還未提交的數據; 為什么要防止臟讀其實最大的原因是如果另一個事務中止了, 需要做回滾, 但是因為你當前事務已經讀取了回滾事務操作的數據, 當前事務的提交將導致回滾是無效的;
  2. 臟寫: 臟寫就是如果一個事務寫入了數據, 但是還未提交事務, 但是已經被另一個事務的寫入給覆蓋了; 臟寫問題更嚴重, 舉個例子: 小明和小紅同時編輯同一個文章, 我們來看一下臟寫的問題:

<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>

小明 小紅
begin transaction Begin transaction;
Select article; Select article;
update title = '小明';
update title = '小紅';
Update author = '小紅';
Commit transaction;
Update author = '小明';
Commit transaction;

最終的結果會變成title是小紅, author是小明, 可以看到如果有臟寫, 來自不同事務的沖突, 導致數據被混淆了;

2.4.1.2 讀已提交的實現

  1. 臟寫的防止: 寫入需要獲取操作對象的鎖, 當該對象鎖被其他事務占有時, 當前事務必須等待, 直到事務完成或中止時才釋放鎖;
  2. 臟讀的防止: 一種方式是讀也需要申請鎖, 如果該對象已經被其他事務鎖定了, 當前事務需要等待; 但是讀鎖的釋放是在讀取完成之后立即釋放的; 這種方式其實并不好, 因為只要存在一個寫入的長事務, 必然會阻塞其他只讀事務; 所有就有了方式二: 既然只需讀取其他事務已提交的數據, 那么只要讀取當前事務寫入前的數據就可以了; 這種方式就是當有寫入事務正在執(zhí)行的時候, 數據庫會做一個快照保存當前寫入事務寫入前的值, 當有讀取事務進來時, 只需要讀取快照即可;只有當寫入事務提交了, 讀取事務才會去讀取最新值;
  3. 所以讀已提交的實現, 讀不阻塞寫, 寫不阻塞讀;

讀已提交存在的問題

讀已提交隔離級別看上去已經十分美好了: 因為他同時防止了臟讀和臟寫, 但是我們來看下這個例子:

拿用戶購買基金以后, 需要扣賬戶余額與加資產舉例(假設賬戶表與資產表都在同一個DB上):

<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>

投資事務 查詢事務
Begin transaction
Select position;(1000)
Begin transaction;
update account = 500;
update position = 1500;
Commit transaction
select account;(500)
Commit transaction

因為是讀已提交的隔離級別, 所以在投資事務未提交前, 用戶先看了下資產數據, 只有1000, 在投資事務提交以后, 用戶看了賬戶余額是500, 導致用戶以為自己少了500的資產;

這種問題被稱為不可重復讀, 這種問題在我們業(yè)務上其實也是存在的, 比如需要查詢并進行校驗的時候, 一個長事務的兩次讀取可能會讀取到不同的值, 導致本次操作失敗的問題;

2.4.1.3 不可重復讀的解決方案(可重復讀)

這個問題還是讀寫并發(fā)的問題, 在不考慮加鎖的情況下, 可以參考讀已提交的快照隔離來實現:讀已提交的實現是考慮了寫事務包了讀事務的時候, 怎么來處理臟讀; 不可重復的問題是讀事務包了寫事務;那么只保留一份快照是不可取的, 因為一個讀事務可能會包掉多個寫事務, 會導致連快照都已經被覆蓋的情況; 所以不可重復讀的解決方案就是保存多份數據快照:這種方案也就是MVCC(多版本并發(fā)控制);

MVCC實現快照隔離

數據庫會默認給每張表加上兩個屬性: created_by 和 deleted_by, 這兩個屬性分別寫入的是該條記錄是由哪個事務創(chuàng)建的(created_by), 由哪個事務更新的(deleted_by), 每次新建事務的時候數據庫會默認分配事務ID;

  1. insert 只會寫入created_by;
  2. Delete 會寫入deleted_by;
  3. update會被解析為 delete + insert;

怎么通過created_by 和 deleted_by來實現可重復讀呢?
多版本控制類似于下面的存儲:

<colgroup><col span="1" width="284"><col span="1" width="284"><col span="1" width="284"></colgroup>

data created_by deleted_by
A 4
D 3 4
C 2 3
B 1 2
A 0 1

Deleted_by只有事務真正提交時才會生成快照, 也就是所有中止操作都不會生成快照;

只要在該事務開始前的快照, 都是對該事務可見的; 在該事務開始后的快照, 對該事務是不可見的;
通過deleted_by關聯created_by可以找到該個事務之前所有的快照, 那么可以通過[delete_by, created_by]這個區(qū)間來判斷, 當前需要讀取哪份快照了;
我們通過前面的購買基金的例子來說明:(假設account和position的created_by為0)

<colgroup><col span="1" width="213"><col span="1" width="213"><col span="1" width="213"><col span="1" width="213"></colgroup>

投資事務1 讀取事務1 投資事務2 讀取事務2
Begin;t_id = 1;
Begin;t_id = 2; select account = 1000;
update account = 500;
Udpdate position = 1500;
Commit;
Begin;t_id = 3; Begin;t_id = 4;
Update account = 1000; Select account = 500;
select position = 1000;select account = 1000; commit; Select account = 500;
Commit; commit;

account最終的快照:

<colgroup><col width="284" span="1"><col width="284" span="1"><col width="284" span="1"></colgroup>
| account | create_by | deleted_by |
| 1000 | 3 | |
| 500 | 2 | 3 |
| 1000 | 0 | 2 |

position最終的快照:

<colgroup><col span="1" width="284"><col span="1" width="284"><col span="1" width="284"></colgroup>

position created_by deleted_by
1500 2
1000 0 2

先來看讀取事務2: 該條事務開始時, 數據庫會先列出當前所有還未提交的事務集合[1,3,4], 然后再判斷account的當前快照[0,2], 因為4>2, 所以第一次讀取到的是500; 第二次讀取的時候, 因為事務3已經提交了, 所以當前account的快照是[0-3], 如果這個時候僅僅只是判斷4>3, 那讀取到的應該是1000; 但是因為在事務開始的時候已經記錄了[1,3,4]是還未提交的, 所以應該過濾掉事務3提交所產生的快照, 所以第二次讀取的仍然是500;

2.4.1.4 寫并發(fā)引發(fā)的問題

快照隔離主要解決是讀寫并發(fā)時, 只讀事務能看到什么, 但是還有一種情形其實也是很常見的, 那就是寫寫并發(fā)了, 比如金融場景中很常見的加資產: 如果一個用戶購買了2個產品, 需要給他加2次資產, 并更新總資產值;

<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>

事務1 事務2
Begin; Begin;
select position = 600;
Update position = 1100; select position = 600;
Commit; Update position = 1100;
commit;

不管是讀已提交或者是可重復讀這種場景都是正常, 但是當這兩個事務提交完成以后, 用戶會發(fā)現自己賬戶少了500的資產, 為什么呢, 因為事務2把事務1的結果覆蓋了, 這種情況叫丟失更新;

如何避免呢?

  1. 原子寫, update position = position + 500; 現在數據庫基本都支持這種原子寫了;
  2. 顯示鎖定: select for update;
  3. CAS: 加樂觀鎖 update position = 1100 where position = 600;

還有一種更神奇的場景: 當我們需要在一個事務里操作多個對象的時候, 比如購買產品需要先扣余額, 再加資產;

賬戶初始金額是1000;

<colgroup><col span="1" width="426"><col span="1" width="426"></colgroup>

事務1 事務2
Begin; Begin;
Select account = 1000;
if(account > 0) then; Select account = 1000;
update account = 0; if(account > 0) then;
update position = position + 1000; update account = 0;
commit; update position = position + 1000;
commit;

在事務中先判斷余額是否足夠, 如果單個事務執(zhí)行, 上面的邏輯沒有任何問題; 但是當兩個事務并發(fā)的時候, 只要有一個事務生效了, 另一個事務的前提其實已經不滿足了, 但是現在并發(fā)的結果是, 明明只有1000塊, 卻購買了2000塊的產品并成功加了資產; 這種問題為寫偏差, 也稱為幻讀, 寫偏差的類型其實很好總結的:

  1. select符合條件的記錄, 然后做判斷;
  2. 如果第一個條件是滿足的, 就進行后續(xù)寫入操作;

解決方案:

  1. 對先決條件加鎖, 即select for update;
  2. 有些場景里是沒法加鎖的, 比如用戶余額這種場景, 那這個時候怎么辦呢? 我們可以人為的在數據庫中引入一個可加鎖的對象, 比如扣款申請單, 那么用戶加資產就可以拆分為 扣余額 和 加資產兩個不同的事務, 每個事務都可以引入原子寫了;

當然 以上兩個問題都可以用串行化的隔離級別來搞定, 但是這個隔離級別效率實在太低了;

2.4.2 強隔離級別

只有串行化(Serializable)是強隔離性的, 字面意思就是如果事務并發(fā)了, 一個時間點只會有一個事務在運行, 其他都需要等待, 正因為串行化執(zhí)行, 性能非常低, 所有在實踐中運用的比較少, 甚至于有些數據庫, 比如Oracle中壓根就沒實現, 雖然有可序列化這個級別, 但是真正的實現確是快照隔離(在下面的弱隔離級別中會討論);

2.4.2.1 2PL

兩階段鎖(注意不是2階段提交): 即當多個事務操作需要寫入同一個對象時(修改或刪除), 需要單獨占有這個對象(可以理解為JAVA中的獨占鎖):

  1. 如果對象A已經被事務1讀取了, 這時候事務B需要操作對象A, 必須等事務A提交或者終止;
  2. 如果對象A已經被事務1操作了, 這時候事務B需要讀取對象A, 必須等事務A提交或者終止;

所以兩階段鎖是即阻塞讀也阻塞寫, 來保證沒有臟讀與臟寫;

2PL的實現

2PL的實現主要是通過對數據庫中每個對象加鎖來實現的, 鎖分為共享模式和獨占模式;

  1. 事務會以共享鎖來讀取對象, 但是如果這個對象已經被其他事務使用獨占鎖鎖定了, 則當前的讀事務必須等待;
  2. 事務需要操作對象時, 必須獲取獨占鎖; 但是如果當前對象已經有其他事務獲取了共享鎖或者獨占鎖, 則當前事務必須等待;
  3. 事務先以共享鎖讀取了對象, 但是寫入對象時, 需要將共享鎖升級為獨占鎖; 鎖升級過程與獲取獨占鎖一致;
  4. 事務獲取鎖以后, 必須持有至事務結束(commit | abort)才能釋放鎖; 這也是兩階段鎖的名稱來源: 一階段(事務開始或者執(zhí)行時)獲取鎖, 二階段(事務結束)釋放鎖;

既然用到了鎖, 且一個事務中可能需要去獲取多把鎖, 那么就很有可能發(fā)生死鎖: 事務A持有A對象的鎖, 需要去獲取B對象的鎖, 但是事務B已經持有了B對象的鎖, 需要去獲取A對象的鎖, 這個時候就會發(fā)生死鎖;

死鎖發(fā)生以后, 數據庫會自動檢測并中止其中一個事務, 以便讓另一個事務繼續(xù)進行, 中止的事務的重試有應用系統完成;這也是兩階段鎖效率低下的一個原因;

2.4.3 串行化快照隔離(SSI)

弱隔離級別會有臟讀, 不可重復讀, 幻讀等問題, 但是采用強隔離級別就會有性能問題, 那么有沒有一種方案能兼容這兩者的優(yōu)點呢?那就是2008年才被提出來的串行化快照隔離;

?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容