Параллельные запросы веб-api и как обрабатывать состояние в ASP.NET ядро

#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 также могут быть решением.