GraphQLのN+1問題に向き合う ~問題提起編~

はじめに

GraphQLを扱うデメリットとしてN+1問題が挙げられる。
なぜGraphQLだとN+1問題が起こってしまうのか考える。

そもそもN+1問題とは

そもそもN+1問題とはなにか。
N件のデータと、取得したそれぞれのデータに紐付くデータを取得する際に、DBへN+1回のアクセスが生じる問題のことをN+1問題という。

<例> ここに一つのブログシステムがある。扱うDBはRDBであり、ブログ投稿記事テーブル、ユーザテーブルの二つのテーブルでデータを管理する。 ブログ投稿記事一覧ページを表示する際に10件のブログ投稿記事を取得し、それぞれの記事の作者であるユーザも取得したい。このとき、ブログ記事はユーザのIDを持っており、そのユーザのIDを基にユーザの情報を取得する。

このような構成の場合、ブログ投稿記事一覧を取得するためのアクセス(1回)と、取得したそれぞれのブログ投稿記事に紐付くユーザを取得するためのアクセス(N回)が生じる。解消方法としては、JOIN句で一回のアクセスで取得してしまう、もしくは取得したブログ投稿記事が持つユーザIDを持つユーザだけを取得するSQLを作成して二回のアクセスで取得する。
以下の記事がわかりやすいと思う。

qiita.com

なぜGraphQLで問題視されるのか

上記の方法で取得すればなんの問題もないはずだが、なぜGraphQLで問題視されるのか。
気になって調べてみると二つの理由があった。

紐付いたリゾルバー切り出してしまった時の問題

こちらに関しては柔軟にしたばかりに、N+1問題が起きてしまったパターン。
どういうことかというと、GraphQLではリクエスト次第ではユーザ情報を含めないようレスポンスを要求することも可能なので、フィールド指定でユーザの情報が呼ばれた時にだけ、ユーザを取得するためのリゾルバが呼ばれるように切り出すことがある。確かにこの切り分けは便利で要求された時のみDBアクセスが走るのでパフォーマンスが良い。
だけども要求された時には毎回、内部でリゾルバーが呼び出されてN+1問題が生じるという話。

毎回網羅的に取得するようにしてしまったときの問題

かといって毎回、JOINなりEager Loadingするとフィールドが要求されていないにときにも、毎回紐付くデータまで取得してしまう。
要求されていないデータまで取得されてしまうことでパフォーマンスの劣化も考えられるという話。

解決策

DataLoaderを使うといいらしい。。。
そのうちまとめよう。

【AWS】最新の AMI を取得するコマンド


AWS の EC2インスタンスを利用する際に、最新の AMI を知りたい時があると思います。
そんな時には、CLIで以下のコマンドを叩きます。

aws ssm get-parameter \
  --name /aws/service/ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2 \               
  --region ap-northeast-1

ami-amazon-linux-latest/amzn2-ami-hvm-x86_64-gp2ap-northeast-1は適宜変更しましょう。

ページングのソートで気をつけること

バックエンドの実装で、ページングしたデータを返す際の話。
基本ライブラリに頼っているのであまり実装のことを考えたことがなかったので、結構苦戦した。

以下のような数字を降順で並べたリストのデータを、ページングでフロントエンドに返すとする。

7,6,5,4,3,2,1,0

ページごとの件数が3だとすると、1ページ目は7,6,5、2ページ目は4,3,2となる。
このときSQLを用いたデータベースへのアクセスだが、1ページ目から2ページ目の遷移は簡単で端っこの値を基に比較演算子を用いる。(5 > X)
3ページ目から2ページ目への遷移はどうだろう。
1,0から4,3,2を取得したい。
このときのポイントとして、全体の並び順が降順であってもDBから取得する際に並べ替えてからLIMITで件数を試ぼってはいけない。
1より大きい値を降順で3件欲しいわけだが、単純に1より大きい値を降順で3件要求すると取得できる値は7,6,5となってしまう。
要するにASCで3件取得した後にDESCで並べ替えなければいけないのだ。
ちなみに昇順でも同じように、前ページを取得する際には取得後の並べ替えでないといけない。

0,1,2,3,4,5,6,7

0,1,2->3,4,5 ASCで取得可能 6,7->3,4,5 DESCで3件取得した後にASCで並べ替え

きっとすぐに忘れるので、ちょっとでも考えるヒントになるように備忘録として残しておく。

マイクロサービスとSOA

似ているよう異なる二つの概念をまとめる。

SOA(サービス指向アーキテクチャ)

  • いくつかのサービスのコレクションによってシステムを実現する構造
  • ソフトウェアを繋ぐミドルウェアであるESB(エンタープライズサービスバス)によって、サービス間の結合を実現する
  • サービス間で同じリソース、ストレージを共有する

マイクロサービス

  • いくつかのサービスのコレクションによってシステムを実現する構造
  • REST API のようなシンプルで軽い手段でサービス間の結合を実現する
  • サービスごとにリソース、ストレージが分離されている

ポイントは通信手段、リソースやストレージを共有するか否かというところかな。
マイクロサービスの方は、それぞれのサービスがより独立しているという認識。

Elasticsearch のCRUD

いつでもElasticsearchでCRUDが実行できるように自分用のメモです。
ElasticsearchはREST APIを提供しているので、馴染みのある感覚でデータの操作を実行できます。
かといってもCurlで実行するのは辛いのでKibanaのコンソールを使います。

使用バージョン

  • Elasticsearch 7.7.1
  • Kibana 7.7.1

Create

  • Documentの新規作成はPUTもしくはPOSTを利用します。

PUTの場合

PUT /member/_doc/1
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
} 
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 0,
  "_primary_term" : 1
}

またこっちの形式でも結果は同じ

PUT /member/_create/1
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}

POSTの場合

POSTを用いる場合はIDを指定しなくても良いです。指定がない場合は自動採番されます。

POST /member/_doc/
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
} 
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "4_1FfHcBun3pb7RqaiAK",
  "_version" : 1,
  "result" : "created",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 2,
  "_primary_term" : 1
}

またはこっちの形式でも結果は同じ

POST /member/_create/1
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}

READ

GETの場合

GET /member/_doc/1
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 1,
  "_seq_no" : 0,
  "_primary_term" : 1,
  "found" : true,
  "_source" : {
    "name" : "Beats",
    "age" : 26,
    "birthday" : "1994-10-29",
    "blog_url" : "https://beatsbeats.hatenablog.com/",
    "job" : [
      "monk",
      "developer"
    ],
    "favorite_song" : {
      "title" : "fav_song",
      "created_at" : "2000-01-01"
    }
  }
}

こっちの形式を使えばインデックスの名前などのメタデータを除いた形で取得可能です。

GET /member/_source/1
{
  "name" : "Beats",
  "age" : 26,
  "birthday" : "1994-10-29",
  "blog_url" : "https://beatsbeats.hatenablog.com/",
  "job" : [
    "monk",
    "developer"
  ],
  "favorite_song" : {
    "title" : "fav_song",
    "created_at" : "2000-01-01"
  }
}

UPDATE

フィールドを指定して更新する場合は以下の形式のリクエストを実行します。

POST /member/_update/1
{ 
  "doc": {
    "name": "Beeeats", 
    "age" : 27, 
    "birthday": "1995-10-29",
    "blog_url": "",
    "job": ["developer"], 
    "favorite_song": {"title":"fav_song","created_at":"2000-01-03"}
  }
}
{
  "_index" : "member",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 2,
  "result" : "updated",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 1,
  "_primary_term" : 1
}

Document全体を新たなDocumentに更新する場合は以下の形式のリクエストを実行します。

PUT /member/_doc/1
{ 
  "name": "Beats", 
  "age" : 27, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}
POST /member/_doc/1
{ 
  "name": "Beats", 
  "age" : 27, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
}

DELETE

DELETE /member/_doc/1
{
  "_index" : "membe",
  "_type" : "_doc",
  "_id" : "1",
  "_version" : 3,
  "result" : "deleted",
  "_shards" : {
    "total" : 2,
    "successful" : 1,
    "failed" : 0
  },
  "_seq_no" : 2,
  "_primary_term" : 1
}

Elasticsearch の基本用語

Elasticsearch に入門したということで基本の用語についてまとめました。

Document

  • Elasticsearch に格納するデータの一つの単位を指します。
  • RDS でいうレコードに相当します。
  • JSON形式のオブジェクトです。
  • Documentは一つ以上のFieldを持ちます。
  • IDは指定がなければ自動採番されます。

Field

  • Documentを表現するJSONオブジェクトの、KeyとValueの組み合わせを指します。
  • Fieldにはいくつかのデータ型が存在します。

www.elastic.cowww.elastic.co

以下に基本的な型のみ列挙しておきます。

text

  • 文字列を表す型です。
  • text型は格納される際に単語ごとに分割され、転置インデックスが構成されます。

keyword

  • 文字列を表す型です。
  • text型と異なる点として、単語が分割されません。
  • メールアドレスやURLなどの完全一致時のみヒットして欲しいような値を格納します。
  • keyword型でhttps://beatsbeats.hatenablog.com/を格納した場合、beatshatenablogで検索しても検索でヒットしません。

long,short,integer,float...etc

  • 数値を表す型です。(公式ページに詳しく記載があります。)

date

  • 日付を表す型です。
  • 下記のいずれかの形式を指定できます。
  • UNIXエポック(1970/1/1 0:00:00からのミリ秒表記):1420070400001
  • 日付のみ:"2015-01-01"
  • 日付+時刻:"2015-01-01T12:10:30Z"

boolean

  • 真偽を表す型です。

object

  • JSONオブジェクトを表す型です。(ネスト構造)

array

  • 配列を表す型です。
  • 配列の各要素は同じ型になります。

また、一つのFiledが複数のデータ型を持つことも可能です。(マルチフィールド型) 明示的に型を定義していない状態で文字列を格納した場合には、自動的にtextとkeywordが定義されます。 こちらは一見便利ですが、インデックスが増えて容量の増加に繋がります。

Documentの簡単な例
{ 
  "name": "Beats", 
  "age" : 26, 
  "birthday": "1994-10-29",
  "blog_url": "https://beatsbeats.hatenablog.com/",
  "job": ["monk","developer"], 
  "favorite_song": {"title":"fav_song","created_at":"2000-01-01"}
} 

Index

  • Documentを格納する場所を表します。
  • RDSでいうテーブルに相当します。
    • 元々はスキーマかデータベースに相当すると言われていましたが、後述のDocument typeが廃止になったためインデックス単位でデータの分別する他、データ格納場所を分ける方法がないためIndexがテーブルに相当すると言えます。
  • Documentがそのまま格納されているのではなく、検索しやすいように様々な形式で格納されている。

Document type

  • Documentの種別を表します。
  • 廃止となった概念です。詳細は下記の記事にあります。

www.elastic.co

Mapping

  • Documentの各Fieldのデータ型を定義した情報を表します。
  • Mappingを事前に定義していなくても、ドキュメント格納時に自動的にElasticsearch側でMappingの定義をしてくれます。



以下は実際にドキュメントをどのように格納しているのかを示す用語なので図も交えてまとめました。

Node

f:id:beatsbeats:20210207200138p:plain

  • Elasticsearch が動作するプロセスを指します。
  • 実体は一つのJVMインスタンスです。
  • 一つのサーバー上で一つのNodeが動作するのが基本ですが、複数のNodeを一つのサーバー上で動作させることも可能です。
  • 一意の名(node.name)を持ちます。
  • Nodeは役割ごとに4つの属性を持ちます。(詳細は別記事にて)

Cluster

f:id:beatsbeats:20210207200210p:plain

  • 互いに通信し合う複数のNodeの集合を指します。
  • 一意のCluster名(cluster.name)を持ちます。

Shard

f:id:beatsbeats:20210207200235p:plain

  • Elasticsearchはデータを複数のノードで分散保持することがですが、分散した際のそれぞれのパートを指します。
  • Shardの数は事前(Index作成時)に設定しておく必要があります。(作成後に増やせないので注意)

f:id:beatsbeats:20210207200258p:plain

Replica

f:id:beatsbeats:20210207200319p:plain

  • Shardの複製を指します。
  • 複製元のShardをPrimary Shardと呼び、複製されたShardをReplica Shardと呼びます。
  • Replica Shardは自動的にPrimary Shardとは別のNodeに作成されます。
  • Primary Shardが配置されているNodeの障害時には、ReplicaがPrimaryに昇格します。
  • Replica Shardの数はいつでも変更することが可能です。

参考

book.impress.co.jp

nextpublishing.jp

開発途中でプロジェクト名を変更した時は一括置換すること

僕の場合 Go を使用することが多いのですが、ディレクトリ名変更語にプロジェクト直下でgo buildをした時に古いプロジェクト名でバイナリファイルが作られ続け割と長い時間ハマりました。
これはgo.modのモジュール名を変え忘れたままだったことが原因でした。
かなり基本的なことですが二度と忘れないようにと記事にしました。

初歩的なミスに限ってなかなか原因に気づけないですね。。。