Typeorm の Lazy relations とメモリ使用量

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

TypeORM では、データベースから Entity を取得するときに関連している Entity を2種類の方法で取得できます。 ドキュメントは Eager and Lazy Relations になります。

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

ここで Eager relations で関連している Entity を取得するには Repository API の find 系メソッドを使う必要があります。 前記事の TypeORM の @RelationId デコレーターとパフォーマンス で REPL を使って振る舞いの違いを確認しているのでそちらも参考にしてください。

どちらかと言えば、多くのアプリケーションで Eager よりも Lazy relations を使う機会の方が多いのではないでしょうか。 本稿では Lazy relations を使ったときに多くのメモリを消費していることに気付いたので調査方法を含めて整理しておきます。 Node.js/V8 のヒープメモリについて、私の理解が浅いので説明が誤っている箇所もあるかもしれません。ご注意ください。

TL/DR

TypeORM で Lazy relations を設定すると、設定したプロパティ数に比例して1つの Entity 単位でメモリを消費します。

あるとき、複数のテーブルを結合して数百件から数千件程度のデータを取得した際、 --max-old-space-size に 512 MiB が設定された Node.js サーバーで OOM (Out Of Memory) が発生したことで気付きました。

TypeORM の GitHub リポジトリの issue にも複雑な関連をもつとメモリを消費するといった issue が報告されています。

少し前に #4499 の issue は #2381 の duplicate として閉じられました。しかし、#2381 の issue ではあまりメモリ使用量については言及されていないため、元の issue のリンクも紹介しておきます。 2-3年前からある issue なので、古いコメントの一部は改善しているかもしれません。issue の内容に目を通しても何が原因なのかよくわかりません。

私が開発に関わっているアプリケーションだと Lazy relations の設定を削除すると、削除前より20-30%程度、メモリ使用量が削減するようにみえました。

Lazy relations のメモリ使用量を測る

実際にサンプルアプリケーションを作ってメモリの使用量を計測してみます。 本稿で検証するサンプルアプリケーションは次のリポジトリにあります。

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

環境構築

次のコマンドで構築できます。

$ docker-compose up -d
$ yarn dbinit

詳細は前記事の TypeORM の @RelationId デコレーターとパフォーマンス の環境構築を参照してください。

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

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

Post1 は前記事の検証用途だったので Post2 から始まっています。

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

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

Post2

categories と attach を Lazy relations で設定します。 オプションで {lazy: true} を設定しなくても型として Promise を指定すると TypeORM は自動的に Lazy relations として扱います。 user はオプションで lazy も eager も指定していないので自動的には取得されません。

@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 を取得すると、次の Entity を取得します。

> await getConnection().getRepository(Post2).findOne()
Post2 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-12T00:31:58.718Z,
  updatedAt: 2021-07-12T00:31:58.718Z
}

Post3

Post2 と比べて、categories と attach を Eager relations として扱います。

Entity()
export class Post3 extends Base<Post3> {
  @PrimaryGeneratedColumn()
  id: number;
  ...
  @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;
}

ここで categories として定義している Category の定義は次のようにそれぞれの Post オブジェクトに対する双方向の関連をもち、Lazy relations として設定されています。 Post4 との比較に使うので頭の片隅に入れておいてください。

@Entity()
export class Category extends Base<Category> {
  @PrimaryGeneratedColumn()
  id: number;

  @Column("text", { default: "" })
  name: String;

  @ManyToMany((type) => Post1, (post) => post.categories, {
    nullable: true,
    lazy: true,
  })
  posts1: Promise<Post1[]>;

  @ManyToMany((type) => Post2, (post) => post.categories, {
    nullable: true,
    lazy: true,
  })
  posts2: Promise<Post2[]>;

  @ManyToMany((type) => Post3, (post) => post.categories, {
    nullable: true,
    lazy: true,
  })
  posts3: Promise<Post3[]>;
}

REPL を使って Entity を取得すると、次の Entity を取得します。

Post3 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-12T00:31:59.497Z,
  updatedAt: 2021-07-12T00:31:59.497Z,
  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' }
}

Post4

Post3 と同様、categories と attach を Eager relations として扱います。

@Entity()
export class Post4 extends Base<Post4> {
  @PrimaryGeneratedColumn()
  id: number;
  ...
  @ManyToMany((type) => Category4, {
    nullable: true,
    eager: true,
  })
  @JoinTable()
  categories: Category4[];
  ...
  @OneToOne((type) => Attach, (attach) => attach.post4, {
    nullable: true,
    eager: true,
  })
  @JoinColumn()
  attach: Attach;
}

Post3 との違いとして categories として Category4 という別の Entity を定義しています。 Category4 は Post オブジェクトに対する関連 (Lazy relations) をもっていないことが違いです。

@Entity()
export class Category4 extends Base<Category4> {
  @PrimaryGeneratedColumn()
  id: number;

  @Column("text", { default: "" })
  name: String;
}

REPL を使って Entity を取得すると、次の Entity を取得します。 テーブルの構造/データの内容は Post3 と全く同じです。

Post4 {
  id: 1,
  contents: 'contents-1',
  createdAt: 2021-07-12T00:32:00.284Z,
  updatedAt: 2021-07-12T00:32:00.284Z,
  categories: [
    Category4 { id: 1, name: 'category-1' },
    Category4 { id: 2, name: 'category-2' },
    Category4 { id: 3, name: 'category-3' }
  ],
  attach: Attach { id: 1, attr: 'attr-1' }
}

ER 図

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

memory profile tables

メモリ使用量の検証のためのテーブル

Category が Lazy relations をもつときとそうでないときを検証するために Category4 を別に定義しています。 Entity の構造 (設定) は意図的に差異を加えていますが、データベースからみたときのテーブルの構造と関連は基本的に同じ構成となるよう Post2, Post3, Post4 のテーブルを作っています。

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

$ eralchemy -i 'postgresql+psycopg2://root:password@localhost:15432/test' \
    -o memory-profile-tables.png \
    --include-tables post2 post3 post4 user category category4 attach \
                     post2_categories_category \
                     post3_categories_category \
                     post4_categories_category4

Node.js (V8) のヒープメモリの使用量を測る

Node.js (V8) のヒープのメモリ使用量を測る方法はいくつかあります。

Node.js の process.memoryUsage() API を使う方法や node-heapdump のライブラリでヒープのスナップショットを取得する方法も試してみました。

最終的にソースを書き換えながら意図したタイミングでヒープのスナップショットを取得する方法として、 Chrome DevTools を使う方法がもっとも簡単だったのでその方法を紹介します。

次の URI で Chrome のウィンドウを開きます。

chrome://inspect/
Chrome DevTools inspect 1

Chrome DevTools inspect の画面1

node コマンドに --inspect オプションを指定してアプリケーションを起動します。

$ node --inspect --require ts-node/register/transpile-only src/memoryProfile.ts
Debugger listening on ws://127.0.0.1:9229/9515df11-4992-481e-aa76-22387262dee9
For help, see: https://nodejs.org/en/docs/inspector

Remote Target の下に起動したアプリケーションの情報が表示されます。

Chrome DevTools inspect 2

Chrome DevTools inspect の画面2

inspect というリンクを選択して Memory タブを選択すると、次のようなメモリープロファイルの画面が開きます。

Chrome DevTools Memory Profile

Chrome DevTools の Memory Profile 画面

アプリケーションに接続できていれば Take snapshot ボタンを選択すると、 ヒープのスナップショットが取得されます。

Chrome DevTools Heap Snapshot

Chrome DevTools の Heap Snapshot 画面

Node.js (V8) ではスナップショットを取得する前に GC が実行されます。 スナップショットを取得する瞬間の GC 待ちのメモリも含めたヒープの状態を取得することはできないようにみえます。 またメモリを大量に使って負荷のかかっている状況でスナップショットを取得しようとすると、 私の環境では Segmentation fault が発生して取得できませんでした。

Segmentation fault (core dumped)

いくつか Node.js のバージョンを変えてみたり、 core の中身からエラーが発生している関数を検索したりしてみたのですが、 よくわかりませんでした。

そこで負荷をかけながらヒープのスナップショットを取得することは諦めて、 確認したい TypeORM の Entity をグローバルに保持するようにして調査を進めました。

本稿では紹介しませんが、 Node.js のメモリに関する調査をするときに便利なオプションをいくつか紹介しておきます。

サンプルアプリケーションの Entity のメモリ使用量を測る

Chrome DevTools を使って実際にサンプルアプリケーションの Entity サイズをみていきます。次のようにしてサーバーアプリケーションを起動します。

$ yarn memoryProfile

意図したタイミングでヒープのスナップショットを取得できれば、サーバーアプリケーションである必要はありません。 私がヒープのスナップショットを取得するプログラムをうまく実装できなかったため、 サーバーアプリケーションにして Chrome DevTools からリモートデバッグできるようにしています。

var post2: Post2[];
var post3: Post3[];
var post4: Post4[];

async function loadPosts() {
  const connection = getConnection();
  const post2Repo = connection.getRepository(Post2);
  const post3Repo = connection.getRepository(Post3);
  const post4Repo = connection.getRepository(Post4);

  post2 = await post2Repo.find({ take: 100 });
  console.log("post2", post2[0]);
  post3 = await post3Repo.find({ take: 100 });
  console.log("post3", post3[0]);
  post4 = await post4Repo.find({ take: 100 });
  console.log("post4", post4[0]);

  console.log("success loading posts");
}

export async function main() {
  await connect(false);
  await loadPosts();
  const server = http.createServer(async (req, res) => {
    res.writeHead(200);
    res.end("OK");
  });
  server.listen({ host: "localhost", port: 18080 });
}

起動後に Post2, Post3, Post4 をグローバルの領域に保持してスナップショットに現れるようにしています。 もっとよいやり方があると思いますが、私がわからなかったのでこんなやり方になっています。

前節で紹介したように Chrome DevTools で接続してスナップショットを取得します。

Summary の横にある Class filter で “Post” と入力すると、 グローバルに保持しておいた Post2, Post3, Post4 の Entity がフィルターされます。

Chrome DevTools Post Entity size

Chrome DevTools Post Entity のサイズ

次の2つのサイズがあります。

スナップショットをみると Shallow Size より Retained Size が小さくなることはないため、 メモリの使用量を考慮するときは Retained Size をみておくとよい気がします。 Retained Size をどうやって算出しているのか、 自分で計算しようと試みたのですが、よくわかりませんでした。

TypeORM の Entity のメモリ使用量についての考察

いまそれぞれの Post の Entity を100件ずつ保持しています。

Retained Size を比較すると、 Post3 が 631KiB と最も大きく、次に Post2 が 182KiB、Post4 が 79KiB となっています。

まず Post2 と Post4 を比較してみましょう。

Heap Snapshot Post2 Detail

ヒープスナップショット Post2 の詳細

Heap Snapshot Post4 Detail

ヒープスナップショット Post4 の詳細

Post2 と Post4 の違いは Lazy と Eager による関連する Entity の取得タイミングの違いです。 Shallow Size は Post4 の方が大きくなっているのは Eager loading によって関連する Entity を取得しているからだと推測します。

Shallow Size と Retained Size の数字は単純にビューをドリルダウンしていった数値の合計とは一致しないので特別な計算方法があるようにみえます。 但し、どの要素の Retained Size が大きいかをみていくと、なんとなくメモリを消費しているところを推測できるかもしれません。

TypeORM では Lazy relation の機能を提供するために RelationLoader を使います。 Object.defineProperty() で Entity ごとに setter/getter を設定しています。 クロージャで定義しているので環境情報を含めてメモリが消費されているのかなと推測されます。

次に最もメモリ使用量の大きかった Post3 の詳細をみていきます。

Heap Snapshot Post3 Detail

ヒープスナップショット Post3 の詳細

Post 3 は Post4 と同様、categroies を Eager loading で取得します。 しかし、Post3 の categroies のそれぞれの要素の Retained Size は Post4 のそれらとは大きくサイズが異なります。 意図的に Lazy relations をもつ Category (しかも3つ!) と、それをもたない Category4 の違いです。

ヒープのスナップショットの Retained Size のみをみる限り、 取得した Entity の Lazy relations が多いほど、 Retained Size のサイズが大きくなる傾向があることがわかりました。

本稿の冒頭で紹介した issue では複雑な関連をもつ Entity を取得すると、 メモリを大きく消費するといった内容がいくつも報告されていました。 この調査結果からもわかるように関連する Entity を一緒に取得すると、 それらの Entity に設定されている Lazy relations に対して RelationLoader が設定され、 その数に比例してメモリの消費量が増えていきます。

RelationLoader がすべてではないかもしれませんが、 いくらかメモリ消費に関連しているのではないかと本稿では推測しています。

まとめ

TypeORM の Lazy relations も必須の機能ではありません。

少しでもメモリの消費量を抑えたいなら使わないようにすればよいです。 しかし、そうすると同時に ORM を使っている利便性も失われていくので悩ましい問題になるかもしれません。

リファレンス

Node.js (V8) のメモリの使用量を調査するときに参考になった記事をまとめておきます。

TypeORM の Lazy relations は Hidden Class が新たに生成されているわけではありませんが、ヒープのスナップショットを調査しているときに参考になったので一緒に紹介しておきます。

TypeORM の @RelationId デコレーターとパフォーマンス Typeorm の Distinct を伴うクエリとパフォーマンス

関連記事

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