Pontos de dados

Usando o Entity Framework para reduzir a latência de rede para o SQL Azure

Julie Lerman

Julie LermanÀ primeira vista, alternar de um banco de dados do SQL Server gerenciado localmente para o banco de dados do SQL Azure baseado na nuvem da Microsoft parece ser difícil. Mas, na realidade, é num piscar de olhos: basta trocar a cadeia de conexão e pronto! Como nós, desenvolvedores, adoramos ver nessas situações, isso “simplesmente funciona”.

No entanto, fazer a troca introduz a latência de rede, que pode afetar substancialmente o desempenho geral do aplicativo. Felizmente, um bom entendimento dos efeitos da latência de rede deixa você com o poder de usar o Entity Framework para reduzir esse impacto no contexto do SQL Azure.

Criação de perfil do código de acesso a dados

Eu utilizo as ferramentas de criação de perfil do Visual Studio 2010 (msdn.microsoft.com/library/ms182372) para comparar diferentes atividades de acesso a dados no banco de dados AdventureWorksDW que reside tanto na minha rede local quanto na minha conta do SQL Azure. Utilizo o criador de perfil para investigar chamadas para carregar alguns clientes do banco de dados usando o Entity Framework. Um teste consulta somente os clientes e recupera suas informações de vendas após o fato usando o recurso de carregamento lento (lazy loading) do Entity Framework 4.0. Um teste posterior carrega imediatamente (eager loading) as vendas do cliente juntamente com os clientes utilizando o método Include. A Figura 1 mostra o aplicativo de console que eu utilizei para executar essas consultas e enumerar os resultados.

Figura 1 Consultas desenvolvidas para explorar o desempenho

{

  using (var context = new AdventureWorksDWEntities())

  {

    var warmupquery = context.Accounts.FirstOrDefault();

  }

  DoTheRealQuery();

}



private static void DoTheRealQuery()

{

  using ( var context=new  AdventureWorksDWEntities())

  {

    var query =   context.Customers.Where(c => c.InternetSales.Any()).Take(100);

    var customers = query.ToList();

    EnumerateCustomers(customers);

  }

}



private static void EnumerateCustomers(List<Customer> customers)

{

  foreach (var c in customers)

  {

    WriteCustomers (c);

    

  }

}



private static void WriteCustomer(Customer c)

{

  Console.WriteLine

 ("CustomerName: {0} First Purchase: {1}  # Orders: {2}",

    c.FirstName.Trim() + "" + c.LastName,  c.DateFirstPurchase, c.InternetSales.Count);

}

Eu comecei com uma consulta inicial para pular o custo de carregar os metadados EDM (Modelo de dados de entidade) na memória, pré-compilando exibições e outras operações únicas. O método DoTheRealQuery então consulta um subconjunto de entidades Customers, executa a consulta em uma lista e enumera os resultados. Durante a enumeração, as vendas do cliente são acessadas, forçando — neste caso — um carregamento lento que volta ao banco de dados para obter as vendas de cada cliente em toda a iteração.

Observando o desempenho em uma rede local

Quando executada na minha rede local em um banco de dados local do SQL Server, a primeira chamada para executar a consulta demora 233 ms. Isso porque estou apenas recuperando os clientes. Quando o código executa a enumeração que força o carregamento lento, ela demora 195 ms.

Agora vou alterar a consulta de forma que ela carregue imediatamente as InternetSales juntamente com Customers:

context.Customers.Include("InternetSales").Where(c => c.InternetSales.Any()).Take(100);

Agora, esses 100 clientes, juntamente com todos os seus registros de vendas, serão retornados do banco de dados. É uma quantidade bem maior de dados.

A chamada query.ToList agora demora cerca de 350 ms, quase 33% mais do que retornar apenas os 100 clientes.

Há outro efeito dessa alteração: quando o código enumera os clientes, os dados de vendas já estão lá na memória. Isso significa que o Entity Framework não precisa fazer 100 viagens extras de ida e volta ao banco de dados. A enumeração, juntamente com a gravação dos detalhes, demora apenas cerca de 70% do tempo de quando o carregamento lento estava fazendo seu trabalho.

Levando em consideração a quantidade de dados, o computador e a velocidade da rede local, em geral, a rota do carregamento lento neste cenário em particular é um pouco mais rápida do que quando você carrega os dados imediatamente. No entanto, ainda é tão rápida que a diferença nem mesmo é perceptível entre os dois casos. Ambos parecem ser executados com tanta rapidez graças ao Entity Framework.

A Figura 2 mostra uma comparação entre o carregamento imediato e o carregamento lento no ambiente de rede local. A coluna ToList mede a execução da consulta, que é a linha de código: var customers = query.ToList();. A Enumeração mede o método EnumerateCustomers. E, finalmente, a coluna Consulta e enumeração mede o método DoTheRealQuery completo, que combina a execução, a enumeração, a instanciação do contexto e a declaração da própria consulta.

image: Comparing Eager Loading to Lazy Loading from a Local Database

Figura 2 Comparação de carregamento imediato e carregamento lento de um banco de dados local

Alternando para o banco de dados do SQL Azure

Agora vou mudar a cadeia de conexão para o meu banco de dados do SQL Azure na nuvem. Não é de surpreender que a latência de rede entre o meu computador e o banco de dados na nuvem torne as consultas mais demoradas do que no banco de dados local. Não é possível evitar a latência nesse cenário. No entanto, o notável é que o aumento não é igual entre as várias tarefas. Para alguns tipos de solicitações, a latência é muito mais exagerada do que para outras. Examine a Figura 3.

Figura 3 Comparando o carregamento imediato com o carregamento lento do SQL Azure

O carregamento imediato dos gráficos ainda é mais lento do que carregar somente os clientes diretamente. Mas onde era cerca de 30% mais lento localmente, no SQL Azure ele agora é cerca de três vezes tão longo quanto o carregamento lento.

Porém, como você pode ver na Figura 3, é o carregamento lento que é mais afetado pela latência de rede. Quando os dados de InternetSales estavam na memória graças ao carregamento imediato, a enumeração dos dados era tão rápida quanto no primeiro conjunto de testes. Porém, o carregamento lento está gerando 100 viagens adicionais de ida e volta ao banco de dados na nuvem. Devido à latência, cada uma dessas viagens demora mais e, em conjunto, o tempo resultante para recuperar os resultados é visivelmente perceptível. 

A enumeração demora mais tempo do que a enumeração na memória por ordens de magnitude. Cada viagem ao banco de dados para obter os dados de InternetSales de um único cliente demora significativamente. Em geral — embora certamente seja muito mais rápido carregar somente os Customers diretamente — neste ambiente a demora foi quase seis vezes maior para recuperar todos os dados com o carregamento lento.

O objetivo aqui não é incriminar o SQL Azure, cujo desempenho é realmente alto, mas chamar a atenção para o fato de que a escolha do mecanismo de consulta do Entity Framework pode ter um impacto negativo no desempenho geral devido aos problemas de latência.

O caso de uso específico desta demonstração não é comum para um aplicativo porque, em geral, você não carregaria lentamente os dados relacionados de uma grande série de objetos. Mas ele destaca que, nesse caso, retornar uma grande quantidade de dados de uma vez (por meio de carregamento imediato) é muito mais eficiente do que retornar essa mesma quantidade de dados por meio de muitas viagens ao banco de dados. Quando você está usando um banco de dados local, a diferença pode não ser tão significativa como quando os dados estão na nuvem, portanto, atenção quando você alternar do banco de dados local para o SQL Azure.

Dependendo da forma dos dados que você está retornando — talvez uma consulta muito mais complexa com numerosos Includes, por exemplo — há vezes em que o carregamento imediato é mais caro e há vezes em que o carregamento lento é mais caro. Existem até ocasiões em que é conveniente distribuir a carga: carregar imediatamente alguns dados e carregar lentamente outros, com base nas informações que você obtiver com a criação do perfil de desempenho. E, em muitos casos, a melhor solução é mover também o seu aplicativo para a nuvem. O Windows Azure e o SQL Azure foram desenvolvidos para funcionar como uma equipe. Movendo o seu aplicativo para o Windows Azure e fazendo com que esse aplicativo obtenha seus dados do SQL Azure, você maximizará o desempenho.

Usar projeções para ajustar os resultados da pesquisa

Ao usar aplicativos executados localmente, em alguns cenários, você pode revisar as consultas para refinar ainda mais a quantidade de dados retornada do banco de dados. Uma técnica a ser considerada é o uso de projeções, que permite a você muito mais controle sobre quais dados relacionados são retornados. Nem o carregamento imediato, nem o carregamento deferido/lento no Entity Framework permite que você filtre ou classifique os dados relacionados que estão sendo retornados. Porém, em uma projeção, você pode.

Por exemplo, a consulta nesta versão modificada do método TheRealQuery retorna somente um subconjunto de entidades InternetSales — aquelas maiores ou iguais a 1.000:

private static void TheRealQuery()

    {

      using ( var context=new  AdventureWorksDWEntities())

      {

        Decimal salesMinimum = 1000;

        var query =

            from customer in context.Customers.Where(c => 

            c.InternetSales.Any()).Take(100)

            select new { customer, Sales = customer.InternetSales };

        IEnumerable customers = query.ToList();

        context.ContextOptions.LazyLoadingEnabled = false;

        EnumerateCustomers(customers);

      }

    }

A consulta retorna os mesmos 100 clientes que a consulta anterior, juntamente com um total de 155 registros de InternetSales em comparação com os 661 registros de vendas retornados sem o filtro SalesAmount.

Preste atenção nesta observação importante sobre projeções e o carregamento lento: quando os dados são projetados, o contexto não reconhece os dados relacionados como tendo sido carregados. Isso só acontece quando eles são carregados por meio do método Include, do método Load ou por carregamento lento. Assim, é importante desabilitar o carregamento lento antes da enumeração, como eu fiz no método TheRealQuery. Caso contrário, o contexto carregará lentamente os dados de InternetSales, embora eles já estejam na memória, fazendo com que a enumeração demore muito mais do que o necessário.

O método de enumeração modificado leva isso em consideração:

private static void EnumerateCustomers(IEnumerable customers)

{

  foreach (var item in customers)

  {

    dynamic dynamicItem=item;

    WriteCustomer((Customer)dynamicItem.customer);

  }

}

O método também aproveita o tipo dynamic no C# 4 para executar associação tardia.

A Figura 4 demonstra o ganho significativo no desempenho, obtido pela projeção mais ajustada.

image: Comparing Eager Loading to a Filtered Projection from SQL Azure

Figura 4 Comparando o carregamento imediato com uma projeção filtrada do SQL Azure

Pode parecer óbvio que a consulta filtrada seja mais rápida do que a consulta carregada imediatamente que retorna mais dados. A comparação mais interessante é o fato de que o banco de dados do SQL Azure processa a projeção filtrada cerca de 70% mais rapidamente, enquanto em um banco de dados local a projeção filtrada é apenas cerca de 15% mais rápida do que a consulta carregada imediatamente. Eu suspeito que a coleção InternetSales carregada imediatamente faça com que a enumeração na memória seja mais rápida, devido à maneira como o Entity Framework a acessa internamente, em comparação com a coleção projetada. Porém, como a enumeração nesse caso está ocorrendo completamente na memória, ela não tem nenhuma relevância na latência de rede. Em geral, a melhoria constatada com a projeção supera o pequeno preço da enumeração quando a projeção é usada.

Na sua rede, talvez não seja necessário alternar para uma projeção para ajustar os resultados da consulta, mas no SQL Azure, esse tipo de ajuste pode trazer ganhos significativos no desempenho do seu aplicativo.

Todos a bordo da nuvem

Os cenários que abordei se baseiam em aplicativos ou serviços hospedados localmente que utilizam o SQL Azure. Você também pode ter o seu aplicativo ou serviço hospedado na nuvem no Windows Azure. Por exemplo, os usuários finais podem estar usando o Silverlight para acessar uma função da Web do Windows Azure que executa o Windows Communication Foundation que, por sua vez, acessa dados no SQL Azure. Nesse caso, não haverá nenhuma latência de rede entre o serviço baseado na nuvem e o SQL Azure.

Seja qual for a combinação, o ponto mais importante a ser lembrado é que, mesmo que o seu aplicativo continue funcionando como esperado, o desempenho poderá ser afetado pela latência de rede.  

Julie Lerman é uma Microsoft MVP, mentora e consultora do .NET, que reside nas colinas de Vermont. Você pode encontrar sua apresentação sobre acesso a dados e outros tópicos do Microsoft .NET em grupos de usuários e conferências ao redor do mundo. Seu blog está em thedatafarm.com/blog e ela é autora do livro altamente reconhecido, “Programming Entity Framework” (O’Reilly Media, 2010). Siga-a no Twitter.com: julielerman.

Agradecemos aos seguintes especialistas técnicos pela revisão deste artigo: Wayne Berry, Kraig Brockschmidt, Tim Laverty e Steve Yi