web-dev-qa-db-ja.com

マルチテナントSQL Serverデータベースの複合主キー

ASP Web API、Entity Framework、およびSQL Server/Azureデータベースを使用して、マルチテナントアプリ(単一データベース、単一スキーマ)を構築しています。このアプリは、1000〜5000のお客様が使用します。すべてのテーブルにはTenantId(Guid/UNIQUEIDENTIFIER)フィールドがあります。現在、私は単一フィールドの主キーであるId(Guid)を使用していますが、Idフィールドのみを使用することで、ユーザーから提供されたデータが正しいテナントの/からのものかどうかを確認します。たとえば、SalesOrderテーブルにCustomerIdフィールドがあります。ユーザーが注文を投稿/更新するたびに、CustomerIdが同じテナントのものかどうかを確認する必要があります。各テナントに複数のアウトレットがある可能性があるため、状況はさらに悪化します。次に、TenantIdOutletIdを確認する必要があります。本当にメンテナンスの悪夢であり、パフォーマンスに悪影響を及ぼします。

TenantIdIdとともに主キーに追加することを考えています。そして、おそらくOutletIdも追加します。したがって、SalesOrderテーブルの主キーは、IdTenantId、およびOutletIdになります。このアプローチの欠点は何ですか?複合キーを使用するとパフォーマンスが大幅に低下しますか?複合キーの順序は重要ですか?私の問題に対するより良い解決策はありますか?

16
Reynaldi

大規模なマルチテナントシステム(18以上のサーバーにまたがる顧客とのフェデレーションアプローチ、各サーバーは同一のスキーマ、ちょうど異なる顧客、および各サーバーで1秒あたり数千のトランザクション)に取り組んだことがあると、私は言うことができます。

  1. "TenantID"と任意のエンティティ "ID"の両方のIDとしてGUIDの選択に同意する人(少なくとも数人)がいます。しかし、いいえ、良い選択ではありません。その他すべての考慮事項は別として、その選択だけではいくつかの点で害があります。まず断片化、大量の無駄なスペース(言わないでください ディスクは安価です エンタープライズストレージについて考えるとき=SAN —または、INTまたはBIGINTのいずれかを使用した場合よりも各データページの行数が少ないため、クエリに時間がかかる)、サポートやメンテナンスが難しいなど。GUIDは移植性に優れています。一部のシステムを別のシステムに転送しますか?そうでない場合は、よりコンパクトなデータ型(たとえば、TINYINTSMALLINTINT、またはBIGINT)に切り替え、 IDENTITY または SEQUENCEを使用して順次インクリメントします。

  2. 項目1を除外すると、ユーザーデータが含まれるすべてのテーブルにTenantIDフィールドが本当に必要になります。そうすれば、追加のJOINを必要とせずに何でもフィルタリングできます。これは、JOIN条件またはWHERE句、あるいはその両方にTenantIDを含めるには、クライアントデータテーブルに対するすべてのクエリが必要であることも意味します。これは、異なる顧客からのデータを誤って混合したり、テナントBからのテナントAデータを表示したりしないことを保証するのにも役立ちます。

  3. Idと共にTenantIdを主キーとして追加することを考えています。そして、おそらくOutletIdも追加します。したがって、注文テーブルの主キーはId、TenantId、OutletIdになります。

    はい、クライアントデータテーブルのクラスター化インデックスは、TenantIDIDを含む複合キーにする必要があります。 **。これにより、クライアントデータテーブルに対するクエリの98.45%がTenantIDを必要とするため、いずれにしても必要なすべての非クラスター化インデックスにTenantIDが確実に含まれます(クラスター化インデックスキーが含まれるため)。 CreatedDateに基づいており、TenantIDを気にしないデータ)。

    いいえ、PKにOutletIDなどのFKを含めません。 PKは行を一意に識別する必要があり、FKを追加してもそれは役に立ちません。実際、各TenantID内の各OutletIDごとに一意ではなく、各TenantIDに対してOrderIDが一意であると想定すると、データが重複する可能性が高くなります。

    また、テナントAからのアウトレットがテナントBと混同しないようにするために、PKにOutletIDを追加する必要はありません。すべてのユーザーデータテーブルのPKにTenantIDがあるため、TenantIDもFKで。たとえば、OutletテーブルのPKは_(TenantID, OutletID)_で、OrderテーブルのPKは_(TenantID, OrderID)_およびFKは_(TenantID, OutletID)_です。 OutletテーブルのPKを参照します。 FKを適切に定義すると、テナントデータが混ざり合うのを防ぐことができます。

  4. 複合キーの順序は重要ですか?

    さて、ここが楽しいところです。どの分野が最初に来るべきかについてはいくつかの議論があります。適切なインデックスを設計するための「一般的な」ルールは、最も選択的なフィールドをリーディングフィールドとして選択することです。 TenantIDは、その性質上、最も選択的なフィールドではありませんIDフィールドは最も選択的なフィールドです。ここにいくつかの考えがあります:

    • IDが最初:これは最も選択的な(つまり、最も一意の)フィールドです。しかし、自動インクリメントフィールド(またはGUIDを使用している場合はランダム)になることにより、各顧客のデータは各テーブル全体に分散されます。これは、顧客が100行を必要とする場合があり、ディスクから100近くのデータページを(高速ではなく)バッファプールに読み込む必要があることを意味します(10データページより多くのスペースを使用します)。また、複数の顧客が同じデータページを更新する必要が頻繁になるため、データページの競合も増加します。

      ただし、さまざまなID値全体の統計はかなり一貫しているため、通常は、パラメータスニッフィングやキャッシュプランの不良の問題にそれほど遭遇しません。あなたは最適な計画を得られないかもしれませんが、恐ろしいものを得る可能性は低くなります。この方法では、基本的にすべての顧客のパフォーマンスが(わずかに)犠牲になり、問題の発生頻度が低くなります。

    • TenantIDが最初:これはまったく選択的ではありません。 100個のTenantIDしかない場合、100万行の変動はごくわずかである可能性があります。ただし、SQL ServerはテナントAのクエリが500,000行をプルバックすることをSQL Serverが認識しますが、テナントBの同じクエリは50行しかないため、これらのクエリの統計はより正確です。ここが主な問題点です。このメソッドは、ストアドプロシージャの最初の実行がテナントAである場合にパラメータースニッフィングの問題が発生する可能性を大幅に高め、クエリオプティマイザーがこれらの統計を確認し、500k行を効率的に取得する必要があることを認識して適切に機能します。しかし、50行しかないテナントBを実行すると、その実行計画は適切ではなくなり、実際には非常に不適切になります。さらに、データは先行フィールドの順序で挿入されていないため、このメソッドは、他のアプローチよりも短時間で多くのページ分割(したがって、より多くの断片化)を作成します。

      ただし、最初のTenantIDがストアドプロシージャを実行する場合、データは(少なくともインデックスのメンテナンスを行った後)物理的および論理的に整理されるため、他のアプローチよりもパフォーマンスが優れているため、クエリ。これは、物理I/Oが少なく、論理読み取りが少なく、同じデータページのテナント間の競合が少なく、バッファープールで使用される無駄なスペースが少ないこと(つまり、ページの寿命が向上すること)を意味します。

      この改善されたパフォーマンスを得るには、主に2つのコストがあります。最初の方法はそれほど難しくありません。断片化の増加に対抗するには、定期的にインデックスをメンテナンスする必要があります。 2番目は少し面白くないです。

      増加したパラメータースニッフィングの問題に対処するには、テナント間で実行プランを分離する必要があります。単純なアプローチは、procsで_WITH RECOMPILE_またはOPTION (RECOMPILE)クエリヒントを使用することですが、これは、TenantIDを最初に配置することで得られるすべての利点を一掃する可能性があるパフォーマンスへの影響です。私が最も効果を発揮したのは、パラメータ化された動的SQLを_sp_executesql_で使用する方法です。動的SQLが必要な理由は、TenantIDをクエリのテキストに連結できるようにするためですが、通常はパラメーターである他のすべての述語はパラメーターのままです。たとえば、特定の注文を探している場合は、次のようにします。

      _DECLARE @GetOrderSQL NVARCHAR(MAX);
      SET @GetOrderSQL = N'
        SELECT ord.field1, ord.field2, etc.
        FROM   dbo.Orders ord
        WHERE  ord.TenantID = ' + CONVERT(NVARCHAR(10), @TenantID) + N'
        AND    ord.OrderID = @OrderID_dyn;
      ';
      
      EXEC sp_executesql
         @GetOrderSQL,
         N'@OrderID_dyn INT',
         @OrderID_dyn = @OrderID;
      _

      これにより、その特定のテナントのデータボリュームと一致する、そのテナントIDのみの再利用可能なクエリプランが作成されます。同じテナントAが別の_@OrderID_に対してもう一度ストアドプロシージャを実行すると、キャッシュされたクエリプランが再利用されます。同じストアドプロシージャを実行する別のテナントは、TenantIDの値のみが異なるクエリテキストを生成しますが、クエリテキストのanyの違いは、別のプランを生成するのに十分です。また、テナントBに対して生成された計画は、テナントBのデータボリュームと一致するだけでなく、_@OrderID_のさまざまな値に対してテナントBで再利用できます(その述語はまだパラメーター化されているため)。

      このアプローチの欠点は次のとおりです。

      • これは、単純なクエリを入力するだけでは少し手間がかかります(ただし、すべてのクエリが動的SQLである必要はなく、パラメーターのスニッフィングの問題が発生するだけです)。
      • システム上にあるテナントの数に応じて、プランのキャッシュのサイズが増加します。これは、クエリごとに、それを呼び出すTenantIDごとに1つのプランが必要になるためです。これは問題ではないかもしれませんが、少なくとも注意する必要があります。
      • 動的SQLは所有権の連鎖を分断します。つまり、ストアドプロシージャにEXECUTE権限を付与しても、テーブルへの読み取り/書き込みアクセスを想定できません。簡単だが安全性の低い修正は、ユーザーがテーブルに直接アクセスできるようにすることです。これは確かに理想的ではありませんが、通常それは迅速かつ容易なトレードオフです。より安全なアプローチは、証明書ベースのセキュリティを使用することです。つまり、証明書を作成してから、その証明書からユーザーを作成し、thatユーザーに必要な権限を付与します(証明書ベースのユーザーまたはログインは、それ自体ではSQL Serverに接続できません)。 ADD SIGNATURE を介して、同じ証明書でダイナミックSQLを使用するストアドプロシージャに署名します。

        モジュールの署名と証明書の詳細については、以下を参照してください。 ModuleSigning.Info

    [〜#〜] update [〜#〜]セクションを参照して、統計情報の問題の軽減に関する問題に関連する追加のトピックを参照してください。この決定。


** 個人的には、すべてのテーブルのPKフィールド名に「ID」のみを使用することは意味がないため、私は本当に嫌いです。PKは常に「ID」であり、子テーブルのフィールドには、親テーブル名。例:_Orders.ID_-> _OrderItems.OrderID_。 _Orders.OrderID_-> _OrderItems.OrderID_を持つデータモデルを処理する方がはるかに簡単です。より読みやすくなり、「あいまいな列参照」エラーが発生する回数を削減できます:-)。


[〜#〜]更新[〜#〜]

  • _OPTIMIZE FOR UNKNOWN_ Query Hint (SQL Server 2008で導入)は、複合PKの順序付けに役立ちますか?

    あんまり。このオプションは、パラメータスニッフィングの問題を回避しますが、単に1つの問題を別の問題に置き換えます。この場合、ストアドプロシージャまたはパラメーター化されたクエリの最初の実行のパラメーター値の統計情報を記憶するのではなく(一部の場合は間違いなく優れていますが、一部の場合は平凡で、一部の場合は恐ろしい可能性があります)、一般的な行数を推定するためのデータ分布の統計。これは、ポジティブ、ネガティブ、またはまったく影響を受けないクエリの数(および程度)について、ヒットまたはミスです。少なくともパラメータスニッフィングを使用すると、一部のクエリが効果を発揮することが保証されました。システムにさまざまなデータ量のテナントがある場合、これはすべてのクエリのパフォーマンスを低下させる可能性があります。

    このオプションは、入力パラメーターをローカル変数にコピーし、ローカル変数をクエリで使用するのと同じことを実現します(ここではテストしましたが、ここではその余地はありません)。追加情報は、このブログ投稿にあります: http://www.brentozar.com/archive/2013/06/optimize-for-unknown-sql-server-parameter-sniffing/ 。コメントを読んで、Daniel Pepermansは、変動が制限されている動的SQLの使用に関して、私のものと同様の結論に達しました。

  • IDがクラスター化インデックスの先頭のフィールドである場合、クエリの正確な統計情報を取得するには、(TenantID、ID)または(TenantID)のみに非クラスター化インデックスを作成するのに役立ちます/十分ですか?単一のテナントの多くの行を処理しますか?

    はい、役立ちます。私が長年取り組んできた大規模なシステムは、IDENTITYフィールドを先行フィールドとして持つインデックス設計に基づいていました。これは、より選択的であり、パラメータースニッフィングの問題が減少したためです。ただし、特定のテナントのデータのかなりの部分に対する操作が必要な場合、パフォーマンスが維持されませんでした。実際、すべてのデータを新しいデータベースに移行するプロジェクトは、SANコントローラーがスループットの面で限界に達したため、保留にする必要がありました。修正は、非クラスター化インデックスをすべてに追加することでした。テナントデータテーブルは(TenantID)だけにする必要があります。IDはすでにクラスタ化インデックスにあるため、(TenantID、ID)を実行する必要はなく、非クラスタ化インデックスの内部構造は当然(TenantID、ID)でした。

    これにより、TenantIDベースのクエリをより効率的に実行できるという当面の問題は解決しましたが、同じ順序のクラスター化インデックスである場合ほどは効率的ではありませんでした。そして今、everyテーブルにもう1つのインデックスがありました。これにより、SAN=使用していたスペースの量が増加し、バックアップのサイズが増加し、バックアップの完了に時間がかかるようになり、ブロッキングとデッドロックの可能性が増加し、INSERTおよびDELETE操作のパフォーマンスが低下しました、など.

    そして、テナントのデータを他の多くのテナントのデータと混ぜ合わせて、多くのデータページに分散させるという一般的な非効率性は依然として残っていました。上で述べたように、これによりこれらのページの競合の量が増加し、特にそれらのページの一部の行がクライアント用であった場合、1つまたは2つの有用な行を含む多くのデータページでバッファプールがいっぱいになります。非アクティブでしたが、まだガベージコレクションされていませんでした。このアプローチでは、バッファープールでデータページを再利用する可能性がはるかに少ないため、ページの期待寿命はかなり低くなりました。これは、より多くのページをロードするためにディスクに戻る時間が長くなることを意味します。

34
Solomon Rutzky