Запрет нескольких одновременных вызовов API, реализация блокировки

Пишу веб сервис с api-методом, который запускает длительную операцию. Каждый пользователь может запустить только одну такую операцию, если во время выполнения операции приходят новые запросы, их необходимо отбрасывать до завершения предыдущей операции.

Для реализации этого механизма планирую использовать отдельную таблицу в базе данных. Перед началом операции пользователь добавляет в таблицу запись что операция начата, по завершении - удаляет эту запись. При обработке очередного запроса - сначала проверяем выставлена ли блокировка, если её нет - выставляем и запускаем длительную операцию.

public class Locker
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public string ResourceName { get; set; } = string.Empty;
    public DateTime LockAcquisitionTime { get; set; }
}

Для исключения создания дублированных блокировок - создам уникальный индекс на столбцы {userId, resourceName}:

    modelBuilder.Entity<Locker>()
           .HasIndex(l => new { l.UserId, l.ResourceName })
           .IsUnique(true);

На случай, если процесс, получивший блокировку упадет до того как освободит её, для каждой операции задается максимальное время её выполнения, после истечения которого блокировка автоматически снимется.

    /// <summary>
    /// Получить блокировку для заданного ресурса
    /// </summary>
    /// <param name="userId">Id пользователя, который хочет получить доступ к ресурсу</param>
    /// <param name="resourceName">Id ресурса/операции</param>
    /// <returns>true, если блокировка была получена, false - если ресурс занят</returns>
    public async Task<bool> Acquire(int userId, string resourceName)
    {
        var now = timeService.GetCurrentUtcTime();
        
        var locker = await dbContext.Lockers.FirstOrDefaultAsync(l => l.UserId == userId && l.ResourceName == resourceName);
        if (locker == null) // блокировка не была установлена
        {
            locker = new Locker()
            {
                UserId = userId,
                ResourceName = resourceName,
                LockAcquisitionTime = now,
            };
            try
            {
                dbContext.Lockers.Add(locker);
                await dbContext.SaveChangesAsync();
            }
            catch(DbUpdateException)
            {
                return false;
            }

            return true;
        }
        else if(now - locker.LockAcquisitionTime >= MAX_OPERATION_TIME) // таймаут операции (на случай, если блокировка не была освобождена по какой-то причине)
        {
            try
            {
                dbContext.Lockers.Remove(locker);
                await dbContext.SaveChangesAsync();
            }
            catch
            {

            }

            return await Acquire(userId, resourceName); 
        }

        return false;
    }

    /// <summary>
    /// Снять блокировку
    /// </summary>
    public async Task Release(int userId, string resourceName)
    {
        var locker = await dbContext.Lockers.FirstOrDefaultAsync(l => l.UserId == userId && l.ResourceName == resourceName);
        if(locker != null)
        {
            dbContext.Lockers.Remove(locker);
            try
            {
                await dbContext.SaveChangesAsync();
            }
            catch
            {

            }
        }
    }

База данных mssql, но в последствии, возможно, перейду на другую, поэтому не хочу сейчас привязываться к какой-то конкретной.

Насколько этот подход жизнеспособный? Нет ли в нем ошибок? Какие есть альтернативные варианты использования?

udp сохранять блокировки планирую в базе, на случай, если будет несколько экземпляров api-сервиса. В таком случае кэш должен быть общим. Кэш с блокировками должен быть вне сервиса, т.к. длительная операция - это операция в стороннем сервисе, поэтому если приложение упадет, то блокировки должны где-то сохраниться, что бы при пробуждении api-сервиса не было возможности запустить вторую операцию


Ответы (1 шт):

Автор решения: aepot

А зачем это вообще в базу класть? Разве проверка этих локеров после перезапуска сервера - жизнеспособный сценарий?

Можно написать простой класс.

public class Locker
{
    private readonly HashSet<(int, string)> _set = new();
    private readonly object _lock = new();

    public bool Aсquire(int id, string resource)
    {
        lock (_lock)
        {
            return _set.Add((id, resource));
        }
    }

    public bool Free(int id, string resource)
    {
        lock (_lock)
        {
            return _set.Remove((id, resource));
        }
    }
}

Проверить его легко

static void Main(string[] args)
{
    Locker locker = new Locker();
    Console.WriteLine(locker.Aсquire(123, "aaa"));  // true
    Console.WriteLine(locker.Aсquire(1234, "aaa")); // true
    Console.WriteLine(locker.Aсquire(123, "bbb"));  // true

    Console.WriteLine(locker.Aсquire(123, "aaa")); // false
    Console.WriteLine(locker.Free(123, "aaa"));   // true
    Console.WriteLine(locker.Aсquire(123, "aaa")); // true
}

Вывод в консоль

True
True
True
False
True
True

То есть всё как ожидалось.

Теперь зарегайте этот класс как синглтон и получите его в контроллере.

Как использовать:

public async Task LockedOperation(int id, string resource)
{
    if (!_locker.Aсquire(id, resource))
        throw new InvalidOperationException($"LockedOperation for user {id} and resource {resource} is already started");

    try
    {
        // здесь запуск длительной операции
        await Task.Delay(10000);
    }
    finally
    {
        _locker.Free(id, resource);
    }
}

Если операция упадёт исключением, лок освободится. Если сервер будет перезапущен, локи сами собой сотрутся. База не нужна.

Если нужен таймаут, то он должен быть реализован внутри самой операции, то есть длительная операция должна падать по таймауту. Тем самым она вызовет освобождение лока.

Если есть возможность создавать для каждого юзера отдельный Locker, то код можно вообще свести только к string вместо (int, string). Но я думаю, идею вы поняли.


Ещё один вариант реализации через ConcurrentDictionary.

public class Locker
{
    private readonly ConcurrentDictionary<(int, string), bool> _locks = new();

    public bool Aсquire(int id, string resource)
    {
        return _locks.TryAdd((id, resource), true);
    }

    public bool Free(int id, string resource)
    {
        return _locks.TryRemove((id, resource), out _);
    }
}

Возможно он обладает более высокой производительностью, я не проверял. Работает точно так же.


udp сохранять блокировки планирую в базе, на случай, если будет несколько экземпляров api-сервиса.

Не надо в базу, есть готовые сервисы кеширования типа memcached или Redis для таких задач. Полноценно работают в кластерном окружении. В любом случае внешнее API класса Locker это не аффектит. Ну либо придется сделать AcquireAsync.

→ Ссылка