TypeORM の @RelationId デコレーターとパフォーマンス

July 12, 2021 - 読了時間 9 分
typeorm orm performance

TypeORM@RelationId というデコレーターがあります。

これは関連テーブルの主キー (外部キー) へのアクセサーを提供し、Entity オブジェクトを取得するときに自動的にプロパティとしてセットしてくれます。 アプリケーションの開発において、あれば便利といった機能の位置付けだと思います。

TL/DR

複数件の Entity オブジェクトを取得するリスト系の用途に使うなら @RelationId の機能は使わない方がよいです。

TypeORM の GitHub リポジトリの issue にもパフォーマンスの問題が登録されています。

issue 登録されていたものの、私はこの機能がボトルネックになっていると当初わからなかったため、 自分でデバッグしていてボトルネックを見つけてからこの issue に気付きました。 知っていないと、開発時にこの問題には気付きにくいと思います。

issue のタイトルにもある通り、 @RelationId を使うと取得件数に対して O(n^2) の計算量を要求します。 経験則では、ライブラリで O(n^2) の計算量を要求する機能をあまりみたことがありません。 ドキュメントにもパフォーマンスについての注意などもないため、(便利なので)安易に使ってしまいやすいかもしれません。

特に Node.js サーバーをシングルスレッドで運用する場合、 O(n^2) のような計算量を要求するような処理は、 たった1つのリクエストが他のリクエストを阻害してしまうので致命的な不具合となりえます。

TypeORM の作者も次のようにコメントしています。

@RelationId functionality is complex.. It’s subject of rework in next versions of typeorm. And its supposed to be used in simple cases (since it can fetch a lot otherwise) https://github.com/typeorm/typeorm/issues/3507#issuecomment-457468879

(意訳) @RelationId の機能は複雑です。TypeORM の次バージョンで作り直します。(大量にデータを取得するので)シンプルな用途のみを想定しています。

1つの @RelationId で O(n^2) になるので、例えば、 あるテーブルが5つの @RelationId をもっていると 5 * O(n^2) になります。

私が開発に関わっているアプリケーションでパフォーマンス検証した限りでは、 数百件程度 (n^2 で数万件のオーダー) までは大きな影響はありませんでした。 但し、千件を超えると秒単位でのレイテンシの差が出てきて、 それより大きくなると指数関数的にレイテンシの差が広がっていきました。

@RelationID のパフォーマンス検証

実際にサンプルアプリケーションを作ってパフォーマンスを検証してみます。 本稿で検証するサンプルアプリケーションは次のリポジトリにあります。

調査した環境は次になります。

環境構築

docker-compose を使って PostgreSQL のデータベースを作ります。

$ docker-compose up -d

次にテーブル作成とテストのための初期データを作成します。

$ yarn dbinit
initialize tables and create data
...

psql コマンドで接続してテーブルが作成されていることやデータがあることを確認してみましょう。

$ docker-compose exec postgres psql -h localhost -U root test

test=# \d post1
                                        Table "public.post1"
  Column   |            Type             | Collation | Nullable |              Default
-----------+-----------------------------+-----------+----------+-----------------------------------
 id        | integer                     |           | not null | nextval('post1_id_seq'::regclass)
 contents  | text                        |           | not null | ''::text
 createdAt | timestamp without time zone |           | not null | now()
 updatedAt | timestamp without time zone |           | not null | now()
 userId    | integer                     |           |          |
 attachId  | integer                     |           |          |
Indexes:
    "PK_5d96af62243b5566110655681a7" PRIMARY KEY, btree (id)
    "REL_391744cd3e041310c3d92fc246" UNIQUE CONSTRAINT, btree ("attachId")
Foreign-key constraints:
    "FK_1d098299fa1236d2895e76559f3" FOREIGN KEY ("userId") REFERENCES "user"(id)
    "FK_391744cd3e041310c3d92fc2464" FOREIGN KEY ("attachId") REFERENCES attach(id)
Referenced by:
    TABLE "post1_categories_category" CONSTRAINT "FK_659193a73a9bae5acd033a83562" FOREIGN KEY ("post1Id") REFERENCES post1(id) ON UPDATE CASCADE ON DELETE CASCADE

test=# select * from post1 limit 3;
 id |  contents  |         createdAt          |         updatedAt          | userId | attachId 
----+------------+----------------------------+----------------------------+--------+----------
  1 | contents-1 | 2021-07-12 01:42:12.628818 | 2021-07-12 01:42:12.628818 |      1 |        1
  2 | contents-2 | 2021-07-12 01:42:12.628818 | 2021-07-12 01:42:12.628818 |      2 |        2
  3 | contents-3 | 2021-07-12 01:42:12.628818 | 2021-07-12 01:42:12.628818 |      3 |        3
(3 rows)

test=# select count(*) from post1;
 count 
-------
 10000
(1 row)

検証対象の Entity オブジェクト

@ManyToMany@ManyToOne@OneToOne の関連をもつ3つの Entity を用意します (後述) 。

TypeORM でデータを取得するとき、大きくわけて2通りの API があります。

Repository API は高レベル API となっており、内部的には Query Builder を使います。 Query Builder を使うやり方の方が生成したい SQL をカスタマイズできます。

Repository.find() と QueryBuilder.getMany() では Eager relations の扱いが異なります。 この後、Post1, Post2, Post3 の Entity を説明しながら REPL を使って振る舞いを確認していきます。

Post1

categoryIds と userId に @RelationId によるプロパティをもちます。 また categories と attach は Lazy relations で取得するようにしています。

@Entity()
export class Post1 extends Base<Post1> {
  @PrimaryGeneratedColumn()
  id: number;
  ...
  @ManyToMany((type) => Category, (category) => category.posts1, {
    nullable: true,
    lazy: true,
  })
  @JoinTable()
  categories: Promise<Category[]>;

  @RelationId((post: Post1) => post.categories)
  categoryIds: number[];

  @ManyToOne((type) => User, (user) => user.posts1)
  user: User;

  @RelationId((post: Post1) => post.user)
  userId: number;

  @OneToOne((type) => Attach, (attach) => attach.post1, {
    nullable: true,
    lazy: true,
  })
  @JoinColumn()
  attach: Promise<Attach>
}

それぞれの Entity の設定と振る舞いの違いがわかりやすいように REPL を使って確認していきます。

$ yarn repl
... (デバッグ用に SQL を出力しています)
... (Enter を入力するとプロンプトが表示されます)
>

post1 の Entity を取得したタイミングで @RelationId の機能により categoryIds と userId をプロパティに保持していることがわかります。Repository API を使うときも Query Builder を使うときも違いはありません。

> const post1 = await getConnection().getRepository(Post1).findOne()
query: SELECT "Post1"."id" AS "Post1_id", "Post1"."contents" AS "Post1_contents", "Post1"."createdAt" AS "Post1_createdAt", "Post1"."updatedAt" AS "Post1_updatedAt", "Post1"."userId" AS "Post1_userId", "Post1"."attachId" AS "Post1_attachId" FROM "post1" "Post1" LIMIT 1
query: SELECT "Post1_categories_rid"."post1Id" AS "post1Id", "Post1_categories_rid"."categoryId" AS "categoryId" FROM "category" "category" INNER JOIN "post1_categories_category" "Post1_categories_rid" ON ("Post1_categories_rid"."post1Id" = $1 AND "Post1_categories_rid"."categoryId" = "category"."id") ORDER BY "Post1_categories_rid"."categoryId" ASC, "Post1_categories_rid"."post1Id" ASC -- PARAMETERS: [1]

> post1
Post1 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:12.628Z,
  updatedAt: 2021-07-11T16:42:12.628Z,
  categoryIds: [ 1, 2, 3 ],
  userId: 1
}
> await getConnection().getRepository(Post1).createQueryBuilder().limit(1).getOne()

Post1 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:12.628Z,
  updatedAt: 2021-07-11T16:42:12.628Z,
  categoryIds: [ 1, 2, 3 ],
  userId: 1
}

Lazy relations として設定した categories と attach は await を使って参照したときに SQL が発行されてそれぞれの Entity が生成されていることがわかります。categories は1つの post に対してすべて3件ずつ紐付いています。

> await post1.categories
query: SELECT "categories"."id" AS "categories_id", "categories"."name" AS "categories_name" FROM "category" "categories" INNER JOIN "post1_categories_category" "post1_categories_category" ON "post1_categories_category"."post1Id" IN ($1) AND "post1_categories_category"."categoryId"="categories"."id" -- PARAMETERS: [1]

[
  Category { id: 1, name: 'category-1' },
  Category { id: 2, name: 'category-2' },
  Category { id: 3, name: 'category-3' }
]

> await post1.attach
query: SELECT "attach"."id" AS "attach_id", "attach"."attr" AS "attach_attr" FROM "attach" "attach" INNER JOIN "post1" "Post1" ON "Post1"."attachId" = "attach"."id" WHERE "Post1"."id" IN ($1) -- PARAMETERS: [1]
query: SELECT "Post1_categories_rid"."post1Id" AS "post1Id", "Post1_categories_rid"."categoryId" AS "categoryId" FROM "category" "category" INNER JOIN "post1_categories_category" "Post1_categories_rid" ON ("Post1_categories_rid"."post1Id" = $1 AND "Post1_categories_rid"."categoryId" = "category"."id") ORDER BY "Post1_categories_rid"."categoryId" ASC, "Post1_categories_rid"."post1Id" ASC -- PARAMETERS: [null]

Attach { id: 1, attr: 'attr-1' }

attach を取得するときに categries に関する SQL が発行されているのは意図したものではありませんが、ここでは無視しておきます。

Post2

Post1 と比べて @RelationId をもたない Entity を作成します。

@Entity()
export class Post2 extends Base<Post2> {
  @PrimaryGeneratedColumn()
  id: number;
  ...
  @ManyToMany((type) => Category, (category) => category.posts2, {
    nullable: true,
    lazy: true,
  })
  @JoinTable()
  categories: Promise<Category[]>;

  @ManyToOne((type) => User, (user) => user.posts2)
  user: User;

  @OneToOne((type) => Attach, (attach) => attach.post2, {
    nullable: true,
    lazy: true,
  })
  @JoinColumn()
  attach: Promise<Attach>
}

同様に REPL を使って Entity を取得します。 @RelationId をもっていないので categoryIds と userId はありません。 Repository API を使うときも Query Builder を使うときも違いはありません。

> const post2 = await getConnection().getRepository(Post2).findOne()
query: SELECT "Post2"."id" AS "Post2_id", "Post2"."contents" AS "Post2_contents", "Post2"."createdAt" AS "Post2_createdAt", "Post2"."updatedAt" AS "Post2_updatedAt", "Post2"."userId" AS "Post2_userId", "Post2"."attachId" AS "Post2_attachId" FROM "post2" "Post2" LIMIT 1

> post2
Post2 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:13.392Z,
  updatedAt: 2021-07-11T16:42:13.392Z
}

Lazy relations は同じ設定なので await を使って categories と attach を参照できます。

> await post2.categories
[
  Category { id: 1, name: 'category-1' },
  Category { id: 2, name: 'category-2' },
  Category { id: 3, name: 'category-3' }
]
> await post2.attach
Attach { id: 1, attr: 'attr-1' }

Lazy relations を一度参照すると、Entity は次のような構造になります。

> post2
Post2 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:13.392Z,
  updatedAt: 2021-07-11T16:42:13.392Z,
  __categories__: [
    Category { id: 1, name: 'category-1' },
    Category { id: 2, name: 'category-2' },
    Category { id: 3, name: 'category-3' }
  ],
  __has_categories__: true,
  __attach__: Attach { id: 1, attr: 'attr-1' },
  __has_attach__: true
}
> await getConnection().getRepository(Post2).createQueryBuilder().limit(1).getOne()
query: SELECT "Post2"."id" AS "Post2_id", "Post2"."contents" AS "Post2_contents", "Post2"."createdAt" AS "Post2_createdAt", "Post2"."updatedAt" AS "Post2_updatedAt", "Post2"."userId" AS "Post2_userId", "Post2"."attachId" AS "Post2_attachId" FROM "post2" "Post2" LIMIT 1

Post2 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:13.392Z,
  updatedAt: 2021-07-11T16:42:13.392Z
}

Post3

Post2 と比べて、categories と attach を Eager relations として設定した Entity を作成します。

  ...
  @ManyToMany((type) => Category, (category) => category.posts3, {
    nullable: true,
    eager: true,
  })
  @JoinTable()
  categories: Category[]
  ...
  @OneToOne((type) => Attach, (attach) => attach.post3, {
    nullable: true,
    eager: true,
  })
  @JoinColumn()
  attach: Attach;
  ...

REPL を使って Entity を取得します。

post3 を取得したタイミングで categories と attach も取得されていることがわかります。

> const post3 = await getConnection().getRepository(Post3).findOne()
query: SELECT DISTINCT "distinctAlias"."Post3_id" as "ids_Post3_id" FROM (SELECT "Post3"."id" AS "Post3_id", "Post3"."contents" AS "Post3_contents", "Post3"."createdAt" AS "Post3_createdAt", "Post3"."updatedAt" AS "Post3_updatedAt", "Post3"."userId" AS "Post3_userId", "Post3"."attachId" AS "Post3_attachId", "Post3_categories"."id" AS "Post3_categories_id", "Post3_categories"."name" AS "Post3_categories_name", "Post3_attach"."id" AS "Post3_attach_id", "Post3_attach"."attr" AS "Post3_attach_attr" FROM "post3" "Post3" LEFT JOIN "post3_categories_category" "Post3_Post3_categories" ON "Post3_Post3_categories"."post3Id"="Post3"."id" LEFT JOIN "category" "Post3_categories" ON "Post3_categories"."id"="Post3_Post3_categories"."categoryId"  LEFT JOIN "attach" "Post3_attach" ON "Post3_attach"."id"="Post3"."attachId") "distinctAlias" ORDER BY "Post3_id" ASC LIMIT 1
query: SELECT "Post3"."id" AS "Post3_id", "Post3"."contents" AS "Post3_contents", "Post3"."createdAt" AS "Post3_createdAt", "Post3"."updatedAt" AS "Post3_updatedAt", "Post3"."userId" AS "Post3_userId", "Post3"."attachId" AS "Post3_attachId", "Post3_categories"."id" AS "Post3_categories_id", "Post3_categories"."name" AS "Post3_categories_name", "Post3_attach"."id" AS "Post3_attach_id", "Post3_attach"."attr" AS "Post3_attach_attr" FROM "post3" "Post3" LEFT JOIN "post3_categories_category" "Post3_Post3_categories" ON "Post3_Post3_categories"."post3Id"="Post3"."id" LEFT JOIN "category" "Post3_categories" ON "Post3_categories"."id"="Post3_Post3_categories"."categoryId"  LEFT JOIN "attach" "Post3_attach" ON "Post3_attach"."id"="Post3"."attachId" WHERE "Post3"."id" IN (1)

> post3
Post3 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:14.180Z,
  updatedAt: 2021-07-11T16:42:14.180Z,
  categories: [
    Category { id: 1, name: 'category-1' },
    Category { id: 2, name: 'category-2' },
    Category { id: 3, name: 'category-3' }
  ],
  attach: Attach { id: 1, attr: 'attr-1' }
}

Eager relations は Repository.find 系の API でしか動作しません。

> await getConnection().getRepository(Post3).createQueryBuilder().limit(1).getOne()
query: SELECT "Post3"."id" AS "Post3_id", "Post3"."contents" AS "Post3_contents", "Post3"."createdAt" AS "Post3_createdAt", "Post3"."updatedAt" AS "Post3_updatedAt", "Post3"."userId" AS "Post3_userId", "Post3"."attachId" AS "Post3_attachId" FROM "post3" "Post3" LIMIT 1

Post3 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:14.180Z,
  updatedAt: 2021-07-11T16:42:14.180Z
}

Query Builder を使うときは leftJoinAndSelect() を使って明示的にテーブルを結合して取得する必要があります。

> await getConnection().getRepository(Post3).createQueryBuilder("post")
    .leftJoinAndSelect("post.categories", "category")
    .leftJoinAndSelect("post.attach", "attach").getOne()
query: SELECT "post"."id" AS "post_id", "post"."contents" AS "post_contents", "post"."createdAt" AS "post_createdAt", "post"."updatedAt" AS "post_updatedAt", "post"."userId" AS "post_userId", "post"."attachId" AS "post_attachId", "category"."id" AS "category_id", "category"."name" AS "category_name", "attach"."id" AS "attach_id", "attach"."attr" AS "attach_attr" FROM "post3" "post" LEFT JOIN "post3_categories_category" "post_category" ON "post_category"."post3Id"="post"."id" LEFT JOIN "category" "category" ON "category"."id"="post_category"."categoryId"  LEFT JOIN "attach" "attach" ON "attach"."id"="post"."attachId"

Post3 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-11T16:42:14.180Z,
  updatedAt: 2021-07-11T16:42:14.180Z,
  categories: [
    Category { id: 1, name: 'category-1' },
    Category { id: 2, name: 'category-2' },
    Category { id: 3, name: 'category-3' }
  ],
  attach: Attach { id: 1, attr: 'attr-1' }
}

ER 図

整理のために ER 図は次のようになります。

Entity の構造 (設定) は意図的に差異を加えていますが、データベースからみたときのテーブルの構造と関連は全く同じとなるよう Post1, Post2, Post3 のテーブルを作っています。

relation id tables

@RelationID 検証のためのテーブル

この ER 図は eralchemy というツールで出力できます。

$ eralchemy -i 'postgresql+psycopg2://root:password@localhost:15432/test' \
    -o relation-id-tables.png \
    --include-tables post1 post2 post3 user category attach \
                     post1_categories_category \
                     post2_categories_category \
                     post3_categories_category

検証対象の Entity とデータ数

それぞれ1万件程度のデータを生成しています。

> await getConnection().getRepository(Post1).count()
10000
> await getConnection().getRepository(Post2).count()
10000
> await getConnection().getRepository(Post3).count()
10000
> await getConnection().getRepository(User).count()
10000
> await getConnection().getRepository(Attach).count()
10000
> await getConnection().getRepository(Category).count()
100

@ManyToMany の関連をもつ categories の関連テーブルは1つの Entity に3件ずつ、合計3万件になります。

test=# select count(1) from post1_categories_category ;
 count 
-------
 30000
test=# select count(1) from post2_categories_category ;
 count 
-------
 30000
test=# select count(1) from post3_categories_category ;
 count 
-------
 30000

Entity の設定違いによるベンチマーク

次のようなベンチマークのためのテストを書いてみました。 Query Builder を使ったこの方法だと Post3 では Eager loading しないのでその違いも後でみてみます。

async function getMany<T>(repo: Repository<T>, n: number): Promise<number> {
  const start = new Date();
  await repo.createQueryBuilder().limit(n).getMany();
  const end = new Date();
  return getElapsedTime(start, end);
}

作成した実際のテストでは次の3つの API のベンチマークを取得しました。

$ yarn test --testNamePattern RelationId
  ● Console
      RelationId Repository.find()
      RelationId QueryBuilder.getMany()
      RelationId QueryBuilder.getRawMany()

私のマシンで実行した結果は次のようになりました。

まず find()getMany() では @RelationId を用いた post1 で取得件数が増えるごとに実行時間が大きく増えていくことが確認できます。 これが取得件数に対して O(n^2) の計算量を要求するということです。

また getMany() は Entity オブジェクトを生成し、 getRawMany() は SQL を実行して返ってきたデータを Entity 型ではなく JavaScript の Object 型で返します。 メソッド名の通り、生データを取得するための API になるので Entity 設定の違いに依らず実行時間はほぼ同じになります。

TypeORM では getMany()getRawMany() は意識して使い分けることが重要です。 この結果からもわかるように getMany() を使って Entity を生成するときに様々な処理が行われており、 それがパフォーマンスに大きく影響を与える可能性があるからです。

Post3 (Eager relations) の find()getMany() を比べると、Eager loading していることによる差異を確認できます。 さらに (大きな差ではないですが) Lazy relations の post2 よりも、 実際には Eager loading しない post3 の方が速い結果になっていることもわかります。 Lazy relations に関するパフォーマンスの問題もまた別の記事で書いてみたいと思います。

まとめ

現実のアプリケーションの例として、 私が開発に関わっているアプリケーションでは type-graphqltype-graphql-dataloader を使っています。

v0.3.7 までの type-graphql-dataloader では README から引用すると、 次のように TypeORM の @RelationId を使って @TypeormLoader という DataLoader の機能を提供していました。

@ObjectType()
@Entity()
export class Photo {
  @Field((type) => ID)
  @PrimaryGeneratedColumn()
  id: number;

  @Field((type) => User)
  @ManyToOne((type) => User, (user) => user.photos)
  @TypeormLoader((type) => User, (photo: Photo) => photo.userId)
  user: User;

  @RelationId((photo: Photo) => photo.user)
  userId: number;
}

DataLoader のパラメーターのためにすべての Entity が @RelationId を保持していて、 Entity を取得する件数によって致命的なパフォーマンスの問題を引き起こしていました。

Add SimpleTypeormLoader without using typeorm @RelationId の PR がマージされ、v0.4.0 からは @RelationId を必要としない実装に改善されています。

このように安易に TypeORM の @RelationId を使うと意図しないパフォーマンス問題を引き起こす可能性があるのでご注意ください。

企業として Github Sponsors になる Typeorm の Lazy relations とメモリ使用量

関連記事

TypeORM の Bulk Insert と psql の \copy を比較する

July 21, 2021 - 読了時間 6 分
typeorm postgres performance

Node.js の REPL 環境をカスタマイズする

July 20, 2021 - 読了時間 2 分
node.js repl typeorm

Typeorm の Distinct を伴うクエリとパフォーマンス

July 13, 2021 - 読了時間 10 分
typeorm orm performance