解決 GraphQL 的限流難題

在上一篇微服務架構設計模式的總結 [1] 的結尾,提到了 GraphQL 的問題。

之前在某公司落地查詢 API 方案時,我們沒有選擇 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 下,所有類型的客戶端都只能接收 response 裏那些它們不需要的字段。

雖然更新、刪除操作會對服務產生更多負載,但它們在基於請求響應的限流模型裏是按一樣的資源消耗量進行計算的。

GraphQL 主要解決了動態字段和數據組合的問題。但 GraphQL 模式下,不同的請求成本也是不一樣的。

Shopify 的方案在執行 GraphQL 請求前會先對這個 GraphQL 請求做靜態分析,來計算該請求的成本,成本以 “點數” 來表示。

這篇文章主要就是介紹它們這套計算方法。

Object :一點

object 是查詢的基本單位,代表單次的 server 端操作,可以是一次數據庫查詢,也可以是一次內部服務的訪問。

Scalars 和 Enums:零點

標量和枚舉是 Object 本身的一部分,在 Object 裏我們已經算過消耗了,這裏的 scalar 和 enum 其實就是 object 上的某個字段。一個 object 上多返回幾個字段消耗是比較少的。

query {
  shop {                  # Object  - 1 point
    id                    # ID      - 0 points
    name                  # String  - 0 points
    timezoneOffsetMinutes # Int     - 0 points
    customerAccounts      # Enum    - 0 points
  }
}

這個例子裏的 shop 是一個 object,消耗 1 點。id,name,timezoneOffsetMinutes,customerAccounts 都是標量類型,消耗 0 點。着的查詢消耗是 1。

Connections: 兩點 + 返回的對象數量

GraphQL 的 Connection 表示的是一對多的關係。Shopify 用的是 Relay 兼容的 Connection 概念,也就是說這裏的 Connection 也遵循常見的規範,比如可以和 edges,node,cursor 以及 pageInfo 一起混合使用。

edges 對象包含的字段是用來描述一對多的關係的:

pageInfo 有 hasPreviousPage 和 hasNextPage 的 boolean 字段,用來在列表中進行導航。

connection 的消耗認爲是兩點 + 要返回的對象數量。在這個例子裏,一個 connection 期望返回五個 object,所以消耗七點:

query {
  orders(first: 5, query: "fulfillment_status:shipped") {
    edges {
      node {
        id
        name
        displayFulfillmentStatus
      }
    }
  }
}

cursor 和 pageInfo 不需要計算成本,因爲他們的成本在做返回對象計算的時候已經都計算過了。

下面這個例子和之前的一樣也消耗七點:

query {
  orders(first:5, query:"fulfillment_status:shipped") {
    edges {
      cursor
      node {
        id
        name
        displayFulfillmentStatus
      }
    }
    pageInfo {
      hasPreviousPage
      hasNextPage
    }
  }
}

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 {
    id
    name
    timezoneOffsetMinutes
    customerAccounts
  }
}

計算出來的 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 字段:

有時實際的消耗也比靜態分析得到的消耗要少一些。比如你的查詢指定要查 connection 裏的 100 個 object,但實際上只返回了 10 個。這種情況下,靜態分析多扣除的點數會返還給 API client。

下面這個例子,我們查詢前五個庫存中的商品,但只有一個商品滿足查詢條件,所以儘管計算出的請求消耗點數是 7,client 並不會被扣掉 7 點。

query {
  products(first: 5, query: "inventory_total:<5") {
    edges {
      node {
        title
      }
    }
  }
}

還是按真實的查詢成本來計算的:

{
  "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 模式鼓勵用戶只請求它們需要的那些數據,使服務器的負載也更加可預期。

其它信息:

[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,不走失

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Vb3eIeGKrL5BDNqlrnVrnQ