Rey*_*ldi 20 index sql-server physical-design multi-tenant design-pattern
我正在使用 ASP Web API、实体框架和 SQL Server/Azure 数据库构建多租户应用程序(单一数据库、单一架构)。此应用程序将被 1000-5000 名客户使用。所有的表都会有TenantId
(Guid / UNIQUEIDENTIFIER
) 字段。现在,我使用单字段主键,即 Id (Guid)。但是通过仅使用 Id 字段,我必须检查用户提供的数据是否来自/用于正确的租户。例如,我有一个SalesOrder
包含CustomerId
字段的表。每次用户发布/更新销售订单时,我都必须检查它CustomerId
是否来自同一个租户。情况变得更糟,因为每个租户可能有多个网点。然后我必须检查TenantId
和OutletId
。这真的是一个维护噩梦,对性能不利。
我想添加TenantId
主键沿Id
。也可能添加OutletId
。所以SalesOrder
表中的主键将是:Id
、TenantId
、 和OutletId
。这种方法的缺点是什么?使用复合键会严重影响性能吗?复合键顺序重要吗?我的问题有更好的解决方案吗?
Sol*_*zky 42
在大型多租户系统上工作过(联合方法,客户分布在 18 多台服务器上,每台服务器具有相同的架构,只是不同的客户,每台服务器每秒有数千笔交易),我可以说:
有些人(至少有几个人)会同意您选择 GUID 作为“TenantID”和任何实体“ID”的 ID。但不,不是一个好的选择。抛开所有其他考虑因素不谈,仅此选择会在几个方面造成伤害:首先是碎片化,大量浪费的空间(在考虑企业存储 - SAN 时不要说磁盘便宜,或者由于每个数据页而导致查询花费更长的时间持行少于它可以与任一INT
或BIGINT
偶数),更难以支持和维护等GUID是伟大的可移植性。数据是否在某个系统中生成然后传输到另一个系统?如果不是,则切换到更紧凑的数据类型(例如TINYINT
, SMALLINT
, INT
, 甚至BIGINT
),并通过IDENTITY
或顺序递增SEQUENCE
.
排除第 1 项,您确实需要在每个包含用户数据的表中都有 TenantID 字段。这样您就可以过滤任何内容而无需额外的 JOIN。这也意味着针对客户端数据表的所有查询都需要TenantID
在 JOIN 条件和/或 WHERE 子句中包含 。这也有助于确保您不会意外混合来自不同客户的数据,或显示来自租户 B 的租户 A 数据。
我正在考虑将 TenantId 与 Id 一起添加为主键。也可能添加 OutletId。因此,销售订单表中的主键将是 Id、TenantId、OutletId。
是的,您应该将客户端数据表上的聚集索引设为复合键,包括TenantID
和ID
**。这也确保了TenantID
在每个非聚集索引中(因为那些包括聚集索引键)你无论如何都需要,因为 98.45% 的对客户端数据表的查询将需要TenantID
(主要的例外是当垃圾收集基于旧数据的在CreatedDate
而不关心TenantID
)。
不,您不会包括诸如OutletID
PK 之类的FK 。PK 需要唯一标识该行,添加 FK 无济于事。事实上,假设 OrderID 对于 each 是唯一的TenantID
,而不是OutletID
在 each 中的per each是唯一的,这会增加重复数据的机会TenantID
。
此外,没有必要添加OutletID
到 PK 以确保租户 A 的 Outlets 不会与租户 B 混淆。由于所有用户数据表都将TenantID
在 PK 中,这意味着TenantID
也将在 FK 中. 例如,Outlet
表的 PK 为(TenantID, OutletID)
,Order
表的 PK(TenantID, OrderID)
和FK(TenantID, OutletID)
引用了Outlet
表上的 PK 。正确定义的 FK 将防止租户数据混合。
复合键顺序重要吗?
好吧,这就是它变得有趣的地方。关于哪个领域应该首先出现存在一些争论。设计好的索引的“典型”规则是选择最具选择性的领域作为领先领域。TenantID
,就其本质而言,不会是最具选择性的领域;该ID
字段是最具选择性的字段。以下是一些想法:
ID 优先:这是最具选择性(即最独特)的字段。但是作为自动增量字段(如果仍然使用 GUID,则是随机的),每个客户的数据都分布在每个表中。这意味着有时客户需要 100 行,这需要将近 100 个数据页从磁盘(速度不快)读入缓冲池(占用的空间超过 10 个数据页)。它还增加了数据页上的争用,因为多个客户需要更频繁地更新同一个数据页。
但是,您通常不会遇到那么多参数嗅探/错误缓存计划问题,因为不同 ID 值的统计数据相当一致。您可能无法获得最佳计划,但您将不太可能获得糟糕的计划。这种方法本质上牺牲了所有客户的性能(略微),以获得较少出现问题的好处。
首先是租户 ID:这根本不是选择性的。如果您只有 100 个租户 ID,那么 100 万行之间的变化可能很小。但是这些查询的统计数据更准确,因为 SQL Server 知道对租户 A 的查询将拉回 500,000 行,而对租户 B 的相同查询只有 50 行。这是主要的痛点所在。这种方法极大地增加了参数嗅探问题的可能性,其中存储过程的第一次运行是针对租户 A 的,并且基于查询优化器看到这些统计信息并知道它需要有效地获取 500k 行而采取适当的行动。但是当只有 50 行的租户 B 运行时,该执行计划不再合适,实际上非常不合适。并且,由于数据没有按照前导字段的顺序插入,
但是,对于第一个运行存储过程的租户 ID,性能应该比其他方法更好,因为数据(至少在进行索引维护之后)将在物理和逻辑上进行组织,从而需要更少的数据页来满足查询。这意味着更少的物理 I/O、更少的逻辑读取、更少的租户之间对相同数据页的争用、更少的缓冲池中浪费的空间(因此提高了页面预期寿命)等。
获得这种改进的性能有两个主要成本。第一个并不难:您必须定期维护索引以抵消增加的碎片。第二个有点不那么有趣。
为了抵消增加的参数嗅探问题,您需要分离租户之间的执行计划。简单的方法是WITH RECOMPILE
在 procs 或OPTION (RECOMPILE)
查询提示上使用,但这是对性能的打击,可能会抹去通过TenantID
首先放置获得的所有收益。我发现最有效的方法是通过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;
Run Code Online (Sandbox Code Playgroud)
这样做的效果是仅为该租户 ID 创建一个可重用的查询计划,该计划将匹配该特定租户的数据量。如果同一个租户 A 再次为另一个租户执行存储过程,@OrderID
那么它将重用缓存的查询计划。运行相同存储过程的不同租户将生成仅在 TenantID 值上不同的查询文本,但查询文本中的任何差异都足以生成不同的计划。并且为租户 B 生成的计划不仅将匹配租户 B 的数据量,而且还可以为租户 B 重用不同的值@OrderID
(因为谓词仍然是参数化的)。
这种方法的缺点是:
动态 SQL 打破了所有权链,这意味着不能通过EXECUTE
对存储过程的权限来假设对表的读/写访问。简单但不太安全的修复只是让用户直接访问表格。这当然不是理想的,但这通常是快速和简单的权衡。更安全的方法是使用基于证书的安全性。意思是,创建一个证书,然后从该证书创建一个用户,授予该用户所需的权限(基于证书的用户或登录名无法自行连接到 SQL Server),然后使用动态 SQL 签署存储过程通过ADD SIGNATURE 获得相同的证书。
有关模块签名和证书的更多信息,请参阅:ModuleSigning.Info
请参阅最后的更新部分,了解与处理因该决定导致的缓解统计问题相关的其他主题。
** 就我个人而言,我真的不喜欢在每个表上只使用“ID”作为 PK 字段名称,因为它没有意义,而且它在 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空间量,提高了我们备份的大小,制作的备份需要较长时间才能完成,增加了潜在的阻塞和死锁,对性能下降INSERT
和DELETE
操作等。
而且我们仍然面临着将租户的数据分散在许多数据页上、与许多其他租户的数据混合在一起的普遍低效问题。正如我上面提到的,这增加了这些页面上的争用量,并且它用大量数据页面填充了缓冲池,其中包含 1 或 2 个有用的行,尤其是当这些页面上的某些行是为客户端处于非活动状态,但尚未被垃圾收集。在这种方法中,重用缓冲池中数据页面的可能性要小得多,因此我们的页面预期寿命非常低。这意味着有更多时间返回磁盘以加载更多页面。
归档时间: |
|
查看次数: |
5077 次 |
最近记录: |