iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 18
2
Modern Web

Think in GraphQL系列 第 18

GraphQL Design: 使用 DataLoader 提升效能 !

header

今天就來講一講 GrpahQL 的效能調校!之前提過許多 GraphQL 的優點,即使單筆 request 花費可能比 REST 還要多,但省下多趟來回 request 以及解決 over fetching 的問題才是增加效能的主因。

但我想說的是,過早且過度的微調是不必要的。其實使用 GraphQL 不只是減少 request 時間,也是要減少開發的成本。不管是不是使用 GraphQL 都是一樣,現在很多效能大都可以靠提升機器解決,真正卡住效能的通常都是程式問題:減少 IO 、清理雜亂的邏輯等等,但是任何提早的優化都是危險且常常效益很低的,因為只有當你完成一個功能後,實際去測量效能才會知道哪些才是瓶頸,而非一昧地犧牲可讀性與維護性去增加那一點點的效能。

GraphQL 已經幫助我們省下大量來回 request 的時間,不過當 GraphQL 遇到一次大量的資料索取時,常常會發生
不過 GraphQL Server 仍一個很大的效能問題: N + 1 problem

接著讓我們看看為何有此問題及 GraphQL 的解決方式。


N + 1 problem

所謂的 N + 1 問題,簡單來說 N + 1 就是 db operation 的次數,比如今天一個 user 可以有好多個 posts ,當遇到需要拿到大量 users 及相關的 posts 時, N + 1 的做法就是先撈出所有 users (1 次) 然後一個個 user.id 去搜相關的 posts (N 次)。可以發現其實 posts 的 N 次完全是浪費時間,因為可以直接用所有 user.id 去找出所有 posts (1 次),就少掉了 N - 1 次的 db operation 。

以 GraphQL 的例子來說,假如今天有一個 Schema 如下

const typeDefs = gql`
  type Query {
    allUsers: [User]
  }

  type User {
    id: Int
    name: String
    followingUsers: [User]
  }
`

在裡面可以看見有一個使用者可以能會有許多追蹤者,而當我們下一個簡單的 query

query {
  allUsers {
    id
    name
    followingUsers {
      id
      name
    }
  }
}

再來看看 resolver functions

  1. 第一步: 進入 Query.allUsers 的時候就會 call userModel.getAllUsers() ,這裡做了一次 db operation
const resolvers = {
  Query: {
    ...,
    allUsers (root, args, { userModel }) {
      // 被 call 1 次
      return userModel.getAllUsers()
    }
  },
  ...
}
  1. 第二步:得到 users 後,接著一個個去拿追蹤者,如果 users 的數量為 N,那這裡會 call userModel.getUsersByIds(user.followingUserIds) N 次。
const resolvers = {
  Query: { ... },
  User: {
    async followingUsers (user, args, { userModel }) {
      // 被 call N 次
      return userModel.getUsersByIds(user.followingUserIds)
    },
  }
}

經過這兩步驟就很容易發現了 N + 1 的問題,接下來看怎麼解決。

裝上 Tracer 追蹤誰是大麻煩

在開始前可以試試看 Apollo Server 的新功能 Tracer ,只需要在 new ApolloServer 時帶上 tracer: true 參數,就可以在 GraphQL Playground 測試時看看每支 query 的到底效能卡在哪裡。程式如下:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  tracing: true,
  ...
});

可以見下圖:

img

DataLoader

這時候就來介紹 Facebook 的一位開發者發明的工具 dataloader 來幫助我們解決 N + 1 的問題。

Dataloader 主要有兩項功能: Batching (批次) & Caching (快取) 。

Batching 在於他能夠將想要進 db 搜尋的 id 都搜集起來,等時間到了一次進 db 搜尋,解決 N + 1 問題。

Caching 在於它在紀錄 id 時會做 memoization ,所以未來若是有重複的 id 進來就會被剔除,保證資料索取數越少越好。

在開始前我們先看個 project:

const { ApolloServer, gql } = require('apollo-server');
const DataLoader = require('dataloader');

const userModel = (() => {
  const users = [
    { id: 1, name: 'A', bestFriendId: 2, followingUserIds: [2, 3, 4] },
    { id: 2, name: 'B', bestFriendId: 1, followingUserIds: [1, 3, 4, 5] },
    { id: 3, name: 'C', bestFriendId: 4, followingUserIds: [1, 2, 5] },
    { id: 4, name: 'D', bestFriendId: 5, followingUserIds: [1, 2, 5] },
    { id: 5, name: 'E', bestFriendId: 4, followingUserIds: [2, 3, 4] }
  ];

  const genPromise = (value, text) =>
    new Promise(resolve => {
      setTimeout(() => {
        console.log(text);
        return resolve(value);
      }, 100);
    });

  return {
    getUserById: id =>
      genPromise(users.find(user => user.id === id), `getUserById: ${id}`),
    getUserByName: name =>
      genPromise(
        users.find(user => user.name === name),
        `getUserByName: ${name}`
      ),
    getUsersByIds: ids =>
      genPromise(
        users.filter(user => ids.includes(user.id)),
        `getUsersByIds: ${ids}`
      ),
    getAllUsers: () => genPromise(users, 'getAllUsers')
  };
})();

const typeDefs = gql`
  type Query {
    testString: String
    user(name: String!): User
    allUsers: [User]
  }

  type User {
    id: Int
    name: String
    bestFriend: User
    followingUsers: [User]
  }
`;

const resolvers = {
  Query: {
    user(root, { name }, { userModel }) {
      return userModel.getUserByName(name);
    },
    allUsers(root, args, { userModel }) {
      return userModel.getAllUsers();
    }
  },
  User: {
    async followingUsers(user, args, { dataloaders }) {
      return userModel.getUsersByIds(user.followingUserIds)
    },
    async bestFriend(user, args, { dataloaders }) {
      return userModel.getUserById(user.bestFriendId)
    }
  }
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
  tracing: true,
  context: async ({ req }) => {
    return { userModel };
  }
});

server.listen().then(({ url }) => {
  console.log(`Server ready at ${url}`);
});

以上 project 的 userModel 是用來模擬 db 操作, 因此加上 setTiemout 來增加時間成本。

如果送出 query

{
  allUsers {
    id
    name
    followingUsers {
      id
      name
    }
  }
}

console 會印出:

img

接著看如何引入 dataloader

  1. 安裝
npm install --save dataloader
  1. 引入
const DataLoader = require('dataloader')
  1. 創建一個新的 dataloader。這邊需注意,因為 dataloader 是使用記憶體,因此官方推薦一次 request 就 new 一次 dataloader ,這樣才不會導致記憶體被不用的舊資料給佔滿 (memory leak)。所以我們把它放進 context 中。

    這裡會用到 new DataLoader(batchLoadFn [, options]) ,最主要的就是 batchLoadFn ,當 dataloader 收集到一堆 key 後在下一次 event tick 就會觸發 batchLoadFn ,然後使用這些 keys 來做 db operation 。

2018-12-20 更新。Dataloader 回傳時要用傳進來的 keys 順序做排序才能對應到正確的值,因此需要再用 userIds.map 將得到的結果排序。

const server = new ApolloServer({
  typeDefs,
  resolvers,
  tracing: true,
  context: async ({ req }) => {
    return {
      userModel,
      dataloaders: {
        users: new DataLoader(async userIds => {
          const users = await userModel.getUsersByIds(userIds)
          return users.sort(
            (a, b) => userIds.indexOf(a.id) - userIds.indexOf(b.id)
          )
        })
      }
    }
  }
})
  1. 將 dataloader 加入 resolvers。這邊會用到 dataloader.load(key) 來蒐集 key ,等到下一個 event tick 時就會觸發上方的 batchLoadFn 並把這邊蒐集到的所有 keys 交給他。而這個 load 會存在記憶體中,並且把之後重複的 key 給踢掉! 如果需要一次回傳多組資料,可以使用 dataloader.loadMany(keys) 這裡的 keys 是個 array 。

dataloader.load(key) 回傳的是一個 Promise ,dataloader.loadMany(keys)回傳的是 [Promise] 。

const resolvers = {
  Query: {
    ...
  },
  User: {
    async followingUsers (user, args, { dataloaders }) {
      return dataloaders.users.loadMany(user.followingUserIds)
      // return userModel.getUsersByIds(user.followingUserIds)
    },
    async bestFriend (user, args, { dataloaders }) {
      return dataloaders.users.load(user.bestFriendId)
      // return userModel.getUserById(user.bestFriendId)
    }
  }
}

這時再送出一樣的 query
console 會印出:

img

有沒有發現 db operation 的次數減少超多的 !!

回到 GraphQL Playgroud 再試試看:

img

回傳值仍一模一樣,不過眼尖的朋友可能發現奇怪的是加了 dataloader 的速度似乎不一定比較快(甚至可能還慢一點),看看細項的 followingUsers 跟未加上 dataloader 時差不了多少,這邊得提醒一下,因為各個 query 都是併發 (concurreny) 執行,因此其實每個小 db operation 都是平行執行,因此自然會比一個大 db operation 來得快。

可以想像,一個大的 db operation 會像是這樣:

---------------->

而很多個平行執行的小 db operation 會像是這樣:

----------->
--------->
------------->

所以,Dataloader 在數量級不大的情況下速度反而比較慢,只有當所需的 db operation 次數真的太大時, Dataloader 才開始顯現其價值。


DataLoader 真的是很強的工具!但除了效能以外,程式碼的可讀性也很重要,每增加一個 dataloader 也會增加理解與維護的負擔,故建議真的遇到效能瓶頸時再來考慮引入。


Reference:


上一篇
GraphQL 入門: Apollo Mock - 做假資料好測試~
下一篇
GraphQL Design: Pagination 輕鬆處理大資料!
系列文
Think in GraphQL30

2 則留言

1
kpman
iT邦新手 5 級 ‧ 2018-11-11 02:35:46

setTiemout typo,應為 setTimeout

1
kpman
iT邦新手 5 級 ‧ 2018-11-11 11:12:04

想請問作者,
文章最後一段「因為在 GraphQL 中 Query 各項 field 為平行執行,所以在資料量真的不大的情況下,平行執行多項小的 db operation 有時可能比你一次大的 db operation 來得更快,所以真的是要先評估真的有效能需求才來做這個」

根據這段文字我理解如下:在一個 event loop 底下所做的 batch,如果這些單一的 query 太小,利用 batch 收集起來變成一個大的 query 反而會讓效能下降。
有沒有相關的數字或者是文獻在討論這個說法呢?

因為我的理解,在 dataloader 內 batch 收集起來的是同一個 table 底下的 query,
如果 SQL 的操作本身就有支援這種多個 id 的搜尋,dataloader 去使用這功能,照理說應該是會提昇效能才對。

fx777 iT邦新手 5 級‧ 2018-11-12 09:42:25 檢舉

因為一個 big query 一定比單一一個 small query 來得慢,而在 GraphQL 中, samll query 執行時大多都是 Concurrency (併發執行),所以在數量不大的情形下, big query 的執行次數雖少,但優勢卻也不明顯。

one big
--------->

multiple small
--->
----->
---->

不過重點其實還是 case by case ,在網路上查詢 "one big query or many small queries" 也會有各種看法。

大致上一筆 big query 的效能一定比多筆 small queries 好,只是考慮到不同的 DB 、資料庫大小、 Index 的位置或 Join 的情境等等。只有實際去測量效能時才能有所保證。

而且還要考量 query 的複雜度,越簡單的 query 在維護與優化上也都更加容易 ~~

感謝你提出看法~我原本文章寫得不是很清楚,之後我會找時間回來補充清楚 XD

kpman iT邦新手 5 級‧ 2018-11-12 16:43:43 檢舉

感謝補充,
蠻同意你所說的,
每個人實作 loader 的複雜度不同,
確實需要考量將這些 Join 或是其他 DB 操作放在 loader 內是否真正能增加效能,這些優化也都需要實測過後才能確定效能是否真正提高。

我要留言

立即登入留言