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

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.

Mục lục
  1. 1. Khóa: chế độ, tài nguyên, tương thích
  2. 2. Leo thang khóa
  3. 3. Optimized locking
  4. 4. Áp dụng trong .NET
  5. Những chỗ hay hiểu sai
  6. Kết luận
  7. Đọc tiếp
  8. Nguồn

Mỗi lần sửa một đơn, SQL Server đặt khóa ở bốn tầng, từ database xuống đúng một dòng. Khi một câu lệnh giữ quá nhiều khóa dòng, engine đổi chúng thành một khóa cả bảng, và mọi đơn mới phải chờ một job không hề đụng tới chúng. Đọc xong, bạn đọc được khóa của một câu lệnh, biết khi nào nó leo thang, và viết job hàng loạt trong .NET không chặn bán hàng.

Đọc nhanh

  • Khóa X của lần sửa giữ đến hết giao dịch ở mọi mức isolation; khóa intent ở tầng trên cho engine kiểm xung đột nhanh.
  • Khóa U khiến hai phiên cùng định sửa một dòng xếp hàng ngay từ bước đọc, thay vì deadlock.
  • Một câu lệnh giữ từ 5.000 khóa trên một bảng thì leo thẳng lên khóa bảng, bỏ qua mức page.
  • Việc lớn chia lô dưới ngưỡng đó, mỗi lô một giao dịch; LOCK_ESCALATION = AUTO chỉ là lớp bảo vệ thêm cho bảng phân vùng.

Bài thứ hai trong năm bài về giao dịch, cùng mốc với bài Giao dịch và XACT_ABORT: SQL Server 2019, RCSI và ADR tắt. dbo.DonHang có PK_DonHang, IX_DonHang_KhachHang, IX_DonHang_DangMo và không có columnstore.

1. Khóa: chế độ, tài nguyên, tương thích

Chế độ khóa

Chế độ Ai lấy Giữ đến
S shared Câu đọc dưới READ COMMITTED có khóa, REPEATABLE READ, SERIALIZABLE Ở READ COMMITTED: nhả khóa dòng trước khi sang dòng sau. Ở hai mức cao hơn: hết giao dịch
U update UPDATE, DELETE, MERGE khi dò dòng cần sửa. SELECT ... WITH (UPDLOCK) Chuyển thành X nếu dòng được sửa, nhả nếu dòng không thỏa điều kiện. Với UPDLOCK: hết giao dịch
X exclusive Mọi lần sửa dữ liệu Hết giao dịch, ở mọi mức isolation
IS, IX intent Đặt ở tầng trên (bảng, page) trước khi khóa S hoặc X ở tầng dưới Theo khóa tầng dưới
SIX Giao dịch đang giữ S trên cả bảng rồi sửa một số dòng Hết giao dịch
Sch-S Biên dịch và chạy câu lệnh, kể cả câu có NOLOCK Trong lúc biên dịch và chạy
Sch-M DDL: ALTER TABLE, TRUNCATE TABLE, SWITCH, rebuild chỉ mục offline Hết giao dịch chứa DDL

Khóa intent cho phép một yêu cầu khóa cả bảng chỉ kiểm một tài nguyên, thay vì duyệt mọi khóa dòng. IX tương thích với IX: hai phiên sửa hai đơn khác nhau cùng giữ IX trên dbo.DonHang.

resource_type Khóa cái gì Trên BanHang
KEY Một dòng trong B-tree, clustered hoặc nonclustered Dòng đơn 10042 trong PK_DonHang
RID Một dòng của heap Bảng không có clustered index
PAGE Một page 8 KB Page chứa đơn 10042 trên FG_DATA
HOBT Một heap hoặc B-tree; với bảng phân vùng, một partition của nó Partition 1 của PK_DonHang khi leo thang ở mức partition
OBJECT Cả bảng, tài liệu gọi là TABLE dbo.DonHang
Loại khác DATABASE, FILE, EXTENT, ALLOCATION_UNIT, METADATA, APPLICATION, XACT Mọi phiên đang dùng BanHang giữ S trên DATABASE. XACT thuộc optimized locking (mục 3)

Ma trận tương thích

Hàng là chế độ đang xin, cột là chế độ phiên khác đang giữ. Bảng đối xứng.

Xin \ Đang giữ IS S U IX SIX X
IS Có Có Có Có Có Không
S Có Có Có Không Không Không
U Có Có Không Không Không Không
IX Có Không Không Có Không Không
SIX Có Không Không Không Không Không
X Không Không Không Không Không Không

Sch-S tương thích với mọi khóa dữ liệu, kể cả X, và chỉ xung đột với Sch-M. Sch-M xung đột với mọi khóa. Vì vậy một ALTER TABLE dbo.DonHang phải chờ cả câu SELECT ... WITH (NOLOCK) đang chạy, và mọi câu đến sau phải xếp hàng sau ALTER TABLE.

Vì sao có khóa U

Đọc bằng S rồi sửa, như code tách SELECT và UPDATE dưới REPEATABLE READ, tự tạo deadlock chuyển đổi khóa:

Thời điểm Phiên A Phiên B
10:00:00 Đọc sản phẩm 42, giữ S
10:00:01 Đọc sản phẩm 42, giữ S
10:00:02 UPDATE sản phẩm 42: xin X, chờ S của B
10:00:03 UPDATE sản phẩm 42: xin X, chờ S của A. Chu trình, một phiên nhận 1205

U tương thích với S nhưng không tương thích với U. Câu UPDATE lấy U khi dò dòng, nên phiên thứ hai xếp hàng ngay ở bước đọc thay vì cùng giữ S rồi kẹt nhau ở bước chuyển sang X. Code ứng dụng tách đọc và ghi thì dùng UPDLOCK ở câu đọc để có cùng hiệu quả (cách b).

Khóa của một UPDATE đơn 10042

Giữ một giao dịch sửa đơn 10042 mở, rồi đọc sys.dm_tran_locks của chính phiên đó, nối với sys.partitions và sys.indexes để ra tên bảng, chỉ mục và partition.

Sửa đơn 10042 trong giao dịch mở, rồi liệt kê khóa của phiênSQL · 19 dòng
BEGIN TRAN;

UPDATE dbo.DonHang SET TongTien = 1800000.00
WHERE DonHangId = 10042 AND NgayTao = CAST('2026-10-02T11:58:00' AS datetime2(0));

SELECT l.resource_type, l.request_mode, l.request_status, l.resource_description,
       COALESCE(OBJECT_NAME(p.object_id),
                CASE WHEN l.resource_type = 'OBJECT'
                     THEN OBJECT_NAME(l.resource_associated_entity_id) END) AS object_name,
       i.name AS index_name, p.partition_number
FROM sys.dm_tran_locks AS l
LEFT JOIN sys.partitions AS p
    ON l.resource_type IN ('PAGE', 'KEY', 'RID', 'HOBT')
   AND p.hobt_id = l.resource_associated_entity_id
LEFT JOIN sys.indexes AS i ON i.object_id = p.object_id AND i.index_id = p.index_id
WHERE l.request_session_id = @@SPID
  AND l.resource_database_id = DB_ID();

ROLLBACK;

Kết quả minh họa, xếp theo tầng. Số page và chuỗi băm của khóa đổi theo máy:

resource_type request_mode request_status resource_description object_name index_name partition_number
DATABASE S GRANT
OBJECT IX GRANT DonHang
PAGE IX GRANT 3:812402 DonHang PK_DonHang 3
KEY X GRANT (8194443284a0) DonHang PK_DonHang 3

Đọc từ trên xuống: S trên database, IX trên bảng, IX trên page chứa dòng, X trên đúng một khóa của PK_DonHang, partition 3 (năm 2026). resource_description của PAGE là file:page; của KEY là chuỗi băm của giá trị khóa, không đọc ngược ra được giá trị.

IX_DonHang_KhachHang không có khóa vì TongTien không nằm trong chỉ mục đó. Đơn 10042 đang ở TrangThai = 2, nên IX_DonHang_DangMo (chỉ chứa TrangThai = 1) cũng không bị đụng. Đơn ở trạng thái 1 sẽ có thêm cặp PAGE IX và KEY X trên chỉ mục lọc đó, vì nó INCLUDE (TongTien).

DATABASE BanHang S OBJECT dbo.DonHang IX PAGE 3:812402, PK_DonHang, partition 3 IX KEY, đơn 10042 (8194443284a0) X
Một UPDATE đơn 10042: khóa intent ở các tầng trên, khóa X chỉ trên đúng một dòng.

2. Leo thang khóa

Mỗi khóa tốn bộ nhớ. Khi một câu lệnh giữ quá nhiều khóa nhỏ, engine đổi chúng thành một khóa lớn. Điều kiện kích hoạt:

  • Một câu lệnh giữ ít nhất 5.000 khóa trên một tham chiếu tới một bảng hoặc chỉ mục. 3.000 khóa trên chỉ mục này cộng 3.000 khóa trên chỉ mục khác của cùng bảng không kích hoạt. Ngưỡng tính theo câu lệnh, không cộng dồn qua các câu của cùng giao dịch.
  • Bộ nhớ khóa của cả instance vượt ngưỡng. Khi tùy chọn locks để mặc định 0, ngưỡng là 24% bộ nhớ engine đang dùng.

Khóa dòng không leo lên khóa page: cả khóa dòng lẫn khóa page đều leo thẳng lên khóa bảng. Bảng phân vùng đặt LOCK_ESCALATION = AUTO thì lên khóa HOBT của partition. Nếu phiên khác đang giữ khóa không tương thích ở mức đích, câu lệnh không chờ: nó tiếp tục khóa từng dòng và thử lại sau mỗi 1.250 khóa mới. Gợi ý ROWLOCK chỉ đổi cách khóa ban đầu, không ngăn leo thang.

Chốt sổ năm 2024 lúc 19:00

Lúc 19:00 ngày 2026-10-02, cửa hàng vẫn nhận đơn. Một job chốt sổ năm 2024: đơn nào chưa ở trạng thái cuối thì chuyển sang Hoàn tất.

UPDATE dbo.DonHang
SET TrangThai = 4
WHERE NgayTao >= CAST('20240101' AS datetime2(0))
  AND NgayTao < CAST('20250101' AS datetime2(0))
  AND TrangThai IN (1, 2, 3);

Câu này quét partition 1, khoảng 3,0 triệu dòng. Nếu tỷ lệ trạng thái giống phân phối chung (5% ở trạng thái 1 đến 3), nó sửa khoảng 150.000 dòng. FG_ARCHIVE phải đang READ_WRITE.

Đến khoảng khóa thứ 5.000 trên PK_DonHang, engine thử leo thang. dbo.DonHang để mặc định LOCK_ESCALATION = TABLE, nên đích là khóa X trên cả bảng. Các phiên đang chèn đơn 2026 giữ IX trên bảng; X không tương thích với IX, lần thử thất bại, job khóa dòng tiếp. Giao dịch chèn đơn chỉ dài vài mili giây, nên một khe trống giữa hai đơn là đủ để một lần thử sau đó thành công. Từ đó, mọi INSERT đơn mới chờ LCK_M_IX đến khi job commit, tức sau khi job quét hết 3 triệu dòng.

sequenceDiagram
  participant J as Job chốt sổ 2024
  participant T as dbo.DonHang
  participant I as Phiên chèn đơn 2026
  I->>T: IX trên bảng, X trên đơn mới
  J->>T: KEY X ở partition 1, khóa thứ 5.000
  J->>T: Xin X trên cả bảng
  T-->>J: Không cấp: phiên chèn giữ IX
  Note over J: Khóa dòng tiếp, thử lại sau mỗi 1.250 khóa mới
  I->>T: COMMIT, nhả IX
  J->>T: Thử lại: được X trên bảng
  I->>T: Đơn sau xin IX trên bảng
  Note over T,I: Chờ LCK_M_IX đến khi job commit

Đơn 2026 nằm ở partition 3, không trùng dòng nào job đang sửa; chúng bị chặn chỉ vì khóa đã lên mức bảng, và sys.dm_tran_locks của phiên job chuyển từ hàng nghìn dòng KEY X thành một dòng OBJECT X. Số lần thử và số lần leo thang tích lũy theo chỉ mục nằm ở sys.dm_db_index_operational_stats; bộ đếm mất khi instance khởi động lại hoặc metadata của bảng bị đẩy khỏi cache. Muốn thấy từng lần leo thang kèm nguyên nhân, dùng event lock_escalation của Extended Events.

Số lần thử và số lần leo thang theo chỉ mục của dbo.DonHangSQL · 5 dòng
SELECT i.name AS index_name, s.partition_number,
       s.index_lock_promotion_attempt_count, s.index_lock_promotion_count
FROM sys.dm_db_index_operational_stats(DB_ID(N'BanHang'), OBJECT_ID(N'dbo.DonHang'), NULL, NULL) AS s
INNER JOIN sys.indexes AS i ON i.object_id = s.object_id AND i.index_id = s.index_id
ORDER BY s.index_lock_promotion_count DESC;

Cách sửa 1: chia lô

Mỗi lô 4.000 dòng, đi theo khóa clustered (NgayTao, DonHangId) để không quét lại phần đã xử lý. Mỗi lô là một giao dịch autocommit: khóa nhả sau từng lô, và log backup giữa các lô cắt được log. Vòng lặp dưới nhớ khóa cuối của lô trước qua OUTPUT ... INTO @Lo và dừng khi một lô sửa 0 dòng.

Vòng chia lô 4.000 dòng theo khóa clusteredSQL · 30 dòng
SET NOCOUNT ON;

DECLARE @NgayCuoi datetime2(0) = CAST('20240101' AS datetime2(0));
DECLARE @IdCuoi bigint = -1;
DECLARE @SoDong int = 1;
DECLARE @Lo TABLE (NgayTao datetime2(0) NOT NULL, DonHangId bigint NOT NULL);

WHILE @SoDong > 0
BEGIN
    DELETE FROM @Lo;

    WITH LoTiepTheo AS (
        SELECT TOP (4000) NgayTao, DonHangId, TrangThai
        FROM dbo.DonHang
        WHERE NgayTao >= @NgayCuoi
          AND NgayTao < CAST('20250101' AS datetime2(0))
          AND (NgayTao > @NgayCuoi OR DonHangId > @IdCuoi)
          AND TrangThai IN (1, 2, 3)
        ORDER BY NgayTao, DonHangId
    )
    UPDATE LoTiepTheo
    SET TrangThai = 4
    OUTPUT inserted.NgayTao, inserted.DonHangId INTO @Lo (NgayTao, DonHangId);

    SET @SoDong = @@ROWCOUNT;

    SELECT TOP (1) @NgayCuoi = NgayTao, @IdCuoi = DonHangId
    FROM @Lo
    ORDER BY NgayTao DESC, DonHangId DESC;
END;

Khoảng 150.000 dòng thành khoảng 38 lô. 4.000 khóa dòng chừa chỗ dưới ngưỡng 5.000 cho các khóa page đi kèm. Chạy xong, index_lock_promotion_count của PK_DonHang không tăng là bằng chứng lô đủ nhỏ. Không bọc cả vòng lặp trong một BEGIN TRAN: khóa của mọi lô sẽ cộng dồn đến cuối, và log không cắt được cho đến khi commit.

Cách sửa 2: leo thang ở mức partition

ALTER TABLE dbo.DonHang SET (LOCK_ESCALATION = AUTO);
SELECT name, lock_escalation_desc FROM sys.tables WHERE name = N'DonHang';

Lệnh ALTER TABLE cần khóa Sch-M trong chốc lát. Sau đó, job leo thang thành X trên HOBT của partition 1, còn ở mức bảng nó chỉ giữ IX, tương thích với IX của phiên chèn đơn 2026. Câu đọc chạm cả partition 1, như báo cáo nhiều năm không lọc theo NgayTao, vẫn bị chặn.

Tài liệu Microsoft ghi rủi ro của AUTO: hai giao dịch đã leo thang trên hai partition khác nhau, rồi mỗi bên cần mở rộng khóa X sang partition của bên kia, sẽ deadlock. Hai job bảo trì chạy song song trên năm 2024 và 2025 là đúng hình dạng đó. Trên bảng không phân vùng, AUTO hành xử như TABLE. Chia lô vẫn là cách sửa chính; AUTO là lớp bảo vệ thêm cho bảng phân vùng.

Không tắt leo thang cho cả instance

LOCK_ESCALATION = DISABLE trên bảng, hoặc trace flag 1211 và 1224 cho cả instance, bỏ giới hạn số khóa. Khi bộ nhớ khóa cạn, câu lệnh nào xin thêm khóa cũng nhận lỗi 1204 và giao dịch của nó bị rollback. Giảm số khóa mỗi câu lệnh trước khi nghĩ tới tắt leo thang.

3. Optimized locking

Phiên bản

Optimized locking có trên Azure SQL Database, SQL database trong Microsoft Fabric và Azure SQL Managed Instance theo update policy Always-up-to-date hoặc SQL Server 2025, ở đó luôn bật. SQL Server 2025 (17.x) có nhưng mặc định tắt, bật theo từng database. SQL Server 2022 trở về trước không có.

Tính năng gồm hai phần. Transaction ID (TID) locking: mỗi dòng mang mã giao dịch sửa nó lần cuối. Giao dịch sửa dòng vẫn lấy khóa dòng và page, nhưng nhả ngay sau khi sửa xong từng dòng; thứ duy nhất giữ đến hết giao dịch là một khóa X trên tài nguyên XACT của chính nó. Phiên khác muốn chờ dòng đó thì xin S trên XACT. Job chốt sổ ở mục 2 vì vậy không còn tích hàng nghìn khóa, và leo thang hiếm khi xảy ra.

Lock after qualification (LAQ) chỉ chạy khi RCSI bật: câu sửa dữ liệu kiểm điều kiện WHERE trên bản commit mới nhất mà không lấy U. Dòng không thỏa thì bỏ qua, không chờ. Dòng thỏa mà đang bị giao dịch khác sửa thì chờ giao dịch đó, rồi kiểm lại điều kiện trước khi sửa. Câu trừ kho có điều kiện vẫn đúng: B thấy 5 thỏa >= 3, chờ A, kiểm lại thấy 2 không thỏa, sửa 0 dòng.

LAQ đổi kết quả ở một kiểu code: câu sửa có điều kiện trên chính cột mà giao dịch khác đang đổi.

Thời điểm Phiên A Phiên B
17:00:00 BEGIN TRAN. Đơn 10051 đã thanh toán: TrangThai 1 thành 2
17:00:01 Giao các đơn đã thanh toán trong ngày: UPDATE ... SET TrangThai = 3 WHERE TrangThai = 2 AND NgayTao ...
17:00:02 COMMIT

Không có LAQ, B chờ ở dòng 10051 rồi thấy 2 và chuyển sang 3. Có LAQ, B kiểm 10051 trên bản commit mới nhất, thấy 1, bỏ qua mà không chờ: đơn 10051 nằm lại ở trạng thái 2 đến lượt giao sau. Microsoft ghi rõ: kể cả khi không có LAQ, ứng dụng không nên giả định thứ tự thực thi chặt giữa các giao dịch dưới mức isolation dựa trên phiên bản. Luồng nào cần thứ tự đó thì dùng UPDLOCK hoặc mức isolation cao hơn.

LAQ không áp dụng khi câu lệnh có gợi ý UPDLOCK, HOLDLOCK, XLOCK, READCOMMITTEDLOCK, khi mức isolation khác READ COMMITTED, khi bảng có columnstore, với MERGE, và với câu có OUTPUT trả kết quả hoặc ghi vào biến bảng. Vì vậy đọc với UPDLOCK và upsert giữ nguyên hành vi khóa đã mô tả.

DATABASEPROPERTYEX(N'BanHang', 'IsOptimizedLockingOn') trả NULL trên SQL Server 2019 và 2022. Khối dưới kiểm và bật tính năng trên SQL Server 2025.

Kiểm và bật optimized lockingSQL · 7 dòng
SELECT DATABASEPROPERTYEX(N'BanHang', 'IsOptimizedLockingOn') AS optimized_locking;
-- NULL trên SQL Server 2019 và 2022: tính năng không có.

-- Chỉ SQL Server 2025 (17.x) trở lên. Mỗi lệnh cần không còn kết nối nào khác vào BanHang.
ALTER DATABASE BanHang SET ACCELERATED_DATABASE_RECOVERY = ON;
ALTER DATABASE BanHang SET READ_COMMITTED_SNAPSHOT ON;
ALTER DATABASE BanHang SET OPTIMIZED_LOCKING = ON;

ADR là điều kiện bắt buộc. RCSI không bắt buộc nhưng thiếu nó thì không có LAQ. Khi bật, chờ khóa hiện với wait type LCK_M_S_XACT_READ và LCK_M_S_XACT_MODIFY, và sys.dm_tran_locks có dòng resource_type = 'XACT'. Deadlock giữa các giao dịch cùng sửa một tập dòng theo thứ tự ngược nhau vẫn xảy ra.

4. Áp dụng trong .NET

Job chốt sổ của BanHang chạy trong một BackgroundService và gọi EF Core. ExecuteUpdateAsync (EF Core 7 trở lên) gửi đúng một câu UPDATE, nên nó chịu cùng ngưỡng 5.000 khóa như câu T-SQL ở mục 2. Chia lô bằng Take đặt trước ExecuteUpdateAsync:

int soDong;
do
{
    soDong = await db.DonHang
        .Where(d => d.NgayTao >= dau2024 && d.NgayTao < dau2025
                 && d.TrangThai >= 1 && d.TrangThai <= 3)
        .OrderBy(d => d.NgayTao).ThenBy(d => d.DonHangId)
        .Take(4000)
        .ExecuteUpdateAsync(s => s.SetProperty(d => d.TrangThai, (byte)4), ct);
} while (soDong > 0);   // không bọc vòng lặp trong BeginTransactionAsync

EF Core 10.0.12 dịch Take(4000) thành INNER JOIN (SELECT TOP(@p) NgayTao, DonHangId ... ORDER BY ...) trên khóa chính, mỗi vòng một câu autocommit. Lô sau không nhớ khóa cuối của lô trước, nên câu SELECT TOP dò lại từ đầu năm qua các dòng đã chốt; trên 3 triệu dòng, thêm điều kiện khóa cuối như vòng T-SQL ở mục 2.

LeoThang.cs so ba cách trên LocalDB (SQL Server 2019, 15.0.4382) với 20.000 đơn năm 2024 chưa chốt, .NET 10.0.401, EF Core 10.0.12. Hai lượt đầu chạy một câu cho cả năm trong giao dịch, đọc khóa của job, cho một phiên khác chèn đơn 2026 với LOCK_TIMEOUT 2000, rồi rollback:

Cách chạy Khóa của job khi chưa commit index_lock_promotion_count Chèn đơn 2026 trong lúc đó
Một câu, LOCK_ESCALATION = TABLE Một khóa OBJECT X +1 Lỗi 1222 sau 2 giây
Một câu, LOCK_ESCALATION = AUTO HOBT X trên một partition, IX trên bảng +1 Xong sau 3 ms
5 lô 4.000 dòng, mỗi lô tự commit Không giữ qua các lô +0 Không có khóa bảng để chờ

Engine thật cho đúng hình ở mục 2, ở quy mô 20.000 dòng thay vì 150.000. Chương trình có dòng #:property PublishAot=false 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 và sẽ báo Model building is not supported when publishing with NativeAOT.

LeoThang.cs, chạy bằng dotnet run LeoThang.csC# · 103 dòng
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
#:property PublishAot=false
// Job chốt sổ năm 2024: một câu UPDATE leo thang khóa, chia lô 4.000 dòng thì không.
// Chạy: dotnet run LeoThang.cs   (BANHANG_DB: chuỗi kết nối tới database có dbo.DonHang)
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";
var dau2024 = new DateTime(2024, 1, 1);
var dau2025 = new DateTime(2025, 1, 1);

await using (var db = new BanHangDb(cs))
    Console.WriteLine($"Đơn 2024 chưa ở trạng thái cuối: {await DonCanChot(db).CountAsync()}");

// 1 và 2: một câu UPDATE cho cả năm, giữ giao dịch mở để quan sát khóa
foreach (var cheDo in new[] { "TABLE", "AUTO" })
{
    await using var db = new BanHangDb(cs);
    await db.Database.ExecuteSqlRawAsync(cheDo == "AUTO"
        ? "ALTER TABLE dbo.DonHang SET (LOCK_ESCALATION = AUTO)"
        : "ALTER TABLE dbo.DonHang SET (LOCK_ESCALATION = TABLE)");
    long truoc = await SoLanLeoThang(db);
    await using var tx = await db.Database.BeginTransactionAsync();
    int soDong = await DonCanChot(db).ExecuteUpdateAsync(s => s.SetProperty(d => d.TrangThai, (byte)4));
    var khoa = await db.Database.SqlQuery<string>($"""
        SELECT CONCAT(resource_type, ' ', request_mode, ': ', COUNT(*)) AS Value
        FROM sys.dm_tran_locks
        WHERE request_session_id = @@SPID AND resource_database_id = DB_ID() AND resource_type <> 'DATABASE'
        GROUP BY resource_type, request_mode
        """).ToListAsync();
    string chen = await ChenDon2026();                     // phiên khác, trong lúc job chưa commit
    await tx.RollbackAsync();                              // trả dữ liệu về như cũ cho lượt sau
    Console.WriteLine($"Một câu, LOCK_ESCALATION = {cheDo}: sửa {soDong} dòng; khóa của job: {string.Join(", ", khoa)}; " +
                      $"leo thang +{await SoLanLeoThang(db) - truoc}; chèn đơn 2026: {chen}");
}

// 3: chia lô 4.000 dòng, mỗi lô một câu autocommit
await using (var db = new BanHangDb(cs))
{
    await db.Database.ExecuteSqlRawAsync("ALTER TABLE dbo.DonHang SET (LOCK_ESCALATION = TABLE)");
    long truoc = await SoLanLeoThang(db);
    var lo = new List<int>();
    int soDong;
    do
    {
        soDong = await DonCanChot(db)
            .OrderBy(d => d.NgayTao).ThenBy(d => d.DonHangId)
            .Take(4000)
            .ExecuteUpdateAsync(s => s.SetProperty(d => d.TrangThai, (byte)4));
        lo.Add(soDong);
    } while (soDong > 0);
    Console.WriteLine($"Chia lô 4.000: các lô {string.Join(", ", lo)}; leo thang +{await SoLanLeoThang(db) - truoc}");
    // trả dữ liệu về trạng thái 1 đến 3 để chạy lại được
    await db.Database.ExecuteSqlAsync($"UPDATE dbo.DonHang SET TrangThai = 1 + DonHangId % 3 WHERE NgayTao >= {dau2024} AND NgayTao < {dau2025}");
}

IQueryable<DonHang> DonCanChot(BanHangDb db) => db.DonHang
    .Where(d => d.NgayTao >= dau2024 && d.NgayTao < dau2025 && d.TrangThai >= 1 && d.TrangThai <= 3);

async Task<long> SoLanLeoThang(BanHangDb db) => await db.Database.SqlQuery<long>($"""
    SELECT SUM(index_lock_promotion_count) AS Value
    FROM sys.dm_db_index_operational_stats(DB_ID(), OBJECT_ID(N'dbo.DonHang'), NULL, NULL)
    """).SingleAsync();

async Task<string> ChenDon2026()
{
    await using var c = new SqlConnection(cs + ";Pooling=false");
    await c.OpenAsync();
    await using var tx = (SqlTransaction)await c.BeginTransactionAsync();
    var dongHo = Stopwatch.StartNew();
    try
    {
        await new SqlCommand("""
            SET LOCK_TIMEOUT 2000;
            INSERT INTO dbo.DonHang (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien)
            VALUES (10060, '2026-10-02T19:00:05', 42, 1, 250000.00);
            """, c, tx).ExecuteNonQueryAsync();
        return $"xong sau {dongHo.ElapsedMilliseconds} ms";
    }
    catch (SqlException ex) { return $"lỗi {ex.Number} sau {dongHo.ElapsedMilliseconds} ms"; }
    finally { await tx.RollbackAsync(); }
}

public class DonHang
{
    public long DonHangId { get; set; }
    public DateTime NgayTao { get; set; }
    public byte TrangThai { get; set; }
}

public class BanHangDb(string cs) : DbContext
{
    public DbSet<DonHang> DonHang => Set<DonHang>();
    protected override void OnConfiguring(DbContextOptionsBuilder o) => o.UseSqlServer(cs);
    protected override void OnModelCreating(ModelBuilder m) => m.Entity<DonHang>(e =>
    {
        e.ToTable("DonHang", "dbo");
        e.HasKey(d => new { d.NgayTao, d.DonHangId });
        e.Property(d => d.NgayTao).HasColumnType("datetime2(0)");
    });
}
Output của LeoThang.cs trên LocalDB4 dòng
Đơn 2024 chưa ở trạng thái cuối: 20000
Một câu, LOCK_ESCALATION = TABLE: sửa 20000 dòng; khóa của job: OBJECT X: 1; leo thang +1; chèn đơn 2026: lỗi 1222 sau 2015 ms
Một câu, LOCK_ESCALATION = AUTO: sửa 20000 dòng; khóa của job: HOBT IX: 3, OBJECT IX: 1, HOBT X: 1; leo thang +1; chèn đơn 2026: xong sau 3 ms
Chia lô 4.000: các lô 4000, 4000, 4000, 4000, 4000, 0; leo thang +0

Những chỗ hay hiểu sai

Hiểu sai Đúng là
Mức isolation thấp thì khóa ghi nhả sớm Khóa X giữ đến hết giao dịch ở mọi mức
Leo thang đi từ dòng lên page rồi lên bảng Đi thẳng lên bảng, hoặc lên partition khi LOCK_ESCALATION = AUTO
ROWLOCK ngăn leo thang Chỉ đổi cách khóa ban đầu
Ngưỡng 5.000 tính trên cả giao dịch Tính theo từng câu lệnh, trên từng tham chiếu tới bảng hoặc chỉ mục
ExecuteUpdateAsync của EF Core sửa từng dòng nên không leo thang Nó gửi một câu UPDATE cho cả tập, chịu đúng ngưỡng của câu đó

Kết luận

Khóa X nằm đến hết giao dịch, nên số dòng một câu lệnh sửa quyết định nó chặn ai: dưới 5.000 khóa thì chỉ chặn đúng các dòng đó, vượt ngưỡng thì chặn cả bảng.

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

  • Job sửa hàng loạt dùng Take(4000) trước ExecuteUpdateAsync, lặp tới khi 0 dòng, mỗi lô một câu tự commit.
  • Ghi index_lock_promotion_count của bảng trước và sau mỗi lần job chạy; tăng là lô còn quá lớn.
  • Bảng phân vùng theo năm như dbo.DonHang đặt LOCK_ESCALATION = AUTO, và không chạy song song hai job trên hai partition.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

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

Trong SQL Server

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.

11 phút đọc

Trong SQL Server

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