如何在库存分配场景中防止竞态条件?

3
我正在处理库存分配和并发的经典问题,想知道在这种情况下最佳实践是什么。我的情况是,我们准备了一个订单,其中包含多个“插槽”,在流程的某个特定阶段将由唯一的库存项目填充,并且此时我希望确保没有人将相同的唯一单位分配给不同订单上的插槽。例如,用户想要在下周四租一辆货车,因此我保留了一个“货车”插槽,但在稍后的时间点,我将从场地中分配一辆具体的车辆到这个插槽中。我希望确保两个不同的操作员不能将同一辆货车分配给下周四的两个不同客户。我们已经有了一个库存可用性检查过程,在日期范围内比较两个表的总和,这两个表之一是进入项,另一个是出项,将它们相加的结果告诉我是否有我想要分配到此日期此插槽的特定项目,但我想防止另一个用户在同一时间点将相同的项目分配给他们自己的插槽。我已经在谷歌上进行了一些搜索和研究,并且看起来我需要一个“悲观锁定”解决方案,但我不确定如何有效地实施它。

分配过程将从使用实体框架的Web API(使用.Net)调用(rest api)。我考虑了以下两个解决方案:

选项1-让数据库处理

在分配点,我开始一个事务并获取用于评估库存可用性的两个表的独占锁。

该过程确认库存可用性,将单位分配给插槽,然后释放锁定。

我认为这将防止两个用户尝试将相同的唯一单元分配给两个不同的订单的竞争条件,但我对每个需要查询这些表的其他进程锁定两个表感到不舒服,直到分配过程完成,因为我认为这可能会导致其他尝试读取这些表的进程遇到瓶颈。在这种情况下,我认为尝试执行重复分配的第二个进程应排队等待第一个进程释放锁定,因为它无法查询可用性表,当它这样做时,它将失败,并报告缺货警告-因此有效地阻止第二个订单分配相同的库存。

在纸面上,这听起来很可行,但我有两个问题:第一个是它会影响性能,第二个是我可能忽略了某些东西。此外,我在这个项目中首次使用Postgres(我通常是SQL Server的人),但我认为Postgres仍然具有实现此功能的特性。
选项2-使用某种手动锁定
我认为我的情况类似于售票网站在音乐会或电影销售过程中遇到的情况,我已经看到他们放置计时器,显示“您的门票将在5分钟后过期”,但我不知道他们如何在后端实现这种系统。他们是否创建一个“保留”库存表,然后在分配过程开始之前对其进行某种过期时间,并在那个计时器到期之前“黑名单”其他试图分配相同单位的用户?
抱歉介绍太长了,但我想完全解释问题,因为我看到过许多类似情况的问题,但没有什么真正帮助我决定如何继续的东西。
我的问题是,在这两个选项中,哪一个(如果有)是“正确的方法”来做到这一点?
编辑:我看到的与此问题最接近的是如何处理库存和并发,但它没有讨论选项1(可能是因为这是一个可怕的想法)。

1
没有所谓的“正确方式”。两种方法都可以。选项1)有规模限制,但更容易,你可以在这里优化很多东西,在关系型数据库锁定方面有整本书。这取决于请求/客户端的数量等因素。如果你想要无限扩展,选项2)可能更好。 - Simon Mourier
选项1似乎过于简化了,你不应该锁定整个表,而是只应该锁定当前事务正在使用的记录。因此,如果我从库存中取出一辆货车,其他所有事务都应该等待我完成后才能检查该特定货车的状态。这并不意味着我不能读取其他货车或车辆类型的状态。 - ATerry
让数据库来处理它!没有必要在客户端设置任何类型的锁定并使服务器过载以在多个客户端之间分配锁定。而且大多数情况下,您根本不需要锁定,我使用单个INSERT SELECT或UPDATE SELECT组合语句解决了类似问题,从而消除了任何锁定需求。唯一的缺点是单元测试变得有点笨重。EF仅适用于与UI相关的内容、简单的CRUD和报告生成,但对于服务器上的计算绝对是一个坏主意。 - Akash Kava
@AkashKava,你的解决方案不可行,因为INSERT SELECT和UPDATE SELECT不是原子操作。它们被执行为子查询,然后进行插入操作,在这两个操作之间,另一个插入操作可能会发生(并最终会)破坏你的数据模型,多次分配相同的资源。 - Jesper
4个回答

3

我认为选项2更好,需要进行一些微调。

如果我需要处理这种情况,以下是我会采取的措施:

  1. 每当用户尝试在某个时间段预订车辆时,我会在临时保留区域(正常表格即可,但如果您有更高的交易量,可以考虑使用缓存数据库解决方案)中进行记录。该记录应包含唯一键,由唯一的汽车ID + 时间段组成,并且不允许重复输入。如果两个用户尝试在同一时间为同一汽车预订相同时间段,则将在您的应用程序中获得错误,因此您可以通知其他用户货车已经被预订了。
  2. 因此,在第二个用户尝试预订车辆之前,用户必须检查该时间段内该车辆的锁定情况。(或者您可以使用此数据显示该时间段内车辆的不可用性)。

这与我最终采取的路线非常相似,我构建了一个轻量级应用程序级缓存,并保留了一组GUID来唯一标识我正在处理的项目的生命周期,然后在退出时从列表中删除这些项目。它有效地维护了黑名单项目的生命周期,直到请求结束,任何对这些相同项目的其他请求都会被阻止,直到锁定被释放(或超时)。 - Mordy

0
我不确定你的数据库布局如何,但如果每个库存项目都是数据库中的单独记录,只需在表格上添加一个IsUsed标志。当你要更新记录时,只需确保将IsUsed = 0作为where子句的一部分。如果返回的total modified为0,则说明有其他东西在你之前更新了它。

问题在于检查库存是否可用和声明库存之间可能发生的竞争条件。如果第二个进程在这两个点之间插入,它将看到第一个进程声明之前的可用库存,然后它也会尝试声明它。此时,“IsUsed”尚未更新为true,因此第二个进程仍然看到未声明的项目。 - Mordy
啊,我明白了。你需要做一个存储过程或事务,在更新之后再选择它,以确保在此之前没有其他东西抓取它。 - Daniel Lorenz
事务或存储过程本身并不能保证选择和随后的插入是原子性执行的。 - Jesper

0

对于这个问题有不同的方法,我只是回答了我在为客户解决这个问题时所考虑和最终采用的方法。

1. 如果在这些资源上的插入和更新操作的流量不大,你可以通过像下面这样的存储过程完全锁定表,但这也可以在简单的客户端代码中完成:

CREATE PROCEDURE ...
AS
BEGIN
  BEGIN TRANSACTION

  -- lock table "a" till end of transaction
  SELECT ...
  FROM a
  WITH (TABLOCK, HOLDLOCK)
  WHERE ...

  -- do some other stuff (including inserting/updating table "a")



  -- release lock
  COMMIT TRANSACTION
END

2. 通过让您的代码获取您自己创建的锁来使用悲观锁。在要锁定的资源类型上添加额外的表格或资源,并在要锁定的资源的ID上设置唯一约束条件。然后,通过尝试插入一行来获取锁,并通过删除它来释放锁。放置时间戳以便您可以有一个任务来清理丢失的锁。该表格可能如下所示:

Id         bigint
BookingId  bigint        -- the resource you want to lock on. Put a unique constrain here
Creation   datetime      -- you can use these 2 timestamps to decide when to automatically remove a lock
Updated    datetime
Username   nvarchar(100) -- maybe who obtained the lock?

采用这种方法,您可以轻松决定哪些代码需要获取锁,以及哪些代码可以在没有锁的情况下读取您的资源和预订表。

3. 如果这是一个按开始时间和结束时间分配的资源,则可以将此时间跨度的粒度设置为例如15分钟。每天的15分钟时间段将从0开始编号。然后,您可以在预订表旁边创建一张表,其中开始和结束时间戳现在由时间段的数字组成。选择一个合理的起始时间戳作为数字0。然后,您将插入与所需的每个预订的不同时间段号码相对应的行数。当然,您需要在“时间段”+“资源ID”上具有唯一约束,以便如果该时间段已经被预订,任何插入都将被拒绝。 可以在预订表上的触发器中很好地更新此表,以便仍然可以在预订表上获得真实的时间戳,并且在执行插入或更新时,可以更新时间段表,并且如果违反唯一约束,则会引发错误,从而回滚事务并防止两个表中的更改。


0
如果您在数据库中有一个存储车辆的表,那么您可以对用户选择的车位上的车辆进行悲观无等待锁定。
一旦获得此锁定,该事务将持有该锁定直到提交或回滚。如果所有其他事务尝试获取车辆上的锁定,则会立即失败。因此,在数据库中没有等待事务的情况下,这将是可扩展的。
对于失败的事务,您可以立即回滚它们并要求用户选择不同的车辆或车位。
现在,如果您有多辆相同类型的车辆,并且您有机会将相同的车辆分配给两个用户,即在同一车位上具有相同的注册号码,则也适用此规则。因为只有一个事务会成功,其他事务都会失败。
以下是此操作的PostgreSQL查询:
SELECT *
FROM   vehicle
WHERE  id = ?
FOR UPDATE nowait

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接