平穩擴展:可支持RevenueCat每日12億次API請求的緩存

平穩擴展:可支持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來避免這種競爭。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章