内存中 OLTP 的示例数据库

适用于:SQL ServerAzure SQL 数据库

概述

此示例展示了内存中 OLTP 功能。 它显示内存优化表和本机编译的存储过程,并可用于演示内存中 OLTP 的性能优势。

注意

若要查看适用于 SQL Server 2014(12.x)的文章,请参阅 AdventureWorks 的扩展以演示 In-Memory OLTP

该示例将 AdventureWorks2022 数据库中的五个表迁移到内存优化表,并包括一个销售订单处理的演示工作负荷。 可以使用此演示工作负荷查看在服务器上使用内存中 OLTP 的性能优势。

在示例说明中,我们讨论了将表迁移到内存优化 OLTP 时所做的权衡取舍,以考虑内存优化表尚不支持的功能。

此示例的文档的结构如下:

先决条件

  • SQL Server 2016 (13.x)

  • 对于性能测试,服务器的规格类似于生产环境。 对于此特定示例,应至少有 16 GB 内存可供 SQL Server 使用。 有关内存中 OLTP 硬件的一般准则,请参阅以下博客文章: SQL Server 中 In-Memory OLTP 的硬件注意事项

安装基于 AdventureWorks 的内存中 OLTP 示例

请按照以下步骤安装示例:

  1. AdventureWorks2016_EXT.bak 下载 SQLServer2016Samples.ziphttps://github.com/microsoft/sql-server-samples/releases/tag/adventureworks 到本地文件夹,例如 C:\Temp

  2. 使用 Transact-SQL 或 SQL Server Management Studio 还原数据库备份:

    1. 标识数据文件的目标文件夹和文件名,例如:

      H:\DATA\AdventureWorks2022_Data.mdf
      
    2. 标识日志文件的目标文件夹和文件名,例如:

      I:\DATA\AdventureWorks2022_log.ldf
      
      1. 日志文件应置于与数据文件不同的驱动器上,最好是低延迟驱动器(如 SSD 或 PCIe 存储)以实现最大性能。

    示例 T-SQL 脚本:

    RESTORE DATABASE [AdventureWorks2022]
      FROM DISK = N'C:\temp\AdventureWorks2022.bak'
        WITH FILE = 1,
      MOVE N'AdventureWorks2022_Data' TO N'h:\DATA\AdventureWorks2022_Data.mdf',
      MOVE N'AdventureWorks2022_Log' TO N'i:\DATA\AdventureWorks2022_log.ldf',
      MOVE N'AdventureWorks2022_mod' TO N'h:\data\AdventureWorks2022_mod'
     GO
    
  3. 若要查看示例脚本和工作负载,请将文件 SQLServer2016Samples.zip 解压缩到本地文件夹。 有关运行工作负荷的说明,请参阅该文件 In-Memory OLTP\readme.txt

示例表和过程的说明

示例基于 AdventureWorks2022 中的现有表为产品和销售订单创建新表。 新表的架构与现有表类似,但存在一些差异,如本节后面部分所述。

新的内存优化表带有后缀 _inmem。 此示例还包括包含后缀 _ondisk 的对应表 - 这些表可用于在内存优化表的性能与系统上的基于磁盘的表之间进行一对一比较。

性能比较工作负荷中使用的内存优化表具有完全持久性并且会完整记录。 它们不会牺牲持久性或可靠性来实现性能提升。

此示例的目标工作负荷是销售订单处理,我们还考虑在其中包含产品和折扣信息。 为此,我们使用表 SalesOrderHeaderSalesOrderDetailProductSpecialOfferSpecialOfferProduct

两个新的存储过程 Sales.usp_InsertSalesOrder_inmemSales.usp_UpdateSalesOrderShipInfo_inmem 用于插入销售订单和更新给定销售订单的发货信息。

新架构 Demo 包含用于执行演示工作负荷的帮助程序表和存储过程。

具体而言,内存中 OLTP 示例向 AdventureWorks2022 添加以下对象:

示例添加的表

新表

Sales.SalesOrderHeader_inmem

  • 有关销售订单的表头信息。 每个销售订单都在此表中有对应的一行。

Sales.SalesOrderDetail_inmem

  • 销售订单的详细信息。 销售订单的每个行项都在此表中有对应的一行。

Sales.SpecialOffer_inmem

  • 有关特价商品的信息,包括与每个特价商品关联的折扣百分比。

Sales.SpecialOfferProduct_inmem

  • 特价商品与产品之间的引用表。 每个特价商品可以包含零个或多个产品,每个产品可以包含在零个或多个特价商品中。

Production.Product_inmem

  • 有关产品的信息,包括其标价。

Demo.DemoSalesOrderDetailSeed

  • 在演示工作负荷中用于构造示例销售订单。

表的基于磁盘的变体:

  • Sales.SalesOrderHeader_ondisk

  • Sales.SalesOrderDetail_ondisk

  • Sales.SpecialOffer_ondisk

  • Sales.SpecialOfferProduct_ondisk

  • Production.Product_ondisk

基于磁盘的原始表与新内存优化表之间的区别

通常,此示例引入的新表使用与原始表相同的列和相同的数据类型。 但二者之间存在一些区别。 我们列出了本部分中的差异,以及更改的理由。

Sales.SalesOrderHeader_inmem

  • 内存优化表支持默认约束 ,大多数默认约束按原样迁移。 但是,原始表 Sales.SalesOrderHeader 包含两个为列 OrderDateModifiedDate 检索当前日期的默认约束。 在具有大量并发的高吞吐量订单处理工作负荷中,任何全局资源都可能成为争用点。 系统时间是这样一个全局资源,我们观察到,当运行插入销售订单的 In-Memory OLTP 工作负荷时,它可能会成为瓶颈,尤其是在需要检索销售订单标头中多个列的系统时间以及销售订单详细信息时。 此示例中解决问题的方式是仅为插入的每个销售订单检索一次系统时间,然后将该值用于 SalesOrderHeader_inmemSalesOrderDetail_inmem 中的 datetime 列以及存储过程 Sales.usp_InsertSalesOrder_inmem

  • 用户定义的数据类型 (UDT) -原始表分别为列 dbo.OrderNumberdbo.AccountNumber 使用两个别名 UDT:PurchaseOrderNumberAccountNumber。 SQL Server 2016 (13.x) 不支持内存优化表的别名 UDT,因此新表分别使用系统数据类型 nvarchar(25) 和 nvarchar(15)。

  • 索引键中的可为空列 - 在原始表中,该列 SalesPersonID 可为 null,而在新表中,该列不可为 null,并且具有具有值的默认约束(-1)。 这种情况是因为内存优化表上的索引在索引键中不能有可为 null 的列;在本例中,-1 是 NULL 的代理项。

  • 计算列 - 计算列 SalesOrderNumberTotalDue 省略,因为 SQL Server 2016 (13.x) 不支持内存优化表中的计算列。 新视图 Sales.vSalesOrderHeader_extended_inmem 反映了列 SalesOrderNumberTotalDue。 因此,如果需要这些列,可以使用此视图。

    • 适用于: SQL Server 2017 (14.x)。 从 SQL Server 2017(14.x)版本开始,计算列在内存优化的表和索引中获得支持。
  • SQL Server 2016 (13.x) 中的内存优化表支持“外键约束”,前提是引用的表也是经过内存优化的。 引用也已迁移到内存优化的表的外键将保留在已迁移的表中,而其他外键则被忽略。 此外,SalesOrderHeader_inmem 是示例工作负荷中的热表,外键约束要求对所有 DML 操作进行额外处理,因为需要查找这些约束中引用的所有其他表。 因此,假设应用确保表的 Sales.SalesOrderHeader_inmem 引用完整性,并且插入行时不会验证引用完整性。

  • Rowguid - 省略了 rowguid 列。 虽然内存优化表支持 uniqueidentifier,但 SQL Server 2016(13.x)不支持 ROWGUIDCOL 选项。 这种列通常用于合并复制或具有文件流列的表。 此示例不包括其中任何一种。

Sales.SalesOrderDetail

  • 默认约束 - 类似于 SalesOrderHeader的情况,需要系统日期/时间的默认约束不会被迁移。 相反,插入销售订单的存储过程负责在首次插入时插入当前系统日期/时间。

  • 计算列 - 未迁移计算列 LineTotal ,因为 SQL Server 2016(13.x)中的内存优化表不支持计算列。 若要访问此列,请使用 Sales.vSalesOrderDetail_extended_inmem 视图。

  • Rowguid - 省略了 rowguid 列。 有关详细信息,请参阅表 SalesOrderHeader 的说明。

Production.Product

  • 别名 UDT - 原始表使用等效于系统数据类型位的用户定义数据类型 dbo.Flag。 迁移的表改用位数据类型。

  • Rowguid - 省略了 rowguid 列。 有关详细信息,请参阅表 SalesOrderHeader 的说明。

Sales.SpecialOffer

  • Rowguid - 省略了 rowguid 列。 有关详细信息,请参阅表 SalesOrderHeader 的说明。

Sales.SpecialOfferProduct

  • Rowguid - 省略了 rowguid 列。 有关详细信息,请参阅表 SalesOrderHeader 的说明。

有关内存优化表的索引的注意事项

内存优化表的基线索引是 NONCLUSTERED 索引,该索引支持点查找(采用相等谓词的索引查找)、范围扫描(采用不相等谓词的索引查找)、全文索引扫描和有序扫描。 此外,NONCLUSTERED 索引还支持对索引键的前导列进行搜索。 事实上,内存优化 NONCLUSTERED 索引支持基于磁盘的 NONCLUSTERED 索引支持的所有操作,唯一的例外是向后扫描。 因此,使用 NONCLUSTERED 索引是针对索引的安全选择。

哈希索引可用于进一步优化工作负荷。 这些索引针对点查找和行插入进行了优化。 但是,必须考虑它们不支持范围扫描、有序扫描或搜索前导索引键列。 因此,使用这些索引时需要谨慎。 此外,有必要在创建时指定 bucket_count 。 它通常应设置为索引键值的一到二倍之间,不过估计过高通常不是什么问题。

更多相关信息:

已迁移表上的索引已针对演示销售订单处理工作负荷进行了优化。 工作负荷依赖于在表 Sales.SalesOrderHeader_inmemSales.SalesOrderDetail_inmem 中进行插入和点查找,还依赖于对表 Production.Product_inmemSales.SpecialOffer_inmem 中主键列进行点查找。

Sales.SalesOrderHeader_inmem 有三个索引,因为性能原因,而且工作负荷无需进行有序或范围扫描,所以这些索引都是哈希索引。

  • (SalesOrderID) 上的哈希索引:bucket_count 大小为 1000 万(向上舍入为 1600 万),因为预期销售订单数为 1000 万

  • (SalesPersonID) 上的哈希索引:bucket_count 为 100 万。 提供的数据集没有许多销售人员。 但此大型 bucket_count 考虑了将来的增长。 此外,如果 bucket_count 过大,也不会造成点查找性能下降。

  • (CustomerID) 上的哈希索引:bucket_count 为 100 万。 提供的数据集没有大量客户,但这允许将来的增长。

Sales.SalesOrderDetail_inmem 有三个索引,因为性能原因,而且工作负荷无需进行有序或范围扫描,所以这些索引都是哈希索引。

  • 哈希索引 (SalesOrderIDSalesOrderDetailID): 这是主键索引,即使对 (SalesOrderIDSalesOrderDetailID) 的查找并不频繁,但使用键的哈希索引加快行插入速度。 bucket_count 大小为 5000 万(向上舍入为 6700 万):预期销售订单数为 1000 万,这是针对平均每个订单五个项目确定的大小

  • 对 (SalesOrderID) 应用的 HASH 索引:通过销售订单进行查找是很常见的,你想要查找与单个订单对应的所有行项目。 bucket_count 大小为 1000 万(向上舍入为 1600 万),因为预期销售订单数为 1000 万

  • (ProductID) 上的哈希索引:bucket_count 为 100 万。 提供的数据集没有很多产品,但这允许将来的增长。

Production.Product_inmem 有三个索引

  • (ProductID) 上的哈希索引:对 ProductID 的查找是演示工作负荷的关键路径,因此这是哈希索引

  • NONCLUSTERED 索引(Name):允许对产品名称进行排序扫描

  • NONCLUSTERED 索引(ProductNumber):这允许对产品编号进行有序扫描

Sales.SpecialOffer_inmem 具有一个 (SpecialOfferID) 上的哈希索引:对特价优惠的点查找是演示工作负荷的关键因素。 bucket_count 大小为 100 万,允许将来增加。

Sales.SpecialOfferProduct_inmem未在演示工作负载中引用,因此在此表上无须使用哈希索引来优化负载 - (SpecialOfferID, ProductID) 和 (ProductID) 上的索引为NONCLUSTERED。

在前面的示例中,某些存储桶计数过大,但 SalesOrderHeader_inmemSalesOrderDetail_inmem 上索引的存储桶计数不是这样:其大小仅用于 1000 万份订单。 这样做是为了允许在内存不足的系统上安装示例,但在这种情况下,演示工作负荷失败并出现内存不足错误。 如果的确要扩展为远远超过 1000 万个销售订单,可以随意相应地提高桶计数。

内存利用率注意事项

示例数据库中的内存利用率(运行演示工作负荷之前以及之后)在 内存优化表的内存利用率一节中进行了讨论。

示例添加的存储过程

用于插入销售订单和更新发货详细信息的两个重要存储过程如下:

  • Sales.usp_InsertSalesOrder_inmem

    • 在数据库中插入新销售订单并输出该销售订单的 SalesOrderID。 作为输入参数,它将获取销售订单标头的详细信息以及订单中的行项。

    • 输出参数:

      • @SalesOrderID int - SalesOrderID 刚刚插入的销售订单
    • 输入参数(必需):

      • @DueDatedatetime2
      • @CustomerIDint
      • @BillToAddressIDint
      • @ShipToAddressIDint
      • @ShipMethodIDint
      • Sales.SalesOrderDetailType_inmem@SalesOrderDetails- 包含订单行项的表值参数 (TVP)
    • 输入参数(可选):

      • @Statustinyint
      • @OnlineOrderFlag
      • @PurchaseOrderNumbernvarchar(25)
      • @AccountNumbernvarchar(15)
      • @SalesPersonIDint
      • @TerritoryIDint
      • @CreditCardIDint
      • @CreditCardApprovalCodevarchar(15)
      • @CurrencyRateIDint
      • @Commentnvarchar(128)
  • Sales.usp_UpdateSalesOrderShipInfo_inmem

    • 更新给定销售订单的发货信息。 这还会更新销售订单的所有行项的发货信息。

    • 这是本机编译的存储过程 Sales.usp_UpdateSalesOrderShipInfo_native 的包装过程,其中的重试逻辑用于处理与更新该订单的并发事务形成的(意外)潜在冲突。 有关详细信息,请参阅重试逻辑

  • Sales.usp_UpdateSalesOrderShipInfo_native

    • 这是实际处理发货信息更新的本机编译的存储过程。 它应该从包装器 Sales.usp_UpdateSalesOrderShipInfo_inmem存储过程调用。 如果客户端可以处理失败并实现重试逻辑,则您可以直接调用此过程,而不是使用包装存储过程。

以下存储过程用于演示工作负荷。

  • Demo.usp_DemoReset

    • 通过清空 SalesOrderHeaderSalesOrderDetail 表并为其重新设定种子来重置演示。

以下存储过程用于对内存优化表进行插入和删除,同时保证域和引用完整性。

  • Production.usp_InsertProduct_inmem
  • Production.usp_DeleteProduct_inmem
  • Sales.usp_InsertSpecialOffer_inmem
  • Sales.usp_DeleteSpecialOffer_inmem
  • Sales.usp_InsertSpecialOfferProduct_inmem

最后,以下存储过程用于验证域和引用完整性。

  1. dbo.usp_ValidateIntegrity

    • 可选参数:@object_id - 用于验证完整性的对象 ID

    • 此过程依赖于用于需要验证的完整性规则的表 dbo.DomainIntegritydbo.ReferentialIntegritydbo.UniqueIntegrity - 示例基于对 AdventureWorks2022 数据库中的原始表存在的检查、外键和唯一约束来填充这些表。

    • 它依赖于帮助程序过程 dbo.usp_GenerateCKCheckdbo.usp_GenerateFKCheckdbo.GenerateUQCheck 来生成执行完整性检测所需的 T-SQL。

使用演示工作负荷进行性能度量

ostress 是由 Microsoft CSS SQL Server 支持团队开发的命令行工具。 此工具可以用于并行执行查询或运行存储过程。 可以将线程数配置为并行运行给定的 T-SQL 语句,并且可以指定应在此线程上执行该语句的次数; ostress 启动线程并在所有线程上并行执行语句。 执行完成所有线程后, ostress 将报告所有线程完成执行所花费的时间。

安装 ostress

ostress 作为报表标记语言(RML)实用工具的一部分进行安装; ostress 没有独立安装。

安装步骤:

  1. 从以下页面下载并运行 RML 实用工具的 x64 安装包:下载 SQL Server 的 RML

  2. 如果有一个对话框指示某些文件正在使用,请选择“继续”

运行 ostress

Ostress 从命令行提示符运行。 在作为 RML 实用工具组件安装的 RML Cmd 命令提示符 中运行该工具最为方便。

若要打开 RML 命令提示符,请按照以下说明执行:

在 Windows 中,通过选择 Windows 键打开“开始”菜单,然后键入 rml。 选择搜索结果列表中的 RML Cmd 命令提示符

确保命令提示符位于 RML 实用工具安装文件夹中。

仅需在没有任何命令行选项的情况下运行ostress,即可查看其命令行选项。 使用此示例运行 ostress 的主要选项如下:

选项 Description
-S 要连接到的 SQL Server 实例的名称。
-E 使用 Windows 身份验证进行连接(默认值):如果使用 SQL Server 身份验证,请使用选项 -U-P 分别指定用户名和密码。
-d 数据库的名称,在本示例中 AdventureWorks2022
-Q 要执行的 T-SQL 语句。
-n 处理每个输入文件/查询的连接数。
-r 每个连接执行每个输入文件/查询的迭代次数。

演示工作负荷

示例工作负荷中使用的主存储过程为 Sales.usp_InsertSalesOrder_inmem/ondisk。 以下示例中的脚本使用示例数据构造表值参数(TVP),并调用该过程来插入包含五个行项的销售订单。

ostress 工具用于并行执行存储过程调用,以模拟同时插入销售订单的客户端。

在每次执行 Demo.usp_DemoReset 的压力运行之后重置演示。 此过程删除内存优化表中的行、截断基于磁盘的表并执行数据库检查点。

以下脚本并发执行,以模拟销售订单处理工作负荷:

DECLARE @i AS INT = 0, @od AS Sales.SalesOrderDetailType_inmem, @SalesOrderID AS INT, @DueDate AS DATETIME2 = sysdatetime(), @CustomerID AS INT = RAND() * 8000, @BillToAddressID AS INT = RAND() * 10000, @ShipToAddressID AS INT = RAND() * 10000, @ShipMethodID AS INT = (RAND() * 5) + 1;
INSERT INTO @od
SELECT OrderQty,
       ProductID,
       SpecialOfferID
FROM Demo.DemoSalesOrderDetailSeed
WHERE OrderID = CAST ((RAND() * 106) + 1 AS INT);
WHILE (@i < 20)
    BEGIN
        EXECUTE Sales.usp_InsertSalesOrder_inmem
            @SalesOrderID OUTPUT,
            @DueDate,
            @CustomerID,
            @BillToAddressID,
            @ShipToAddressID,
            @ShipMethodID,
            @od;
        SET @i + = 1;
    END

借助此脚本,构造的每个示例订单会通过在 WHILE 循环中执行的 20 个存储过程插入 20 次。 因为数据库用于构造示例订单,所以使用该循环。 在典型的生产环境中,中间层应用程序构造要插入的销售订单。

前面的脚本将销售订单插入内存优化表中。 将销售订单插入到基于磁盘的表中的脚本通过将两个_inmem替换为_ondisk来派生。

我们使用 ostress 工具通过多个并发连接执行脚本。 我们使用参数 -n 来控制连接数,以及用于控制脚本在每个连接上执行次数的参数 r

运行工作负荷

为了进行大规模测试,我们使用 100 个连接插入 1000 万个销售订单。 此测试适合在中等服务器(例如,8 个物理核心、16 个逻辑核心以及用于日志的基本 SSD 存储)上执行。 如果测试在硬件上表现不佳,请查看 “排查运行缓慢的测试问题”部分。 如果要降低此测试的压力级别,请通过更改参数 -n来降低连接数。 例如,若要将连接计数降低到 40,请将参数 -n100 更改为 -n40

作为工作负荷的性能度量值,我们使用运行工作负荷后报告的 ostress.exe 已用时间。

以下说明和测量值用于插入 1000 万个销售订单的工作负荷。 有关运行一个减少规模的工作负载以插入 100 万个销售订单的说明,请参阅作为SQLServer2016Samples.zip归档文件一部分的In-Memory OLTP\readme.txt说明。

内存优化表

首先,在内存优化表上运行工作负荷。 以下命令打开 100 个线程,每个线程运行 5,000 次迭代。 每次迭代在单独事务中插入 20 个销售订单。 每次迭代进行 20 个插入,对数据库用于生成待插入数据进行补偿。 这会生成总共 20 * 5,000 * 100 = 10,000,000 个销售订单插入。

打开 RML Cmd 提示符,并执行以下命令:

选择“复制”按钮复制该命令,将其粘贴到 RML 实用工具命令提示符中。

ostress.exe -n100 -r5000 -S. -E -dAdventureWorks2022 -q -Q"DECLARE @i AS INT = 0, @od AS Sales.SalesOrderDetailType_inmem, @SalesOrderID AS INT, @DueDate AS DATETIME2 = SYSDATETIME(), @CustomerID AS INT = RAND() * 8000, @BillToAddressID AS INT = RAND() * 10000, @ShipToAddressID AS INT = RAND() * 10000, @ShipMethodID AS INT = (RAND() * 5) + 1; INSERT INTO @od SELECT OrderQty, ProductID, SpecialOfferID FROM Demo.DemoSalesOrderDetailSeed WHERE OrderID = CAST ((RAND() * 106) + 1 AS INT); WHILE (@i < 20) BEGIN EXECUTE Sales.usp_InsertSalesOrder_inmem @SalesOrderID OUTPUT, @DueDate, @CustomerID, @BillToAddressID, @ShipToAddressID, @ShipMethodID, @od; SET @i + = 1; END"

在一台共有 8 个物理(16 个逻辑)核心的测试服务器上,需要 2 分 5 秒。 在另一台有 24 个物理(48 个逻辑)核心的测试服务器上,需要 1 分 0 秒。

在工作负荷运行期间观察 CPU 利用率(例如使用任务管理器)。 可以看到 CPU 使用率接近 100%。 如果情况并非如此,那么您可能遇到日志 IO 瓶颈,请参阅排查测试运行缓慢的问题

基于磁盘的表

以下命令在基于磁盘的表上运行工作负荷。 此工作负荷可能需要一段时间才能执行,这主要是由于系统中的闩锁争用。 内存优化表无闩锁,因此不会遇到此问题。

打开 RML 命令提示符,执行以下命令:

选择“复制”按钮复制该命令,将其粘贴到 RML 实用工具命令提示符中。

ostress.exe -n100 -r5000 -S. -E -dAdventureWorks2022 -q -Q"DECLARE @i AS INT = 0, @od AS Sales.SalesOrderDetailType_ondisk, @SalesOrderID AS INT, @DueDate AS DATETIME2 = sysdatetime(), @CustomerID AS INT = RAND() * 8000, @BillToAddressID AS INT = RAND() * 10000, @ShipToAddressID AS INT = RAND() * 10000, @ShipMethodID AS INT = (RAND() * 5) + 1; INSERT INTO @od SELECT OrderQty, ProductID, SpecialOfferID FROM Demo.DemoSalesOrderDetailSeed WHERE OrderID = CAST ((RAND() * 106) + 1 AS INT); WHILE (@i < 20) BEGIN EXECUTE Sales.usp_InsertSalesOrder_ondisk @SalesOrderID OUTPUT, @DueDate, @CustomerID, @BillToAddressID, @ShipToAddressID, @ShipMethodID, @od; SET @i + = 1; END"

在一台共有 8 个物理(16 个逻辑)核心的测试服务器上,需要 41 分 25 秒。 在另一台有 24 个物理(48 个逻辑)核心的测试服务器上,需要 52 分 16 秒。

此测试中内存优化表与基于磁盘的表之间的性能差异的主要因素是,使用基于磁盘的表时,SQL Server 无法充分利用 CPU。 原因在于闩锁争用:并发事务尝试写入相同数据页;闩锁用于确保一次只有一个事务才能写入页。 In-Memory OLTP 引擎是无闩锁的,并且不会在页面中组织数据行。 因此,并发事务不会阻止彼此的插入,从而使 SQL Server 能够充分利用 CPU。

在工作负荷运行期间可以观察 CPU 利用率(例如使用任务管理器)。 使用基于磁盘的表时,CPU 利用率达不到 100%。 在有 16 个逻辑处理器的测试配置中,利用率保持在 24% 左右。

或者,可以使用性能监视器通过性能计数器 \SQL Server:Latches\Latch Waits/sec 查看每秒的闩锁等待数。

重置演示

若要重置演示,请打开 RML 命令提示符,执行以下命令:

ostress.exe -S. -E -dAdventureWorks2022 -Q"EXEC Demo.usp_DemoReset"

根据硬件的不同,这可能需要几分钟才能运行。

我们建议在每次演示运行之后重置。 由于此工作负荷仅插入,因此每次运行占用更多的内存,因此需要重置以防止内存不足。 运行之后占用的内存量在 运行工作负荷之后的内存利用率一节中进行了讨论。

排查运行缓慢的测试问题

测试结果通常因硬件而异,以及测试运行中使用的并发级别。 如果结果不符合预期时,应该考虑的几个因素:

  • 并发事务数:在单个线程上运行工作负荷时,In-Memory OLTP 的性能提升可能小于 2X。 并发级别较高的情况下,闩锁争用才是一个重大问题。

  • SQL Server 可用的内核数较少:这意味着系统中存在低级别的并发性,因为只有任意数量的并发事务可供 SQL 使用。

    • 症状:如果在基于磁盘的表上运行工作负荷时 CPU 使用率很高,这意味着不存在大量争用,这表示缺少并发性。
  • 日志驱动器的速度:如果日志驱动器无法跟上系统中的事务吞吐量级别,则工作负荷在日志 IO 上变得瓶颈。 虽然日志记录对于内存中 OLTP 更加高效,但是如果日志 IO 是瓶颈,则性能提升的可能性有限。

    • 症状:如果 CPU 使用率不接近 100%,或者在内存优化表上运行工作负荷时波动剧烈,则可能存在日志 IO 瓶颈。 这可以通过打开资源监视器查看日志驱动器的队列长度来进行确认。

示例中的内存和磁盘空间利用率

在以下示例中,我们将介绍示例数据库的内存和磁盘空间利用率方面的预期。 我们还在具有 16 个逻辑核心的测试服务器上显示结果。

内存优化表的内存利用率

数据库的总体利用率

以下查询可用于获取系统中内存中 OLTP 的总体内存利用率。

SELECT type,
       name,
       pages_kb / 1024 AS pages_MB
FROM sys.dm_os_memory_clerks
WHERE type LIKE '%xtp%';

刚刚创建数据库之后的快照:

类型 name pages_MB
MEMORYCLERK_XTP 默认 94
MEMORYCLERK_XTP DB_ID_5 877
MEMORYCLERK_XTP 默认 0
MEMORYCLERK_XTP 默认 0

默认内存分配器包含系统范围内存结构,相对较小。 用户数据库的内存分配器在本例中是 ID 为 5 的数据库( database_id 在您的实例中可能有所不同),约为 900 MB。

每个表的内存利用率

以下查询可用于深化到各个表及其索引的内存利用率:

SELECT object_name(t.object_id) AS [Table name],
       memory_allocated_for_table_kb,
       memory_allocated_for_indexes_kb
FROM sys.dm_db_xtp_table_memory_stats AS dms
     INNER JOIN sys.tables AS t
         ON dms.object_id = t.object_id
WHERE t.type = 'U';

下表显示此查询对于全新示例安装的结果:

表名称 memory_allocated_for_table_kb memory_allocated_for_indexes_kb
SpecialOfferProduct_inmem 64 3840
DemoSalesOrderHeaderSeed 1984 5,504
SalesOrderDetail_inmem 15316 663552
DemoSalesOrderDetailSeed 64 10,432
SpecialOffer_inmem 3 8192
SalesOrderHeader_inmem 7168 147456
Product_inmem 124 12352

可以看到,这些表相当小: SalesOrderHeader_inmem 大约为 7 MB, SalesOrderDetail_inmem 大小约为 15 MB。

此处比较显著的是为索引分配的内存大小(与表数据大小相比)。 这是因为示例中的哈希索引已针对更大的数据大小进行预制。 哈希索引的大小固定,因此其大小不会随表中的数据大小而增长。

运行工作负荷之后的内存利用率

插入 1000 万个销售订单后,所有内存利用率类似于以下查询:

SELECT type,
       name,
       pages_kb / 1024 AS pages_MB
FROM sys.dm_os_memory_clerks
WHERE type LIKE '%xtp%';

结果集如下。

type name pages_MB
MEMORYCLERK_XTP 默认 146
MEMORYCLERK_XTP DB_ID_5 7374
MEMORYCLERK_XTP 默认 0
MEMORYCLERK_XTP 默认 0

可以看到,SQL Server 将稍小于 8 GB 的大小用于示例数据库中的内存优化表和索引。

在一次示例运行之后查看每个表的详细内存使用率:

SELECT object_name(t.object_id) AS [Table name],
       memory_allocated_for_table_kb,
       memory_allocated_for_indexes_kb
FROM sys.dm_db_xtp_table_memory_stats AS dms
     INNER JOIN sys.tables AS t
         ON dms.object_id = t.object_id
WHERE t.type = 'U';

结果集如下。

Table name memory_allocated_for_table_kb memory_allocated_for_indexes_kb
SalesOrderDetail_inmem 5113761 663552
DemoSalesOrderDetailSeed 64 10368
SpecialOffer_inmem 2 8192
SalesOrderHeader_inmem 1575679 147456
Product_inmem 111 12032
SpecialOfferProduct_inmem 64 3712
DemoSalesOrderHeaderSeed 1984 5,504

可以看到共有约 6.5 GB 的数据。 表 SalesOrderHeader_inmemSalesOrderDetail_inmem 上的索引大小与插入销售订单之前的索引大小相同。 索引大小没有更改,因为两个表都使用哈希索引,哈希索引是静态的。

演示重置之后

存储过程 Demo.usp_DemoReset 可以用于重置演示。 它会删除表SalesOrderHeader_inmemSalesOrderDetail_inmem中的数据,并重新分配原始表SalesOrderHeader中的数据和 SalesOrderDetail

现在,即使删除了表中的行,这并不意味着立即回收内存。 SQL Server 根据需要在后台从内存优化表中的已删除行回收内存。 在演示重置后立即看到,系统上没有事务工作负荷,已删除行中的内存尚未回收:

SELECT type,
       name,
       pages_kb / 1024 AS pages_MB
FROM sys.dm_os_memory_clerks
WHERE type LIKE '%xtp%';

结果集如下。

type name pages_MB
MEMORYCLERK_XTP 默认 2261
MEMORYCLERK_XTP DB_ID_5 7396
MEMORYCLERK_XTP 默认 0
MEMORYCLERK_XTP 默认 0

这是预期的:事务工作负荷运行时内存被回收。

如果启动演示负载的第二次运行,会发现内存使用率最初下降,因为之前删除的行被清理了。 在某些时候,内存大小会再次增加,直到工作负荷完成。 在演示重置后插入 1000 万行后,内存利用率与首次运行后的利用率非常相似。 例如:

SELECT type,
       name,
       pages_kb / 1024 AS pages_MB
FROM sys.dm_os_memory_clerks
WHERE type LIKE '%xtp%';

结果集如下。

type name pages_MB
MEMORYCLERK_XTP 默认 1,863
MEMORYCLERK_XTP DB_ID_5 7390
MEMORYCLERK_XTP 默认 0
MEMORYCLERK_XTP 默认 0

内存优化表的磁盘利用率

可以使用查询获得数据库检查点文件在给定时间的总体磁盘上大小:

SELECT SUM(df.size) * 8 / 1024 AS [On-disk size in MB]
FROM sys.filegroups AS f
     INNER JOIN sys.database_files AS df
         ON f.data_space_id = df.data_space_id
WHERE f.type = N'FX';

初始状态

最初创建示例文件组和示例内存优化表时,会预先创建多个检查点文件,系统开始填充文件 - 预先创建的检查点文件数取决于系统中的逻辑处理器数。 由于示例最初很小,因此在初始创建后,预创建的文件大多为空。

以下代码是示例在有 16 个逻辑处理器的计算机上的初始磁盘上大小:

SELECT SUM(df.size) * 8 / 1024 AS [On-disk size in MB]
FROM sys.filegroups AS f
     INNER JOIN sys.database_files AS df
         ON f.data_space_id = df.data_space_id
WHERE f.type = N'FX';

结果集如下。

磁盘上大小 (MB)
2312

可以看到,检查点文件的磁盘大小(即 2.3 GB)与实际数据大小(接近 30 MB)之间存在很大的差异。

可以使用以下查询更详细地查看磁盘空间利用率的来源。 此查询返回的磁盘上大小与处于状态 5 (REQUIRED FOR BACKUP/HA)、6 (IN TRANSITION TO TOMBSTONE) 或 7 (TOMBSTONE) 的文件的大小接近。

SELECT state_desc,
       file_type_desc,
       COUNT(*) AS [count],
       SUM(CASE WHEN state = 5 AND file_type = 0 THEN 128 * 1024 * 1024
                WHEN state = 5 AND file_type = 1 THEN 8 * 1024 * 1024
                WHEN state IN (6, 7) THEN 68 * 1024 * 1024
           ELSE file_size_in_bytes END) / 1024 / 1024 AS [on-disk size MB]
FROM sys.dm_db_xtp_checkpoint_files
GROUP BY state, state_desc, file_type, file_type_desc
ORDER BY state, file_type;

对于示例的初始状态,结果类似于下表中具有 16 个逻辑处理器的服务器:

state_desc file_type_desc count 磁盘上大小 (MB)
PRECREATED 数据 16 2048
PRECREATED DELTA 16 128
正在施工中 数据 1 128
正在施工中 DELTA 1 8

可以看到,大部分空间由预先创建的数据和差异文件使用。 SQL Server 为每个逻辑处理器预创建一对(数据、增量)文件。 此外,数据文件的预设大小为 128 MB,增量文件为 8 MB,以提高数据插入这些文件的效率。

内存优化表中的实际数据在单个数据文件中。

运行工作负荷之后

进行插入 1000 万个销售订单的单次测试运行之后,总体磁盘上大小类似于下面这样(对于 16 核测试服务器):

SELECT SUM(df.size) * 8 / 1024 AS [On-disk size in MB]
FROM sys.filegroups AS f
     INNER JOIN sys.database_files AS df
         ON f.data_space_id = df.data_space_id
WHERE f.type = N'FX';

结果集如下。

磁盘上大小 (MB)
8828

磁盘上大小接近 9 GB,这接近于数据的内存中大小。

更详细地查看各种状态间的检查点文件大小:

SELECT state_desc,
       file_type_desc,
       COUNT(*) AS [count],
       SUM(CASE WHEN state = 5 AND file_type = 0 THEN 128 * 1024 * 1024
                WHEN state = 5 AND file_type = 1 THEN 8 * 1024 * 1024
                WHEN state IN (6, 7) THEN 68 * 1024 * 1024
            ELSE file_size_in_bytes END) / 1024 / 1024 AS [on-disk size MB]
FROM sys.dm_db_xtp_checkpoint_files
GROUP BY state, state_desc, file_type, file_type_desc
ORDER BY state, file_type;

结果集如下。

state_desc file_type_desc count on-disk size MB
PRECREATED 数据 16 2048
PRECREATED DELTA 16 128
正在施工中 数据 1 128
正在施工中 DELTA 1 8

我们仍有 16 对预创建的文件,准备在检查点关闭时使用。

当前正在构建一对,直到当前检查点关闭之前使用。 这一对与活动的检查点文件一起,对于内存中 6.5 GB 的数据使用了 6.5 GB 的磁盘。 回想一下,索引不会保留在磁盘上,因此磁盘上的总体大小小于内存中的大小,在这种情况下。

演示重置之后

演示重置后,如果系统上没有事务性工作负载,并且没有数据库检查点,则不会立即回收磁盘空间。 要使检查点文件在各个阶段移动并最终被丢弃,需要发生多个检查点和日志截断事件,以启动检查点文件的合并,以及启动垃圾回收。 如果您的系统中有事务性工作负载,这些操作会自动执行(如果您采用的是 FULL 恢复模式,请定期备份日志),但在系统闲置时,例如在演示场景中,它们不会发生。

在此示例中,在演示重置后,你可能会看到如下内容:

SELECT SUM(df.size) * 8 / 1024 AS [On-disk size in MB]
FROM sys.filegroups AS f
     INNER JOIN sys.database_files AS df
         ON f.data_space_id = df.data_space_id
WHERE f.type = N'FX';

结果集如下。

磁盘上大小 (MB)
11839

差不多 12 GB,这显著大于演示重置之前的 9 GB。 这是因为已启动一些检查点文件合并,但尚未安装某些合并目标,并且某些合并源文件尚未清理,如以下示例所示:

SELECT state_desc,
       file_type_desc,
       COUNT(*) AS [count],
       SUM(CASE WHEN state = 5 AND file_type = 0 THEN 128 * 1024 * 1024
                WHEN state = 5 AND file_type = 1 THEN 8 * 1024 * 1024
                WHEN state IN (6, 7) THEN 68 * 1024 * 1024
           ELSE file_size_in_bytes END) / 1024 / 1024 AS [on-disk size MB]
FROM sys.dm_db_xtp_checkpoint_files
GROUP BY state, state_desc, file_type, file_type_desc
ORDER BY state, file_type;

结果集如下。

state_desc file_type_desc count on-disk size MB
PRECREATED 数据 16 2048
PRECREATED DELTA 16 128
ACTIVE 数据 38 5152
ACTIVE DELTA 38 1331
合并目标 数据 7 896
合并目标 DELTA 7 56
合并源代码 数据 13 1,772
合并的源代码 DELTA 13 455

事务活动在系统中进行时,合并目标会进行安装,合并源会进行清理。

在第二次运行演示工作负荷后,在演示重置后插入了 1000 万个销售订单,你会看到第一次运行工作负荷期间构建的文件已被清理。 如果在负载运行时多次执行之前的查询,您可以看到检查点文件通过各个阶段的过程。

在第二次工作负载运行插入1000万个销售订单后,你会看到磁盘利用率与第一次运行后非常相似,虽然不一定完全相同,这是由于系统的动态特性。 例如:

SELECT state_desc,
       file_type_desc,
       COUNT(*) AS [count],
       SUM(CASE WHEN state = 5 AND file_type = 0 THEN 128 * 1024 * 1024
                WHEN state = 5 AND file_type = 1 THEN 8 * 1024 * 1024
                WHEN state IN (6, 7) THEN 68 * 1024 * 1024
           ELSE file_size_in_bytes END) / 1024 / 1024 AS [on-disk size MB]
FROM sys.dm_db_xtp_checkpoint_files
GROUP BY state, state_desc, file_type, file_type_desc
ORDER BY state, file_type;

结果集如下。

state_desc file_type_desc count on-disk size MB
PRECREATED 数据 16 2048
PRECREATED DELTA 16 128
正在施工中 数据 2 268
正在施工中 DELTA 2 16
ACTIVE 数据 41 5608
ACTIVE DELTA 41 328

在这种情况下,UNDER CONSTRUCTION 状态中有两个检查点文件对,这意味着多个文件对已移动到 UNDER CONSTRUCTION 状态,这可能是由于工作负荷中的并发性较高。 多个并发线程同时需要一个新的文件对,因此从 PRECREATED 移动了一对到 UNDER CONSTRUCTION