Приложение ASP.NET core 2.1 (Entity Framework), состоящее из нескольких конечных точек веб-API. Одним из них является конечная точка «присоединения», где пользователи могут присоединяться к очереди. Другой — конечная точка «покинуть», где пользователи могут выйти из очереди.
В очереди 10 свободных мест.
Если все 10 мест заполнены, мы возвращаем сообщение «Очередь заполнена».
Если присоединилось ровно 3 пользователя, мы возвращаем true.
Если количество присоединившихся пользователей НЕ 3, мы возвращаем false.
200 активных пользователей готовы присоединиться к разным очередям и выйти из них. Все они одновременно вызывают конечную точку «присоединиться» и «оставить».
Это означает, что мы должны обрабатывать поступающие запросы последовательно, чтобы иметь возможность гарантировать, что пользователи добавляются и удаляются из правильных очередей удобным и контролируемым образом. (правильно?)
Один из вариантов — добавить класс QueueService
как AddSingleton<>
в IServiceCollection
, а затем создать lock()
, чтобы в каждый момент времени мог войти только один пользователь. Однако как нам обращаться с dbContext
, потому что он зарегистрирован как AddTransient<>
или AddScoped<>
?
Псевдокод для части соединения:
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] Есть ли блестящий образец, который я пропустил? Или как я мог справиться с этим по-другому?
This means that we have to handle the requests coming in is a sequential way, to be able to ensure that users are added and removed to the correct queues in a nice and controlled manner. (right?)
По сути да. Запросы могут выполняться одновременно, но они должны каким-то образом синхронизироваться друг с другом (например, блокировка или транзакции базы данных).
Should I handle the state of the queues in memory instead?
Часто лучше сделать веб-приложение без состояния и с независимыми запросами. В этой модели состояние хранится в базе данных. Огромным преимуществом является отсутствие необходимости синхронизации и приложение может работать на нескольких серверах. Кроме того, если приложение перезапускается (или аварийно завершает работу), состояние не теряется.
Здесь это кажется вполне возможным и уместным.
Поместите состояние в реляционную базу данных и используйте управление параллелизмом, чтобы сделать параллельный доступ безопасным. Например, используйте сериализуемый уровень изоляции и повторите попытку в случае взаимоблокировки. Это дает вам очень хорошую модель программирования, в которой вы притворяетесь, что являетесь единственным пользователем базы данных, но это совершенно безопасно. Весь доступ должен быть помещен в транзакцию, и в случае взаимоблокировки вся транзакция должна быть повторена.
Если вы настаиваете на состоянии в памяти, разделите одноэлементные и переходные компоненты. Поместите глобальное состояние в одноэлементный класс и поместите операции, воздействующие на это состояние, в переходный. Таким образом, внедрение зависимостей временных компонентов, таких как доступ к базе данных, становится очень простым и понятным. Глобальный класс должен быть крошечным (может быть, только поля данных).
Вы можете использовать транзакции для атомарного запроса и обновления базы данных. Мой ответ уточняет это.
Я остановился на этом простом решении.
Создайте статический (или одноэлементный) класс с ConcurrentDictionary<int, object>
, который принимает идентификатор очереди и блокировку.
При создании новой очереди добавьте в словарь идентификатор очереди и новый блокирующий объект.
Создайте класс QueueService AddTransient<>
, а затем:
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.
Спасибо. Я согласен сохранить состояние в db, и это то, что я пытался сделать в своем примере, но столкнулся с проблемой dbContext из-за singleton . Вы рекомендуете изменить QueueService на переходный, снять блокировку, а затем использовать управление параллелизмом на стороне БД, верно? Я бы ожидал двух или более входящих запросов с одинаковыми данными, что создает конфликт в базе данных. (?)