Внедрение бизнес-правил в ядро ​​платформы сущностей - PullRequest
7 голосов
/ 25 марта 2019

Давайте предположим, что у меня есть действие контроллера, которое выполняет следующее:

  1. проверяет, есть ли место для календаря в определенное время
  2. проверяет, нет ли уже забронированных встреч, которые перекрываются с этим слотом
  3. , если оба условия выполнены, это создает новую встречу в данное время

Тривиальная реализация представляет множество проблем:

  • что если слот календаря, выбранный в 1, будет удален до шага 3?
  • что, если другая встреча забронирована после шага 2, но до шага 3?

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

Учитывая следующее тривиальное решение:

public class AController
{
    // ...
    public async Task Fn(..., CancellationToken cancellationToken)
    {
        var calendarSlotExists = dbContext.Slots.Where(...).AnyAsync(cancellationToken);
        var appointmentsAreOverlapping = dbContext.Appointments.Where(...).AnyAsync(cancellationToken);
        if (calendarSlotExists && !appointmentsAreOverlapping)
            dbContext.Appointments.Add(...);
        dbContext.SaveChangesAsync(cancellationToken);
    }
}

Каков наилучший способ всегда предотвращать проблемы параллелизма и как мне обрабатывать возможные тупики?

Ответы [ 3 ]

4 голосов
/ 01 апреля 2019

Проверка целостности базы данных - ваш лучший друг

На основании вашего описания ваши встречи основаны на слотах.Это значительно упростило задачу, поскольку вы можете эффективно определить уникальное ограничение для SlotId в таблице Appointments.И тогда вам понадобится внешний ключ для Appointments.SlotId ссылок Slot.Id

, что если слот календаря, выбранный в 1, будет удален до шага 3?

DB будетвыбросить исключение нарушения внешнего ключа

что, если другое событие будет забронировано после шага 2, но до шага 3?

БД выдаст исключение дублированного ключа

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

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

0 голосов
/ 07 апреля 2019

Иногда в сценариях высокой доступности рекомендуется обменять немедленную согласованность (полученную с помощью транзакций) на конечную согласованность (полученную с помощью рабочих процессов / саг).

В вашем примере вы могли бы рассмотреть подход, использующий промежуточное состояние для хранения «ожидающих» встреч с последующей новой проверкой его согласованности.

public async Task Fn(..., CancellationToken cancellationToken)
{
    // suppose "appointment" is our entity, we will store it as "pending" using
    // PendingUntil property (which is Nullable<DateTimeOffset>).
    // an appointment is in "pending" state if the PendingUntil property is set
    // (not null), and its value is >= UtcNow
    var utcNow = DateTimeOffset.UtcNow;
    appointment.PendingUntil = utcNow.AddSeconds(5);

    // we will then use this property to find out if there are other pending appointments

    var calendarSlotExists = await dbContext.Slots.Where(...).AnyAsync(cancellationToken);
    var appointmentsAreOverlapping = await dbContext.Appointments
                                                    .Where(...)
                                                    .Where(a => a.PendingUntil == null || 
                                                                a.PendingUntil >= now)
                                                    .AnyAsync(cancellationToken);

    if (calendarSlotExists && !appointmentsAreOverlapping)
        dbContext.Appointments.Add(appointment);
    else
        return BadRequest(); // whatever you what to return

    await dbContext.SaveChangesAsync(cancellationToken); // save the pending appointment

    // now check if the pending appointment is still valid

    var calendarSlotStillExists = await dbContext.Slots.Where(...).AnyAsync(cancellationToken); // same query as before

    // a note on the calendar slot existance: you should of course negate any
    // slot deletion for (pending or not) appointments.

    // we will then check if there is any other appointment in pending state that was
    // stored inside the database "before" this one.
    // this query is up to you, below you'll find just an example

    var overlappingAppointments = await dbContext.Appointments.Where(...)
                                                 .Where(a => a.Id != appointment.Id &&
                                                             a.PendingUntil == null || 
                                                             a.PendingUntil >= now)
                                                 .ToListAsync(cancellationToken);

    // we are checking if other appointments (pending or not) have been written to the DB
    // of course we need to exclude the appointment we just added

    if (!calendarSlotStillExists || overlappingAppointments.Any(a => a.PendingUntil == null || a.PendingUntil < appointment.PendingUntil)
    {
        // concurrency check failed
        // this means that another appointment was added after our first check, but before our appointment.
        // we have to remove the our appointment
        dbContext.Appointments.Remove(appointment);
        await dbContext.SaveChangesAsync(cancellationToken); // restore DB
        return BadRequest(); // same response as before
    }

    // ok, we can remove the pending state
    appointment.PendingUntil = null;

    await dbContext.SaveChangesAsync(cancellationToken); // insert completed
    return Ok();
}

Это, конечно, удвоит число попаданий в базу данных, но позволит полностью избежать транзакций (с блокировками и задержкой блокировки).

Вам просто нужно оценить, какой аспект для вас важнее: масштабируемость или немедленная согласованность.

0 голосов
/ 05 апреля 2019

Похоже, вам нужен пессимистичный параллельный подход для управления вашей задачей.К сожалению, это не поддерживается в Entity Framework Core.

В качестве альтернативы, вы можете использовать статический ConcurrentDictionary или реализовать свой собственный ConcurrentHashSet, чтобы обезопасить себя от нескольких запросов и избежать того, чтобы другое назначение можно было забронировать после шага 2, но до шага 3.

О том, что слот календаря, извлеченный в 1, был удален перед проблемой шага 3, я думаю, что наличие отношения внешнего ключа между назначением и слотом для проверки целостности базы данных в SaveChanges или наличие ConcurrentDictionary / ConcurrentHashSet Public и проверка егоот других действий (удалить слоты и т. д.) перед их выполнением, есть хорошие варианты для его решения.

static ConcurrentDictionary<int, object> operations = new ConcurrentDictionary<int, object>();

    public async Task<IActionResult> AControllerAction()
    {
        int? calendarSlotId = 1; //await dbContext.Slots.FirstOrDefaultAsync(..., cancellationToken))?.Id;

        try
        {
            if (calendarSlotId != null && operations.TryAdd(calendarSlotId.Value, null))
            {
                bool appointmentsAreOverlapping = false; //await dbContext.Slots.Where(...).AnyAsync(cancellationToken);

                if (!appointmentsAreOverlapping)
                {
                    //dbContext.Appointments.Add(...);
                    //await dbContext.SaveChangesAsync(cancellationToken);

                    return ...; //All done!
                }

                return ...; //Appointments are overlapping
            }

            return ...; //There is no slot or slot is being used
        }
        catch (Exception ex)
        {
            return ...; //ex exception (DB exceptions, etc)
        }
        finally
        {
            if (calendarSlotId != null)
            {
                operations.TryRemove(calendarSlotId.Value, out object obj);
            }
        }
    }
...