Thực chiến

Flash sale bán quá tồn kho: 300 máy, 1.490 đơn đã xác nhận

Tái hiện một đợt flash sale bán quá tồn trên SQL Server, sửa bằng trừ kho có điều kiện, đo dòng nóng, rồi thêm giữ hàng có hạn với job trả hàng.

Mục lục
  1. 1. Sự cố: TonKho về 0 đúng lúc, kho vẫn thiếu 1.190 máy
  2. 2. Tái hiện: hai khách cùng thấy "còn 1"
  3. 3. Sửa lần 1: trừ có điều kiện trong một câu
  4. 4. Dòng nóng: mọi lượt mua xếp hàng trên một dòng
  5. 5. Giữ hàng có hạn và job trả hàng
  6. 6. Hậu kiểm
  7. Những chỗ hay hiểu sai
  8. Đọc tiếp
  9. Nguồn

20:00 thứ Sáu 2026-09-18, BanHang mở flash sale mẫu điện thoại SP-00777: 300 máy, giá 4.990.000 đ thay cho 8.990.000 đ. Hai giây sau, trang sản phẩm báo hết hàng và TonKho về 0. Lúc 20:40, kho xuất danh sách soạn hàng và thấy 1.490 đơn đã xác nhận. Kho và CSKH phải hủy 1.190 đơn. Bài này dựng lại sự cố trên LocalDB, sửa bằng một câu UPDATE có điều kiện, rồi đi tiếp hai việc bản sửa đó chưa trả lời: dòng nóng và giữ hàng chờ thanh toán. Giờ giấc là minh họa, còn mọi số đơn là số đo khi tái hiện.

Đọc nhanh

  • Endpoint flash sale đọc TonKho, kiểm ở app, rồi ghi TonKho = giá trị đọc − 1. Với 2.000 khách tranh 300 máy: 1 phiên bán đúng 300, 8 phiên xác nhận 1.461 đến 1.525 đơn, từ 16 phiên cả 2.000 khách đều được xác nhận.
  • CHECK (TonKho >= 0) không chặn lần nào, vì mọi giá trị được ghi đều không âm. Nó chỉ chặn được khi câu ghi là TonKho = TonKho - 1.
  • Trừ có điều kiện cho 0 đơn bán quá, đổi lại một dòng nóng: ở 64 phiên, trung bình 61,6 phiên đứng chờ khóa của dòng đó, p95 khoảng 56 ms. Chia tồn thành 16 ô nhanh hơn 4 đến 5 lần ở tải liên tục nhưng chậm hơn ở đợt 300 máy, nên không dùng.
  • Giữ hàng 10 phút, trừ kho ngay lúc giữ. Không có job trả hàng, 58 máy bị giữ treo và chỉ bán được 242. Có job, bán đủ 300. Job phải sửa theo khóa chính trước: bản dò chỉ mục rồi sửa gây 9 deadlock trong 5 lần thử đua với lệnh báo thanh toán.

1. Sự cố: TonKho về 0 đúng lúc, kho vẫn thiếu 1.190 máy

Ba dấu hiệu khiến ca trực tin mọi thứ bình thường: TonKho về đúng 0, log API không có lỗi, và ràng buộc CK_SanPham_TonKho không chặn lần nào. Ngày thường cũng chưa từng bán quá. BanHang có khoảng 13.000 đơn mỗi ngày, trung bình 3 dòng mỗi đơn, rải trên 5.000 sản phẩm: chừng 8 dòng đơn cho một sản phẩm mỗi ngày (ước lượng). Hai lượt mua cùng một sản phẩm trong cùng vài mili giây gần như không xảy ra. Flash sale dồn 2.000 lượt vào một dòng dbo.SanPham trong hai giây.

2. Tái hiện: hai khách cùng thấy "còn 1"

Endpoint flash sale được viết riêng cho nhanh, không đi qua thủ tục ghi đơn dbo.usp_DonHang_Tao. Nó đọc TonKho, kiểm TonKho >= 1 trong C#, rồi ghi TonKho = giá trị đọc − 1. Câu SELECT ở READ COMMITTED nhả khóa S ngay khi đọc xong, nên giữa hai bước, phiên khác đọc được cùng giá trị.

sequenceDiagram
  participant A as Khách A
  participant B as Khách B
  participant API as API flash sale
  participant DB as dbo.SanPham
  A->>API: Mua SP-00777
  API->>DB: SELECT TonKho
  DB-->>API: 1
  B->>API: Mua SP-00777
  API->>DB: SELECT TonKho
  DB-->>API: 1
  API->>DB: UPDATE SET TonKho = 0, cho A
  API-->>A: Đặt hàng thành công
  API->>DB: UPDATE SET TonKho = 0, cho B
  Note over DB: Hai đơn, một máy, TonKho = 0 vẫn hợp lệ
  API-->>B: Đặt hàng thành công

Đây là mất cập nhật, đã phân tích ở Transaction, khóa và isolation. Câu hỏi của sự cố là nó lớn đến đâu khi 2.000 khách cùng vào. Tái hiện cần SQL Server 2019 (LocalDB là đủ), .NET 10 SDK và gói Microsoft.Data.SqlClient 7.1.1. Bảng dbo.SanPham giữ nguyên định nghĩa của BanHang:

-- sqlcmd -S "(localdb)\MSSQLLocalDB" -f 65001 -i a1-sanpham.sql
CREATE DATABASE Kumeo_flashsale COLLATE Vietnamese_100_CI_AS;
GO
USE Kumeo_flashsale;
GO
CREATE TABLE dbo.SanPham (            -- nguyên định nghĩa của BanHang
    SanPhamId int NOT NULL CONSTRAINT PK_SanPham PRIMARY KEY CLUSTERED,
    MaSanPham varchar(20) NOT NULL CONSTRAINT UQ_SanPham_Ma UNIQUE,
    Ten nvarchar(200) NOT NULL,
    DonGia decimal(18, 2) NOT NULL,
    TonKho int NOT NULL CONSTRAINT CK_SanPham_TonKho CHECK (TonKho >= 0)
);
INSERT INTO dbo.SanPham VALUES (777, 'SP-00777', N'Điện thoại 128 GB', 8990000, 300);

Harness mở N phiên, mỗi phiên một kết nối, lần lượt nhận khách từ một bộ đếm chung cho tới đủ 2.000 khách. cu là luồng cũ, cu-tru giữ chỗ kiểm ở app nhưng ghi tương đối, moi là bản sửa ở mục 3, tai dùng cho mục 4. Max Pool Size=200 vì pool mặc định chỉ có 100 kết nối.

#:package Microsoft.Data.SqlClient@7.1.1
using System.Collections.Concurrent;
using System.Diagnostics;
using Microsoft.Data.SqlClient;

// dotnet run flashsale.cs -- <cu|cu-tru|moi|tai> <số phiên>
const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_flashsale;Integrated Security=true;Encrypt=false;Max Pool Size=200";
const string ChoKhoa = "SELECT row_lock_wait_in_ms FROM sys.dm_db_index_operational_stats(DB_ID(), OBJECT_ID('dbo.SanPham'), 1, NULL)";
string cheDo = args[0];
int soPhien = int.Parse(args[1]);
bool tai = cheDo == "tai";                // tải liên tục 5 s, kho đặt rất lớn để không hết
async Task<int> MuaCu(SqlConnection cn, bool truTuongDoi)
{
    int ton = (int)(await new SqlCommand("SELECT TonKho FROM dbo.SanPham WHERE SanPhamId = 777", cn).ExecuteScalarAsync())!;
    if (ton < 1) return 0;                // app báo "hết hàng"
    var ghi = new SqlCommand($"UPDATE dbo.SanPham SET TonKho = {(truTuongDoi ? "TonKho - 1" : "@Moi")} WHERE SanPhamId = 777", cn);
    ghi.Parameters.AddWithValue("@Moi", ton - 1);   // ton là giá trị đọc ở câu trên
    return await ghi.ExecuteNonQueryAsync();   // 1: app báo "đặt hàng thành công"
}
Task<int> MuaMoi(SqlConnection cn) => new SqlCommand(
    "UPDATE dbo.SanPham SET TonKho = TonKho - 1 WHERE SanPhamId = 777 AND TonKho >= 1", cn).ExecuteNonQueryAsync();
Func<SqlConnection, Task<int>> mua = cheDo switch { "cu" => cn => MuaCu(cn, false), "cu-tru" => cn => MuaCu(cn, true), _ => MuaMoi };
async Task<long> Sql(string lenh)
{
    await using var cn = new SqlConnection(Cs);
    await cn.OpenAsync();
    return Convert.ToInt64(await new SqlCommand(lenh, cn).ExecuteScalarAsync());
}

await Sql($"UPDATE dbo.SanPham SET TonKho = {(tai ? 100_000_000 : 300)} WHERE SanPhamId = 777; SELECT 0;");
var phien = new List<SqlConnection>();
for (int i = 0; i < soPhien; i++) { var cn = new SqlConnection(Cs); await cn.OpenAsync(); phien.Add(cn); }
int khach = -1, xacNhan = 0;
var loi = new ConcurrentDictionary<int, int>();
var treMs = new ConcurrentBag<double>();
long choTruoc = await Sql(ChoKhoa);
var dongHo = Stopwatch.StartNew();
await Task.WhenAll(phien.Select(cn => Task.Run(async () =>
{
    while (tai ? dongHo.Elapsed.TotalSeconds < 5 : Interlocked.Increment(ref khach) < 2000)
    {
        long t0 = Stopwatch.GetTimestamp();
        try { if (await mua(cn) == 1) Interlocked.Increment(ref xacNhan); }
        catch (SqlException ex) { loi.AddOrUpdate(ex.Number, 1, (_, n) => n + 1); }
        treMs.Add(Stopwatch.GetElapsedTime(t0).TotalMilliseconds);
    }
})));
double giay = dongHo.Elapsed.TotalSeconds;
var tre = treMs.Order().ToArray();
double p95 = tre[(int)Math.Ceiling(0.95 * tre.Length) - 1];
string ketQua = tai
    ? $"{xacNhan / giay:F0} lần trừ/s, p95 {p95:F1} ms, {(await Sql(ChoKhoa) - choTruoc) / 1000.0 / giay:F1} phiên đứng chờ khóa dòng"
    : $"xác nhận {xacNhan}, bán quá {Math.Max(0, xacNhan - 300)}, TonKho cuối {await Sql("SELECT TonKho FROM dbo.SanPham WHERE SanPhamId = 777")}, " +
      $"lỗi [{string.Join(", ", loi.Select(e => $"{e.Key} x{e.Value}"))}], {giay * 1000:F0} ms, p95 {p95:F1} ms";
Console.WriteLine($"{cheDo}, {soPhien} phiên: {ketQua}");

Mỗi mức phiên chạy 3 lần trên LocalDB 2019 CU27 (15.0.4382), laptop Intel Core Ultra 5 125U, Windows 11:

Luồng cũ bán quá ngay từ 2 phiên đồng thời, từ 16 phiên mọi khách đều được xác nhận

1 phiên0 đơn2 phiên296 đơn4 phiên643 đơn8 phiên1.190 đơn16 phiên1.700 đơn32 phiên1.700 đơn64 phiên1.700 đơn
Số đơn bán quá, trung vị 3 lần chạy, 2.000 khách, 300 máy. Đơn xác nhận ở 8 phiên: 1.461, 1.490, 1.525.
Bảng số liệu
Giá trị
1 phiên0 đơn
2 phiên296 đơn
4 phiên643 đơn
8 phiên1.190 đơn
16 phiên1.700 đơn
32 phiên1.700 đơn
64 phiên1.700 đơn

Số bán quá đổi giữa các lần chạy vì nó phụ thuộc thứ tự lập lịch, chiều hướng thì không: càng nhiều phiên đọc cùng một giá trị, càng nhiều lần trừ bị ghi đè. Từ 16 phiên, lần ghi sau thường mang giá trị đọc từ trước nhiều lần trừ, nên TonKho đi xuống chậm hơn số đơn: cả 2.000 khách được xác nhận mà TonKho cuối còn 74 đến 242. Ở 8 phiên trở xuống, TonKho về đúng 0, như tối 18/9.

CK_SanPham_TonKho chỉ thấy giá trị được ghi. Luồng cũ ghi ton − 1 với ton >= 1, nên giá trị nào cũng hợp lệ: 21 lần chạy, không một lỗi 547. cu-tru chỉ đổi câu ghi thành TonKho = TonKho - 1. Câu đó đọc giá trị hiện tại dưới khóa X, nên không lần trừ nào bị mất, và lần trừ thứ 301 làm TonKho âm thì bị CHECK chặn: 0 đơn bán quá, và trong 3 lần chạy mỗi mức, 3 đến 5 khách ở 8 phiên, 20 khách ở 64 phiên nhận lỗi 547 thay vì "hết hàng". CHECK chặn được số âm, không chặn được một số dương sai ghi đè lên số đúng.

3. Sửa lần 1: trừ có điều kiện trong một câu

moi gộp đọc, kiểm và ghi vào một câu UPDATE ... WHERE TonKho >= 1, rồi chỉ báo thành công khi câu đó sửa đúng 1 dòng. Vì sao câu này đúng, kể cả khi bật RCSI, đã có ở cách a của bài transaction. Kết quả: 0 đơn bán quá ở 8 và 64 phiên, 3 lần mỗi mức. dbo.usp_DonHang_Tao vốn đã trừ kho kiểu này; lỗi nằm ở chỗ endpoint flash sale đi đường riêng. Cache vẫn hiển thị "còn X" trên trang sản phẩm, nhưng không còn tham gia quyết định bán.

4. Dòng nóng: mọi lượt mua xếp hàng trên một dòng

Bản sửa đúng, nhưng mọi lượt mua giờ cùng sửa dòng SanPhamId = 777. Khóa X của một lần trừ giữ đến khi commit xong, và commit mặc định chỉ trả về sau khi log đã ghi xuống đĩa. Tài liệu delayed durability của Microsoft nói điều này theo chiều ngược lại: bỏ chờ log thì khóa được nhả sớm hơn. Trong lúc đó, các phiên khác đứng chờ khóa U ở cùng dòng. Chế độ tai đo việc này: kho đặt rất lớn, chạy 5 giây, lấy row_lock_wait_in_ms của PK_SanPham chia cho thời gian chạy để ra số phiên trung bình đang đứng chờ. Trung vị 3 lần chạy:

Số phiên Lần trừ mỗi giây p95 (ms) Phiên đứng chờ khóa
1 796 2,8 0
4 2.740 2,8 2,1
16 2.987 10,3 14,0
64 1.855 55,9 61,6
128 1.967 106,8 124,7

p95 của một lần trừ tăng gần tỉ lệ với số phiên cùng chờ một dòng

p95 (ms)

050100150141664128

Số phiên

Chế độ tai, trung vị 3 lần chạy. Thời gian dao động lớn giữa các lần chạy, chỉ dùng để thấy bậc độ lớn.
Bảng số liệu
Số phiênUPDATE có điều kiện trên một dòng
12,8 ms
42,8 ms
1610,3 ms
6455,9 ms
128106,8 ms

Cột cuối ổn định nhất: từ 16 phiên trở lên, ở cả 9 lần chạy, gần như mọi phiên trừ một đang chờ. Từ 4 phiên, thông lượng nằm trong khoảng 1.855 đến 2.987 lần trừ mỗi giây và không tăng khi thêm phiên, còn p95 tăng theo số phiên, đúng hình dạng của một hàng đợi một cửa. Số thời gian dao động mạnh vì laptop thử chạy song song việc khác: thông lượng ở 4 phiên đo được từ 1.802 đến 3.597.

Với 300 máy, dòng nóng chỉ kéo dài khoảng 300 commit, chừng 0,1 đến 0,2 giây ở thông lượng trên (ước lượng). Sau đó câu UPDATE trượt điều kiện: không sửa dòng nào, không ghi log, không giữ X. moi với 64 phiên xử lý xong 2.000 khách trong 376 đến 578 ms.

Bài đã thử cách quen thuộc để giảm tranh chấp: chia tồn thành 16 ô trong một bảng riêng, mỗi ô nằm trên một page riêng để không tranh latch của page, mỗi lượt chọn ngẫu nhiên một ô rồi dò ô kế tiếp khi ô đó hết. Ở tải liên tục với kho lớn, chia ô có lợi: trung vị 7 cặp đo xen kẽ cho thông lượng gấp 4,1 lần ở 16 phiên và 5,1 lần ở 64 phiên, p95 ở 64 phiên giảm từ 72,8 xuống 15,1 ms. Ở đợt 300 máy với 2.000 khách và 64 phiên thì ngược lại: trong 5 cặp chạy xen kẽ, 16 ô mất 778 đến 1.458 ms, một dòng mất 272 đến 413 ms, tức chậm hơn 2,6 đến 5,4 lần. Hàng hết sau vài trăm commit, và mỗi lượt đến sau đó phải dò đủ 16 ô mới biết hết hàng. Chia ô còn tách tồn thật khỏi SanPham.TonKho, mọi luồng khác phải cộng 16 dòng. BanHang không dùng cách này cho flash sale vài trăm máy.

5. Giữ hàng có hạn và job trả hàng

Bản sửa lần 1 vẫn coi lúc bấm mua là lúc bán. Khách bỏ trang thanh toán thì máy nằm trong một đơn không ai trả tiền. Trừ kho lúc thanh toán thì bán quá chuyển sang lúc thanh toán: khách trả tiền xong mới biết hết hàng. BanHang chọn giữ hàng có hạn: bấm mua thì trừ kho và ghi một lượt giữ hết hạn sau 10 phút, thanh toán thành công thì lượt giữ thành đã bán, hết hạn hoặc thanh toán lỗi thì job trả máy về kho.

Mọi lần đổi chỉ chạy khi TrangThai = 1, nên thanh toán và job không cùng thắng. dbo.SanPham TonKho dbo.GiuHang TrangThai = 1 giữ tối đa 10 phút TrangThai = 2 đã bán TrangThai = 3 đã trả về kho bấm mua TonKho − 1 thanh toán thành công hết hạn, hoặc thanh toán lỗi: job trả hàng TonKho + 1, cùng giao dịch với bước đổi trạng thái
Mỗi máy luôn nằm ở đúng một chỗ: trong TonKho, đang giữ, hoặc đã bán.
-- sqlcmd -S "(localdb)\MSSQLLocalDB" -d Kumeo_flashsale -f 65001 -i a2-giuhang.sql
CREATE TABLE dbo.GiuHang (
    GiuHangId bigint IDENTITY NOT NULL CONSTRAINT PK_GiuHang PRIMARY KEY CLUSTERED,
    SanPhamId int NOT NULL,
    KhachHangId int NOT NULL,
    SoLuong int NOT NULL CONSTRAINT CK_GiuHang_SoLuong CHECK (SoLuong > 0),
    DonGia decimal(18, 2) NOT NULL,      -- giá flash sale lúc giữ
    TrangThai tinyint NOT NULL,          -- 1 đang giữ, 2 đã thanh toán, 3 đã trả về kho
    GiuLuc datetime2(3) NOT NULL,        -- giờ UTC
    HetHanLuc datetime2(3) NOT NULL,
    INDEX IX_GiuHang_HetHan (TrangThai, HetHanLuc)
);
GO
CREATE OR ALTER PROCEDURE dbo.usp_GiuHang_Tao
    @SanPhamId int, @KhachHangId int, @SoLuong int, @DonGia decimal(18, 2),
    @GiuGiay int = 600, @GiuHangId bigint = NULL OUTPUT
AS
BEGIN
    SET XACT_ABORT, NOCOUNT ON;
    DECLARE @Luc datetime2(3) = SYSUTCDATETIME();
    BEGIN TRANSACTION;
    UPDATE dbo.SanPham SET TonKho = TonKho - @SoLuong
    WHERE SanPhamId = @SanPhamId AND TonKho >= @SoLuong;
    IF @@ROWCOUNT = 1
    BEGIN
        INSERT INTO dbo.GiuHang (SanPhamId, KhachHangId, SoLuong, DonGia, TrangThai, GiuLuc, HetHanLuc)
        VALUES (@SanPhamId, @KhachHangId, @SoLuong, @DonGia, 1, @Luc, DATEADD(SECOND, @GiuGiay, @Luc));
        SET @GiuHangId = SCOPE_IDENTITY();
    END;
    COMMIT TRANSACTION;                  -- hết hàng thì @GiuHangId là NULL
END;
GO
-- Cổng báo kết quả, gọi lại nhiều lần vẫn đúng. Trả về trạng thái sau cùng: 2 đã bán,
-- 3 đã về kho (tiền đã trừ thì phải hoàn), 1 thanh toán lỗi và chờ job trả hàng.
CREATE OR ALTER PROCEDURE dbo.usp_GiuHang_KetQua @GiuHangId bigint, @ThanhCong bit
AS
BEGIN
    SET NOCOUNT ON;
    IF @ThanhCong = 1
        UPDATE dbo.GiuHang SET TrangThai = 2 WHERE GiuHangId = @GiuHangId AND TrangThai = 1;
    ELSE                                 -- cho hết hạn ngay
        UPDATE dbo.GiuHang SET HetHanLuc = SYSUTCDATETIME() WHERE GiuHangId = @GiuHangId AND TrangThai = 1;
    RETURN (SELECT TrangThai FROM dbo.GiuHang WHERE GiuHangId = @GiuHangId);
END;
GO
CREATE OR ALTER PROCEDURE dbo.usp_GiuHang_TraHetHan @Lo int = 1000
AS
BEGIN
    SET XACT_ABORT, NOCOUNT ON;
    DECLARE @Id TABLE (GiuHangId bigint PRIMARY KEY);
    DECLARE @Tra TABLE (SanPhamId int NOT NULL, SoLuong int NOT NULL);
    INSERT INTO @Id                      -- 1. Tìm ứng viên qua chỉ mục, chưa giữ khóa
    SELECT TOP (@Lo) GiuHangId FROM dbo.GiuHang WHERE TrangThai = 1 AND HetHanLuc <= SYSUTCDATETIME();
    BEGIN TRANSACTION;
    UPDATE g SET TrangThai = 3           -- 2. Sửa theo khóa chính, kiểm lại điều kiện
    OUTPUT inserted.SanPhamId, inserted.SoLuong INTO @Tra
    FROM dbo.GiuHang AS g INNER JOIN @Id AS i ON i.GiuHangId = g.GiuHangId
    WHERE g.TrangThai = 1 AND g.HetHanLuc <= SYSUTCDATETIME();
    UPDATE sp SET TonKho = sp.TonKho + t.SoLuong      -- 3. Cộng lại kho, cùng giao dịch
    FROM dbo.SanPham AS sp
    INNER JOIN (SELECT SanPhamId, SUM(SoLuong) AS SoLuong FROM @Tra GROUP BY SanPhamId) AS t
        ON t.SanPhamId = sp.SanPhamId;
    COMMIT TRANSACTION;
    SELECT ISNULL(SUM(SoLuong), 0) AS DaTra FROM @Tra;
END;
GO

usp_GiuHang_Tao trừ kho có điều kiện và ghi lượt giữ trong cùng một giao dịch, nên không có lượt giữ nào mà kho chưa trừ. Mọi lần đổi trạng thái đều kèm TrangThai = 1: thanh toán thành công và job cùng nhắm một lượt giữ vừa hết hạn thì chỉ một câu sửa được dòng, câu kia chờ khóa, kiểm lại và thấy điều kiện sai. usp_GiuHang_KetQua trả trạng thái sau cùng thay vì số dòng bị sửa, vì cổng có thể gửi lại webhook như ở bài idempotency: lần gửi thứ hai nhận 2, không bị hiểu nhầm thành phải hoàn tiền. Đơn tạo từ lượt giữ đã thanh toán không trừ kho lần nữa.

Mô phỏng dưới chạy đợt sale với thời gian thu nhỏ 200 lần: 2.000 khách, 300 máy, giữ 3 giây thay cho 10 phút, job mỗi 0,3 giây thay cho mỗi phút. Khách gặp hết hàng thử lại sau 1 giây, tới giây thứ 15 thì thôi. Kết cục của lượt giữ thứ i lấy từ Random(42) với tỉ lệ giả định: 80% trả tiền, 5% thanh toán lỗi, 15% bỏ đi.

#:package Microsoft.Data.SqlClient@7.1.1
using System.Diagnostics;
using Microsoft.Data.SqlClient;

// dotnet run giuhang.cs -- <co-job|khong-job>. Thời gian thu nhỏ 200 lần: giữ 10 phút = 3 s, job mỗi phút = 0,3 s.
const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_flashsale;Integrated Security=true;Encrypt=false;Max Pool Size=200";
bool coJob = args[0] == "co-job";
var rng = new Random(42);   // kết cục của lượt giữ thứ i: T trả tiền 80%, L thanh toán lỗi 5%, B bỏ đi 15%
var ketCuc = Enumerable.Range(0, 5000).Select(_ => rng.Next(100) switch { < 80 => 'T', < 85 => 'L', _ => 'B' }).ToArray();
var choCong = Enumerable.Range(0, 5000).Select(_ => 0.2 + 1.3 * rng.NextDouble()).ToArray();  // giây tới khi cổng báo
SemaphoreSlim phienMua = new(64), phienCong = new(16);   // webhook của cổng có nhóm worker riêng
async Task<object?> Sql(SemaphoreSlim phien, string lenh, long a = 0)
{
    await phien.WaitAsync();
    try
    {
        await using var cn = new SqlConnection(Cs);
        await cn.OpenAsync();
        var cmd = new SqlCommand(lenh, cn);
        cmd.Parameters.AddWithValue("@a", a);
        return await cmd.ExecuteScalarAsync();
    }
    finally { phien.Release(); }
}

await Sql(phienMua, "TRUNCATE TABLE dbo.GiuHang; UPDATE dbo.SanPham SET TonKho = 300 WHERE SanPhamId = 777;");
int jobTra = 0, phaiHoan = 0, khongMuaDuoc = 0;
var dongHo = Stopwatch.StartNew();
var job = Task.Run(async () =>
{
    while (coJob && dongHo.Elapsed.TotalSeconds < 20)
    { jobTra += (int)(await Sql(phienCong, "EXEC dbo.usp_GiuHang_TraHetHan;"))!; await Task.Delay(300); }
});
var khach = Enumerable.Range(1, 2000).Select(k => Task.Run(async () =>
{
    while (dongHo.Elapsed.TotalSeconds < 15)
    {
        var id = await Sql(phienMua, "DECLARE @Id bigint; EXEC dbo.usp_GiuHang_Tao 777, @a, 1, 4990000, 3, @Id OUTPUT; SELECT @Id;", k);
        if (id is DBNull) { await Task.Delay(1000); continue; }   // hết hàng: 1 s sau thử lại
        int i = (int)(long)id!;
        if (ketCuc[i] == 'B') return;                               // bỏ đi, không thanh toán
        await Task.Delay(TimeSpan.FromSeconds(choCong[i]));
        int thanhCong = ketCuc[i] == 'T' ? 1 : 0;
        var r = await Sql(phienCong, $"DECLARE @r int; EXEC @r = dbo.usp_GiuHang_KetQua @a, {thanhCong}; SELECT @r;", i);
        if (thanhCong == 1 && (int)r! == 3) Interlocked.Increment(ref phaiHoan);   // đã về kho: hoàn tiền
        return;
    }
    Interlocked.Increment(ref khongMuaDuoc);
})).ToArray();
await Task.WhenAll(khach.Append(job));
Console.WriteLine(args[0] + ": " + await Sql(phienMua, """
    SELECT CONCAT(N'lượt giữ ', COUNT(*), N', đã bán ', SUM(IIF(TrangThai = 2, SoLuong, 0)),
        N', còn giữ ', SUM(IIF(TrangThai = 1, SoLuong, 0)), N', đã trả về kho ', SUM(IIF(TrangThai = 3, SoLuong, 0)),
        N', TonKho ', (SELECT TonKho FROM dbo.SanPham WHERE SanPhamId = 777))
    FROM dbo.GiuHang;
    """) + $", job trả {jobTra}, phải hoàn tiền {phaiHoan}, khách không mua được {khongMuaDuoc}");

Không có job, 58 máy nằm mãi ở TrangThai = 1 dù đã quá hạn: 15 lượt thanh toán lỗi và 43 lượt bỏ đi trong 300 lượt giữ đầu. 1.700 khách bị báo hết hàng trong khi 58 máy vẫn trên kệ. Có job, 72 máy quay về kho, lượt giữ thứ 301 đến 372 bán thêm 58 máy, đủ 300, và không lượt thanh toán nào phải hoàn tiền. Ở cả hai chế độ, TonKho cộng số đang giữ cộng số đã bán bằng 300: không bán quá. Ba lần chạy mỗi chế độ cho cùng các con số, vì kết cục gắn với số thứ tự lượt giữ và các mốc thời gian cách xa độ trễ của một lệnh.

Bản đầu của job tìm và sửa trong một câu UPDATE TOP (@Lo) ... WHERE TrangThai = 1 AND HetHanLuc <= .... Câu đó đi qua IX_GiuHang_HetHan trước rồi mới tới khóa chính. Lệnh báo thanh toán đi ngược lại: khóa chính trước, rồi sửa IX_GiuHang_HetHan vì TrangThai nằm trong chỉ mục. Phép thử đua cho 2.000 lượt giữ vừa hết hạn, 64 phiên báo thanh toán thành công chạy cùng 4 phiên chạy job. Bản đầu gây 9 deadlock trong 5 lần chạy, 3 lần nạn nhân là lệnh báo thanh toán. Mọi đồ thị deadlock trong system_health lặp lại một mẫu: lệnh báo thanh toán giữ X trên PK_GiuHang và chờ X trên IX_GiuHang_HetHan, job giữ U trên IX_GiuHang_HetHan và chờ U trên PK_GiuHang. Bản trong bài tách bước tìm khỏi bước sửa và sửa theo khóa chính, cùng thứ tự với lệnh thanh toán: 0 deadlock trong 5 lần, dù mỗi lần job vẫn giành được 5 đến 10 lượt giữ trước lệnh thanh toán. Quy tắc cùng thứ tự truy cập nằm ở mục deadlock của bài transaction.

6. Hậu kiểm

Chỉ số, 2.000 khách, 300 máy Luồng cũ Sau sửa
Bán quá, 8 phiên, 3 lần chạy 1.161, 1.190, 1.225 0, 0, 0
Bán quá, 64 phiên, 3 lần chạy 1.700 cả ba lần 0, 0, 0
Thời gian xử lý, 64 phiên, trung vị 1.452 ms 532 ms
p95 một lượt mua, 64 phiên, trung vị 69,6 ms 38,7 ms
Máy giữ treo sau đợt sale Chưa có giữ hàng 58 khi tắt job, 0 khi có job

Hai dòng đầu là kết luận chính: chúng là số đếm và lặp lại được. Hai dòng giữa là thời gian trên một laptop đang bận, chỉ cho thấy bản sửa không chậm hơn: mỗi khách bớt một lượt đi về với database. Sau sự cố, BanHang thêm một cảnh báo chạy mỗi phút trong đợt sale: TonKho cộng số đang giữ cộng số đã bán phải bằng số máy nhập cho đợt. Lệch một máy là gọi người trực.

Những chỗ hay hiểu sai

  • "Có CHECK (TonKho >= 0) thì không bán quá được." CHECK chỉ chặn số âm. Luồng cũ ghi đè bằng số dương sai: 21 lần chạy, không lỗi 547 nào.
  • "Bọc SELECT và UPDATE trong một giao dịch là đủ." Ở READ COMMITTED, khóa S nhả ngay sau câu đọc, hai phiên vẫn cùng đọc 1.
  • "Dòng nóng thì phải chia ô." Chia ô chỉ có lợi khi hàng còn lâu mới hết. Ở đợt 300 máy, nó chậm hơn một dòng 2,6 đến 5,4 lần.
  • "Job trả hàng chỉ cần một câu UPDATE TOP." Thứ tự khóa của câu đó ngược với lệnh báo thanh toán: 9 deadlock trong 5 lần thử đua.

Đọc tiếp

Nguồn

Đọc tiếp

Trong SQL Server

Transaction, khóa và mức isolation

Giao dịch, chế độ khóa, leo thang khóa, mức isolation và các hiện tượng đồng thời trên BanHang, kèm cách tìm phiên chặn và đọc deadlock.

52 phút đọc