使用Redis構建高併發高可靠的秒殺拍賣系統 - Luis

banq發表於2021-05-23

如何構建高可靠性且一致地處理數百萬併發使用者的拍賣系統、搶拍系統?
諸如耐克,阿迪達斯或至尊之類的品牌在市場上創造了一種新的趨勢,稱為“drops”,在那裡他們釋出了數量有限的商品。在實際發行之前,通常是有限的執行或預發行的有限報價。
這構成了一些特殊的挑戰,因為每次銷售基本上都是“黑色星期五”,並且您有成千上萬(或數百萬)的使用者試圖在同一時刻購買數量非常有限的商品。
主要要求
  • 所有客戶都可以實時檢視專案更改(庫存,描述等);
  • 處理突然增加的負載;
  • 儘量減少客戶端與後端的互動;
  • 在避免競爭條件的同時處理併發性;
  • 處理同一專案的成千上萬個併發請求;
  • 容錯
  • 自動縮放
  • 高可用性;
  • 成本效益。

 

後端架構
一切都從我們的後臺開始。我們需要將drops資訊和可用專案儲存在適當的資料結構中。我們將使用2個系統。PostgreSQL和Redis。
PostgreSQL,MySQL,Cassandra或任何其他持久資料庫引擎都可以使用。我們需要持久且一致的事實來源來儲存我們的資料。

Redis不僅僅是一個簡單的鍵值記憶體資料庫,它還具有特殊的資料結構(例如HASH和LIST),可以將系統效能提高几個數量級。
Redis將成為我們的主力軍,並佔據我們的大部分流量。選擇的資料結構將是每個專案的Redis HASH和每個結帳佇列的LIST。
使用HASH而不是TEXT,我們可以僅更新每個專案屬性的單個元素,例如專案庫存。它甚至使我們可以INCR / DECR整數字段,而無需先讀取該屬性。
這將非常方便,因為它將允許非阻塞併發訪問我們的商品資訊。
每次後臺員工使用者進行任何更改時,都需要對Redis進行更新,以確保在DROP資料方面我們擁有PostgreSQL資料庫的精確副本。

  • 1.獲取拍賣資訊

當使用者啟動應用程式時,它需要做的第一件事是請求DROP有效負載並連線到WebSocket。
這樣,它只需要一次請求資訊,此後便會在每次有相關更新時實時獲取資訊,而無需不斷輪詢伺服器。這稱為推送架構,在這裡特別有用,因為它啟用了“實時”,同時為將我們的網路干擾降至最低。啟用Redis快取返回資訊以保護我們的主資料庫。
但是我們需要考慮Redis可能發生的某些事情,並且不能相信它將始終擁有我們的資料。始終將Redis視為快取記憶體是個好習慣,即使它的行為類似於普通資料庫。
一種簡單的方法是驗證Redis是否未返回任何資料,然後立即從PostgreSQL載入並將其儲存到Redis。之後,您才應將資料返回給使用者。這樣,我們將您與主資料庫的互動減至最少,同時保證您始終擁有正確的資料。
我們的流量通常分佈不均。確定受影響最大的端點,並確保它們具有可擴充套件的體系結構。
  • 2.搶拍

這部分是我們整個系統中最關鍵和最危險的部分。我們需要考慮到市場上充斥著專門為利用這些系統而構建的機器人。整個地下經濟在購買和轉售這些物品時會蓬勃發展。對於本篇文章,我們將重點放在可用性和一致性上,而不會深入探討我們必須嘗試保護該系統的多種選擇。
DROP啟動後,結帳端點將充滿請求。這意味著需要儘可能高效地處理每個請求,理想情況下,僅在保證順序的同時使用記憶體資料庫和O(1)操作,因為這是先來先服務的業務模型。
這是Redis閃亮的地方,它會檢查所有標記!
  • O(1)讀寫操作;
  • 記憶體資料庫;
  • 單執行緒保證順序,而無需在我們的程式碼上增加互斥鎖複雜性;
  • 極高的吞吐量和極低的延遲;
  • 專門的資料結構,例如HASH和LIST。

在很高的級別上,當使用者請求結帳端點時,我們只需要檢查Drop是否已經開始(Redis),是否仍然可用(Redis),使用者是否已經在佇列中(Redis),以及如果他透過了所有檢查,則將請求儲存到結帳佇列(Redis)。
這意味著我們能夠在幾毫秒內完成大部分O(1)操作並使用記憶體資料庫來處理結帳請求,而不必擔心併發性和一致性。
  • 3.充值信用卡並完成交易

現在,每個商品只有一個佇列,所有嘗試或當前嘗試購買該商品的使用者都按順序儲存在這些佇列中,我們擁有“世界上所有時間”來非同步處理它們。
只要您保證沒有一個佇列同時由多個工作程式處理,我們就可以根據需要擁有任意數量的伺服器,並使用一個自動伸縮組根據需要進行伸縮,以處理所有佇列。
這種簡單的體系結構使我們可以嘗試從信用卡中收取費用,甚至還可以留出一些空間用於重試和其他支票。
如果佇列中的第一個使用者由於CC問題或其他問題而失敗,我們可以繼續進行到佇列中的下一個使用者。
它還使我們能夠以完全相同的方式處理唯一的商品條目或帶有庫存的商品條目(只需將庫存設定為1)。
 

技術挑戰

  • 負載均衡器

如果您使用的是AWS託管的負載均衡器,則可能會遇到問題。在開始下降之前,與開始時相比,流量將非常低。這意味著您的負載均衡器僅分配了幾個計算單元(節點),並且在DROP期間需要幾分鐘的時間才能擴充套件到所需的流量。
好吧,我們沒有幾分鐘,對吧?…並且我們也不希望我們的負載均衡器向使用者返回502錯誤。
我們在這裡至少有兩個選擇。在丟棄開始之前,使用模擬流量(例如,使用lambda)為您的Load Balancer熱身,或者使用HAProxy執行您自己的Load Balancer群集。
兩者都是有效的,這取決於您的團隊規模和使用這些系統的經驗。
第三種選擇是與AWS聯絡,以便他們可以預熱LB,但是由於這是手動過程,因此我不建議這樣做。
  • Redis可伸縮性

關於我們的Redis,如果您開始有大量的專案和/或使用者參與,最好擴充套件一下。
最好的方法是採用多寫節點(叢集模式)方法,而不要使用主/副本體系結構。這主要是為了避免滯後問題。請記住,我們希望保持一致性而不需要太多的程式碼複雜性。主動-主動的多區域挑戰性很大且昂貴,但有時這是唯一的選擇。
您可以使用模或確定性雜湊函式在這些節點上分配放置項。
在這裡使用非同步非常重要。這樣,我們的總延遲將僅是來自Redis節點的最大延遲,而不是來自所有已使用節點的所有延遲的總和。
在我們的用例中,將專案ID用作分割槽鍵效果很好,因為負載將均勻分佈在整個鍵空間中。
 

Redis主-主挑戰
Redis擴充套件到世界上多個資料中心區域時的主要問題

  • CAP定理
  • 區域間的一致性
  • 管理多個部署
  • 不再能夠使用簡單的Redis LIST保持一致性
  • 成本
  • 多區域故障轉移
  • 複雜

每次我們需要處理分散式系統時,我們都需要按照CAP定理進行選擇/妥協。我們的兩個主要要求是“可用性”和“一致性”。我們需要確保我們不會過度銷售我們的商品,因為它們中的大多數庫存都有限。最重要的是,人們將賭注押在一個特定的專案上,這意味著如果我們以後告訴他們所買的鞋子畢竟不可用,他們就不會接受。
實際上,這意味著如果某個區域無法與主要區域聯絡,它將阻止“結帳”。這也意味著,如果主要區域處於離線狀態,則每個區域都將無法正常工作。
我們決定從CAP定理中獲得A和C,但是不幸的是,這並不意味著我們免費獲得了它們,只是因為我們放棄了P的要求……
如果僅使用Cassandra或CockroachDB,我們就能擺脫困境!
借鑑Google Spanner及其外部一致性概念的啟發,我們決定“使用原子鐘和GPS ”作為我們的真理來源。幸運的是,AWS釋出了一項名為Time Sync的免費服務,非常適合我們的用例!
使用此AWS服務,我們全球的所有計算機都是同步的。
依靠機器時鐘可以消除在交易過程中從API獲取時間戳的需要,從而減少了等待時間,並且無需安裝斷路器(就像處理外部呼叫時一樣)。
當訂單到達時,我們只需要獲取當前時間並將其傳送到主Redis例項即可。使用非同步模型對於處理這些請求非常重要,因為“遠端”請求可能需要一段時間才能完成,從而在嘗試服務於數千個併發請求時可能會造成瓶頸。
以前,我們使用每個專案一個Redis LIST來保留“訂單訂單”。既然我們正在使用時間戳記來保持我們在全球範圍內的一致性,那麼LIST不再是最好的資料結構。…除非您想在每次收到亂序的資料包或需要彈出第一個訂單時執行O(N)操作時都重新洗牌。

排序集(ZSET)可以解救!
使用時間戳記作為分數,Redis將為我們保持一切正常。將新訂單新增到專案非常簡單:

ZADD orders:<item_id> <timestamp> <user_token>

要檢視每個商品的訂單,您可以:

ZPOPMIN orders:<item_id>
 

成本
執行多區域設定通常會比較昂貴,我們的情況也不例外。
在全球範圍內複製PostgreSQL,Redis和EC2並不便宜,這迫使我們迭代解決方案。
最後,我們需要了解我們需要最佳化的地方以及可以妥協的地方。我認為這種雙重性幾乎適用於所有情況。
需要最低延遲的使用者流正在載入應用程式並在佇列中下訂單。其他所有內容都相對滯後。這意味著我們可以專注於該路徑,而對其他使用者互動的要求則不那麼嚴格。
我們所做的最重要的事情是刪除了本地PostgreSQL例項,調整了後端以僅將Redis用於關鍵路徑,並容忍最終的一致性,以便我們可以擺脫它。這也有助於降低API計算需求(雙贏!)。
我們還為API和非同步工作程式使用了AWS競價型例項
最後,我們最佳化了PostgreSQL叢集,以使用比平常小的例項來擺脫困境。
總而言之,我們的Redis被用作:

  • 快取
  • 結帳佇列
  • 非同步任務佇列
  • 使用通道的Websockets後端

這代表了在成本和整體複雜性上的大量節省。
 

多區域故障轉移
多區域故障轉移可以採用不同的形式。不幸的是,沒有一個可以稱為完美。我們將需要選擇要折衷的地方:

  • RTO(恢復時間目標)
  • RPO(恢復點目標)
  • 成本
  • 複雜

每個用例都會有所不同,解決方案几乎總是“視情況而定”。
對於此用例,我們使用了3種機制:
  • RDS自動備份-多區域單一區域時間點恢復
  • 使用AWS Lambda的指令碼化手動快照-多區域恢復
  • 跨區域只讀副本—多區域可用性和最佳資料永續性

為了避免大部分閒置的PostgreSQL副本造成高昂的成本,此例項是一臺較小的計算機,可以在緊急情況下進行擴充套件並升級為“主要”例項。這意味著我們需要忍受幾分鐘的離線時間,但是我覺得這是對這種用例的一個很好的折衷。
提升只讀副本通常比恢復快照更快。另外,透過這種方式丟失資料的機會也更少。
只要確保較小的例項可以跟上寫入負載即可。您可以透過監視其副本延遲來監視它(或者更好的是,為其配置警報!)。重新啟動將“修復”延遲,但是如果大小上的差異是問題的根源,則您只是在推送問題,而您應該調高例項大小。
手動快照為您提供了額外的安全性和“資料版本控制”層,但以S3儲存為代價。
AWS在這裡有一篇關於DR的非常好的文章。
最後,我建議確保一致性和永續性,即使為此過程進行一些手動互動也要付出代價。如果您有工程設計時間和能力來安全地自動化整個過程,並不時地執行該演練以確保所有自動化都是可靠的,那就去做吧。這是Netflix之類的廣告所宣傳的聖盃。
如果不是這樣,則丟失整個區域的情況極不可能發生,並且只要您有警報,就可以在不到30分鐘的時間內手動完成故障轉移到新區域的過程(如果按順序執行了上述步驟)。

相關文章