0

Entity Framework Core 7 Preview 6

Como no podía ser menos, tenemos disponible también la preview 6 para Entity Framework Core 7. Esta versión se enfoca más que nada en temas de optimización de rendimiento. 

Se mejoró el rendimiento de SaveChanges significativamente, con un enfoque especial en la eliminación de viajes de ida y vuelta innecesarios a la base de datos. En algunos casos se pudieron ver una reducción del 74 % en el tiempo necesario.

Las mejoras en la canalización de actualizaciones en EF Core 7.0 son diferentes a versiones anteriores. Se detectaron oportunidades de mejora en el SQL cuando EF envía a la base de datos y en la cantidad de viajes de ida y vuelta que ocurren bajo el capó cuando se invoca SaveChanges. La optimización de los viajes de ida y vuelta de la red es especialmente importante para el rendimiento de las aplicaciones modernas:

  • La latencia de la red suele ser un factor importante por lo que eliminar un viaje de ida y vuelta innecesario puede tener mucho más impacto que muchas microoptimizaciones en el código mismo.
  • La latencia también varía en función de varios factores, por lo que eliminar un viaje de ida y vuelta tiene un efecto creciente cuanto mayor sea la latencia.
  • En la implementación local tradicional, el servidor de la base de datos suele estar ubicado cerca de los servidores de aplicaciones. En el entorno de la nube, el servidor de la base de datos tiende a estar más lejos, lo que aumenta la latencia.

Independientemente de la optimización del rendimiento que se describe a continuación, recomiendo tener en cuenta los viajes de ida y vuelta al interactuar con una base de datos y leer los documentos de rendimiento de EF donde tenemos ejemplos.

Transacciones y viajes de ida y vuelta

Examinemos un programa EF muy trivial que inserta una sola fila en la base de datos:

var blog = new Blog { Name = "MyBlog" };
ctx.Blogs.Add(blog);
await ctx.SaveChangesAsync();

Ejecutar esto con EF Core 6.0 muestra los siguientes mensajes de registro (filtrados para resaltar las cosas importantes):

dbug: 2022-07-10 17:10:48.450 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 17:10:48.521 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (30ms) [Parameters=[@p0='Foo' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 2022-07-10 17:10:48.549 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

El comando principal, que tomó 30 milisegundos, contiene dos declaraciones SQL (ignorando NOCOUNT que no es relevante): la declaración INSERT esperada, seguida de SELECT para obtener la ID de la nueva fila que acabamos de insertar. En EF Core, cuando la clave de su entidad es un int, EF generalmente la configura para que se genere en la base de datos de manera predeterminada; para SQL Server, esto significa una columna de IDENTIDAD. Dado que es posible que desee continuar realizando más operaciones después de insertar esa fila, EF debe recuperar el valor de ID y completarlo en la instancia de su blog.

Dos puntos: se inicia una transacción antes de que se ejecute el comando y se confirma después. Esa transacción nos cuesta dos viajes de ida y vuelta adicionales a la base de datos: uno para iniciarlo y otro para confirmar. Ahora, la transacción está ahí por una razón: es posible que SaveChanges deba aplicar múltiples operaciones de actualización, y queremos que esas actualizaciones se incluyan en la transacción, de modo que, si hay una falla, todo se revierte y la base de datos se mantiene en un estado consistente. Pero ¿qué sucede si solo hay una operación, como en el caso anterior? Las bases de datos garantizan la transaccionalidad para (la mayoría de las sentencias SQL individuales; si ocurre algún error, no necesita preocuparse por la declaración parcialmente completada.  Podemos eliminar por completo la transacción cuando se trata de una sola declaración.

info: 2022-07-10 17:24:28.740 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (52ms) [Parameters=[@p0='Foo' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

Es mucho más corto y la transacción ya no está. Veamos cuál es el valor de esta optimización comparándola con BenchmarkDotNet.

Insertar varias filas

Veamos que pasa:

for (var i = 0; i < 4; i++)
{
    var blog = new Blog { Name = "Foo" + i };
    ctx.Blogs.Add(blog);
}
await ctx.SaveChangesAsync();

Antes el resultado de la ejecución era:

dbug: 2022-07-10 18:46:39.583 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 18:46:39.677 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (52ms) [Parameters=[@p0='Foo0' (Size = 4000), @p1='Foo1' (Size = 4000), @p2='Foo2' (Size = 4000), @p3='Foo3' (Size = 4000)], CommandType='Text', CommandTimeout
='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 2022-07-10 18:46:39.705 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

SQL Server tiene una declaración MERGE, que originalmente estaba destinada a fusionar dos tablas, pero se puede usar para otros fines. Resulta que usar MERGE para insertar cuatro filas es significativamente más rápido que 4 declaraciones INSERT separadas, incluso cuando se procesan por lotes. Entonces lo anterior hace lo siguiente:

  • Cree una tabla temporal (ese es el bit DECLARE @inserted0).
  • Use MERGE para insertar en cuatro filas, según los parámetros que enviamos, en la tabla. Una cláusula OUTPUT (¿recuerdas eso?) genera el ID generado por la base de datos en la tabla temporal.
  • SELECT para recuperar los ID de la tabla temporal.

Comparemos eso con la salida de EF Core 7.0:

info: 2022-07-10 18:46:56.530 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='Foo0' (Size = 4000), @p1='Foo1' (Size = 4000), @p2='Foo2' (Size = 4000), @p3='Foo3' (Size = 4000)], CommandType='Text', CommandTimeout
='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

La transacción se ha ido, como se indicó anteriormente: MERGE también es una declaración única que está protegida por una transacción implícita. Tenga en cuenta que si usáramos 4 declaraciones INSERT en su lugar, no podríamos omitir la transacción explícita (con sus viajes de ida y vuelta adicionales); así que esa es otra ventaja de usar MERGE, además del mejor rendimiento básico que ofrece aquí.

Han cambiado otras cosas: la tabla temporal desapareció y la cláusula OUTPUT ahora envía los ID generados directamente al cliente. Comparemos cómo funcionan estas dos variaciones:

El escenario remoto se ejecuta casi 8 milisegundos más rápido, o una mejora del 61 %. El escenario local es aún más impresionante: la mejora de 1,243 milisegundos equivale a una mejora del 74%; la operación se ejecuta cuatro veces más rápido en EF Core 7.0. Tengamos en cuenta que estos resultados incluyen dos optimizaciones separadas: la eliminación de la transacción discutida anteriormente y la optimización de MERGE para no usar una tabla temporal.

SQL Server y la cláusula OUTPUT

Surge la pregunta de por qué EF Core no usó una cláusula OUTPUT simple, sin una tabla temporal, hasta ahora. Después de todo, el nuevo SQL es más simple y rápido. SQL Server tiene algunas limitaciones que no permiten la cláusula OUTPUT en ciertos escenarios. Lo que es más importante, el uso de la cláusula OUTPUT en una tabla que tiene un activador definido no es compatible y genera un error. Se admite OUTPUT con INTO. Pero, cuando estaban diseñando EF Core por primera vez, el objetivo que tenían era que las cosas funcionaran en todos los escenarios, para que la experiencia del usuario fuera lo más fluida posible; tampoco se conocia cuántos gastos generales agregaba realmente la tabla temporal. Revisando esto para EF Core 7.0, habia las siguientes opciones:

  • Conservar el comportamiento lento actual de forma predeterminada y permitirá los usuarios optar por la técnica más nueva y más eficiente.
  • Cambiar a una técnica más eficiente y proporcionar una opción para que las personas que usan disparadores cambien a un comportamiento más lento.

El equipo menciona que no es una decisión fácil de tomar y se encuentran trabajando para nunca interrumpir a los usuarios si es posible evitarlo. Sin embargo, dada la extrema diferencia de rendimiento y el hecho de que los usuarios ni siquiera se darían cuenta de la situación, se terminó eligiendo la opción 2. Los usuarios con disparadores que actualicen a EF Core 7.0 obtendrán una excepción informativa que les indicará la opción. -out, y todos los demás obtienen un rendimiento significativamente mejorado sin necesidad de saber nada.

Menos viajes de ida y vuelta: principales y dependientes

Veamos un escenario más. En este, vamos a insertar un principal (Blog) y un dependiente (Post):

ctx.Blogs.Add(new Blog
{
    Name = "MyBlog",
    Posts = new()
    {
        new Post { Title = "My first post" }
    }
});
await ctx.SaveChangesAsync();

Lo generado:

dbug: 2022-07-10 19:39:32.826 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:39:32.890 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (22ms) [Parameters=[@p0='MyBlog' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
info: 2022-07-10 19:39:32.929 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (3ms) [Parameters=[@p1='1', @p2='My first post' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Post] ([BlogId], [Title])
      VALUES (@p1, @p2);
      SELECT [Id]
      FROM [Post]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 2022-07-10 19:39:32.932 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCo

Vemos cuatro viajes de ida y vuelta: dos para la gestión de transacciones, uno para la inserción de blogs y uno para la inserción de publicaciones. EF Core generalmente realiza el procesamiento por lotes en SaveChanges, lo que significa que se envían varios cambios en un solo comando, para una mayor eficiencia. Sin embargo, en este caso eso no es posible: dado que la clave del blog es una columna de IDENTIDAD generada por la base de datos, debemos recuperar el valor generado antes de poder enviar la inserción de la publicación, que debe contenerlo. Este es un estado de cosas normal, y no hay mucho que podamos hacer al respecto.

Es posible cambiar nuestro Blog y Publicación para usar claves GUID en lugar de números enteros. De forma predeterminada, EF Core realiza la generación de clientes en claves GUID, lo que significa que genera un nuevo GUID en lugar de que lo haga la base de datos, como es el caso de las columnas IDENTIDAD. Con EF Core 6.0, obtenemos lo siguiente:

dbug: 2022-07-10 19:47:51.176 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:47:51.273 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (36ms) [Parameters=[@p0='7c63f6ac-a69a-4365-d1c5-08da629c4f43', @p1='MyBlog' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
info: 2022-07-10 19:47:51.284 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p2='d0e30140-0f33-4435-e165-08da629c4f4d', @p3='0', @p4='7c63f6ac-a69a-4365-d1c5-08da629c4f43' (Nullable = true), @p5='My first post' (Size
 = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Post] ([Id], [BlogId], [BlogId1], [Title])
      VALUES (@p2, @p3, @p4, @p5);
dbug: 2022-07-10 19:47:51.296 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Desafortunadamente, el blog y la publicación aún se insertan a través de diferentes comandos. EF Core 7.0 elimina esto y hace lo siguiente:

dbug: 2022-07-10 19:40:30.259 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:40:30.293 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (26ms) [Parameters=[@p0='ce67f663-221a-4a86-3d5b-08da629b4875', @p1='MyBlog' (Size = 4000), @p2='127329d1-5c31-4001-c6a6-08da629b487b', @p3='0', @p4='ce67f663-
221a-4a86-3d5b-08da629b4875' (Nullable = true), @p5='My first post' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Post] ([Id], [BlogId], [BlogId1], [Title])
      VALUES (@p2, @p3, @p4, @p5);
dbug: 2022-07-10 19:40:30.302 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Dado que la clave del Blog es generada por el cliente, ya no es necesario esperar por ningún valor generado por la base de datos, y los dos INSERT se combinan en un solo comando, lo que reduce un viaje de ida y vuelta.

Cuidado, antes de salir corriendo y hacer eso, debe saber que EF Core también tiene una función llamada HiLo, que proporciona resultados similares con una clave entera. Cuando se configura HiLo, EF configura una secuencia de base de datos y obtiene un rango de valores de ella (10 de forma predeterminada); EF Core almacena en caché internamente estos valores precargados y se usan cada vez que se necesita insertar una nueva fila. El efecto es similar al escenario GUID anterior: siempre que tengamos valores restantes de la secuencia, ya no necesitaremos obtener una ID generada por la base de datos al insertar. Una vez que EF agote esos valores, hará un solo viaje de ida y vuelta para obtener el siguiente rango de valores, y así sucesivamente.

HiLo se puede habilitar en función de la propiedad de la siguiente manera:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>().Property(b => b.Id).UseHiLo();
}

Una vez hecho esto, nuestra salida SaveChanges es eficiente y se asemeja al escenario GUID:

dbug: 2022-07-10 19:54:25.862 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 2022-07-10 19:54:25.890 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (20ms) [Parameters=[@p0='1', @p1='MyBlog' (Size = 4000), @p2='1', @p3='My first post' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Post] ([BlogId], [Title])
      OUTPUT INSERTED.[Id]
      VALUES (@p2, @p3);
dbug: 2022-07-10 19:54:25.909 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Debemos tener en cuenta que esta optimización de eliminación de ida y vuelta también acelera algunos otros escenarios, incluida la estrategia de mapeo de herencia de tabla por tipo (TPT), casos en los que las filas se eliminan y se insertan en la misma tabla en una sola llamada SaveChanges, y algunos otros.

Conclusiones

Este gran ORM al pasar el tiempo viene con nuevas más funcionalidades y ni nombras si las mejoras aumentan el rendimiento lo cual nos hace ahorrar en tiempos de procesamiento y consumo de recursos. En futuros post veremos más novedades en Entity Framework.

Fernando Sonego

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *