你当前正在访问 Microsoft Azure Global Edition 技术文档网站。 如果需要访问由世纪互联运营的 Microsoft Azure 中国技术文档网站,请访问 https://docs.azure.cn

繁忙数据库对立模式

将处理卸载到数据库服务器可能会导致它花费大量时间运行代码,而不是响应存储和检索数据的请求。

问题描述

许多数据库系统可以运行代码。 示例包括存储过程和触发器。 通常,在靠近数据附近执行此处理,而不是将数据传输到客户端应用程序进行处理更为高效。 但是,由于多种原因,过度使用这些功能可能会损害性能:

  • 数据库服务器可能需要花费太多时间来处理,而不是接受新的客户端请求和提取数据。
  • 数据库通常是共享资源,因此在高使用率期间,数据库可能会成为瓶颈。
  • 如果数据存储按流量计费,那么运行时成本可能会过高。 这尤其适用于托管数据库服务。 例如,Azure SQL 数据库按数据库事务单位(DTU)收费。
  • 数据库具有纵向扩展的有限容量,水平缩放数据库并不简单。 因此,最好将处理移动到计算资源(例如 VM 或应用服务应用)中,以便轻松横向扩展。

出现此反模式的原因通常是:

  • 数据库被视为服务而不是存储库。 应用程序可以使用数据库服务器来格式化数据(例如,转换为 XML)、处理字符串数据或执行复杂的计算。
  • 开发人员尝试编写可以直接向用户显示其结果的查询。 例如,查询可能会根据区域设置合并字段或设置日期、时间和货币的格式。
  • 开发人员尝试通过将计算推送到数据库来更正 超量提取 反模式。
  • 存储过程用于封装业务逻辑,也许是因为它们被认为更易于维护和更新。

以下示例检索指定销售区域最有价值的 20 个订单,并将结果的格式设置为 XML。 它使用 Transact-SQL 函数分析数据并将结果转换为 XML。

SELECT TOP 20
  soh.[SalesOrderNumber]  AS '@OrderNumber',
  soh.[Status]            AS '@Status',
  soh.[ShipDate]          AS '@ShipDate',
  YEAR(soh.[OrderDate])   AS '@OrderDateYear',
  MONTH(soh.[OrderDate])  AS '@OrderDateMonth',
  soh.[DueDate]           AS '@DueDate',
  FORMAT(ROUND(soh.[SubTotal],2),'C')
                          AS '@SubTotal',
  FORMAT(ROUND(soh.[TaxAmt],2),'C')
                          AS '@TaxAmt',
  FORMAT(ROUND(soh.[TotalDue],2),'C')
                          AS '@TotalDue',
  CASE WHEN soh.[TotalDue] > 5000 THEN 'Y' ELSE 'N' END
                          AS '@ReviewRequired',
  (
  SELECT
    c.[AccountNumber]     AS '@AccountNumber',
    UPPER(LTRIM(RTRIM(REPLACE(
    CONCAT( p.[Title], ' ', p.[FirstName], ' ', p.[MiddleName], ' ', p.[LastName], ' ', p.[Suffix]),
    '  ', ' '))))         AS '@FullName'
  FROM [Sales].[Customer] c
    INNER JOIN [Person].[Person] p
  ON c.[PersonID] = p.[BusinessEntityID]
  WHERE c.[CustomerID] = soh.[CustomerID]
  FOR XML PATH ('Customer'), TYPE
  ),

  (
  SELECT
    sod.[OrderQty]      AS '@Quantity',
    FORMAT(sod.[UnitPrice],'C')
                        AS '@UnitPrice',
    FORMAT(ROUND(sod.[LineTotal],2),'C')
                        AS '@LineTotal',
    sod.[ProductID]     AS '@ProductId',
    CASE WHEN (sod.[ProductID] >= 710) AND (sod.[ProductID] <= 720) AND (sod.[OrderQty] >= 5) THEN 'Y' ELSE 'N' END
                        AS '@InventoryCheckRequired'

  FROM [Sales].[SalesOrderDetail] sod
  WHERE sod.[SalesOrderID] = soh.[SalesOrderID]
  ORDER BY sod.[SalesOrderDetailID]
  FOR XML PATH ('LineItem'), TYPE, ROOT('OrderLineItems')
  )

FROM [Sales].[SalesOrderHeader] soh
WHERE soh.[TerritoryId] = @TerritoryId
ORDER BY soh.[TotalDue] DESC
FOR XML PATH ('Order'), ROOT('Orders')

显然,这是复杂的查询。 稍后我们将看到,事实证明,在数据库服务器上使用大量处理资源。

如何解决此问题

将处理从数据库服务器移动到其他应用程序层。 理想情况下,您应该将数据库限制为仅执行数据访问操作,并仅使用数据库优化功能,如关系数据库管理系统(RDBMS)中的聚合功能。

例如,可以将前面的 Transact-SQL 代码替换为只需检索要处理的数据的语句。

SELECT
soh.[SalesOrderNumber]  AS [OrderNumber],
soh.[Status]            AS [Status],
soh.[OrderDate]         AS [OrderDate],
soh.[DueDate]           AS [DueDate],
soh.[ShipDate]          AS [ShipDate],
soh.[SubTotal]          AS [SubTotal],
soh.[TaxAmt]            AS [TaxAmt],
soh.[TotalDue]          AS [TotalDue],
c.[AccountNumber]       AS [AccountNumber],
p.[Title]               AS [CustomerTitle],
p.[FirstName]           AS [CustomerFirstName],
p.[MiddleName]          AS [CustomerMiddleName],
p.[LastName]            AS [CustomerLastName],
p.[Suffix]              AS [CustomerSuffix],
sod.[OrderQty]          AS [Quantity],
sod.[UnitPrice]         AS [UnitPrice],
sod.[LineTotal]         AS [LineTotal],
sod.[ProductID]         AS [ProductId]
FROM [Sales].[SalesOrderHeader] soh
INNER JOIN [Sales].[Customer] c ON soh.[CustomerID] = c.[CustomerID]
INNER JOIN [Person].[Person] p ON c.[PersonID] = p.[BusinessEntityID]
INNER JOIN [Sales].[SalesOrderDetail] sod ON soh.[SalesOrderID] = sod.[SalesOrderID]
WHERE soh.[TerritoryId] = @TerritoryId
AND soh.[SalesOrderId] IN (
    SELECT TOP 20 SalesOrderId
    FROM [Sales].[SalesOrderHeader] soh
    WHERE soh.[TerritoryId] = @TerritoryId
    ORDER BY soh.[TotalDue] DESC)
ORDER BY soh.[TotalDue] DESC, sod.[SalesOrderDetailID]

然后,应用程序使用 .NET Framework System.Xml.Linq API 将结果格式化为 XML。

// Create a new SqlCommand to run the Transact-SQL query
using (var command = new SqlCommand(...))
{
    command.Parameters.AddWithValue("@TerritoryId", id);

    // Run the query and create the initial XML document
    using (var reader = await command.ExecuteReaderAsync())
    {
        var lastOrderNumber = string.Empty;
        var doc = new XDocument();
        var orders = new XElement("Orders");
        doc.Add(orders);

        XElement lineItems = null;
        // Fetch each row in turn, format the results as XML, and add them to the XML document
        while (await reader.ReadAsync())
        {
            var orderNumber = reader["OrderNumber"].ToString();
            if (orderNumber != lastOrderNumber)
            {
                lastOrderNumber = orderNumber;

                var order = new XElement("Order");
                orders.Add(order);
                var customer = new XElement("Customer");
                lineItems = new XElement("OrderLineItems");
                order.Add(customer, lineItems);

                var orderDate = (DateTime)reader["OrderDate"];
                var totalDue = (Decimal)reader["TotalDue"];
                var reviewRequired = totalDue > 5000 ? 'Y' : 'N';

                order.Add(
                    new XAttribute("OrderNumber", orderNumber),
                    new XAttribute("Status", reader["Status"]),
                    new XAttribute("ShipDate", reader["ShipDate"]),
                    ... // More attributes, not shown.

                    var fullName = string.Join(" ",
                        reader["CustomerTitle"],
                        reader["CustomerFirstName"],
                        reader["CustomerMiddleName"],
                        reader["CustomerLastName"],
                        reader["CustomerSuffix"]
                    )
                   .Replace("  ", " ") //remove double spaces
                   .Trim()
                   .ToUpper();

               customer.Add(
                    new XAttribute("AccountNumber", reader["AccountNumber"]),
                    new XAttribute("FullName", fullName));
            }

            var productId = (int)reader["ProductID"];
            var quantity = (short)reader["Quantity"];
            var inventoryCheckRequired = (productId >= 710 && productId <= 720 && quantity >= 5) ? 'Y' : 'N';

            lineItems.Add(
                new XElement("LineItem",
                    new XAttribute("Quantity", quantity),
                    new XAttribute("UnitPrice", ((Decimal)reader["UnitPrice"]).ToString("C")),
                    new XAttribute("LineTotal", RoundAndFormat(reader["LineTotal"])),
                    new XAttribute("ProductId", productId),
                    new XAttribute("InventoryCheckRequired", inventoryCheckRequired)
                ));
        }
        // Match the exact formatting of the XML returned from SQL
        var xml = doc
            .ToString(SaveOptions.DisableFormatting)
            .Replace(" />", "/>");
    }
}

注释

此代码有点复杂。 对于新应用程序,你可能更喜欢使用序列化库。 但是,此处的假设是开发团队正在重构现有应用程序,因此该方法需要返回与原始代码完全相同的格式。

注意事项

  • 许多数据库系统经过高度优化以执行某些类型的数据处理,例如计算大型数据集的聚合值。 不要将这些类型的处理移出数据库。

  • 如果这样做会导致数据库通过网络传输更多数据,请不要重新定位处理。 请参阅超量提取对立模式

  • 如果将处理移动到应用程序层,则可能需要横向扩展才能处理其他工作。

如何检测问题

繁忙数据库的症状包括访问数据库的操作中的吞吐量和响应时间出现不成比例的下降。

可执行以下步骤来帮助识别此问题:

  1. 使用性能监视来确定生产系统执行数据库活动所花费的时间。

  2. 在这些时间段内检查数据库执行的工作。

  3. 如果怀疑特定作可能会导致数据库活动过多,请在受控环境中执行负载测试。 每个测试都应在不同用户负载下运行可疑操作的组合。 检查负载测试中的遥测数据,观察数据库的使用方式。

  4. 如果数据库活动显示大量处理,但数据流量很少,请查看源代码以确定是否可以在其他地方更好地执行处理。

如果数据库活动量较低或响应时间相对较快,则忙碌的数据库不太可能是性能问题。

示例诊断

以下部分将这些步骤应用到前面所述的示例应用程序。

监视数据库活动量

下图显示了对示例应用程序运行负载测试的结果,测试使用了步骤加载,最多支持50个并发用户。 请求量迅速达到限制并保持在该级别,而平均响应时间稳步增加。 这两个指标使用了对数刻度。

在数据库中执行处理的负载测试结果

此折线图显示用户负载、每秒请求数和平均响应时间。 该图显示响应时间随着负载的增加而增加。

下一个图显示 CPU 使用率和 DTU 作为服务配额的百分比。 DTU 度量数据库执行的处理工作量。 该图显示 CPU 和 DTU 利用率都很快达到 100%。

Azure SQL 数据库监视器显示执行处理时数据库的性能

此折线图显示一段时间内的 CPU 百分比和 DTU 百分比。 此图显示二者很快达到 100%。

检查数据库执行的工作

可能是数据库执行的任务是真正的数据访问作,而不是处理,因此了解在数据库繁忙时运行的 SQL 语句非常重要。 监视系统以捕获 SQL 流量,并将 SQL 操作与应用程序请求相关联。

如果数据库操作纯粹是数据访问操作,并且无需大量处理,那么问题可能是多余的提取

实施解决方案并验证结果

下图显示了使用更新的代码的负载测试。 吞吐量明显较高,每秒请求数超过 400 个,而之前的请求数超过 12 个。 平均响应时间也要低得多,比 4 秒以上只有 0.1 秒以上。

显示用于在客户端应用程序中执行处理的负载测试结果的图形。

此折线图显示用户负载、每秒请求数和平均响应时间。 该图显示整个负载测试中的响应时间大致保持不变。

CPU 和 DTU 利用率表明,尽管吞吐量增加,但系统需要更长的时间才能达到饱和。

Azure SQL 数据库监视器显示在客户端应用程序中执行处理时数据库的性能

此折线图显示一段时间内的 CPU 百分比和 DTU 百分比。 图表显示 CPU 和 DTU 达到 100% 的时间比之前更长。