Chuyển tới nội dung chính

12.7 — 6. Transactions và Locking

Tóm tắt

Isolation level là đánh đổi giữa tính đúng đắn và khả năng chịu tải, và mặc định của hai hệ khác nhau: SQL Server là READ COMMITTED dùng khoá (người đọc chặn người ghi), PostgreSQL là READ COMMITTED dùng MVCC (không chặn). Bật READ_COMMITTED_SNAPSHOT trên SQL Server xoá bỏ phần lớn tranh chấp đọc/ghi và thường là thay đổi một dòng có tác động lớn nhất. Về deadlock: nguyên nhân gần như luôn là hai transaction khoá cùng các tài nguyên theo thứ tự khác nhau — sửa bằng cách thống nhất thứ tự, không phải bằng cách tăng timeout. Và nguyên tắc bao trùm: transaction phải ngắn, không bao giờ chứa lời gọi HTTP hay chờ người dùng.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Giải thích bốn isolation level và hiện tượng mỗi mức cho phép.
  • Quyết định có bật RCSI hay không.
  • Chẩn đoán deadlock và sửa đúng nguyên nhân.
  • Chọn giữa optimistic và pessimistic concurrency.
  • Viết logic retry cho lỗi tạm thời.

Nội dung bài học​

12.7.1 — ACID​

Ý nghĩaAi đảm bảo
AtomicityTất cả hoặc không gì cảTransaction log
ConsistencyRàng buộc luôn được giữConstraint + logic của bạn
IsolationTransaction không thấy trạng thái dở dang của nhauIsolation level
DurabilityĐã commit là còn sau khi mất điệnWrite-ahead log

Ba chữ đầu và cuối gần như cố định. Chữ I là thứ bạn chọn, và chọn sai gây ra những lỗi dữ liệu khó tái hiện nhất.

BEGIN TRANSACTION;

BEGIN TRY
INSERT INTO Customers (CompanyName) VALUES (@name);
DECLARE @customerId INT = SCOPE_IDENTITY();

UPDATE Leads
SET Status = 'Won', ConvertedAt = SYSUTCDATETIME(),
ConvertedToCustomerId = @customerId
WHERE LeadId = @leadId;

COMMIT TRANSACTION;
END TRY
BEGIN CATCH
IF XACT_STATE() <> 0 ROLLBACK TRANSACTION;
THROW;
END CATCH;

XACT_STATE() thay cho @@TRANCOUNT: một số lỗi làm transaction không thể commit nhưng vẫn mở, và chỉ XACT_STATE() phân biệt được ba trạng thái đó.

12.7.2 — Bốn isolation level​

MứcDirty readNon-repeatable readPhantom read
READ UNCOMMITTEDCóCóCó
READ COMMITTEDKhôngCóCó
REPEATABLE READKhôngKhôngCó
SERIALIZABLEKhôngKhôngKhông
  • Dirty read: đọc dữ liệu của transaction chưa commit — có thể bị rollback ngay sau đó.
  • Non-repeatable read: đọc cùng một dòng hai lần trong một transaction và nhận hai giá trị khác nhau.
  • Phantom read: chạy cùng một truy vấn hai lần và lần sau có thêm dòng mới.

READ UNCOMMITTED và WITH (NOLOCK) — cùng một thứ — đọc được cả dữ liệu chưa commit. Nhưng ít người biết nó còn có thể trả về dòng trùng lặp hoặc bỏ sót dòng hoàn toàn khi có page split xảy ra giữa chừng. Nó không chỉ là "dữ liệu hơi cũ".

NOLOCK bị lạm dụng rộng rãi như một cách "làm cho truy vấn nhanh". Với báo cáo ước lượng thì chấp nhận được; với bất cứ thứ gì liên quan tới tiền thì không.

SERIALIZABLE đảm bảo đúng tuyệt đối bằng cách khoá cả khoảng giá trị — chi phí rất cao và dễ gây deadlock. Chỉ dùng cho đoạn code thật sự cần.

12.7.3 — RCSI: thay đổi một dòng, tác động lớn​

ALTER DATABASE CrmDb SET READ_COMMITTED_SNAPSHOT ON;

Sau lệnh này, READ COMMITTED trên SQL Server hoạt động bằng ảnh chụp thay vì khoá:

READ COMMITTED mặc địnhVới RCSI
Người đọc chặn người ghiCóKhông
Người ghi chặn người đọcCóKhông
Dữ liệu đọc đượcBản mới nhất đã commitẢnh chụp lúc câu lệnh bắt đầu
Chi phíKhoátempdb (version store)

Đây là hành vi mà PostgreSQL có sẵn (bài 12.3), và nó xoá bỏ phần lớn timeout kiểu "báo cáo chạy 30 giây làm treo mọi người dùng".

Ba lưu ý trước khi bật:

  1. Cần ALTER DATABASE với quyền độc quyền — làm ngoài giờ.
  2. tempdb phải đủ chỗ và nhanh; theo dõi version store.
  3. Code dựa vào việc đọc chặn để đồng bộ hoá sẽ đổi hành vi — hiếm nhưng có.

Bài Isolation level trong SQL: chọn sai thì dữ liệu sai ở đâu đi sâu vào từng hiện tượng.

12.7.4 — Deadlock​

Transaction A:  khoa Customers(42)  ->  doi Leads(7)
Transaction B: khoa Leads(7) -> doi Customers(42)
=> Chờ nhau vĩnh viễn -> SQL Server chọn MỘT nạn nhân và rollback

Nguyên nhân gần như luôn là thứ tự khoá khác nhau. Cách sửa cũng vậy:

// SAI — thứ tự phụ thuộc dữ liệu đầu vào
foreach (var id in customerIds) // mỗi request một thứ tự khác nhau
await UpdateCustomerAsync(id, ct);

// ĐÚNG — thứ tự NHẤT QUÁN
foreach (var id in customerIds.OrderBy(x => x))
await UpdateCustomerAsync(id, ct);

Bốn cách giảm deadlock, theo thứ tự hiệu quả:

  1. Thống nhất thứ tự truy cập tài nguyên trong toàn hệ thống.
  2. Rút ngắn transaction — khoá giữ càng lâu, cửa sổ va chạm càng rộng.
  3. Index phù hợp — quét bảng khoá nhiều dòng hơn cần thiết. Đây là nguyên nhân bị bỏ qua nhiều nhất.
  4. Isolation level thấp nhất đủ dùng, hoặc bật RCSI.

Điểm 3 đáng nhấn mạnh: một UPDATE ... WHERE Status = 'New' không có index trên Status sẽ quét toàn bảng và khoá mọi dòng nó chạm vào. Thêm index biến nó thành khoá vài dòng.

Xem deadlock đã xảy ra:

-- SQL Server: deadlock graph nam san trong system_health
SELECT XEvent.value('(data/value)[1]', 'varchar(max)') AS DeadlockGraph
FROM (
SELECT CAST(target_data AS XML) AS TargetData
FROM sys.dm_xe_session_targets st
JOIN sys.dm_xe_sessions s ON s.address = st.event_session_address
WHERE s.name = 'system_health' AND st.target_name = 'ring_buffer'
) AS Data
CROSS APPLY TargetData.nodes('//RingBufferTarget/event[@name="xml_deadlock_report"]') AS X(XEvent);
-- PostgreSQL: ghi vao log server
SET log_lock_waits = on;
SET deadlock_timeout = '1s';

Đừng sửa deadlock bằng cách tăng timeout. Nó chỉ làm transaction chờ lâu hơn trước khi thất bại.

12.7.5 — Optimistic hay pessimistic​

Optimistic — giả định xung đột hiếm, phát hiện khi ghi:

UPDATE Customers
SET CompanyName = @name, RowVersion = ...
WHERE CustomerId = @id AND RowVersion = @originalRowVersion;

-- 0 dòng bị ảnh hưởng => ai đó đã sửa trước
// EF Core lam san
try
{
await db.SaveChangesAsync(ct);
}
catch (DbUpdateConcurrencyException ex)
{
// Hiện cho người dùng: "Bản ghi đã được người khác sửa"
}

Pessimistic — khoá trước khi đọc:

BEGIN TRANSACTION;

SELECT * FROM Inventory WITH (UPDLOCK, ROWLOCK)
WHERE ProductId = @id; -- khoa ngay tu luc doc

UPDATE Inventory SET Quantity = Quantity - @qty WHERE ProductId = @id;

COMMIT;
OptimisticPessimistic
Khi xung đột hiếmTốt hơnKhoá vô ích
Khi xung đột thường xuyênNhiều lần thử lạiTốt hơn
Trải nghiệm người dùngCó thể mất công nhập lạiNgười khác bị chờ
Rủi roXung đột phát hiện muộnDeadlock

Mặc định là optimistic. Trong một CRM, hai người sửa cùng một khách hàng trong cùng vài giây là rất hiếm — khoá trước là trả giá cho một việc gần như không xảy ra.

Pessimistic đúng cho: trừ kho, cấp số hoá đơn tuần tự, và những chỗ mà thử lại là không chấp nhận được.

Có một mẫu thứ ba tốt hơn cả hai cho bộ đếm:

-- Không đọc rồi ghi — làm TẤT CẢ trong một câu lệnh
UPDATE Inventory
SET Quantity = Quantity - @qty
WHERE ProductId = @id AND Quantity >= @qty;

IF @@ROWCOUNT = 0 THROW 50001, 'Không đủ hàng', 1;

Một câu lệnh duy nhất là nguyên tử — không có khoảng trống giữa đọc và ghi để ai chen vào. Nhanh hơn và đơn giản hơn cả hai cách trên.

12.7.6 — Transaction phải ngắn​

// RAT TE
await using var tx = await db.Database.BeginTransactionAsync(ct);

var order = await db.Orders.FindAsync([id], ct);
await _paymentGateway.ChargeAsync(order.Total, ct); // GOI HTTP — co the mat 30 GIAY
order.Status = "Paid";
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);

Khoá được giữ suốt thời gian chờ mạng. Cổng thanh toán chậm 30 giây là 30 giây mọi transaction khác chạm vào dòng đó phải xếp hàng.

// TOT — I/O ben ngoai NAM NGOAI transaction
var charge = await _paymentGateway.ChargeAsync(order.Total, ct);

await using var tx = await db.Database.BeginTransactionAsync(ct);
order.Status = "Paid";
order.ChargeId = charge.Id;
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);

Ba thứ không bao giờ được nằm trong transaction:

  • Lời gọi HTTP tới bên thứ ba.
  • Chờ người dùng — "bấm OK để xác nhận".
  • Gửi email, push notification (bài 11.8).

Nếu bắt buộc phải phối hợp giữa database và hệ thống ngoài, dùng mẫu outbox — ghi ý định trong transaction, thực thi sau (Module 17).

12.7.7 — Retry​

Deadlock và timeout là lỗi tạm thời — thử lại thường thành công:

builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString, sql =>
sql.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: [1205]))); // 1205 = deadlock victim

EnableRetryOnFailure mặc định không retry deadlock; phải thêm mã 1205.

Hai điều kiện để retry an toàn:

  1. Thao tác phải idempotent, hoặc transaction bao trọn để rollback sạch (bài 9.2).
  2. Phải có backoff và giới hạn số lần. Retry ngay lập tức chỉ làm deadlock lặp lại.

Và luôn log mỗi lần retry. Tỷ lệ retry tăng là tín hiệu sớm của vấn đề tranh chấp — nếu retry im lặng, bạn chỉ biết khi nó đã đủ tệ để vượt quá số lần thử.

12.7.8 — Rà lại code của bạn​

Danh sách rà soát transaction

  • •Không có lời gọi HTTP hay chờ người dùng nào bên trong transaction.
  • •Đã cân nhắc bật READ_COMMITTED_SNAPSHOT cho SQL Server.
  • •Không dùng NOLOCK cho dữ liệu liên quan tới tiền hoặc quyết định nghiệp vụ.
  • •Các thao tác truy cập nhiều bảng theo một thứ tự nhất quán.
  • •Mọi UPDATE và DELETE hàng loạt đều có index hỗ trợ điều kiện WHERE.
  • •Bộ đếm và tồn kho cập nhật bằng một câu lệnh, không đọc rồi ghi.
  • •Concurrency mặc định là optimistic với RowVersion.
  • •Pessimistic lock chỉ dùng nơi thử lại là không chấp nhận được.
  • •Có retry cho deadlock, kèm mã lỗi 1205 và có backoff.
  • •Mỗi lần retry đều được ghi log.
  • •Rollback dùng XACT_STATE(), không chỉ @@TRANCOUNT.

Bài tập áp dụng​

Bài 1 — Tạo deadlock và đọc deadlock graph​

Viết hai transaction khoá hai bảng theo thứ tự ngược nhau, chạy đồng thời, và đọc deadlock graph từ system_health. Thống nhất thứ tự và kiểm chứng.

Tiêu chí hoàn thành: bạn đọc được từ graph ba thông tin: giao dịch nào bị huỷ, hai câu lệnh nào đụng nhau, và tài nguyên nào bị tranh chấp.

Gợi ý và lời giải — Bài 1

Gợi ý. Deadlock cần hai điều kiện: hai giao dịch chồng lấn về thời gian, và chúng lấy khoá theo thứ tự ngược nhau.

Lời giải — dựng deadlock:

-- Phiên 1
BEGIN TRANSACTION;
UPDATE Accounts SET Points = Points - 100 WHERE Id = 1;
WAITFOR DELAY '00:00:03';
UPDATE Accounts SET Points = Points + 100 WHERE Id = 2;
COMMIT;
-- Phiên 2 — chạy đồng thời, THỨ TỰ NGƯỢC LẠI
BEGIN TRANSACTION;
UPDATE Accounts SET Points = Points - 100 WHERE Id = 2;
WAITFOR DELAY '00:00:03';
UPDATE Accounts SET Points = Points + 100 WHERE Id = 1;
COMMIT;
Msg 1205, Level 13, State 51
Transaction (Process ID 58) was deadlocked on lock resources with another process
and has been chosen as the deadlock victim. Rerun the transaction.

Đọc deadlock graph từ system_health — nó bật sẵn, không cần cấu hình gì:

SELECT CAST(target_data AS XML) AS td
INTO #x
FROM sys.dm_xe_session_targets t
JOIN sys.dm_xe_sessions s ON s.address = t.event_session_address
WHERE s.name = 'system_health' AND t.target_name = 'ring_buffer';

SELECT x.value('(@timestamp)[1]', 'datetime2') AS occurred,
x.query('.') AS graph
FROM #x
CROSS APPLY td.nodes('//RingBufferTarget/event[@name="xml_deadlock_report"]') AS q(x)
ORDER BY occurred DESC;

Ba thông tin cần đọc trong graph:

<deadlock>
<victim-list>
<victimProcess id="process1a2b3c" /> <!-- 1. giao dịch BỊ HUỶ -->
</victim-list>
<process-list>
<process id="process1a2b3c" ...>
<inputbuf>UPDATE Accounts SET Points = ... WHERE Id = @p0</inputbuf>
<!-- 2. câu lệnh giao dịch này ĐANG chạy -->
</process>
<process id="process4d5e6f" ...>
<inputbuf>UPDATE Accounts SET Points = ... WHERE Id = @p0</inputbuf>
<!-- CÙNG câu lệnh -> deadlock giữa code với chính nó -->
</process>
</process-list>
<resource-list>
<keylock objectname="dbo.Accounts" ...> <!-- 3. tài nguyên tranh chấp -->
<owner-list><owner id="process4d5e6f" /></owner-list>
<waiter-list><waiter id="process1a2b3c" /></waiter-list>
</keylock>
</resource-list>
</deadlock>

Việc hai inputbuf giống hệt nhau là thông tin quan trọng nhất: nó cho biết đây không phải xung đột giữa hai tính năng khác nhau, mà là một đoạn code deadlock với chính nó khi chạy đồng thời — và bạn chỉ cần sửa đúng một chỗ.

Sửa bằng thứ tự khoá nhất quán:

public async Task TransferPointsAsync(Guid fromId, Guid toId, int points, CancellationToken ct)
{
await using var tx = await _db.Database.BeginTransactionAsync(ct);

// Nạp CẢ HAI tài khoản theo thứ tự ID tăng dần — NHẤT QUÁN mọi lần
var ids = new[] { fromId, toId }.OrderBy(id => id).ToArray();

var accounts = await _db.Accounts
.Where(a => ids.Contains(a.Id))
.OrderBy(a => a.Id) // thứ tự khoá XÁC ĐỊNH
.ToListAsync(ct);

accounts.First(a => a.Id == fromId).Points -= points;
accounts.First(a => a.Id == toId).Points += points;

await _db.SaveChangesAsync(ct); // MỘT lần lưu
await tx.CommitAsync(ct);
}
Giao dịch A (X -> Y): khoá theo thứ tự ID -> khoá X rồi Y
Giao dịch B (Y -> X): khoá theo thứ tự ID -> khoá X rồi Y <- CÙNG thứ tự

-> B chờ A xong -> KHÔNG deadlock, chỉ chờ đợi

Thứ tự nhất quán biến deadlock thành chờ đợi — chậm hơn một chút nhưng không thất bại.

Và gộp thành một SaveChangesAsync rút ngắn thời gian giữ khoá, giảm cả xác suất tranh chấp lẫn thời gian chờ của giao dịch khác. Chi tiết ở bài 12.11.

Ba nguyên tắc chống deadlock, theo thứ tự hiệu quả:

  1. Thứ tự truy cập nhất quán — sắp xếp id trước khi khoá, luôn truy cập bảng theo cùng một thứ tự.

  2. Giao dịch càng ngắn càng tốt — đừng gọi API bên ngoài, đừng gửi email, đừng đọc file trong transaction.

  3. Retry cho Msg 1205 — deadlock là lỗi tạm thời, thử lại thường thành công:

    options.UseSqlServer(cs, o => o.EnableRetryOnFailure(
    maxRetryCount: 3, maxRetryDelay: TimeSpan.FromSeconds(5), errorNumbersToAdd: null));

Bài 2 — Đo tác động của RCSI​

Chạy một truy vấn báo cáo 30 giây song song với các UPDATE, đo thời gian chờ. Bật READ_COMMITTED_SNAPSHOT và đo lại.

Tiêu chí hoàn thành: bạn nêu được RCSI dời chi phí đi đâu, chứ không phải "nó làm mọi thứ nhanh hơn".

Gợi ý và lời giải — Bài 2

Gợi ý. Người ghi không còn chặn người đọc — vậy người đọc lấy dữ liệu cũ từ đâu ra?

Lời giải — trước khi bật RCSI:

-- Phiên 1: báo cáo dài
SELECT Status, COUNT(*), SUM(Value)
FROM Leads
GROUP BY Status; -- mất ~30 giây trên bảng lớn
-- Phiên 2: cập nhật thông thường, chạy song song
UPDATE Leads SET Status = N'Won' WHERE Id = 12345;
-- Xem trạng thái chờ
SELECT session_id, wait_type, wait_time, blocking_session_id
FROM sys.dm_exec_requests
WHERE blocking_session_id <> 0;
session_id  wait_type  wait_time  blocking_session_id
59 LCK_M_X 28400 58

UPDATE chờ 28 giây vì báo cáo đang giữ khoá đọc trên các dòng nó đi qua.

Sau khi bật RCSI:

ALTER DATABASE Crm SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
(không có dòng nào trong dm_exec_requests bị chặn)
UPDATE: 3 ms
SELECT: vẫn ~30 giây, trả về ảnh chụp tại thời điểm bắt đầu

Người đọc không chặn người ghi nữa.

RCSI dời chi phí đi đâu — đây là phần quan trọng của bài. Nó không làm biến mất chi phí; nó chuyển chi phí từ thời gian chờ sang dung lượng và I/O của tempdb.

Khi RCSI bật, mỗi lần một dòng bị sửa, SQL Server giữ phiên bản cũ của dòng đó trong tempdb để những giao dịch đang đọc vẫn thấy ảnh chụp nhất quán. Chuỗi phiên bản này gọi là version store.

Trước RCSI:  người ghi chờ người đọc (hoặc ngược lại)
Sau RCSI: không ai chờ ai, nhưng tempdb phải giữ phiên bản cũ

Ba hệ quả phải biết:

  1. tempdb phải đủ lớn và đủ nhanh. Với hệ thống ghi nhiều, version store có thể lên hàng GB. Đặt tempdb trên đĩa nhanh là bắt buộc.

  2. Giao dịch đọc dài giữ phiên bản cũ sống lâu. Một báo cáo chạy 2 giờ buộc tempdb giữ mọi phiên bản sinh ra trong 2 giờ đó. Đây là cách phổ biến nhất làm đầy tempdb:

    SELECT SUM(version_store_reserved_page_count) * 8 / 1024 AS version_store_mb
    FROM sys.dm_db_file_space_usage;
  3. Mỗi dòng tốn thêm 14 byte cho con trỏ phiên bản, nên bảng phình nhẹ và số trang tăng.

Bảng đánh đổi:

Không RCSICó RCSI
Người đọc chặn người ghiCóKhông
Người ghi chặn người đọcCóKhông
Dung lượng tempdbBình thườngTăng theo lượng ghi
Dữ liệu người đọc thấyMới nhất đã commitẢnh chụp lúc câu lệnh bắt đầu

Dòng cuối là một khác biệt về ngữ nghĩa, không chỉ về hiệu năng: báo cáo chạy 30 giây sẽ không thấy những thay đổi commit trong 30 giây đó. Với báo cáo thì đúng và tốt; với logic nghiệp vụ dựa vào "đọc giá trị mới nhất" thì phải xem lại.

PostgreSQL luôn hoạt động theo mô hình này — đó chính là MVCC ở bài 12.3, và cái giá tương ứng là table bloat thay vì tempdb.

Khuyến nghị: bật RCSI cho gần như mọi hệ thống OLTP mới. Nó loại bỏ phần lớn tình huống chặn nhau mà chỉ đổi lấy dung lượng tempdb — một đánh đổi gần như luôn đúng. Nhưng hãy bật ở môi trường thử trước và theo dõi version store một thời gian.


Bài 3 — NOLOCK đọc được giá trị chưa bao giờ tồn tại​

Chạy SELECT WITH (NOLOCK) liên tục trong khi một transaction khác UPDATE rồi ROLLBACK. Ghi lại lần bạn đọc được giá trị chưa bao giờ tồn tại.

Tiêu chí hoàn thành: bạn liệt kê được ba loại sai mà NOLOCK gây ra, không chỉ dirty read.

Gợi ý và lời giải — Bài 3

Gợi ý. Dirty read là loại sai nổi tiếng nhất, nhưng không phải loại tệ nhất. Hai loại kia liên quan tới việc dữ liệu di chuyển trên đĩa trong lúc bạn đang đọc.

Lời giải — dirty read:

-- Phiên 1
BEGIN TRANSACTION;
UPDATE Accounts SET Points = 999999 WHERE Id = 1;
WAITFOR DELAY '00:00:05';
ROLLBACK; -- giá trị này CHƯA BAO GIỜ tồn tại
-- Phiên 2, chạy trong lúc phiên 1 đang chờ
SELECT Points FROM Accounts WITH (NOLOCK) WHERE Id = 1;
Points
999999 <- đọc được giá trị đã bị rollback

Ba loại sai của NOLOCK:

1. Dirty read — đọc dữ liệu chưa commit, như trên. Đây là loại ai cũng biết, và nhiều người chấp nhận với lý do "báo cáo sai một chút không sao".

2. Bỏ sót dòng hoặc đọc trùng dòng. Loại này ít người biết và nguy hiểm hơn hẳn. Khi bạn quét một bảng, SQL Server đi theo thứ tự trang. Nếu một UPDATE làm dòng di chuyển sang trang khác — vì tách trang hoặc vì giá trị khoá đổi — thì:

Dòng di chuyển từ trang ĐÃ đọc sang trang CHƯA đọc  -> đọc HAI lần
Dòng di chuyển từ trang CHƯA đọc sang trang ĐÃ đọc -> BỎ SÓT

Hậu quả: SELECT COUNT(*) WITH (NOLOCK) trả về con số sai lệch so với số dòng thật, và bạn không có cách nào biết sai bao nhiêu.

3. Lỗi trực tiếp khi trang đang bị dịch chuyển:

Msg 601, Level 12, State 3
Could not continue scan with NOLOCK due to data movement.

Truy vấn thất bại hẳn, không phải trả về sai. Với báo cáo chạy hàng đêm, đây là lỗi xuất hiện ngẫu nhiên và không tái hiện được.

Vì sao NOLOCK vẫn phổ biến. Nó được truyền tay như một mẹo tăng tốc: thêm hai chữ là truy vấn hết bị chặn. Và trong phần lớn lần chạy, nó thật sự cho kết quả đúng — nên người ta không thấy vấn đề cho tới khi một con số quan trọng lệch.

Ba cách thay thế, theo thứ tự nên chọn:

-- 1. TỐT NHẤT — bật RCSI, người đọc không chặn người ghi mà vẫn nhất quán
ALTER DATABASE Crm SET READ_COMMITTED_SNAPSHOT ON;

Sau khi bật RCSI, NOLOCK gần như không còn lý do tồn tại: bạn đã có tính không chặn nhau mà không mất tính đúng đắn.

-- 2. Báo cáo cần nhất quán tuyệt đối — dùng snapshot tường minh
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
BEGIN TRANSACTION;
SELECT ...;
COMMIT;
-- 3. Tách hẳn tải báo cáo sang bản sao chỉ đọc
-- Availability Group readable secondary, hoặc một bản sao đồng bộ định kỳ

Khi nào NOLOCK chấp nhận được. Rất hẹp: khi bạn cần một con số ước lượng và biết rõ nó là ước lượng — ví dụ hiển thị "khoảng 12.000 bản ghi" trên một bảng điều khiển. Ngoài ra, hãy coi mỗi WITH (NOLOCK) trong code là một khoản nợ cần trả.

Cách rà soát:

grep -rniE 'WITH\s*\(\s*NOLOCK\s*\)|READUNCOMMITTED' --include=*.sql --include=*.cs src/ | wc -l

Và trong EF Core, NOLOCK thường lọt vào qua cách này:

// Nó áp NOLOCK cho MỌI truy vấn trong transaction
using var tx = _db.Database.BeginTransaction(IsolationLevel.ReadUncommitted);

Đoạn trên nguy hiểm hơn WITH (NOLOCK) viết tay, vì nó không hiện ra ở chỗ truy vấn — người đọc code sau này hoàn toàn không biết.

Tự kiểm tra​

Câu hỏi thường gặp

Trong ACID, chữ nào là thứ bạn chọn?

Chữ I, tức Isolation. Atomicity và Durability do transaction log và write-ahead log đảm bảo, Consistency do ràng buộc và logic của bạn. Isolation level là đánh đổi giữa tính đúng đắn và khả năng chịu tải, và chọn sai gây ra những lỗi dữ liệu khó tái hiện nhất.

NOLOCK có vấn đề gì ngoài đọc dữ liệu chưa commit?

Nó còn có thể trả về dòng trùng lặp hoặc bỏ sót dòng hoàn toàn khi có page split xảy ra giữa chừng. Nên nó không chỉ là dữ liệu hơi cũ; với bất cứ thứ gì liên quan tới tiền thì không dùng được.

READ_COMMITTED_SNAPSHOT thay đổi gì?

Nó khiến READ COMMITTED trên SQL Server hoạt động bằng ảnh chụp thay vì khoá, nên người đọc không chặn người ghi và ngược lại. Đây là hành vi PostgreSQL có sẵn, và nó xoá bỏ phần lớn tình trạng một báo cáo dài làm treo mọi người dùng. Cái giá là tempdb phải chịu tải version store.

Nguyên nhân gốc của deadlock là gì?

Gần như luôn là hai transaction khoá cùng các tài nguyên theo thứ tự khác nhau. Cách sửa là thống nhất thứ tự truy cập, rút ngắn transaction, và thêm index vì một UPDATE không có index sẽ quét bảng và khoá mọi dòng nó chạm. Tăng timeout không sửa được gì, chỉ làm transaction chờ lâu hơn trước khi thất bại.

Nên dùng optimistic hay pessimistic concurrency?

Mặc định là optimistic, vì trong hệ thống nghiệp vụ điển hình thì hai người sửa cùng một bản ghi trong vài giây là rất hiếm, nên khoá trước là trả giá cho việc gần như không xảy ra. Pessimistic đúng cho trừ kho, cấp số hoá đơn tuần tự và những chỗ mà thử lại không chấp nhận được.

Cập nhật bộ đếm thế nào là tốt nhất?

Làm tất cả trong một câu lệnh UPDATE với điều kiện kiểm tra ngay trong WHERE, rồi xem số dòng bị ảnh hưởng. Một câu lệnh là nguyên tử nên không có khoảng trống giữa đọc và ghi để ai chen vào, và nó nhanh hơn cùng đơn giản hơn cả optimistic lẫn pessimistic.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Bật RCSI trên SQL Server — một dòng lệnh xoá bỏ phần lớn tranh chấp đọc/ghi.
  2. Deadlock là vấn đề thứ tự khoá, không phải vấn đề timeout.
  3. Không bao giờ gọi HTTP trong transaction. Khoá giữ suốt thời gian chờ mạng.

Tham khảo​

Điều hướng​

Bài liên quan​