1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

数千社規模のBtoB SaaSを支えるマルチテナントDB設計と無停止マイグレーション

1
Posted at

eyecatch

1. 結社の使命と技術の現実(プロローグ)

我々TOAIは、「命の地球プロジェクト」という壮大なビジョンのもと、世界を変革するためのシステム群を構築・運用している。我々結社が目指すのは、スピリチュアルな理想論ではなく、極めて物理的で現実的な課題解決だ。

しかし、その理想を支えるバックエンドの現場は、常に「物理法則」と「データベースの制約」との泥臭い戦いである。公式ドキュメントのチュートリアルや、綺麗に抽象化されたORMのサンプルコードは、開発初期のスピードを引き上げてくれる。だが、システムが成長し、数千社規模のテナントが相乗りするフェーズに突入した瞬間、それらは突如として牙を剥く。

深夜のオフィスや自宅のベッドでアラートに叩き起こされ、pg_stat_activity を睨みつけながらデッドロックやサービスクラッシュと格闘した経験は、バックエンドエンジニアであれば一度や二度ではないだろう。

本記事では、TOAIのCTOとして、我々が実際の運用の中で踏み抜いた「データベースの修羅場」の記録と、エンジニアの睡眠とチームの開発生産性を守るための「防衛的スキーマ設計・ゼロダウンタイムマイグレーションの実戦アーキテクチャ」を公開する。


2. 泥沼のインシデントログ:綺麗なORMが牙を剥く瞬間

我々が検証環境、あるいは初期の本番環境で直面した生々しいインシデントと、その技術的背景を紐解いていく。

2.1. 論理削除(deleted_at)と一意性制約が引き起こすデータ汚染の罠

  • 直面した事象:
    テナントAのユーザーがアカウントを退会(deleted_at = NOW())した後、同じメールアドレスで再登録を試みた。我々の想定では、一意性制約(Unique Violation)はクリアされ、正常に登録できるはずだった。しかし結果として、データベース上には「同一テナント内でアクティブな重複レコードが2件存在する」という深刻なデータ破損が発生した。

  • アーキテクチャの陥篽と技術的考察:
    問題の根源は、PostgreSQLをはじめとする多くのRDBにおける「NULL の評価仕様」にある。

    -- 罠が潜む従来のスキーマ定義
    CREATE TABLE users (
        id UUID PRIMARY KEY,
        tenant_id UUID NOT NULL,
        email VARCHAR(255) NOT NULL,
        deleted_at TIMESTAMP NULL,
        CONSTRAINT idx_tenant_email UNIQUE (tenant_id, email, deleted_at)
    );
    

    標準的なSQLの仕様において、NULL は「未知の値」として扱われるため、NULL = NULLFALSE(あるいは UNKNOWN)と評価される。つまり、deleted_atNULL である複数のアクティブレコードは、RDBの視点からは「重複していない」と見なされてしまうのだ。これにより、アプリケーション側のバリデーションをすり抜けたりクエストが、容易にユニーク制約を突破してしまう。

  • 実戦的解決策(Partial Indexの採用):
    この問題を解決するためには、複合ユニーク制約に deleted_at を含めるのではなく、Partial Index(部分インデックス) を活用して「未削除のレコードのみ」を対象とした一意性を担保する必要がある。

    -- 解決後の定義:未削除(deleted_at IS NULL)の間だけユニーク制約を強制する
    CREATE UNIQUE INDEX idx_tenant_email_active 
    ON users (tenant_id, email) 
    WHERE deleted_at IS NULL;
    

    これにより、B-treeインデックスのサイズ自体も小さく保たれ、オプティマイザの検索効率も向上するという副次的なメリットも得られる。

2.2. 外部キー制約追加が引き起こす瞬断(ACCESS EXCLUSIVEロックの恐怖)

  • 直面した事象:
    数千万行を抱える orders テーブルに対し、新たに tenant_id を付与してテナントマスタへの外部キー制約を追加するマイグレーションを実行した。その瞬間、本番APIの全リクエストがハングアップし、数分間に及ぶタイムアウト障害が発生した。

  • アーキテクチャの陥篽と技術的考察:
    マイグレーションツール(Alembic等)が自動生成する単純な ALTER TABLE ADD CONSTRAINT は、システムにとって致命的なロックを引き起こす。

    -- 障害を引き起こしたクエリ
    ALTER TABLE orders ADD CONSTRAINT fk_orders_tenant FOREIGN KEY (tenant_id) REFERENCES tenants(id);
    

    PostgreSQLにおいて、この構文が実行されると対象テーブルに対して ACCESS EXCLUSIVE ロックが取得される。これは「SELECTを含むすべてのアクセスをブロックする」最強の排他ロックである。RDBは、既存の数千万行のデータすべてが外部キー制約を満たしているか(tenants テーブルにIDが存在するか)をフルスキャンして検証し終えるまで、このロックを解放しない。結果として、APIは全滅する。

  • 実戦的解決策(NOT VALIDVALIDATE CONSTRAINT の分離):
    数千万行のテーブルに対するロック時間をミリ秒単位に短縮するには、マイグレーションを2段階に分割する。

    -- ステップ1: 検証なしで制約を追加(メタデータの更新のみ)
    ALTER TABLE orders ADD CONSTRAINT fk_orders_tenant 
    FOREIGN KEY (tenant_id) REFERENCES tenants(id) NOT VALID;
    
    -- ステップ2: バックグラウンドで既存データを検証(排他ロックをかけない)
    ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_tenant;
    

    NOT VALID を付与することで、新規の INSERT / UPDATE に対しては制約が強制されるが、既存データの検証はスキップされるため、ACCESS EXCLUSIVE ロックはシステムカタログの更新にかかる一瞬で解放される。その後、VALIDATE CONSTRAINT によって、より弱いロック(SHARE UPDATE EXCLUSIVE)の元で安全に既存データの検証を行うことができる。


3. ゼロダウンタイム・マイグレーションの真髄:Expand & Contract パターン

数千社が24時間稼働するBtoB SaaSにおいて、「深夜2時間のメンテナンス」はビジネス上の致命傷となる。カラム名の変更やデータ構造の大幅なリファクタリングを無停止で行うためには、「Expand & Contract(拡張と縮小)」パターンを厳格に適用しなければならない。

例として、users テーブルのカラムを name から display_name に変更するケースを考える。

フェーズ1: Expand(拡張) - デュアルライトの開始

決して RENAME COLUMN を使ってはならない。旧カラムを参照しているデプロイ前のアプリケーションが即座にクラッシュするからだ。
まず、新しいカラム display_name を追加し、アプリケーション層では**二重書き込み(Dual Write)**を実装する。

# SQLAlchemy等でのDual Write実装例
def update_username(self, session, new_name: str):
    self.name = new_name          # 旧カラムへの書き込み
    self.display_name = new_name  # 新カラムへの書き込み(Expandフェーズ)
    session.commit()

フェーズ2: Migrate(データ同期) - WAL肥大化との戦い

次に、過去のレコードの display_name を埋めるバッチ処理を実行する。ここで注意すべきは、全件を一括で UPDATE してしまうと、トランザクションログ(WAL)が急激に肥大化し、リードレプリカへのレプリケーション遅延を引き起こす点だ。
必ずプライマリキーの範囲(チャンク)ごとに分割し、意図的にスリープを挟みながらI/O負荷をコントロールしつつ同期を行う。

フェーズ3: Contract(縮小) - 負債の刈り取り

新しいコードが完全に浸透し、旧カラムへの読み書きが一切行われていないことをメトリクス(DatadogやNew Relicなど)で確認した後、アプリケーションから旧カラムの定義を削除してデプロイする。最後に、DBから旧カラムを DROP して完了となる。


4. マルチテナントDBアーキテクチャ:なぜ我々は Shared-Schema を選んだのか

マルチテナントアーキテクチャの選定は、システムの命運を分ける最大の意思決定だ。我々は以下の3つのアプローチを検討した。

  1. Isolated-Database (データベース完全分離): セキュリティは最強だが、数千社規模になるとインフラコストが爆発し、コネクションプーリングの管理が物理的に破綻する。
  2. Shared-Database / Isolated-Schema (スキーマ分離): 論理的な分離は美しいが、マイグレーション時に全スキーマをループ処理する必要があり、デプロイ時間が致命的に伸びる。
  3. Shared-Database / Shared-Schema (全テナント同居): コストとマイグレーションの効率は最高だが、tenant_id の絞り込み忘れによる「データ漏洩(Cross-tenant Leak)」のリスクが常に伴う。

TOAIのアーキテクチャ選定:
我々は、コスト効率と開発生産性を最大化するため、**「Shared-Schema」**をベースアーキテクチャとして採用している。
しかし、人間の注意深さに依存した tenant_id のフィルタリングは必ず破綻する。そのため、PostgreSQLの Row-Level Security (RLS) を導入し、DBレイヤーで強制的に他テナントのデータを見えなくするガードレールを敷いている。さらに、ORMのベースクラスで自動的にカレントテナントのIDを付与するグローバルフィルタを実装し、アプリケーションとDBの二重防御を構築している。


5. 永続的な運用のための「守り」の設計(エピローグ)

システムは、リリースされた瞬間からレガシーへの道を歩み始める。「命の地球プロジェクト」を未来永劫稼働させ続けるためには、攻めの開発だけでなく、守りの運用を自動化する必要がある。

我々は pg_stat_statements を駆使し、以下のメトリクスを常に監視している。

  • tenant_id を含まない危険なフルスキャンクエリの検知
  • ロック待機時間(blk_read_time, blk_write_time)の急増
  • 長期間使用されていないインデックス(pg_stat_user_indexes.idx_scan = 0)の定期監査

これらの技術的実践は、決して魔法ではない。泥臭い障害対応の果てに獲得した、物理法則に抗うためのエンジニアリングの結晶である。

我々TOAI結社は、これからも真の技術的価値を追求し、世界の基盤を支え続ける。技術の闇に怯える夜をなくし、すべての開発者が創造的な仕事に専念できる世界のために。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?