.NET  - Otimizando consultas com execução em paralelo


  Hoje veremos como otimizar consultas na plataforma .NET com a execução em paralelo.

Às vezes, temos que realizar várias consultas ao banco de dados, colocar os resultados na memória e processá-los conforme necessário.



Vamos supor que temos uma tabela Fornecedores e uma tabela Clientes onde ambas as tabelas têm uma quantidade substancial de dados e queremos combinar os dados existentes das duas tabelas com critérios específicos.

Geralmente, realizaremos duas consultas sequencialmente da seguinte forma : consultar primeiro a tabela Fornecedores e, em seguida, consultar a tabela Clientes. A segunda consulta será executada após a conclusão da primeira consulta.

Se cada consulta levar 5 segundos para ser processada e o processo de mesclagem da consulta na memória levar 2 segundos, o tempo total necessário para concluir todo o processo será de cerca de 12 segundos (5+5+2).

Agora surge a pergunta: “Podemos realizar a primeira e a segunda consulta simultaneamente (paralela) e a seguir combinar os resultados depois que as duas consultas forem concluídas ?

Sim, podemos !!!

Com isso, o tempo total necessário para concluir todo o processo será reduzido significativamente executando a primeira e a segunda consultas simultaneamente.

Como fazer isso ???

Temos duas opções principais que podemos usar :

  1. O método AsParallel()  da Linq;
  2. O método WhenAll() da classe Task;

O método AsParallel() esta disponível no namespace System.Linq e permite que a consulta LINQ seja executada em paralelo, aproveitando os recursos de hardware disponíveis, como processadores com vários núcleos. Quando você chama o método AsParallel() em uma consulta LINQ, o .NET irá executar a consulta em várias threads para acelerar o processamento. Por exemplo:

using System.Linq;

var numeros = Enumerable.Range(1, 1000000);

var resultado = numeros.AsParallel()
                      .Where(x => x % 2 == 0)
                      .ToArray();

Nesse exemplo, a consulta LINQ é executada em paralelo usando o método AsParallel(), e, isso permite que o .NET processe a consulta mais rapidamente, pois aproveita vários núcleos do processador. Observe que o resultado da consulta é convertido em uma matriz usando o método ToArray().

Já o método WhenAll() é um método disponível na classe Task do namespace System.Threading.Tasks. Ele permite que você execute várias tarefas de forma assíncrona e aguarde até que todas as tarefas sejam concluídas. Por exemplo:

using System.Threading.Tasks;

var t1 = Task.Run(() => MetodoAssincrono1());
var t2 = Task.Run(() => MetodoAssincrono2());

await Task.WhenAll(t1, t2);

Nesse exemplo, duas tarefas assíncronas são criadas usando o método Task.Run(), e o método WhenAll() é chamado com as duas tarefas. Isso permite que ambas as tarefas sejam executadas em paralelo e aguarda até que ambas sejam concluídas.

Executando consultas em paralelo

Vamos então mostrar na prática a vantagem em executar consultas em paralelo usando as duas abordagens acima.

Vamos criar um projeto Console no VS 2022 chamado OtimizaConsultas usando o .NET e o recurso do Top Level Statement.

Inclua no projeto o pacote nuget : Microsoft.EntityFrameworkCore.SqlServer  (estou usando a versão 7.05)

Neste projeto vamos criar a pasta Entities e criar nesta pasta as classes Fornecedor e Cliente:

1- Cliente

public class Cliente
{
  
public int Id { get; set; }
  
public string? Nome { get; set; }
}

2- Fornecedor

public class Fornecedor
{
 
public int Id { get; set; }
 
public string? Nome { get; set; }
}

Crie uma pasta Context no projeto e crie a classe de contexto AppDbContext que herda de DbContext:

public class AppDbContext : DbContext
{
  
public AppDbContext(DbContextOptions options) : base(options)
   {}

   public DbSet<Fornecedor> Fornecedores { get; set; }
  
public DbSet<Cliente> Clientes { get; set; }
}

Ainda nesta pasta vamos criar a classe PopulaDados() que iremos usar para criar o banco de dados e as tabelas Fornecedores e Clientes incluindo 1000000 registros para podermos fazer o teste.

public class PopulaDados
{
    private readonly AppDbContext _db;
    public PopulaDados(AppDbContext db)
    {
        _db = db;
    }
    public async Task Run()
    {
        await _db.Database.EnsureDeletedAsync();
        await _db.Database.EnsureCreatedAsync();
        var total = 1000000;
        for (int i = 0; i < total; i++)
        {
            _db.Fornecedores.Add(new Fornecedor
            {
                Nome = $"F{(i + total)}"
            });
            _db.Clientes.Add(new Cliente
            {
                Nome = $"C{(i + total)}"
            });
        }
        await _db.SaveChangesAsync();
    }
}

Ao final da execução deste código teremos a tabela Clientes e Fornecedores com 1000000 de registros onde o campo Nome em Clientes inicia com 'C1000000' e em Fornecedores inicia com 'F1000000'

O código _db.Database.EnsureDeletedAsync() é usado para excluir o banco de dados associado ao contexto _db.

Esse método é usado para garantir que um banco de dados seja excluído antes que o contexto o crie novamente. Ele é útil em cenários de teste, onde você deseja excluir o banco de dados antigo antes de executar novamente o teste para garantir que o teste esteja limpo e comece do zero.

O método EnsureDeletedAsync() exclui o banco de dados de forma assíncrona, e isso é útil em cenários em que você precisa excluir o banco de dados em uma thread separada para não bloquear a thread principal.

Observe que esse método não apenas remove as tabelas do banco de dados, mas também exclui todo o banco de dados. Se você quiser apenas excluir algumas tabelas, pode usar o método DropTable(). Além disso, lembre-se de que este método exclui permanentemente o banco de dados, portanto, use-o com cuidado em cenários de produção.

O código _db.Database.EnsureCreatedAsync() é usado para criar o banco de dados associado ao contexto _db.

Esse método é usado para criar o banco de dados caso ele não exista. Quando você chama esse método, o Entity Framework Core verifica se o banco de dados já existe e, se não existir, ele cria o banco de dados com base no modelo definido pelo seu contexto.

O método EnsureCreatedAsync() cria o banco de dados de forma assíncrona, no entanto, é importante notar que o ele não atualiza o esquema do banco de dados se você fizer alterações no modelo do seu contexto. Se você fizer alterações no modelo, precisará excluir e recriar o banco de dados ou usar a migração de banco de dados para atualizar o esquema do banco de dados.

Este método é projetado para ser usado em cenários de desenvolvimento e teste, pois cria o banco de dados automaticamente. Em um ambiente de produção, geralmente é necessário usar a migração de banco de dados para criar ou atualizar o esquema do banco de dados.

Vamos agora definir na classe Program o código onde vamos executar a consulta sequêncial e a seguir vamos executar as consultas em paralelo comparando o tempo gasto usando a classe StopWatch.

using EFConsulta.Context;
using
Microsoft.EntityFrameworkCore;
using
System.Diagnostics;

var stopwatch = new Stopwatch();

var connectionString = "Data Source=.;Initial Catalog = DemoDB; Integrated Security = True";
var
optionsBuilder = new DbContextOptionsBuilder().UseSqlServer(connectionString);

// inicialização
a
wait InicializacaoDados(stopwatch, optionsBuilder);

stopwatch.Restart();
var
ctxSequencial = new AppDbContext(optionsBuilder.Options);
await
ConsultaSequencial(stopwatch, ctxSequencial);

// paralela - WhenAll()
stopwatch.Restart();

// cria duas instancias do contexto

var
ctxParalelo1 = new AppDbContext(optionsBuilder.Options);
var
ctxParalelo2 = new AppDbContext(optionsBuilder.Options);
await
ConsultaParalela(stopwatch, ctxParalelo1, ctxParalelo2);

// paralela - AsParallel()
stopwatch.Restart();
ConsultaParalela2(stopwatch, ctxParalelo1, ctxParalelo2);
Console.ReadKey();

Primeiro temos o código para inicializar os dados criando o banco e as tabelas e populando os dados:

static async Task InicializacaoDados(Stopwatch stopwatch, DbContextOptionsBuilder optionsBuilder)
{
   Console.WriteLine(
"Criando o banco e populando as tabelas...");
   Console.WriteLine(
"Aguarde...");

  
// inicializa os dados
   stopwatch.Start();
  
var dbInicializa = new AppDbContext(optionsBuilder.Options);
  
var populaDados = new PopulaDados(dbInicializa);
  
await populaDados.Run();
   stopwatch.Stop();
  Console.WriteLine(
$"Inicialização dos dados: {stopwatch.Elapsed}");
}

A seguir vejamos o código da consulta sequencial:

static async Task ConsultaSequencial(Stopwatch stopwatch, AppDbContext ctxSequencial)
{
   Console.WriteLine(
"\nExecutando consulta sequencial...\n");
  
var fornecedoresSequencial = await ctxSequencial.Clientes
                                .Where(x => x.Nome.Contains(
"7"))
                                .OrderByDescending(x => x.Nome)
                                .ToListAsync();

    var clientesSequencial = await ctxSequencial.Clientes
                                   .Where(x => x.Nome.Contains(
"7"))
                                   .OrderByDescending(x => x.Nome)
                                   .ToListAsync();

    var totalSequencial = fornecedoresSequencial.Concat(clientesSequencial).ToList();
    stopwatch.Stop();
    Console.WriteLine(
$"\nConsulta Sequêncial (Total: {totalSequencial.Count}): {stopwatch.Elapsed}");
}

Agora o código da consulta em paralelo usando o método WhenAll() da classe Task:

static async Task ConsultaParalela(Stopwatch stopwatch, AppDbContext ctxParalelo1,
                                  AppDbContext ctxParalelo2)
{
   Console.WriteLine(
"\nExecutando consulta em paralelo usando WhenAll...\n");
  
// task para a primeira consulta
  
var taskFornecedorParalelo = ctxParalelo1.Fornecedores
                                .Where(x => x.Nome.Contains(
"7"))
                                .OrderByDescending(x => x.Nome)
                                .ToListAsync();

   // task para segunda consulta
  
var taskClienteParalelo = ctxParalelo2.Clientes
                             .Where(x => x.Nome.Contains(
"7"))
                             .OrderByDescending(x => x.Nome)
                             .ToListAsync();

   // roda ambas as task em paralelo
  
await Task.WhenAll(taskFornecedorParalelo, taskClienteParalelo);
  
// Obtem os resultados das consultas
  
var resultadoConsultaFornecedores = taskFornecedorParalelo.Result;
  
var resultadoConsultaClientes = taskClienteParalelo.Result;
  
var consultaParaleloTotal = resultadoConsultaFornecedores.Count + resultadoConsultaClientes.Count;

   stopwatch.Stop();
   Console.WriteLine(
$"\nConsulta em Paralelo (Total: {consultaParaleloTotal}): {stopwatch.Elapsed}");
}

Para concluir temos o código da consulta em paralelo usando o método  AsParallel() da LINQ:

static void ConsultaParalela2(Stopwatch stopwatch, AppDbContext ctxParalelo1,
                              AppDbContext ctxParalelo2)
{
   Console.WriteLine(
"\nExecutando consulta em paralelo usando AsParallel...\n");
  
var fornecedores = ctxParalelo1.Fornecedores
                      .AsParallel()
                      .Where(f => f.Nome.Contains(
"7"))
                      .Select(f =>
new { Id = f.Id, Nome = f.Nome })
                      .ToList();

    var clientes = ctxParalelo2.Clientes
                   .AsParallel()
                   .Where(c => c.Nome.Contains(
"7"))
                   .Select(c =>
new { Id = c.Id, Nome = c.Nome })
                   .ToList();

     var resultado = fornecedores.Concat(clientes).ToList();
     stopwatch.Stop();
     Console.WriteLine(
$"\nConsulta Paralela (Total: {resultado.Count}): {stopwatch.Elapsed}");
}

Executando o projeto teremos o seguinte resultado no console:

A consulta sequêncial leva algo em torno de 7 segundos enquanto a consulta em paralelo com WhenAll() gasta 4 segundos , quase a metade tempo.  Na segunda consulta em paralelo temos um tempo parecido com a primeira consulta em paralelo.

Com isso temos que a execução das consultas em paralelo aumenta o desempenho e velocidade da execução.

No entanto, devemos ter cuidado, pois temos que usar um contexto de dados diferente para cada consulta que será executada em paralelo e usar vários contextos de dados requerem mais alocação de memória e mais pool de conexões.

Pegue o código do projeto: OtimizaConsultas.zip ...

"Uns confiam em carros e outros em cavalos, mas nós faremos menção do nome do Senhor nosso Deus."
Salmos 20:7

Referências:


José Carlos Macoratti