解决 GraphQL 的限流难题
在上一篇微服務架構設計模式的總結[1]?的結尾,提到了 GraphQL 的問題。
之前在某公司落地查詢 API 方案時,我們沒有選擇 GraphQL,是因為:
GraphQL 對于數據用戶來說有一定的學習成本
GraphQL 的穩定性很難做,難以限流
學習成本倒也不是特別大的問題,程序員們本能上還是喜歡接觸新東西的,這會讓他們有一種虛假的獲得感。
關鍵是這個限流問題,是真的很難做,開放 GraphQL 的 API,就像你在 MySQL 上直接開了一個 SQL 接口一樣,用 SQL 可以一次只查一條數據,也可以一次查一億條數據。
所有查詢都只用主鍵做條件查一條數據,那單 MySQL 實例可以搞出百萬 QPS。如果一個查詢要查一億條數據,那這條查詢就把 MySQL 實例的 CPU / 內存打爆了 [doge]。
GraphQL 里類似的情況是這樣:
query?maliciousQuery?{album(id:?”some-id”)?{photos(first:?9999)?{album?{photos(first:?9999)?{album?{photos(first:?9999)?{album?{#...?Repeat?this?10000?times...}}}}}}} }這個例子來自這里[2]。嵌套查詢會導致查詢的成本無法預測。
Shopify 今年 6 月份發了一篇《Rate Limiting GraphQL APIs by Calculating Query Complexity》[3]的文章,講了他們在使用 GraphQL 時做的限流策略。
下面的內容主要是這篇文章的翻譯。
開頭說了說 REST 風格 API 的限制,這個應該大多數人都知道了。。主要就是下面這兩種限制:
REST 認為戶端每個請求都是一樣的消耗,即使 API 響應里有很多數據他們不需要
POST,PUT,PATCH 和 DELETE 這些請求相比 GET 請求會產生更多 side effects,會消耗更多服務端資源,但在 REST 里這些都是一視同仁的。
在大一統的 REST 風格 API 下,所有類型的客戶端都只能接收 response 里那些它們不需要的字段。
雖然更新、刪除操作會對服務產生更多負載,但它們在基于請求響應的限流模型里是按一樣的資源消耗量進行計算的。
GraphQL 主要解決了動態字段和數據組合的問題。但 GraphQL 模式下,不同的請求成本也是不一樣的。
Shopify 的方案在執行 GraphQL 請求前會先對這個 GraphQL 請求做靜態分析,來計算該請求的成本,成本以“點數”來表示。
這篇文章主要就是介紹它們這套計算方法。
Object :一點
object 是查詢的基本單位,代表單次的 server 端操作,可以是一次數據庫查詢,也可以是一次內部服務的訪問。
Scalars 和 Enums:零點
標量和枚舉是 Object 本身的一部分,在 Object 里我們已經算過消耗了,這里的 scalar 和 enum 其實就是 object 上的某個字段。一個 object 上多返回幾個字段消耗是比較少的。
query?{shop?{??????????????????#?Object??-?1?pointid????????????????????#?ID??????-?0?pointsname??????????????????#?String??-?0?pointstimezoneOffsetMinutes?#?Int?????-?0?pointscustomerAccounts??????#?Enum????-?0?points} }這個例子里的 shop 是一個 object,消耗 1 點。id,name,timezoneOffsetMinutes,customerAccounts 都是標量類型,消耗 0 點。著的查詢消耗是 1。
Connections: 兩點 + 返回的對象數量
GraphQL 的 Connection 表示的是一對多的關系。Shopify 用的是 Relay 兼容的 Connection 概念,也就是說這里的 Connection 也遵循常見的規范,比如可以和 edges,node,cursor 以及 pageInfo 一起混合使用。
edges 對象包含的字段是用來描述一對多的關系的:
node:query 返回的 object 列表
cursor:當前在列表中的游標位置
pageInfo 有 hasPreviousPage 和 hasNextPage 的 boolean 字段,用來在列表中進行導航。
connection 的消耗認為是兩點 + 要返回的對象數量。在這個例子里,一個 connection 期望返回五個 object,所以消耗七點:
query?{orders(first:?5,?query:?"fulfillment_status:shipped")?{edges?{node?{idnamedisplayFulfillmentStatus}}} }cursor 和 pageInfo 不需要計算成本,因為他們的成本在做返回對象計算的時候已經都計算過了。
下面這個例子和之前的一樣也消耗七點:
query?{orders(first:5,?query:"fulfillment_status:shipped")?{edges?{cursornode?{idnamedisplayFulfillmentStatus}}pageInfo?{hasPreviousPagehasNextPage}} }Interfaces 和 Unions:一點
Interfaces 和 unions 和 object 類似,只不過是能返回不同類型的 object,所以算一點。
Mutations:十點
Mutations 指的是那些有 side effect 的請求,即該請求會影響數據庫中的數據或索引,甚至可能觸發 webhook 和 email 通知。這種請求要比一般的查詢請求消耗更多資源,所以算 10 點。
在 GraphQL 的響應中獲取 Query Cost 信息
當然,你不需要自己計算 query 成本。Shopify 設計的 API 響應可以直接把 object 消耗的成本包含在響應內容中??梢栽谒麄兊?Shopify Admin API GraphiQL explorer[4]?里跑查詢來實時觀察相應的查詢成本。
query?{shop?{idnametimezoneOffsetMinutescustomerAccounts} }計算出來的 cost 會在 extention object 里展示:
{"data":?{"shop":?{"id":?"gid://shopify/Shop/91615055400","name":?"My?Shop","timezoneOffsetMinutes":?-420,"customerAccounts":?"DISABLED"}},"extensions":?{"cost":?{"requestedQueryCost":?1,"actualQueryCost":?1,"throttleStatus":?{"maximumAvailable":?1000.0,"currentlyAvailable":?999,"restoreRate":?50.0}}} }返回 Query Cost 詳情
上面舉的例子是直接返回一個計算出的總值,還可以得到按字段細分的查詢消耗,在請求中加一個?X-GraphQL-Cost-Include-Fields: true?的 header 就可以讓 extention object 展示更詳細的點數信息了:
{"data":?{"shop":?{"id":?"gid://shopify/Shop/91615055400","name":?"My?Shop","timezoneOffsetMinutes":?-420,"customerAccounts":?"DISABLED"}},"extensions":?{"cost":?{"requestedQueryCost":?1,"actualQueryCost":?1,"throttleStatus":?{"maximumAvailable":?1000.0,"currentlyAvailable":?999,"restoreRate":?50.0},"fields":?[{"path":?["shop","id"],"definedCost":?0,"requestedTotalCost":?0,"requestedChildrenCost":?null},{"path":?["shop","name"],"definedCost":?0,"requestedTotalCost":?0,"requestedChildrenCost":?null},{"path":?["shop","timezoneOffsetMinutes"],"definedCost":?0,"requestedTotalCost":?0,"requestedChildrenCost":?null},{"path":?["shop","customerAccounts"],"definedCost":?0,"requestedTotalCost":?0,"requestedChildrenCost":?null},{"path":?["shop"],"definedCost":?1,"requestedTotalCost":?1,"requestedChildrenCost":?0}]}} }理解請求消耗和實際的查詢消耗
可以注意到上面的返回結果里有不同類似的 cost 字段:
請求消耗是在執行查詢前通過對 GraphQL 進行靜態分析得到的值
實際的查詢消耗是通過執行查詢得到的值
有時實際的消耗也比靜態分析得到的消耗要少一些。比如你的查詢指定要查 connection 里的 100 個 object,但實際上只返回了 10 個。這種情況下,靜態分析多扣除的點數會返還給 API client。
下面這個例子,我們查詢前五個庫存中的商品,但只有一個商品滿足查詢條件,所以盡管計算出的請求消耗點數是 7,client 并不會被扣掉 7 點。
還是按真實的查詢成本來計算的:
{"data":?{"products":?{"edges":?[{"node":?{"title":?"Low?inventory?product"}}]}},"extensions":?{"cost":?{"requestedQueryCost":?7,"actualQueryCost":?3,"throttleStatus":?{"maximumAvailable":?1000.0,"currentlyAvailable":?997,"restoreRate":?50.0}}} }對本文中的 Query Cost 模型進行有效性驗證
The calculated query complexity and execution time have a linear correlation
使用了查詢復雜度計算規則之后,我們能夠讓查詢的成本和服務端的負載基本線性匹配。這使得 Shopify 對網關層的基礎設施能夠有效地進行負載預測和橫向擴展,并且也給用戶提供了穩定的構建 app 的平臺。我們還可以檢測出那些資源消耗大戶,專門對它們進行性能優化。
通過對 GraphQL 查詢的復雜度計算進行限流,我們得到了比 REST 更可靠的 API client,同時相比 REST 又具備了更優的靈活性,這種 API 模式鼓勵用戶只請求它們需要的那些數據,使服務器的負載也更加可預期。
其它信息:
Shopify API rate limits[5]
Shopify Admin API GraphiQL explorer[6]
How Shopify Manages API Versioning and Breaking Changes[7]
ShipIt! Presents: A Look at Shopify's API Health Report[8]
[1]
這篇總結:?https://mp.weixin.qq.com/s/TLZR252J7EHcR8h_vEsXrA
[2]這里:?https://medium.com/swlh/please-rate-limit-your-graphql-api-9832b5c64418
[3]《Rate Limiting GraphQL APIs by Calculating Query Complexity》:?https://shopify.engineering/rate-limiting-graphql-apis-calculating-query-complexity
[4]Shopify Admin API GraphiQL explorer:?https://shopify.dev/tools/graphiql-admin-api
[5]Shopify API rate limits:?https://shopify.dev/concepts/about-apis/rate-limits#compare-rate-limits-by-api
[6]Shopify Admin API GraphiQL explorer:?https://shopify.dev/tools/graphiql-admin-api
[7]How Shopify Manages API Versioning and Breaking Changes:?https://shopify.engineering/shopify-manages-api-versioning-breaking-changes
[8]ShipIt! Presents: A Look at Shopify's API Health Report:?https://shopify.engineerin
歡迎關注?TechPaper 和 碼農桃花源
總結
以上是生活随笔為你收集整理的解决 GraphQL 的限流难题的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 蚂蚁集团万级规模 k8s 集群 etcd
- 下一篇: 曹大带我学 Go(10)—— 如何给 G