防止同时选择相同的下一个计算值
Prevent concurrent selection of same next calculated value
我的数据库中有一个名为 SerialNumber
的 table,如下所示:
[Id] [bigint] IDENTITY(1,1) NOT NULL,
[WorkOrderId] [int] NOT NULL,
[SerialValue] [bigint] NOT NULL,
[SerialStatusId] [int] NOT NULL
在我的应用程序中,对于给定的工单 (WorkOrderId
),我总是需要获得 MIN
SerialValue
的 SerialStatusId
为 1。
也就是说,如果一个客户 select 编辑了某个序列号,它应该立即更改为 SerialStatusId =2
,这样下一个客户就不会 select 相同的值。
该应用程序安装在可能同时访问数据库的多个站点上。
我尝试了几种方法来解决这个问题但没有成功:
update SerialNumber
set SerialStatusId = 2
output inserted.SerialValue
where WorkOrderId = @wid AND
SerialValue = (select MIN(SerialValue) FROM SerialNumber where SerialStatusId = 1)
和
declare @val bigint;
set @val = (select MIN(SerialNumber.SerialValue) from SerialNumber where SerialStatusId = 1
and WorkOrderId = @wid);
select SerialNumber.SerialValue from SerialNumber where SerialValue = @val;
update SerialNumber set SerialStatusId = 2 where SerialValue = @val;
我测试的方法是 select 不相同的值是:
public void Test_GetNext()
{
Task t1 = Task.Factory.StartNew(() => { getSerial(); });
Task t2 = Task.Factory.StartNew(() => { getSerial(); });
Task t3 = Task.Factory.StartNew(() => { getSerial(); });
Task t4 = Task.Factory.StartNew(() => { getSerial(); });
Task t5 = Task.Factory.StartNew(() => { getSerial(); });
Task.WaitAll(t1, t2, t3, t4, t5);
var duplicateKeys = serials.GroupBy(x => x)
.Where(group => group.Count() > 1)
.Select(group => group.Key).ToList();
}
private List<long> serials = new List<long>();
private void getSerial()
{
object locker = new object();
SerialNumberRepository repo = new SerialNumberRepository();
while (serials.Count < 200)
{
lock (locker)
{
serials.Add(repo.GetNextSerialWithStatusNone(workOrderId));
}
}
}
到目前为止,我所做的所有测试都从包含 200 个连续剧的 table 中给出了约 70 个重复值。
我怎样才能改进我的 SQL 声明,使其不会 return 重复值?
更新
试图实现@Chris 建议的内容,但仍然得到重复的数量:
SET XACT_ABORT, NOCOUNT ON
DECLARE @starttrancount int;
BEGIN TRY
SELECT @starttrancount = @@TRANCOUNT
IF @starttrancount = 0 BEGIN TRANSACTION
update SerialNumber
set SerialStatusId = 2
output deleted.SerialValue
where WorkOrderId = 35 AND
SerialValue = (select MIN(SerialValue) FROM SerialNumber where SerialStatusId = 1)
IF @starttrancount = 0
COMMIT TRANSACTION
ELSE
EXEC sp_releaseapplock 'get_code';
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 AND @starttrancount = 0
ROLLBACK TRANSACTION
RAISERROR (50000,-1,-1, 'mysp_CreateCustomer')
END CATCH
您得到重复项的原因是因为在读取前一个值和更新新值到 SerialNumber
之间存在 race condition。如果两个并发连接(例如两个不同的线程)同时读取相同的值,它们将都派生相同的新 SerialNumber
。第二个连接也会冗余地再次更新状态。
更新的解决方案 - 前一个导致死锁
要使用您当前的设计解决此问题,在读取 的下一个值时,您需要预先锁定 table 中的行以防止其他并行读取器。
CREATE PROC dbo.GetNextSerialLocked(@wid INT)
AS
BEGIN
BEGIN TRAN
DECLARE @NextAvailableSerial BIGINT;
SELECT @NextAvailableSerial = MIN(SerialValue)
FROM SerialNumber WITH (UPDLOCK, HOLDLOCK) where SerialStatusId = 1;
UPDATE SerialNumber
SET SerialStatusId = 2
OUTPUT inserted.SerialValue
WHERE WorkOrderId = @wid AND SerialValue = @NextAvailableSerial;
COMMIT TRAN
END
GO
由于获取 + 更新现在分为多个语句,我们确实需要一个事务来控制 HOLDLOCK
的生命周期。
您还可以将事务隔离级别提高到 SERIALIZABLE
- 虽然这不会阻止并行读取,但是当第二个写入器也尝试写入 [=52] 时它会导致死锁=], 假定读取的值现在已更改。
您也可以考虑替代设计以避免以这种方式获取数据,例如如果序列号应该递增,请考虑 IDENTITY
.
另请注意,您的 C# 代码锁定无效,因为每个 Task
将有一个独立的 locker
对象,并且永远不会争用同一锁。
object locker = new object(); // <-- Needs to be shared between Tasks
SerialNumberRepository repo = new SerialNumberRepository();
while (serials.Count < 200)
{
lock (locker) <-- as is, no effect.
此外,添加连续出版物时,您需要在 serials
集合周围设置内存屏障。最简单的可能只是将其更改为并发集合:
private ConcurrentBag<long> serials = new ConcurrentBag<long>();
如果你想从 C# 序列化访问,你需要在所有任务/线程中使用一个共享的 static object
实例(显然从代码序列化需要所有对序列号的访问都来自同样的过程)。在数据库中序列化更具可扩展性,因为您还可以为不同的 WorkOrderId
设置多个并发读取器
看看这个问题的答案:TSQL mutual exclusive access in a stored procedure。
您需要确保对 table 的互斥访问,您目前只能依靠运气(正如您所见,这行不通)。
我的数据库中有一个名为 SerialNumber
的 table,如下所示:
[Id] [bigint] IDENTITY(1,1) NOT NULL,
[WorkOrderId] [int] NOT NULL,
[SerialValue] [bigint] NOT NULL,
[SerialStatusId] [int] NOT NULL
在我的应用程序中,对于给定的工单 (WorkOrderId
),我总是需要获得 MIN
SerialValue
的 SerialStatusId
为 1。
也就是说,如果一个客户 select 编辑了某个序列号,它应该立即更改为 SerialStatusId =2
,这样下一个客户就不会 select 相同的值。
该应用程序安装在可能同时访问数据库的多个站点上。
我尝试了几种方法来解决这个问题但没有成功:
update SerialNumber
set SerialStatusId = 2
output inserted.SerialValue
where WorkOrderId = @wid AND
SerialValue = (select MIN(SerialValue) FROM SerialNumber where SerialStatusId = 1)
和
declare @val bigint;
set @val = (select MIN(SerialNumber.SerialValue) from SerialNumber where SerialStatusId = 1
and WorkOrderId = @wid);
select SerialNumber.SerialValue from SerialNumber where SerialValue = @val;
update SerialNumber set SerialStatusId = 2 where SerialValue = @val;
我测试的方法是 select 不相同的值是:
public void Test_GetNext()
{
Task t1 = Task.Factory.StartNew(() => { getSerial(); });
Task t2 = Task.Factory.StartNew(() => { getSerial(); });
Task t3 = Task.Factory.StartNew(() => { getSerial(); });
Task t4 = Task.Factory.StartNew(() => { getSerial(); });
Task t5 = Task.Factory.StartNew(() => { getSerial(); });
Task.WaitAll(t1, t2, t3, t4, t5);
var duplicateKeys = serials.GroupBy(x => x)
.Where(group => group.Count() > 1)
.Select(group => group.Key).ToList();
}
private List<long> serials = new List<long>();
private void getSerial()
{
object locker = new object();
SerialNumberRepository repo = new SerialNumberRepository();
while (serials.Count < 200)
{
lock (locker)
{
serials.Add(repo.GetNextSerialWithStatusNone(workOrderId));
}
}
}
到目前为止,我所做的所有测试都从包含 200 个连续剧的 table 中给出了约 70 个重复值。
我怎样才能改进我的 SQL 声明,使其不会 return 重复值?
更新
试图实现@Chris 建议的内容,但仍然得到重复的数量:
SET XACT_ABORT, NOCOUNT ON
DECLARE @starttrancount int;
BEGIN TRY
SELECT @starttrancount = @@TRANCOUNT
IF @starttrancount = 0 BEGIN TRANSACTION
update SerialNumber
set SerialStatusId = 2
output deleted.SerialValue
where WorkOrderId = 35 AND
SerialValue = (select MIN(SerialValue) FROM SerialNumber where SerialStatusId = 1)
IF @starttrancount = 0
COMMIT TRANSACTION
ELSE
EXEC sp_releaseapplock 'get_code';
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 AND @starttrancount = 0
ROLLBACK TRANSACTION
RAISERROR (50000,-1,-1, 'mysp_CreateCustomer')
END CATCH
您得到重复项的原因是因为在读取前一个值和更新新值到 SerialNumber
之间存在 race condition。如果两个并发连接(例如两个不同的线程)同时读取相同的值,它们将都派生相同的新 SerialNumber
。第二个连接也会冗余地再次更新状态。
更新的解决方案 - 前一个导致死锁
要使用您当前的设计解决此问题,在读取 的下一个值时,您需要预先锁定 table 中的行以防止其他并行读取器。
CREATE PROC dbo.GetNextSerialLocked(@wid INT)
AS
BEGIN
BEGIN TRAN
DECLARE @NextAvailableSerial BIGINT;
SELECT @NextAvailableSerial = MIN(SerialValue)
FROM SerialNumber WITH (UPDLOCK, HOLDLOCK) where SerialStatusId = 1;
UPDATE SerialNumber
SET SerialStatusId = 2
OUTPUT inserted.SerialValue
WHERE WorkOrderId = @wid AND SerialValue = @NextAvailableSerial;
COMMIT TRAN
END
GO
由于获取 + 更新现在分为多个语句,我们确实需要一个事务来控制 HOLDLOCK
的生命周期。
您还可以将事务隔离级别提高到 SERIALIZABLE
- 虽然这不会阻止并行读取,但是当第二个写入器也尝试写入 [=52] 时它会导致死锁=], 假定读取的值现在已更改。
您也可以考虑替代设计以避免以这种方式获取数据,例如如果序列号应该递增,请考虑 IDENTITY
.
另请注意,您的 C# 代码锁定无效,因为每个 Task
将有一个独立的 locker
对象,并且永远不会争用同一锁。
object locker = new object(); // <-- Needs to be shared between Tasks
SerialNumberRepository repo = new SerialNumberRepository();
while (serials.Count < 200)
{
lock (locker) <-- as is, no effect.
此外,添加连续出版物时,您需要在 serials
集合周围设置内存屏障。最简单的可能只是将其更改为并发集合:
private ConcurrentBag<long> serials = new ConcurrentBag<long>();
如果你想从 C# 序列化访问,你需要在所有任务/线程中使用一个共享的 static object
实例(显然从代码序列化需要所有对序列号的访问都来自同样的过程)。在数据库中序列化更具可扩展性,因为您还可以为不同的 WorkOrderId
看看这个问题的答案:TSQL mutual exclusive access in a stored procedure。
您需要确保对 table 的互斥访问,您目前只能依靠运气(正如您所见,这行不通)。