Chuyển tiền giữa hai ví: sổ cái bút toán kép và deadlock khi chuyển chéo
Ba cách trừ và cộng tiền giữa hai ví BHPay trên SQL Server, đo bằng 16 phiên đồng thời: mất cập nhật, deadlock 1205, và khóa hai ví theo thứ tự ViId.
21:04 một tối thứ Sáu, Lan chuyển cho Minh 350.000 đ tiền ăn tối qua ví BHPay, gần như cùng lúc Minh chuyển cho Lan 120.000 đ tiền taxi. Trước đó ví Lan có 1.000.000, ví Minh 500.000. App báo cả hai lệnh thành công, rồi ví Lan hiện 1.120.000, ví Minh 380.000: khoản 350.000 không rời ví Lan, cũng không tới ví Minh. Bài này dựng lệnh chuyển P2P trên sổ cái bút toán kép và đo ba cách cài trên SQL Server.
Đọc nhanh
- Mỗi lệnh sinh hai bút toán −x và +x.
Vi.SoDulà số tính sẵn, phải bằng tổng bút toán của ví, và tổng mọi bút toán bằng 0. - Đọc số dư về app, tính rồi ghi: trên 6.400 lệnh, khoảng 4.900 lần ghi đè, gần như mọi ví lệch sổ, tổng số dư lệch tới 4.920.000 đ.
- Một câu
UPDATE ... SoDu - @SoTien WHERE ... SoDu >= @SoTienđúng tiền, nhưng hai lệnh chéo khóa hai ví ngược thứ tự: 305 đến 373 deadlock 1205 mỗi vòng. - Khóa hai ví theo
ViIdtăng dần: 0 deadlock.MaLenhdo client sinh làm lần gửi lại không chuyển tiền lần hai.
1. Hai lệnh chéo lúc 21:04
Bản đầu tiên, gọi là cách 1, đọc số dư hai ví về app, kiểm đủ tiền, tính số mới, rồi mở giao dịch ghi hai số đó cùng sổ cái. Mốc giờ là minh họa:
21:04:07,120 Phiên L (Lan → Minh 350.000) đọc: Lan 1.000.000, Minh 500.000
21:04:07,135 Phiên M (Minh → Lan 120.000) đọc: Minh 500.000, Lan 1.000.000
21:04:07,160 Phiên L ghi Lan = 650.000, Minh = 850.000, COMMIT
21:04:07,190 Phiên M ghi Minh = 380.000, Lan = 1.120.000, COMMIT
M tính trên số đọc trước khi L commit, nên lần ghi của M xóa kết quả của L. Số đúng là Lan 770.000, Minh 730.000. Đây là mất cập nhật (lost update), như tồn kho ở mục 6 của chương transaction. Bọc câu đọc trong giao dịch READ COMMITTED không cứu được, vì khóa S nhả ngay sau khi đọc. Nếu hai phiên tới bước ghi cùng lúc, mỗi phiên giữ một ví chờ ví kia, và một phiên nhận lỗi 1205.
2. Sổ cái bút toán kép
Chỉ có một cột số dư thì không tính lại được ví Lan đúng ra có bao nhiêu. Sổ cái giữ lịch sử: mỗi lệnh sinh hai bút toán, −350.000 cho ví Lan và +350.000 cho ví Minh, chỉ thêm, không sửa, không xóa. Kế toán đòi tổng Nợ bằng tổng Có; ở đây một cột có dấu thay hai cột, nên điều kiện thành tổng bằng 0. Định nghĩa của database BHPay, tách khỏi BanHang:
CREATE TABLE dbo.Vi (
ViId int NOT NULL CONSTRAINT PK_Vi PRIMARY KEY CLUSTERED,
KhachHangId int NULL, -- NULL với tài khoản hệ thống
LoaiVi tinyint NOT NULL, -- 1 ví khách, 2 ví cửa hàng, 9 đối ứng ngân hàng liên kết
SoDu decimal(18, 2) NOT NULL CONSTRAINT DF_Vi_SoDu DEFAULT 0,
CONSTRAINT CK_Vi_SoDu CHECK (SoDu >= 0 OR LoaiVi = 9)
);
CREATE TABLE dbo.LenhChuyen (
LenhChuyenId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_LenhChuyen PRIMARY KEY CLUSTERED,
MaLenh uniqueidentifier NOT NULL, -- client sinh, gửi lại y nguyên ở mọi lần thử
ViGui int NOT NULL CONSTRAINT FK_LenhChuyen_ViGui REFERENCES dbo.Vi (ViId),
ViNhan int NOT NULL CONSTRAINT FK_LenhChuyen_ViNhan REFERENCES dbo.Vi (ViId),
SoTien decimal(18, 2) NOT NULL CONSTRAINT CK_LenhChuyen_SoTien CHECK (SoTien > 0),
TaoLuc datetime2(3) NOT NULL CONSTRAINT DF_LenhChuyen_TaoLuc DEFAULT SYSUTCDATETIME(),
CONSTRAINT UQ_LenhChuyen_MaLenh UNIQUE (ViGui, MaLenh),
CONSTRAINT CK_LenhChuyen_HaiVi CHECK (ViGui <> ViNhan)
);
CREATE TABLE dbo.ButToan (
ButToanId bigint IDENTITY(1, 1) NOT NULL CONSTRAINT PK_ButToan PRIMARY KEY CLUSTERED,
LenhChuyenId bigint NOT NULL CONSTRAINT FK_ButToan_LenhChuyen REFERENCES dbo.LenhChuyen (LenhChuyenId),
ViId int NOT NULL CONSTRAINT FK_ButToan_Vi REFERENCES dbo.Vi (ViId),
SoTien decimal(18, 2) NOT NULL CONSTRAINT CK_ButToan_SoTien CHECK (SoTien <> 0) -- âm: ví giảm, dương: ví tăng
);
CREATE INDEX IX_ButToan_Vi ON dbo.ButToan (ViId) INCLUDE (SoTien);
Tiền nạp từ ngân hàng liên kết cũng là một lệnh chuyển, từ tài khoản đối ứng ViId = 1 (LoaiVi = 9) sang ví khách. Tài khoản này được phép âm, và phần âm đúng bằng tổng tiền trong mọi ví, con số đem so với sao kê ngân hàng.
SoDu chỉ là số tính sẵn, cập nhật trong cùng giao dịch với bút toán. Từ đó có hai bất biến: SoDu mỗi ví bằng tổng bút toán của ví, và tổng mọi bút toán, kể cả của tài khoản đối ứng, bằng 0. Truy vấn đối soát kiểm cả hai, mọi cột phải ra 0:
SELECT
(SELECT COUNT(*) FROM dbo.Vi AS v
WHERE v.SoDu <> (SELECT ISNULL(SUM(b.SoTien), 0) FROM dbo.ButToan AS b WHERE b.ViId = v.ViId)) AS ViLech,
(SELECT COUNT(*) FROM dbo.Vi AS v
WHERE v.LoaiVi <> 9 AND (SELECT SUM(b.SoTien) FROM dbo.ButToan AS b WHERE b.ViId = v.ViId) < 0) AS ViAmTheoSoCai,
(SELECT COUNT(*) FROM (SELECT LenhChuyenId FROM dbo.ButToan GROUP BY LenhChuyenId
HAVING SUM(SoTien) <> 0 OR COUNT(*) <> 2) AS x) AS LenhKhongCan,
(SELECT SUM(SoTien) FROM dbo.ButToan) AS TongButToan,
(SELECT SUM(SoDu) FROM dbo.Vi) AS TongSoDu;
ViLech bắt đúng lỗi lúc 21:04: ví Lan có SoDu 1.120.000 nhưng tổng bút toán 770.000. ViAmTheoSoCai đếm ví đã tiêu quá số tiền có. TongSoDu khác 0 là tiền tự sinh ra hoặc mất đi.
3. UPDATE có điều kiện và deadlock khi chuyển chéo
Cách 2 để SQL Server trừ trên giá trị hiện tại, như cách a của chương transaction: UPDATE dbo.Vi SET SoDu = SoDu - @SoTien WHERE ViId = @ViGui AND SoDu >= @SoTien, kiểm @@ROWCOUNT, rồi cộng ví nhận trong cùng giao dịch (chuỗi lenh của harness ở mục 5). UPDATE giữ khóa X đến hết giao dịch, nên phiên đến sau chờ rồi trừ trên số đã commit. Hết mất cập nhật, nhưng thứ tự khóa đi theo chiều chuyển: ví gửi trước, ví nhận sau.
sequenceDiagram participant L as Lệnh Lan → Minh participant VL as Ví 41206 của Lan participant VM as Ví 73588 của Minh participant M as Lệnh Minh → Lan L->>VL: Trừ 350.000, giữ X M->>VM: Trừ 120.000, giữ X L->>VM: Cộng 350.000, chờ U M->>VL: Cộng 120.000, chờ U Note over VL,VM: Vòng chờ khép kín VL-->>M: Nạn nhân deadlock, Msg 1205, rollback VM-->>L: M nhả X, L cộng xong rồi COMMIT
Lock monitor tìm chu trình mỗi 5 giây, rút xuống tới 100 mili giây khi deadlock dày, rồi rollback một phiên (mục 10 của chương transaction). Tiền vẫn đúng vì app chạy lại cả lệnh. Cái giá là khoảng chờ và lần chạy lại.
4. Khóa hai ví theo thứ tự ViId
Cách 3 dựa trên một nhận xét: muốn chờ nhau thành vòng, phải có một phiên giữ ví lớn mà chờ ví nhỏ hơn. Nếu mọi lệnh khóa ví có ViId nhỏ trước, điều đó không xảy ra: hai lệnh của Lan và Minh đều khóa ví 41206 trước, lệnh đến sau xếp hàng ngay ở bước đầu. Hướng dẫn deadlock của Microsoft gọi đây là truy cập đối tượng theo cùng thứ tự.
CREATE OR ALTER PROCEDURE dbo.usp_LenhChuyen_Tao
@MaLenh uniqueidentifier, @ViGui int, @ViNhan int, @SoTien decimal(18, 2)
AS
BEGIN
SET NOCOUNT ON;
SET XACT_ABORT ON;
DECLARE @ViNho int = IIF(@ViGui < @ViNhan, @ViGui, @ViNhan);
DECLARE @ViLon int = IIF(@ViGui < @ViNhan, @ViNhan, @ViGui);
DECLARE @LenhChuyenId bigint, @SoViKhoa int = 0;
BEGIN TRY
BEGIN TRANSACTION;
-- 1. Khóa hai ví theo thứ tự ViId tăng dần. Khóa U giữ đến hết giao dịch.
SELECT @SoViKhoa += 1 FROM dbo.Vi WITH (UPDLOCK, ROWLOCK) WHERE ViId = @ViNho;
SELECT @SoViKhoa += 1 FROM dbo.Vi WITH (UPDLOCK, ROWLOCK) WHERE ViId = @ViLon;
IF @SoViKhoa <> 2
THROW 50011, N'Ví không tồn tại.', 1;
-- 2. Lệnh gửi lại với cùng MaLenh: trả lệnh cũ, không chuyển lần hai.
SELECT @LenhChuyenId = LenhChuyenId FROM dbo.LenhChuyen
WHERE ViGui = @ViGui AND MaLenh = @MaLenh;
IF @LenhChuyenId IS NULL
BEGIN
-- 3. Trừ có điều kiện rồi cộng. Cả hai dòng đã nằm trong tay phiên này.
UPDATE dbo.Vi SET SoDu = SoDu - @SoTien WHERE ViId = @ViGui AND SoDu >= @SoTien;
IF @@ROWCOUNT = 0
THROW 50010, N'Số dư không đủ.', 1;
UPDATE dbo.Vi SET SoDu = SoDu + @SoTien WHERE ViId = @ViNhan;
-- 4. Một lệnh, hai bút toán, tổng bằng 0.
INSERT INTO dbo.LenhChuyen (MaLenh, ViGui, ViNhan, SoTien) VALUES (@MaLenh, @ViGui, @ViNhan, @SoTien);
SET @LenhChuyenId = SCOPE_IDENTITY();
INSERT INTO dbo.ButToan (LenhChuyenId, ViId, SoTien)
VALUES (@LenhChuyenId, @ViGui, -@SoTien), (@LenhChuyenId, @ViNhan, @SoTien);
END;
COMMIT TRANSACTION;
SELECT @LenhChuyenId AS LenhChuyenId;
END TRY
BEGIN CATCH
IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;
END;
GO
- Bước 1 dùng
UPDLOCK, ROWLOCKnhư cách b của chương transaction. Không gộp thành một câuWHERE ViId IN (...): câu nhiều dòng không bảo đảm khóa dòng nào trước. - Bước 2 làm lệnh idempotent. App sinh
MaLenhkhi khách bấm Chuyển và gửi lại đúng mã đó ở mọi lần thử, như idempotency key. Hai lần gửi cùng mã xếp hàng ở bước 1, nên lần sau thấy lệnh của lần trước. Trên LocalDB, 20 lần gọi song song cùng mộtMaLenh, lặp ba lần, lần nào cũng ra đúng một lệnh. - Chạy lại sau 1205 an toàn vì giao dịch đã rollback. Chạy lại sau timeout chỉ an toàn nhờ bước 2.
- Nạp, rút, hoàn tiền cũng phải khóa theo thứ tự này, nếu không deadlock quay lại.
5. Đo ba cách với 16 phiên
Harness là một file-based app, cần .NET SDK 10. Mỗi lần chạy nạp lại 20 ví, mỗi ví 2.000.000, rồi 16 phiên song song, mỗi phiên 400 lệnh giữa hai ví ngẫu nhiên, hạt giống 42 cộng số phiên. Gặp 1205 thì chạy lại với cùng MaLenh. Cách 1 ghi kèm OUTPUT deleted.SoDu: giá trị bị đè khác số đã đọc là một cập nhật vừa mất.
#:package Microsoft.Data.SqlClient@7.1.1
using Microsoft.Data.SqlClient;
string cach = args[0]; // doc-ghi | tru-cong | thu-tu
string cs = args.Length > 1 ? args[1] : @"Server=(localdb)\MSSQLLocalDB;Database=BHPay;Integrated Security=true";
const string SoCai = """
INSERT INTO dbo.LenhChuyen (MaLenh, ViGui, ViNhan, SoTien) VALUES (@MaLenh, @ViGui, @ViNhan, @SoTien);
DECLARE @Id bigint = SCOPE_IDENTITY();
INSERT INTO dbo.ButToan (LenhChuyenId, ViId, SoTien) VALUES (@Id, @ViGui, -@SoTien), (@Id, @ViNhan, @SoTien);
""";
string lenh = cach == "thu-tu" ? "EXEC dbo.usp_LenhChuyen_Tao @MaLenh, @ViGui, @ViNhan, @SoTien;" : $"""
SET XACT_ABORT ON; BEGIN TRANSACTION;
UPDATE dbo.Vi SET SoDu = SoDu - @SoTien WHERE ViId = @ViGui AND SoDu >= @SoTien;
IF @@ROWCOUNT = 0 THROW 50010, N'Số dư không đủ.', 1;
UPDATE dbo.Vi SET SoDu = SoDu + @SoTien WHERE ViId = @ViNhan;
{SoCai} COMMIT;
""";
await using var chuan = new SqlConnection(cs); // làm lại dữ liệu: 20 ví, mỗi ví nạp 2.000.000 từ tài khoản 1
await chuan.OpenAsync();
await Sql(chuan, null, """
DELETE dbo.ButToan; DELETE dbo.LenhChuyen; DELETE dbo.Vi;
INSERT INTO dbo.Vi (ViId, LoaiVi) VALUES (1, 9);
INSERT INTO dbo.Vi (ViId, KhachHangId, LoaiVi)
SELECT 10000 + n, n, 1 FROM (SELECT ROW_NUMBER() OVER (ORDER BY object_id) AS n FROM sys.all_objects) AS s WHERE n <= 20;
INSERT INTO dbo.LenhChuyen (MaLenh, ViGui, ViNhan, SoTien) SELECT NEWID(), 1, ViId, 2000000 FROM dbo.Vi WHERE LoaiVi = 1;
INSERT INTO dbo.ButToan (LenhChuyenId, ViId, SoTien)
SELECT LenhChuyenId, ViGui, -SoTien FROM dbo.LenhChuyen UNION ALL SELECT LenhChuyenId, ViNhan, SoTien FROM dbo.LenhChuyen;
UPDATE dbo.Vi SET SoDu = (SELECT SUM(SoTien) FROM dbo.ButToan AS b WHERE b.ViId = Vi.ViId);
""");
long xong = 0, tuChoi = 0, deadlock = 0, ghiDe = 0, batDau = Environment.TickCount64;
await Parallel.ForAsync(0, 16, new ParallelOptions { MaxDegreeOfParallelism = 16 }, async (phien, _) =>
{
var rng = new Random(42 + phien); // 16 phiên, mỗi phiên 400 lệnh cố định
await using var cn = new SqlConnection(cs);
await cn.OpenAsync();
for (int i = 0; i < 400; i++)
{
int gui = 10001 + rng.Next(20), nhan = 10001 + rng.Next(19), n;
if (nhan >= gui) nhan++; // ví nhận khác ví gửi
decimal soTien = rng.Next(1, 21) * 10_000m;
var p = new (string, object)[] { ("@MaLenh", Guid.NewGuid()), ("@ViGui", gui), ("@ViNhan", nhan), ("@SoTien", soTien) };
while (true) // gặp 1205: chạy lại cả lệnh, cùng MaLenh
{
try { n = cach == "doc-ghi" ? await DocTinhGhi(cn, gui, nhan, soTien, p) : await Lenh(cn, lenh, p); break; }
catch (SqlException e) when (e.Number == 50010) { n = -1; break; }
catch (SqlException e) when (e.Number == 1205) { Interlocked.Increment(ref deadlock); }
}
if (n < 0) Interlocked.Increment(ref tuChoi);
else { Interlocked.Increment(ref xong); Interlocked.Add(ref ghiDe, n); }
}
});
double giay = (Environment.TickCount64 - batDau) / 1000.0;
Console.WriteLine($"{cach} xong={xong} tu_choi={tuChoi} deadlock={deadlock} ghi_de={ghiDe} lenh_moi_giay={xong / giay:0}");
// Cách 1: đọc số dư về app, tính, ghi giá trị mới. Trả số lần đè lên thay đổi của phiên khác, -1 khi thiếu tiền.
async Task<int> DocTinhGhi(SqlConnection cn, int gui, int nhan, decimal soTien, (string, object)[] p)
{
decimal soDuGui = (decimal)(await Sql(cn, null, "SELECT SoDu FROM dbo.Vi WHERE ViId = @ViGui", p))!;
decimal soDuNhan = (decimal)(await Sql(cn, null, "SELECT SoDu FROM dbo.Vi WHERE ViId = @ViNhan", p))!;
if (soDuGui < soTien) return -1;
await using var tx = (SqlTransaction)await cn.BeginTransactionAsync();
const string Ghi = "UPDATE dbo.Vi SET SoDu = @Moi OUTPUT deleted.SoDu WHERE ViId = @ViId";
decimal cuGui = (decimal)(await Sql(cn, tx, Ghi, ("@Moi", soDuGui - soTien), ("@ViId", gui)))!;
decimal cuNhan = (decimal)(await Sql(cn, tx, Ghi, ("@Moi", soDuNhan + soTien), ("@ViId", nhan)))!;
await Sql(cn, tx, SoCai, p);
await tx.CommitAsync();
return (cuGui != soDuGui ? 1 : 0) + (cuNhan != soDuNhan ? 1 : 0);
}
static async Task<int> Lenh(SqlConnection cn, string sql, (string, object)[] p) { await Sql(cn, null, sql, p); return 0; }
static async Task<object?> Sql(SqlConnection cn, SqlTransaction? tx, string sql, params (string, object)[] p)
{
using var cmd = new SqlCommand(sql, cn, tx);
foreach (var (ten, giaTri) in p) cmd.Parameters.AddWithValue(ten, giaTri);
return await cmd.ExecuteScalarAsync();
}
Mỗi vòng chạy dotnet run ChuyenTien.cs -- doc-ghi, rồi tru-cong, thu-tu, mỗi lần xong chạy truy vấn đối soát trên laptop Intel Core Ultra 5 125U, Windows 11, LocalDB 15.0.4382 (SQL Server 2019 CU27), .NET SDK 10.0.401, Microsoft.Data.SqlClient 7.1.1. Ví lệch, ví âm là ViLech, ViAmTheoSoCai. Mỗi ô ghi ba vòng, 0 là cả ba vòng bằng 0:
| Cách | Deadlock | Ghi đè | Ví lệch | Ví âm | TongSoDu |
Lệnh/giây (trung vị) |
|---|---|---|---|---|---|---|
| 1 | 148, 138, 129 | 4.821, 4.974, 4.885 | 20, 19, 20 | 5, 6, 7 | −940.000, +2.060.000, −4.920.000 | 86 |
| 2 | 355, 373, 305 | 0 | 0 | 0 | 0 | 48 |
| 3 | 0 | 0 | 0 | 0 | 0 | 227 |
Chỉ khóa hai ví theo thứ tự ViId mới hết deadlock
Bảng số liệu
| Vòng 1 | Vòng 2 | Vòng 3 | |
|---|---|---|---|
| 1. Đọc, tính, ghi | 148 lần | 138 lần | 129 lần |
| 2. UPDATE có điều kiện | 355 lần | 373 lần | 305 lần |
| 3. Khóa theo ViId | 0 lần | 0 lần | 0 lần |
Cách 1 không báo lỗi nào về tiền, nhưng khoảng 39% số lần ghi đè lên cập nhật của phiên khác, và tổng số dư lệch cả hai chiều: vòng 2 tự sinh ra 2.060.000 đ, vòng 3 mất 4.920.000 đ. LenhKhongCan và TongButToan bằng 0 ở cả chín lần chạy: sổ cái vẫn đúng, nên tính lại SoDu từ ButToan là ra số đúng. Thời gian dao động mạnh vì máy chạy song song việc khác, nên cột cuối chỉ cho bậc độ lớn.
16 phiên trên 20 ví là dồn tải cố ý. Với 9.000 lượt P2P mỗi ngày trên 150.000 ví, va chạm hiếm hơn nhiều, nhưng cách 1 thua lần nào là sai tiền lần đó.
6. Đối soát cuối ngày
Job đối soát chạy lúc 00:15 giờ Việt Nam (17:15 UTC hôm trước), khi BHPay vẫn nhận lệnh. Ở READ COMMITTED có khóa, truy vấn đọc dòng Vi và các dòng ButToan của cùng một ví ở hai thời điểm, vì khóa S nhả trước khi sang dòng sau. Ba lần chạy truy vấn lặp lại, xen kẽ hai mức, trong lúc harness cách 3 chạy 2.000 lệnh mỗi phiên. Ở READ COMMITTED, 224 trên 246 lần báo lệch dù sổ không lệch, truy vấn 6 lần bị chọn làm nạn nhân deadlock, và ở một lần chạy có 6 lệnh chuyển nhận 1205. Ở SNAPSHOT: 0 lần báo lệch, 0 deadlock.
Cách sửa: chạy một lần ALTER DATABASE BHPay SET ALLOW_SNAPSHOT_ISOLATION ON, rồi đặt truy vấn trong giao dịch SNAPSHOT. Giao dịch đó thấy mọi bảng tại cùng một thời điểm và không khóa khi đọc. Đổi lại, mọi lệnh sửa dữ liệu sinh phiên bản dòng (mục 8 của chương transaction).
Khi một cột khác 0, sổ cái là nguồn đúng: sửa nguyên nhân trước, rồi tính lại SoDu từ ButToan. Bút toán sai thì ghi bút toán điều chỉnh, không sửa dòng cũ.
Đọc tiếp
- Transaction, khóa và isolation: đọc deadlock và vòng thử lại cho 1205.
- Idempotency key: lưu phản hồi và trả
409. - Bài toán hai vị tướng: vì sao không tránh được gửi lại sau timeout, và lệnh rút tiền về ngân hàng bị treo.
- Xác thực webhook thanh toán: tiền nạp vào ví đến qua webhook; phát lại một webhook là cộng tiền hai lần.
- Chia tiền không lệch một đồng: giữ tổng bút toán đúng từng đồng khi một khoản phải chia ra nhiều dòng.
Nguồn
- Microsoft Learn, Deadlocks guide, mục "Access objects in the same order".
- Microsoft Learn, SET TRANSACTION ISOLATION LEVEL.
- Microsoft Learn, Table hints và OUTPUT clause.
- Microsoft Learn, File-based apps.