Cơ sở dữ liệuSQL Server, phần 29/34
Deadlock khi hai giao dịch khóa ngược thứ tự
Thủ tục trả hàng khóa đơn rồi kho, thủ tục thêm hàng khóa kho rồi đơn, và 10 trên 10 lượt chạy song song có một phiên nhận lỗi 1205. Viết lại theo một thứ tự truy cập đưa số lượt deadlock về 0; execution strategy của EF Core chạy lại cả giao dịch cho deadlock còn sót.
Mục lục
- 1. Vấn đề: hai thủ tục khóa chéo đơn 10042 và sản phẩm 42
- 2. Mục đích: không vòng chờ nào giữa các luồng của BanHang
- 3. Cơ sở lý thuyết: vòng chờ, lock monitor và nạn nhân
- 4. Cách giải quyết: một thứ tự truy cập, cộng vòng thử lại cả giao dịch
- 5. Cách cài đặt: viết lại thứ tự, đọc system_health, thử lại bằng EF Core
- 6. Chứng minh: cùng thứ tự đưa số lượt lỗi 1205 từ 10 về 0
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: Lúc 16:40, thủ tục trả hàng sửa đơn 10042 rồi cộng kho sản phẩm 42, thủ tục thêm hàng trừ kho rồi sửa đơn; hai phiên khóa chéo nhau và một phiên nhận lỗi 1205.
- Cách giải: Mọi giao dịch khóa theo một thứ tự
SanPham,DonHang,ChiTietDonHang; ứng dụng chạy lại cả giao dịch khi vẫn gặp 1205 hoặc 3960. - Chứng minh: Ngược thứ tự, 10 trên 10 lượt có lỗi 1205; cùng thứ tự, 0 trên 10; với
EnableRetryOnFailure, nạn nhân chạy lại lần thứ hai và commit. - Trong .NET:
EnableRetryOnFailurecủa EF Core 10, vớiBeginTransactionAsyncđặt trongCreateExecutionStrategy().ExecuteAsync, và mã đơn sinh trước vòng thử lại.
1. Vấn đề: hai thủ tục khóa chéo đơn 10042 và sản phẩm 42
Lúc 16:40 ngày 2026-10-02, hai thủ tục cũ cùng đụng đơn 10042 và sản phẩm 42. Phiên A ghi nhận khách trả 1 sản phẩm 42: giảm tiền đơn rồi cộng kho. Phiên B thêm 1 sản phẩm 42 vào đơn của một khách khác đang sửa tại quầy: trừ kho rồi tăng tiền đơn.
-- Phiên A: trả hàng
BEGIN TRAN;
UPDATE dbo.DonHang SET TongTien = TongTien - 250000.00
WHERE DonHangId = 10042 AND NgayTao = CAST('2026-10-02T11:58:00' AS datetime2(0));
UPDATE dbo.SanPham SET TonKho = TonKho + 1 WHERE SanPhamId = 42;
COMMIT;
-- Phiên B: thêm hàng
BEGIN TRAN;
UPDATE dbo.SanPham SET TonKho = TonKho - 1 WHERE SanPhamId = 42;
UPDATE dbo.DonHang SET TongTien = TongTien + 250000.00
WHERE DonHangId = 10042 AND NgayTao = CAST('2026-10-02T11:58:00' AS datetime2(0));
COMMIT;
Chạy cùng lúc, một trong hai nhận:
Msg 1205, Level 13, State 51
Transaction (Process ID 71) was deadlocked on lock resources with another process and has been chosen as the deadlock victim. Rerun the transaction.
API trả lỗi cho nhân viên quầy, thao tác trả hàng hoặc thêm hàng mất, và nhân viên phải làm lại từ đầu. Code cũ bắt SqlException rồi chạy lại đúng câu vừa lỗi, nên có lúc nó trừ kho mà không tăng tiền đơn.
2. Mục đích: không vòng chờ nào giữa các luồng của BanHang
- 0 lượt lỗi 1205 trên 10 lượt chạy song song cặp thủ tục lúc 16:40.
- Deadlock còn sót không tới người dùng: giao dịch nạn nhân được chạy lại từ đầu và commit, cả hai thay đổi có hiệu lực.
- Mỗi deadlock trên production để lại một báo cáo đọc được: bảng, chỉ mục, phiên giữ, phiên chờ, lệnh cuối.
- Ngoài phạm vi: blocking kéo dài không có vòng, ở bài Blocking.
3. Cơ sở lý thuyết: vòng chờ, lock monitor và nạn nhân
Vòng chờ khép kín
| Thời điểm | Phiên A (session 64) | Phiên B (session 71) |
|---|---|---|
| 16:40:00.000 | UPDATE đơn 10042: giữ X trên khóa của đơn |
|
| 16:40:00.010 | UPDATE sản phẩm 42: giữ X trên khóa của sản phẩm |
|
| 16:40:00.020 | UPDATE sản phẩm 42: chờ LCK_M_U |
|
| 16:40:00.030 | UPDATE đơn 10042: chờ LCK_M_U. Vòng khép kín |
|
| Trong vòng 5 giây | Lock monitor chọn B làm nạn nhân, rollback, trả Msg 1205 | |
| Ngay sau đó | UPDATE sản phẩm 42 chạy tiếp, COMMIT |
Blocking thường tự hết khi phiên giữ khóa xong việc. Ở đây không phiên nào xong được, vì mỗi phiên đang chờ khóa mà phiên kia giữ tới hết giao dịch. Đồ thị chờ có một vòng:
Lock monitor chọn nạn nhân
Lock monitor tìm vòng mỗi 5 giây theo mặc định, và rút xuống tới 100 mili giây khi deadlock xảy ra dày. Nạn nhân là phiên có DEADLOCK_PRIORITY thấp hơn. Cùng mức thì phiên rẻ hơn để rollback. Cùng chi phí thì chọn ngẫu nhiên. Job bảo trì như job chốt sổ ở bài Leo thang khóa có thể đặt SET DEADLOCK_PRIORITY LOW để luôn nhường phiên bán hàng.
Nạn nhân bị rollback cả giao dịch, không chỉ câu vừa lỗi. Lúc ứng dụng nhận 1205, lần trừ kho trước đó của B đã bị hoàn tác. Chạy lại đúng câu vừa lỗi là tăng tiền đơn mà không trừ kho. Đơn vị để thử lại là cả giao dịch.
Lỗi 3960 cũng vậy: giao dịch SNAPSHOT ghi trúng dòng đã bị giao dịch khác sửa thì bị rollback cả giao dịch (bài Mức isolation). Timeout (SqlException.Number = -2) thì khác: lúc client thôi chờ, server có thể đã commit, nên chạy lại có thể làm hai lần.
Cùng thứ tự thì không có vòng
Nếu mọi giao dịch khóa các tài nguyên theo cùng một thứ tự, vòng chờ không khép được. Phiên lấy được khóa đầu tiên đi tiếp; phiên kia xếp hàng ngay ở khóa đầu tiên, khi chưa giữ khóa nào mà phiên trước cần. Quy ước của BanHang là SanPham, rồi DonHang, rồi ChiTietDonHang, đúng thứ tự trong dbo.usp_DonHang_Tao. Phiên A viết lại cho cộng kho trước, sửa đơn sau; lúc đó A và B xếp hàng trên sản phẩm 42 thay vì khóa chéo.
Thứ tự giữa các bảng thì code quyết định được. Thứ tự giữa các dòng trong một câu UPDATE nhiều dòng thì không: câu trừ kho của một đơn hai sản phẩm không bảo đảm khóa sản phẩm 42 trước sản phẩm 108. Hai đơn cùng chứa hai sản phẩm đó vẫn có thể deadlock, hiếm hơn nhiều. Phần còn sót đó cần vòng thử lại.
4. Cách giải quyết: một thứ tự truy cập, cộng vòng thử lại cả giao dịch
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
| Cùng thứ tự truy cập bảng | Loại hẳn vòng giữa các luồng theo quy ước | Phải rà mọi thủ tục; không kiểm soát được thứ tự dòng trong một câu | Cách chính |
| Giao dịch ngắn | Thu hẹp cửa sổ va chạm | Không loại được vòng | Luôn |
| Chỉ mục đúng | Câu UPDATE seek thay vì scan, không lấy U trên dòng thừa |
Không sửa thứ tự | Khi báo cáo deadlock có dòng không liên quan |
| Thử lại cả giao dịch khi 1205 hoặc 3960 | Người dùng không thấy lỗi | Giao dịch phải chạy lại được an toàn; tốn thêm thời gian | Luôn, cho phần còn sót |
DEADLOCK_PRIORITY LOW cho job |
Chọn trước ai chịu thiệt | Không giảm số deadlock | Job bảo trì |
Bật RCSI hoặc thêm NOLOCK |
Không có | Chỉ đổi cách đọc; hai câu ghi vẫn lấy U và X |
Không giải được bài này |
Chọn cùng thứ tự truy cập làm cách sửa chính, và luôn kèm vòng thử lại cả giao dịch. Các bước:
- Ghi quy ước thứ tự bảng vào tài liệu của nhóm và rà các thủ tục đụng từ hai bảng trở lên.
- Viết lại thủ tục nào đi ngược, như phiên A.
- Bọc mọi giao dịch của ứng dụng trong một vòng thử lại chỉ bắt 1205 và 3960, với mã định danh sinh trước vòng lặp.
- Đọc báo cáo deadlock từ
system_healthmỗi ngày, để tìm luồng còn đi ngược.
5. Cách cài đặt: viết lại thứ tự, đọc system_health, thử lại bằng EF Core
Phiên A theo quy ước:
BEGIN TRAN;
UPDATE dbo.SanPham SET TonKho = TonKho + 1 WHERE SanPhamId = 42;
UPDATE dbo.DonHang SET TongTien = TongTien - 250000.00
WHERE DonHangId = 10042 AND NgayTao = CAST('2026-10-02T11:58:00' AS datetime2(0));
COMMIT;
Đọc từ system_health
Session system_health chạy sẵn trên SQL Server và Managed Instance, kể cả LocalDB, và ghi event xml_deadlock_report vào hai target. ring_buffer nằm trong bộ nhớ và chỉ giữ các event gần nhất. event_file nằm trong thư mục Log của instance và còn sau khi khởi động lại. Từ SQL Server 2016, sys.fn_xe_file_target_read_file nhận tên tệp không kèm đường dẫn; cột timestamp_utc có từ SQL Server 2017:
SELECT f.timestamp_utc,
CAST(f.event_data AS xml).query('(event/data[@name="xml_report"]/value/deadlock)[1]') AS deadlock_xml
FROM sys.fn_xe_file_target_read_file(N'system_health*.xel', NULL, NULL, NULL) AS f
WHERE f.object_name = N'xml_deadlock_report'
ORDER BY f.timestamp_utc DESC;
Mốc thời gian là UTC: deadlock lúc 16:40 giờ Việt Nam hiện là 09:40. Session này đặt MAX_DISPATCH_LATENCY 120 giây, nên báo cáo có thể vào event_file sau deadlock tới khoảng 2 phút. Bấm vào ô deadlock_xml trong SSMS, lưu thành tệp .xdl rồi mở lại để có đồ thị.
Phần cần đọc là resource-list: mỗi keylock ghi tên bảng, chỉ mục, phiên giữ (owner) và phiên chờ (waiter). Mỗi process có inputbuf chứa câu lệnh cuối và isolationlevel. Thuộc tính này lộ ra phiên đang chạy SERIALIZABLE ngoài ý muốn, một nguồn deadlock hay gặp vì new TransactionScope() không tham số mở giao dịch Serializable.
Thử lại ở ứng dụng
EF Core có sẵn vòng thử lại: EnableRetryOnFailure bật SqlServerRetryingExecutionStrategy. Strategy chỉ chạy lại được cả giao dịch khi giao dịch nằm trọn trong ExecuteAsync của nó, vì lần thử lại phải chạy lại từ BeginTransactionAsync:
builder.Services.AddDbContext<BanHangDb>(o =>
o.UseSqlServer(cs, sql => sql.EnableRetryOnFailure()));
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
await using var tx = await db.Database.BeginTransactionAsync();
await db.Database.ExecuteSqlAsync($"UPDATE dbo.SanPham SET TonKho = TonKho - 1 WHERE SanPhamId = {id}");
await db.Database.ExecuteSqlAsync($"UPDATE dbo.DonHang SET TongTien = TongTien + {gia} WHERE ...");
await tx.CommitAsync();
}); // mã đơn, giá, thời điểm: tính trước ExecuteAsync để mọi lần chạy dùng cùng giá trị
Thứ làm vòng lặp an toàn là giao dịch chạy lại được. Bên gọi lấy mã đơn một lần, chẳng hạn SELECT NEXT VALUE FOR dbo.seq_DonHangId với sequence ở bài Khóa chính, chốt NgayTao, rồi mới vào vòng. Nếu sau này mở rộng thử lại cho lỗi mạng, một lần thử trước có thể đã commit mà client không nhận được phản hồi; lần sau gặp lỗi 2627 trên PK_DonHang và rollback, thay vì tạo đơn thứ hai và trừ kho hai lần.
Với Microsoft.Data.SqlClient thuần, vòng thử lại bọc lời gọi dbo.usp_DonHang_Tao: tối đa 4 lần, chỉ bắt SqlException số 1205 hoặc 3960, chờ ngẫu nhiên tăng dần giữa các lần. Cần .NET 6 trở lên cho Random.Shared.
DonHangService.TaoDonHangAsync: vòng thử lại với SqlClient
using System;
using System.Data;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Data.SqlClient;
public static class DonHangService
{
private const int SoLanThuToiDa = 4;
// 1205: nạn nhân deadlock. 3960: xung đột cập nhật dưới SNAPSHOT.
// Với cả hai lỗi, SQL Server đã rollback toàn bộ giao dịch trước khi báo về.
private static bool NenThuLai(SqlException ex) => ex.Number is 1205 or 3960;
// dong: DataTable với các cột DongSo (short), SanPhamId (int), SoLuong (int),
// đúng thứ tự cột của dbo.DongDonHang.
public static async Task TaoDonHangAsync(string chuoiKetNoi, long donHangId, DateTime ngayTao,
int khachHangId, DataTable dong, CancellationToken ct = default)
{
for (int lan = 1; ; lan++)
{
try
{
using var ketNoi = new SqlConnection(chuoiKetNoi);
await ketNoi.OpenAsync(ct);
using var lenh = new SqlCommand("dbo.usp_DonHang_Tao", ketNoi)
{
CommandType = CommandType.StoredProcedure,
CommandTimeout = 30
};
lenh.Parameters.Add("@DonHangId", SqlDbType.BigInt).Value = donHangId;
lenh.Parameters.Add("@NgayTao", SqlDbType.DateTime2).Value = ngayTao;
lenh.Parameters.Add("@KhachHangId", SqlDbType.Int).Value = khachHangId;
SqlParameter tvp = lenh.Parameters.Add("@Dong", SqlDbType.Structured);
tvp.TypeName = "dbo.DongDonHang";
tvp.Value = dong;
await lenh.ExecuteNonQueryAsync(ct);
return;
}
catch (SqlException ex) when (NenThuLai(ex) && lan < SoLanThuToiDa)
{
// Chờ ngẫu nhiên, tăng dần, để hai giao dịch không va lại đúng nhịp cũ.
await Task.Delay(Random.Shared.Next(50, 250) * lan, ct);
}
}
}
}
Hai chương trình đo chạy bằng dotnet run, chuỗi kết nối trong biến môi trường BANHANG_DB. ThuTu.cs chạy cặp 16:40 mười lượt với thứ tự cũ và mười lượt với phiên A đã viết lại, không thử lại, rồi đếm số lượt có lỗi 1205. Phiên B bắt đầu sau A 50 ms, và mỗi phiên dừng 200 ms giữa hai câu. Deadlock.cs dựng lại deadlock bằng hai task EF Core, mỗi task một DbContext, với một rào chắn giữ cả hai ở giữa giao dịch cho tới khi cả hai đã giữ khóa đầu tiên. Dòng #:property PublishAot=false của nó cần vì file-based app của .NET 10 bật Native AOT mặc định, còn EF Core dựng model lúc chạy.
ThuTu.cs, chạy bằng dotnet run ThuTu.cs
#:package Microsoft.Data.SqlClient@7.1.1
// Deadlock lúc 16:40, mỗi kiểu 10 lượt, không thử lại: phiên trả hàng A khóa đơn rồi kho (ngược phiên B),
// rồi A viết lại theo quy ước SanPham trước DonHang. Đếm số lượt có phiên nhận lỗi 1205.
// Chạy: dotnet run ThuTu.cs (BANHANG_DB: chuỗi kết nối tới database có dbo.SanPham và dbo.DonHang)
using System.Diagnostics;
using Microsoft.Data.SqlClient;
string db = Environment.GetEnvironmentVariable("BANHANG_DB")
?? @"Server=(localdb)\MSSQLLocalDB;Database=BanHang_Thu;Integrated Security=true;TrustServerCertificate=true";
const string Don10042 = "DonHangId = 10042 AND NgayTao = '2026-10-02T11:58:00'";
string giamDon = $"UPDATE dbo.DonHang SET TongTien = TongTien - 250000.00 WHERE {Don10042}";
string tangDon = $"UPDATE dbo.DonHang SET TongTien = TongTien + 250000.00 WHERE {Don10042}";
string congKho = "UPDATE dbo.SanPham SET TonKho = TonKho + 1 WHERE SanPhamId = 42";
string truKho = "UPDATE dbo.SanPham SET TonKho = TonKho - 1 WHERE SanPhamId = 42";
await Task.WhenAll(GiaoDich("SELECT 1", "SELECT 1", 0), GiaoDich("SELECT 1", "SELECT 1", 0)); // mở sẵn hai kết nối trong pool
foreach (var (ten, aCau1, aCau2) in new[] { ("A sửa đơn rồi cộng kho, ngược B", giamDon, congKho),
("A cộng kho rồi sửa đơn, cùng B ", congKho, giamDon) })
{
int soLuotDeadlock = 0;
var thoiGian = new List<long>();
for (int luot = 0; luot < 10; luot++)
{
await GiaoDich("UPDATE dbo.SanPham SET TonKho = 7 WHERE SanPhamId = 42",
$"UPDATE dbo.DonHang SET TongTien = 1750000.00 WHERE {Don10042}", 0); // đặt lại dữ liệu mỗi lượt
var dongHo = Stopwatch.StartNew();
int[] loi = await Task.WhenAll(GiaoDich(aCau1, aCau2, 0), GiaoDich(truKho, tangDon, 50)); // B: trừ kho rồi tăng đơn
thoiGian.Add(dongHo.ElapsedMilliseconds);
if (loi.Contains(1205)) soLuotDeadlock++;
}
Console.WriteLine($"{ten}: {soLuotDeadlock}/10 lượt có lỗi 1205; mỗi lượt {thoiGian.Min()} đến {thoiGian.Max()} ms, trung bình {thoiGian.Average():F0} ms");
}
async Task<int> GiaoDich(string cau1, string cau2, int treMs)
{
await Task.Delay(treMs);
await using var c = new SqlConnection(db);
await c.OpenAsync();
await using var tx = (SqlTransaction)await c.BeginTransactionAsync();
try
{
await new SqlCommand(cau1, c, tx).ExecuteNonQueryAsync();
await Task.Delay(200); // phần việc giữa hai câu
await new SqlCommand(cau2, c, tx).ExecuteNonQueryAsync();
await tx.CommitAsync();
return 0;
}
catch (SqlException ex) { return ex.Number; }
}
Deadlock.cs, chạy bằng dotnet run Deadlock.cs
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:property PublishAot=false
// Deadlock lúc 16:40 và xung đột SNAPSHOT, chạy qua execution strategy của EF Core.
// Chạy: dotnet run Deadlock.cs (BANHANG_DB: chuỗi kết nối tới database có dbo.SanPham và dbo.DonHang)
using System.Data;
using System.Diagnostics;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
string cs = Environment.GetEnvironmentVariable("BANHANG_DB")
?? @"Server=(localdb)\MSSQLLocalDB;Database=BanHang_Thu;Integrated Security=true;TrustServerCertificate=true";
await using (var db = new BanHangDb(cs, thuLai: false))
{
await db.Database.ExecuteSqlRawAsync("ALTER DATABASE CURRENT SET ALLOW_SNAPSHOT_ISOLATION ON");
await db.Database.ExecuteSqlAsync($"""
UPDATE dbo.SanPham SET TonKho = 7 WHERE SanPhamId = 42;
UPDATE dbo.DonHang SET TongTien = 1750000.00
WHERE DonHangId = 10042 AND NgayTao = '2026-10-02T11:58:00';
""");
}
// 0. Giao dịch tự mở ngoài execution strategy
try
{
await using var db = new BanHangDb(cs, thuLai: true);
await using var tx = await db.Database.BeginTransactionAsync();
await db.Database.SqlQuery<int>($"SELECT TonKho AS Value FROM dbo.SanPham WHERE SanPhamId = 42").SingleAsync();
}
catch (InvalidOperationException ex) { Console.WriteLine($"0. {ex.Message.Split(". ")[0]}."); }
// 1. Phiên A trả hàng (đơn rồi kho), phiên B thêm hàng (kho rồi đơn): thứ tự ngược nhau.
FormattableString giamDon = $"UPDATE dbo.DonHang SET TongTien = TongTien - 250000.00 WHERE DonHangId = 10042 AND NgayTao = '2026-10-02T11:58:00'";
FormattableString tangDon = $"UPDATE dbo.DonHang SET TongTien = TongTien + 250000.00 WHERE DonHangId = 10042 AND NgayTao = '2026-10-02T11:58:00'";
FormattableString congKho = $"UPDATE dbo.SanPham SET TonKho = TonKho + 1 WHERE SanPhamId = 42";
FormattableString truKho = $"UPDATE dbo.SanPham SET TonKho = TonKho - 1 WHERE SanPhamId = 42";
foreach (bool thuLai in new[] { false, true })
{
var gap = new Gap(2);
var dongHo = Stopwatch.StartNew();
var kq = await Task.WhenAll(
Task.Run(() => ChayGiaoDich("A", giamDon, congKho, thuLai, gap, dongHo)),
Task.Run(() => ChayGiaoDich("B", truKho, tangDon, thuLai, gap, dongHo)));
Console.WriteLine($"1. {(thuLai ? "EnableRetryOnFailure" : "không thử lại")}: {string.Join(" | ", kq)}");
}
// 2. A đọc tồn dưới SNAPSHOT, B trừ kho và commit, rồi A trừ kho: lỗi 3960.
var aDaDoc = new TaskCompletionSource();
var bDaGhi = new TaskCompletionSource();
int lanA = 0;
Console.Write("2. SNAPSHOT, EnableRetryOnFailure mặc định: A ");
var phienA = Task.Run(async () =>
{
await using var db = new BanHangDb(cs, thuLai: true);
await db.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
lanA++;
await using var tx = await db.Database.BeginTransactionAsync(IsolationLevel.Snapshot);
int ton = await db.Database.SqlQuery<int>($"SELECT TonKho AS Value FROM dbo.SanPham WHERE SanPhamId = 42").SingleAsync();
Console.Write($"lần {lanA} đọc {ton}");
if (lanA == 1) { aDaDoc.SetResult(); await bDaGhi.Task; }
try { await db.Database.ExecuteSqlAsync($"UPDATE dbo.SanPham SET TonKho = TonKho - 1 WHERE SanPhamId = 42 AND TonKho >= 1"); }
catch (SqlException ex) { Console.Write($", lỗi {ex.Number}; "); throw; }
await tx.CommitAsync();
Console.WriteLine(", commit");
});
});
await aDaDoc.Task;
await using (var db = new BanHangDb(cs, thuLai: false))
await db.Database.ExecuteSqlAsync(truKho);
bDaGhi.SetResult();
await phienA;
async Task<string> ChayGiaoDich(string ten, FormattableString cau1, FormattableString cau2, bool thuLai, Gap gap, Stopwatch dongHo)
{
int lan = 0;
await using var db = new BanHangDb(cs, thuLai);
try
{
await db.Database.CreateExecutionStrategy().ExecuteAsync(async () =>
{
lan++;
await using var tx = await db.Database.BeginTransactionAsync();
await db.Database.ExecuteSqlAsync(cau1);
if (lan == 1) await gap.DenAsync(); // cả hai đã giữ khóa đầu tiên
await db.Database.ExecuteSqlAsync(cau2);
await tx.CommitAsync();
});
return $"{ten} commit sau {lan} lần chạy, {dongHo.ElapsedMilliseconds} ms";
}
catch (Exception ex) when ((ex as SqlException ?? ex.InnerException as SqlException) is { } sql)
{
return $"{ten} nhận {ex.GetType().Name} (lỗi {sql.Number}) sau {lan} lần chạy, {dongHo.ElapsedMilliseconds} ms";
}
}
public class BanHangDb(string cs, bool thuLai) : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder o) =>
o.UseSqlServer(cs, sql => { if (thuLai) sql.EnableRetryOnFailure(); });
}
sealed class Gap(int soBen)
{
int _con = soBen;
readonly TaskCompletionSource _du = new(TaskCreationOptions.RunContinuationsAsynchronously);
public Task DenAsync() { if (Interlocked.Decrement(ref _con) == 0) _du.SetResult(); return _du.Task; }
}
6. Chứng minh: cùng thứ tự đưa số lượt lỗi 1205 từ 10 về 0
Môi trường: LocalDB (SQL Server 2019, 15.0.4382), .NET 10.0.401, Microsoft.Data.SqlClient 7.1.1, EF Core 10.0.12, Windows 11.
| Kiểu chạy, 10 lượt mỗi kiểu | Lượt có lỗi 1205 | Thời gian mỗi lượt |
|---|---|---|
| A sửa đơn rồi cộng kho, ngược B | 10 / 10 | 351 đến 5.504 ms, trung bình 1.329 ms |
| A cộng kho rồi sửa đơn, cùng B | 0 / 10 | 426 đến 723 ms, trung bình 502 ms |
Cùng thứ tự đạt tiêu chí thứ nhất. Thời gian mỗi lượt cũng ổn định hơn: lượt ngược thứ tự dài bằng thời gian lock monitor tìm ra vòng, từ vài trăm mili giây tới hơn 5 giây; lượt cùng thứ tự chỉ cộng thêm thời gian B xếp hàng sau A. Ba lần chạy ThuTu.cs đều cho 10 / 10 và 0 / 10.
Vòng thử lại, đo bằng Deadlock.cs:
| Kịch bản | Kết quả |
|---|---|
EnableRetryOnFailure, nhưng BeginTransactionAsync gọi ngoài strategy |
Truy vấn trong giao dịch ném InvalidOperationException: strategy "does not support user-initiated transactions" |
| Deadlock 16:40, không bật thử lại | Một phiên commit; phiên kia nhận InvalidOperationException bọc SqlException 1205 |
Deadlock 16:40, EnableRetryOnFailure() |
Nạn nhân chạy lại lambda lần thứ hai và commit; cả hai giao dịch đều có hiệu lực |
Xung đột SNAPSHOT, EnableRetryOnFailure() |
Lần 1 lỗi 3960, lần 2 đọc giá trị mới và commit |
Dòng thứ ba đạt tiêu chí thứ hai. Hai điều rút ra cho code. Không bật thử lại, EF Core bọc lỗi 1205 trong InvalidOperationException kèm lời khuyên bật EnableRetryOnFailure, nên catch (SqlException ex) when (ex.Number == 1205) quanh lời gọi EF không bắt được nó. Và ở EF Core 10.0.12, strategy mặc định thử lại cả 1205 lẫn 3960 mà không cần errorNumbersToAdd. Bảy lần chạy, lượt không thử lại xong sau 0,9 đến 7,3 giây, lượt có thử lại sau 2,6 đến 7,4 giây.
Tiêu chí thứ ba: trước lần chạy ThuTu.cs ở trên, system_health của LocalDB có 42 báo cáo deadlock nhắc tới database thử; 3 giây sau lần chạy có 43, và khoảng 100 giây sau có 52, đúng 10 báo cáo mới cho 10 lượt lỗi. Báo cáo cuối cùng, rút gọn, cho thấy nạn nhân là A, phiên đã dùng ít log hơn (308 so với 320 byte); trong Deadlock.cs, nạn nhân là B.
Báo cáo deadlock thật từ system_health, rút gọn
<deadlock>
<victim-list><victimProcess id="process1d57e456108" /></victim-list>
<process-list>
<process id="process1d57e456108" spid="60" logused="308" lockMode="U" isolationlevel="read committed (2)"
waitresource="KEY: 27:72057594043432960 (c1ceb6ec8491)" transactionname="user_transaction">
<inputbuf>UPDATE dbo.SanPham SET TonKho = TonKho + 1 WHERE SanPhamId = 42</inputbuf>
</process>
<process id="process1d58ae00108" spid="63" logused="320" lockMode="U" isolationlevel="read committed (2)"
waitresource="KEY: 27:72057594043301888 (f6019d0d30a0)" transactionname="user_transaction">
<inputbuf>UPDATE dbo.DonHang SET TongTien = TongTien + 250000.00 WHERE DonHangId = 10042 AND NgayTao = '2026-10-02T11:58:00'</inputbuf>
</process>
</process-list>
<resource-list>
<keylock objectname="Kumeo_F.dbo.SanPham" indexname="PK_SanPham" mode="X">
<owner-list><owner id="process1d58ae00108" mode="X" /></owner-list>
<waiter-list><waiter id="process1d57e456108" mode="U" requestType="wait" /></waiter-list>
</keylock>
<keylock objectname="Kumeo_F.dbo.DonHang" indexname="PK_DonHang" mode="X">
<owner-list><owner id="process1d57e456108" mode="X" /></owner-list>
<waiter-list><waiter id="process1d58ae00108" mode="U" requestType="wait" /></waiter-list>
</keylock>
</resource-list>
</deadlock>
Output của ThuTu.cs và Deadlock.cs trên LocalDB
A sửa đơn rồi cộng kho, ngược B: 10/10 lượt có lỗi 1205; mỗi lượt 351 đến 5504 ms, trung bình 1329 ms
A cộng kho rồi sửa đơn, cùng B : 0/10 lượt có lỗi 1205; mỗi lượt 426 đến 723 ms, trung bình 502 ms
0. The configured execution strategy 'SqlServerRetryingExecutionStrategy' does not support user-initiated transactions.
1. không thử lại: A commit sau 1 lần chạy, 1948 ms | B nhận InvalidOperationException (lỗi 1205) sau 1 lần chạy, 1920 ms
1. EnableRetryOnFailure: A commit sau 1 lần chạy, 4968 ms | B commit sau 2 lần chạy, 4976 ms
2. SNAPSHOT, EnableRetryOnFailure mặc định: A lần 1 đọc 8, lỗi 3960; lần 2 đọc 7, commit
Chưa chứng minh ở đây: deadlock giữa hai đơn cùng chứa sản phẩm 42 và 108, vì thứ tự khóa dòng trong một câu UPDATE nhiều dòng phụ thuộc kế hoạch thực thi.
7. Kết luận
Deadlock là vòng chờ, và vòng chỉ khép được khi hai giao dịch khóa cùng các tài nguyên theo thứ tự khác nhau. Một thứ tự chung cho cả hệ thống loại phần lớn chúng; vòng thử lại cả giao dịch lo phần còn sót mà không để người dùng thấy lỗi.
Trong dự án .NET của bạn:
- Ghi quy ước thứ tự bảng, như
SanPham,DonHang,ChiTietDonHang, và rà mọi thủ tục cùng mọi chỗ gọiSaveChangesAsyncsửa từ hai bảng trở lên. - Bật
EnableRetryOnFailure()trongUseSqlServer, và đưa mọiBeginTransactionAsyncvàodb.Database.CreateExecutionStrategy().ExecuteAsync(...). - Sinh mã đơn và chốt
NgayTaotrướcExecuteAsync, để lần chạy lại không tạo đơn thứ hai. - Đếm
xml_deadlock_reporttrongsystem_healthmỗi ngày và đọcresource-listcủa từng báo cáo mới.
Những chỗ hay hiểu sai
- "Gặp 1205 thì chạy lại câu lệnh vừa lỗi." Cả giao dịch đã rollback; phải chạy lại từ đầu giao dịch.
- "
catch (SqlException ex) when (ex.Number == 1205)bắt được deadlock qua EF Core." Không bật thử lại, EF Core bọc nó trongInvalidOperationException. - "Bật RCSI là hết deadlock." RCSI chỉ đổi cách đọc; hai câu ghi vẫn khóa chéo nhau được.
- "Thử lại cả timeout cho chắc." Lúc client timeout, server có thể đã commit.
Đọc tiếp
- Bài trước trong series: SQL Server — Blocking: tìm phiên đầu chuỗi.
- Bài sau trong series: SQL Server — Plan cache phình vì ghép chuỗi: một câu, một kế hoạch: mở đầu chương Kế hoạch thực thi, một câu SQL thành kế hoạch ra sao và vì sao ghép chuỗi làm plan cache phình.
- Chuyển tiền giữa hai ví: deadlock khi hai lệnh chuyển chéo nhau, đo với 16 phiên, và cách khóa theo thứ tự
ViId. - Flash sale bán quá tồn kho: job hết hạn giữ hàng đi cùng thứ tự với lệnh báo thanh toán để không tạo vòng chờ.