iT邦幫忙

2022 iThome 鐵人賽

DAY 28
2
Software Development

軟體架構師的自我修養系列 第 28

[Day 28] 快取一致性實戰(上)

  • 分享至 

  • xImage
  •  

今天我們要討論資料一致性,尤其是快取和資料庫間的一致性。事實上,這是一個很重要的主題,特別是當組織變大,那麼對一致性的要求就會提高,這也連帶影響快取的實作。

舉例來說,一個新創的服務和一個成熟的服務相比不需要很高的SLA。對於一個新創服務來說,資料一致性的SLA也許四個九(99.99%)就算很高了,但是對於成熟服務例如AWS S3來說,SLA甚至是11個九。

我們都知道每增加一個九,實作複雜度會像指數級數般往上增加,所以對於新創服務來說,基本上沒有足夠資源來維護一個很高的SLA。

因此,如何盡可能有效的運用資源以提高一致性?這就是今天我們要討論的。再強調一次,這邊的一致性指的是快取和資料庫間的資料一致性。

為什麼要快取?

我相信我們都同意把資料放在兩個不同地方無可避免地會不一致。那麼是什麼原因讓我們寧可背負不一致的風險也要使用快取?

  1. 資料庫的成本非常高。為了同時保證資料持久化和可用性,甚至關聯式資料庫還提供ACID保證,這些都使資料庫的實作很複雜且需要高級的硬體規格支撐,無論硬碟、記憶體或CPU。這就使資料庫光硬體成本就非常高。
  2. 資料庫的效能是有限的。為了資料持久化,資料庫必須將資料寫進硬碟,這也使資料庫的效能受限於硬碟。畢竟,硬碟的讀寫效能遠輸記憶體。
  3. 資料庫離使用者很遠。這裡的遠指的是物理距離上的遠。因為資料庫很昂貴,所以我們不會在世界各地都放置一個資料庫,而是會選擇一個最划算的資料中心來安置。在亞洲,通常AWS會選擇新加坡的資料中心,因為價格最低,但對日本的用戶來說,新加坡距離很遠這也會影響網路的傳輸速率。

綜上所述,我們需要快取。

因為快取不需要資料持久化,所以可以使用記憶體作為儲存媒介,不僅便宜也更有效率。正因為便宜,所以快取可以盡可能靠近使用者,以剛例子來說,我們雖然資料庫放在新加坡,但可以在東京放一個快取,讓日本使用者可以就近存取。

快取模式

既然快取是必要的,那麼如何盡可能提高快取一致性呢?

為了避免失焦,這系列文提到的快取會以Redis為準,而資料庫則是MySQL。我們的目的是盡可能用有限資源(包含硬體和人力)提高資料一致性,因此一些大型組織的複雜架構不在解說範圍內,例如Meta的TAO

TAO是一個分散式快取,並且具備超高SLA(10個九)。但是,要維運這個服務,背後有非常複雜的架構,甚至光是監控系統就大到不行,而這不是一般組織負擔得起的。

因此,我們會專注在以下模式,並闡明他們的問題以及說明該如何避免。

  • 快取期限
  • 旁讀(Read Aside)
  • 透過快取讀(Read Through)
  • 透過快取寫(Write Through)
  • 背景寫(Write Ahead)
  • 刪除兩次(Double Delete)

以下章節都會遵循這個流程。

  1. 讀取路徑
  2. 寫入路徑
  3. 潛在問題
  4. 如何改善

快取期限

讀取路徑

  • 先從快取讀資料
  • 如果快取資料不存在
  • 改從資料庫讀
  • 並且寫回快取

在將資料寫入快取時,我們會加入一個TTL讓資料自動過期。

EXPIRE key seconds [ NX | XX | GT | LT]

寫入路徑

  • 只有將資料寫入資料庫

潛在問題

當更新資料時,因為資料只有寫回資料庫,所以快取和資料庫的資料不一致。這個不一致會一直持續到快取過期,儘管如此,要選擇一個好的TTL是很困難的。

如果TTL設得太長,那不一致的持續時間就會變長,反之,太短則會讓快取沒有效益。

值得一提的是快取是為了減少資料庫的負載並且提升資料存取效能,如果一個超短的TTL會讓快取完全沒用。舉例來說,如果TTL設為1秒,但1秒間沒有人要讀取,那這個快取就毫無價值。

如何改善

寫入路徑看起來很正常,但是當資料庫更新,必須要有一個機制能更新快取內的資料,而這也是旁讀的概念。

旁讀

讀取路徑

  • 先從快取讀資料
  • 如果快取資料不存在
  • 改從資料庫讀
  • 並且寫回快取

這個流程和「快取期限」一樣,但是TTL會設定的足夠長,這會使快取更有機會發揮效益。

寫入路徑

  • 先將資料寫入資料庫
  • 接著清掉快取

潛在問題

這樣的寫入路徑和讀取路徑看起來沒問題,但還是會有幾個邊角案例無可避免。

A想要更新資料,但是B同時想要讀取資料。個別來看,AB都是正確的,但放在一起就出錯了。在上面例子中,BA清掉快取前就讀到資料了,因此B取得的資料是不一致的。


A要更新資料,且已經完成資料庫更新,但在清掉快取前就因為「某些原因」被砍了,那麼快取就會維持不一致一段時間,直到下次更新或快取過期。

被砍了聽起來很嚴重也很少見,事實上,這遠比你想的容易發生。有幾種不同的情境會導致被砍。

  1. 當應用程式升降版,無論是容器或虛擬機,舊版本的應用程式都會被新版的取代,此時舊版本就會被砍。
  2. 當水平收縮(scale-in)時,多餘的應用程式會被回收,所以也會被砍。
  3. 還有一種,當應用程式崩潰時,那無可避免地會被砍。

A想要讀取資料,而B想要更新資料,再一次強調,AB個別來看都是正確的但放在一起就錯了。

首先,A試著從快取讀,但是沒找到對應資料,因此改從資料庫讀。於此同時,B試著更新資料庫並且將快取清除。接著,A把資料寫回快取,結果不一致就發生了,而且這個不一致也會持續一段時間。

如何改善

案例1和案例3可以靠著正確實作應用程式而將發生機率降到極低。以案例1為例,在更新完資料庫後別做多餘的事,趕快把快取清掉;而案例3,在從資料庫讀完資料後,別做太多資料轉換,盡快把資料寫回快取。透過這種方式,我們可以極大的降低機率,但即使如此,也有一些無可避免的情況,例如垃圾回收觸發的「世界暫停」,那應用程式怎麼改都無解。

至於案例2,可以透過實作優雅終止(graceful shutdown)來避免出現問題,但若是應用程式崩潰,那基本上沒救。

旁讀變體

為了解決案例1和案例2,有些人會試著修改原來的流程。

寫入路徑

  • 先從快取讀資料
  • 如果快取資料不存在
  • 改從資料庫讀
  • 並且寫回快取

這個過程與旁讀一模一樣。

寫入路徑

  • 先清掉快取
  • 接著才寫入資料庫

這與旁讀的流程完全相反。

潛在問題

雖然旁讀的邊角案例1和案例2可以解決,但會產生新的問題。

A試著更新資料,而B想要讀取資料,A先把快取清了所以B讀不到快取只能改從資料庫讀取。接著,A繼續更新資料庫,最終B把從資料庫讀取的舊資料寫回快取,不一致就這樣發生了。

如何改善

事實上,案例1和案例2比起這個變體的邊角案例更難發生,尤其是正確實作應用程式可以極大減低案例1和案例2的發生機率。

另一方面,變體1的邊角案例很難有效改善。

因此,不建議使用變體。

結論

一般來說,旁讀快取就可以達到相對高的資料一致性了,雖然只是簡單的實作,但也足夠可靠。

儘管如此,如果想要進一步提升一致性,那麼單靠旁讀是不夠的。總是會需要較複雜的機制,但也會帶來更高的實作負擔和成本。

因此,我將那些較複雜的方案留到明天。明天我們會繼續介紹如何用有限的資料盡可能達成更高的快取一致性。

再一次強調,雖然旁讀快取很單純,但只要正確實作,也足夠可靠了。


上一篇
[Day 27] 用Redis做基數計數
下一篇
[Day 29] 快取一致性實戰(下)
系列文
軟體架構師的自我修養31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言