Cơ sở dữ liệuSQL Server, phần 18/24

Mất cập nhật tồn kho và upsert

Vì sao đọc tồn kho rồi ghi lại làm mất cập nhật ở cả READ COMMITTED lẫn RCSI, ba cách sửa (UPDATE có điều kiện, UPDLOCK, rowversion), upsert với UPDLOCK HOLDLOCK, và cách viết từng cách bằng EF Core.

Mục lục
  1. 1. Mất cập nhật trên tồn kho
  2. 2. Upsert và khóa phạm vi
  3. 3. Áp dụng trong .NET
  4. Những chỗ hay hiểu sai
  5. Kết luận
  6. Đọc tiếp
  7. Nguồn

Hai khách cùng mua những chiếc cuối của một sản phẩm, cả hai đơn thành công, và tồn kho chỉ bị trừ một lần. Lỗi này không sinh ra thông báo nào: code đọc tồn, tính trong ứng dụng rồi ghi lại, và nó xảy ra ở mức isolation mặc định lẫn khi bật RCSI. Đọc xong, bạn chọn được cách ghi đúng cho trừ kho, màn hình sửa dữ liệu và upsert, cả trong T-SQL lẫn EF Core.

Đọc nhanh

  • Đọc tồn kho, tính, rồi ghi giá trị mới làm mất cập nhật ở cả READ COMMITTED lẫn RCSI.
  • Mặc định trừ kho bằng một câu UPDATE ... WHERE TonKho >= @SoLuong rồi kiểm số dòng bị sửa.
  • Logic nhiều bước dùng UPDLOCK trong giao dịch ngắn; dữ liệu đi qua màn hình người dùng dùng rowversion.
  • Upsert bằng IF NOT EXISTS, MERGE trần hay FindAsync rồi Add gây lỗi 2627; thêm UPDLOCK, HOLDLOCK.

Bài thứ tư trong năm bài về giao dịch. Bài dùng dbo.SanPham với sản phẩm 42 giá 250.000 ở bài Giao dịch và XACT_ABORT, khóa U ở bài Khóa, và bảng mức isolation ở bài Mức isolation. Mốc vẫn là SQL Server 2019, RCSI tắt.

1. Mất cập nhật trên tồn kho

Lúc 15:00, sản phẩm 42 còn 5. Hai khách cùng mua 3. Kết quả đúng: một đơn thành công, tồn còn 2, đơn kia bị từ chối. Code dưới đây, chạy ở mỗi phiên, cho kết quả sai:

DECLARE @TonKho int;
BEGIN TRAN;
SELECT @TonKho = TonKho FROM dbo.SanPham WHERE SanPhamId = 42;
IF @TonKho >= 3
    UPDATE dbo.SanPham SET TonKho = @TonKho - 3 WHERE SanPhamId = 42;
COMMIT;
sequenceDiagram
  participant A as Phiên A
  participant S as Dòng sản phẩm 42
  participant B as Phiên B
  A->>S: 15:00:00.100 đọc TonKho = 5
  B->>S: 15:00:00.120 đọc TonKho = 5
  A->>S: 15:00:00.150 ghi TonKho = 2, giữ X
  B->>S: 15:00:00.160 ghi TonKho = 2, chờ X của A
  A->>S: 15:00:00.170 COMMIT
  B->>S: 15:00:00.171 ghi 2, COMMIT
  Note over A,B: Bán 6 sản phẩm, tồn ghi 2

Sáu sản phẩm đã bán, tồn ghi 2. Lần trừ của A biến mất. CK_SanPham_TonKho không cứu được vì cả hai lần ghi đều là 2, hợp lệ.

Dưới RCSI, lỗ hổng rộng hơn. Câu SELECT không bao giờ chờ: dù B đọc sau khi A đã ghi 2 mà chưa commit, B vẫn đọc 5, bản commit gần nhất. Bật RCSI giải quyết đọc chặn ghi, không giải quyết đọc rồi ghi.

Cách a: một câu UPDATE có điều kiện

DECLARE @SanPhamId int = 42, @SoLuong int = 3;

UPDATE dbo.SanPham
SET TonKho = TonKho - @SoLuong
WHERE SanPhamId = @SanPhamId AND TonKho >= @SoLuong;

IF @@ROWCOUNT = 0
    THROW 50002, N'Không đủ tồn kho.', 1;

A trừ xong giữ X. UPDATE của B lấy U khi dò dòng nên chờ. A commit, B đọc bản mới nhất là 2, điều kiện 2 >= 3 sai, không dòng nào được sửa, B báo hết hàng. Dưới RCSI kết quả giống hệt: câu UPDATE ở READ COMMITTED dò dòng bằng khóa U trên dữ liệu hiện tại, không đọc phiên bản cũ. dbo.usp_DonHang_Tao dùng đúng cách này. Đây là cách nên chọn mặc định cho mọi phép cộng trừ số lượng.

Cách b: đọc với UPDLOCK

Khi logic giữa đọc và ghi phức tạp hơn một phép trừ (giữ hàng theo hạng khách, kiểm hạn mức), khóa dòng ngay ở câu đọc:

DECLARE @SanPhamId int = 42, @SoLuong int = 3, @TonKho int;

SET XACT_ABORT ON;
BEGIN TRAN;

SELECT @TonKho = TonKho
FROM dbo.SanPham WITH (UPDLOCK, ROWLOCK)
WHERE SanPhamId = @SanPhamId;

IF ISNULL(@TonKho, 0) < @SoLuong            -- NULL: sản phẩm không tồn tại
    THROW 50002, N'Không đủ tồn kho.', 1;

UPDATE dbo.SanPham SET TonKho = @TonKho - @SoLuong WHERE SanPhamId = @SanPhamId;
COMMIT;

UPDLOCK giữ U đến hết giao dịch. Câu đọc của B chờ ở dòng đó cho đến khi A commit, rồi đọc 2. Gợi ý khóa làm câu đọc dùng khóa trên dữ liệu hiện tại kể cả khi RCSI bật. ROWLOCK giữ khóa ở mức dòng để không chặn sản phẩm khác trên cùng page. Giao dịch phải ngắn: mọi đơn có sản phẩm 42 xếp hàng sau nó.

Cách c: optimistic concurrency với rowversion

Màn hình kiểm kho: nhân viên mở sản phẩm 42, đếm hàng thật trong vài phút, rồi nhập số tuyệt đối. Không thể giữ khóa trong lúc người dùng suy nghĩ. Thêm một cột phiên bản; cột mới không đổi các cột đã có:

ALTER TABLE dbo.SanPham ADD PhienBan rowversion;

rowversion là 8 byte, đổi mỗi lần dòng bị sửa, bởi bất kỳ ai. Giá trị lấy từ một bộ đếm chung của database, không mang nghĩa ngày giờ, nên chỉ dùng để so bằng.

-- Bước 1, lúc mở màn hình. Ứng dụng giữ PhienBan cùng dữ liệu.
SELECT TonKho, PhienBan FROM dbo.SanPham WHERE SanPhamId = 42;

-- Bước 2, khi bấm Lưu. Giá trị dưới là minh họa; chạy thật thì thay bằng PhienBan ở bước 1.
DECLARE @PhienBanDaDoc binary(8) = 0x00000000000A3F51;

UPDATE dbo.SanPham SET TonKho = 12
WHERE SanPhamId = 42 AND PhienBan = @PhienBanDaDoc;

IF @@ROWCOUNT = 0
    THROW 50003, N'Sản phẩm đã bị sửa từ lúc mở màn hình. Tải lại rồi nhập lại.', 1;

Có một đơn bán sản phẩm 42 trong lúc nhân viên đếm thì PhienBan đã đổi, UPDATE sửa 0 dòng, nhân viên thấy thông báo thay vì ghi đè lên lần bán đó.

Cách Hợp với Giá phải trả
a. UPDATE có điều kiện Cộng trừ số lượng, đổi trạng thái có điều kiện Logic phải viết được trong một câu
b. UPDLOCK Logic nhiều bước trong một giao dịch ngắn Các phiên cùng dòng xếp hàng
c. rowversion Dữ liệu đi qua màn hình người dùng Ứng dụng phải xử lý xung đột và cho thử lại

2. Upsert và khóa phạm vi

Bảng tổng hợp doanh thu theo ngày, cộng dồn sau mỗi đơn:

CREATE TABLE dbo.DoanhThuNgay (
    Ngay date NOT NULL,
    SanPhamId int NOT NULL,
    SoLuong int NOT NULL,
    DoanhThu decimal(18, 2) NOT NULL,
    CONSTRAINT PK_DoanhThuNgay PRIMARY KEY CLUSTERED (Ngay, SanPhamId)
);

Cách viết hay gặp là IF NOT EXISTS (SELECT ...) INSERT ... ELSE UPDATE .... Hai đơn đầu tiên của ngày 2026-10-03, mỗi đơn 2 sản phẩm 42, tức 500.000:

Thời điểm Phiên A Phiên B
00:00:01.200 IF NOT EXISTS: chưa có dòng (2026-10-03, 42)
00:00:01.201 IF NOT EXISTS: chưa có
00:00:01.205 INSERT: thành công
00:00:01.206 INSERT: Msg 2627, vi phạm PK_DoanhThuNgay, khóa trùng (2026-10-03, 42)

Ở READ COMMITTED, câu kiểm tra không khóa được một dòng chưa tồn tại. Bọc cả khối trong BEGIN TRAN không đổi điều đó. Một câu MERGE đơn lẻ có cùng cuộc đua: tài liệu MERGE khuyên thêm HOLDLOCK khi một khóa có thể vừa được chèn vừa được sửa đồng thời.

Cách đúng: khóa khoảng trống nơi dòng sẽ nằm, bằng ngữ nghĩa SERIALIZABLE (HOLDLOCK) cộng chế độ U (UPDLOCK):

CREATE OR ALTER PROCEDURE dbo.usp_DoanhThuNgay_Cong
    @Ngay date, @SanPhamId int, @SoLuong int, @DoanhThu decimal(18, 2)
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    BEGIN TRANSACTION;

    UPDATE dbo.DoanhThuNgay WITH (UPDLOCK, HOLDLOCK)
    SET SoLuong = SoLuong + @SoLuong, DoanhThu = DoanhThu + @DoanhThu
    WHERE Ngay = @Ngay AND SanPhamId = @SanPhamId;

    IF @@ROWCOUNT = 0
        INSERT INTO dbo.DoanhThuNgay (Ngay, SanPhamId, SoLuong, DoanhThu)
        VALUES (@Ngay, @SanPhamId, @SoLuong, @DoanhThu);

    COMMIT TRANSACTION;
END;
GO
Thời điểm Phiên A Phiên B
00:00:01.200 UPDATE: 0 dòng, giữ RangeS-U trên khoảng chứa (2026-10-03, 42)
00:00:01.201 UPDATE: chờ LCK_M_RS_U
00:00:01.205 INSERT (2, 500.000), COMMIT
00:00:01.206 UPDATE thấy dòng, cộng thành (4, 1.000.000), COMMIT

Viết bằng MERGE thì gắn gợi ý vào bảng đích, MERGE dbo.DoanhThuNgay WITH (HOLDLOCK) AS t USING ..., và vẫn đặt trong mẫu XACT_ABORT khi nằm trong thủ tục.

Khóa phạm vi (key-range lock) chỉ xuất hiện dưới SERIALIZABLE hoặc HOLDLOCK. Nó khóa một khóa của chỉ mục cùng khoảng trống ngay trước khóa đó, để không ai chèn được giá trị mới vào khoảng đang được bảo vệ.

Chế độ Phạm vi Dòng Dùng khi
RangeS-S Chia sẻ S Câu đọc quét một đoạn khóa dưới SERIALIZABLE
RangeS-U Chia sẻ U Câu dò để sửa dưới SERIALIZABLE, hoặc UPDLOCK, HOLDLOCK
RangeI-N Chèn Không Kiểm tra khoảng trước khi chèn khóa mới vào chỉ mục
RangeX-X Độc quyền X Sửa một khóa nằm trong đoạn đang khóa

Khi chưa có dòng nào của ngày 2026-10-03, khoảng trống bị khóa kéo từ khóa cuối của ngày 2026-10-02 đến hết chỉ mục. Lúc đầu ngày, phiên cộng doanh thu cho sản phẩm 108 cũng rơi vào khoảng đó và phải chờ phiên của sản phẩm 42. Khoảng chờ chỉ dài bằng giao dịch upsert, vài mili giây, nên giao dịch này phải giữ ngắn.

Các khóa của PK_DoanhThuNgay theo thứ tự (Ngay, SanPhamId), lúc 00:00:01.200 ngày 2026-10-03 2026-10-02 sản phẩm 42 2026-10-02 khóa cuối của ngày Khoảng trống RangeS-U, phiên A đang giữ hết chỉ mục 2026-10-03, 42 phiên B: chờ LCK_M_RS_U 2026-10-03, 108 phiên khác: cũng chờ Hai khóa mới đều rơi vào khoảng phiên A đang khóa, nên chờ A commit.
Đầu ngày chưa có dòng nào của 2026-10-03, nên một khóa phạm vi chặn mọi upsert của ngày mới cho tới khi phiên A commit.

3. Áp dụng trong .NET

Trong EF Core, lost update có dạng quen thuộc nhất: tải entity, trừ trong C#, SaveChangesAsync. EF gửi UPDATE ... SET TonKho = @p WHERE SanPhamId = @id, đúng giá trị tuyệt đối của timeline ở mục 1. Ba cách sửa ở mục 1 đều có dạng EF Core:

// Cách a: một câu UPDATE có điều kiện (EF Core 7 trở lên)
int soDong = await db.SanPham
    .Where(p => p.SanPhamId == 42 && p.TonKho >= soLuong)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.TonKho, p => p.TonKho - soLuong), ct);
if (soDong == 0) return Results.Conflict("Không đủ tồn kho.");

// Cách b: đọc với UPDLOCK trong giao dịch
await using var tx = await db.Database.BeginTransactionAsync(ct);
var sp = await db.SanPham
    .FromSql($"SELECT * FROM dbo.SanPham WITH (UPDLOCK, ROWLOCK) WHERE SanPhamId = {id}")
    .SingleAsync(ct);

// Cách c: rowversion làm concurrency token
public class SanPham
{
    public int SanPhamId { get; set; }
    public int TonKho { get; set; }
    [Timestamp] public byte[] PhienBan { get; set; } = [];   // EF thêm PhienBan vào WHERE
}

Với [Timestamp], SaveChangesAsync sửa 0 dòng thì ném DbUpdateConcurrencyException; màn hình kiểm kho bắt nó, tải lại và cho nhân viên nhập lại. FromSql nhận chuỗi nội suy và biến {id} thành tham số, không ghép chuỗi.

TonKho.cs chạy cả sáu kịch bản trên LocalDB (SQL Server 2019, 15.0.4382), .NET 10.0.401, EF Core 10.0.12. Mỗi kịch bản là hai task cùng mua 3 sản phẩm 42 khi tồn còn 5, hoặc cùng upsert dòng (2026-10-03, 42); một rào chắn bắt cả hai task đọc xong rồi mới ghi, để cuộc đua xảy ra mỗi lần chạy:

Cách viết Hai task nhận Kết quả trong database
Tải entity, trừ, SaveChangesAsync, không token Cả hai "bán 3" Tồn 2: mất một lần trừ
Như trên, entity có [Timestamp] Một "bán 3", một DbUpdateConcurrencyException Tồn 2
ExecuteUpdateAsync có điều kiện Một "bán 3", một "hết hàng" Tồn 2
FromSql với UPDLOCK, task sau bắt đầu sau 100 ms Task sau chờ 199 ms ở câu đọc, đọc 2, "hết hàng" Tồn 2
Upsert: FindAsync, Add nếu chưa có Một ghi được, một lỗi 2627 (2, 500.000): mất một đơn
Upsert: gọi dbo.usp_DoanhThuNgay_Cong Cả hai ghi được (4, 1.000.000)

Thời gian chờ ở dòng thứ tư đổi theo lần chạy, từ 199 đến 360 ms trong sáu lần: task sau chờ tới khi task trước xong 300 ms xử lý giữa đọc và ghi rồi commit. 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.

TonKho.cs, chạy bằng dotnet run TonKho.csC# · 174 dòng
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:property PublishAot=false
// Hai khách cùng mua 3 sản phẩm 42 khi tồn còn 5: bốn cách viết bằng EF Core. Rồi hai đơn đầu ngày cùng upsert doanh thu.
// Chạy: dotnet run TonKho.cs   (BANHANG_DB: chuỗi kết nối; cần cột dbo.SanPham.PhienBan, bảng dbo.DoanhThuNgay và thủ tục dbo.usp_DoanhThuNgay_Cong)
using System.ComponentModel.DataAnnotations;
using System.Diagnostics;
using Microsoft.EntityFrameworkCore;

string cs = Environment.GetEnvironmentVariable("BANHANG_DB")
    ?? @"Server=(localdb)\MSSQLLocalDB;Database=BanHang_Thu;Integrated Security=true;TrustServerCertificate=true";
System.Globalization.CultureInfo.CurrentCulture = new("vi-VN");

// 1. Đọc entity, trừ trong C#, SaveChanges. Không có concurrency token.
await DatTon(5);
var gap = new Gap(2);
var kq = await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => Task.Run(async () =>
{
    await using var db = new KhoDb(cs);
    var sp = await db.SanPham.SingleAsync(p => p.SanPhamId == 42);
    await gap.DenAsync();                       // cả hai đã đọc 5
    if (sp.TonKho < 3) return "hết hàng";
    sp.TonKho -= 3;
    await db.SaveChangesAsync();
    return "bán 3";
})));
Console.WriteLine($"1. SaveChanges, không token:      {string.Join(", ", kq)}; tồn còn {await DocTon()}");

// 2. Cùng code, entity có [Timestamp] PhienBan.
await DatTon(5);
gap = new Gap(2);
kq = await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => Task.Run(async () =>
{
    await using var db = new KhoDbToken(cs);
    var sp = await db.SanPham.SingleAsync(p => p.SanPhamId == 42);
    await gap.DenAsync();
    if (sp.TonKho < 3) return "hết hàng";
    sp.TonKho -= 3;
    try { await db.SaveChangesAsync(); return "bán 3"; }
    catch (DbUpdateConcurrencyException) { return "DbUpdateConcurrencyException"; }
})));
Console.WriteLine($"2. SaveChanges, có [Timestamp]:   {string.Join(", ", kq)}; tồn còn {await DocTon()}");

// 3. Một câu UPDATE có điều kiện: ExecuteUpdateAsync.
await DatTon(5);
gap = new Gap(2);
kq = await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => Task.Run(async () =>
{
    await using var db = new KhoDb(cs);
    await gap.DenAsync();
    int soDong = await db.SanPham
        .Where(p => p.SanPhamId == 42 && p.TonKho >= 3)
        .ExecuteUpdateAsync(s => s.SetProperty(p => p.TonKho, p => p.TonKho - 3));
    return soDong == 1 ? "bán 3" : "hết hàng";
})));
Console.WriteLine($"3. ExecuteUpdateAsync có điều kiện: {string.Join(", ", kq)}; tồn còn {await DocTon()}");

// 4. Đọc với UPDLOCK qua FromSql, ghi trong cùng giao dịch. B bắt đầu sau A 100 ms.
await DatTon(0); await MuaUpdlock(0);           // làm nóng: biên dịch truy vấn, mở pool
await DatTon(5);
kq = await Task.WhenAll(MuaUpdlock(0), MuaUpdlock(100));
Console.WriteLine($"4. FromSql với UPDLOCK:           {string.Join(" | ", kq)}; tồn còn {await DocTon()}");

// 5. Upsert kiểu đọc rồi chèn: FindAsync, Add nếu chưa có, SaveChanges.
var ngay = new DateTime(2026, 10, 3);
await XoaDoanhThu();
gap = new Gap(2);
kq = await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => Task.Run(async () =>
{
    await using var db = new KhoDb(cs);
    var dong = await db.DoanhThuNgay.FindAsync(ngay, 42);
    await gap.DenAsync();                       // cả hai đều thấy chưa có dòng
    if (dong is null) db.DoanhThuNgay.Add(new DoanhThuNgay { Ngay = ngay, SanPhamId = 42, SoLuong = 2, DoanhThu = 500000m });
    else { dong.SoLuong += 2; dong.DoanhThu += 500000m; }
    try { await db.SaveChangesAsync(); return "ghi được"; }
    catch (DbUpdateException ex) when (ex.InnerException is Microsoft.Data.SqlClient.SqlException { Number: 2627 }) { return "lỗi 2627"; }
})));
Console.WriteLine($"5. FindAsync rồi Add:             {string.Join(", ", kq)}; {await DocDoanhThu()}");

// 6. Gọi thủ tục UPDLOCK, HOLDLOCK.
await XoaDoanhThu();
gap = new Gap(2);
await Task.WhenAll(Enumerable.Range(0, 2).Select(_ => Task.Run(async () =>
{
    await using var db = new KhoDb(cs);
    await gap.DenAsync();
    await db.Database.ExecuteSqlAsync($"EXEC dbo.usp_DoanhThuNgay_Cong @Ngay = {ngay}, @SanPhamId = 42, @SoLuong = 2, @DoanhThu = 500000.00");
})));
Console.WriteLine($"6. usp_DoanhThuNgay_Cong:         cả hai ghi được; {await DocDoanhThu()}");

async Task XoaDoanhThu()
{
    await using var db = new KhoDb(cs);
    await db.Database.ExecuteSqlAsync($"DELETE dbo.DoanhThuNgay WHERE Ngay = {ngay}");
}

async Task<string> DocDoanhThu()
{
    await using var db = new KhoDb(cs);
    var d = await db.DoanhThuNgay.AsNoTracking().SingleAsync(x => x.Ngay == ngay && x.SanPhamId == 42);
    return $"dòng (2026-10-03, 42) = ({d.SoLuong}, {d.DoanhThu:N0})";
}

async Task<string> MuaUpdlock(int treMs)
{
    await Task.Delay(treMs);
    await using var db = new KhoDb(cs);
    await using var tx = await db.Database.BeginTransactionAsync();
    var dongHo = Stopwatch.StartNew();
    var sp = await db.SanPham
        .FromSql($"SELECT * FROM dbo.SanPham WITH (UPDLOCK, ROWLOCK) WHERE SanPhamId = {42}")
        .SingleAsync();
    long choMs = dongHo.ElapsedMilliseconds;
    if (sp.TonKho < 3) return $"đọc {sp.TonKho} sau {choMs} ms, hết hàng";
    await Task.Delay(300);                      // logic nhiều bước giữa đọc và ghi
    sp.TonKho -= 3;
    await db.SaveChangesAsync();
    await tx.CommitAsync();
    return $"đọc {sp.TonKho + 3} sau {choMs} ms, bán 3";
}

async Task DatTon(int ton)
{
    await using var db = new KhoDb(cs);
    await db.Database.ExecuteSqlAsync($"UPDATE dbo.SanPham SET TonKho = {ton} WHERE SanPhamId = 42");
}

async Task<int> DocTon()
{
    await using var db = new KhoDb(cs);
    return await db.SanPham.Where(p => p.SanPhamId == 42).Select(p => p.TonKho).SingleAsync();
}

public class SanPham
{
    public int SanPhamId { get; set; }
    public string MaSanPham { get; set; } = "";
    public int TonKho { get; set; }
    [Timestamp] public byte[] PhienBan { get; set; } = [];
}

public class DoanhThuNgay
{
    public DateTime Ngay { get; set; }
    public int SanPhamId { get; set; }
    public int SoLuong { get; set; }
    public decimal DoanhThu { get; set; }
}

public class KhoDb(string cs) : DbContext
{
    public DbSet<SanPham> SanPham => Set<SanPham>();
    public DbSet<DoanhThuNgay> DoanhThuNgay => Set<DoanhThuNgay>();
    protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseSqlServer(cs);
    protected override void OnModelCreating(ModelBuilder m)
    {
        m.Entity<SanPham>().ToTable("SanPham", "dbo");
        m.Entity<DoanhThuNgay>(e =>
        {
            e.ToTable("DoanhThuNgay", "dbo");
            e.HasKey(d => new { d.Ngay, d.SanPhamId });
            e.Property(d => d.Ngay).HasColumnType("date");
            e.Property(d => d.DoanhThu).HasColumnType("decimal(18, 2)");
        });
        if (GetType() == typeof(KhoDb)) m.Entity<SanPham>().Ignore(p => p.PhienBan);  // bản không có token
    }
}
public class KhoDbToken(string cs) : KhoDb(cs);

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 TonKho.cs trên LocalDB6 dòng
1. SaveChanges, không token:      bán 3, bán 3; tồn còn 2
2. SaveChanges, có [Timestamp]:   DbUpdateConcurrencyException, bán 3; tồn còn 2
3. ExecuteUpdateAsync có điều kiện: hết hàng, bán 3; tồn còn 2
4. FromSql với UPDLOCK:           đọc 5 sau 6 ms, bán 3 | đọc 2 sau 199 ms, hết hàng; tồn còn 2
5. FindAsync rồi Add:             ghi được, lỗi 2627; dòng (2026-10-03, 42) = (2, 500.000)
6. usp_DoanhThuNgay_Cong:         cả hai ghi được; dòng (2026-10-03, 42) = (4, 1.000.000)

Những chỗ hay hiểu sai

Hiểu sai Đúng là
Bật RCSI là hết chặn Ghi vẫn chặn ghi. Đọc rồi ghi vẫn mất cập nhật
SaveChangesAsync tự phát hiện ai đó đã sửa dòng Chỉ khi entity có concurrency token như [Timestamp]
Bọc IF NOT EXISTS ... INSERT trong BEGIN TRAN là đủ cho upsert Ở READ COMMITTED, câu kiểm không khóa được dòng chưa có; cần UPDLOCK, HOLDLOCK
CHECK (TonKho >= 0) chặn bán quá tồn Nó chỉ chặn số âm; hai lần ghi cùng giá trị 2 đều hợp lệ

Kết luận

Mọi luồng đọc một giá trị rồi ghi dựa trên nó phải tự khóa đúng chỗ: điều kiện nằm trong câu UPDATE, khóa U giữ từ câu đọc, hoặc một cột phiên bản để phát hiện người khác đã sửa.

Trong dự án .NET của bạn:

  • Tìm các chỗ tải entity, cộng trừ số lượng rồi SaveChangesAsync; đổi sang ExecuteUpdateAsync với điều kiện trong Where và kiểm số dòng trả về.
  • Thêm cột rowversion và [Timestamp] cho entity đi qua form sửa, bắt DbUpdateConcurrencyException và trả HTTP 409 cho client tải lại.
  • Logic nhiều bước dùng FromSql($"... WITH (UPDLOCK, ROWLOCK) ...") trong BeginTransactionAsync, và giữ giao dịch đó dưới vài chục mili giây.
  • Upsert gọi thủ tục có UPDLOCK, HOLDLOCK, không dùng FindAsync rồi Add.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

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.

13 phút đọc

Trong SQL Server

Khóa và leo thang khóa

SQL Server khóa những gì khi sửa một dòng, các chế độ khóa tương thích ra sao, khi nào khóa dòng leo thẳng lên khóa bảng, và cách chia lô job hàng loạt trong EF Core để không chặn bán hàng.

14 phút đọc

Trong SQL Server

Mức isolation và SNAPSHOT

Mức isolation nào chặn dirty read, non-repeatable read, phantom và write skew trên BanHang, SNAPSHOT khác RCSI ở đâu, và cách đặt mức isolation đúng từ EF Core và TransactionScope.

12 phút đọc