Shopify使用Memcached而不是Redis快取提升20%效能

banq發表於2021-09-24

Shopify構建了一個自定義快取解決方案,將資料庫負載減少了 15%,整體應用延遲減少了大約 20%。
 

識別問題
商店應用程式的主螢幕是最常用的功能,提供的主頁提要Feed很複雜,因為除了處理來自數十家運營商的跟蹤資料之外,它還需要彙總來自數百萬 Shopify 和非 Shopify 商家的訂單。由於各種因素,載入和合並這些資料在計算上既昂貴又非常緩慢。在我們開始這個專案之前,Shop 30%的資料庫負載來自家庭訂閱源。此負載不僅影響主頁提要,還影響應用程式各個方面的效能。
我們四處尋找簡單、直接的解決方案來解決這個問題,比如引入IdentityCache、更新我們的資料庫架構和新增更多的資料庫索引。經過一番調查,我們瞭解到我們幾乎沒有什麼資料庫級最佳化要做,也沒有時間進行大量的程式碼重寫。另一方面,快取對於這種情況似乎是理想的。由於使用者每天都使用主頁提要以及主頁提要的基於時間的排序,因此主頁提要資料通常僅在最近寫入後才會讀取,因此非常適合某種型別的快取。
 
尋找解決方案
由於主頁提要Feed的結構,我們無法使用即插即用的快取解決方案。我們將給定使用者的主頁提要視為使用者購買的排序列表,其中列表可能很大(有些人經常購物!)。該列表可以透過一系列併發操作更新,包括:

  • 新增一個新訂單以顯示在主頁上(例如,當有人從 Shopify 商店購買時)
  • 更新與訂單相關的詳細資訊(例如,訂單交付時)
  • 從列表中刪除訂單(例如,當使用者手動歸檔訂單時)。

為了快取主頁提要Feed ,我們需要一個系統來維護使用者提要的快取版本,同時處理提要中訂單的任意更新,並保證提要訂單是正確的。
由於我們處理的更新數量,使用在每次寫入後失效的通讀快取模式是不可行的,因為快取最終會失效,因此實際上幾乎沒有用。經過一些研究,我們沒有找到現有的解決方案:
  • 寫入後未失效
  • 可以在不向使用者顯示陳舊資料的情況下處理故障情況。

所以,我們自己建了一個。
  

Building Shop 的快取解決方案
在引入快取之前,當使用者請求載入主頁提要時,Rails 應用程式會序列執行多個資料庫查詢,具有很高的延遲。引入快取後,當使用者請求載入他們的主頁提要時,Rails 從快取中載入他們的主頁提要,併發出更少(更快)的資料庫請求。
我們現在不是在使用者每次請求主頁提要時查詢資料庫,而是在快速、分散式、水平擴充套件的快取系統(我們選擇 Memcached)中快取他們的主頁提要的副本。然後,如果滿足某些條件,我們會在請求時從快取而不是資料庫中提供服務。為了在每次資料庫更新之前保持快取有效和正確,我們將快取標記為“無效”以確保快取資料在快取和資料庫不同步時不被使用。寫入完成後,我們用新資料更新快取並再次將其標記為“有效”。
 

決定使用 Memcached
在 Shopify,我們使用兩種不同的快取技術:MemcachedRedis。Redis 比 Memcached 更強大,支援更復雜的操作,儲存更復雜的物件。Memcached 更簡單,開銷更少,更廣泛地用於 Shop 內部的快取。雖然我們使用Redis來管理佇列和一些快取,但我們不需要Redis的複雜性,所以我們選擇了分散式Memcached。 
我們必須解決的主要問題是確保快取永遠不會包含過時的記錄。我們透過使用直寫失效策略構建快取來最小化快取失效的可能性,該策略在資料庫寫入之前使快取失效並在成功寫入後重新驗證它。這就引出了下一個難題:我們如何在 Memcached 中實際儲存資料並處理併發更新?
最簡單的方法是在 Memcached 中為每個使用者儲存一個金鑰,將使用者對映到他們的主頁。然後,在寫入時,透過從快取中驅逐鍵來使快取無效,更新資料庫,最後透過再次寫入鍵來重新驗證快取。不幸的是,問題是不支援併發寫入。在 Shop 的規模下,多臺工作機器通常會同時處理同一使用者的訂單更新。使用先刪除後寫入的策略會引入競爭條件,從而導致快取不正確,這是不可接受的。
為了支援併發寫入,我們儲存了一個額外的鍵/值對(掛起的寫入鍵),用於跟蹤每個使用者的快取有效性。該鍵儲存對給定使用者的主頁提要的活動寫入次數。每次工作機器即將寫入資料庫時​​,我們都會增加這個值。更新完成後,我們遞減該值。這意味著當掛起的寫入鍵為零時快取有效。
然而,還有最後一種情況。如果機器進行資料庫更新並且由於中斷或異常而未能減少掛起的寫入鍵,會發生什麼情況?我們如何知道掛起的寫入鍵是否大於零,因為當前正在進行資料庫寫入或程式被中斷?
解決方案是引入一個在任何資料庫更新之前寫入的短期到期的鍵。如果此鍵存在,則我們知道有可能進行資料庫更新,但如果不存在且掛起的寫入鍵大於零,則我們知道沒有發生活動的資料庫寫入,因此再次重新預熱快取是安全的。

def update_order(user)
  user.pending_writes += 1
  user.active_writes = true # this key expires
  yield # do update
  # if yield raises an exception and pending_writes is never decremented,
  # a background job uses the expiring active_writes key to safely reset the cache.
  user.pending_writes -= 1
end

def read_home_feed(user)
  return user.home_feed if user.pending_writes == 0
  generated_home_feed = # many database calls
  return generated_home_feed
end


另一個有趣的細節是,我們需要此程式碼與 Shop 中的所有現有程式碼一起工作,並與該程式碼無縫互動。我們編寫了一系列Active Record Concerns,將它們混合到相關的資料庫記錄中。使用 Active Record 意味著 ORM 的 API 保持完全相同,導致此更改對開發人員完全透明,並確保所有這些程式碼都向前相容。當任何在 Google 或 Facebook 上銷售的人都可以使用 Shop Pay 時,我們能夠以最少的開銷整合快取。
 
在全球推廣此快取後,我們看到了立竿見影的效果。我們的資料庫伺服器負載更輕,除了更低的資料庫負載和更快的家庭饋送效能外,我們還觀察到整體 CPU 使用率下降了兩位數,整體 GraphQL 延遲下降了 20%。我們的資料庫伺服器負載更輕,我們的使用者擁有更快的體驗,我們的開發人員無需擔心資料庫負載過高。這是一個雙贏的局面。

 

相關文章