【问题标题】:SQL Threadsafe UPDATE TOP 1 for FIFO QueueFIFO 队列的 SQL Threadsafe UPDATE TOP 1
【发布时间】:2018-03-17 15:00:14
【问题描述】:

我正在准备一张发票表,然后准备打印。

[STATUS] 列是草稿,打印,打印,打印

我需要获取要打印的第一个 (FIFO) 记录的 ID,并更改记录状态。该操作必须是线程安全的,以便另一个进程不会选择相同的InvoiceID

我可以这样做吗(对我来说看起来很原子,但也许不是......):

1:

WITH CTE AS
(
    SELECT TOP(1) [InvoiceID], [Status]
    FROM    INVOICES
    WHERE   [Status] = 'Print'
    ORDER BY [PrintRequestedDate], [InvoiceID] 
)
UPDATE CTE
SET [Status] = 'Printing'
    , @InvoiceID = [InvoiceID]

...使用@InvoiceID 执行操作...

UPDATE  INVOICES
SET [Status] = 'Printed'
WHERE   [InvoiceID] = @InvoiceID

或者我必须使用这个(用于第一条语句)

2:

UPDATE INVOICES
SET    [Status] = 'Printing'
    , @InvoiceID = [InvoiceID]
WHERE  [InvoiceID] = 
(
    SELECT TOP 1 [InvoiceID]
    FROM    INVOICES WITH (UPDLOCK)
    WHERE   [Status] = 'Print'
    ORDER BY [PrintRequestedDate], [InvoiceID] 
)

...使用@InvoiceID ...等执行操作

(我无法将状态更改为“正在打印”的交易保持打开状态,直到流程结束,即状态最终更改为“已打印”时)。

编辑:

如果重要的话,数据库是READ_COMMITTED_SNAPSHOT

可以为将 UPDATE STATUS 更新为“Printing”并获取 ID 进行交易。但我无法继续保持交易开放,直至将状态更改为“已打印”。这是一份 SSRS 报告,它对 SQL 进行了几个不同的查询以获取发票的各个位,它可能会崩溃/无论如何,使交易保持打开状态。

@Gordon Linoff“如果你想要一个队列”FIFO 序列并不重要,我只想先打印首先请求的发票......“或多或少”(不想要任何不必要的复杂性。 ..)

@Martin Smith“看起来像一个普通的表作为队列要求” - 是的,正是如此,感谢非常有用的链接。

解决方案:

我采用的方案来自cmets:

@lad2025 将我指向SQL Server Process Queue Race Condition,它使用了WITH (ROWLOCK, READPAST, UPDLOCK),@MartinSmith 解释了隔离问题是什么,并将我指向了Using tables as Queues——这正是我想要做的事情。

我还没有理解为什么UPDATE TOP 1 是安全的,而UPDATE MyTable SET xxx = yyy WHERE MyColumn = (SELECT TOP 1 SomeColumn FROM SomeTable ORDER BY AnotherColumn)(没有隔离提示)不是,我应该自我教育,但我很高兴将隔离提示放在我的代码中并继续用别的东西:)

感谢大家的帮助。

【问题讨论】:

  • SQL Server Process Queue Race Condition - WITH (ROWLOCK, READPAST, UPDLOCK)
  • 原子性并不能保证您的想法。它只是意味着该语句完全提交或完全回滚。锁定提示控制隔离。它可以防止数据在被读取和更新或被两个并发进程读取之间发生更改,并且比使用隔离级别执行此操作更不生硬 - 实现更高的并发性。
  • 否 - 问题是如果没有默认隔离级别的锁定提示,两个线程都可以使用[Status] = 'Print' 标识同一行,因为共享锁不冲突,然后都依次进行更新。在默认读取提交级别,SQL Server 可以获取 S 锁,然后在读取行后立即释放它们。 UPDLOCK 意味着两个并发线程将无法在同一行中取出它。
  • @JohnLBevan 是的,InvoiceID 是唯一的(我试图暗示这一点,我应该说出来)。 “我假设没有其他进程正在运行,可以将打印记录的状态修改为打印或继续打印;” ... 好点子。是的,你是对的......但是......用户可以将状态更改为REPRINT,这可能会在打印过程中发生。如果 SSRS 报告失败,则记录将保留在 PRINTING,因此用户需要对其进行排序(b 将状态更改为 REPRINT),但显然我需要注意一些碰撞风险。跨度>
  • 仅供参考:由于这个评论帖子让我害怕认为我的知识可能有问题,所以我在这里开始了另一个关于具体问题的帖子:stackoverflow.com/questions/49341943/…

标签: sql-server


【解决方案1】:

我担心会重复 [InvoiceID]
同一个 [InvoiceID] 的多个打印请求

在第一次更新 ONE 行得到set [Status] = 'Printing'

在第二次更新时,所有 [InvoiceID] 行都会得到 set [Status] = 'Printed'
这甚至会设置 status = 'draft' 的行

也许这就是你想要的

另一个进程可能会在 set [Status] = 'Print' 之前获取相同的 [InvoiceID]

所以有些重复会打印,有些不会

我和 cmets 一起使用 update lock

这是不确定的,但您可以直接使用 top (1) 并跳过 order by。您将倾向于获得最新的行,但不能保证。如果您清除队列,那么您将获得所有队列。

这表明你可以输掉 'draft' = 1

declare @invID int; 
declare @T table (iden int identity primary key, invID int, status tinyint);
insert into @T values (1, 2), (5, 1), (3, 1), (4, 1), (4, 2), (2, 1), (1, 1), (5, 2), (5, 2);
declare @iden int;
select * from @t order by iden;

declare @rowcount int = 1; 
while (@ROWCOUNT > 0)
    begin
        update top (1) t 
        set t.status = 3, @invID = t.invID,  @iden = t.iden
        from @t t 
        where t.status = '2';
        set @rowcount = @@ROWCOUNT;
        if(@rowcount > 0)
            begin 
                select @invID, @iden;
                -- do stuff  
                update t 
                set t.status = 4
                from @t t
                where t.invID = @invID; -- t.iden = @iden;
                select * from @T order by iden;
            end
    end

【讨论】:

  • 注意:set status = 'Printed' 有一个where 条件:WHERE [InvoiceID] = @InvoiceID
  • @JohnLBevan 我知道确实如此。你想说啥?你测试过这个吗?
  • 我在评论您的观点:“在第二次更新中,所有 [InvoiceID] 行都设置为 [Status] = 'Printed' 这甚至会设置状态 = 'draft' 的行”。但是,也许我们做出了不同的假设……我假设InvoiceId 在该表中是唯一的。即根据我的假设,您所说的内容无关紧要/令人困惑;但如果你假设它不是唯一的,那么你有道理......
  • 你为什么认为它是独一无二的?这是一个打印队列。
  • 是的,InvoiceID 是唯一的。抱歉,应该在 O/P 中这么说。按日期排序的原因是,它根据发票设置为打印状态的时间作为 FIFO 运行,而不是在第一次创建发票记录时作为草稿,并且当时在 InvoiceID 中给定 IDENTITY。抱歉,我使用示例列名是为了暗示这一点,但下次我将在 O/P 中明确解释
【解决方案2】:

单个语句的原子性

我认为您的代码还不错。即因为您有一条语句,该语句在语句运行后立即将状态更新为printing,因此状态会更新;因此,在您的进程看到之前,搜索print 之前运行的任何内容都会将同一记录更新为printing;所以你的进程会选择一个后续记录,或者在你的语句运行后命中它的任何进程都会将其视为printing 所以不会选择它。确实没有记录可以在语句运行时提取它的情况,因为正如所讨论的,单个 SQL 语句应该是原子的。

免责声明

也就是说,我还不足以说明确的锁定提示是否会有所帮助;在我看来,它们不是必需的,因为上述内容是原子的,但 cmets 中的其他人可能比我更了解情况。但是,运行测试(尽管数据库和两个线程都在同一台机器上运行)我无法创建竞争条件......也许如果客户端在不同的机器上/如果有更多的并发,你会更有可能发现问题。

我希望其他人对您的问题有不同的解释,因此存在分歧。

试图反驳自己

这是我用来尝试引起竞争条件的代码;您可以将其放入LINQPad 5,选择语言C# Program,根据需要调整连接字符串(以及可选的任何语句),然后运行:

const long NoOfRecordsToTest = 1000000;
const string ConnectionString = "Server=.;Database=Play;Trusted_Connection=True;";  //assumes a database called "play"
const string DropFifoQueueTable = @"
    if object_id('FIFOQueue') is not null 
    drop table FIFOQueue";
const string CreateFifoQueueTable = @"
    create table FIFOQueue 
    (
        Id bigint not null identity (1,1) primary key clustered
        , Processed bit default (0) --0=queued, null=processing, 1=processed
    )";
const string GenerateDummyData = @"
    with cte as
    (
        select 1 x
        union all
        select x + 1
        from cte
        where x < @NoRowsToGenerate
    )
    insert FIFOQueue(processed)
    select 0
    from cte
    option (maxrecursion 0)
    ";
const string GetNextFromQueue = @"
    with singleRecord as
    (
        select top (1) Id, Processed
        from FIFOQueue --with(updlock, rowlock, readpast) --optionally include this per comment discussions
        where processed = 0
        order by Id
    )
    update singleRecord
    set processed = null
    output inserted.Id";
//we don't really need this last bit for our demo; I've included in case the discussion turns to this..
const string MarkRecordProcessed = @"
    update FIFOQueue
    set Processed = 1
    where Id = @Id";
void Main()
{
    SetupTestDatabase();

    var task1 = Task<IList<long>>.Factory.StartNew(() => ExampleTaskForced(1));
    var task2 = Task<IList<long>>.Factory.StartNew(() => ExampleTaskForced(2));
    Task.WaitAll(task1, task2);
    foreach (var processedByBothThreads in task1.Result.Intersect(task2.Result))
    {
        Console.WriteLine("Both threads processed id: {0}", processedByBothThreads);
    }
    Console.WriteLine("done");
}

static void SetupTestDatabase()
{
    RunSql<int>(new SqlCommand(DropFifoQueueTable), cmd => cmd.ExecuteNonQuery());
    RunSql<int>(new SqlCommand(CreateFifoQueueTable), cmd => cmd.ExecuteNonQuery());
    var generateData = new SqlCommand(GenerateDummyData);
    var param = generateData.Parameters.Add("@NoRowsToGenerate",SqlDbType.BigInt);
    param.Value = NoOfRecordsToTest;
    RunSql<int>(generateData, cmd => cmd.ExecuteNonQuery());
}

static IList<long> ExampleTaskForced(int threadId) => new List<long>(ExampleTask(threadId)); //needed to ensure prevent lazy loadling from causing issues with our tests
static IEnumerable<long> ExampleTask(int threadId)
{
    long? x;
    while ((x = ProcessNextInQueue(threadId)).HasValue)
    {
        yield return x.Value;
    }
    //yield return 55; //optionally return a fake result just to prove that were there a duplicate we'd catch it
}

static long? ProcessNextInQueue(int threadId)
{
    var id = RunSql<long?>(new SqlCommand(GetNextFromQueue), cmd => (long?)cmd.ExecuteScalar());
    //Debug.WriteLine("Thread {0} is processing id {1}", threadId, id?.ToString() ?? "[null]"); //if you want to see how we're doing uncomment this line (commented out to improve performance / increase the likelihood of a collision

    /* then if we wanted to do the second bit we could include this
    if(id.HasValue) {
        var markProcessed = new SqlCommand(MarkRecordProcessed);
        var param = markProcessed.Parameters.Add("@Id",SqlDbType.BigInt);
        param.Value = id.Value;
        RunSql<int>(markProcessed, cmd => cmd.ExecuteNonQuery());
    }
    */

    return id;
}

static T RunSql<T>(SqlCommand command, Func<SqlCommand,T> callback)
{
    try
    {
        using (var connection = new SqlConnection(ConnectionString))
        {    
            command.Connection = connection;
            command.Connection.Open();
            return (T)callback(command);
        }
    }
    catch (Exception e)
    {
        Debug.WriteLine(e.ToString());
        throw;
    }
}

其他cmets

上面的讨论实际上只讨论了多个线程从队列中获取下一条记录,同时避免任何单个记录被多个线程拾取。还有几点...

SQL 之外的竞争条件

根据我们的讨论,如果 FIFO 是强制性的,那么还有其他事情需要担心。即,虽然您的线程将按顺序拾取每条记录,但这取决于它们。例如Thread 1 获取记录 10 然后 Thread 2 获取记录 11。现在Thread 2Thread 1 发送记录10 之前将记录11 发送到打印机。如果他们要使用同一台打印机,您的打印件就会出现故障。如果它们是不同的打印机,那不是问题;任何打印机上的所有打印都是连续的。我假设是后者。

异常处理

如果正在处理某事的线程中发生任何异常(即线程的记录为printing),则应考虑如何处理此问题。一种选择是保持该线程重试;尽管如果这是一些根本性的错误,那可能是不确定的。另一种是将记录置于某个error 状态以由另一个进程处理/接受该记录不会按顺序出现的情况。最后,如果队列中的发票顺序是理想的而不是硬性要求,您可以让拥有线程将状态放回print,以便它或另一个线程可以获取该记录以重试(尽管再次,如果记录存在根本问题,这可能会阻塞队列)。

我在这里的推荐是error 状态;这样您就可以更清楚地了解问题/可以使用另一个流程来处理问题。

崩溃处理

另一个问题是,因为您对printing 的更新未保存在事务中,如果服务器崩溃,您会在数据库中保留此状态的记录,并且当您的系统重新联机时,它会被忽略。避免这种情况的方法是包含一个列,说明哪个线程正在处理它;这样当系统重新启动时,该线程可以从它停止的地方恢复,或者包含一个日期戳,以便在一段时间后任何状态为printing的记录可以被清除/重置为@987654342 @ 或 Print 状态(根据需要)。

WITH CTE AS
(
    SELECT TOP(1) [InvoiceID], [Status], [ThreadId]
    FROM    INVOICES
    WHERE   [Status] = 'Print'
    OR     ([Status] = 'Printing' and [ThreadId] = @ThreadId) --handle previous crash
    ORDER BY [PrintRequestedDate], [InvoiceID] 
)
UPDATE CTE
SET [Status] = 'Printing'
, [ThreadId] = @ThreadId
OUTPUT Inserted.[InvoiceID]

了解其他流程

我们主要关注印刷元素;但其他进程也可能与您的Invoices 表进行交互。我们大概可以假设,除了创建初始的Draft 记录并在准备好打印后将其更新为Print 之外,这些进程不会触及Status 字段。但是,相同的记录可能会被完全不相关的进程锁定。如果我们想确保 FIFO,我们不能使用 ReadPast 提示,因为某些记录可能具有状态 Print 但已被锁定,因此尽管它们具有较早的 PrintRequestedDate,但我们将跳过它们。但是,如果我们希望尽可能快地打印内容,并且在不方便的情况下使它们井井有条,包括ReadPast 将允许我们的打印过程跳过锁定的记录并继续,一旦它们回来处理它们发布。

同样,另一个进程可能会在我们的记录处于Printing 状态时锁定它,因此我们无法更新它以将其标记为完成。同样,如果我们想避免这种情况导致阻塞,我们可以使用ThreadId 列来允许我们的线程将记录留在状态Printing 并稍后在它没有锁定时回来清理它。显然,这假设ThreadId 列仅由我们的打印过程使用。

有一个专门的打印队列表

为避免一些无关流程锁定发票的问题,请将Status 字段移到其自己的表中;所以你只需要从invoices表中读取;不更新。

这还有一个好处是(如果您不关心打印历史记录)您可以在完成后删除记录,因此您将获得更好的性能(因为您不必搜索整个发票表找到那些准备好打印的)。也就是说,这个选项还有另一种选择(如果您使用的是 SQL2008 或更高版本)。

使用过滤索引

由于 Status 列将被更新多次,因此它不是索引的理想选择;即随着状态的进展,索引中记录的位置从一个分支跳转到另一个分支。 但是,由于我们正在对其进行过滤,因此拥有索引也会真正受益。 为了解决这个矛盾,一个选择是使用过滤索引;即仅索引我们对打印过程感兴趣的那些记录;所以我们维护一个小的索引以获得大的好处。

create nonclustered index ixf_Invoices_PrintStatusAndDate  
on dbo.Invoices ([Status], [PrintRequestedDate])
include ([InvoiceId]) --just so we don't have to go off to the main table for this, but can get all we need form the index
where [Status] in ('Print','Printing') 

使用“枚举”/参考表

我怀疑您的示例使用字符串来保持演示代码的简单性,但为了完整起见,将其包括在内。 在数据库中使用字符串会使事情变得难以支持。与其将 status 设为字符串值,不如使用相关 Statuses 表中的 ID。

create table Statuses
(
    ID smallint not null primary key clustered --not identity since we'll likely mirror this enum in code / may want to have defined ids
    ,Name
)   
go
insert Statuses 
values 
(0, 'Draft')
, (1,'Print')
, (2,'Printing')
, (3,'Printed')

create table Invoices 
(
    --...
    , StatusId smallint foreign key references Statuses(Id)
    --...
)

【讨论】:

  • 谢谢。"SQL 语句可以这样简化" 我的理解是两个(竞争)进程可以选择和更新同一行:((这就是我问这个问题的原因,在为了确定这一点,以及隔离对于防止这种情况至关重要)。我也认为我会发现很难模拟最坏情况的比赛条件。我认为,总的来说,使用它并不麻烦隔离提示,所以我会采用这种方法。
  • @paparazzo 好点;我犯了一个错误,错过了/似乎这确实使我的 SQL 无效,所以上面的测试是错误的......我会修改并重新运行。
  • @Kristen 有道理。另外,请忽略我建议的优化,因为我错过了狗仔队评论的订单。
  • ps。 @Kristen 包含 readpast 提示有一个潜在的缺点......如果其他东西正在锁定 'Print' 记录(可能其他一些进程具有包含它们的页面锁),那么该语句将跳过它们并处理下一个可用记录,直到它们的锁被释放......所以取决于 Invoices 表是否仅用作打印队列,或者它是否是更大系统的一部分(即,在前者的情况下,这种情况不会发生;因为后者可能会有很多事情锁定这些记录)。还取决于您对订购打印的关心程度
  • "那么该语句将跳过它们并处理下一个可用记录,直到它们的锁被释放" 谢谢。幸运的是,顺序并不重要,我只想尝试按 FIFO 顺序打印它们。否则,如果有大量积压,早期的可能会延迟发送。
猜你喜欢
  • 1970-01-01
  • 2020-03-27
  • 2023-03-11
  • 1970-01-01
  • 2020-01-25
  • 2023-04-03
  • 2017-04-12
  • 2019-04-11
  • 1970-01-01
相关资源
最近更新 更多