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ỗ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
Xcủ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
Ukhiế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 = AUTOchỉ 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ên
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).
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.DonHang
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 clustered
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 locking
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.cs
#: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 LocalDB
Đơ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ướcExecuteUpdateAsync, lặp tới khi 0 dòng, mỗi lô một câu tự commit. - Ghi
index_lock_promotion_countcủ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đặtLOCK_ESCALATION = AUTO, và không chạy song song hai job trên hai partition.
Đọc tiếp
- Bài trước trong series: SQL Server — Giao dịch và XACT_ABORT.
- Bài sau trong series: SQL Server — Mức isolation và SNAPSHOT: mức isolation đổi cách câu đọc lấy khóa ra sao.
- Migration không dừng hệ thống: khóa
Sch-Mcủa một lệnhALTER TABLEđứng chờ sau báo cáo và chặn mọi request đến sau, đo trên 10 triệu đơn. - Số hóa đơn liên tục: khóa
Xtrên một dòng đếm làm mọi phiên xếp hàng, và cái giá khi giữ giao dịch thêm 30 ms.