病みつきエンジニアブログ

機械学習、Python、Scala、JavaScript、などなど

gqlgen でのキャッシュ性能向上を考えてみる

NewsDigest ではアプリの BFF として GraphQL を使っていて、ライブラリとしては gqlgen を使ってます。で、なるべく CDN でのキャッシュヒット率を上げたいなぁということで、 gqlgen でできることをプロトタイプしてみました。

github.com

前提として、

  • Apollo とかは使ってなくて、curl でのコンセプトレベルの検証
  • 僕は Go を書く力が弱い
  • 頭の体操であって、実戦投入したものではない

1: Persisted Query をつかう

まず、Persisted Query を使います。

Add File-based Persisted Query · yamitzky/example-gqlgen-cached@5917721 · GitHub

Persisted Query は、簡単に言うと、クエリ自体をハッシュ文字列(0123456789abcdefの列)に置換して送信するものです。GraphQL のリクエストサイズは結構デカく(冗長)、数kb とかになったりするので、これを数文字のハッシュ文字列に置換できれば、送信サイズを大幅に圧縮できます。例えば、次のような curl です。

curl -g 'http://localhost:8080/query?extensions={"persistedQuery":{"version":1,"sha256Hash":"688a6c"}}'

理論上、どんなに大きなサイズのクエリであっても、上記のような定数長のクエリに置換できます。また、上記のように GET でリクエストすれば、CDN によってキャッシュしやすいです。

一般論として、gqlgen に搭載されている APQ の機能は、クライアント側がハッシュ値を勝手に登録することが期待されています。GraphQL のクエリは無限のパターンがあり得るためです。一方で、GitHub に上げた例では、事前にクエリ文字列をコミットしなければならない という前提を置きました(Cache.Add が何もしない)。プライベートな BFF として作るのであれば、どういうクエリを送りたいかは事前にコントロールできるためです。むしろこのように事前にファイルにコミットしておくことで、意図しないクエリ文字列の違いによるキャッシュヒット率の低下を防いだり、クエリ登録のネゴシエーション*1も減らすことができます。まぁ BFF のようなチーム開発するものなら許容できるでしょう。

余談ですが、 http_get.go を模して*2 /queries/abcdef みたいな感じで RESTful っぽくリクエストできるような GraphQL API を作ることもできると思います*3

2: Cache-Control を設定する

Persisted Query を使うだけだと、CDN のキャッシュ時間は固定値でしか設定できません。例えば頻繁に更新される TODO 一覧と、あんまり更新されないユーザー一覧が同じキャッシュ時間だと不便です。そこで、リゾルバー側から Cache-Control ヘッダーをリクエストできるようにしてみました。

Add cache-control · yamitzky/example-gqlgen-cached@f4e956f · GitHub

GraphQL では1度に複数のリソース(例:todos と users)を取得できることになるので、最終的には最もキャッシュ時間が短いものにします。例えば、todos は60秒のキャッシュ、users が1日のキャッシュで良いのであれば、

  • { todos { id } }・・・60秒
  • { users { id } } ・・・86400秒
  • { todos { id }, users { id } }・・・ 60秒

ということです。directiveで設定する方法もあると思いますが、動的に変えられるアプローチのほうが良いと思います。

ちなみに、同じようなコンセプトですが、もっと良い方法がプルリクされてます*4

3: resolver レベルのキャッシュ

2 番目の方法では、1度に複数のリソースを取得した場合、最終的に最も短いキャッシュ時間を採用していました。これは CDN のキャッシュとしては適切ですが、CDN のキャッシュが切れたときに少しもったいないです。例えば、 { todos { id }, users { id } } に対して60秒のキャッシュが設定されていますが、60秒経過した後、1日程度はキャッシュしていいはずの users を再計算することになります(実際には DB へのアクセスになるでしょう)。

そこで、gqlgen の extension の仕組みを使って、resolver レベルでキャッシュできるようにしてみました*5。これであれば、できるだけ CDN でキャッシュされ、CDN のキャッシュが切れたとしても適切な範囲で resolver レベルでメモリや Redis などにキャッシュできます。

Add resolver-level cache · yamitzky/example-gqlgen-cached@05c6e5e · GitHub

その他の採用しなかったアプローチ

Akamai とかの GraphQL 対応した CDN をつかうと、2 と 3 をもう少し綺麗に対応できそうな感じがします。

*1:APQ は Optimistic Request なので、メモリキャッシュであれば分散したサーバー分ネゴシエーションが増え、それを避けるには Redis のようなキャッシュサーバーへの余分なリクエストが必要になります

*2:これも余談ですが、最近の gqlgen はリファクタされたおかげで、あまりコピペせずにハンドラー作れるのですね

*3:RESTful 前提のメトリクスツールが使えるとかのメリットがあります。それ GraphQL である必要ないやんけという意見もありそうですが、リポジトリにクエリ文字列を保存さえすればクライアント側はほしいデータを柔軟にとってこれるので、十分に GraphQL のメリットは享受できてると思います。Apollo みたいな便利 GraphQL クライアントが動くかはわからん

*4:書き終えたあとに気づいた... gqlgen はミドルウェアからヘッダーを動的に書きかえる方法がないので、コアリポジトリを変更するか protocol コピペすることになります

*5:面倒くさかったので、TTL は設定してません