用于FIFO队列的SQL Threadsafe UPDATE TOP 1

时间:2018-03-17 15:00:15

标签: sql-server

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

[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和"打印"并获取ID。但我不能继续保持交易一直打开,将状态更改为" Printed"。这是一份SSRS报告,它对SQL进行了几次不同的查询以获取发票的各个部分,并且它可能会崩溃/无论如何都会使事务处于打开状态。

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

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

解决方案:

我采用的解决方案来自评论:

@ 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)(没有隔离提示)不是,我应该教育自己,但我很高兴只是提出隔离提示在我的代码中继续使用其他东西:)

感谢您的帮助。

2 个答案:

答案 0 :(得分:0)

我的担心是重复[InvoiceID]
同一[InvoiceID]

的多个打印请求

在第一次更新时,ONE行获取set [Status] = 'Printing'

在第二次更新时,所有[InvoiceID]行获得set [Status] = 'Printed'
这甚至可以设置status ='draft'

的行

也许这就是你想要的

另一个进程可以在set [Status] = 'Print'

之前选择相同的[InvoiceID]

因此会打印一些副本,有些则不会

我评论使用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

答案 1 :(得分:0)

单一陈述的原子性

我觉得你的代码很好。即因为你有一个语句,只要语句运行就将状态更新为printing,状态就会更新;因此,在您的流程看到之前,搜索print之前运行的任何内容都会将相同的记录更新为printing;因此,您的流程会选择一个后续记录,或者在您的语句运行后遇到它的任何进程都会将其视为printing,因此不会选择它。实际上并没有一个记录能够在语句运行时提取它的情况,因为单个SQL语句的讨论应该是原子的。

声明

那就是说,我还不足以说出明确的锁定提示是否会有所帮助;在我看来他们不需要,因为上面是原子的,但评论中的其他人可能比我更了解情况。但是,运行测试(虽然数据库和两个线程都在同一台机器上运行)我无法创建竞争条件......也许如果客户端在不同的机器上/如果有更多的并发性你&# 39; d更有可能看到问题。

我的希望是其他人以不同的方式解释你的问题,因此存在分歧。

尝试反驳自己

这是我曾经试图引起竞争条件的代码;您可以将其放入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;
    }
}

其他评论

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

SQL之外的竞争条件

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

异常处理

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

我的建议是error状态;因为那时你对这个问题有更多的了解/可以专门用另一个过程处理问题。

崩溃处理

另一个问题是,由于您对printing的更新未在交易中保留,如果服务器崩溃,您将在数据库中保留此状态的记录,并且当您的系统重新联机时忽略。避免这种情况的方法是包括一个列,说明哪个线程正在处理它;这样,当系统恢复时,该线程可以从中断处继续,或者包含一个日期戳,以便在一段时间后任何状态为printing的记录超时&#34;可以根据需要扫描/重置为ErrorPrint状态。

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但是被锁定,所以我们跳过它们,尽管它们具有较早的{{1 }}。但是,如果我们想要尽可能快地打印出来的东西,并且在没有不方便的情况下将它们整理好,包括PrintRequestedDate将允许我们的打印过程跳过锁定的记录并继续进行,一旦它们返回处理它们#39;重新发布。

同样,另一个进程可能会锁定我们在ReadPast状态时的记录,因此我们无法对其进行更新以将其标记为已完成。同样,如果我们想要避免这种情况导致持久性,我们可以使用Printing列来允许我们的线程将状态ThreadId的记录保留下来,并在以后的时候返回清理它&# 39;没有锁定。显然,这假设Printing列仅供我们的打印过程使用。

拥有专用的打印队列表

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

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

使用过滤索引

由于Status列会多次更新,因此它不是一个很好的索引候选者;即,随着状态的进展,索引中的记录位置从一个分支跳转到另一个分支。 但是,由于我们正在对其进行过滤,因此它也可以从索引中获益。 为了解决这个矛盾,可以选择使用过滤索引;即只索引我们对我们的印刷过程感兴趣的记录;因此我们维持一个小指数以获得巨大利益。

invoices

使用&#34;枚举&#34; /参考表

我怀疑你的示例使用字符串来保持演示代码的简单性,但是为了完整性而包括这一点。 在数据库中使用字符串会使事情难以支持。而不是使状态成为字符串值,而是使用相关状态表中的ID。

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')