Inside Hyperdrive:我們如何解決 Hyperdrive MySQL 快取的罕見錯誤
Inside Hyperdrive: How we solved a rare Hyperdrive MySQL caching bug
Hyperdrive 現已正式釋出 MySQL 支援,並修正一個罕見的快取錯誤,提升連線穩定率至 99.99%。
- 功能更新:新增 MySQL 協議支援,與 Postgres 一樣可在 Cloudflare Workers 上使用連線池與全域 CDN 快取。
本篇說明 Hyperdrive MySQL GA 及關鍵 bug 修正,讀者可瞭解如何提升連線穩定與效能。
Hyperdrive 支援 MySQL 現已正式推出(GA)。Hyperdrive 是 Cloudflare 為 Workers 提供的資料庫連線池,最初僅支援 Postgres。自從我們在去年宣佈MySQL 協定支援的測試版以來,我們一直在努力將 MySQL 的處理方式做到與 Postgres 同樣可靠。
我們從在 Cloudflare 網路上構建並執行 MySQL 連線池中學到了不少。 結果顯示,測試版與正式版之間的差異只是在一層「外套」中修復了 20 個錯誤。
在測試版期間,我們的目標是驗證實際客戶使用情境是否達到 99.99% 的生產可用性目標。由於我們會追蹤來自 Workers 的客戶端連線何時因 Hyperdrive 相關錯誤結束,我們知道可靠性上仍有缺口。除此之外,測試版客戶的回饋也讓我們發現了生產環境中一個罕見且微妙的錯誤。為了說明這個錯誤的複雜性,我們首先需要解釋 Hyperdrive 的工作原理。
Hyperdrive 是什麼?
Hyperdrive 是一個分散式資料庫連線池,將 Cloudflare Workers 連線至 SQL 資料庫。Hyperdrive 會在我們的全球網路中保持熱連線,並將這些連線分享給多個 Worker 請求。除了連線池功能外,Hyperdrive 還能將常用查詢快取至我們的全球 CDN 快取,降低延遲與來源資料庫負載。
建立到 SQL 資料庫的連線成本高,至少需要五次網路往返。對於像 Workers 這樣的無伺服器計算平臺,為每個請求建立連線將不切實際,因為它會增加每個請求的延遲,且大量開啟的連線會產生高昂的每連線資源成本。雖然 MySQL 在這方面比 Postgres 更好,因為 MySQL 不是為每個連線使用整個作業系統程序,但典型部署仍可能因大量連線而遇到資源問題。
欲瞭解更多細節,請參閱我們介紹 Hyperdrive 的部落格文章。
通往穩定的旅程
我們遇到的一些問題很小,報告後也很容易修復。Beta 版第一個報告的問題之一是寫入資料庫的表情符號未能正確回傳。這是與字元編碼相關的錯誤。Hyperdrive 在啟動握手時協商連線的編碼,我們協商 utf8 作為編碼。結果發現 utf8 是 已棄用的別名 為 utf8mb3 ,並不是完整的 UTF‑8:它只支援每個 Unicode 程式碼點 1–3 個位元組,而完整的 UTF‑8 每個程式碼點最多 4 個位元組。要獲得完整 UTF‑8,我們需要將握手改為使用 utf8mb4 ,這解決了表情符號問題。
另一個與編碼相關的錯誤發生在我們透過 COM_RESET_CONNECTION 加入連線清理時。這是一個輕量級的 MySQL 指令,用於清除指定連線中的任何會話狀態,正是 Hyperdrive 所需的,以確保在檢出之間不會洩漏任何會話層級的狀態。但加入此連線清理步驟再次造成編碼問題!事實上,COM_RESET_CONNECTION 並未將連線狀態重設為我們在連線啟動時協商的字元編碼;它會將連線重設為伺服器預設值。這意味著在與一個預設為 latin1 的 MySQL 伺服器例項協商 utf8mb4 後,一旦執行 COM_RESET_CONNECTION,我們的連線現在編碼為 latin1。解決方案是每次連線重置後都執行一個將編碼設為 utf8mb4 的指令。
難以偵測的錯誤
在測試版期間,我們最高交易量的 MySQL 客戶報告了少量但持續的空查詢回應,這些回應本應始終有非空結果。我們開始調查,先檢視所有的遠端監控資料並瞭解使用者的邏輯。接著我們提高了此 Hyperdrive 例項的追蹤頻率,希望能看到此錯誤的執行流程。我們搜尋了可能解釋此問題的網路故障,但似乎沒有任何東西能說明此行為。此錯誤大約發生 0.001% 的時間,使得尋找相關的端到端執行追蹤變得非常困難。
但我們確實有一個線索:這個錯誤僅發生在啟用快取的 Hyperdrive 例項上。這讓我們懷疑 Hyperdrive 的 MySQL 快取存在問題。
Hyperdrive 快取
為了了解 Hyperdrive 的快取,我們首先需要談談 Hyperdrive 的拓撲結構以及它如何與 CDN 快取互動。每個 Hyperdrive 例項都有一個分離式架構,包含兩個節點:Client 和 Endpoint。Client 節點在與使用者的 Worker 同一臺機器上執行,處理本機快取(如已啟用),並在需要時將查詢轉發到 Endpoint。Endpoint 節點負責連線池,並部署在離原始資料庫最近的資料中心。每個節點都使用其所在資料中心本地的 Cloudflare CDN 快取。重要的是,這意味著如果兩個節點在同一個資料中心執行,它們將共享快取。
在 Client 與 Endpoint 之間共享快取通常對效能有利。我們使用查詢線路訊息內容的雜湊值並做一定的正規化,將結果位元組存入快取。如果一個在與其 Endpoint 相同資料中心執行的 Hyperdrive Client 接收到請求,而該 Endpoint 最近已快取相同查詢的結果,Client 就可以直接從共享的資料中心快取提供回應。為了正確運作,此方案需要同一回應的相同位元組被快取,無論是由 Client 或 Endpoint 儲存。
MySQL 客戶端/伺服器協定
由於無法直接追蹤此問題,我們決定花時間研究 MySQL 客戶端/伺服器協定。也許我們在 MySQL 處理中內建了對 Postgres 協定的假設,導致了這些問題。
此協定說明 MySQL 客戶端如何與 MySQL 伺服器通訊。在資料庫連線池的情境下,會有兩個扮演客戶端、兩個扮演伺服器,會稍顯混淆。第一個客戶端是使用 MySQL 客戶端函式庫的 Worker,連線至第一個扮演伺服器的 Hyperdrive Client。Hyperdrive Client 再連線至 Hyperdrive Endpoint,該 Endpoint 充當 MySQL 客戶端,連線至實際的 MySQL 原始伺服器。
此協定以 連線階段 開始,MySQL 客戶端與 MySQL 伺服器在此階段同意共同的認證方式與功能。連線建立後,客戶端與伺服器進入 命令階段,此時客戶端送出指令(例如 SQL 語句)給伺服器執行,伺服器回應並等待下一個指令。
在連線階段交換功能旗標,以支援協定演進且保持向後相容。每個功能旗標代表一項功能,例如在命令階段支援壓縮。客戶端與伺服器只能使用對方支援的功能,否則可能發生協定不同步,導致不可預期的行為。
對於 Hyperdrive 來說,這種功能協商可能相當棘手,因為 Hyperdrive 同時扮演原始資料庫的客戶端與 Worker 的伺服器。為確保 Hyperdrive 能解析原始 MySQL 伺服器的訊息,Hyperdrive 內部使用一組固定的功能旗標來與原始資料庫通訊。若 Hyperdrive 的功能旗標與 Worker 客戶端的旗標不一致,Hyperdrive 必須將所有以其旗標編碼的原始回應轉換為 Worker 所期望的旗標。
在除錯此問題時,我們發現一個流行的 Node.js MySQL 客戶端函式庫(mysql2)目前不支援 the CLIENT_DEPRECATE_EOF capability,該功能會改變結果集的結束方式。若不使用此功能,伺服器會在欄位定義與資料列之間傳送一個 EOF 封包,並在結果集結束時再傳送一次 EOF 封包。當協商到 CLIENT_DEPRECATE_EOF 時,資料型別 EOF 封包會被省略,最後的 EOF 封包則被 OK 封包取代。MySQL 伺服器 5.7.5 及以上版本支援此功能,但只有在客戶端協商到時才會使用新的 OK 封包行為。
雖然 Hyperdrive 支援 CLIENT_DEPRECATE_EOF 功能,但 Worker 的 MySQL 驅動程式可能不支援。為應對此情況,Hyperdrive 會調整其行為,以支援 Worker 客戶端驅動程式所期望的功能。這意味著需要重新編寫結果集以包含 EOF 封包,並在必要時將所有 OK 封包轉換為 EOF 封包。EOF 封包。
經過數天的檢視遙測資料、檔案以及 Hyperdrive 程式碼,一切終於明朗。我們終於找到了最後缺失的關鍵:Hyperdrive 客戶端快取程式碼中的一個錯誤假設。當為 Postgres 實作客戶端快取時,我們內建了一個假設:可以將回寫到 Worker 的原始位元組直接快取。雖然這在 Postgres 的線路協定下是合理的假設,但由於 MySQL 能力的不匹配,這已不再成立。若客戶端節點快取的正是它回寫給 Worker 的位元組,這些位元組的格式就與 Endpoint 節點預期的不一致。原因是 Endpoint 節點始終使用 Hyperdrive 的能力,而客戶端節點回寫的位元組則已被轉譯為符合 Worker 的能力。
將這些線索結合起來,我們就能理解 Hyperdrive 何時會以錯誤的能力編碼回傳 MySQL 響應,從而導致意外的空結果與掛起。此錯誤發生於客戶端與 Endpoint 均位於同一資料中心、共用快取,且其中一方讀取到另一方快取的值時。
以下將更詳細說明兩種情境。
Endpoint 快取,Client 讀取
- Endpoint 正在處理對查詢 Q 的請求。它從原始伺服器獲得回應 R,並將回應結果位元組 R_hyperdrive(使用 Hyperdrive 能力編碼的回應)快取到共享快取中。
- Client 接收到另一個查詢 Q 的請求,檢查共享快取並找到 R_hyperdrive。
- Client 直接以 R_hyperdrive 回應 Worker,未將其轉譯為 Worker 的能力。
以下圖示說明此流程:
Client 快取,Endpoint 讀取
- Client 正在完成對查詢 Q 的請求處理,並將已轉譯為 Worker 能力的回應位元組 R_worker 快取。
- Endpoint 正在處理查詢 Q 的請求,並檢查快取以尋找 R_worker。
- Endpoint 將 R_worker 傳送給另一個 Client,該 Client 企圖將 R_worker 轉譯為 Worker 的能力,最終導致傳送早期 EOF 或掛起。
你可以在此看到:
一旦發現此類失效模式,我們的修正非常簡單。我們現在確保無論哪個節點儲存,快取中始終寫入以 Hyperdrive 能力編碼的查詢結果位元組的標準版本。更改發布後,空行問題消失,Client 連線成功率提升至 99.99%。
可靠性即功能
您可以立即使用 Hyperdrive 對 MySQL 的支援!透過儀錶板或 wrangler 為現有的 MySQL 相容資料庫建立 Hyperdrive,或連線到新的 PlanetScale 資料庫。若遇到任何問題或有疑問,請於我們的 Discord 聯絡我們。
npx wrangler hyperdrive create HYPERDRIVE_NAME --connection-string="mysql://user:password@db-host.example.com:3306/dbname"