在实体框架核心中执行业务规则

时间:2019-03-25 17:42:34

标签: c# .net .net-core transactions entity-framework-core

让我们假设我有一个执行以下操作的控制器操作:

  1. 检查在特定时间是否有日历空位
  2. 检查是否已预订与该时段重叠的约会
  3. 如果两个条件都满足,它将在给定时间创建一个新约会

简单的实现存在多个问题:

  • 如果在第3步之前删除了1中提取的日历槽,怎么办?
  • 如果在第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 个答案:

答案 0 :(得分:4)

数据库完整性检查是您最好的朋友

根据您的描述,您的约会基于空档。这使问题变得更加简单,因为您可以在SlotId表上为Appointments定义一个唯一约束。然后,您将需要一个用于Appointments.SlotId引用Slot.Id

的外键
  

如果在第3步之前删除了1中提取的日历槽,该怎么办?

数据库将引发外键冲突异常

  

如果在第2步之后但在第3步之前又预定了另一个约会,怎么办?

数据库会抛出重复的键异常

下一步,您需要捕获这两个异常并将用户重定向回预订页面。再次从DB重新加载数据,并检查是否有无效条目,通知用户进行修改,然后重试。

对于死锁部分,它实际上取决于您的表结构。访问数据的方式,对数据进行索引的方式以及数据库的查询计划。没有确定的答案。

答案 1 :(得分:0)

似乎您需要悲观的并发方法来管理任务。不幸的是,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);
            }
        }
    }

答案 2 :(得分:0)

有时,在高可用性场景中,建议权衡立即一致性(通过事务获得)以最终一致性(通过工作流/传奇获得)。

在您的示例中,您可以考虑使用一种中间状态存储“挂起”约会的方法,然后再进行一次一致性检查。

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();
}

当然,这将使数据库的访问次数增加一倍,但是将完全避免事务(具有死锁和锁定延迟)。

您只需要评估哪个方面对您更重要:可伸缩性或即时一致性。