Anti-padrão Chatty I/O

O efeito cumulativo de um elevado número de pedidos de E/S pode ter um impacto significativo no desempenho e na capacidade de resposta.

Descrição do problema

As chamadas de rede e outras operações de E/S são inerentemente lentas em comparação com as tarefas de computação. Normalmente, cada pedido de E/S tem custos gerais significativos e o efeito cumulativo de muitas operações de E/S pode abrandar o sistema. Seguem-se algumas causas comuns de E/S chatty.

Leitura e escrita de registos individuais numa base de dados como pedidos distintos

O exemplo seguinte lê a partir de uma base de dados de produtos. Existem três tabelas, Product, ProductSubcategory e ProductPriceListHistory. O código obtém todos os produtos numa subcategoria, juntamente com as informações de preços, ao executar uma série de consultas:

  1. Consulte a subcategoria a partir da tabela ProductSubcategory.
  2. Localize todos os produtos nessa subcategoria ao consultar a tabela Product.
  3. Para cada produto, consulte os dados de preços a partir da tabela ProductPriceListHistory.

A aplicação utiliza o Entity Framework para consultar a base de dados. Pode encontrar o exemplo completo aqui.

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);
    }
}

Este exemplo mostra o problema explicitamente, mas, por vezes, um O/RM pode mascarar o problema, se obtiver implicitamente os registos subordinados um de cada vez. Isto é conhecido como o "problema N+1".

Implementar uma única operação lógica como uma série de pedidos HTTP

Isto acontece frequentemente quando os programadores tentam seguir um paradigma orientado para objetos e tratam os objetos remotos como se fossem objetos locais na memória. Isto pode resultar em demasiados percursos de ida e volta na rede. Por exemplo, a API da Web seguinte expõe as propriedades individuais de objetos User através de métodos GET HTTP individuais.

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)
    {
        ...
    }
}

Enquanto não existe nada tecnicamente errado com esta abordagem, a maioria dos clientes precisa provavelmente de obter várias propriedades para cada User, o que resulta num código de cliente semelhante ao seguinte.

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();

Ler e escrever num ficheiro no disco

A E/S de ficheiros envolve abrir um ficheiro e mover para o ponto adequado antes de ler ou escrever dados. Quando a operação estiver concluída, o ficheiro pode estar fechado para poupar recursos do sistema operativo. Uma aplicação que lê e escreve continuamente pequenas quantidades de informações num ficheiro irá gerar custos gerais de E/S significativos. Pequenos pedidos de escrita também podem originar uma fragmentação de ficheiros, o que abranda ainda mais as operações de E/S subsequentes.

O exemplo seguinte utiliza FileStream para escrever um objeto Customer num ficheiro. A criação de FileStream abre o ficheiro e a eliminação do mesmo fecha o ficheiro. (A using instrução elimina automaticamente o FileStream objeto.) Se o aplicativo chamar esse método repetidamente à medida que novos clientes forem adicionados, a sobrecarga de E/S poderá se acumular rapidamente.

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);
    }
}

Como resolver o problema

Reduza o número de pedidos de E/S ao empacotar os dados num menor número de pedidos maiores.

Obtenha os dados a partir de uma base de dados como uma única consulta, em vez de várias consultas mais pequenas. Segue-se uma versão revista do código que obtém as informações do produto.

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);
    }
}

Siga os princípios de conceção REST para APIs da Web. Segue-se uma versão revista da API da Web do exemplo anterior. Em vez de métodos GET separados para cada propriedade, existe um único método GET que devolve User. Isto resulta num corpo de resposta maior por pedido, mas é provável que cada cliente efetue menos chamadas de 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();

Para a E/S de ficheiros, considere colocar os dados na memória intermédia e, em seguida, escrevê-los num ficheiro como uma única operação. Esta abordagem reduz os custos gerais de abrir e fechar repetidamente o ficheiro e ajuda a reduzir a fragmentação do ficheiro no disco.

// 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);

Considerações

  • Os primeiros dois exemplos efetuam menos chamadas de E/S, mas cada uma delas obtém mais informações. Tem de considerar o compromisso entre estes dois fatores. A resposta adequada irá depender dos padrões de utilização reais. Por exemplo, no exemplo da API da Web, é possível os clientes precisem frequentemente apenas do nome de utilizador. Nesse caso, poderá ser aconselhável expô-la como uma chamada de API separada. Para obter mais informações, veja o anti-padrão Obtenção Externa.

  • Ao ler dados, não crie pedidos de E/S demasiado grandes. Uma aplicação deve obter apenas as informações que é provável que utilize.

  • Por vezes, ajuda criar partições das informações para um objeto em dois segmentos, os dados acedidos com frequência (que ocorrem na maioria dos pedidos) e os dados acedidos com menos frequência (raramente utilizados). Muitas vezes, os dados acedidos com mais frequência são uma parte relativamente pequena do total de dados para um objeto, pelo que devolver apenas essa parte pode evitar custos gerais de E/S significativos.

  • Ao escrever dados, evite bloquear os recursos mais tempo do que o necessário, para reduzir as possibilidades de contenção durante uma operação demorada. Se uma operação de escrita abranger vários arquivos de dados, ficheiros ou serviços, adote uma abordagem eventualmente consistente. Veja Orientações de Consistência de Dados.

  • Se colocar os dados na memória intermédia antes de os escrever, os dados ficam vulneráveis se o processo falhar. Se a taxa de dados tiver normalmente picos ou for relativamente dispersa, poderá ser mais seguro colocar os dados na memória intermédia numa fila durável externa, como Hubs de Eventos.

  • Considere colocar em cache os dados obtidos de um serviço ou base de dados. Isto pode ajudar a reduzir o volume de E/S ao evitar pedidos repetidos para os mesmos dados. Para obter mais informações, veja Melhores práticas para colocação em cache.

Como detetar o problema

Os sintomas de E/S chatty incluem elevada latência e baixo débito. É provável que os utilizadores finais reportem tempos de resposta prolongados ou falhas causadas por tempo limite excedido, devido ao aumento da contenção de recursos de E/S.

Pode efetuar os passos seguintes para ajudar a identificar as causas de qualquer problema:

  1. Efetue a monitorização de processos do sistema de produção para identificar as operações com fracos tempos de resposta.
  2. Efetue testes de carga de cada operação identificada no passo anterior.
  3. Durante os testes de carga, recolha dados de telemetria sobre os pedidos de acesso a dados efetuados por cada operação.
  4. Recolha estatísticas detalhadas de cada pedido enviado para um arquivo de dados.
  5. Crie o perfil da aplicação no ambiente de teste para estabelecer onde podem ocorrer possíveis estrangulamentos de E/S.

Procure por qualquer um destes sintomas:

  • Um elevado número de pequenos pedidos de E/S efetuados para o mesmo ficheiro.
  • Um elevado número de pequenos pedidos de rede efetuados por uma instância de aplicação para o mesmo serviço.
  • Um elevado número de pequenos pedidos efetuados por uma instância de aplicação para o mesmo arquivo de dados.
  • As aplicações e os serviços tornam-se vinculados por E/S.

Diagnóstico de exemplo

As secções seguintes aplicam estes passos ao exemplo apresentado anteriormente que consulta uma base de dados.

Testar a carga da aplicação

Este gráfico mostra os resultados do teste de carga. O tempo de resposta mediano é medido em dezenas de segundos por pedido. O gráfico mostra a latência muito elevada. Com uma carga de 1000 utilizadores, um utilizador pode ter de aguardar durante quase um minuto para ver os resultados de uma consulta.

Key indicators load-test results for the chatty I/O sample application

Nota

A aplicação foi implementada como uma aplicação Web do Serviço de Aplicações do Azure através de uma Base de Dados SQL do Azure. O teste de carga utilizou uma carga de trabalho com passos simulados superior a 1000 utilizadores em simultâneo. A base de dados foi configurada com um conjunto de ligações que suporta até 1000 ligações simultâneas para reduzir a probabilidade de a contenção de ligações afetar os resultados.

Monitorizar a aplicação

Pode utilizar um pacote de monitorização de desempenho de aplicações (APM) para capturar e analisar as métricas-chave que podem identificar E/S chatty. As métricas que são importantes irão depender da carga de trabalho de E/S. Neste exemplo, os pedidos de E/S interessantes foram as consultas da base de dados.

A imagem seguinte mostra os resultados gerados através do APM New Relic. O tempo de resposta médio da base de dados teve um pico aproximadamente nos 5,6 segundos por pedido durante a carga de trabalho máxima. O sistema conseguiu suportar uma média de 410 pedidos por minuto durante o teste.

Overview of traffic hitting the AdventureWorks2012 database

Recolher informações de acesso a dados detalhadas

A análise mais aprofundada dos dados de monitorização mostra que a aplicação executa três instruções SELECT SQL diferentes. Estas correspondem aos pedidos gerados pelo Entity Framework para obter dados a partir das tabelas ProductListPriceHistory, Product e ProductSubcategory. Além disso, a consulta que obtém os dados da tabela ProductListPriceHistory é de longe a instrução SELECT executada com mais frequência por uma ordem de magnitude.

Queries performed by the sample application under test

Parece que o método GetProductsInSubCategoryAsync, apresentado anteriormente, efetua 45 consultas SELECT. Cada consulta faz com que a aplicação abra uma nova ligação SQL.

Query statistics for the sample application under test

Nota

Esta imagem mostra as informações de rastreio para a instância mais lenta da operação GetProductsInSubCategoryAsync no teste de carga. Num ambiente de produção, é útil examinar os rastreios das instâncias mais lentas, para ver se existe um padrão que sugira um problema. Se observar os valores médios, pode detetar problemas que irão ser significativamente piores em carga.

A imagem seguinte mostra as instruções SQL reais que foram emitidas. A consulta que obtém as informações de preços é executada para cada produto individual na subcategoria de produtos. A utilização de uma associação reduziria significativamente o número de chamadas da base de dados.

Query details for the sample application under test

Se estiver a utilizar um O/RM, como o Entity Framework, rastrear as consultas SQL pode fornecer informações sobre como o O/RM traduz as chamadas programáticas para instruções SQL e indica as áreas onde o acesso a dados pode ser otimizado.

Implementar a solução e verificar o resultado

A reescrita da chamada ao Entity Framework produziu os seguintes resultados.

Key indicators load test results for the chunky API in the chatty I/O sample application

Este teste de carga foi efetuado na mesma implementação com o mesmo perfil de carga. Desta vez, o gráfico mostra uma latência muito menor. O tempo médio de pedidos em 1000 utilizadores situa-se entre 5 e 6 segundos, menos quase um minuto.

Desta vez, o sistema suportou uma média de 3970 pedidos por minuto, em comparação com os 410 do teste anterior.

Transaction overview for the chunky API

O rastreio da instrução SQL mostra que todos os dados são obtidos numa única instrução SELECT. Embora esta consulta seja consideravelmente mais complexa, é executada apenas uma vez por operação. Apesar de as associações complexas se poderem tornar dispendiosas, os sistemas de base de dados relacionais estão otimizados para este tipo de consulta.

Query details for the chunky API