多租户SQL Server数据库中的复合主键


16

我正在使用ASP Web API,实体框架和SQL Server / Azure数据库构建多租户应用程序(单个数据库,单个架构)。此应用将由1000-5000个客户使用。所有表都将具有TenantId(Guid / UNIQUEIDENTIFIER)字段。现在,我使用的是ID(Guid)的单字段主键。但是,仅使用Id字段,我就必须检查用户提供的数据是否来自/正确的租户。例如,我有一个SalesOrder包含CustomerId字段的表。每次用户发布/更新销售订单时,我都必须检查是否CustomerId来自同一租户。情况变得更糟,因为每个租户可能都有多个出口。然后我要检查TenantIdOutletId。这确实是维护的噩梦,并且对性能不利。

我正在考虑将一起添加TenantId到主键中Id。并可能也添加OutletId。所以在主键SalesOrder表将是:IdTenantId,和OutletId。这种方法的缺点是什么?使用复合键会严重损害性能吗?复合键顺序重要吗?我的问题有更好的解决方案吗?

Answers:


34

在大规模,多租户系统上工作(客户分布在18台以上服务器上的联合方法,每台服务器具有相同的架构,只是不同的客户,每台服务器每秒数千个事务),我可以说:

  1. 有些人(至少是少数人)会同意您选择GUID作为“ TenantID”和任何实体“ ID”的ID。但是,不是,不是一个好选择。除了所有其他考虑因素之外,仅此选择就会在某些方面受到损害:碎片化开始,大量浪费的空间(在考虑企业存储(SAN)时不要说磁盘便宜),或者由于每个数据页而导致查询花费更长的时间持行少于它可以与任一INTBIGINT偶数),更难以支持和维护等GUID是伟大的可移植性。数据是否在某个系统中生成然后传输到另一个系统?如果不是,则切换到更紧凑的数据类型(例如TINYINTSMALLINTINT,或甚至BIGINT通过),并且增量顺序地IDENTITYSEQUENCE

  2. 对于第1项,您确实需要在每个具有用户数据的表中都有TenantID字段。这样,您可以过滤任何内容而无需额外的JOIN。这也意味着对客户端数据表的所有查询都必须具有TenantIDJOIN条件和/或WHERE子句。这也有助于确保您不会意外混合来自不同客户的数据,或显示来自租户B的租户A数据。

  3. 我正在考虑将TenantId与ID一起添加为主键。并可能也添加OutletId。因此,销售订单表中的主键将是ID,TenantId,OutletId。

    是的,您应该使客户端数据表上的聚簇索引为复合键,包括TenantIDID **。这也确保了TenantID无论如何您都将需要每个非聚簇索引(因为它们包含聚簇索引关键字),因为针对客户端数据表的98.45%的查询将需要TenantID(主要例外是基于垃圾收集旧数据时继续CreatedDate,不在乎TenantID)。

    不,您不会包括OutletIDPK之类的FK 。PK需要唯一标识该行,而添加FK则无济于事。实际上,假设OrderID对每个数据而言都是唯一的TenantID,而不是每个OutletID内部的唯一数据,这将增加重复数据的机会TenantID

    另外,也不必添加OutletIDPK以确保来自租户A的出口不会与租户B混淆。由于所有用户数据表都将包含TenantID在PK中,因此这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值之间的统计数据相当一致,因此通常不会遇到那么多参数嗅探/错误的缓存计划问题。您可能不会获得最理想的计划,但获得恐怖计划的可能性较小。这种方法本质上(稍微)牺牲了所有客户的性能,从而获得了较少出现的问题的好处。

    • 先租户ID:这根本不是选择性的。如果只有100个TenantID,则100万行的变化可能很小。但是,由于SQL Server知道对租户A的查询将拉回500,000行,而对租户B的相同查询只有50行,因此这些查询的统计信息更加准确。这是主要的痛点所在。如果存储过程的第一次运行是针对租户A的,则此方法极大地增加了出现参数嗅探问题的机会,并且基于查询优化器看到这些统计信息并知道需要有效地获取50万行,从而适当地采取了行动。但是,当只有50行的Tenant B运行时,该执行计划不再适用,实际上是不合适的。并且,由于未按前导字段的顺序插入数据,

      但是,对于第一个运行存储过程的TenantID,性能应优于其他方法,因为数据(至少在进行索引维护之后)将在物理和逻辑上进行组织,从而需要更少的数据页来满足查询。这意味着更少的物理I / O,更少的逻辑读取,更少的相同数据页面的租户之间的争用,更少的缓冲池占用的空间(因此提高了页面寿命)。

      获得此改进的性能有两个主要成本。第一个并不是那么困难:您必须定期维护索引以抵消增加的碎片。第二个不太有趣。

      为了解决增加的参数嗅探问题,您需要在租户之间分离执行计划。简单的方法是WITH RECOMPILE在proc或OPTION (RECOMPILE)查询提示上使用,但这对性能造成了打击,可能会抹杀所有因获得TenantID第一而获得的收益。我发现效果最好的方法是通过使用参数化Dynamic 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;

      这样做的效果是仅为该TenantID创建一个可重用的查询计划,该计划将匹配该特定Tenant的数据量。如果同一租户A再次执行该存储过程,@OrderID则它将重用该缓存的查询计划。运行同一存储过程的另一个租户将生成一个查询文本,该查询文本仅在TenantID的值上有所不同,但是查询文本中的任何差异足以生成一个不同的计划。而且,为租户B生成的计划不仅将与租户B的数据量匹配,而且对于不同的值@OrderID(由于仍对谓词进行了参数化),它还可重用于租户B。

      这种方法的缺点是:

      • 与仅键入一个简单查询相比,这需要做更多的工作(但并非所有查询都必须是Dynamic SQL,只是那些最终会出现参数嗅探问题的查询)。
      • 根据系统上有多少租户,它确实会增加计划缓存的大小,因为每个查询现在每个调用它的TenantID都需要1个计划。这可能不是问题,但至少要注意一点。
      • 动态SQL破坏了所有权链,这意味着无法通过EXECUTE对存储过程的许可来假定对表的读/写访问。简单但安全性较低的修复方法只是让用户直接访问表。这当然不是理想的,但这通常是快速简便的权衡。更为安全的方法是使用基于证书的安全性。意思是,创建一个证书,然后从该证书创建一个用户,向该用户授予所需的权限(基于证书的用户或登录名不能自行连接到SQL Server),然后使用该协议对使用动态SQL的存储过程进行签名通过添加签名获得相同的证书。

        有关模块签名和证书的更多信息,请参见:ModuleSigning.Info
         

    请参阅结尾处的“ 更新”部分,以获取与该决策所产生的缓解统计问题有关的其他主题。


**就我个人而言,我真的不喜欢在每个表的PK字段名称上仅使用“ ID”,因为它没有意义,而且在FK之间也不统一,因为PK始终是“ ID”,并且子表中的字段必须包括父表名称。例如:Orders.ID-> OrderItems.OrderID。我发现处理具有:Orders.OrderID-> 的数据模型要容易得多OrderItems.OrderID。它更具可读性,并减少了出现“歧义列引用”错误的次数:-)。


更新

  • 请问OPTIMIZE FOR UNKNOWN 查询提示(在SQL Server 2008推出)的帮助下与复合PK任排序?

    并不是的。此选项确实解决了参数嗅探问题,但仅将一个问题替换为另一个。在这种情况下,它不会记住存储过程或参数化查询的初始运行的参数值的统计信息(这对某些人肯定是很棒的,但对于某些人来说可能是中等的,对于某些人来说可能是可怕的),它使用的是通用的统计数据分布以估计行数。对于多少个查询(以及在什么程度上)将受到正面,负面或根本没有影响,这是成败的。至少使用参数嗅探可以确保某些查询受益。如果您的系统的租户的数据量相差很大,则可能会损害所有查询的性能。

    此选项的作用与将输入参数复制到局部变量,然后在查询中使用局部变量的作用相同(我已经对此进行了测试,但此处没有余地)。其他信息可以在此博客文章中找到:http : //www.brentozar.com/archive/2013/06/optimize-for-unknown-sql-server-parameter-sniffing/。阅读评论后,Daniel Pepermans得出了与我相似的结论,即使用动态SQL的变化有限。

  • 如果ID是聚集索引中的前导字段,那么对(TenantID,ID)或仅对(TenantID)具有非聚集索引来为处理单个租户的多行查询提供准确的统计信息,是否有帮助/足够?

    是的,这会有所帮助。我提到的多年来一直在使用的大型系统是基于以该IDENTITY领域为主导领域的索引设计,因为它具有更高的选择性并减少了参数嗅探问题。但是,当我们需要对特定租户的大部分数据进行操作时,性能并没有提高。实际上,由于SAN控制器在吞吐量方面已达到极限,因此必须暂停将所有数据迁移到新数据库的项目。解决方法是将非群集索引添加到所有租户数据表中,使其仅为(TenantID)。无需做任何事情(TenantID,ID),因为ID已在聚集索引中,因此非聚集索引的内部结构自然是(TenantID,ID)。

    尽管这确实解决了能够更高效地执行基于TenantID的查询的直接问题,但如果按相同顺序排列的聚簇索引,它们的效率仍然不如以前。而且,现在我们在每个表上都有一个索引。这增加了我们正在使用的SAN空间的数量,增加了备份的大小,使备份需要更长的时间才能完成,增加了阻塞和死锁的可能性,降低了性能INSERTDELETE操作性能,等等。

    而且,将承租人的数据分散在许多数据页面上并与许多其他承租人的数据混合在一起,通常效率低下。正如我上面提到的,这增加了这些页面上的争用量,并且用许多包含1或2个有用行的数据页面填充了缓冲池,尤其是当那些页面上的某些行是为那些处于非活动状态,但尚未被垃圾回收。用这种方法重用缓冲池中的数据页的可能性要小得多,因此我们的页面预期寿命很低。这意味着有更多时间返回磁盘以加载更多页面。


2
您是否考虑过在此问题空间中进行未知的优化测试?只是好奇。
RLF

1
@RLF是的,我们研究了该选项,它至少应该比从首先拥有IDENTITY字段获得的不那么理想的性能更好或更坏。我不记得我在哪里读过这篇文章,但是据推测它提供了与将输入参数重新分配给局部变量相同的“平均”状态。但是本文探讨了为什么该选项不能真正解决问题的原因:brentozar.com/archive/2013/06/…阅读评论,Daniel Pepermans得出了类似的结论,即:变化有限的动态SQL :)
Solomon Rutzky

3
如果聚集索引处于打开状态,(ID, TenantID)并且您还在上创建了非聚集索引(TenantID, ID),或者只是在on上创建了非聚集索引,(TenantID)以为处理单个租户的大多数行的查询提供准确的统计信息,该怎么办?
弗拉基米尔·巴拉诺夫

1
@VladimirBaranov很好的问题。我已经在答案末尾的新UPDATE部分中解决了这个问题:-)。
所罗门·鲁兹基

4
关于动态sql为每个客户生成计划的好地方。
Max Vernon
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.