Delia's Blog

菜鳥工程師小鈞的筆記

0%

EF Core 併發處理,樂觀鎖與悲觀鎖

前言

寫這篇文章的原因,是小妹幫公司做週年慶專案,實作搶優惠券小遊戲,前幾年的週年慶完全沒有做任何防止超賣處理,而我當時只知道C#的單機鎖 (lock) 語法,加上lock後在單機環境不管怎麼壓力測試都正常,上線卻還是發生了超賣問題,而且是最大獎啊!!!! 最後找出問題,原來是Azure Appservice Instance開到5台導致單機鎖沒用。

當時很緊急的找到Redis Redlock快速套用解決了問題,但似乎會有效能問題,好險活動瞬間流量沒有到太高,Redis沒有被打爆,另外也有上網問一些搶購解法,有大神問我為何DB沒鎖,才知道有樂觀鎖、悲觀鎖這個東西…真是丟臉,不過當下沒實際實驗過所以不敢用,現在就來親自實驗一次~

SQL併發模擬

執行以下測試資料表

CREATE TABLE Stock
(
Id INT IDENTITY(1,1) PRIMARY KEY,
ProductId VARCHAR(20),
Quantity INT,
UpdateTime DATETIME
)
Go

INSERT INTO Stock VALUES('P01',100,GETDATE())
INSERT INTO Stock VALUES('P02',100,GETDATE())
INSERT INTO Stock VALUES('P03',100,GETDATE())

開啟兩個查詢介面,使用最一般的SQL語法更新P01庫存,並將其中一筆SQL延遲時間設為5秒,並且先執行有延遲的SQL,再馬上執行沒延遲的SQL。

DECLARE @quantity int
DECLARE @productId varchar(20)
BEGIN TRAN
SET @productId = 'P01'
SELECT @quantity = Quantity FROM STOCK WHERE ProductId = @productId
WAITFOR DELAY '00:00:05'
UPDATE STOCK SET Quantity = @quantity -1 WHERE ProductId = @productId
COMMIT TRAN

結果發現庫存還是99,因為第二筆SQL讀進來的是100,所以兩筆執行UPDATE都是更新為99。

樂觀鎖 (Optimistic Concurrency)

允許多個SQL來操作 table;但樂觀並不代表不負責,通常會在 table 中增加一個 version 的欄位來做更新的確認。

首先給表加上一個timestamp欄位

ALTER TABLE Stock ADD TimeFlag TIMESTAMP NOT NULL

SQL語法加上TimeFlag當作版本號並加上where條件

DECLARE @quantity int
DECLARE @productId varchar(20)
DECLARE @flag timestamp --加上timeStamp當作版本號
BEGIN TRAN
SET @productId = 'P01'
SELECT @quantity = Quantity , @flag = TimeFlag FROM STOCK WHERE ProductId = @productId
WAITFOR DELAY '00:00:10'
UPDATE STOCK SET Quantity = @quantity -1
WHERE ProductId = @productId AND TimeFlag = @flag --多一個timeStamp條件
COMMIT TRAN

結果發現延遲執行SQL執行失敗

悲觀鎖 (Pessimistic Concurrency)

當一個SQL執行獲得悲觀鎖後,其他的 SQL無法對這個 data 進行修改,直到悲觀鎖被釋放後才能執行。

在SQL的SELECT後面加上Update Locks : WITH(UPDLOCK)

DECLARE @quantity int
DECLARE @productId varchar(20)
BEGIN TRAN
SET @productId = 'P01'
SELECT @quantity = Quantity FROM STOCK WITH(UPDLOCK) WHERE ProductId = @productId
WAITFOR DELAY '00:00:10'
UPDATE STOCK SET Quantity = @quantity -1 WHERE ProductId = @productId
COMMIT TRAN

執行延遲SQL再執行其他SQL時會等待鎖釋放,所以結果一定正確。


使用EF Core 處理併發問題

Github範例程式
https://github.com/DeliaHung/EFcore-Concurrency

EF core 文件
https://www.learnentityframeworkcore.com/configuration/fluent-api/isconcurrencytoken-method

Microsoft 文件
https://learn.microsoft.com/zh-tw/ef/core/saving/concurrency?tabs=data-annotations

模擬併發

建立ConsoleApp專案,建立測試用的Entity,模擬庫存控制。

public class EFstock
{
public int Id { get; set; }
public string ProductId { get; set; }
public int Quantity { get; set; }
}

public class EFstockConfiguration : IEntityTypeConfiguration<EFstock>
{
public void Configure(EntityTypeBuilder<EFstock> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).UseIdentityColumn(1,1);
}
}

建立Dbcontext,可設定將SQL結果輸出至console視窗

public class TestContext : DbContext
{
public DbSet<EFstock> EFstocks { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseSqlServer("Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=testLock;Integrated Security=True;Connect Timeout=30;Encrypt=False;Trust Server Certificate=False;Application Intent=ReadWrite;Multi Subnet Failover=False")
.LogTo((message) => Console.WriteLine($"【SQL Command : {message} \r\n"), LogLevel.Information);
}
protected override void OnModelCreating(ModelBuilder builder)
{
builder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());

base.OnModelCreating(builder);
}
}

開啟nuget控制台,使用code first產生資料表,
成功產出測試資料表,並新增測試資料

add-migration "first"
update-database
insert into [EFstocks] values('P01',110)

program.cs測試程式如下,開啟10個線程,每個線程執行更新10次,預期庫存會剩下10個。

using EFcoreConcurrency;
using Microsoft.EntityFrameworkCore;

var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
var task = Task.Run(() =>
{
for (int j = 0; j < 10; j++)
{
UpdateStock();
}
});
tasks.Add(task);
}

Task.WaitAll(tasks.ToArray());

foreach (Task t in tasks)
Console.WriteLine("Task {0} Status: {1}", t.Id, t.Status);

Console.WriteLine("Number of files read: {0}", tasks.Count);

//一般更新庫存
async Task UpdateStock()
{
using var dbContext = new TestContext();
var p = dbContext.EFstocks.FirstOrDefault(f => f.ProductId == "P01");
if (p.Quantity <= 0)
{
Console.WriteLine($"【{Task.CurrentId}】【庫存不足】");
return;
}
p.Quantity -= 1;
dbContext.SaveChanges();
Console.WriteLine($"【{Task.CurrentId}】【成功下單】");
}

驚! 全部 成功下單 ,但數量卻不是預期的剩下10個!!

EF Core 樂觀鎖測試

方案一:併發令牌

設定方式

public class EFstock
{
public int Id { get; set; }

public string ProductId { get; set; }

[ConcurrencyCheck]
public int Quantity { get; set; }
}

也可使用Fluent API 設定

public class EFstockConfiguration : IEntityTypeConfiguration<EFstock>
{
public void Configure(EntityTypeBuilder<EFstock> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).UseIdentityColumn(1,1);

//fluent API配置
builder.Property(t => t.Quantity).IsConcurrencyToken();
}
}

打開TextContext的LogTo方法,查看輸出視窗的SQL Command,發現where條件多了 Quantity = @p2

並且主程式跳出了DbUpdateConcurrencyException。

衝突處理

捕捉DbUpdateConcurrencyException,並重新執行更新直到成功。

using EFcoreConcurrency;
using Microsoft.EntityFrameworkCore;

var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
var task = Task.Run(() =>
{
for (int j = 0; j < 10; j++)
{
UpdateStockWithOptimistic();
}
});
tasks.Add(task);
}

Task.WaitAll(tasks.ToArray());

foreach (Task t in tasks)
Console.WriteLine("Task {0} Status: {1}", t.Id, t.Status);

Console.WriteLine("Number of files read: {0}", tasks.Count);


//樂觀鎖
async Task UpdateStockWithOptimistic()
{
var saved = false;
while (!saved)
{
try
{
using var dbContext = new TestContext();

var p = dbContext.EFstocks.FirstOrDefault(f => f.ProductId == "P01");

if (p.Quantity <= 0)
{
Console.WriteLine($"【{Task.CurrentId}】【庫存不足】");
break;
}
p.Quantity -= 1;

dbContext.SaveChanges();

saved = true;
Console.WriteLine($"【{Task.CurrentId}】【成功下單】");
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine($"【{Task.CurrentId}】【發生衝突!!!!!】");
Thread.Sleep(10);
}
}
}

庫存控制成功,剛好剩10個。

方案二:RowVersion

設定方式

public class EFstock
{
public int Id { get; set; }

public string ProductId { get; set; }

public int Quantity { get; set; }

[Timestamp]
public byte[] Version { get; set; }
}

也可使用Fluent API 設定

public class EFstockConfiguration : IEntityTypeConfiguration<EFstock>
{
public void Configure(EntityTypeBuilder<EFstock> builder)
{
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).UseIdentityColumn(1,1);

//fluent API配置
builder.Property(x => x.Version).IsRowVersion();
}
}

記得重新執行Migration

add-migration "add Version"
update-database

執行剛剛的主程式,查看輸出視窗的SQL Command,發現where條件多了Version = @p2,庫存結果與ConcurrencyToken一樣正確。

EF Core 悲觀鎖測試

加上悲觀鎖後,每一個程序運行到 FromSqlRaw 這一行時都要先看看鎖是否佔用,所以不會發生DbUpdateConcurrencyException,會直到鎖釋放往下執行。悲觀策略對於並發性能的影響是很大的,如果請求很多,那麼就需要一直排隊,並且還可能有死鎖問題。

using EFcoreConcurrency;
using Microsoft.EntityFrameworkCore;

var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
var task = Task.Run(() =>
{
for (int j = 0; j < 10; j++)
{
UpdateStockWithPessimistic();
}
});
tasks.Add(task);
}

Task.WaitAll(tasks.ToArray());

foreach (Task t in tasks)
Console.WriteLine("Task {0} Status: {1}", t.Id, t.Status);

Console.WriteLine("Number of files read: {0}", tasks.Count);

//悲觀鎖
async Task UpdateStockWithPessimistic()
{
var saved = false;
while (!saved)
{
try
{
using var dbContext = new TestContext();
using var tran = dbContext.Database.BeginTransaction();
var p = dbContext.EFstocks.FromSqlRaw("select * from EFstocks with(updlock) where ProductId = 'P01'").FirstOrDefault();

if (p.Quantity <= 0)
{
Console.WriteLine($"【{Task.CurrentId}】【庫存不足】");
break;
}

p.Quantity -= 1;

var result = dbContext.SaveChanges();
tran.Commit();

saved = true;

Console.WriteLine($"【{Task.CurrentId}】【成功下單】");
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine($"【{Task.CurrentId}】【發生衝突!!!!!】");
Thread.Sleep(10);
}
}
}

沒有衝突,每筆更新都等待鎖釋放完畢才往下執行,庫存正確。

結語

這篇算是自己第一次寫得比較像樣的文章,但是對鎖的很多知識還沒有太深的了解,關於lock類型有好多種,例如Shared Locks (s)、Update Locks (U)、Exclusive Locks (X)…等等的差異,還有最近遇到批次大量更新庫存的方法,目前自己想到的方案是Redis控制庫存 + Message Queue走最終一致性,不曉得是不是合理的方案,之後會再深入研究。