vatt'ghern jaskier's ballads

ZippyDB 原本的存取模型讓每個 client 直接連上它要用到的每一台資料庫主機——fan-in 是 client 母體數乘上一個機率,母體一多,機隊裡每台主機都跟著變差。Meta 在資料庫前面插了一層 proxy 之後,這條公式被整條換掉:client 母體數直接從式子裡消失,剩下的變數換成他們自己就能調的 shard density。

資料庫前面加一層,連線的扇入不再跟著 client 數長

ZGateway 是 Meta 放進 ZippyDB 存取路徑裡的一層 stateless proxy,站在成千上萬個 client 與資料庫(ZServer)機隊之間,內部跑的還是 Meta 既有的那套 thick C++ client,只是換了個地方跑、每個 use case 各自一份。它現在扛下大約 40% 的 ZippyDB 流量,Meta 預期這個比例會超過 60%,尖峰吞吐超過每秒 10 億次操作——比例只會愈墊愈高,代表 ZippyDB 這個資料庫的整體可靠度,愈來愈綁在 ZGateway 這一層的正確性上,不是綁在資料庫本身。這篇要拆的不是「加一層快取讓查詢變快」這種常見故事,而是一條連線公式的改寫:direct-access 模型下,一台資料庫主機要接的連線數是 client 母體數乘上一個機率,兩者成線性關係;插入 ZGateway 之後,這條公式裡不再含 client 母體數這一項,剩下能調的變數換成 Meta 自己就管得到的東西。

request 路徑:client 只認得 ZGateway,不再認得任何一台資料庫主機

在 direct-access 模型裡,Meta 自己的說法很直接:「every ZippyDB client connects to every database host it needs」——一個 client 要用到多少台資料庫主機,就跟多少台各自維持連線。規模有多大:一個典型 client 平均掛著數萬條 outbound 連線,一台典型資料庫主機則能接住數萬條 inbound 連線——後者是原文講的容量上限,不是常態下的實測平均值。這不是理論上的邊界情況——團隊在文章裡寫:「a routing bug led every client to open a connection per shard, hosts breached their file-descriptor limit, and the fleet fell into a reboot loop.」一個 routing bug,讓每個 client 對每個 shard 各開一條連線,資料庫主機直接撞穿上限,整個機隊掉進重開機的死亡循環。這種故障模式的麻煩之處在於它不需要流量真的暴增,只需要連線這件事本身的拓樸算錯一步。檔案描述符的上限是作業系統層級的資源,一旦被打穿,程序連新的連線都建不起來,連自我修復用的健康檢查、監控代理都可能撞在同一個限制上——這也是為什麼一個小小的 routing bug 能演變成整個機隊的重開機循環,而不是幾台主機的單點故障。連線管理這一層出問題的殺傷力,往往比查詢本身變慢更難收拾:TCP handshake、TLS handshake、每條連線佔用的核心記憶體,都是在真正碰到資料之前就已經付出的成本,這筆帳在機隊規模變大之後最先浮現。

點擊任一方塊查看責任邊界 · 4 個元件

client 只連 ZGateway——ServiceRouter 只負責告訴它連哪一個 ServiceRouter discovery,不進資料路徑 client ZGateway TLS · admission control · batch/cache ZServer 虛線是 discovery,實線才是真正的 request 資料路徑

click a component

ServiceRouter · 責任邊界

Meta 內部的 hyperscale service mesh。ZGateway 以 regional tier 的型態透過 ServiceRouter 被發現,讓每個 client 都能找到離自己最近的 gateway。

不負責的事:request 的內容、認證、要打到哪個 shard——它只回答「你該連哪個 regional tier」,不出現在真正的資料路徑上。

client · 責任邊界

握著一條 sticky connection,把 request 送到自己 regional 的 ZGateway host,不再直接摸任何一台資料庫主機。

不負責的事:TLS、admission control、要不要跟別的 request 併批,全部搬進 ZGateway,client 端的程式碼不用管。

ZGateway · 責任邊界

拿到 request 先 terminate TLS、對照 use case 的 ACL 做授權,再套用 per-tenant admission control、驗證與 shaping;內部引擎是 Meta 既有的 thick C++ client,每個 use case 各自跑一份。

不負責的事:自己不持久化資料——shard 的實際狀態仍在 ZServer,ZGateway 只是路徑,不是家。

ZServer · 責任邊界

Meta 稱它是「the database (ZServer) fleet」——實際存 shard 資料的那一層。

不負責的事:它收到的已經是併過批的 RPC,不知道背後原本有幾個 client 各自發了請求。

ZGateway 站在 client 與 ZServer 之間,是一層獨立部署的機隊。它以 regional tier 的型態透過 ServiceRouter 被發現——Meta 內部的 hyperscale service mesh——讓每個 client 就近連到自己的 gateway。request 送到之後,ZGateway 先 terminate TLS,對照 use case 的 ACL 做授權,再套用 per-tenant admission control、驗證與 shaping,才決定往哪個 ZServer 送。前面提到內部引擎「一個 use case 一份」的安排,代表不同 use case 之間彼此隔離:一個 use case 的 client 邏輯出狀況,理論上不會直接污染另一個 use case 的連線狀態,即便它們共用同一台 ZGateway host 的作業系統資源。「就近連到自己的 gateway」這件事本身也是一種延遲管理——request 在 client 端多繞一段路由邏輯可以接受,但如果每個 request 都要跨區域才找得到一台 ZGateway host,這一跳的延遲成本會直接疊加在使用者感受得到的路徑上,regional tier 的設計把找路的成本壓在 client 跟離自己最近的 gateway 之間,不會隨著要打的 shard 分佈在哪個 region 而變動。client 端因此只需要維持一條到 regional ZGateway host 的 sticky connection,不再需要知道任何一台資料庫主機的位址,也不需要自己處理連線數量隨規模成長這件事——那件事現在是 ZGateway 的問題,不是每一個呼叫端各自的問題。換句話說,擴充 gateway 容量變成 Meta 單方面就能做的事:多開幾台 ZGateway host、讓 ServiceRouter 把新的 regional tier 也納入探索範圍,client 端不需要跟著改一行程式碼,因為它們原本就只認得「離自己最近的 gateway」這個抽象,不認得任何一台實際的資料庫主機。

扇入公式:把 client 母體數從等式裡拿掉

Meta 把 direct-access 模型的連線負擔寫成一條機率公式:一台資料庫主機被某個 client 摸到的機率是 p(B) = 1 - e^(-B/H)——H 是資料庫主機總數、B 對應到單一 client 平均要摸的 shard 數。原文接著用一組「round mock figures」示範這個模型怎麼算:H 取 500,000、B 取 50,000,這是拿來讓公式具體可算的示範輸入,不是原文宣稱的 Meta 現行機隊規模或單一 client 實際 shard 數。代入這兩個示範數字換算下來,p 大約是 9.5%——這是單一 client 摸到某一台主機的機率,不是連線數;要再乘上 client 母體數才會得到一台主機實際的扇入,而這一步的換算是我們依原文公式回推的,原文本身沒有印出這個數字(下面那張圖把它標成估算值)。50,000 這個示範輸入也說明了為什麼 direct 模型的連線數這麼容易失控:只要單一 client 平均要摸的 shard 數夠大、又分散在夠多台主機上,一次上線就可能同時觸及其中一大片。direct 模型下一台主機的 fan-in,就是這個機率乘上 client 母體數:Meta 原文寫「database-host fan-in is H_client · p — linear in the client population, so every new cohort makes every database host worse」,client 母體再大一輪,每台資料庫主機的負擔就再重一輪,而且是機隊裡每一台都一起變差,不是只有少數幾台。

插入 ZGateway 之後,這條公式整個換掉。Meta 的說法是:「fan-in reduces to approximately R · S_host, regions times shard density per host, independent of both fleets.」式子裡沒有 H_client 這一項——不管 client 母體數是十萬還是一百萬,資料庫主機看到的連線數都不會因此變動。剩下能調的變數只有 shard density,而這是 Meta 自己就能決定的東西,不需要等哪個團隊自願把自己的 client 數砍下來。原文寫得更直接:「The only lever left is shard density, which we own.」把可調變數從「別人的行為」換成「自己能動的旋鈕」,是這整套設計裡最值錢的一步。要調 shard density,具體做法是決定每台 ZServer host 上放多少個 shard——打包得更密,S_host 變大,fan-in 也跟著變大;打包得更疏,fan-in 跟著變小。這是一個純粹的容量規劃決策,不需要跟任何一個 client 團隊商量,也不需要哪個 use case 自願縮減自己的 client 母體。回到前面那組「round mock figures」:20 個 region、500,000 台資料庫主機、30,000 台 proxy 主機、1,000,000 個 client——這些同樣是原文拿來讓公式具體可算的示範參數,不是原文宣稱的 Meta 現行機隊規模,但放在一起看,至少讓「fan-in 從線性變常數」這句話有一個具體量級可以想像。

拖曳滑桿看 client 母體數怎麼影響 fan-in · 10 萬到 200 萬

1,000,000
一台主機收到的連線數(每台,估算值) direct ≈95,000 gateway ↓ 97-98%(原文實測比例,不隨滑桿變動) 0 50,000 100,000 150,000 200,000 連線數(換算估算值,條)

direct 模型:這台資料庫主機平均要接的連線數,是我們依原文自己給的公式 p(B)=1-e^(-B/H) 加上兩個輸入回推出來的估計值,不是原文直接寫出的數字——H 取 500,000 台主機、B 取單一 client 平均要摸的 50,000 個 shard。這兩個輸入本身也不是原文陳述的 Meta 現行機隊規模:它們來自原文一組明文標示的「round mock figures」示範算例,只是拿來讓公式具體可算。換算出 p≈9.5%,乘上示範算例裡的 client 母體數,落在約 9.5 萬條這個量級——這是示範輸入套進公式的結果,不是 Meta 生產環境量到的真實 fan-in。滑桿拉到 200 萬,同一個公式換算出來的量級接近 19 萬條。

gateway 模型:公式是 R·S_host,完全不含 H_client 這一項,所以這一行不會隨滑桿移動。它實際等於多少,原文沒有給絕對數字,這裡直接引用原文量到的比例——per-host 連線數比 direct 模型低了大約 97-98%——而不是另外編一個換算出來的絕對值。

把 client 母體數從等式裡拿掉,換來的兩個新詞值得認識一下——它們合起來就是這條公式的另一半。

ZGateway 把 client 端原本要處理的兩件事都接管了:一是要不要重連——client 現在靠一條 sticky connection同一個 client 反覆用同一條連線打向同一個 regional ZGateway host,不必每個 request 都重新握手,跟 direct 模型下「每個要用到的資料庫主機各開一條連線」正好相反。 跟自己的 gateway host 打交道;二是要不要調整 shard densityS_host——每台主機上放了多少個 shard。gateway 的 fan-in 公式是 R·S_host,完全不含 client 數,Meta 自己的說法是「The only lever left is shard density, which we own.」 ——後者才是決定資料庫主機負擔的那根槓桿,而它現在完全握在 Meta 自己手裡。

batching、coalescing,跟一個會失效的快取

光是把連線收斂還不夠,ZGateway 在轉發之外還做了三件事,各自處理一種浪費。第一是 batching:每台 host 上有一個共用的 batcher,把送往同一個目的地的請求群組起來,合併成一支打進後端的 RPC——原本要打十次的請求,變成一次打包好的請求。第二是 coalescing:同一時間打向同一把 key 的請求,就算有數千個呼叫者同時發出,也會被收攏成一次後端讀取,Meta 的說法是「a hot key can never become a stampede」。「stampede」這個詞在快取設計裡很常見:一把熱門 key 的快取到期或 miss 的瞬間,如果同時有數千個請求都在等這把 key,沒有 coalescing 的話,這些請求會全部穿透快取直接打到後端,等於用一次 cache miss 換來數千次重複的後端讀取,後端很容易因此被打垮。這兩件事合在一起,等於把「同一份資料被很多人同時要」這種常見流量形狀,從後端的視角看起來變成了一次讀取。對寫 client 端程式碼的工程師來說,這代表「打 request 前要不要自己先做批次」這件事,很大程度上不用自己操心——擠壓請求的工作被搬到 gateway 這一層集中處理,不用每個 use case 團隊各自重新發明一套 client 端 batching。

把 request 留在記憶體裡湊批,代價是 OOM 風險——Meta 自己承認這「is an OOM risk」,所以 batching 內建兩道保險:idle eviction 處理慢性成長,超過 TTL 還沒被打包的 batch-map entry,下一次 flush 時直接被清掉;in-flight cap 處理急性過載,擋住短時間內湧入太多還沒送出的批次。這兩道保險處理的是不同時間尺度的風險:idle eviction 抓的是慢性問題——某個目的地的 batch 一直沒有新請求加入、也還沒被 flush,長期占著記憶體不放;in-flight cap 抓的是急性問題——短時間內湧入的未送出批次太多,記憶體用量在幾秒鐘內就衝高。前者靠時間門檻清理,後者靠數量上限擋,兩道保險各管一種增長節奏。第三件事是讀取快取:熱門的讀取直接從 in-process cache 出,miss 的時候 gateway 拿一把 per-key 的 fill lock,避免同一個 key 的重建請求彼此打架、把後端打成好幾份重複的讀取。快取的新鮮度不是靠 TTL 硬撐,而是接一條 change-data-capture 事件流,寫入跟 checkpoint 事件一來,就讓對應的 entry 失效或重新填值——這代表快取跟資料庫之間的落差有多久,取決於 CDC 事件流的延遲,而不是一個寫死的過期時間。對讀多寫少的 use case 來說,這代表相當一部分請求根本不會真的打進 ZServer——熱門 key 命中 in-process cache 就結束了,連 batching 那一步都不需要經過。

Discriminant Load Shedding 與 adaptive load balancing

ZippyDB 上跑著大量互不相關的 use case,一個租戶壓垮系統不該連累其他人。ZGateway 的做法叫 Discriminant Load Shedding(DLS):每個 request 先映射到一個 per-tenant 的 bucket,依 use case 分桶、再依優先權切開,桶跟桶之間輪流放行,不讓任何一個桶長期霸占處理順序。分桶不是只分到「哪個 use case」這麼粗——同一個 use case 內部還會依優先權再切一層,就算某個桶底下的請求量特別大,它也只能按排定的順序拿到自己的處理額度,不會因為量大就自動搶到更多資源。在多數 load shedding 的設計裡,被 shed 代表請求在進入正式處理流程前就被直接回絕,呼叫端可以快速知道現在打不進去,不用等到超時才發現——這也是「隔離」兩個字真正的意義:付出代價的是吵鬧的那幾個桶自己,不是排隊等它們讓路的其他桶。Meta 做過一次受控過載測試:CPU 打到 90% 以上,大約 1,350 個活躍的 tenant bucket 裡,只有 6 個——真正在吵鬧的那幾個——被 shed,其餘 1,344 個照常執行、零被拒。goodput 跟單純的 throughput 不一樣:throughput 算的是機器實際處理了多少 request,goodput 只算其中真正成功、對呼叫端有用的那一部分,被 shed、被拒絕、逾時失敗的都不算進去。整台機器的 goodput 在這種壓力下仍然維持在 97-98%,而 DLS 這套機制本身只吃掉大約 8% 的 CPU——隔離的代價不到自己救回來的一成。這筆帳在多租戶系統裡通常划算:不做隔離的代價往往是整個機隊一起被拖慢,而不是只有製造問題的那幾個租戶自己付代價。

另一邊,adaptive load balancing 負責把流量在 ZGateway 機隊內部攤平:balancer 讀每台 host 最近的 CPU 使用率,把整個 tier 的平均值正規化成 1.0,再依每台主機偏離平均值的方向調整它的權重——CPU 用得比平均高,權重就往下修,反之往上修,讓熱點主機自然少接一些新流量。用 CPU 使用率而不是單純的請求數來衡量負載,是因為同一個 route 底下不同 request 的計算成本本來就不一樣——batching、coalescing、快取 miss 之後的 fill lock,都會讓某些 request 比其他 request 貴上好幾倍,光數請求數量沒辦法反映真實的機器負擔。這幾套機制加起來,把 ZGateway 的整體 overhead 壓到平均只多花約 6% 的計算成本,而這一整層 proxy 現在扛著超過每秒 10 億次操作。下面這張表把前面散落各處的數字收在一起:

ZGateway 生產環境量到的數字,逐一對應文中提到的機制。
指標 量到的數字 條件
ZippyDB 流量佔比40%,看向 60%+全站流量,前者為現況、後者為預期
per-host 連線數降 ~97-98%對比 direct-access
總 persistent 連線數降 ~19x每條 backend 連線同時扛著多個 client 的流量,不是一對一
平均 overhead~6%average use case 的計算成本
尖峰吞吐>10 億次操作/秒整層 ZGateway
DLS 隔離1,350 個 bucket 只 6 個被 shed>90% CPU 受控過載測試,goodput 維持 97-98%,機制自身耗 ~8% CPU

跨區域失效與誠實的代價

ZGateway 也接手了跨區域失效這件麻煩事,分成三層,範圍一層比一層細。

切換分頁比較三層失效防線 · 3 個分頁

最粗的一層。Meta 原文:「Global routing builds a routing table spanning regions.」這張表決定一個 request 預設要往哪個 region 送,是三層裡覆蓋範圍最廣、也最不精細的一層——先有這張表,才談得上後面兩層怎麼收斂範圍。

把範圍收窄一級。原文:「Mega-regions group geographically close regions into one locality.」地理位置相近的 region 被歸進同一個 locality,同一個 mega-region 內部互相支援時,跨的地理距離比隨機跨洲失效小得多,延遲代價也跟著小。

最精細的一層。原文:「Rings declare exactly which regions back each other up.」不是「哪裡都能救」,而是每個 region 都有事先指定好的救援對象——出事的時候不用臨時決定該往哪裡切,答案早就寫在 ring 的宣告裡。

好處說完了,代價也不該藏著。Meta 自己寫得很坦白:「The tradeoff is a hop and one more tier to operate.」多一跳,意味著每個 request 的延遲預算裡多了一段;多一層要維運,意味著 ZGateway 自己也會壞、也要監控、也要升級,不是裝了就沒事的旁路元件。「多一層要維運」具體代表一整套額外的維運負擔:要有自己的部署流程、自己的容量規劃、自己的 on-call、自己的 dashboard——這些東西在 direct-access 模型裡完全不存在,因為那時候根本沒有 ZGateway 這一層。前面提到 batching 把 request 暫留在記憶體裡湊批,本身「is an OOM risk」,idle eviction 跟 in-flight cap 就是為了兜住這個風險,不是錦上添花的保險。目前 batcher 跟 cache 都跑在同一個 process 裡,Meta 也承認「one tenant's memory blowup can threaten everything on the host」——一個租戶的記憶體炸開,可能拖垮同一台 host 上所有其他租戶的請求,這件事現在是靠規劃中、還沒上線的 multi-process 架構去解。把 batcher 跟 cache 拆進獨立的 process,理論上能讓一個 use case 的記憶體問題被作業系統的 process 邊界擋住,不會直接波及同一台 host 上的其他租戶——但這個邊界本身也不是免費的,跨 process 溝通要付出額外的序列化與 context switch 成本,這也是為什麼它還停在規劃階段,不是說改就能改。更遠一點,他們在評估把 gateway 的一部分「push part of the gateway down beside the ZServer host」,用 co-location 把「一段額外的網路跳點跟幾個百分比的 overhead」再壓下去,同時保留現在這種解耦帶來的好處。這聽起來像是走回頭路——把兩層黏在一起,理論上確實能省掉那段網路跳點——但真正的取捨在於:黏得太緊,ZGateway 原本獨立擴縮、獨立升級的彈性就會跟著犧牲掉,這也是為什麼 Meta 只講「push part of the gateway」,不是把整層都搬過去;這件事目前還停在計畫階段,不是已經上線的東西。

What this enables:資料庫主機收到的連線數不再跟著 client 母體一起長,Meta 剩下唯一還能自己調的槓桿是 shard density,代價是路徑上多了一跳、多了一層要維運的 proxy,而且 batching 把 request 留在記憶體裡這件事,得靠 idle eviction 跟 in-flight cap 兩道保險主動兜住。