天天看點

高并發系統設計十二(緩存讀寫政策)

上節,我們了解了緩存的定義、分類以及不足,你現在應該對緩存有了初步的認知。從今天開始,我們一起了解一下使用緩存的正确姿勢,比如緩存的讀寫政策是什麼樣的,如何做到緩存的高可用以及如何應對緩存穿透。通過了解這些内容,你會對緩存的使用有深刻的認識,這樣在實際工作中就可以在緩存使用上遊刃有餘了。

今天,我們先講講緩存的讀寫政策。你可能覺得緩存的讀寫很簡單,隻需要優先讀緩存,緩存不命中就從資料庫查詢,查詢到了就回種緩存。實際上,針對不同的業務場景,緩存的讀寫政策也是不同的。

而我們在選擇政策時也需要考慮諸多的因素,比如說,緩存中是否有可能被寫入髒資料,政策的讀寫性能如何,是否存在緩存命中率下降的情況等等。接下來,我就以标準的“緩存+ 資料庫”的場景為例,帶你剖析經典的緩存讀寫政策以及它們适用的場景。這樣一來,你就可以在日常的工作中根據不同的場景選擇不同的讀寫政策。

Cache Aside(旁路緩存)政策

我們來考慮一種最簡單的業務場景,比方說在你的電商系統中有一個使用者表,表中隻有 ID和年齡兩個字段,緩存中我們以 ID 為 Key 存儲使用者的年齡資訊。那麼當我們要把 ID 為 1的使用者的年齡從 19 變更為 20,要如何做呢?

你可能會産生這樣的思路:先更新資料庫中 ID 為 1 的記錄,再更新緩存中 Key 為 1 的資料。

高并發系統設計十二(緩存讀寫政策)

這個思路會造成緩存和資料庫中的資料不一緻。比如,A 請求将資料庫中 ID 為 1 的使用者年齡從 19 變更為 20,與此同時,請求 B 也開始更新 ID 為 1 的使用者資料,它把資料庫中記錄的年齡變更為 21,然後變更緩存中的使用者年齡為 21。緊接着,A 請求開始更新緩存資料,它會把緩存中的年齡變更為 20。此時,資料庫中使用者年齡是 21,而緩存中的使用者年齡卻是 20。

高并發系統設計十二(緩存讀寫政策)

為什麼産生這個問題呢?因為變更資料庫和變更緩存是兩個獨立的操作,而我們并沒有對操作做任何的并發控制。那麼當兩個線程并發更新它們的時候,就會因為寫入順序的不同造成資料的不一緻。

另外,直接更新緩存還存在另外一個問題就是丢失更新。還是以我們的電商系統為例,假如電商系統中的賬戶表有三個字段:ID、戶名和金額,這個時候緩存中存儲的就不隻是金額資訊,而是完整的賬戶資訊了。當更新緩存中賬戶金額時,你需要從緩存中查詢完整的賬戶資料,把金額變更後再寫入到緩存中。

這個過程中也會有并發的問題,比如說原有金額是 20,A 請求從緩存中讀到資料,并且把金額加 1,變更成 21,在未寫入緩存之前又有請求 B 也讀到緩存的資料後把金額也加 1,也變更成 21,兩個請求同時把金額寫回緩存,這時緩存裡面的金額是 21,但是我們實際上預期是金額數加 2,這也是一個比較大的問題。

那我們要如何解決這個問題呢?其實,我們可以在更新資料時不更新緩存,而是删除緩存中的資料,在讀取資料時,發現緩存中沒了資料之後,再從資料庫中讀取資料,更新到緩存中。

高并發系統設計十二(緩存讀寫政策)

這個政策就是我們使用緩存最常見的政策,Cache Aside 政策(也叫旁路緩存政策),這個政策資料以資料庫中的資料為準,緩存中的資料是按需加載的。它可以分為讀政策和寫政策,其中讀政策的步驟是:

1、從緩存中讀取資料;

2、如果緩存命中,則直接傳回資料;

3、如果緩存不命中,則從資料庫中查詢資料;

4、查詢到資料後,将資料寫入到緩存中,并且傳回給使用者。

寫政策的步驟是:

1、更新資料庫中的記錄;

2、删除緩存記錄。

你也許會問了,在寫政策中,能否先删除緩存,後更新資料庫呢?答案是不行的,因為這樣也有可能出現緩存資料不一緻的問題,我以使用者表的場景為例解釋一下。

假設某個使用者的年齡是 20,請求 A 要更新使用者年齡為 21,是以它會删除緩存中的内容。

這時,另一個請求 B 要讀取這個使用者的年齡,它查詢緩存發現未命中後,會從資料庫中讀取到年齡為 20,并且寫入到緩存中,然後請求 A 繼續更改資料庫,将使用者的年齡更新為21,這就造成了緩存和資料庫的不一緻。

高并發系統設計十二(緩存讀寫政策)

那麼像 Cache Aside 政策這樣先更新資料庫,後删除緩存就沒有問題了嗎?其實在理論上還是有缺陷的。假如某個使用者資料在緩存中不存在,請求 A 讀取資料時從資料庫中查詢到年齡為 20,在未寫入緩存中時另一個請求 B 更新資料。它更新資料庫中的年齡為 21,并且清空緩存。這時請求 A 把從資料庫中讀到的年齡為 20 的資料寫入到緩存中,造成緩存和資料庫資料不一緻。

高并發系統設計十二(緩存讀寫政策)

不過這種問題出現的幾率并不高,原因是緩存的寫入通常遠遠快于資料庫的寫入,是以在實際中很難出現請求 B 已經更新了資料庫并且清空了緩存,請求 A 才更新完緩存的情況。而一旦請求 A 早于請求 B 清空緩存之前更新了緩存,那麼接下來的請求就會因為緩存為空而從資料庫中重新加載資料,是以不會出現這種不一緻的情況。

Cache Aside 政策是我們日常開發中最經常使用的緩存政策,不過我們在使用時也要學會依情況而變。比如說當新注冊一個使用者,按照這個更新政策,你要寫資料庫,然後清理緩存(當然緩存中沒有資料給你清理)。可當我注冊使用者後立即讀取使用者資訊,并且資料庫主從分離時,會出現因為主從延遲是以讀不到使用者資訊的情況。

而解決這個問題的辦法恰恰是在插入新資料到資料庫之後寫入緩存,這樣後續的讀請求就會從緩存中讀到資料了。并且因為是新注冊的使用者,是以不會出現并發更新使用者資訊的情況。

Cache Aside 存在的最大的問題是當寫入比較頻繁時,緩存中的資料會被頻繁地清理,這樣會對緩存的命中率有一些影響。如果你的業務對緩存命中率有嚴格的要求,那麼可以考慮兩種解決方案:

1、一種做法是在更新資料時也更新緩存,隻是在更新緩存前先加一個分布式鎖,因為這樣在同一時間隻允許一個線程更新緩存,就不會産生并發問題了。當然這麼做對于寫入的性能會有一些影響;

2、另一種做法同樣也是在更新資料時更新緩存,隻是給緩存加一個較短的過期時間,這樣即使出現緩存不一緻的情況,緩存的資料也會很快地過期,對業務的影響也是可以接受。

當然了,除了這個政策,在計算機領域還有其他幾種經典的緩存政策,它們也有各自适用的使用場景。

Read/Write Through(讀穿 / 寫穿)政策

這個政策的核心原則是使用者隻與緩存打交道,由緩存和資料庫通信,寫入或者讀取資料。這就好比你在彙報工作的時候隻對你的直接上級彙報,再由你的直接上級彙報給他的上級,你是不能越級彙報的。

Write Through 的政策是這樣的:先查詢要寫入的資料在緩存中是否已經存在,如果已經存在,則更新緩存中的資料,并且由緩存元件同步更新到資料庫中,如果緩存中資料不存在,我們把這種情況叫做“Write Miss(寫失效)”。

一般來說,我們可以選擇兩種“Write Miss”方式:一個是“Write Allocate(按寫分

配)”,做法是寫入緩存相應位置,再由緩存元件同步更新到資料庫中;另一個是“Nowrite allocate(不按寫配置設定)”,做法是不寫入緩存中,而是直接更新到資料庫中。

在 Write Through 政策中,我們一般選擇“No-write allocate”方式,原因是無論采用哪種“Write Miss”方式,我們都需要同步将資料更新到資料庫中,而“No-write

allocate”方式相比“Write Allocate”還減少了一次緩存的寫入,能夠提升寫入的性能。

Read Through 政策就簡單一些,它的步驟是這樣的:先查詢緩存中資料是否存在,如果存在則直接傳回,如果不存在,則由緩存元件負責從資料庫中同步加載資料。

下面是 Read Through/Write Through 政策的示意圖:

高并發系統設計十二(緩存讀寫政策)

Read Through/Write Through 政策的特點是由緩存節點而非使用者來和資料庫打交道,在我們開發過程中相比 Cache Aside 政策要少見一些,原因是我們經常使用的分布式緩存元件,無論是 Memcached 還是 Redis 都不提供寫入資料庫,或者自動加載資料庫中的資料的功能。而我們在使用本地緩存的時候可以考慮使用這種政策,比如說在上一節中提到的本地緩存 Guava Cache 中的 Loading Cache 就有 Read Through 政策的影子。

我們看到 Write Through 政策中寫資料庫是同步的,這對于性能來說會有比較大的影響,因為相比于寫緩存,同步寫資料庫的延遲就要高很多了。那麼我們可否異步地更新資料庫?

這就是我們接下來要提到的“Write Back”政策。

Write Back(寫回)政策

這個政策的核心思想是在寫入資料時隻寫入緩存,并且把緩存塊兒标記為“髒”的。而髒塊兒隻有被再次使用時才會将其中的資料寫入到後端存儲中。

需要注意的是,在“Write Miss”的情況下,我們采用的是“Write Allocate”的方式,也

就是在寫入後端存儲的同時要寫入緩存,這樣我們在之後的寫請求中都隻需要更新緩存即可,而無需更新後端存儲了,我将 Write back 政策的示意圖放在了下面:

高并發系統設計十二(緩存讀寫政策)

如果使用 Write Back 政策的話,讀的政策也有一些變化了。我們在讀取緩存時如果發現緩存命中則直接傳回緩存資料。如果緩存不命中則尋找一個可用的緩存塊兒,如果這個緩存塊兒是“髒”的,就把緩存塊兒中之前的資料寫入到後端存儲中,并且從後端存儲加載資料到緩存塊兒,如果不是髒的,則由緩存元件将後端存儲中的資料加載到緩存中,最後我們将緩存設定為不是髒的,傳回資料就好了。

高并發系統設計十二(緩存讀寫政策)

發現了嗎?其實這種政策不能被應用到我們常用的資料庫和緩存的場景中,它是計算機體系結構中的設計,比如我們在向磁盤中寫資料時采用的就是這種政策。無論是作業系統層面的Page Cache,還是日志的異步刷盤,亦或是消息隊列中消息的異步寫入磁盤,大多采用了這種政策。因為這個政策在性能上的優勢毋庸置疑,它避免了直接寫磁盤造成的随機寫問題,畢竟寫記憶體和寫磁盤的随機 I/O 的延遲相差了幾個數量級呢。

但因為緩存一般使用記憶體,而記憶體是非持久化的,是以一旦緩存機器掉電,就會造成原本緩存中的髒塊兒資料丢失。是以你會發現系統在掉電之後,之前寫入的檔案會有部分丢失,就是因為 Page Cache 還沒有來得及刷盤造成的。

當然,你依然可以在一些場景下使用這個政策,在使用時,我想給你的落地建議是:你在向低速裝置寫入資料的時候,可以在記憶體裡先暫存一段時間的資料,甚至做一些統計彙總,然後定時地重新整理到低速裝置上。比如說,你在統計你的接口響應時間的時候,需要将每次請求的響應時間列印到日志中,然後監控系統收集日志後再做統計。但是如果每次請求都列印日志無疑會增加磁盤 I/O,那麼不如把一段時間的響應時間暫存起來,經過簡單的統計平均耗時,每個耗時區間的請求數量等等,然後定時地,批量地列印到日志中。

小結

本節,我們了解了緩存使用的幾種政策,以及每種政策适用的使用場景是怎樣的。

我想讓你掌握的重點是:

1.Cache Aside 是我們在使用分布式緩存時最常用的政策,你可以在實際工作中直接拿來使用。

2.Read/Write Through 和 Write Back 政策需要緩存元件的支援,是以比較适合你在實作本地緩存元件的時候使用;

3.Write Back 政策是計算機體系結構中的政策,不過寫入政策中的隻寫緩存,異步寫入後端存儲的政策倒是有很多的應用場景。

而且,你還需要了解,我們今天提到的政策都是标準的使用姿勢,在實際開發過程中需要結合實際的業務特點靈活使用甚至加以改造。這些業務特點包括但不僅限于:整體的資料量級情況,通路的讀寫比例的情況,對于資料的不一緻時間的容忍度,對于緩存命中率的要求等等。理論結合實踐,具體情況具體分析,你才能得到更好的解決方案。