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. 1. Vấn đề: hai thủ tục khóa chéo đơn 10042 và sản phẩm 42
  2. 2. Mục đích: không vòng chờ nào giữa các luồng của BanHang
  3. 3. Cơ sở lý thuyết: vòng chờ, lock monitor và nạn nhân
  4. 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. 5. Cách cài đặt: viết lại thứ tự, đọc system_health, thử lại bằng EF Core
  6. 6. Chứng minh: cùng thứ tự đưa số lượt lỗi 1205 từ 10 về 0
  7. 7. Kết luận
  8. Đọc tiếp
  9. 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: EnableRetryOnFailure của EF Core 10, với BeginTransactionAsync đặt trong CreateExecutionStrategy().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:

KEY đơn 10042 PK_DonHang KEY sản phẩm 42 PK_SanPham Phiên A session 64 chạy tiếp, COMMIT Phiên B session 71 nạn nhân, Msg 1205 giữ X chờ U giữ X chờ U chu trình
Nét liền: khóa và phiên đang giữ nó. Nét đứt: phiên đang chờ khóa. Vòng kín là deadlock.

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:

  1. 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.
  2. Viết lại thủ tục nào đi ngược, như phiên A.
  3. 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.
  4. Đọc báo cáo deadlock từ system_health mỗ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 SqlClientC# · 49 dòng
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.csC# · 50 dòng
#: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.csC# · 108 dòng
#: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

Ngược thứ tự, mọi lượt đều có một phiên nhận lỗi 1205; cùng thứ tự, không lượt nào

A sửa đơn rồi cộng kho, ngược B10 lượtA cộng kho rồi sửa đơn, cùng thứ tự với B0 lượt
Số lượt có lỗi 1205 trên 10 lượt chạy song song, không thử lại. LocalDB SQL Server 2019 (15.0.4382), Microsoft.Data.SqlClient 7.1.1.
Bảng số liệu
Giá trị
A sửa đơn rồi cộng kho, ngược B10 lượt
A cộng kho rồi sửa đơn, cùng thứ tự với B0 lượt

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ọn23 dòng
<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 LocalDB7 dòng
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ọi SaveChangesAsync sửa từ hai bảng trở lên.
  • Bật EnableRetryOnFailure() trong UseSqlServer, và đưa mọi BeginTransactionAsync vào db.Database.CreateExecutionStrategy().ExecuteAsync(...).
  • Sinh mã đơn và chốt NgayTao trước ExecuteAsync, để lần chạy lại không tạo đơn thứ hai.
  • Đếm xml_deadlock_report trong system_health mỗi ngày và đọc resource-list củ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ó trong InvalidOperationException.
  • "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

Nguồn

Đọc tiếp

Trong SQL Server

Job hàng loạt chặn bán hàng: leo thang khóa

Job chốt sổ năm 2024 sửa khoảng 150.000 đơn bằng một câu UPDATE, vượt ngưỡng 5.000 khóa và leo thang lên khóa cả bảng DonHang, chặn mọi đơn mới. Chia lô 4.000 dòng bằng ExecuteUpdateAsync giữ số lần leo thang ở 0 và cho đơn 2026 chèn được sau 19 ms thay vì lỗi 1222 sau 2.015 ms.

12 phút đọc

Trong SQL Server

Báo cáo cuối ngày lệch số: mức isolation và SNAPSHOT

Báo cáo cuối ngày tính tổng rồi lấy danh sách đơn trong hai câu, và một đơn commit giữa hai câu làm hai con số lệch 300.000. READ COMMITTED và RCSI đều để lệch, SERIALIZABLE khớp nhưng chặn bán hàng; SNAPSHOT cho hai câu khớp mà lệnh chèn đơn vẫn xong sau 10 ms.

11 phút đọc

Trong SQL Server

Hai đơn cùng đọc rồi ghi: mất cập nhật tồn kho và upsert

Hai khách cùng mua 3 sản phẩm 42 khi tồn còn 5, cả hai đơn thành công và tồn chỉ bị trừ một lần; hai đơn đầu ngày cùng upsert doanh thu thì một đơn mất với lỗi 2627. Câu UPDATE có điều kiện, UPDLOCK, rowversion và UPDLOCK HOLDLOCK sửa từng dạng, đo bằng EF Core.

12 phút đọc