共用方式為


冗長的輸入/輸出反模式

大量 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 一次隱含擷取子記錄,此範例會明確顯示問題,但有時候 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 作業的速度。

下列範例會使用 FileStream 將 物件寫入 Customer 檔案。 建立 FileStream 會開啟檔案,而將 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 Database 部署為 Azure App Service 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 語句。 擷取價格資訊的查詢會針對產品子類別中的每個個別產品執行。 使用聯結可大幅減少資料庫呼叫數目。

測試範例應用程式的查詢詳細資訊

如果您使用 O/RM,例如 Entity Framework,追蹤 SQL 查詢可以提供 O/RM 如何將程式設計呼叫轉譯成 SQL 語句的深入解析,並指出數據存取可能優化的區域。

實作解決方案並驗證結果

重寫對 Entity Framework 的呼叫會產生下列結果。

聊天 I/O 範例應用程式中區塊 API 的主要指標負載測試結果

此負載測試是在相同的部署上執行,使用相同的負載配置檔。 這一次圖表會顯示低得多的延遲。 1000 位使用者的平均要求時間介於 5 到 6 秒之間,比近一分鐘減少。

這一次,系統平均每分鐘支援 3,970 個要求,而先前的測試則為 410 個。

區塊 API 的交易概觀

追蹤 SQL 語法會顯示所有資料是透過單一 SELECT 語法擷取的。 雖然此查詢相當複雜,但每個作業只會執行一次。 雖然複雜的聯結可能會變得昂貴,但關係資料庫系統會針對這種類型的查詢進行優化。

查詢 chunky API 的詳細資訊