Привет! Меня зовут Александр, я поддерживаю внутренний сервис для заказа обедов сотрудниками Контура под названием Контур.Кафе.
Недавно во время обновления Entity Framework Core (далее — EFC) с версии 2 до версии 8 мы столкнулись с проблемами при использовании разделенных запросов. Эти проблемы могли оставить часть сотрудников без обедов. Спешу ими поделиться.
Разделенные запросы
Разделенные запросы позволяют загружать связанные сущности из базы данных (далее — БД) с помощью нескольких запросов, которые выполняются в разных транзакциях.
Рассмотрим следующий набор сущностей (взято и упрощено из Контур.Кафе):
// заказ
public class Order
{
// EFC вызывает этот конструктор
private Order(int id)
{
Id = id;
}
public Order(int id, IEnumerable<DishInOrder> dishes)
{
Id = id;
Dishes = dishes.ToList();
}
public int Id { get; set; }
public List<DishInOrder> Dishes { get; set; } = new();
public Confirmation? Confirmation { get; set; }
public override string ToString() =>
$"Заказ {Id} с блюдами: [{string.Join(',', Dishes.Select(c => c.Name))}]. Подтверждение: {Confirmation?.Id}";
}
// блюдо в заказе
public record DishInOrder(int Id, string Name);
// подтверждение отправки заказов в бухгалтерию
public record Confirmation(int Id);
И их конфигурацию:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(e =>
{
e.Property(o => o.Id).ValueGeneratedNever();
e.HasMany<DishInOrder>(c => c.Dishes)
.WithOne();
e.HasOne(o => o.Confirmation)
.WithMany();
});
modelBuilder.Entity<DishInOrder>(e =>
{
e.Property(d => d.Id).ValueGeneratedNever();
e.Property(d => d.Name);
});
modelBuilder.Entity<Confirmation>(e => e.Property(c => c.Id).ValueGeneratedNever());
}
Загрузка заказов в режиме SingleQuery
var orders = await dbContext.Orders
.Include(w => w.Confirmation)
.Include(w => w.Dishes)
.AsSingleQuery() // не обязательно, поведение по умолчанию
.ToListAsync();
Сгенерированный запрос:
SELECT [o].[Id], [o].[ConfirmationId], [c].[Id], [d].[Id], [d].[Name], [d].[OrderId]
FROM [Orders] AS [o]
LEFT JOIN [Confirmations] AS [c] ON [o].[ConfirmationId] = [c].[Id]
LEFT JOIN [DishInOrder] AS [d] ON [o].[Id] = [d].[OrderId]
ORDER BY [o].[Id], [c].[Id]
Загрузка заказов в режиме SplitQuery
var orders = await dbContext.Orders
.Include(w => w.Confirmation)
.Include(w => w.Dishes)
.AsSplitQuery()
.ToListAsync();
Сгенерированные запросы:
SELECT [o].[Id], [o].[ConfirmationId], [c].[Id]
FROM [Orders] AS [o]
LEFT JOIN [Confirmations] AS [c] ON [o].[ConfirmationId] = [c].[Id]
ORDER BY [o].[Id], [c].[Id]
SELECT [d].[Id], [d].[Name], [d].[OrderId], [o].[Id], [c].[Id]
FROM [Orders] AS [o]
LEFT JOIN [Confirmations] AS [c] ON [o].[ConfirmationId] = [c].[Id]
INNER JOIN [DishInOrder] AS [d] ON [o].[Id] = [d].[OrderId]
ORDER BY [o].[Id], [c].[Id]
Как работало ранее
EFC 2.0 использовал режим SplitQuery
EFC 3.0 начал использовать режим SingleQuery
SingleQuery вызывает проблему cartesian explosion, которая приводит к деградации времени выполнения запросов
В EFC 5.0 вернули возможность использовать SplitQuery.
Потеря связанных сущностей
В ряде случаев, при наличии параллельных пишущих транзакций, дочерние сущности могут быть не загружены.
Предзаполним БД для дальнейшей демонстрации проблемы. В базу добавилось два заказа: один с супами, другой с гарнирами. И подтверждение отправки в бухгалтерию, которое не привязано ни к одному из заказов.
await using (var dbContext = new CafeContext())
{
await dbContext.Database.EnsureDeletedAsync();
await dbContext.Database.EnsureCreatedAsync();
dbContext.Confirmations.Add(new Confirmation(1));
dbContext.Orders.Add(new Order(1,
new DishInOrder[] {new(11, "Борщ"), new(12, "Щи"), new(13, "Солянка")}));
dbContext.Orders.Add(new Order(2,
new DishInOrder[] {new(21, "Макароны"), new(22, "Гречка"), new(23, "Картофель")}));
await dbContext.SaveChangesAsync();
}
Далее для проверки будем вызывать следующий код, который будет запрашивать заказы и писать на консоль их состав:
await using (var dbContext = new CafeContext())
{
var orders = await dbContext.Orders
.Include(w => w.Confirmation)
.Include(w => w.Dishes)
.AsSplitQuery()
.ToListAsync();
Console.WriteLine(string.Join(Environment.NewLine, orders));
}
В текущем виде при использовании SingleQuery или SplitQuery код ведет себя ожидаемо и пишет состав заказов на консоль:
Заказ 1 с блюдами: [Борщ,Щи,Солянка]. Подтверждение:
Заказ 2 с блюдами: [Макароны,Гречка,Картофель]. Подтверждение:
Ребенок с ранее не подгруженным родителем
Что делаем:
Загружаем родителей
Вставляем нового родителя с ребенком перед загрузкой детей
Догружаем детей для п.1.
Для этого модифицируем конструктор заказа (Order) который вызывает EFC, т.е. добавляем в конструктор следующее:
if (id == 1)
{
using var dbContext = new CafeContext();
dbContext.Orders.Add(new Order(0, new DishInOrder[] {new(31, "Котлета")}));
dbContext.SaveChanges();
}
Сгенерированные запросы
SELECT [o].[Id], [o].[ConfirmationId], [c].[Id]
FROM [Orders] AS [o]
LEFT JOIN [Confirmations] AS [c] ON [o].[ConfirmationId] = [c].[Id]
ORDER BY [o].[Id], [c].[Id]
-- Began transaction with isolation level 'ReadCommitted'.
INSERT INTO [Orders] ([Id], [ConfirmationId])
VALUES (@p0, @p1);
INSERT INTO [DishInOrder] ([Id], [OrderId], [Name])
VALUES (@p2, @p3, @p4);
-- Committing transaction.
SELECT [d].[OrderId], [d].[Id], [d].[Name], [o].[Id], [c].[Id]
FROM [Orders] AS [o]
LEFT JOIN [Confirmations] AS [c] ON [o].[ConfirmationId] = [c].[Id]
INNER JOIN [DishInOrder] AS [d] ON [o].[Id] = [d].[OrderId]
ORDER BY [o].[Id], [c].[Id]
Что получаем:
Заказ 1 с блюдами: []. Подтверждение:
Заказ 2 с блюдами: []. Подтверждение:
Составы заказов потеряны при загрузке данных.
Полный листинг примера
using Microsoft.EntityFrameworkCore;
namespace EfSplit;
public record Confirmation(int Id);
public record DishInOrder(int Id, string Name);
public class Order
{
// EFC вызывает этот конструктор
private Order(int id)
{
Id = id;
if (id == 1)
{
using var dbContext = new CafeContext();
dbContext.Orders.Add(new Order(0, new DishInOrder[] {new(31, "Котлета")}));
dbContext.SaveChanges();
}
}
public Order(int id, IEnumerable<DishInOrder> dishes)
{
Id = id;
Dishes = dishes.ToList();
}
public int Id { get; set; }
public List<DishInOrder> Dishes { get; set; } = new();
public Confirmation? Confirmation { get; set; }
public override string ToString() => $"Заказ {Id} с блюдами: [{string.Join(',', Dishes.Select(c => c.Name))}]. Подтверждение: {Confirmation?.Id}";
}
public class CafeContext : DbContext
{
private const string ConnectionString = "";
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.LogTo(Console.WriteLine);
optionsBuilder.UseSqlServer(ConnectionString);
}
public DbSet<Order> Orders { get; set; } = null!;
public DbSet<Confirmation> Confirmations { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(e =>
{
e.Property(o => o.Id).ValueGeneratedNever();
e.HasMany<DishInOrder>(c => c.Dishes)
.WithOne();
e.HasOne(o => o.Confirmation)
.WithMany();
});
modelBuilder.Entity<DishInOrder>(e =>
{
e.Property(d => d.Id).ValueGeneratedNever();
e.Property(d => d.Name);
});
modelBuilder.Entity<Confirmation>(e => e.Property(c => c.Id).ValueGeneratedNever());
}
}
public static class Program
{
public static async Task Main()
{
await using (var dbContext = new CafeContext())
{
await dbContext.Database.EnsureDeletedAsync();
await dbContext.Database.EnsureCreatedAsync();
dbContext.Confirmations.Add(new Confirmation(1));
dbContext.Orders.Add(new Order(1,
new DishInOrder[] {new(11, "Борщ"), new(12, "Щи"), new(13, "Солянка")}));
dbContext.Orders.Add(new Order(2,
new DishInOrder[] {new(21, "Макароны"), new(22, "Гречка"), new(23, "Картофель")}));
await dbContext.SaveChangesAsync();
}
await using (var dbContext = new CafeContext())
{
var orders = await dbContext.Orders
.Include(w => w.Confirmation)
.Include(w => w.Dishes)
.AsSplitQuery()
.ToListAsync();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(string.Join(Environment.NewLine, orders));
Console.ForegroundColor = ConsoleColor.White;
}
}
}
Изменение навигационного свойства родителя перед загрузкой детей
Что делаем:
Загружаем родителей
Меняем Confirmation у загруженного родителя
Догружаем детей для п.1.
Для этого модифицируем конструктор заказа который вызывает EFC:
// Добавить подтверждение к заказу 2 после его загрузки но перед загрузкой его блюд
if (id == 1)
{
using var dbContext = new CafeContext();
dbContext.Database.ExecuteSql(FormattableStringFactory.Create("
UPDATE [Orders] SET ConfirmationId = 1 where Id = 2"));
}
Запросы
SELECT [o].[Id], [o].[ConfirmationId], [c].[Id]
FROM [Orders] AS [o]
LEFT JOIN [Confirmations] AS [c] ON [o].[ConfirmationId] = [c].[Id]
ORDER BY [o].[Id], [c].[Id]
UPDATE [Orders] SET ConfirmationId = 1 where Id = 2
SELECT [d].[OrderId], [d].[Id], [d].[Name], [o].[Id], [c].[Id]
FROM [Orders] AS [o]
LEFT JOIN [Confirmations] AS [c] ON [o].[ConfirmationId] = [c].[Id]
INNER JOIN [DishInOrder] AS [d] ON [o].[Id] = [d].[OrderId]
ORDER BY [o].[Id], [c].[Id]
Что получаем:
Заказ 1 с блюдами: [Борщ,Щи,Солянка]. Подтверждение:
Заказ 2 с блюдами: []. Подтверждение:
Состав второго заказа утерян.
Хотя казалось бы, как изменение ConfirmationId может повлиять на загрузку блюд?
Полный листинг примера
using System.Runtime.CompilerServices;
using Microsoft.EntityFrameworkCore;
namespace EfSplit;
public record Confirmation(int Id);
public record DishInOrder(int Id, string Name);
public class Order
{
// EFC вызывает этот конструктор
private Order(int id)
{
Id = id;
// Добавить подтверждение к заказу 2 после его загрузки но перед загрузкой его блюд
if (id == 1)
{
using var dbContext = new CafeContext();
dbContext.Database.ExecuteSql(FormattableStringFactory.Create("UPDATE [Orders] SET ConfirmationId = 1 where Id = 2"));
}
}
public Order(int id, IEnumerable<DishInOrder> dishes)
{
Id = id;
Dishes = dishes.ToList();
}
public int Id { get; set; }
public List<DishInOrder> Dishes { get; set; } = new();
public Confirmation? Confirmation { get; set; }
public override string ToString() => $"Заказ {Id} с блюдами: [{string.Join(',', Dishes.Select(c => c.Name))}]. Подтверждение: {Confirmation?.Id}";
}
public class CafeContext : DbContext
{
private const string ConnectionString = "";
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.LogTo(Console.WriteLine);
optionsBuilder.UseSqlServer(ConnectionString);
}
public DbSet<Order> Orders { get; set; } = null!;
public DbSet<Confirmation> Confirmations { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(e =>
{
e.Property(o => o.Id).ValueGeneratedNever();
e.HasMany<DishInOrder>(c => c.Dishes)
.WithOne();
e.HasOne(o => o.Confirmation)
.WithMany();
});
modelBuilder.Entity<DishInOrder>(e =>
{
e.Property(d => d.Id).ValueGeneratedNever();
e.Property(d => d.Name);
});
modelBuilder.Entity<Confirmation>(e => e.Property(c => c.Id).ValueGeneratedNever());
}
}
public static class Program
{
public static async Task Main()
{
await using (var dbContext = new CafeContext())
{
await dbContext.Database.EnsureDeletedAsync();
await dbContext.Database.EnsureCreatedAsync();
dbContext.Confirmations.Add(new Confirmation(1));
dbContext.Orders.Add(new Order(1,
new DishInOrder[] {new(11, "Борщ"), new(12, "Щи"), new(13, "Солянка")}));
dbContext.Orders.Add(new Order(2,
new DishInOrder[] {new(21, "Макароны"), new(22, "Гречка"), new(23, "Картофель")}));
await dbContext.SaveChangesAsync();
}
await using (var dbContext = new CafeContext())
{
var orders = await dbContext.Orders
.Include(w => w.Confirmation)
.Include(w => w.Dishes)
.AsSplitQuery()
.ToListAsync();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(string.Join(Environment.NewLine, orders));
Console.ForegroundColor = ConsoleColor.White;
}
}
}
Решение
На эту тему создан issue на Github. В нем автор описывает первую проблему, и я попытался описать вторую проблему.
Ответственный за EFC ссылается на документацию, которая говорит:
While most databases guarantee data consistency for single queries, no such guarantees exist for multiple queries. If the database is updated concurrently when executing your queries, resulting data may not be consistent. You can mitigate it by wrapping the queries in a serializable or snapshot transaction, although doing so may create > performance issues of its own. For more information, see your database's documentation.
И не отрицает, что первый случай похож на баг.
Я видел это предупреждение в документации, прежде чем применить SplitQuery. Но я понял это требование как относящееся к БД. Действительно, если мы запрашиваем агрегат в разных транзакциях, то между ними могут произойти изменения, которые приведут агрегат в неконсистентное состояние на клиенте. Однако, как оказалось, это требование относится и к какой-то внутренней логике соединения детей с родителем в EFC.
При этом в используемом нами EFC 2.0, в котором агрегаты запрашивались так же разделенными запросами, такого поведения не было. Поэтому каких-то проблем в этом месте не ожидалось.
В качестве решения мы обернули запросы в транзакцию с уровнем изоляции SNAPSHOT.
Листинг примера №2 с исправлением
using System.Data;
using System.Runtime.CompilerServices;
using Microsoft.EntityFrameworkCore;
namespace EfSplit;
public record Confirmation(int Id);
public record DishInOrder(int Id, string Name);
public class Order
{
// EFC вызывает этот конструктор
private Order(int id)
{
Id = id;
// Добавить подтверждение к заказу 2 после его загрузки но перед загрузкой его блюд
if (id == 1)
{
using var dbContext = new CafeContext();
dbContext.Database.ExecuteSql(FormattableStringFactory.Create("UPDATE [Orders] SET ConfirmationId = 1 where Id = 2"));
}
}
public Order(int id, IEnumerable<DishInOrder> dishes)
{
Id = id;
Dishes = dishes.ToList();
}
public int Id { get; set; }
public List<DishInOrder> Dishes { get; set; } = new();
public Confirmation? Confirmation { get; set; }
public override string ToString() => $"Заказ {Id} с блюдами: [{string.Join(',', Dishes.Select(c => c.Name))}]. Подтверждение: {Confirmation?.Id}";
}
public class CafeContext : DbContext
{
private const string ConnectionString = "";
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.LogTo(Console.WriteLine);
optionsBuilder.UseSqlServer(ConnectionString);
}
public DbSet<Order> Orders { get; set; } = null!;
public DbSet<Confirmation> Confirmations { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Order>(e =>
{
e.Property(o => o.Id).ValueGeneratedNever();
e.HasMany<DishInOrder>(c => c.Dishes)
.WithOne();
e.HasOne(o => o.Confirmation)
.WithMany();
});
modelBuilder.Entity<DishInOrder>(e =>
{
e.Property(d => d.Id).ValueGeneratedNever();
e.Property(d => d.Name);
});
modelBuilder.Entity<Confirmation>(e => e.Property(c => c.Id).ValueGeneratedNever());
}
}
public static class Program
{
public static async Task Main()
{
await using (var dbContext = new CafeContext())
{
await dbContext.Database.EnsureDeletedAsync();
await dbContext.Database.EnsureCreatedAsync();
// не будет работать в PG
await dbContext.Database.ExecuteSqlAsync(FormattableStringFactory.Create("ALTER DATABASE [adeb_split] SET ALLOW_SNAPSHOT_ISOLATION ON"));
dbContext.Confirmations.Add(new Confirmation(1));
dbContext.Orders.Add(new Order(1,
new DishInOrder[] {new(11, "Борщ"), new(12, "Щи"), new(13, "Солянка")}));
dbContext.Orders.Add(new Order(2,
new DishInOrder[] {new(21, "Макароны"), new(22, "Гречка"), new(23, "Картофель")}));
await dbContext.SaveChangesAsync();
}
await using (var dbContext = new CafeContext())
{
await using var tran = await dbContext.Database.BeginTransactionAsync(IsolationLevel.Snapshot);
var orders = await dbContext.Orders
.Include(w => w.Confirmation)
.Include(w => w.Dishes)
.AsSplitQuery()
.ToListAsync();
await tran.CommitAsync();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(string.Join(Environment.NewLine, orders));
Console.ForegroundColor = ConsoleColor.White;
}
}
}
Утечка уровня изоляции транзакции
Проблема не относится напрямую к EFC. Но так как она возникает из-за ранее примененного решения, то мы рассмотрим и ее.
Меняя уровень изоляции транзакции, фактически меняется уровень изоляции соединения. При использовании пула соединений, после завершения транзакции, использованное соединение возвращается обратно в пул соединений. Любая другая новая транзакция может извлечь соединение из пула с ранее измененным уровнем изоляции. Может случиться такое, что транзакция получит соединение с невосместимым уровнем изоляции, что приведет к проблемам в согласованности данных.
На эту тему есть issue в репозитории Microsoft.Data.SqlClient.
Возможные решения:
Устанавливать уровень изоляции перед началом каждой транзакции
Возвращать обратно уровень изоляции после завершения транзакции, которая его меняет
для каждого используемого уровня изоляции определить свою строку подключения (через изменение Application Name) и подмножество запросов, которые будут выполняться с этой строкой подключения
Не использовать пул соединений
Как посмотреть уровень изоляции
SELECT CASE transaction_isolation_level
WHEN 0 THEN 'Unspecified'
WHEN 1 THEN 'ReadUncomitted'
WHEN 2 THEN 'Readcomitted'
WHEN 3 THEN 'Repeatable'
WHEN 4 THEN 'Serializable'
WHEN 5 THEN 'Snapshot'
END, *
FROM sys.dm_exec_sessions
WHERE login_name = 'логин' and DB_NAME(database_id) = 'имя БД'
Заключение
В результате проделанной работы я пришел к выводу, что нужно использовать транзакции при работе с разделенными запросами во всех случаях. Разделенные запросы в EFC 2 и EFC 8 ведут себя по-разному.
Примеры из статьи запускались на MSSQL и PostgreSQL.
Использованный стек
OS: Linux
NET: 8.0.201
EFC: 8.0.10
SQL Server: 2016, 2022 (Developer Edition)
Postgres: 17
Комментарии (10)
Dr9vik
03.12.2024 12:28https://github.com/dotnet/efcore/issues/33826
первая ссылка
повторил
написал нормальные модели, а не это стыдобище в примере
дало тот же результат что и ожидалиa_deb Автор
03.12.2024 12:28Ссылка на данный issue была приведена в статье и первый пример был взят от туда.
Со вторым примером столкнулись уже мы.
Подскажите что вам не понравилось в моделях? Я их взял из Контур.Кафе и существенно обрезал. Вы бы хотели видеть более абстрактные модели?
kemsky
Выполнение sql из контруктора, в новом контексте и коннекшене, при всем уважении, выглядит дико, особенно в момент материализации, это приглашение к проблемам само по себе.
a_deb Автор
Добрый день. Первоначально проблема возникла при работе двух независящих друг от друга плановых задач. Пример в статье с использованием конструктора моделирует эту ситуацию и позволяет ее стабильно воспроизводить.
kemsky
Думаю, именно пример из этого тикета это основная причина по которой он не пофикшен до сих пор (дикий код, рейс, нет тест репо == отложим до лучших времен). Ситуация, как мне кажется, довольно редкая, если не писать странный код, обычно выгружают из базы через проекции то, что надо, или только свой агрегат через инклюды, на каком реальном кейзе это воспроизвелось?
a_deb Автор
В нашем случае выполнялись две плановые задачи:
отправка опубликованных заказов в офисы
отправка опубликованных заказов в бухгалтерию
Первая задача не модифицировала заказы, только читала.
Вторая задача выполняла изменение одного поля (ConfirmationId).
Во время модификации поля во второй задаче, происходило чтение в первой, что привело к тому, что часть данных не подгрузилась.
Модификация во второй задаче не должна была влиять на чтение в первой задаче, так как она меняла родительское поле которое не влияет на загрузку детей.
В EFC 2.0 проблем в таком кейсе не было.
В том же issue недавно появился новый пример, в котором не используется конструктор и соответственно не затрагивается материализация.
https://github.com/torutek/EfTest/blob/master/EfTest/EfTestContext.cs#L53
Но это тоже своеобразный хак
a_deb Автор
Дополню, что в нашем случае:
задача отправки заказов в офисы прочитала заказы (Order)
задача отправки заказов в бухгалтерию поменяла поле в Order
задача отправки заказов в офисы прочитала блюда из заказов (DishInOrder) - вторым, разделенным запросом
И вышло так что в пункте 3 для некоторых заказов не подгрузился список блюд
kemsky
Это как раз то, чего они пишут не делать в документации со сплитом - не читать конкуренто изменяемые данные, пусть даже можно было бы пофиксить этот конкретный рейс. Делать так плохо в любом случае (сегодня поменяли одно поле, завтра два, результат не предсказуемый). И зачем выгружать изменяемую часть, зная что она может поменяться? И неужели для отправки в офис и бухгалтерию требуются все поля и все вложенные сущности?
a_deb Автор
Я согласен с вами по поводу проблем в применяемом подходе.
Передо мной стояла задача обновить EFC.
При обновлении я включил SplitQuery, так как без него были проблемы, и не видел в этом чего-то плохого, ведь я таким образом не менял поведение, которое было в EFC 2.0.
Как оказалось, в новой версии EFC механизм SplitQuery стал нести больше опасности чем раньше. И я пришел к выводу, что без транзакций нет смысла его использовать. Ведь сегодня запросов на запись может не быть, а завтра они появятся и про SplitQuery никто не вспомнит.
a_deb Автор
Так же в приведенном выше случае нет проблем с точки зрения БД. Согласованность не нарушается.
Если выполнить сгенерированные EFC запросы не в EFC (а например на голом ADO.NET), то проблемы не будет.
Поэтому я не воспринял предупреждение из документации, как относящееся к внутренней логике EFC соединения детей с родителями, о чем собственно сказал в статье