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など)に寄せると、後戻りが減ります。

コメント