单个语句的原子性
我认为您的代码还不错。即因为您有一条语句,该语句在语句运行后立即将状态更新为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 2 在Thread 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)
--...
)