計費提取:GraphQL 內部 API 優化的故事

已發表: 2022-03-11

Toptal 工程團隊的主要優先事項之一是向基於服務的架構遷移。 該計劃的一個關鍵要素是計費提取,在該項目中,我們將計費功能與 Toptal 平台隔離開來,將其部署為單獨的服務。

在過去的幾個月裡,我們提取了功能的第一部分。 為了將計費與其他服務集成,我們同時使用了異步API(基於 Kafka)和同步API(基於 HTTP)。

本文記錄了我們為優化和穩定同步 API 所做的努力。

增量法

這是我們倡議的第一階段。 在我們全面提取賬單的過程中,我們努力以漸進的方式工作,為生產提供小的和安全的更改。 (參見關於該項目另一個方面的精彩演講的幻燈片:從 Rails 應用程序中增量提取引擎。)

起點是Toptal 平台,一個單一的 Ruby on Rails 應用程序。 我們首先在數據級別識別計費和 Toptal 平台之間的接縫。 第一種方法是用常規方法調用替換 Active Record (AR) 關係。 接下來,我們需要實現對計費服務的 REST 調用,以獲取該方法返回的數據。

我們部署了一個小型計費服務,訪問與平台相同的數據庫。 我們能夠使用 HTTP API 或直接調用數據庫來查詢帳單。 這種方法使我們能夠實現安全的回退; 如果 HTTP 請求由於任何原因(不正確的實現、性能問題、部署問題)失敗,我們使用直接調用並將正確的結果返回給調用者。

為了使轉換安全無縫,我們使用功能標誌在 HTTP 和直接調用之間切換。 不幸的是,第一次嘗試用 REST 實現的速度慢得讓人無法接受。 啟用 HTTP 時,簡單地用遠程請求替換 AR 關係會導致崩潰。 儘管我們只為相對較小比例的調用啟用了它,但問題仍然存在。

我們知道我們需要一種完全不同的方法。

計費內部 API(又名 B2B)

我們決定用 GraphQL (GQL) 替換 REST,以便在客戶端獲得更大的靈活性。 我們希望在此過渡期間做出數據驅動的決策,以便能夠預測這次的結果。

為此,我們檢測了來自 Toptal 平台(單體)的每個請求以進行計費並記錄詳細信息:響應時間、參數、錯誤,甚至它們的堆棧跟踪(以了解平台的哪些部分使用計費)。 這使我們能夠檢測熱點——代碼中發送許多請求或導致響應緩慢的地方。 然後,使用stacktraceparameters ,我們可以在本地重現問題,並為許多修復提供一個簡短的反饋循環。

為了避免生產中出現令人討厭的意外,我們添加了另一個級別的功能標誌。 我們在 API 中的每個方法都有一個標誌,以便從 REST 遷移到 GraphQL。 我們逐漸啟用 HTTP 並觀察日誌中是否出現“有問題”。

在大多數情況下,“有問題”要么是較長的(多秒)響應時間、 429 Too Many Requests502 Bad Gateway 。 我們採用了幾種模式來解決這些問題:預加載和緩存數據、限制從服務器獲取的數據、添加抖動和速率限制。

預加載和緩存

我們注意到的第一個問題是從單個類/視圖發送的大量請求,類似於 SQL 中的 N+1 問題。

Active Record 預加載無法在服務邊界上工作,因此,我們有一個頁面在每次重新加載時發送約 1,000 個計費請求。 來自單個頁面的一千個請求! 一些後台工作的情況也好不到哪裡去。 我們寧願提出數十個請求,而不是數千個。

其中一個後台作業是獲取作業數據(我們將此模型稱為Product )並檢查是否應根據計費數據將產品標記為非活動狀態(對於本示例,我們將調用模型BillingRecord )。 儘管產品是分批獲取的,但每次需要時都會請求計費數據。 每個產品都需要計費記錄,因此處理每個產品都會導致計費服務請求獲取它們。 這意味著每個產品一個請求,並且導致從單個作業執行發送大約 1,000 個請求。

為了解決這個問題,我們添加了賬單記錄的批量預加載。 對於從數據庫中提取的每批產品,我們請求一次計費記錄,然後將它們分配給相應的產品:

 # fetch all required billing records and assign them to respective products def cache_billing_records(products) # array of billing records billing_records = Billing::QueryService .billing_records_for_products(*products) indexed_records = billing_records.group_by(&:product_gid) products.each do |p| e.cache_billing_records!(indexed_records[p.gid].to_a) } end end

以 100 個批次和每批次對計費服務的一個請求,我們從每個作業約 1,000 個請求增加到約 10 個。

客戶端連接

當我們有一系列產品並且我們需要它們的計費記錄時,批處理請求和緩存計費記錄效果很好。 但是反過來呢:如果我們獲取賬單記錄,然後嘗試使用他們各自的產品,從平台數據庫中獲取?

正如預期的那樣,這導致了另一個 N+1 問題,這次是在平台方面。 當我們使用產品收集 N 條計費記錄時,我們正在執行 N 條數據庫查詢。

解決方案是一次獲取所有需要的產品,將它們存儲為按 ID 索引的哈希值,然後將它們分配給各自的計費記錄。 一個簡化的實現是:

 def product_billing_records(products) products_by_gid = products.index_by(&:gid) product_gids = products_by_gid.keys.compact return [] if product_gids.blank? billing_records = fetch_billing_records(product_gids: product_gids) billing_records.each do |billing_record| billing_record.preload_product!( products_by_gid[billing_record.product_gid] ) end end

如果您認為它類似於哈希聯接,那麼您並不孤單。

服務器端過濾和提取不足

我們在平台方面戰勝了最嚴重的請求高峰和 N+1 問題。 不過,我們的反應仍然很慢。 我們確定它們是由於向平台加載過多數據並在那裡過濾(客戶端過濾)引起的。 將數據加載到內存、對其進行序列化、通過網絡發送以及反序列化只是為了丟棄大部分數據,這是一種巨大的浪費。 這在實現過程中很方便,因為我們有通用且可重用的端點。 在操作過程中,它被證明無法使用。 我們需要更具體的東西。

我們通過向 GraphQL 添加過濾參數來解決這個問題。 我們的方法類似於眾所周知的優化,包括將過濾從應用程序級別移動到數據庫查詢( find_all與 Rails 中的where )。 在數據庫世界中,這種方法是顯而易見的,並且可以用作SELECT查詢中的WHERE 。 在這種情況下,它需要我們自己實現查詢處理(在 Billing 中)。

我們部署了過濾器並等待看到性能改進。 相反,我們在平台上看到了 502 錯誤(我們的用戶也看到了)。 不好。 一點都不好!

為什麼會這樣? 這種變化應該提高響應時間,而不是中斷服務。 我們無意中引入了一個微妙的錯誤。 我們在客戶端保留了 API 的兩個版本(GQL 和 REST)。 我們使用功能標誌逐漸切換。 我們部署的第一個不幸的版本在遺留 REST 分支中引入了回歸。 我們將測試集中在 GQL 分支上,因此我們錯過了 REST 中的性能問題。 經驗教訓:如果缺少搜索參數,則返回一個空集合,而不是數據庫中的所有內容。

查看 Billing 的NewRelic數據。 我們在流量停滯期間通過服務器端過濾部署了更改(我們在遇到平台問題後關閉了計費流量)。 您可以看到部署後響應更快且更可預測。

圖片:計費服務的 NewRelic 數據。部署後響應速度更快。

將過濾器添加到 GQL 模式並不難。 GraphQL 真正大放異彩的情況是我們獲取了太多字段而不是太多對象的情況。 使用 REST,我們發送了所有可能需要的數據。 創建一個通用端點迫使我們將其與平台上使用的所有數據和關聯打包在一起。

使用 GQL,我們能夠選擇字段。 我們沒有獲取需要加載多個數據庫表的 20 多個字段,而是只選擇了需要的三到五個字段。 這使我們能夠在平台部署期間消除突然的計費使用高峰,因為其中一些查詢被部署期間運行的彈性搜索重新索引作業使用。 作為一個積極的副作用,它使部署更快、更可靠。

最快的請求是你不提出的請求

我們限制了獲取對象的數量和打包到每個對像中的數據量。 我們還能做什麼? 也許根本不獲取數據?

我們注意到另一個有改進空間的地方:我們經常使用平台中最後一個計費記錄的創建日期,並且每次都調用計費來獲取它。 我們決定,與其在每次需要時同步獲取它,不如根據計費發送的事件緩存它。

我們提前計劃,準備好任務(其中四到五個),並開始努力盡快完成,因為這些請求會產生很大的負載。 我們還有兩週的工作要做。

幸運的是,在我們開始後不久,我們重新審視了這個問題,並意識到我們可以使用平台上已經存在但形式不同的數據。 我們沒有添加新表來緩存來自 Kafka 的數據,而是花了幾天時間比較來自計費和平台的數據。 我們還諮詢了領域專家是否可以使用平台數據。

最後,我們用數據庫查詢替換了遠程調用。 從性能和工作負載的角度來看,這是一個巨大的勝利。 我們還節省了一周多的開發時間。

圖片:使用數據庫查詢而不是遠程調用的性能和工作負載。

分配負載

我們正在一一實施和部署這些優化,但仍然存在計費響應429 Too Many Requests的情況。 我們本可以增加對 Nginx 的請求限制,但我們想更好地理解這個問題,因為它暗示通信沒有按預期運行。 您可能還記得,我們​​可以承受生產中的這些錯誤,因為它們對最終用戶不可見(因為回退到直接調用)。

該錯誤發生在每個星期天,當平台安排提醒人才網絡成員有關過期時間表時。 為了發送提醒,作業會獲取相關產品的計費數據,其中包括數千條記錄。 我們優化它的第一件事是批處理和預加載計費數據,並僅獲取必填字段。 兩者都是眾所周知的技巧,因此我們不會在這裡詳細介紹。

我們部署並等待下一個星期天。 我們確信我們已經解決了這個問題。 然而,在周日,錯誤再次出現。

計費服務不僅在日程安排期間被調用,而且在向網絡成員發送提醒時也被調用。 提醒在單獨的後台作業中發送(使用 Sidekiq),因此預加載是不可能的。 最初,我們認為這不是問題,因為並非每個產品都需要提醒,而且提醒都是一次性發送的。 提醒安排在網絡成員所在時區的下午 5 點。 但是,我們錯過了一個重要的細節:我們的成員並非均勻地分佈在不同時區。

我們為成千上萬的網絡成員安排了提醒,其中大約 25% 的人生活在一個時區。 大約 15% 的人生活在人口第二多的時區。 當時鐘在這些時區下午 5 點滴答作響時,我們不得不一次發送數百條提醒。 這意味著向計費服務發出數百個請求,這超出了該服務的處理能力。

無法預加載帳單數據,因為提醒是在獨立作業中安排的。 我們無法從計費中獲取更少的字段,因為我們已經優化了該數字。 將網絡成員轉移到人口較少的時區也是不可能的。 那麼我們做了什麼? 我們移動了提醒,只是一點點。

我們在安排提醒的時間添加了抖動,以避免所有提醒將在完全相同的時間發送的情況。 我們沒有安排在下午 5 點整,而是安排在下午 5:59 到下午 6:01 之間的兩分鐘範圍內。

我們部署了服務並等待下週日,確信我們最終解決了問題。 不幸的是,週日,錯誤再次出現。

我們很困惑。 根據我們的計算,請求應該分佈在兩分鐘的時間內,這意味著我們每秒最多有兩個請求。 這不是服務無法處理的事情。 我們分析了計費請求的日誌和時間,我們意識到我們的抖動實現不起作用,所以請求仍然出現在一個緊湊的組中。

圖片:由於抖動實現不充分導致的大量請求。

是什麼導致了這種行為? 這是 Sidekiq 實現調度的方式。 它每 10-15 秒輪詢一次 redis,因此,它無法提供一秒的分辨率。 為了實現請求的均勻分佈,我們使用Sidekiq::Limiter ——Sidekiq Enterprise 提供的一個類。 我們使用了窗口限制器,該窗口限制器允許八個請求移動一秒窗口。 我們選擇該值是因為我們的 Nginx 限制為每秒 10 個請求的計費。 我們保留了抖動代碼,因為它提供了粗粒度的請求分散:它在兩分鐘內分發 Sidekiq 作業。 然後使用 Sidekiq Limiter 來確保每組作業的處理都不會超出定義的閾值。

我們再次部署它並等待週日。 我們有信心最終解決了這個問題——我們做到了。 錯誤消失了。

API 優化:Nihil Novi Sub Sole

我相信您對我們採用的解決方案並不感到驚訝。 批處理、服務器端過濾、僅發送必填字段和速率限制都不是新技術。 經驗豐富的軟件工程師無疑會在不同的環境中使用它們。

預加載以避免 N+1? 我們在每個 ORM 中都有它。 哈希連接? 甚至 MySQL 現在也有它們。 不足取? SELECT * vs. SELECT field是一個已知的技巧。 分擔負載? 這也不是一個新概念。

那麼我為什麼要寫這篇文章呢? 為什麼我們一開始就沒有做好呢? 像往常一樣,上下文是關鍵。 許多這些技術只有在我們實現它們之後或者只有在我們注意到需要解決的生產問題時才看起來很熟悉,而不是在我們盯著代碼時。

對此有幾種可能的解釋。 大多數時候,我們都在嘗試做最簡單的事情來避免過度設計。 我們從一個無聊的 REST 解決方案開始,然後才轉向 GQL。 我們在功能標誌後面部署了更改,監控了一小部分流量的所有行為,並根據實際數據應用了改進。

我們的一個發現是重構時性能下降很容易被忽視(提取可以被視為重要的重構)。 添加嚴格的邊界意味著我們切斷了為優化代碼而添加的聯繫。 不過,在我們測量性能之前,這一點並不明顯。 最後,在某些情況下,我們無法在開發環境中重現生產流量。

我們努力為計費服務提供一個通用 HTTP API 的小表面。 結果,我們得到了一堆通用端點/查詢,這些端點/查詢承載了不同用例所需的數據。 這意味著在許多用例中,大部分數據都是無用的。 這是 DRY 和 YAGNI 之間的一個折衷:使用 DRY,我們只有一個端點/查詢返回計費記錄,而使用 YAGNI,我們最終會在端點中得到只會損害性能的未使用數據。

在與計費團隊討論抖動時,我們還注意到另一個權衡。 從客戶端(平台)的角度來看,每個請求都應該在平台需要時得到響應。 性能問題和服務器過載應該隱藏在計費服務的抽象背後。 從計費服務的角度來看,我們需要想辦法讓客戶端了解服務器的性能特徵來承受負載。

同樣,這裡沒有什麼是新穎或開創性的。 它是關於識別不同上下文中的已知模式並理解變化帶來的權衡。 我們已經從艱難的道路上學到了這一點,我們希望我們已經讓您免於重複我們的錯誤。 毫無疑問,您將自己犯錯誤並從中吸取教訓,而不是重複我們的錯誤。

特別感謝參與我們工作的同事和隊友:

  • 馬卡爾·埃爾莫欣
  • 加布里埃爾·倫齊
  • 塞繆爾·維加·卡瓦列羅
  • 盧卡·圭迪