平穩擴充套件:可支援RevenueCat每日12億次API請求的快取

charlieroro發表於2023-11-28

平穩擴充套件:可支援RevenueCat每日12億次API請求的快取

本文介紹了RevenueCat的快取設計方案,涉及到快取的一致性和高可靠性,譯自:Scaling smoothly: RevenueCat’s data-caching techniques for 1.2 billion daily API requests

在RevenueCat,每天需要處理12億條請求,為此,我們要實現以下兩點:

  • 在多個web伺服器之間執行負載分擔
  • 使用快取加速訪問,並保障後端系統和資料儲存。

快取系統由多個配置了大量ram和網路容量的伺服器組成,為了實現快速檢索,將資料儲存到記憶體或快閃記憶體中。快取伺服器是key-value型別的,且大部分是memcached。為了保證快速且簡易,伺服器之間通常不會共享任何內容,即一個key-value儲存不會依賴其他系統,由客戶端來選擇使用哪個伺服器來儲存或檢索資料。客戶端通常使用雜湊對不同的key進行分片,並將其分佈到對應的快取伺服器上,以此來分發資料並達到負載均衡。

快取系統需要實現如下三點:

  • 低延遲:快取必須要足夠快。如果快取伺服器出現故障(如伺服器沒有響應),則不能嘗試重新和快取伺服器建立新的連線,否則,一旦積累了成千上萬個請求,則可能會導致web伺服器卡死。
  • Up and warm:快取需要線上,並儲存大部分熱點資料。如果快取失敗,則會導致後端系統過載
  • 一致性:快取不能持有過期或錯誤的資料

本文的實現主要是圍繞memcached開發的,其實現key參考原始碼,但文中討論的技術點也適用於其他快取場景。

低延遲

建立連線池

相對於快取操作來說,TCP連線的建立要慢的多。TCP握手需要2-3個額外的報文,以及到快取伺服器的一次往返報文。

  • 快取伺服器通常受網路流量的限制,因此降低報文數量非常重要
  • 由於快取響應來自記憶體,因此速度非常快(約為100us),而相同AZ的網路往返時間約為500~700us,因此網路佔響應的主導因素。如果加上連線建立的時間,則幾乎會讓響應時間翻倍。

我們的快取客戶端建立了一個連線池,可以配置啟動時建立的連線數以及連線池可以包含的最大連線數。你可能需要設定最佳快取連線數,防止在峰值時頻繁建立新的連線。

故障檢測

有時候快取伺服器會無法響應,這通常是因為一些小問題導致的,如短暫的網路問題,短暫的流量峰值等。通常可以透過重試快取操作來完成任務,但風險也極大。如果快取無法在短時間內恢復,此時重試操作可能會影響到整個服務基礎設施。

考慮如下場景:

假設一個伺服器每秒接收1000個請求,其中快取處理95%的請求,DB處理5%的請求。快取處理一個請求的時間約10ms,DB處理一個請求的時間約50ms,因此平均響應時間為12ms,伺服器平均併發處理的請求數為12。

如果一個快取伺服器無法響應,則需要考慮重試請求,此時有兩種選擇:

  • 立即重試:

    如果採用立即重試的方式,則會使快取的請求翻倍。如果伺服器因為過載而無法響應,這種方式將會繼續加重伺服器的負載,導致其無法恢復。如果伺服器是因為事務原因無法響應,此·時也會遇到相同的問題。在嘗試重試之前,通常需要等待一段時間。

  • 在一小段時間後重試:

    假設等待時間為100ms,重試的請求可能會多次命中相同的快取伺服器,假設僅命中一次,並假設只有25%的請求需要從該快取伺服器上檢索資料,此時延遲增加為100ms*25/100=25ms,即原始的延遲增加了3倍,這也意味著伺服器的容量需要增加3倍。在這種情況下,單個伺服器將無法承受這種規模的流量,資料庫的連線速度會變慢,進而導致請求變慢,如果快取出現故障,會進一步增加伺服器的負載。在負載過重的情況下,一次100毫秒的等待重試可就以讓整個伺服器群崩潰。

如果快取伺服器無法響應,此時應該執行故障檢測,認為快取出現miss,並繼續執行下一步操作,不要執行快取重試。

再進一步,可以在一段時間內將其標記為故障,且在這段時間不再連線到該快取伺服器。TCP是面向連線的協議,它內部存在很多超時機制,因此可以將其認為是潛在的"等待時間"。

總結一下,如何實現低延遲:

  • 設定較低的超時時間:設定較低的連線和接收超時時間,這樣可以更快地認為一個伺服器出現故障,防止長時間等待響應。快取延遲非常穩定,P99很低,這要歸功於伺服器只使用了RAM,因此可以激進地將其認為是100ms。
  • 故障檢測&標記:出現故障時,客戶端可以在一段時間內(幾秒)將伺服器標記為當機。此時可以使用連線池內的健康連線,如果沒有,則應該將該請求標記為失敗並退出,不應該嘗試建立新的連線。
  • 將故障認為是快取miss,應用應該回退到使用源資料。

Up and warm

伺服器無法一直線上,你需要假設它們可能會出現故障,並給出應對方式。
此外還需要保證能夠使用大部分熱點資料來預熱快取池。你需要監控快取命中率,保證快取有足夠的容量來處理熱點資料。此外重啟伺服器會丟失資料,這對如何操作快取伺服器組施加了很多限制。
下面介紹RevenueCat如何保證快取線上和預熱。

對故障做出規劃

伺服器會產生故障,那麼該如何最小化故障影響?你可能需要增加很多快取伺服器,快取伺服器的資料越多,單個快取伺服器當機產生的影響就越小。但過多的快取也增加了成本壓力,且浪費資源。下面是快取伺服器資料對故障的影響對比圖:

image

可以看到,當存在大量小型的快取伺服器時,的確可以降低單個伺服器故障所造成的影響。
但小的快取伺服器也會帶來hot keys的問題。當一個請求佔比較大時,包含該請求key的伺服器的負載要遠大於其他伺服器,可能會導致處理飽和等問題。而如果快取伺服器較大,則hot keys並不會給整體負載帶來的巨大偏差。

image

快取伺服器數量和大小的劃分取決於一系列因素,如容量、訪問模式、流量等等。
總之,你需要了解後端的容量,並設計快取層,確保在至少2個快取伺服器當機的情況下仍然能夠正常運作。如果對後端進行了分片,則需要確保快取和後端的分片是正交的,這樣一臺快取伺服器當機造成的影響會分散到所有後端伺服器上,而不會造成某臺伺服器的高負載。

備用快取池

快取伺服器會處理大量流量,但如果為了在兩臺快取伺服器當機的情況下正常運作,而採取增加後端例項的做法,是一種過度擴充套件。下面給出了一些"備用"快取叢集的方式,如果一個伺服器出現故障,則客戶端可以嘗試連線到備用快取池。

映象池(mirrored pool)

映象池中,資料會寫入兩個快取池中,由於它們的資料是同步且預熱的,因此可以在需要的時候從備用快取池中讀取資料。

映象池應該採用不同的"salt"(hash中使用的隨機字串),這樣當一個快取池中的伺服器當機後,該伺服器的keyspace會以不同的方式分佈到備用快取池中,備用快取池中的所有伺服器都會獲得一部分keyspace。如果使用相同的salt,則會使備用快取池中的某個伺服器的負載翻倍,進而導致過載甚至級聯故障。

image

這種方式的主要缺點是成本,在記憶體中儲存大量資料的方式本身就很昂貴,更不用說儲存兩份相同的資料。但如果在不同的AZ中執行web伺服器時就可以採用這種方式(每個AZ有一份自己的快取池)。由於請求會首先到本AZ的快取上,這樣既保證了請求速度,也降低了跨AZ傳輸帶來的延遲,透過這種方式也抵消了重複資料帶來的成本。

排水池(Gutter pool)

排水池是一個小型的快取池,當主快取池的快取伺服器出現故障後,作為一個臨時儲存。你需要為其設定一個很短的TTL,如10s。這樣就可以快取請求最熱點的資料,防止請求到達後端服務,以此來降低後端所需的容量。
由於配置了較小的TTL,因此它不像映象池那樣可以有效降低後端壓力,但它不需要保證雙寫的一致性,因此更容易維護,也更簡單經濟。

專有快取池

Memcache非常簡單,所有的資料都歸屬於相同的keyspace,資料被劃分為塊,稱為slabs,每個slab用於固定大小的資料。當Memcache的記憶體耗盡後,它會採用LRU的方式釋放記憶體,以此來接納新的資料。但有時需要將一個塊從一個容量更改為另一個容量,從而需要清除整個塊;而有時在接收到新的大小的資料時,由於沒有太多專門適用於它的slab,導致這些資料很快被(從快取中)驅逐出去。

簡單地說,難以控制快取中應該儲存哪些資料。有時你需要預熱特定的資料集,特別是一些計算成本較大的資料或驅逐後會導致不精確的計數器。
最好的方式就是為特定場景建立特定的快取池,這樣就可以保證關鍵場景擁有足夠的快取容量。你需要持續監測每種場景下的快取命中率,並據此來建立快取池或特定的快取伺服器。

這種方式的唯一缺點是,web伺服器需要為每個池中的每個快取伺服器建立對應的連線。可以採用代理的方式降低開啟的連線數目。

Hot keys

在現實場景中,某些keys或變成hot keys,最典型的例子是,當需要從每個請求、某些限速器或大客戶的API金鑰中拉取配置時...

在一些極端場景中,一個單獨的memcache都無法處理一個請求的key。下面是一個業界使用的解決方案:

  • 分割key:使用版本的方式對key進行分片。例如將keyX變為keyX/1, keyX/2, keyX/3等,每個子key將被放到不同的伺服器上。客戶端會從一個伺服器讀取資料(通常取決於client id),但會寫到所有伺服器上,以此來保證資料的一致性。這種方式最難的部分在於,如何探測hot keys,如何構建pipeline來讓所有客戶端知道需要切分哪些keys,切成多少塊,以及如何協調所有客戶端在同一時間執行操作,避免不一致。由於hot keys通常是由真實事件或某些趨勢觸發的,因此它們並不是靜態的,你需要快速完成上述操作。

  • 本地快取:這是一種在客戶端探測hot keys並快取到本地的簡單機制。由於本地快取不提供一致性保證,因此比較適用於那些極少變更的資料。透過設定較低的TTL並選擇合適的快取keys,可以找到一個可接受的折衷方案。memcache的 meta-command 協議可以幫助找到hot keys,它支援返回上次訪問key的時間,並且可以實現基於機率的熱點快取。如果你看到一個key在過去X秒內被訪問了很多次,則說明它是hot key。

驚群效應(thundering herds)

如果一個hot key過期或被刪除時,所有的web伺服器會觸發快取miss,並同時從後端伺服器獲取資料,可能會導致負載峰值,增加請求延遲和處理飽和度,進而級聯回整個web服務層。

在RevenueCat中,我們通常會在寫時保證快取的一致性,以此來降低驚群效應。除此之外還有其他快取模式:

  • 設定低TTLs:使用一個相對較小的TTL來重新整理快取週期,適用於非使用者資料,如配置。
  • 使快取失效:例如可以流式修改DB,並使快取中的資料失效。

這些模式下,如果keys過期或設定失效的是hot key,則可能會因為驚群效應導致很多問題

我們的meta-memcache庫提供瞭如下兩種實現來避免這些問題:

  • 重快取(Recache)策略:使用重快取TTL來實現重快取策略。當剩餘的TTL<給定的值,其中一個客戶端會返回快取miss,並更新快取的值,而其他客戶端則可以繼續使用現有的值
  • 過期策略:在刪除命令中,可以選擇性地將key標記為過期,並觸發上述機制:某個客戶端會返回快取miss並更新快取的值,其他客戶端則繼續使用老的值。

驚群效應還有第三種場景:當驅逐一個大量請求的key時。歸功於memcache的LRU快取過期方式,這種情況通常很少見,但不代表不會發生,如快取伺服器重啟時。此時會出現大量請求miss,所有的web伺服器會同一時間請求後端伺服器。我們提供了一種Lease(租賃)策略。和上面策略類似,只有一個客戶端有權重置快取值,但此時其他客戶端不再使用老的資料,它們會等待快取更新。上面我們討論過等待快取帶來的風險,但這種方式對後端的影響也非常大,因此使用這種策略時需要了解它帶來的影響。

重分片

有時快取叢集的容量會被耗盡,如果在此時新增資料,會導致從快取中驅逐老的資料,命中率下降,負載增大。

通常解決這種問題的方式是增加更多的伺服器,但這種方式可能會影響客戶端對資料的分片,因此需要特別小心。根據採用的分片機制,你可能需要重新調整所有的keyspace,但這會使所有的快取失效。

為了避免這種情況,你可以採用一致性雜湊演算法,該演算法會維護大部分keys的位置,只會變更新增伺服器百分比範圍內的keys。

遷移

有時候需要更換快取伺服器,此時可以採取每次替換一個的方式,並在替換後給快取留出預熱的時間,但這種方式非常耗費時間,而且可能導致問題。
有次我們接收到雲廠商的維護通告,即需要在一週內重啟我們的快取伺服器,因此我們為快取客戶端制定了一個遷移策略。

image

它會執行一個客戶端驅動的平滑遷移流程:

  1. 預熱目標快取池,透過映象方式將資料寫入該快取池
  2. 將部分到原快取池的讀操作同步到目標快取池,此時可以預熱所讀取的資料
  3. 在某個時候完成足夠的快取預熱後,就可以將所有的讀操作轉移到新的快取池,此時仍然保證快取池雙寫。透過這種方式可以保證資料一致,在目標快取預熱不充分或出現過載問題時可以選擇回退
  4. 最後將流量全部遷移到目標快取池後,就可以刪除原始快取池

該流程是透過遷移客戶端所接收的配置進行的:遷移模式和遷移階段開始時間(使用時間戳表示)的對映,以此來協調所有伺服器,並在同一時間改變行為。注意,需要確保所有伺服器的時間是同步的,以保持毫秒範圍內的時間偏差。

為了保證高度一致性,一開始只需將新增的讀操作(目前不存在的)傳送到目標快取池,這樣可以避免和寫操作競爭。同時這部分讀操作採用了no-reply模式,即不會關心也不會等待響應,避免增加額外的請求延遲。

一些非冪等的操作,如計數器或鎖等都無法保證一致性的操作都不應該複製到目標快取池,且這些資料通常也不需要預熱。

這種遷移客戶端的方式幫助我們在2-3小時內使用16臺伺服器替換了完整的叢集,保證了高命中率,且對資料庫的影響很低,對終端使用者也沒有明顯的影響。

一致性

除了快取伺服器,我們還有很多web伺服器來處理併發流量,即使一個web伺服器,它也可以在多個CPU上併發處理請求,這意味著可能會出現快取一致性問題。
一個導致一致性問題的例子如下:

image

在上面例子中,一開始快取是空的:

  • Web server1 嘗試讀取快取,返回miss,然後回退到DB讀取資料,讀取到資料"red"並嘗試回填到快取中
  • Web server2 正好執行一個寫操作,需要將資料設定為"green",並同時更新DB和快取

快取寫入時機的不同會導致不同的結果。如果快取的資料和DB不匹配,則表示發生了資料不一致。

你可能覺得只要將快取回填操作從"set"改為"add"就可以了,只有在快取為空的時候才能執行"add"操作。這種方式可以解決上述場景中的問題,但無法涵蓋其他場景:

  • Web server1可能會寫入失敗、超時、丟失或快取伺服器當機,導致無法更新快取,此時可以執行"add"操作,但資料仍然是舊的
  • 可能存在滯後的資料庫副本,在回填操作之間引入競爭。

我們的meta-memcache庫支援很多底層meta命令,用於處理一致性和高吞吐量問題:

  • compare-and-swap:檢測寫資料競爭,在讀取時會獲取到一個token,並在寫入時攜帶該token,如果在讀取後修改了該值,則token將不匹配,寫入失敗。
  • leases:只有一個客戶端有權更新快取。Memcache可以標記快取miss,這樣其他客戶端就知道當前有另一個客戶端正在更新快取,而不會相互競爭。
  • 使用重快取策略實現stale-while-revalidate:在一個客戶端更新快取的同時,其他客戶端可以使用老的數值
  • 標記過期:相比刪除一個key,你可以將其標記為過期,這樣某個客戶端就可以更新快取。注意需要重新校驗快取,並防止發生驚群效應。
  • 較低的TTLs:使用較低的TTL可以確保在key過期前重新整理它。
  • 寫入失敗跟蹤:跟蹤寫入錯誤

這裡我們只列舉了保證快取一致性的常見策略。

寫入失敗跟蹤

寫入失敗通常表示快取出現了不一致,無法寫入期望的資料,此時快取狀態不明,可能出現了錯誤。

正如前面所述,在處理快取時重試快取可能會造成短時間的效能問題,甚至產生級聯錯誤。

我們的策略是在第一時間丟擲錯誤,並記錄寫入失敗的keys。我們的快取客戶端會註冊一個寫入失敗處理器,它會收集這些金鑰,消除重複資料,並讓每個報告的keys對應的快取至少失效一次。

透過這種簡單的機制可以認為寫入總是成功的,大大簡化了CRUD操作中的快取一致性。

兩個儲存中的CRUD一致性

在寫入資料時,你需要同時更新DB和快取,以保證一致性。由於資料庫通常提供了事務的概念,因此兩個儲存會面臨如何保證一致性的複雜問題。

我們已經實現了訪問資料的CRUD策略,它們實現了高度一致的快取機制,並且可以很容易重用,只需要配置行為、資料來源等。我們強烈建議為CRUD訪問構建抽象,抽象掉更新DB和快取的細微差別,這樣產品工程師就可以關注業務邏輯,並且這些策略經過長期實踐,可以安全地使用。

下面介紹我們是如何實現高度一致性快取的CRUD操作。

READ

首選嘗試讀取快取,如果快取miss,則從DB中讀取,並回填到快取中。

為了防止併發寫入導致的競爭,我們採用"新增"的方式執行快取回填操作。

併發回填產生的競爭問題不大,即使某些快取副本的資料存在滯後。如果讀取了舊的資料,這是因為該資料剛剛被重新整理,且很快會有快取寫入來修復該問題。如果寫入失敗,則寫入失敗跟蹤器會保證讓受影響的keys失效。

還有可能發生快取寫入正常工作,但key卻立馬失效,以及從老的讀取操作中回填快取的場景,對於這些場景的處理方式為:

  • 將快取keys嵌入到DB事務中(postgresql允許在WAL中寫入使用者資料,mysql允許嵌入一些後設資料作為查詢註釋),然後,在讀取副本成功後,讓WAL/binlog尾部的keys失效。
  • 設定更新延遲,使延遲時間超過副本滯後時間,類似於寫入失敗跟蹤器。

幸運的是,副本的延遲通常小於100ms,因此快取被驅逐的機率通常也比較小,我們不需要實現這些功能。

UPDATE

更新DB和快取的方式有:

  • 首先寫入DB,然後寫入快取。快取寫入可能會全部失敗,即使是寫入失敗跟蹤器也可能會產生故障。這樣在DB提交之後會阻塞伺服器。
  • 如果反過來,如果DB寫入失敗,則快取會具有新的資料,導致資料不一致。

我們在快取操作前後實現了一些策略:

  1. 快取寫入前:降低快取TTL到某個值,如30s
  2. 寫入DB
  3. 快取寫入後:更新快取數值

考慮如下場景:

  • 步驟1和2之間產生故障:此時DB沒有變更,快取也是一致的。快取會透過降低TTL被回填。
  • 步驟2和3之間產生故障:在寫入DB之後,並沒有更新快取,此時老資料會被保留一段時間,但由於降低了TTL的緣故,該資料會很快過期,並被新資料填充。
  • 我們還會記錄寫入失敗(降低TTL還考慮到了寫入失敗的場景),因此如果因為某種原因出現快取寫入失敗時,我們會在快取伺服器可用時,使受影響的keys失效,以保證一致性。

總之,在DB操作前降低TTL是一種簡單有效地實現高一致性更新的方式。

CREATE

由於我們的id來自DB,而DB提供了避免競爭所需的序列化(id是唯一)。因此可以在DB寫入後使用一個簡單的"新增"操作。

DELETE

在我們的應用場景中不存在刪除競爭,因此可以發起簡單的刪除操作。但由於刪除操作並不會在快取中留下任何蹤跡,因此可能會產生回填競爭(特別是讀取DB副本時出現較大延遲時)。你可以使用"刪除標記"以及降低TTL來避免這種競爭。

相關文章