大家好,比我聪明的人!我创建了一个队列表系统,但它似乎太简单,无法避免竞争条件。我是否遗漏了什么或者以下竞争条件安全吗?
模式
我有一张桌子,我们称之为ProductQueue
:
CREATE TABLE dbo.ProductQueue
(
SerialId BIGINT PRIMARY KEY,
QueuedDateTime DATETIME NOT NULL -- Only using this for reference, no functionality is tied to it
);
我有添加到队列的过程,称为AddToProductQueue
:
CREATE PROCEDURE dbo.AddToProductQueue (@SerialId BIGINT)
AS
BEGIN
INSERT INTO dbo.ProductQueue (SerialId, QueuedDateTime)
OUTPUT Inserted.SerialId
SELECT @SerialId, GETDATE();
END
我还有一个从队列中删除的过程,称为RemoveFromProductQueue
:
CREATE PROCEDURE dbo.RemoveFromProductQueue (@SerialId BIGINT)
AS
BEGIN
DELETE FROM dbo.ProductQueue
OUTPUT Deleted.SerialId
WHERE SerialId = @SerialId;
END
注意,对于源数据库/系统中的SerialId
a 来说是全局唯一的。Product
即,a 的两个实例Product
不可能具有相同的SerialId
。这就是数据库方面的范围。
工作流程
- 我有一个每小时运行的申请流程。
SerialIds
该进程从源系统获取变量列表。- 它迭代地调用其列表中
AddToProductQueue
每个的过程SerialId
。 - 如果该过程尝试插入表
SerialId
中ProductQueue
已存在的 ,则会引发主键冲突错误,并且应用程序进程会捕获该错误并跳过该错误SerialId
。 - 否则,该过程会成功地将其添加
SerialId
到ProductQueue
表中并将其返回给应用程序进程。 - 然后,应用程序进程将成功排队的添加
SerialId
到单独的列表中。 - 应用程序进程完成迭代所有要
SerialIds
入队的候选者列表后,它会迭代其成功排队的新列表,并在每个的单独线程SerialIds
中对它们进行外部工作。(这项工作与数据库无关。)SerialId
- 最后,当每个线程完成其外部工作时,该异步线程中的最后一步是通过调用该过程将其
SerialId
从表中删除。(请注意,会实例化一个新的数据库上下文对象,并为每个异步调用此过程创建一个新连接,因此它在应用程序端是线程安全的。)ProductQueue
RemoveFromProductQueue
附加信息
- 表上没有任何索引
ProductQueue
,并且表中的行数永远不会超过 1,000 行。(实际上,大多数时候它实际上只有几行。) - 相同的
SerialId
可以再次成为在应用程序进程的未来执行时被重新添加到队列表中的候选者。 - 没有安全措施可以阻止应用程序进程的第二个实例同时运行,无论是意外还是第一个实例运行时间超过 1 小时等。(这是我最关心的并发部分。)
- 队列表和过程所在的数据库(以及正在建立的连接)的事务隔离级别是默认隔离级别
Read Committed
。
潜在问题
- 应用程序进程的运行实例以未处理的方式崩溃,卡
SerialIds
在队列表中。这对于业务需求来说是可以接受的,我们计划提供异常报告来帮助我们手动修复这种情况。 - 应用程序进程同时执行多次,并
SerialIds
在其初始源列表中的实例之间获取一些相同的内容。我还无法想到这种情况的任何负面影响,因为排队过程是原子的,并且SerialIds
由于该原子排队过程,应用程序进程将处理的实际列表应该是独立的。我们并不关心应用程序进程的哪个实例实际处理每个进程SerialId
,只要SerialId
两个进程实例不同时处理相同的进程即可。
在这种情况下,您要求数据库引擎做的唯一一件事就是强制执行
PRIMARY KEY
. 当然,它会在所有条件和隔离级别下执行此操作。潜在的竞争条件都是数据库外部的,这里的考虑因素并不是真正的主题。
也就是说,我认为数据库参与竞争条件的唯一方法是添加候选序列 ID 的过程,并将其包装在数据库事务中,但您没有提到任何相关内容。
也许进程可能会以意想不到的方式使用数据库事务,例如,如果您使用的 ORM 在没有明确询问的情况下通过魔法完成了有用的事情。或者也许您正在使用隐式事务。
在这种情况下,应用程序实例 A 将开始在单个数据库事务中添加其(一长串)序列 ID。同时,应用程序实例 B(具有同样长的列表,至少包括 A 列表中存在的一个)在插入重叠序列 ID 时将被阻止(因为实例 A 尚未提交其事务)。
在一系列不幸的事件中,实例 A 将在被阻止的实例 B 执行其插入(朝向其列表的末尾)之前完成异步处理重复的序列 ID(朝向其列表的开头)。
在这种情况下,A 和 B 都会成功处理相同的序列 ID。
考虑到您所描述的流程概要,这似乎不太可能,但并非不可能。
我不确定为什么你的表的名称中有“队列”一词,但在我看来,你真正想要的是锁定机制。
直接回答你的问题:如果你不关心过时的锁,并且只要
INSERT
事务、处理和DELETE
事务处于因果关系(即在上一步报告成功之后按此顺序发生),我不认为没有看到任何双重处理的潜力。也就是说,以自动化方式解决您在“潜在问题”下列出的这两个问题的一种方法是实现锁定算法Redlock的单实例变体。
每个实例都有自己唯一的 ID(例如,一个 guid 或每次运行处理应用程序时生成的内容)
每当实例获取 a 时
SerialId
,它都会尝试获取它的锁:查询的输出(如果有)将是您锁定的序列 ID。如果没有输出,则说明您没有锁定。
将当前时间戳传入变量中
@now
,将未来五分钟的时间戳传入变量中@expires
运行一个保持活动的线程,每分钟或您需要的频率提交此查询,扩展变量中的值
@expires
,例如每次过去五分钟。您可以修改查询以扩展迄今为止在单个批次中尚未处理的锁。确保立即承诺。完成处理后,仅删除属于您的锁:
这边走:
当然,您可以只安装一个真实的 Redis 实例,并在您的应用程序中使用 Redlock 的现成实现,其中有很多。
我发现 Service Broker 完全不能令人满意,造成了很大的开销。对话需要清理。你的桌子会更好。
我还建议在主键后面的索引上允许行锁。
您所做的事情没有任何问题,但 SQL Server 已经有一个内置服务 Broker Services 来处理和管理数据队列。我谦虚地建议你投入时间。谢谢😀