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

琐碎 I/O 对立模式

大量 I/O 请求的累积影响可能会对性能和响应能力产生重大影响。

问题描述

与计算任务相比,网络调用和其他 I/O 操作在本质上是缓慢的。 每个 I/O 请求通常具有显著的开销,许多 I/O 操作的累积影响可能会降低系统速度。 下面是出现琐碎 I/O 的一些常见原因。

将单个记录作为不同的请求读取和写入数据库

以下示例从产品数据库中读取数据。 有三个表,ProductProductSubcategoryProductPriceListHistory。 该代码通过执行一系列查询来检索子类别中的所有产品以及定价信息:

  1. ProductSubcategory 表中查询子类别。
  2. 通过查询 Product 表查找该子类别中的所有产品。
  3. 对于每个产品,请查询表中的 ProductPriceListHistory 定价数据。

应用程序使用 Entity Framework 查询数据库。

public async Task<IHttpActionResult> GetProductsInSubCategoryAsync(int subcategoryId)
{
    using (var context = GetContext())
    {
        // Get product subcategory.
        var productSubcategory = await context.ProductSubcategories
                .Where(psc => psc.ProductSubcategoryId == subcategoryId)
                .FirstOrDefaultAsync();

        // Find products in that category.
        productSubcategory.Product = await context.Products
            .Where(p => subcategoryId == p.ProductSubcategoryId)
            .ToListAsync();

        // Find price history for each product.
        foreach (var prod in productSubcategory.Product)
        {
            int productId = prod.ProductId;
            var productListPriceHistory = await context.ProductListPriceHistory
                .Where(pl => pl.ProductId == productId)
                .ToListAsync();
            prod.ProductListPriceHistory = productListPriceHistory;
        }
        return Ok(productSubcategory);
    }
}

此示例显式显示了问题,但有时,如果 O/RM 逐个地隐式提取子记录,则可能会掩盖问题。 这称为“N+1 问题”。

将单个逻辑操作实现为一系列 HTTP 请求

当开发人员尝试遵循面向对象的范例,并将远程对象视为内存中的本地对象时,通常会发生这种情况。 这可能会导致网络往返次数过多。 例如,以下 Web API 通过单独的 HTTP GET 方法公开对象的单个属性 User

public class UserController : ApiController
{
    [HttpGet]
    [Route("users/{id:int}/username")]
    public HttpResponseMessage GetUserName(int id)
    {
        ...
    }

    [HttpGet]
    [Route("users/{id:int}/gender")]
    public HttpResponseMessage GetGender(int id)
    {
        ...
    }

    [HttpGet]
    [Route("users/{id:int}/dateofbirth")]
    public HttpResponseMessage GetDateOfBirth(int id)
    {
        ...
    }
}

虽然这种方法在技术上没有问题,但大多数客户端可能需要为每个属性获取多个属性 User,从而导致客户端代码如下所示。

HttpResponseMessage response = await client.GetAsync("users/1/username");
response.EnsureSuccessStatusCode();
var userName = await response.Content.ReadAsStringAsync();

response = await client.GetAsync("users/1/gender");
response.EnsureSuccessStatusCode();
var gender = await response.Content.ReadAsStringAsync();

response = await client.GetAsync("users/1/dateofbirth");
response.EnsureSuccessStatusCode();
var dob = await response.Content.ReadAsStringAsync();

读取和写入磁盘上的文件

文件 I/O 涉及在读取或写入数据之前打开文件并移动到相应的点。 操作完成后,文件可能会关闭以保存操作系统资源。 将少量信息持续读取和写入文件的应用程序将产生大量的 I/O 开销。 较小的写入请求还可能导致文件碎片,进一步减缓后续 I/O操作的速度。

以下示例使用一个FileStreamCustomer对象将对象写入文件。 创建 FileStream 会打开该文件,释放它会关闭该文件。 (语句 using 自动释放 FileStream 对象。如果应用程序在添加新客户时反复调用此方法,则 I/O 开销可以快速累积。

private async Task SaveCustomerToFileAsync(Customer customer)
{
    using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
    {
        BinaryFormatter formatter = new BinaryFormatter();
        byte [] data = null;
        using (MemoryStream memStream = new MemoryStream())
        {
            formatter.Serialize(memStream, customer);
            data = memStream.ToArray();
        }
        await fileStream.WriteAsync(data, 0, data.Length);
    }
}

如何解决此问题

通过将数据打包成更大、更少的请求来减少 I/O 请求数。

以单个查询的形式从数据库提取数据,而不是多个较小的查询。 下面是检索产品信息的代码的修订版本。

public async Task<IHttpActionResult> GetProductCategoryDetailsAsync(int subCategoryId)
{
    using (var context = GetContext())
    {
        var subCategory = await context.ProductSubcategories
                .Where(psc => psc.ProductSubcategoryId == subCategoryId)
                .Include("Product.ProductListPriceHistory")
                .FirstOrDefaultAsync();

        if (subCategory == null)
            return NotFound();

        return Ok(subCategory);
    }
}

遵循 Web API 的 REST 设计原则。 下面是前面示例中 Web API 的修订版本。 不要针对每个属性单独使用 GET 方法,而可以使用单个返回 User 的 GET 方法。 这会导致每个请求的响应正文较大,但每个客户端可能会发出更少的 API 调用。

public class UserController : ApiController
{
    [HttpGet]
    [Route("users/{id:int}")]
    public HttpResponseMessage GetUser(int id)
    {
        ...
    }
}

// Client code
HttpResponseMessage response = await client.GetAsync("users/1");
response.EnsureSuccessStatusCode();
var user = await response.Content.ReadAsStringAsync();

对于文件 I/O,请考虑在内存中缓冲数据,然后将缓冲数据作为单个操作写入文件。 此方法可减少重复打开和关闭文件的开销,并帮助减少磁盘上文件的碎片。

// Save a list of customer objects to a file
private async Task SaveCustomerListToFileAsync(List<Customer> customers)
{
    using (Stream fileStream = new FileStream(CustomersFileName, FileMode.Append))
    {
        BinaryFormatter formatter = new BinaryFormatter();
        foreach (var customer in customers)
        {
            byte[] data = null;
            using (MemoryStream memStream = new MemoryStream())
            {
                formatter.Serialize(memStream, customer);
                data = memStream.ToArray();
            }
            await fileStream.WriteAsync(data, 0, data.Length);
        }
    }
}

// In-memory buffer for customers.
List<Customer> customers = new List<Customers>();

// Create a new customer and add it to the buffer
var customer = new Customer(...);
customers.Add(customer);

// Add more customers to the list as they are created
...

// Save the contents of the list, writing all customers in a single operation
await SaveCustomerListToFileAsync(customers);

注意事项

  • 前两个示例进行 更少的 I/O 调用,但每个示例检索 更多信息 。 必须考虑这两个因素之间的权衡。 正确的答案将取决于实际使用模式。 例如,在 Web API 示例中,可能会发现客户端通常只需要用户名。 在这种情况下,将它公开为单独的 API 调用可能有意义。 有关详细信息,请参阅 “超量提取 反模式”。

  • 读取数据时,不要使 I/O 请求太大。 应用程序应仅检索可能使用的信息。

  • 有时,将对象的信息划分为两个区块是有帮助的,即占大多数请求的经常访问的数据和很少访问的数据。 通常,最常访问的数据是对象总数据的一小部分,因此只返回该部分可以节省大量的 I/O 开销。

  • 写入数据时,请避免将资源锁定超过必要的时间,以减少在执行冗长操作期间发生资源争用的可能性。 如果写入操作跨多个数据存储、文件或服务,则采用最终一致性方法。 请参阅 数据一致性指南

  • 如果在写入之前在内存中缓冲数据,则如果进程崩溃,则数据会易受攻击。 如果数据率通常出现突发或相对稀疏,在事件中心等外部持久队列中缓冲数据可能会更安全。

  • 请考虑缓存从服务或数据库检索的数据。 这可以帮助减少 I/O 的数量,方法是避免对相同数据的重复请求。 有关详细信息,请参阅 缓存最佳做法

如何检测问题

聊天 I/O 的症状包括高延迟和低吞吐量。 由于 I/O 资源争用加剧,最终用户可能会反映响应时间延长,或服务超时导致失败。

可以执行以下步骤来帮助确定任何问题的原因:

  1. 对生产系统执行进程监视,识别响应时间不佳的操作。
  2. 对上一步骤中识别到的每个操作执行负载测试。
  3. 在负载测试期间,收集有关每个操作发出的数据访问请求的遥测数据。
  4. 收集发送到数据存储的每个请求的详细统计信息。
  5. 在测试环境中分析应用程序,判定可能出现 I/O 瓶颈的位置。

查找以下任何症状:

  • 向同一个文件发出大量的小型 I/O 请求。
  • 应用程序实例向同一服务发出的大量小型网络请求。
  • 应用程序实例向同一数据存储发出的大量小型请求。
  • 应用程序和服务受 I/O 约束。

示例诊断

以下部分将这些步骤应用于前面所示查询数据库的示例。

对应用程序进行负载测试

此图显示了负载测试的结果。 每个请求的响应时间中位数以数十秒为单位。 该图显示非常高的延迟。 加载了 1000 个用户后,用户可能需要等待近一分钟才能查看查询结果。

聊天 I/O 示例应用程序的关键指标负载测试结果

注释

该应用程序是使用 Azure SQL 数据库部署为 Azure 应用服务 Web 应用。 负载测试使用了包含多达 1000 个并发用户的模拟步骤工作负荷。 数据库配置了支持最多 1000 个并发连接的连接池,以减少连接争用会影响结果的可能性。

监视应用程序

可以使用应用程序性能管理 (APM) 包来捕获和分析可能识别聊天 I/O 的关键指标。 哪些指标很重要,取决于 I/O 工作负荷。 对于此示例,有趣的 I/O 请求是数据库查询。

下图显示了使用 New Relic APM 生成的结果。 在最大工作负荷期间,每个请求的平均数据库响应时间峰值约为 5.6 秒。 系统在整个测试中平均每分钟支持 410 个请求。

AdventureWorks2012 数据库的访问量概述

收集详细的数据访问信息

深入了解监视数据,显示应用程序执行三个不同的 SQL SELECT 语句。 这些请求对应于 Entity Framework 生成的用于从ProductListPriceHistoryProductProductSubcategory表中提取数据的请求。 此外,从 ProductListPriceHistory 表中检索数据的查询是执行频率高出一个数量级的 SELECT 语句。

受测试的示例应用程序执行的查询

事实证明,前面所示的 GetProductsInSubCategoryAsync 方法执行了 45 个 SELECT 查询。 每个查询都会导致应用程序打开新的 SQL 连接。

正在测试的示例应用程序的查询统计信息

注释

此图显示了负载测试中 GetProductsInSubCategoryAsync 操作的最缓慢实例的跟踪信息。 在生产环境中,有用的做法是检查最缓慢的实例,以确定是否有某个方案提示了问题。 如果只是查看平均值,你可能会忽略负载下会急剧恶化的问题。

下一张图像显示发出的实际 SQL 语句。 在产品类别中的每个产品上运行查询以提取价格信息。 使用 JOIN 操作会大大减少数据库调用次数。

正在测试的示例应用程序的查询详细信息

如果使用 O/RM(如实体框架),跟踪 SQL 查询可以深入了解 O/RM 如何将编程调用转换为 SQL 语句,并指示数据访问可能优化的区域。

实施解决方案并验证结果

重写对 Entity Framework 的调用会产生以下结果。

琐碎 I/O 示例应用程序中块式 API 的关键指标负载测试结果

此负载测试是使用相同的负载配置文件在同一部署上执行的。 这一次,图形显示延迟要低得多。 1000 个用户的平均请求时间介于 5 到 6 秒之间,比近一分钟减少。

这一次,系统平均每分钟支持 3,970 个请求,而之前的测试则支持 410 个请求。

区块 API 的事务概述

跟踪 SQL 语句显示,所有数据都在单个 SELECT 语句中获取。 尽管此查询要复杂得多,但每次作只执行一次。 尽管复杂的联接可能变得昂贵,但关系数据库系统针对这种类型的查询进行优化。

块式 API 的查询详细信息