前言 寫這篇文章的原因,是小妹幫公司做週年慶專案,實作搶優惠券小遊戲,前幾年的週年慶完全沒有做任何防止超賣處理,而我當時只知道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 TRANSET @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 BEGIN TRANSET @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 COMMIT TRAN
結果發現延遲執行SQL執行失敗
悲觀鎖 (Pessimistic Concurrency) 當一個SQL執行獲得悲觀鎖後,其他的 SQL無法對這個 data 進行修改,直到悲觀鎖被釋放後才能執行。
在SQL的SELECT後面加上Update Locks : WITH(UPDLOCK)
DECLARE @quantity int DECLARE @productId varchar (20 )BEGIN TRANSET @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 ); 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 ); 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走最終一致性,不曉得是不是合理的方案,之後會再深入研究。