Cơ sở dữ liệuSQL Server, phần 19/24
Blocking, deadlock và thử lại giao dịch
Tìm phiên đầu chuỗi khi API timeout hàng loạt, xử lý giao dịch bị bỏ quên, đọc deadlock từ system_health, sửa bằng thứ tự truy cập, và thử lại cả giao dịch bằng execution strategy của EF Core.
Một nhân viên quên COMMIT trong SSMS, và từ giữa trưa mọi lệnh sửa đơn hàng đó đều timeout. Hai thủ tục khóa cùng hai dòng theo thứ tự ngược nhau, và một trong hai nhận lỗi 1205 thay vì kết quả. Đọc xong, bạn tìm được phiên gây chặn trong vài phút, đọc được báo cáo deadlock, và viết được vòng thử lại an toàn trong .NET.
Đọc nhanh
- Blocking kéo dài thường do một phiên
sleepingcóopen_transaction_count > 0: giao dịch mở mà không còn ai chạy lệnh. KILLbắt đầu rollback chứ không chấm dứt ngay; giao dịch lớn rollback lâu và giữ khóa đến khi xong.- Deadlock đọc từ session
system_health; cách sửa chính là cùng thứ tự truy cập bảng và giao dịch ngắn. - Ứng dụng thử lại cả giao dịch khi là nạn nhân deadlock hoặc gặp xung đột
SNAPSHOT, không thử lại timeout.
Bài cuối trong năm bài về giao dịch, cùng mốc với bài Giao dịch và XACT_ABORT: SQL Server 2019, RCSI và ADR tắt. Chế độ khóa và sys.dm_tran_locks ở bài Khóa và leo thang khóa.
1. Blocking: tìm phiên đầu chuỗi
Sáng 2026-10-03, lúc 09:00, một nhân viên kế toán mở SSMS và chạy:
BEGIN TRAN;
UPDATE dbo.DonHang SET TrangThai = 3
WHERE DonHangId = 10042 AND NgayTao = CAST('2026-10-02T11:58:00' AS datetime2(0));
Không có COMMIT. Người đó đi ăn trưa. Từ khoảng 12:00, log ứng dụng đầy lỗi timeout khi xem và sửa đơn 10042.
Bước 1, ai đang chờ: sys.dm_exec_requests lọc blocking_session_id > 0, kèm câu đang chạy lấy từ sys.dm_exec_sql_text.
Bước 1: các request đang bị chặn
SELECT r.session_id, r.blocking_session_id, r.wait_type,
r.wait_time AS wait_ms, r.wait_resource,
SUBSTRING(t.text, r.statement_start_offset / 2 + 1,
(CASE r.statement_end_offset WHEN -1 THEN DATALENGTH(t.text)
ELSE r.statement_end_offset END
- r.statement_start_offset) / 2 + 1) AS cau_dang_chay
FROM sys.dm_exec_requests AS r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) AS t
WHERE r.blocking_session_id > 0
ORDER BY r.wait_time DESC;
Kết quả minh họa lúc 12:15:
| session_id | blocking_session_id | wait_type | wait_ms | wait_resource | cau_dang_chay |
|---|---|---|---|---|---|
| 87 | 58 | LCK_M_U | 26900 | KEY: 7:72057594047823872 (8194443284a0) | UPDATE dbo.DonHang SET TongTien = ... |
| 112 | 87 | LCK_M_U | 21400 | KEY: 7:72057594048086016 (61a06abd401c) | UPDATE sp SET TonKho = ... |
| 95 | 58 | LCK_M_S | 12300 | KEY: 7:72057594047823872 (8194443284a0) | SELECT ... FROM dbo.DonHang ... |
Phiên 87 thêm sản phẩm 42 vào đơn 10042: đã trừ kho, đang chờ dòng đơn. Phiên 112 là dbo.usp_DonHang_Tao của một đơn mới có sản phẩm 42, chờ phiên 87. Phiên 95 là màn hình chăm sóc khách hàng; nó chờ vì RCSI đang tắt. wait_ms đều dưới 30.000 vì ứng dụng timeout sau 30 giây rồi gọi lại. Chuỗi là 112 chờ 87, 87 chờ 58.
flowchart LR S112["112: usp_DonHang_Tao, chờ LCK_M_U trên sản phẩm 42"] -- "chờ" --> S87["87: đã trừ kho sản phẩm 42, chờ LCK_M_U trên đơn 10042"] S87 -- "chờ" --> S58["58: SSMS kế toán, sleeping, giữ X trên đơn 10042"] S95["95: màn hình chăm sóc khách, chờ LCK_M_S trên đơn 10042"] -- "chờ" --> S58
Bước 2, phiên đầu chuỗi: chặn người khác nhưng không bị ai chặn. Truy vấn lấy phiên có trong cột blocking_session_id mà không tự chờ ai, kèm lệnh cuối từ sys.dm_exec_input_buffer.
Bước 2: phiên đầu chuỗi và lệnh cuối nó gửi
SELECT s.session_id, s.login_name, s.host_name, s.program_name, s.status,
s.open_transaction_count, s.last_request_end_time, ib.event_info AS lenh_cuoi
FROM sys.dm_exec_sessions AS s
OUTER APPLY sys.dm_exec_input_buffer(s.session_id, NULL) AS ib
WHERE EXISTS (SELECT 1 FROM sys.dm_exec_requests AS r
WHERE r.blocking_session_id = s.session_id)
AND NOT EXISTS (SELECT 1 FROM sys.dm_exec_requests AS r
WHERE r.session_id = s.session_id AND r.blocking_session_id > 0);
| session_id | login_name | program_name | status | open_transaction_count | last_request_end_time | lenh_cuoi |
|---|---|---|---|---|---|---|
| 58 | CONGTY\ketoan01 | Microsoft SQL Server Management Studio - Query | sleeping | 1 | 2026-10-03 09:00:04 | BEGIN TRAN; UPDATE dbo.DonHang SET TrangThai = 3 ... |
sleeping cộng open_transaction_count = 1 là chữ ký của giao dịch bị bỏ quên: phiên không chạy gì từ 09:00:04 nhưng vẫn giữ khóa. Phiên 58 không có dòng nào trong sys.dm_exec_requests vì nó không chạy câu nào; sys.dm_exec_input_buffer vẫn trả lệnh cuối cùng nó gửi.
Bước 3, giao dịch mở bao lâu và đã ghi bao nhiêu: nối sys.dm_tran_session_transactions, sys.dm_tran_active_transactions và sys.dm_tran_database_transactions.
Bước 3: tuổi và lượng log của từng giao dịch đang mở
SELECT st.session_id, at.name AS transaction_name, at.transaction_begin_time,
DATEDIFF(MINUTE, at.transaction_begin_time, GETDATE()) AS so_phut_mo,
dt.database_transaction_log_record_count AS so_log_record,
dt.database_transaction_log_bytes_used / 1024 AS log_kb
FROM sys.dm_tran_session_transactions AS st
INNER JOIN sys.dm_tran_active_transactions AS at ON at.transaction_id = st.transaction_id
LEFT JOIN sys.dm_tran_database_transactions AS dt
ON dt.transaction_id = st.transaction_id AND dt.database_id = DB_ID(N'BanHang')
ORDER BY at.transaction_begin_time;
Phiên 58: user_transaction, bắt đầu 09:00:03, mở 195 phút, vài log record. sys.dm_tran_locks lọc theo request_session_id = 58 cho thấy OBJECT IX, PAGE IX, KEY X, đúng hình dạng ở bài Khóa. Đây cũng là giao dịch mà chương lưu trữ nói tới: khi ADR tắt, log backup sau 09:00 vẫn chạy nhưng không cắt được phần log từ lúc giao dịch này bắt đầu trở đi. DBCC OPENTRAN(N'BanHang') cũng chỉ ra phiên 58 và giờ bắt đầu.
Bước 4, xử lý. Chỉ người giữ phiên mới COMMIT được giao dịch đó. Liên lạc được thì họ quyết định commit hay rollback ngay trong cửa sổ SSMS của mình. Không liên lạc được và blocking đang làm hỏng đơn hàng thì:
KILL 58;
KILL 58 WITH STATUSONLY;
KILL ngắt kết nối và rollback giao dịch: lần đổi TrangThai của đơn 10042 mất. Giao dịch một dòng rollback gần như tức thì, và WITH STATUSONLY báo lỗi 6120 vì không còn rollback nào đang chạy. Giao dịch đã sửa 3 triệu dòng thì khác.
`KILL` không bỏ qua phần rollback
Khi ADR tắt, rollback phải hoàn tác từng thay đổi đã ghi log và có thể chạy lâu hơn chính phần việc đã làm. Trong lúc đó khóa vẫn giữ và chuỗi blocking vẫn còn. KILL 58 WITH STATUSONLY báo phần trăm và số giây ước lượng. Khởi động lại SQL Server không bỏ được phần đó: recovery lúc khởi động vẫn phải undo giao dịch, trong khi mọi kết nối khác đã bị cắt. ADR rút ngắn cả hai trường hợp.
Phòng lần sau: tài khoản chỉ đọc cho nhân viên tra cứu, sửa dữ liệu production qua thủ tục có XACT_ABORT ON, và một job cảnh báo khi truy vấn ở bước 3 thấy giao dịch mở quá 10 phút.
2. Deadlock
Lúc 16:40, hai thủ tục cũ cùng đụng đơn 10042 và sản phẩm 42 theo thứ tự ngược nhau. 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: trừ kho rồi tăng tiền đơn.
-- Phiên A
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
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;
| 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. Chu trình 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 |
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.
Lock monitor tìm chu trình 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 Khóa có thể đặt SET DEADLOCK_PRIORITY LOW để luôn nhường phiên bán hàng.
Đọc từ system_health
Session system_health chạy sẵn trên SQL Server và Managed Instance, 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 và tìm trong thư mục mặc định đó. 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;
Đọc từ ring_buffer thì lấy target_data của sys.dm_xe_session_targets với target_name = N'ring_buffer', ép sang xml, rồi duyệt RingBufferTarget/event[@name="xml_deadlock_report"]. Mốc thời gian của Extended Events là UTC: deadlock lúc 16:40 giờ Việt Nam hiện là 09:40. 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 của báo cáo là resource-list: mỗi keylock ghi tên bảng, chỉ mục, phiên giữ (owner) và phiên chờ (waiter). Ở đây A giữ X trên khóa đơn và chờ sản phẩm; B ngược lại, và B nằm trong victim-list.
Báo cáo deadlock lúc 16:40, rút gọn và minh họa
<deadlock>
<victim-list><victimProcess id="process2b7c" /></victim-list>
<process-list>
<process id="process2b7c" spid="71" lockMode="U" isolationlevel="read committed (2)"
waitresource="KEY: 7:72057594047823872 (8194443284a0)" transactionname="user_transaction" />
<process id="process1f2a" spid="64" lockMode="U" isolationlevel="read committed (2)"
waitresource="KEY: 7:72057594048086016 (61a06abd401c)" transactionname="user_transaction" />
</process-list>
<resource-list>
<keylock objectname="BanHang.dbo.DonHang" indexname="PK_DonHang" mode="X">
<owner-list><owner id="process1f2a" mode="X" /></owner-list>
<waiter-list><waiter id="process2b7c" mode="U" requestType="wait" /></waiter-list>
</keylock>
<keylock objectname="BanHang.dbo.SanPham" indexname="PK_SanPham" mode="X">
<owner-list><owner id="process2b7c" mode="X" /></owner-list>
<waiter-list><waiter id="process1f2a" mode="U" requestType="wait" /></waiter-list>
</keylock>
</resource-list>
</deadlock>
Mỗi process thật còn có inputbuf chứa câu lệnh cuối. isolationlevel lộ ra phiên nào đang chạy SERIALIZABLE ngoài ý muốn, một nguồn deadlock hay gặp vì TransactionScope của .NET mặc định Serializable (đo ở bài Mức isolation). transactionname="implicit_transaction" chỉ ra một driver đang để implicit transactions bật.
Sửa
- Cùng thứ tự truy cập. Quy ước của
BanHang:SanPham, rồiDonHang, rồiChiTietDonHang, nhưdbo.usp_DonHang_Tao. Viết lại phiên A cho cộng kho trước, sửa đơn sau. Hai phiên lúc đó xếp hàng trên sản phẩm 42 thay vì khóa chéo. - Giao dịch ngắn. Khóa giữ càng lâu, cửa sổ cho chu trình càng rộng.
- Chỉ mục đúng. Câu
UPDATE ... WHEREkhông có chỉ mục phù hợp quét và lấyUtrên nhiều dòng hơn cần, va vào khóa của phiên khác. Kế hoạch có seek hay scan đọc ở Toán tử và Key Lookup.
Thử lại ở ứng dụng
Deadlock không loại bỏ được hoàn toàn. Ứng dụng phải thử lại cả giao dịch, không phải câu lệnh vừa lỗi: lúc nhận 1205, mọi thay đổi trước đó của giao dịch đã bị rollback. Chỉ thử lại lỗi mà chắc chắn giao dịch đã rollback: 1205 và 3960. Timeout (SqlException.Number = -2) không nằm trong danh sách: lúc client thôi chờ, server có thể đã commit.
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.
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);
}
}
}
}
Cần .NET 6 trở lên cho Random.Shared. Thứ làm vòng lặp an toàn là tính idempotent của cả giao dịch. 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 ở Khóa chính: IDENTITY, SEQUENCE, GUID, chốt NgayTao, rồi mới gọi TaoDonHangAsync. Mọi lần thử dùng lại đúng hai giá trị đó.
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 thử sau khi đó gặp lỗi khóa chính 2627 trên PK_DonHang và toàn bộ giao dịch rollback, kể cả bước trừ kho, thay vì tạo đơn thứ hai và trừ kho hai lần. Lấy số bên trong thủ tục thì mỗi lần thử ra một số mới, và lớp bảo vệ này mất.
3. Quy tắc cho người viết ứng dụng
Chín quy tắc gom từ năm bài về giao dịch:
- Giao dịch ngắn và nằm trọn trong một lần gọi thủ tục. Không chờ người dùng, không gọi HTTP, không gửi email giữa
BEGIN TRANvàCOMMIT. SET XACT_ABORT ONtrong mọi thủ tục mở giao dịch. Nhánh bắt lỗi ở ứng dụng chạyIF @@TRANCOUNT > 0 ROLLBACKtrước khi trả kết nối (mẫu chuẩn).- Cùng thứ tự truy cập bảng trong mọi luồng:
SanPham,DonHang,ChiTietDonHang. - Ghi dựa trên giá trị vừa đọc: một câu
UPDATEcó điều kiện, hoặcUPDLOCK. Dữ liệu đi qua màn hình người dùng:rowversion. Upsert:UPDLOCK, HOLDLOCK(mất cập nhật và upsert). - Báo cáo không chặn ghi: bật RCSI. Không rải
NOLOCK. - Việc lớn chia lô dưới ngưỡng 5.000 khóa, mỗi lô một giao dịch (leo thang khóa).
- Thử lại cả giao dịch khi gặp 1205 hoặc 3960, với mã định danh sinh trước vòng lặp.
- Biết driver đang làm gì: autocommit hay implicit transactions,
CommandTimeoutbao nhiêu, mức isolation mặc định của framework là gì. - Theo dõi: giao dịch mở quá 10 phút, deadlock trong
system_health,index_lock_promotion_counttăng bất thường.
4. Áp dụng trong .NET
EF Core có sẵn vòng thử lại: EnableRetryOnFailure bật SqlServerRetryingExecutionStrategy. Strategy chỉ thử 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ị
Deadlock.cs dựng lại deadlock lúc 16:40 bằng hai task, mỗi task một DbContext, và 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. Chạy trên LocalDB (SQL Server 2019, 15.0.4382), .NET 10.0.401, EF Core 10.0.12:
| 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 như ở bài Mức isolation, EnableRetryOnFailure() |
Lần 1 lỗi 3960, lần 2 đọc giá trị mới và commit |
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; lock monitor quét theo chu kỳ, nên thời gian phát hiện deadlock dao động giữa các lần.
Dòng #:property PublishAot=false 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.
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; }
}
Output của Deadlock.cs trên LocalDB
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
Những chỗ hay hiểu sai
| Hiểu sai | Đúng là |
|---|---|
KILL chấm dứt ngay |
KILL bắt đầu rollback. Giao dịch lớn rollback lâu, và khóa giữ đến khi xong |
| Phiên gây chặn là phiên đang chạy lâu nhất | Thường là phiên sleeping không chạy gì nhưng còn giao dịch mở |
| 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ó trong InvalidOperationException |
Kết luận
Blocking kéo dài và deadlock đều đọc được từ DMV và system_health; phần sửa nằm ở code: giao dịch ngắn, cùng thứ tự truy cập, và một vòng thử lại chạy lại cả giao dịch với cùng mã định danh.
Trong dự án .NET của bạ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. - Đặt
Application Nameriêng cho từng service trong chuỗi kết nối, đểprogram_nameở bước 2 chỉ ngay ra service giữ khóa. - Cảnh báo khi một phiên
sleepingcóopen_transaction_count > 0quá 10 phút, và đếmxml_deadlock_reporttrongsystem_healthmỗi ngày.
Đọc tiếp
- Bài trước trong series: SQL Server — Mất cập nhật tồn kho và upsert.
- Bài sau trong series: SQL Server — Kế hoạch thực thi và plan cache: đọc kế hoạch để biết câu
UPDATEnào quét cả partition và khóa nhiều hơn cần. - Điều tra truy vấn chậm, phần 2: khi một API đột nhiên chậm, cách phân biệt chặn khóa (
LCK_M_*, CPU thấp) với nghẽn CPU hay hàng đợi bộ 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.