1 ポイント 投稿者 GN⁺ 2025-04-29 | 1件のコメント | WhatsAppで共有
  • Railsでテナントごとに個別のデータベースを使う構成を構築する方法と、その過程で直面した課題を説明
  • ActiveRecordは基本的に単一DB接続を前提に設計されているため、テナントごとの接続切り替えは複雑で扱いにくい
  • Rails 6以降のconnected_to機能を活用し、実行時に接続を動的に切り替える方法を提案
  • SQLite3は小規模で多数の独立したDBを扱うのに適しており、バックアップ、デバッグ、削除などの管理がしやすい
  • 大規模システムの最適化を中心に進化してきたRailsインフラとは異なり、小さく独立したデータベースを中心としたアーキテクチャも可能であることを強調

テナントごとに個別のデータベースを使う理由

  • データモデルの中で独立して動作するテナント(Site)単位で分離すると、データの分離と管理が容易になる
  • テナントごとにデータを別DBへ保存すると、大規模サイトへの拡張やセキュリティ上の課題にも有利
  • SQLiteを使えばサーバー設定なしでファイル1つだけでデータベースを運用でき、簡便で柔軟

Railsで難しい点

  • SQLiteの基本的なopen/close操作は非常に簡単だが、ActiveRecordは内部的に複雑なコネクション管理構造を持つ
  • ActiveRecordは接続をモデルに固定して使う構造で設計されているため、実行時のテナント切り替えが難しい
  • コネクションプール、クエリキャッシュ、スキーマキャッシュなどがすべて接続に依存しており、接続変更のたびに負担が大きい

Railsのマルチデータベース管理の歴史

  • Rails 1: ActiveRecord::Base単位でDBを指定可能
  • Rails 3: コネクションプール導入
  • Rails 4: connection_handling追加
  • Rails 6: connected_to導入
  • Rails 7: connected_to機能拡張とシャーディング対応
  • それでもなお「実行時にテナントを動的に追加・削除する」といったシナリオは標準ではサポートされていない

テナントごとのデータベースの利点

  • テナントごとのファイルだけをバックアップ・復元できるため、運用とデバッグが簡単になる
  • テナント削除は単にファイルを削除する(unlink)だけでよい
  • 大規模データベースサーバーは数十TB級のDB最適化を目指す一方、SQLiteは数千個の小規模DBに最適化されている
  • 実際にiCloudも数百万個の小さなSQLite DBをCassandra上に保存する構造を採用している

問題解決の過程

  • 従来の方法(手動のestablish_connection)は多重接続環境でConnectionNotEstablishedエラーを引き起こした
  • Rails 6以降のやり方に合わせ、コネクションプールを手動管理する代わりにRailsに任せる構造へ変更
  • 各テナントごとに動的にconnection poolを作成し、connected_toブロックで処理を包む
  • ミドルウェアを使ってリクエスト時点で必要なDB接続を動的に準備し、解放する方式へ改善

中核となるコードパターン

  • コネクションプールを確認し、なければ作成
MUX.synchronize do  
  if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?  
    ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)  
  end  
end  
  • 接続後、connected_toブロック内で安全にクエリを実行
ActiveRecord::Base.connected_to(role: role_name) do  
  pages = Page.order(created_at: :desc).limit(10)  
end  

Rackストリーミング処理

  • Rackレスポンスがストリーミングの場合、接続管理のためにRack::BodyProxyFiberを使って安全にコネクションを閉じる
connected_to_context_fiber = Fiber.new do  
  ActiveRecord::Base.connected_to(role: role_name) do  
    Fiber.yield  
  end  
end  
connected_to_context_fiber.resume  
  
status, headers, body = @app.call(env)  
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }  
  
[status, headers, body_with_close]  

最終的なミドルウェア構成

  • リクエストごとに適切なDB接続を見つけてconnected_toへ切り替え、レスポンス終了後に後始末するミドルウェアShardine::Middlewareを作成
  • Railsプロジェクトのconfig.ruファイルには次のように適用できる
use Shardine::Middleware do |env|  
  site_name = env["SERVER_NAME"]  
  {adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}  
end  

残された課題

  • ActiveRecord 6ではまだshard機能を活用していないが、以後のバージョンでは読み書き分離も可能
  • テナント削除時のコネクションプール整理機能は、現時点では必要がないため未実装
  • 今後は「小さなデータベースを多数扱う」アーキテクチャがさらに注目される可能性が高い

1件のコメント

 
GN⁺ 2025-04-29
Hacker Newsの意見
  • 約100万人のユーザーとともに「database-per-tenant」方式を運用中

    • この方式は読み取り中心のアプリに適しており、ほとんどのテナントは小規模で、テーブル内のレコード数も多くないため、複雑な結合でも非常に高速
    • 主な問題は、個々のデータベースを1つずつマイグレーションしなければならないため、リリース時間が大幅に伸びる可能性があること
    • スキーマやデータのドリフトが発生するとリリースが止まり、一部のテナントで機能が動かない理由を突き止める必要がある
  • SQLiteは好きだが、既存のOLTPデータベースはインデックスの一部をメモリからアンロードする必要があるのではないかと思う

    • ユーザーごとのデータベースを使えば、非アクティブなユーザーや別インスタンスでのみアクティブなユーザーのために、メモリ上に何も保持しなくてよい
    • これはMongoのJSONの状況に似ており、PostgresはMongoより2倍速い
  • ほとんどの人にテナントごとのデータベースは不要であり、一般的なやり方ではない

    • マイグレーションやスキーマドリフトといった欠点を打ち消せる特定のケースがある
    • 使えるからといって、必ず使うべきというわけではない
    • 慎重に進め、本当にテナントごとのデータベースが必要であることを理解しておくべき
  • 中間的なアプローチとして次を検討できる

    • 上位N件のテナントを特定する
    • それらのテナント向けにDBを分離する
    • 上位N件はIOPSや重要度(収益面)などを基準に決める
    • データモデルは、各テナントに対応する行を抽出できるように設計されている必要がある
  • 偶然にも、Elixir向けのFeebDBに取り組んでいる

    • これはEctoの代替と見なせるが、数千のデータベースがある場合にはうまく機能しない
    • もともとは面白い実験として始めたが、過去に働いたあらゆる場所でこのアーキテクチャは大いに役立ったはず
    • 目標は、database-tenantアプローチにおける一般的な問題点を取り除くか軽減すること
    • 各データベースに単一ライターを保証
    • 全テナントに対して改善された接続管理
    • 必要に応じたマイグレーションとバックアップのサポート
    • 複数DBに対する map/reduce/filter 操作のサポート
    • クラスターデプロイのサポート
  • Forward Emailは、各メールボックス/ユーザーごとに暗号化されたsqlite dbを使って似たようなことをしている

    • ユーザーごとの保護を差別化する優れた方法
  • 名前がとても素晴らしい。Sean Conneryを連想させる

  • 「database per tenant」ワークフローは今に始まったことではない

    • James Edward Grayが2012年のRailsConfでこの件について話していた
  • 以前に似たようなものを使ったことがあり、とても満足していた

    • ユーザーがデータを欲しがれば、データベース全体を渡せる
    • ユーザーがアカウントを削除したら、rm username.sqlで簡単に処理できる
    • コンプライアンスが非常に容易になる
  • データが互いに隔離され、単一テナント内でスケーリングの問題がないなら、ひどい設計をしてしまうのは難しい

    • ほとんど何でもうまくいくだろう