実践Node.jsパフォーマンスアップ|Stream編

この記事は、2021/7/8 に行われた WESEEK Tech Conference の内容です。

パフォーマンスって何だろう

パフォーマンスについて調べると次のような意味が出てきます。

パフォーマンスは、プロセス、システム、プロセッサー、ネットワーク、またはデバイスが、
特定の作業単位の負荷に対してどのように働くかを意味します。

出典: IBM 「パフォーマンスを特徴付ける条件」 から引用 https://www.ibm.com/docs/ja/zos/2.2.0?topic=tuning-how-is-performance-characterized

難しい定義を意識しなくとも、日常生活では直感的に感じているはずです。
例えば、Webサイトである操作を行なった時にその結果が反映されるまで時間がかかることや、動作が重くなってサイト自体が落ちることなど。

ユーザーの視点で見るとかなり表面的な部分が評価されます。

ユーザーは厳しいです。
少しでも使いづらいと感じると直ちに離脱の要因になってしまいます。

このスライドでは、ユーザーの離脱を防ぐために処理速度やメモリ消費を改善する方法について考えていきます。

ある問題へのパフォーマンスチューニングを依頼されたと仮定して
問題の認識、原因調査、戦略立案、実践・評価
を行い一緒に考えていきましょう 🤝

問題の認識

弊社プロダクトの GROWI を題材にみていきます。

3000件を超える大量のページを用意し、一度に移動しようとします。

すると、20秒ほど応答がないばかりか待機中に画面上の変化が一切ありませんでした。

GROWI では、ページについて以下の操作が可能です。

  • ページの移動
  • ページの複製
  • ページの削除(/trash に移動する)
  • ページの復元(/trash から元のパスに移動する)
  • ページの完全削除

全ての操作で同様の問題が発生していることがわかりました。
3000件は通常に運用していると簡単に超える件数です。
ページの大量操作に関するパフォーマンスアップは必要不可欠なようです。

原因調査

原因の調査のため該当の箇所のコードを参照してみます。

await Promise.all(pages.map((page) => {
  return this.completelyDeletePage(page, user, options);
}));

引用:https://github.com/weseek/growi/blob/v4.2.7/src/server/models/page.js#L1215-L1217

Promise.all 内で各ページについて Page.completelyDeletePage(page); を実行しています。
並列で個々のページに対して削除の処理を行なっているようです。

Page.completelyDeletePage(page); の中身の部分を確認してみます。

pageSchema.statics.completelyDeletePage = async function(pageData, user, options = {}) {
  // 中略
  const { _id, path } = pageData;

  // 中略
  await crowi.pageService.deleteCompletely(_id, path);

  return pageData;
};

引用:https://github.com/weseek/growi/blob/v4.2.7/src/server/models/page.js#L1189-L1203

async deleteCompletely(pageId, pagePath) {
  // 中略

  return Promise.all([
    Bookmark.removeBookmarksByPageId(pageId),
    Comment.removeCommentsByPageId(pageId),
    PageTagRelation.remove({ relatedPage: pageId }),
    ShareLink.remove({ relatedPage: pageId }),
    Revision.removeRevisionsByPath(pagePath),
    Page.findByIdAndRemove(pageId),
    Page.removeRedirectOriginPageByPath(pagePath),
    this.removeAllAttachments(pageId),
  ]);
}

引用:https://github.com/weseek/growi/blob/v4.2.7/src/server/service/page.js#L11-L30

どうやら内部でも並列で実行しているようです。
本当に複数処理によって処理時間がかかっているのでしょうか。
処理速度を実際に計測してみました。

performance.now() を用いて経過時間を計測します。

結果は、上記のようになりました。
100ページの場合は1秒に満たないですが、5000ページともなると 20秒以上もかかっています。
上記のコードの中に落とし穴があるようです。

戦略立案

まず DB への通信回数が多いのが原因ではないかと考えました。

ブックマークの削除、コメントの削除、タグの削除... というようにページ 1件につき、DB への接続が複数発生しています。
したがって、3000 件のページを操作するとさらにそれ以上の回数分 DB への通信が発生します。

Connection Pool を調整して、同時クエリ実行数を増やして対応する。
を検討します。

  • Connection Pool とは
    • データベースに接続した状態(Connection)をキャッシュしておき(Pool する)、必要に応じて Pool から接続を再利用する手法。
  • Connection Pool のメリット
    • Pool された接続数の分だけ同時に接続できるため DB 操作の処理時間は減らせる。
    • DB への接続と認証というコストの高い処理をあらかじめ行えるため、処理時間を減らせる。
  • Connection Pool のデメリット
    • 接続のキャッシュを Pool しておくのもメモリを必要とするため、多ければ多いほどリソースを圧迫する。
    • 接続を設定・切断するための時間がかかる。

mongoose では次のように設定できます。

mongoose.createConnection(uri, { poolSize: 20 });

const uri = 'mongodb://localhost:27017/test';
mongoose.createConnection(uri);

mongoose ではデフォルトで 5 Connections の接続が Pool されます。
今回の場合、Default の 5 Connections で動いていました。

つまり、

Connection Pool を 3000 に増やせば
3000 Page も対応できる!

ということでしょうか。

検討の結果、
Connection Pool 調整戦略は今回は採用しない 😭
こととしました。

Connection Pool は増やせば増やすほど維持するコストが高くります。

効果がありそうかつ、コストが妥当な最適な接続数を見極めるのには時間がかかりそうです。

一括書き込みをして最低限の接続数で処理する。
を検討します。

DB への接続が多くなってしまっていることが原因であることが先程の検討でわかりました。

そのため次のような方法で DB への接続回数を減らす戦略が取れないか考えます。

const pageIds = pages.map((page) => {
   return page._id
}));

await Bookmark.deleteMany({ page: { $in: pageIds } }),

あらかじめ map を用いて対象の PageId や PagePath を抽出して配列を生成します。
この処理は DB 接続がないので全ページを走破してもコストが低いです。

抽出した後に、一括書き込みを行い一度に複数件処理を行います。
これによってページが増えても DB への接続を数回に制限できそうです。

検討の結果、
一括書き込み戦略は 採用する...が考慮しないといけないことはまだある
という結論になりました。

一括書き込みを実施することで、DB への接続を数回に減らすことができます。
しかし、一括書き込みでも数千、数万となればメモリを大幅に消費することになりそうです。

今回の対象がページの件数なのであり得る件数です。

よってさらなる戦略が必要です。

Stream を使って一定量ずつ処理を行う。
を検討します。

Stream とはデータの流れを扱うための API です。

Readable:データを読み取ることができるストリーム
Writable:データを書き込むことができるストリーム
Duplex: ReadableとWritableの両方の性質を持つストリーム
Transform:データの書き込みおよび読み取り時にデータを変更または変換できるストリーム

の 4つの基本的なタイプがあります。

引用:https://nodejs.org/api/stream.html#stream_stream

Stream はメモリの状況を見て挙動を変えてくれるという利点があります。

読み込んだデータを逐次内部バッファに溜め込んでいき、
設定した閾値を大幅に超えないように次の Stream に流すことができます。

詳しくは、こちらの記事を参照ください。
https://techblog.yahoo.co.jp/advent-calendar-2016/node-stream-highwatermark/

今回は閾値を byte 数ではなく、ページ 1つ1つのオブジェクトごとに設定します。

実際に次のようなコードを実装します。

objectMode を true にすることで DB から読み込んだデータを 1ページ毎に処理できます。
batchBuffer という配列に溜め込んでいき、 100 件を超えたら次のストリームに流すように実装します。

検討の結果、
処理速度問題を一括書き込みで対応し、メモリ消費問題 -> Stream処理で対応することにしました。
という結論になりました。

実践

まずは一括書き込みから実装します。

async deleteMultipleCompletely(pages, user, options = {}) {
  const ids = pages.map(page => (page._id));
  const paths = pages.map(page => (page.path));

  await this.deleteCompletelyOperation(ids, paths);

  return;
}

あらかじめページの id と path の配列を用意します。
生成した配列を await this.deleteCompletelyOperation(ids, paths); に渡します。

async deleteCompletelyOperation(pageIds, pagePaths) {
  // ~中略~

  return Promise.all([
    Bookmark.deleteMany({ page: { $in: pageIds } }),
    Comment.deleteMany({ page: { $in: pageIds } }),
    PageTagRelation.deleteMany({ relatedPage: { $in: pageIds } }),
    ShareLink.deleteMany({ relatedPage: { $in: pageIds } }),
    Revision.deleteMany({ path: { $in: pagePaths } }),
    Page.deleteMany({ 
      $or: [{ 
        path: { $in: pagePaths } }, { path: { $in: redirectedFromPagePaths } }, { _id: { $in: pageIds } 
      }] 
    }),
    attachmentService.removeAllAttachments(attachments),
  ]);
}

deleteCompletelyOperation 内では、一括書き込みを行います。

これによって条件に一致するすべてのドキュメントを一括で削除し、DB への接続回数が 数回で済むようになりました。

続いて Stream の実装を行います。

評価

メモリの計測を行います。

setInterval(() => {
  const heapUsed = process.memoryUsage().heapUsed
  console.log('Heap:', Math.round(heapUsed / 1024 / 1024 * 100 / 100), 'MB');
}, 1000)

サーバー起動時に上記のコードを実行します。
これによって 1秒毎にメモリを計測できます。

計測の結果 450~ 480 MB まで上がっていたものが 80 MB前後で抑えられるようになりました。

速度の計測は以下のようになりました。

結果として全ての件数で 1/8 ほどの処理時間で行えるようになりました 🎉

まとめ

大量のデータを扱うのには次のことを考えなければいけません

  • 一括書き込みを使用して処理速度が改善できないか
  • Node stream を使用してメモリ消費を抑えられないか

今回の対処方法を実行することでかなり改善されます。
ユーザーを逃さないために

GROWI ではより複雑な例もあります。

readStream で読み込んだデータを Transform を使い整形して書き込んでいます。

ぜひ参考にしてみてください。

readStream
  .pipe(thinOutStream)
  .pipe(batchStream)
  .pipe(appendBookmarkCountStream)
  .pipe(appendTagNamesStream)
  .pipe(writeStream);

https://github.com/weseek/growi/blob/e66ed1ac2df86f8bbee2db2d09370a521d8a81ed/src/server/service/search-delegator/elasticsearch.js#L500-L505

著者プロフィール

itizawa

株式会社WESEEK / GROWI 開発エンジニア

ものづくりの境地に足を踏み入れ2年半...3度の飯よりコードを書くことが好きになりました。
今年はできることを増やしていきながら、なぜエンジニアとして生きているのか。答えを模索しています。

株式会社WESEEKについて

株式会社WESEEKは、システム開発のプロフェッショナル集団です。

【現在の主な事業】

  1. 通信大手企業の業務フロー自動化プロジェクト
  2. ソーシャルゲームの受託開発
  3. 自社発オープンソースプロダクト「GROWI」「GROWI.cloud」の開発

GROWI

GROWIは、Markdown記法でページを記述できるオープンソースのWikiシステムです。

GROWI.cloud

GROWI.cloudはOSSのGROWIを専門的知識がなくても簡単に運用・管理できる、法人・個人向けの商用サービスです。

大手SIer・ISPや中小企業、大学の研究室など様々な場所でご利用いただいております。

【主な特徴】

  • テキストも図表もどんどん書ける、強力な編集機能
  • チーム拡大に迅速に対応できる管理者向け機能を提供
  • 充実した機能・サポートでエンタープライズにも対応

【導入事例記事】
インターネットマルチフィード株式会社様
https://growi.cloud/interviews/mfeed/?utm_source=connpass-top&utm_medium=web-site&utm_campaign=mf

株式会社HIKKY(VR法人HIKKY)様
https://growi.cloud/interviews/hikky

WESEEK Tech Conference

WESEEK Tech Conferenceは、株式会社WESEEKが主催するエンジニア向けの勉強会です。
月に2回ほど、WESEEKに所属するエンジニアが様々なテーマで発表を行う予定です。

次回のWESEEK Tech Conferenceは「企業を超えたアジャイル+Railsを利用した開発の成功事例」!
木曜日が祝日のため、7/21(水)の19:00~20:00に開催予定です。

今回はバックエンドエンジニアの今間が登壇!
そして、WESEEKのお客さまであり開発案件を担当させていただいている、インターネットマルチフィード株式会社の杉本さんをゲストに迎えて2人でお送りします。

社内に開発部隊が存在しなかった状態から、アジャイル開発手法、Rails などの技術を取り入れ、どのように円滑に開発業務が進められるようになったのかをご紹介します。

現在、connpassやTECH PLAYで参加受付中です。皆様のご参加をお待ちしております!

https://weseek.connpass.com/event/218147/
https://techplay.jp/event/823418

一緒に働く仲間を募集しています

東京の高田馬場オフィス、大分にある別府サテライトオフィスにてエンジニアを募集しております。
中途採用だけではなく、インターンシップも積極的に受け入れています!

詳しい募集要項は、弊社HPの採用ページからご確認ください。