EF Core×PostgreSQLでテーブル名を動的切り替えたい?マルチテナント設計とパーティショニング最適解

EF Core+PostgreSQLで「会社(テナント)ごとに別テーブル(Cars_A / Cars_B…)へ保存したい」という要望はよくありますが、EF Coreは“実行時パラメータでテーブル名を動的に差し替える”設計と相性が良くありません。性能と分離を両立する現実解は、PostgreSQLのパーティショニングやRLS(行レベルセキュリティ)を軸に、必要に応じてDapper/SQL直書きを併用する形です。

目次

なぜ「会社ごとに別テーブル」になりがちなのか

マルチテナント設計では、次の2つが強い動機になります。

  • 性能:テナントごとにアクセスするデータ範囲が狭いので、読み取り/書き込みを速くしたい
  • データ分離:他社データが「論理的に見えない」ではなく「物理的・権限的に触れない」状態にしたい

ここから「Cars_A」「Cars_B」方式(テナントごとにテーブルを増やす)に行き着くのは自然です。ただし、テナントが毎週増える前提だと、DB・アプリ双方で運用負債が急増します。

EF Coreが“動的テーブル切り替え”に向きにくい理由

EF Coreは、基本的にモデル(マッピング情報)を起動時に構築し、キャッシュして使い回す思想です。つまり、

  • エンティティ → どのテーブルへ → どの列へ、という対応は「固定」される
  • 「リクエストごとに Company=A なら Cars_A、Company=B なら Cars_B」という実行時差し替えは標準の得意領域ではない

強引にやる方法はあるが、テナントが増えると破綻しやすい

「どうしてもEFだけで切り替えたい」という場合、以下のような回避策が話題になります。

  • DbContextのモデルキャッシュキーをテナントで分ける(テナントごとに別モデルとしてキャッシュさせる)
  • OnModelCreatingでテーブル名を組み立ててToTable()する

ただ、テナントが毎週増える=モデルが際限なく増える可能性が高く、

  • メモリ使用量の増加(モデルキャッシュが増殖)
  • 初回アクセスの遅延(そのテナントのモデル構築コスト)
  • デプロイ/移行の複雑化(テーブル増加とスキーマ変更の追従)

が積み上がり、長期運用では厳しくなりがちです。結論として、“テーブル名が実行時に変わる”なら、EFで頑張るよりSQL直書き(またはDapper等)に寄せた方が整理しやすいケースが多いです。

まず押さえたい:テナント分離の選択肢(王道パターン)

マルチテナントの「分離」の考え方は大きく4つあります。

方式分離の強さ性能の伸ばしやすさ運用コストEF Core相性
DB分離(テナントごとにDB)非常に強い強い(物理分散しやすい)高い(DBが増える)比較的良い
スキーマ分離(テナントごとにschema)強い中〜強中〜高(schema増)工夫が必要
テーブル分離(テナントごとにCars_A等)強い中〜強高い(テーブル増殖)基本は不向き
同一テーブル+tenant_id(論理分離)中(DB側で強化可能)強い(索引/パーティション)低〜中非常に良い

今回の要件は「性能」と「完全分離」。この2つを同時に狙うなら、同一テーブル+tenant_idをベースに、DB側の仕組みで“完全分離の安全柵”を作るのが堅実です。

本題:PostgreSQLのパーティショニングは適切か?

「1つの巨大テーブルがつらい」前提なら、PostgreSQLの宣言的パーティショニングは有力です。理由はシンプルで、参照対象のパーティションだけを読める(パーティションプルーニング)と、I/Oが減って速くなりやすいからです。

パーティショニングのメリット(今回の論点に直結)

  • 性能:company_id等で絞れるクエリなら、該当パーティションに絞って読める
  • 運用:古いデータの退避・削除を「パーティション単位」で扱える(アーカイブが楽)
  • 分離・権限:設計次第で、パーティション/ロール/ポリシーにより安全柵を置きやすい

デメリット(ハマりどころ)

  • 管理コスト増:設計、監視、バックアップ、インデックス方針が複雑になる
  • クエリ次第:会社IDで絞らない集計などは複数パーティションをまたぐ
  • パーティション数の増やしすぎ:パーティションを“会社ごとに無限”に作ると、メタデータ管理やプランニングの負担が増える

ここが重要ポイントです。「会社ごとに別パーティション」を無制限に作る設計は、テーブル無限増殖と同じ方向に転びやすいため、パーティションは“数を適度に固定する”のが現実的です。

おすすめの着地点:同一テーブル+company_id+(必要なら)パーティション

テナントが毎週増える運用を想定すると、次の構成がバランスが良いです。

  • 物理テーブルは Cars(親)+複数パーティション(子)
  • 論理分離のキーとして company_id を必須カラムにする
  • 分離の安全柵として RLS(行レベルセキュリティ) を有効化する
  • 読み取り性能が足りない箇所だけ Redisキャッシュ を後付けする

パーティション設計:会社ごとに増やさず、ハッシュで“固定数”に分散

会社が増え続ける場合は、company_idのハッシュ分割が扱いやすいです。例えば32/64/128といった固定数にしておけば、会社数が増えてもテーブル(パーティション)数は増えません。

想定規模の目安推奨の考え方例
会社数が増えるが、総行数はまだ中規模まずは非パーティション+適切な索引Cars(company_id, id)など
会社数も行数も増えて、I/Oが重いcompany_idでハッシュ分割(固定数)HASH 32 partitions
時系列で肥大化・削除/退避が頻繁時間で分割(Range)+必要ならサブ分割月次パーティション+company_id

DDL例(イメージ)

以下は「親テーブル+company_idでハッシュパーティション」のイメージです(実運用では列や制約は要件に合わせて調整してください)。

-- 親テーブル
CREATE TABLE cars (
  id           bigserial PRIMARY KEY,
  company_id   bigint NOT NULL,
  vin          text NOT NULL,
  model        text NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
) PARTITION BY HASH (company_id);

-- 例:32分割(固定)
CREATE TABLE cars_p00 PARTITION OF cars FOR VALUES WITH (MODULUS 32, REMAINDER 0);
CREATE TABLE cars_p01 PARTITION OF cars FOR VALUES WITH (MODULUS 32, REMAINDER 1);
-- ... cars_p31まで作る

-- よく使う検索に合わせて索引(パーティションごとに作られる)
CREATE INDEX ON cars (company_id, id);
CREATE INDEX ON cars (company_id, created_at DESC);
CREATE UNIQUE INDEX ON cars (company_id, vin);

ポイントは、会社ごとにテーブルを増やさないのに、会社で絞るクエリは該当パーティションに寄りやすい、という点です。

「完全分離」を本気で担保するなら:RLS(行レベルセキュリティ)を検討

アプリ側の「CompanyIdでWHEREする」だけだと、バグや実装漏れ、将来の改修で混入リスクが残ります。“絶対に他社データを返さない”をDBが保証するなら、RLSが強力です。

RLSの考え方

  • DBセッションに「現在のcompany_id」を設定する
  • そのcompany_idと一致する行だけを参照可能にするポリシーを作る
-- RLS有効化
ALTER TABLE cars ENABLE ROW LEVEL SECURITY;

-- セッション変数に company_id を入れる運用例(アプリが接続直後に実行)
-- SELECT set_config('app.current_company_id', '123', true);

-- ポリシー(参照・更新など)
CREATE POLICY cars_tenant_isolation ON cars
USING (company_id = current_setting('app.current_company_id')::bigint)
WITH CHECK (company_id = current_setting('app.current_company_id')::bigint);

これにより、アプリが誤ってcompany_id条件を付け忘れても、DB側でブロックされます。分離を「慣習」ではなく「仕組み」で担保できるのが大きな価値です。

それでも「会社ごとにテーブル(Cars_A等)」を続けたい場合の現実的な注意点

要件として「物理テーブルが会社単位で絶対に分かれていないとダメ」という事情もあります。その場合は、少なくとも次を覚悟して設計する必要があります。

運用負債が増えるポイント

  • スキーマ変更:列追加や型変更のたびに、全テーブルへ同等の変更が必要
  • メタデータ肥大:テーブルやインデックスが増えるほど、カタログ管理が重くなる
  • 権限管理:会社ごとに権限をどう付けるか(ロール設計が複雑化)
  • 監視/保守:VacuumやAnalyze、統計、バックアップ対象の管理が増える

やるなら「スキーマ分離+共通テーブル名」の方がまだ扱いやすいことがある

テーブル名を会社で変えるより、schemaを会社で変えてテーブル名は共通(例:tenant_a.cars, tenant_b.cars)の方が、運用ルールを揃えやすいケースがあります。とはいえ、schemaも増えれば同様に管理対象は増えます。最終的には「会社数×期間」で増殖するものを何にするか、という問題です。

「動的テーブル名」が必要ならDapper/SQL直書きが強い(ただし安全に)

EF Coreでの動的切り替えが苦しい以上、実行時にテーブル名が変わる要件はDapper等で扱うのがわかりやすいです。ただし、テーブル名はパラメータ化できないため、SQLインジェクション対策として“ホワイトリスト化”が必須です。

Dapperの例(テーブル名は必ず検証する)

public async Task<Car> GetCarAsync(long companyId, long carId)
{
    var table = ResolveTenantTableName(companyId); // ここでホワイトリスト検証
    var sql = $@"
SELECT id, company_id, vin, model, created_at
FROM {table}
WHERE company_id = @CompanyId AND id = @Id;";


return await _conn.QuerySingleAsync<Car>(sql, new { CompanyId = companyId, Id = carId });


}

private string ResolveTenantTableName(long companyId)
{
// 例:会社ID→会社コード→テーブル名へ。外部入力を直結しない。
var code = *tenantRepository.GetCompanyCode(companyId); // "A" 等
if (!Regex.IsMatch(code, "^[A-Z]{1,8}$")) throw new SecurityException();
return $"cars*{code}";
}

この方式は「要件は満たせる」一方で、テーブルが増殖し続ける問題そのものは残ります。“動的テーブル”を採るなら、DB運用の自動化(DDL生成、移行、権限付与、監視)までセットで考えるのが現実的です。

Redisキャッシュ案はどう位置づけるべきか

Redisは強力ですが、今回の「会社分離」の主題を代替するものではありません。基本的には読み取り負荷の軽減として扱うのが安全です。

Redisが効く場面

  • 同じ会社の同じ一覧/APIが短時間に繰り返し読まれる
  • ランキングやサマリなど、再計算コストが高いが許容遅延がある
  • DBがボトルネックで、キャッシュヒット率が見込める

Redisの課題(必ず設計に入れる)

  • 整合性:更新/削除時のキャッシュ無効化(またはTTL設計)
  • メモリコスト:会社ごとにhashを作ると増え方が読みにくい
  • 障害時の挙動:キャッシュダウン時にDBへ雪崩が起きないようにする

おすすめは、まずDB設計(索引・パーティション・RLS)で基礎体力を作り、本当に遅いエンドポイントだけをキャッシュで叩く順序です。

よくある落とし穴(先回りチェックリスト)

  • 会社ID条件が抜ける:アプリのフィルタに頼り切ると事故が起きる。RLSや権限で保険を。
  • パーティション数を増やしすぎる:会社ごとパーティションを無限に作らない。固定数(ハッシュ)や期間単位で制御。
  • インデックス方針が曖昧:company_idを含む複合インデックスを基本に、実クエリで追加する。
  • スキーマ変更の自動化不足:テーブル/スキーマが増える方式ほど、移行スクリプトと検証を自動化しないと回らない。
  • 分析クエリの想定漏れ:会社横断の集計があるなら、別の集計テーブルやETL/BI導線を用意する。

結論:この要件での最適解をどう選ぶか

「会社が毎週増える」「性能も分離も欲しい」なら、長期的に強いのは次の整理です。

  • EF Coreで実行時にテーブル名を切り替えるのは筋が悪い(やれなくはないが増えるほど重くなる)
  • テーブルを会社ごとに無限増殖させる設計は、DB運用・移行・権限・監視が雪だるま式に難しくなる
  • 現実解は、同一テーブル+company_idを基本に、パーティションで性能を引き上げ、RLSで分離を強制する
  • どうしても動的テーブルが必要な箇所だけ、Dapper/SQL直書きで安全に実装し、DDL運用も自動化する
  • Redisは分離の代替ではなく、読み取り最適化の追加カードとして使う

設計は「今の要件を満たす」だけでなく、「半年後に会社数が倍になっても壊れない」ことが重要です。会社数が増える前提があるなら、“増えるもの(テーブル/パーティション/スキーマ/DB)を何にするか”を最初に決め、増殖を制御できる方式(固定数パーティション+RLSなど)に寄せると、後戻りが減ります。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次