#c# #asp.net-web-api #asp.net-core #design-patterns #concurrency
#c# #asp.net-web-api #asp.net-core #шаблоны проектирования #параллелизм
Вопрос:
ASP.NET приложение core 2.1 (Entity Framework), состоящее из нескольких конечных точек веб-api. Одним из них является конечная точка «join», где пользователи могут присоединиться к очереди. Другая конечная точка «оставить», где пользователи могут покинуть очередь.
В очереди 10 доступных мест.
Если все 10 мест заполнены, мы отправляем ответное сообщение со словами: «Очередь заполнена».
Если присоединилось ровно 3 пользователя, мы возвращаем true .
Если количество присоединенных пользователей НЕ равно 3, мы возвращаем false .
200 пользователей, удовлетворенных запуском, готовы присоединиться к разным очередям и покинуть их. Все они одновременно вызывают конечную точку «присоединиться» и «покинуть».
Это означает, что мы должны обрабатывать поступающие запросы последовательно, чтобы иметь возможность гарантировать, что пользователи добавляются и удаляются в правильные очереди приятным и контролируемым образом. (правильно?)
Один из вариантов — добавить QueueService
класс, как AddSingleton<>
в IServiceCollection
, а затем создать a lock()
, чтобы гарантировать, что одновременно может входить только один пользователь. Однако, как мы обрабатываем dbContext
, потому что он зарегистрирован как AddTransient<>
или AddScoped<>
?
Код Psedudo для части соединения:
public class QueueService
{
private readonly object _myLock = new object();
private readonly QueueContext _context;
public QueueService(QueueContext context)
{
_context = context;
}
public bool Join(int queueId, int userId)
{
lock (_myLock)
{
var numberOfUsersInQueue = _context.GetNumberOfUsersInQueue(queueId); <- Problem.
if (numberOfUsersInQueue >= 10)
{
throw new Exception("Queue is full.");
}
else
{
_context.AddUserToQueue(queueId, userId); <- Problem.
}
numberOfUsersInQueue = _context.GetNumberOfUsersInQueue(queueId); <- Problem.
if (numberOfUsersInQueue == 3)
{
return true;
}
}
return false;
}
}
Другой вариант — сделать QueueService
переходный процесс, но тогда я теряю состояние службы, и для каждого запроса предоставляется новый экземпляр, что делает блокировку () бессмысленной.
Вопросы:
[1] Должен ли я вместо этого обрабатывать состояние очередей в памяти? Если да, то как согласовать его с базой данных?
[2] Есть ли блестящий шаблон, который я пропустил? Или как я мог справиться с этим по-другому?
Ответ №1:
Это означает, что мы должны обрабатывать поступающие запросы последовательно, чтобы иметь возможность гарантировать, что пользователи добавляются и удаляются в правильные очереди приятным и контролируемым образом. (правильно?)
По сути, да. Запросы могут выполняться одновременно, но они должны каким-то образом синхронизироваться друг с другом (например, блокировка или транзакции базы данных).
Должен ли я вместо этого обрабатывать состояние очередей в памяти?
Часто лучше сделать веб-приложение без состояния и запросы независимыми. В соответствии с этой моделью состояние хранится в базе данных. Огромное преимущество заключается в том, что синхронизация не требуется, и приложение может работать на нескольких серверах. Кроме того, если приложение перезапускается (или выходит из строя), состояние не теряется.
Это кажется вполне возможным и уместным здесь.
Поместите состояние в реляционную базу данных и используйте управление параллелизмом, чтобы сделать параллельный доступ безопасным. Например, используйте сериализуемый уровень изоляции и повторите попытку в случае взаимоблокировки. Это дает вам очень хорошую модель программирования, в которой вы притворяетесь, что являетесь единственным пользователем базы данных, но при этом это совершенно безопасно. Весь доступ должен быть помещен в транзакцию, и в случае взаимоблокировки вся транзакция должна быть повторена.
Если вы настаиваете на состоянии в памяти, разделите одноэлементные и переходные компоненты. Поместите глобальное состояние в одноэлементный класс и поместите операции, действующие на это состояние, в переходный. Таким образом, внедрение зависимостей переходных компонентов, таких как доступ к базе данных, очень просто и чисто. Глобальный класс должен быть крошечным (возможно, только поля данных).
Комментарии:
1. Спасибо. Я согласен сохранить состояние в БД, и это то, что я пытался сделать в моем примере, но я столкнулся с проблемой DbContext из-за синглтона. Вы рекомендуете изменить QueueService на переходный, снять блокировку, а затем использовать управление параллелизмом на стороне БД, верно? Я бы ожидал два или более входящих запросов с одинаковыми данными, что приводит к конфликту в БД. (?)
2. Вы можете использовать транзакции для атомарного запроса и обновления базы данных. Мой ответ подробно останавливается на этом.
Ответ №2:
В итоге я пришел к этому простому решению.
Создайте статический (или одноэлементный) класс с a ConcurrentDictionary<int, object>
, который принимает queueId и блокировку.
При создании новой очереди добавьте queueId и новый объект блокировки в словарь.
Создайте класс AddTransient<>
QueueService, а затем:
public bool Join(int queueId, int userId)
{
var someLock = ConcurrentQueuePlaceHolder.Queues[queueId];
lock (someLock)
{
var numberOfUsersInQueue = _context.GetNumberOfUsersInQueue(queueId); <- Working
if (numberOfUsersInQueue >= 10)
{
throw new Exception("Queue is full.");
}
else
{
_context.AddUserToQueue(queueId, userId); <- Working
}
numberOfUsersInQueue = _context.GetNumberOfUsersInQueue(queueId); <- Working
if (numberOfUsersInQueue == 3)
{
return true;
}
}
return false;
}
Больше никаких проблем с _context.
Таким образом, я могу обрабатывать параллельные запросы приятным и контролируемым способом.
Если в какой-то момент в игру вступят несколько серверов, брокер сообщений или ESB также могут быть решением.