《Designing Data-Intensive Applications》ch 7— Transactions

一個沒那麼肥的肥宅
今天的天空,有點藍
21 min readMar 1, 2021

前言:《Designing Data-Intensive Applications》這一系列的文章是想分享我閱讀《Designing Data-Intensive Applications》這本書的筆記。希望可以給自己的學習歷程留下一些什麼,也希望對想了解這方面知識的人有一些幫助。

在真實的系統中,經常會有許多問題發生:database 的軟體或硬體隨時可能會異常、應用程式隨時會崩潰、網路中斷造成應用程式無法與 database 溝通、數個 clients 同時對 database 做 write 而互相覆蓋資料等等。正是由於各種複雜、需要考量的因素,因此更需要審慎使用並測試各項工具才能確保運作正常。Transactions 就是為了簡化這些議題而來,通常一個 transaction 是由數個 reads 與 writes 所構成。本章將探討關於 transaction 的更多細節,包含了演算法以及該注意的地方。

Transaction 的概念來得相當早,通常可以回溯自 1975 年的第一台 SQL database IBM System R,此後大部分的 relational database 都支援這個概念。西元 2000 年後以 scalability 為考量的 NoSQL database 逐漸蔚為風潮,使用了全新的 data model,支援了 replication 與 partitioning,卻也將 transaction 視為雞肋而捨棄。這似乎讓人認為 scalability 與 transaction 魚與熊掌不可兼得,然而事實上可能不是如此簡單。就如大部分工程的技巧,transactions 同樣有其好處與限制,將在下面仔細介紹。

Transactions 保證的 ACID 分別是 AtomicityConsistencyIsolation Durability。不過實際上各個 database 的實作細節可能不盡相同,甚至理解上也都有所出入,因此 ACID 不幸地逐漸成為了行銷而存在的市場術語而已。不符合 ACID 的系統有時被稱為 BASE,分別是 Basically Available、Soft state 與 Eventual consistency,這個詞彙甚至比 ACID 的定義更加模糊。接下來讓我們仔細檢視 ACID 的各個定義。

一般來說,atomic 指的是某樣無法被分割的東西,但在 ACID 中則有些許差異。在此指的是當 client 企圖執行數個 writes 的 transaction 時,要碼全部成功 ( committed ),要碼因為發生了某些不可預期的問題 ( ex: 硬碟滿了、網路斷線等等 ) 而讓整個 transaction 被捨棄 ( aborted ),abort 的這個行為可以讓 client 確保所有的資料都沒有被更動到。如果沒有 atomicity,那麼有任何意外狀況發生時,要真正察覺哪些資料被改變哪些沒有就會變得非常困難。

Consistency 指的是系統中的某些條件或是事實必須永遠成立。譬如說在合理的帳戶系統中,所有的借款與貸款的金額應該要平衡。如果在執行 transaction 之前,有確保這件事實,且 transactions 也仍然確保這件事實的話,那麼我們可以肯定這個條件有一直被滿足。不過由於 consistency 所表達的概念與 database 較無關而與應用層的邏輯較相關,所以 ACID 中 C 的存在似乎並不合適。

大部分的 databases 可以同時被數個 clients 存取,如果他們分別在處理不同資料時不會發生問題,但是如果是同一份資料時可能會產生 race conditions ( 如圖 7–1 )。Isolation 指的是同時執行的數個 transactions 會與彼此 isolated,在某些教科書中又叫做 serializability,意思是每個 transaction 可以將自己假設成唯一一個在 database 執行中的 transaction。不過在實務上,serializable isolation 由於代價高昂會減少效能而很少使用,後面將會繼續討論 isolation 的各種形式。

Figure 7–1. A race condition between two clients concurrently incrementing a counter(DDIA)

Durability 指的是一旦 transaction 成功地 committed,即使發生硬體問題或是 database 壞掉,資料仍然永遠不會遺失。在單一 node 的 database 中,這通常代表的是資料被寫入傳統硬碟或是 SSD 等非揮發性的儲存裝置,另外還會有類似 write-ahead log 的存在。在 replicated 的 database 中,可能指的是資料有被成功地複製到其他的 nodes 上了。

ACID 的特性可以確保系統能夠處理 multi-object transactions。考量一個郵件系統,當我們想要展示所有尚未讀取過的郵件時,可以透過下面的這種 query:

SELECT COUNT(*) FROM emails WHERE recipient_id = 2 AND unread_flag = true

在有大量的郵件時這樣的 query 會變得十分緩慢,我們可以藉由某種程度的 denormalization 來儲存尚未讀取的信件數。然而圖 7–2 就顯示了在這樣的設計底下可能出現的異常情況。Isolation 的特性可以避免這樣的問題:要碼同時看到新增的郵件與更新過的信件數,要碼兩者皆尚未更新,不會出現進行到一半的中間狀態。

Figure 7–2. Violating isolation: one transaction reads another transaction’s uncommitted writes (a “dirty read”)(DDIA)

圖 7–3 顯示的是另一種會需要 atomicity 的異常。有了 atomicity,倘若更新信件數的操作失敗了,那麼整個 transaction 都會被 aborted 並且退回原狀態 ( roll back )。

Figure 7–3. Atomicity ensures that if an error occurs any prior writes from that transaction are undone, to avoid an inconsistent state(DDIA)

Concurrency 的問題是相當難以被察覺的,因為它的發生與時間點有相關,使得其相當少見且難以重現。在理論上,transaction 的 isolation 可以確保沒有 concurrency 的發生,serializable isolation 會讓數個 transactions 執行起來就像是 serially 地執行。可惜的是,實際上這樣的 isolation 相當程度地降低系統的效能,因此很多系統還是會採用比較弱一些的 isolation 方式。

首先是 read committed,其保證了兩件事:對 database 執行 read 時,只會看到已經被 committed 的資料 ( 即沒有 dirty read,參考圖 7–4 );對 database 執行 write 時,只會對已經被 committed 的資料做覆蓋 ( 即沒有 dirty writes,參考圖 7–5 )。

Figure 7–4. No dirty reads: user 2 sees the new value for x only after user 1’s transaction has committed(DDIA)
Figure 7–5. With dirty writes, conflicting writes from different transactions can be mixed up(DDIA)

大部分的時候,database 是透過較底層的 locks 來避免 dirty writes。而對於 dirty reads,同樣可以用這種方式的 lock 來達成,只不過在實務上因為效能問題可能會選擇用圖 7–4 的方式:對於被 written 的物件,database 會記得其原本的值以及被擁有 write lock 的 transaction 所設的新值。當該 transaction 仍在進行時,其他的 transactions 只會拿到原本的值,只有當新的值被 committed 之後才會切換到新的值。

Read committed 的 isolation 的好處多多,他允許 aborts、避免讀取不完全的 transactions 結果等,然而這仍然有其限制。舉例來說,圖 7–6 就是一個在 read committed 仍然會遇到的問題:Alice 想要從 Account 1 轉錢到 Account 2,若在轉錢的 transaction 尚未完成時查看,會發現帳戶總和少了 100 元。

Figure 7–6. Read skew: Alice observes the database in an inconsistent state(DDIA)

這種現象稱為 nonrepeateable read ( read skew ),因為如果在 transaction 結束時再次查看,她會看到 Account 2 實際上有 600 元。Snapshot isolation 是對於這種問題常見的解法,顧名思義每個 transaction 都會從 database 取得某個 consistent snapshot,因而使得 transaction 執行的過程中可以確保其所看見的資料在該時間點不會變動。這種性質在需要大量時間的 read-only queries 如備份或是分析資料時相當有幫助。

在實作部分,databases 採用的是比圖 7–4 更一般化的機制。Databases 要的是保持數個已經 committed 的物件版本,是為了給正在執行的數個 transactions 能夠看到各自適合的版本的值。這種技巧又叫 multi-version concurrency control ( MVCC )。圖 7–7 即是一個 PostgreSQL 的 snapshot isolation 範例。在這樣的設定中,每個 transaction 都有自己的 transaction ID ( txid );當某個 transaction 要對 database 執行 write 操作時,被更改的資料會紀錄該 txid。因此每筆資料都會有很多個版本的值與其相對應關於 txid 的 created_by、deleted_by 欄位。隨著時間過去,當 database 確定某些版本的值已經不會在被使用之後,就會對他們進行 garbage collection。透過清楚定義完 visibility rules 可以確保達成 snapshot isolation。

Figure 7–7. Implementing snapshot isolation using multi-version objects(DDIA)

Snapshot isolation 在對於 read-only transactions 而言相當有用,可惜的是許多 databases 用不同的詞彙來表示這個意義:Oracle 用 serializable;PostgreSQL 與 MySQL 則使用 repeatable read。另外,即使一些 databases 實作了 repeatable read,他們能提供的保證也不盡相同,因此需要特別注意。

上述主要聚焦於 read-only transaction 會遇到的問題,接下來想討論的是關於同時 write 所會產生的衝突。Lost update ( 如前面圖 7–1 ) 就是一個這樣的問題,這種問題產生的原因,往往與應用程式想要執行某種有讀取、修改並寫入資料的行為 ( read-modify-write cycle ) 有關。如果兩個 transactions 同時執行這樣的操作,那麼其中一個所做的操作會有遺失的風險。

許多的 databases 都支援 atomic 等級更新的操作,這通常是透過對於某些物件取 exclusive lock 來完成,有時又被稱為 cursor stability。像是在 relational database 中,一班來說下面這樣的指令就相當安全:

UPDATE counters SET value = value + 1 WHERE key = 'foo';

這可以讓應用層不需要經歷 read-modify-write cycle,減少了潛在造成問題的因素。可惜的是在某些時候 ( ex:對維基的頁面進行文字編輯修改 ),writes 是無法被表示成 atomic 的操作的。另一個避免 lost updates 的方式是透過明確地 lock 住即將會被更改的物件,例如下面的 FOR UPDATE:

BEGIN TRANSACTION;SELECT * FROM figures
WHERE name = 'robot' AND game_id = 222
FOR UPDATE;
UPDATE figures SET position = 'c4' WHERE id = 1234;COMMIT;

對於那些不提供 transactions 的 databases,我們還可以使用 atomic 等級的 compare-and-set 操作。其目的是在讓我們要修改資料時,先確保該值在我們最後一次 read 的結果之後都沒有被改變。倘若有變化,則是讓修改無效。範例如下:

UPDATE wiki_pages SET content = 'new content'
WHERE id = 1234 AND content = 'old content';

前面提到了 dirty write 與 lost updates 等兩種同時對同一個物件 write 的所會產生的問題,這邊將討論另一種也是同時 write 所會產生的問題:Write skew。想像一個為了協調醫師值班的應用程式,在這樣的設定中,醫院允許多個醫師同時值班,但在每個時刻值班室一定至少要有一個以上的醫師存在。假設此時圖 7–8 的兩個值班中的醫師 Alice 與 Bob 都同時想要休息,由於 snapshot isolation 因此各自的 transaction 裡 on_call 都維持在原本的值 ( 即 2 ),在這樣的狀況下兩個 transactions 都成功醫師因此各自去休息,但醫院的值班要求卻沒有被滿足了。

Figure 7–8. Example of write skew causing an application bug(DDIA)

Write skew 既不是 dirty write 也不是 lost update,因為不同的 transactions 所要修改的資料是不同的。我們可以將之視為比 lost update 更一般化的問題:當所修改物件不同時為 write skew,為同一個物件時則可能是 dirty write 或是 lost update。遇到這種情形,倘若不能使用 serializable isolation 的話,一個可以解決的選項是明確地 lock 住該 transaction 所需要的資料,在醫師值班的例子中呈現如下:

BEGIN TRANSACTION;SELECT * FROM doctors
WHERE on_call = true
AND shift_id = 1234 FOR UPDATE;
UPDATE doctors
SET on_call = false
WHERE name = 'Alice'
AND shift_id = 1234;
COMMIT;

會產生 write skew 的情形,通常都包含有以下的模式:一個檢查某種需求是否被滿足的 SELECT query、根據前一個 query 應用程式會選擇下一步為何、當應用程式繼續執行時會對 database 進行 write 的操作 ( INSERT、UPDATE、DELETE ) 等。這種由於某個 transaction 的 write 會對其他 transaction 的搜尋 query 產生影響的效果,被稱為 phantom

當遇到像上面一樣棘手的情形,其他種 isolation 無法解決時,直接使用 serializable isolation 可以說是最簡單而有效的解法了。這可以說是最強的 isolation 層級了,保證了即使有多個 transactions 同時執行,會與他們 serially 執行的結果相同,因此確保不會有 race conditions 發生。以下要討論的是關於實現 serializable isolation 的幾種做法。

第一種實現方式是徹底移除 concurrency,使得每個時刻都只會有一個 transaction 在單一執行緒裡面執行。然而儘管這個想法非常直覺,卻是直到近年來隨著硬體 RAM 變得便宜與 OLTP transactions 通常時間較短的特性才越來越成形。

在互動型的應用程式中,人們的所需要做決定的時間相比於 transaction 要慢上許多,在應用程式與 database 之間的網路溝通也相當花時間。倘若不在 database 中使用 concurrency 的話,throughput 會由於 database 很常在等待而變得相當受限。因此在單一執行緒的 serial transaction 中通常不希望有互動性的 multi-statement transaction,相對地,應用程式更傾向使用 stored procedure,因為這樣子就不用等待網路或是硬碟的 I/O,可以讓速度更快。

Figure 7–9. The difference between an interactive transaction and a stored procedure(DDIA)

過去的幾十年間,實際上最常用來達成 serializability 的是 two-phase locking ( 2PL )。回憶一下前面所提及避免 dirty writes 的方式:倘若有兩個 transactions 同時想對同一物件做 write,那麼會有個 lock 來確保第二個 transaction 必須要一直等待直到第一個 transaction 完成之後才能開始。在 2PL 中,對於 lock 的要求更為嚴格:

  • 如果 transaction A 想 read 某一物件而 transaction B 想對其 write 時,B 必須等待 A 完成後才能開始。
  • 如果 transaction A 想 write 某一物件而 transaction B 想對其 read 時,B 必須要等待 A 完成後才能開始。

相對於 snapshot isolation 的 readers 永遠不會堵塞 writers、writers 永遠不會堵塞 readers。在 2PL 中,writer 不只會堵塞其他 writer,還會進一步堵塞 reader。這種透過 shared/exclusive lock 所達成的 serializability,能夠避免前面所提到 lost updates 與 write skew 等等的所有 race conditions。

2PL 最大的缺點是效能問題,由於存取大量的 locks 以及減少了 concurrency的機會,倘若有一個比較慢的 transaction 正在執行時,則整個系統可能會因而停頓。此外,由於演算法中使用了大量的 lock,因此經常有 deadlock 發生。

到現在為止,一方面我們發現了 serializability 的實作要碼表現不夠好 ( 2PL ) ,要碼擴展性有限 ( serial execution );另一方面比較弱的 isolation 效能優秀,但容易發生 race conditions ( 像是 lost updates、write skew、phantoms 等等 )。是否 serializable isolation 與高效能就是兩條不可能交會的平行線了呢?或許不全然如此,serializable snapshot isolation ( SSI ) 是個相當有潛力的明日之星,他提供了 serializability 但效能卻不會差 snapshot isolation 多少。

2PL 被視為 pessimistic 的 concurrency 機制,其哲學是如果多個 transactions 執行時有可能發生錯誤的話,那麼就稍等一會直到狀況比較安全時再開始,這有點像多執行緒的 mutual exclusion 一樣。而 serial execution 更是極端,其幾乎是代表著每個 transaction 都會 lock 住一部分的 database。相反地,serializable snapshot isolation 是個比較 optimistic 的技巧,相對於前者使用堵塞來確保情形,這裡更傾向讓 transaction 繼續執行;當 transaction 要結束時,database 會檢查是否有違反 isolation,對於違反的 transaction 丟棄或是重試,而只允許 serializable 的 transaction 完成。這種 optimistic 的好處是在使用同一物件的 transactions 不是太多的情形下,效能相對優秀。

回憶一下在 snapshot isolation 中產生 write skew 的醫師值班系統中,如果想要滿足 serializable,database 必須設法偵測 transaction 是否有用到同時被有其他 transactions 更動到的資料。以下介紹兩種可以偵測的方式:

第一種是從 Snapshot isolation 的 multi-version concurrency control ( MVCC ) 實作而成,參考圖 7–10,當 transaction 43 即將 commit 的時候,database 發現到了 transaction 42 已經 committed 完成,使得 transaction 43 中的條件不再被滿足,因而 transaction 43 就被 aborted。

Figure 7–10. Detecting when a transaction reads outdated values from an MVCC snapshot(DDIA)

另一種則是偵測出哪個 writes 會對之前的 reads 產生影響。在圖 7–11 中, transaction 43 會通知 transaction 42 其之前的 read 結果已經過時了;反之, transaction 42 同樣也會通知 transaction 43 其之前的 read 結果也過時了。 transaction 42 先 commit,因此不受 transaction 43 的 write 影響。然而當 transaction 43 即將 commit 時,由 transaction 42 所導致的衝突 write 已經被 committed,因此只好捨棄 transaction 43。

Figure 7–11. In serializable snapshot isolation, detecting when one transaction modifies another transaction’s reads(DDIA)

當然,從工程面的角度來看待這樣的演算法設計,少不了 trade-off。如果 database 非常仔細地追蹤每個 transaction 的細節,那麼就能很明確知道哪個 transaction 該被丟棄,可是紀錄過多的代價將會十分高昂;較少的細節追蹤會讓 database 的效能較高,但缺點是會導致不必要的 transaction 被丟棄。

相對於 2PL,SSI 的最大好處是不會因為 lock 的關係堵塞住 transaction,就像 snapshot isolation 一樣,writers 不會 block readers,而 readers 也不會堵塞 writers。如此一來,當遇到大量的 read-only queries 時可以跑在 consistent snapshot 上而不需要任何 lock,效能因此大幅提升。

--

--