送信確認のメールが届きます。
お問い合わせ内容に応じて、24〜72時間以内に担当者よりご連絡いたします。
送信することで、当社の【プライバシーポリシー】および、Open Reach Techからのメール受信に同意したものとみなします。
プライバシー同意チェックボックスを選択してください。

components..title

components..description

components..title

components..description

送信確認のメールが届きます。
お問い合わせ内容に応じて、24〜72時間以内に担当者よりご連絡いたします。
送信することで、当社の【プライバシーポリシー】および、Open Reach Techからのメール受信に同意したものとみなします。
プライバシー同意チェックボックスを選択してください。

JOIN上限61テーブルの壁!Sequelizeの separate: true で巨大クエリを分割・回避したアーキテクチャのトレードオフ

Yuki Koのプロフィール写真
Yuki KoBackend Developer

Node.jsとSequelizeを用いた開発で直面した、MariaDB(MySQL)の「JOIN上限61テーブル」エラー。巨大なクエリを回避した方法と、そのアーキテクチャのトレードオフについて解説します。

Banner of JOIN上限61テーブルの壁!Sequelizeの separate: true で巨大クエリを分割・回避したアーキテクチャのトレードオフ

1. はじめに

こんにちは。今回は、Node.jsとSequelize、そしてMariaDBを用いた開発において、少し複雑なデータ構造を扱おうとした際に直面したRDBMSの限界と、その解決策について共有したいと思います。

現在、私はSalesforceのシステムを再現するような、比較的大規模な受託開発プロジェクトに携わっています。Salesforceは非常に多機能で強力なプラットフォームですが、運用コストの観点から自社専用のシステムをスクラッチで開発し、そこへ移行したいというお客様のニーズに応えるためのプロジェクトです。

技術スタックとしては、バックエンドに Node.js とORMの Sequelize を採用し、データベースには MariaDB を使用しています。

この開発を進める中で、Salesforce特有の動的で複雑なデータ構造を一般的なRDBMSで表現しようとした結果、MariaDB(およびMySQL)に存在する「ある上限値」に激突してしまいました。本記事では、そのエラーが発生した背景と、Sequelizeの機能を活用してどのように回避したのかをご紹介します。

2. 直面した課題:List View機能の再現とエラーの発生

Salesforceには「List View(リストビュー)」という非常に便利な機能があります。これは、各オブジェクト(例えば「商談」や「取引先」など)から、ユーザーが事前に設定した条件に基づいてデータを抽出し、柔軟にリスト形式で一覧表示できる機能です。

今回のプロジェクトでも、このList Viewと同等の機能を再現することが重要な要件の一つでした。

システム設計において、Salesforce上の「1つのオブジェクト」を単純に「1つの巨大なテーブル」としてマッピングしてしまうと、柔軟性や拡張性が損なわれてしまいます。そのため、今回のデータモデルでは、1つのオブジェクトに対してメインとなるテーブルを置き、そこに紐づく関連データは複数のテーブルに分割してリレーションを張るという、「正規化」を行いました。

そして、この設計に基づいてList View用のデータ取得APIを実装しました。一覧表示の要件を満たすため、メインテーブルに対して必要なすべての関連テーブルをSequelizeの include を使って結合(JOIN)し、一括でデータを取得しようと試みました。

しかし、ある特定のオブジェクトでこのデータ取得APIを呼び出した際、アプリケーションがクラッシュし、次のような無情なエラーメッセージが出力されました。

Too many tables; MySQL can only use 61 tables in a join

3. なぜその事象が発生したのか

このエラーは、文字通り「1つのクエリ内で結合(JOIN)しているテーブルの数が多すぎる」というものです。なぜこのような事態に陥ったのか、原因は主に以下の3つの要素が絡み合っていました。

① 正規化によるテーブル数の爆発

前述の通り、要件を満たすためにデータを細かく正規化していました。List Viewでは、画面上に表示する項目として「親テーブルのデータ」「子テーブルのデータ」「参照先マスタのデータ」など、多岐にわたる情報が一度に要求されます。結果として、1件のレコードを完全に復元して表示するために、数十個のテーブルを結合してデータをかき集める必要が生じていました。

② Sequelizeの include (Eager Loading)のデフォルトの挙動

Sequelizeを使って関連データを取得する際、include オプションを指定してEager Loadingを行うのが一般的です。 このとき、Sequelizeは賢くデータを取得してくれますが、デフォルトの挙動としては「メインテーブルに対して、include で指定されたすべてのテーブルを LEFT OUTER JOIN で結合した、1つの巨大なSQLクエリ」を生成して発行します。

開発者がNode.jsのコード上で数行の include をネストして記述するだけで、裏側では非常に巨大で複雑なJOINクエリが自動生成されてDBに投げられています。

③ MariaDB / MySQLの「61テーブル」のハードリミット

そして最大の壁となったのが、RDBMS側の仕様です。MariaDB(およびそのベースであるMySQL)の仕様上、1つの SELECT クエリで JOIN できるテーブルの最大数は「61」までと厳密に定められています。 これは設定ファイル等で変更できるソフトリミットではなく、データベースエンジンの内部構造に由来するハードリミットです。

つまり、「要件を満たすために正規化して細かく分けたテーブル」を、「Sequelizeが1つの巨大なJOINクエリとしてまとめあげ」、「その数が61を超えてしまったため、MariaDBがクエリの実行を拒否した」というのが、今回直面したトラブルの全貌でした。

4. どうやって解決したのか

この「61テーブルの壁」に対して、私たちは2つのアプローチを試みました。1つは失敗に終わりましたが、最終的にはSequelizeの機能をうまく活用することでこの問題を乗り越えることができました。

失敗したアプローチ:テーブル構造の見直し

エラーが発覚したのは幸いにもリリース前でした。そのため、まずはデータベースのスキーマ(設計)そのものを見直すアプローチを取りました。

具体的には、第3正規形までの正規化は維持しつつも、細かく分割していた1対1の関係にある関連テーブルの一部をメインテーブルに統合したり関連テーブルどうしで結合する修正を行いました。JOINの数が多すぎるなら、最初からテーブルをくっつけてしまえばJOINを減らせる、という単純な発想です。

しかし、この試みはうまくいきませんでした。1対1の関係にある関連テーブルの統合は可能な限り行いましたが、第3正規形までは維持したいという設計方針がありました。そのため、関連テーブルがさらに別のマスターテーブルを include している箇所などは統合しきれず、あっという間に数十個の LEFT OUTER JOIN が生成されてしまいます。過剰分割されていた1対1のテーブルの結合だけでは限界があり、結果として、どうしても61テーブル未満に抑えきれず、根本的な解決には至りませんでした。

成功したアプローチ:Sequelizeの separate: true の活用

スキーマ変更での解決が困難だと判断した私たちは、「1つの巨大なクエリで全てを取得する」というアプローチ自体を見直すことにしました。そこで白羽の矢が立ったのが、Sequelizeの include オプションに用意されている separate: true という設定です。

特に結合先のテーブル数が膨れ上がっていた大きな要因は、「メインテーブルから一対多(hasMany)で紐づく子テーブルがあり、さらにその子テーブルが多数の参照先マスタテーブルを include している」というネスト状態でした。この hasManyinclude に対して separate: true を指定すると、Sequelizeの裏側の挙動が劇的に変化します。

通常、Sequelizeは以下のような1つの巨大なJOINクエリを発行します。

【通常の挙動(巨大なJOIN)】

SELECT 
  main.*, 
  child1.*, 
  child2.*
FROM main
LEFT OUTER JOIN child1
  ON main.id = child1.main_id
LEFT OUTER JOIN child2
  ON main.id = child2.main_id
-- これが延々と60個以上続き、上限エラーに...

しかし、separate: true を指定すると、Sequelizeは1つのJOINクエリにするのをやめ、メインテーブルの取得と関連テーブルの取得を別々のクエリに分割して発行するようになります。

【separate: true 指定時の挙動(クエリの分割)】

-- ① まずメインテーブル(および通常のJOIN対象)を取得する
SELECT * FROM main;

-- ② SequelizeがNode.js側で取得したMainのIDを抽出し、IN句を使って関連テーブルを個別に取得する
SELECT *
FROM child1
WHERE main_id IN (1, 2, 3...);

SELECT *
FROM child2
WHERE main_id IN (1, 2, 3...);

このようにクエリが分割されることで、1つのSELECTクエリあたりに含まれるJOINの数が劇的に減少します。取得されたデータは、最終的にNode.js(Sequelize)側でよしなにメモリ上で結合(マッピング)され、あたかも1つのクエリで取得したかのようなネストされたオブジェクトとしてアプリケーションに返されます。

この機能を利用し、結合数が膨らむ原因となっていた hasMany アソシエーションを別クエリに切り離すことで、無事にMariaDBの「61テーブル制限」を回避し、必要なデータをすべて取得することができました。

5. 導入にあたっての考察・トレードオフ

separate: true は、今回のような「RDBMSのJOIN上限の壁」を突破するための有効な手段となりました。しかし、銀の弾丸というわけではなく、アーキテクチャ上の明確なトレードオフが存在します。導入にあたっては以下の点を意識しておく必要があります。

① Node.js側のメモリ消費とCPU負荷の増加 これまでデータベース(MariaDB)の強力なエンジンに任せていた「データの結合処理」を、Node.js側のメモリ上でSequelizeが行うことになります。取得対象のデータ量が膨大な場合、Node.jsプロセスのメモリ使用量が跳ね上がったり、巨大なオブジェクト群をマッピングする計算でイベントループを圧迫してしまうリスクがあります。

今回の私たちのケースでは、取得する1ページあたりのレコード数を制限(ページネーション)していたこともあり、separate: true を導入しても Node.js側のメモリ枯渇や顕著なパフォーマンス低下は発生していません。

② クエリ発行回数の増加とネットワークオーバーヘッド 1回の巨大なクエリで済んでいたものが、「メインクエリ + 関連テーブルの数だけの子クエリ」という複数回の通信に分割されます。 (※ただし、単純なループ処理による「N+1問題」とは異なり、Sequelizeが自動的に IN 句を使って1つの関連テーブルにつき1回のクエリで一括取得してくれるため、クエリの回数がレコード数に比例して爆発するわけではありません。) それでもデータベースとの通信回数(ラウンドトリップ)は増えるため、ネットワーク遅延の影響は受けやすくなります。

③ 適用箇所の見極め 上記のようなトレードオフを考慮すると、闇雲にすべての includeseparate: true を指定するのは避けるべきです。「一対一(belongsTohasOne)」のシンプルな結合はこれまで通りデータベース側のJOINに任せ(そもそも一対一のテーブルでは separate オプションはサポートされていません)、今回のように「一対多(hasMany)」で特にテーブル数が爆発的に増えてしまう箇所や、JOINによる一時テーブルの肥大化でDBの負荷が著しく高まる箇所にピンポイントで適用するのがベストプラクティスと言えそうです。

6. おわりに

本記事では、Salesforceライクな複雑なデータ構造を持つシステムを開発する中で遭遇した「MariaDB(MySQL)の61テーブル上限エラー」と、Sequelizeの separate: true を活用した解決策についてご紹介しました。

ORMは、直感的なコードでデータベースを操作でき、開発スピードを飛躍的に向上させてくれる素晴らしい技術です。しかし、裏側で「どのようなSQLが自動生成され、データベースに投げられているのか」を意識していないと、今回のようなRDBMSの限界や思わぬパフォーマンスの壁に激突してしまいます。

ORMに任せきりにするのではなく、開発環境で出力されるSQLログなどにも目を通し、フレームワークの機能を要件に合わせて適切に使い分けることの重要性を改めて痛感しました。この記事が、同じように複雑なデータモデリングや「Too many tables」エラーに立ち向かっているエンジニアの皆様の参考になれば幸いです。