DB 是怎麼撐起 8 億 user 和每秒數百萬個查詢?
前陣子看到 OpenAI 發表了一篇技術文章,詳細說明了他們是怎麼擴展 DB 來支撐 8 億個 ChatGPT user,原文請見這裡。讀完有種的親切的熟悉感,同時也覺得他們很厲害,因為他們沒有用很多酷炫的新技術,而是盡可能將手上有的工具發揮 (榨乾) 到極致,不斷優化現有架構來突破困境。
文中提到現在 DB 系統的表現是:每秒可以處理數百萬次查詢,足以應付來自全世界的大量需求;全球將近 50 個 replica 的 replication lag 幾乎是 0,表示不管在地球哪個角落看到的資料都是最新的;p99 客戶端延遲約 10~30 毫秒,代表 user 體驗上有 99% 的請求都比眨眼還要快得到回應;以及整體 可用性是五個 9 (99.999%),可以理解成系統一年的停機時間不超過 5 分鐘。
OpenAI 目前的 DB 架構屬於 single-primary 設計,也就是 single-leader replication:由一個 Azure PostgreSQL 主資料庫負責所有的 寫入 請求,以及將近 50 個 read replicas 分散在全球、負責 讀取 請求。因為 ChatGPT 和他們的 API 的讀寫比例是以讀取為主 (read-heavy workloads),replication 可以很好的應付大量讀取需求。
ChatGPT 推出後,流量成長的極快,他們已經為此做了很多 DB 優化,包括 垂直擴展:使用 Azure 最頂規的機器,以及 水平擴展:replication 和 sharding,前者是增加更多的 read replica、後者是將新的可分片的頻繁寫入負載轉移到 Cosmos DB。值得注意的是,PostgreSQL 本身沒有分片,因為對主資料庫分片的過程很複雜又花時間,像是需要更動數百個 application endpoint,可能需要數月甚至數年,所以短期內他們不這麼做。
雖然在處理讀取流量上,整體架構擴展的很不錯,但由於 PostgreSQL 本身的設計上使用了 MVCC (多版本並發控制) 技術,這會讓它處理大量寫入請求的效率比較差。在 MVCC 實作下,DB 必須保存資料的多個不同版本,所以即使只是修改了一個欄位,PostgreSQL 也會複製整筆資料來建立新版本。當寫入流量飆高,就會出現顯著的 寫入放大、讀取放大、table 和 index 膨脹等各種問題。加上前面提到,OpenAI 的架構設計又只有一個主資料庫來處理寫入請求,因此即便應用程式的負載是讀取偏重,在 8 億這種規模的流量下,還是造成了一些事故 (SEV) 發生。
他們觀察這些事故,發現 Postgres 過載會引發一個惡性循環:首先,上游可能會遇到一些問題,導致 DB 負載突然暴增 (上游的問題包括:快取層故障導致大範圍 cache miss、吃資源的複雜 join query 使 CPU 飽和、或是新功能上線引發大量寫入);接著,隨著資源使用率攀升,query 的延遲增加,查詢就變得很慢,或是出現逾時 (time out),請求沒辦法在時間內完成。接下來,系統會自動 retry,但這些 retry 請求又讓負載變得更重,最後,系統就掉進可怕的循環,整個 ChatGPT 和 API 服務的效能下降,系統隨時可能失控。為了緩解 MVCC 造成的限制並降低寫入壓力,避免觸發恐怖旋渦,OpenAI 用了許多聰明的作法,來解決不同的挑戰:
1. 減少主 DB 的負載 (包括讀取與寫入)
前面提到,single-primary 的設計只有主資料庫一個寫入節點,即使機器規格升級到最頂,還是有擴展的極限所在,如果出現寫入飆升,可能會迅速使主節點過載,因此首先就是要盡量把 primary 的負載降到最低。在讀取方面,除了有些因為是寫入交易的一部分而必須留在 primary,其餘的讀取請求都交給分布在全球的 replica 處理,來盡可能的做到 讀寫分離。在寫入方面,他們持續將能分片的新的寫入搬到 Cosmos DB,而不寫進主 DB。同時也利用 lazy write 來避免在寫入尖峰當下,將所有的寫入一次塞進去,因為有些即時性需求沒那麼高的資料,可以晚點再寫入,讓 DB 不用一下子處理這麼多密集的請求。
2. 優化 Query
他們發現過去嚴重 SEV 事件的罪魁禍首,是出自某個查詢竟然 join 了 12 張 table! 這種複雜的查詢會吃掉大量 CPU,讓系統服務變的很慢。因此,讓 OLTP 專注於 OLTP 該做的事就好,不在上面進行複雜的 multi-table joins。如果必須使用 join,就把 query 拆開、將 join 邏輯移到 APP 層處理,不要為難 DB。對於必須留在 primary 的交易,他們也進行了查詢優化,避免出現 slow query。此外,也不能太依賴 ORM 框架,要小心 n+1 問題,並仔細檢查 ORM 實際產生的 SQL 語句,也許都可以再進一步優化。總之,就是要確保每一句 SQL 都是最有效率的。
3. 連線池 (Connection Pooling)
由於每一個 DB instance 能處理的連線數量是有限制的,像 Azure PostgreSQL 的上限是 5000 個,如果流量一衝上來,很容易連線數就用完了,服務就會直接中斷。因此,他們在應用程式 (user request) 和資料庫 (read replica) 的中間,透過 Kubernetes 部署了 PgBouncer 作為代理層 (proxy layer),來集中管理 DB 的連線。這個連線池,讓他們可以有效率的重複使用連線,不只大幅減少了 client 連線數量,還讓平均建立連線的時間,從 50 毫秒降到 5 毫秒!
4. 快取鎖定 (Cache Locking)
DB 快取是將頻繁存取、可重使用的資料暫存在記憶體,避免每次都去 DB 查詢。當大部分查詢請求可以從快取中提供,除了減輕 DB 的讀取壓力,也降低系統延遲,提升 (讀多) 應用程式的效能。如果應用程式向快取查不到資料 (快取未命中, cache miss),就會向 DB 查詢,而當 cache miss 增加 (比如一個極高流量的熱門資料 (Hot Key) 在快取中過期或被刪除),大量請求就會直接推送到 PostgreSQL,又導致 CPU 被塞爆,服務速度變慢。為了處理這種踩踏事件 (cache stampede),他們使用了快取鎖定機制,當同一個 key 上有多個請求發生 cache miss,也就是一堆請求都查不到同一個快取資料,這時系統只會放行 一個 請求來拿鎖並向資料庫拿資料、然後再寫回快取,而其他請求必須在旁邊等待快取更新。也就是說,DB 只會被查一次,而不是全部的請求同時衝過去,這大幅減少了重複的讀取負載。
5. 負載隔離 (Workload Isolation)
當新功能上線時,可能會出現效率不好的查詢,而這些請求會消耗太多資源,導致在同個 instance 上執行的其他業務功能變的很慢,這就是所謂的 吵雜鄰居 問題 (常見於 multi-tenant 環境,因為某個 user 獨佔了過多共用資源如 CPU、memory、頻寬,導致其他 user 的效能變差)。為了確保某一產品的活動,不會影響其他產品的效能和可靠性,他們把 request 分成了高優先度和低優先度,並分別路由到不同的 DB instance,讓那些高優先度、最重要的請求,有特定的專屬 DB。這樣一來,即使新功能上線出現了很吃資源的請求,也只會影響到低優先度的 DB,而不會影響那些重要請求的效能。
文中還有提到其他解決方案,包括設置 hot standby 來避免單點故障 (因為架構上只有一個 writer),hot standby 就是一個持續同步的 replica,當主 DB 倒下或需要維護,它可以馬上代替成為主節點,讓服務繼續運行達到高可用;還有在多個層面執行 rate limit (包括 application, connection pooler, proxy, query),限制一個用戶、IP、或 entity 在特定時間範圍內,可以向服務發送請求的數量上限,來避免巨大流量快速消耗伺服器的資源;最後,他們在 schema change 上做了嚴格的管理,比如只允許不會重寫整張 table 的輕量操作,而且這些操作只要超過 5 秒就會自動 timeout,以及執行 backfilling 時也用了 rate limit,雖然很花時間,但可以避免寫入高峰影響到 production。
即便現在的成果已經很驚人,OpenAI 早就在爲下一步做準備,他們與 Azure 正在合作開發 cascading replication 技術,也在評估要不要對主資料庫 PostgreSQL 進行分片。在這個每天都有新技術冒出來的世界,他們選擇了深度優化系統,不斷挑戰 DB 效能的極限,將我們熟悉的工具發揮的淋漓盡致。
Reply by Email
