Cơ sở dữ liệuSQL Server, phần 4/6
Chỉ mục, thống kê và cách dữ liệu được tìm
Cây B-tree, seek và key lookup, chỉ mục phủ, chỉ mục lọc, thống kê và ngưỡng tự cập nhật, tính bằng số trên bảng DonHang 10 triệu dòng.
Mục lục
- 1. Cây B-tree của một chỉ mục
- 2. Clustered, nonclustered và heap
- 3. Seek, scan và key lookup
- 4. Chỉ mục phủ, INCLUDE và thứ tự cột khóa
- 5. Điều kiện seek được
- 6. Chỉ mục lọc và chỉ mục duy nhất
- 7. Thống kê
- 8. Cái giá của chỉ mục khi ghi
- 9. Tìm chỉ mục thiếu và chỉ mục thừa
- 10. Danh sách kiểm khi thiết kế chỉ mục cho BanHang
- Những chỗ hay hiểu sai
- Đọc tiếp
- Nguồn
Kiến trúc lưu trữ dừng ở page 8 KB và dòng đơn hàng 35 byte. Chương này đi lên một tầng: các page đó xếp thành cây thế nào, tìm một đơn tốn bao nhiêu lần đọc, và trình tối ưu dựa vào đâu để chọn giữa seek và scan.
Mốc là SQL Server 2019, compatibility level 150. Ví dụ dùng dbo.DonHang 10 triệu dòng, phân vùng theo năm, với PK_DonHang và IX_DonHang_KhachHang của chương lưu trữ, IX_DonHang_DangMo của Kỹ thuật thường dùng. Số page và số lần đọc trong bài là ước lượng theo công thức của Microsoft, tính trên rowstore. Nếu đã tạo NCCI_DonHang, các câu quét lớn có thể chạy trên columnstore và số đọc khác hẳn.
Đọc nhanh
- Mỗi partition của
PK_DonHanglà một cây 3 tầng. Tìm một đơn theo đủ khóa(NgayTao, DonHangId)tốn 3 lần đọc logic. Câu không cóNgayTaophải seek vào từng partition. - Seek cộng key lookup tốn khoảng 3 lần đọc mỗi dòng. Khách 20 đơn: khoảng 70 lần đọc. Khách 42 với 300.000 đơn: khoảng 900.000 lần, gấp 20 lần quét cả bảng (khoảng 45.880 lần đọc).
INCLUDE (TrangThai, TongTien)trênIX_DonHang_KhachHangđưa khách 42 về khoảng 1.270 lần đọc, đổi lại thêm khoảng 96 MB và mỗi lần đổiTrangThaiphải sửa thêm chỉ mục đó.- Cột khóa: cột so sánh bằng trước, cột khoảng hoặc cột sắp xếp sau. Hàm bọc cột như
YEAR(NgayTao)làm mất cả seek lẫn khả năng bỏ qua partition. - Chỉ mục lọc chỉ được dùng khi giá trị lọc viết thẳng trong câu.
TrangThai = @TrangThaikhông khớpWHERE TrangThai = 1. - Với compatibility level từ 130, thống kê trên bảng 10 triệu dòng tự cập nhật sau 100.000 thay đổi, tức khoảng 7–8 ngày đơn mới. Đơn hôm nay luôn nằm ngoài histogram cho đến lần cập nhật sau.
sys.dm_db_index_usage_statsvà gợi ý chỉ mục thiếu mất khi khởi động lại. Disable trước, theo dõi Query Store, rồi mới xóa.
1. Cây B-tree của một chỉ mục
PK_DonHang và IX_DonHang_KhachHang là chỉ mục rowstore. Mỗi chỉ mục là một cây B+ (tài liệu Microsoft gọi chung là B-tree) với ba loại tầng:
| Tầng | index_level |
Mỗi dòng chứa |
|---|---|---|
| Root | Cao nhất | Khóa nhỏ nhất của một page con và địa chỉ page con đó |
| Trung gian (intermediate) | Từ 1 đến root − 1 | Như root |
| Lá (leaf) | 0 | Clustered: cả dòng dữ liệu. Nonclustered: cột khóa, cột INCLUDE và row locator |
Một lần seek đi từ root xuống. Ở mỗi tầng engine chọn đúng một page con theo khóa cần tìm, rồi dừng ở lá. Số page phải đọc bằng số tầng, gọi là độ sâu (index_depth). Các page cùng tầng nối với nhau bằng con trỏ trước và sau, nên đọc một khoảng khóa là đi ngang trên tầng lá, không quay lại root.
dbo.DonHang được phân vùng. Mỗi partition của một chỉ mục là một cây riêng, có root riêng. PK_DonHang vì vậy là bốn cây: ba cây có dữ liệu và cây của partition 4 (từ 2027) còn trống.
Độ sâu của PK_DonHang, tính tay
Dòng ở tầng không phải lá, theo công thức ước lượng của Microsoft, gồm khóa, null bitmap nếu có cột khóa cho phép NULL, 1 byte header và 6 byte con trỏ page con (số page 4 byte, số file 2 byte).
| Thành phần | Byte |
|---|---|
NgayTao datetime2(0) |
6 |
DonHangId bigint |
8 |
Null bitmap: mọi cột khóa NOT NULL |
0 |
| Header dòng chỉ mục | 1 |
| Con trỏ page con | 6 |
| Dòng không phải lá | 21 |
Cộng 2 byte slot là 23 byte. Một page không phải lá chứa 8.096 / 23 = 352 dòng, vừa khít: 352 × 23 = 8.096.
Partition 3 (năm 2026) có 3.600.000 dòng. Tầng lá cần 3.600.000 / 218 = 16.513,8, làm tròn lên 16.514 page. Tầng ngay trên cần một dòng cho mỗi page lá: 16.514 dòng / 352 = 46,9, tức 47 page. 47 dòng vừa một page, và page đó là root. Cây có 3 tầng.
Công thức của Microsoft cho cùng kết quả: số tầng không phải lá = 1 + log₃₅₂(16.514 / 352) = 1 + log₃₅₂(46,9) ≈ 1,66, làm tròn lên 2. Cộng tầng lá là 3.
flowchart TB R["Root, index_level 2: 1 page, 47 dòng"] I1["Trung gian, index_level 1: page 1 trên 47, 352 dòng"] I47["Trung gian: page 47 trên 47, 322 dòng"] L1["Lá, index_level 0: page 1, 218 đơn"] L2["Lá: page 2, 218 đơn"] LN["Lá: page 16.514, đơn mới nhất"] R --> I1 R --> I47 I1 --> L1 I1 --> L2 I47 --> LN L1 -.- L2
46 page trung gian đầy chứa 46 × 352 = 16.192 dòng. Page thứ 47 giữ 322 dòng còn lại. Đường chấm giữa hai page lá là con trỏ nối hai chiều trên cùng tầng.
| Partition | Dòng | Page lá (÷ 218) | Page trung gian (÷ 352) | Root | Độ sâu |
|---|---|---|---|---|---|
| 1, trước 2025 | 3.000.000 | 13.762 | 40 | 1 | 3 |
| 2, năm 2025 | 3.400.000 | 15.597 | 45 | 1 | 3 |
| 3, năm 2026 | 3.600.000 | 16.514 | 47 | 1 | 3 |
| 4, từ 2027 | 0 | 0 | 0 | 0 | Chưa có cây dữ liệu |
Tổng tầng lá là 45.873 page. Tính gộp 10.000.000 / 218 ra 45.872. Lệch một page vì làm tròn từng partition.
Một partition phải vượt 352 × 352 = 123.904 page lá, tức khoảng 123.904 × 218 ≈ 27 triệu dòng, thì mới lên 4 tầng. Bảng không phân vùng với 10 triệu dòng cũng chỉ 3 tầng: 45.872 / 352 = 130,3, tức 131 page trung gian và 1 root. Phân vùng không làm cây nông đi. Các số trên là ước lượng theo công thức. Page thực tế có thể thưa hơn vì xóa, tách page, hoặc fillfactor dưới 100.
Kiểm tra trên máy
sys.dm_db_index_physical_stats ở chế độ DETAILED trả một dòng cho mỗi tầng của mỗi partition. Câu dưới chỉ xem một chỉ mục và một partition:
DECLARE @obj int = OBJECT_ID(N'dbo.DonHang');
DECLARE @idx int = (SELECT index_id FROM sys.indexes
WHERE object_id = @obj AND name = N'PK_DonHang');
SELECT
ps.index_level, ps.index_depth, ps.page_count, ps.record_count,
ps.avg_record_size_in_bytes, ps.avg_page_space_used_in_percent
FROM sys.dm_db_index_physical_stats(DB_ID(), @obj, @idx, 3, 'DETAILED') AS ps
WHERE ps.alloc_unit_type_desc = N'IN_ROW_DATA'
ORDER BY ps.index_level DESC;
DETAILED đọc mọi page
DETAILED quét toàn bộ tầng lá: với partition 3 là khoảng 16.500 page, tức 130 MB đi qua buffer pool. Chạy ngoài giờ cao điểm hoặc trên bản restore. Chỉ cần độ sâu và số page lá thì dùng 'LIMITED' với NULL ở tham số partition: chế độ này chỉ đọc các page trên tầng lá, nhưng không trả dòng cho từng tầng và để trống record_count, avg_record_size_in_bytes.
Kết quả mong đợi cho partition 3, theo công thức ở trên:
| index_level | index_depth | page_count | record_count | avg_record_size_in_bytes |
|---|---|---|---|---|
| 2 | 3 | 1 | 47 | 21 |
| 1 | 3 | 47 | 16.514 | 21 |
| 0 | 3 | 16.514 | 3.600.000 | 35 |
record_count của tầng 1 bằng số page tầng 0, và của tầng 2 bằng số page tầng 1: mỗi dòng ở tầng trên trỏ đúng một page ở tầng dưới.
Một lần seek bằng đúng độ sâu
SET STATISTICS IO ON;
SELECT DonHangId, NgayTao, KhachHangId, TrangThai, TongTien
FROM dbo.DonHang
WHERE NgayTao = CAST('2026-10-02T11:58:00' AS datetime2(0))
AND DonHangId = 10042;
Kết quả minh họa:
Table 'DonHang'. Scan count 0, logical reads 3, physical reads 0, page server reads 0,
read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0,
lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
Giá trị NgayTao là hằng số nên engine biết ngay đơn nằm ở partition 3 và chỉ đi vào cây đó: root, một page trung gian, một page lá. Scan count 0 vì đây là seek một giá trị trên khóa chính duy nhất.
Thiếu cột đầu của khóa thì không seek được. WHERE DonHangId = 10042 không có NgayTao, nên PK_DonHang không định vị được dòng và phải quét toàn bộ một chỉ mục có cột DonHangId. Kể cả khi thêm một chỉ mục aligned trên DonHangId, câu đó vẫn phải seek vào từng partition: tối đa 4 cây × 3 tầng = 12 lần đọc thay vì 3. Tài liệu phân vùng của Microsoft nói đúng điều này: seek một dòng trên bảng phân vùng mà điều kiện không có cột phân vùng sẽ chạy bấy nhiêu seek như số partition.
2. Clustered, nonclustered và heap
Chỉ mục nonclustered nào cũng mang theo khóa clustered. Khóa clustered rộng thì mọi chỉ mục khác phình theo.
- Clustered index: tầng lá là chính dòng dữ liệu. Lá của
PK_DonHanglà dòng 35 byte của chương lưu trữ. Mỗi bảng có tối đa một clustered index. - Heap: bảng không có clustered index. Dòng không theo thứ tự khóa nào. Chỉ mục nonclustered trỏ tới dòng bằng RID 8 byte gồm số file, số page và số slot.
- Nonclustered index: tầng lá là dòng chỉ mục gồm cột khóa, cột
INCLUDEnếu có, và row locator để quay về dòng dữ liệu.
Row locator nằm ở đâu phụ thuộc vào loại bảng và chỉ mục:
| Bảng gốc | Nonclustered không unique | Nonclustered unique |
|---|---|---|
| Heap | RID nối vào cuối khóa | RID nằm ở tầng lá, như cột INCLUDE |
| Clustered unique | Cột khóa clustered chưa có trong khóa được nối vào cuối khóa | Cột khóa clustered nằm ở tầng lá |
| Clustered không unique | Như trên, cộng uniqueifier 4 byte khi có | Như trên |
Engine không lưu một cột hai lần: cột đã có trong khóa nonclustered thì không thêm lại.
Một dòng của IX_DonHang_KhachHang
IX_DonHang_KhachHang (KhachHangId, NgayTao) không unique. Khóa clustered là (NgayTao, DonHangId). NgayTao đã có trong khóa, nên chỉ DonHangId được nối vào. Khóa thực của chỉ mục là (KhachHangId, NgayTao, DonHangId).
| Thành phần dòng lá | Byte |
|---|---|
KhachHangId int |
4 |
NgayTao datetime2(0) |
6 |
DonHangId bigint, row locator |
8 |
| Null bitmap: 2 + (3 + 7) / 8, chia nguyên | 3 |
| Header dòng chỉ mục | 1 |
| Dòng lá | 22 |
Công thức tầng lá của Microsoft luôn tính null bitmap. Tầng trên chỉ tính khi có cột khóa cho phép NULL.
- Tầng lá: 22 + 2 byte slot = 24. 8.096 / 24 = 337,3, tức 337 dòng mỗi page.
- Tầng trên: 18 byte khóa + 1 + 6 = 25, cộng slot là 27. 8.096 / 27 = 299,9, tức 299 dòng mỗi page.
| Partition | Dòng | Page lá (÷ 337) | Page trung gian (÷ 299) | Độ sâu |
|---|---|---|---|---|
| 1 | 3.000.000 | 8.903 | 30 | 3 |
| 2 | 3.400.000 | 10.090 | 34 | 3 |
| 3 | 3.600.000 | 10.683 | 36 | 3 |
Tầng lá tổng cộng 29.676 page, khoảng 232 MB, so với 45.873 page, khoảng 358 MB, của PK_DonHang. Chạy lại câu DETAILED ở mục 1 với N'IX_DonHang_KhachHang' thay cho N'PK_DonHang': avg_record_size_in_bytes ở index_level 0 nên quanh 22 nếu ước lượng đúng.
Nếu khóa clustered là (NgayTao, uniqueidentifier) thay cho bigint, row locator dài 16 byte. Dòng lá thành 4 + 6 + 16 + 3 + 1 = 30 byte, mỗi page còn 8.096 / 32 = 253 dòng. Cùng chỉ mục cần thêm khoảng 337 / 253 − 1 ≈ 33% page. Chọn kiểu khóa nằm ở Kiểu dữ liệu, collation và khóa chính.
Trên heap, lần quay về dòng dữ liệu là một lần đọc page theo RID thay vì 3 lần đọc theo khóa clustered. Đổi lại, dòng trên heap không có thứ tự, và một dòng dài ra mà không còn chỗ trên page sẽ để lại bản ghi chuyển tiếp (forwarded record), làm mỗi lần đọc qua nó tốn thêm một page. dbo.DonHang giữ clustered index.
3. Seek, scan và key lookup
Ba thao tác truy cập dữ liệu trong kế hoạch thực thi:
- Index seek: đi từ root xuống để tìm một khóa hoặc điểm đầu của một khoảng, rồi đọc ngang trên tầng lá đến hết khoảng.
- Index scan: đọc toàn bộ tầng lá của chỉ mục, hoặc của các partition còn lại sau khi engine bỏ qua partition không liên quan.
- Key lookup: với mỗi dòng tìm được trên chỉ mục nonclustered, seek vào clustered index bằng row locator để lấy cột còn thiếu. Trên heap, toán tử tương ứng là RID lookup.
Màn hình "lịch sử đơn của khách" chạy câu sau. IX_DonHang_KhachHang có KhachHangId, NgayTao, DonHangId nhưng không có TrangThai và TongTien, nên mỗi dòng tìm được cần một key lookup.
SET STATISTICS IO ON;
SELECT DonHangId, NgayTao, TrangThai, TongTien
FROM dbo.DonHang
WHERE KhachHangId = 77;
Khách 77, 20 đơn
Đơn của khách 77 rải trên ba partition có dữ liệu. Không có điều kiện NgayTao, nên seek vào cả ba cây của IX_DonHang_KhachHang: 3 × 3 = 9 lần đọc. Mỗi dòng tìm được thêm một key lookup vào PK_DonHang: 20 × 3 = 60 lần đọc. Tổng khoảng 70.
Kết quả minh họa, đã lược các cột page server và lob:
Table 'DonHang'. Scan count 4, logical reads 71, physical reads 0, read-ahead reads 0, ...
Scan count đếm số lần seek hoặc scan bắt đầu ở tầng lá, ở đây là các lần seek vào từng partition. Key lookup theo khóa clustered duy nhất không cộng vào Scan count, nhưng mọi page nó đọc nằm trong logical reads. Bảng và chỉ mục của nó chung một dòng Table 'DonHang'.
Khách 42, 300.000 đơn
Cùng câu với KhachHangId = 42. Hint dưới đây ép đường seek cộng lookup để so sánh. Câu thứ hai để trình tối ưu tự chọn.
SELECT DonHangId, NgayTao, TrangThai, TongTien
FROM dbo.DonHang WITH (INDEX (IX_DonHang_KhachHang))
WHERE KhachHangId = 42;
SELECT DonHangId, NgayTao, TrangThai, TongTien
FROM dbo.DonHang
WHERE KhachHangId = 42;
| Đường đi | Tính | Lần đọc ước lượng |
|---|---|---|
Seek IX_DonHang_KhachHang rồi key lookup |
300.000 / 337 ≈ 891 page lá, cộng 300.000 × 3 | khoảng 900.900 |
Quét PK_DonHang, lọc từng dòng |
45.873 page lá, cộng vài page trên mỗi cây | khoảng 45.880 |
Kết quả minh họa:
-- Câu 1, ép seek và key lookup
Table 'DonHang'. Scan count 4, logical reads 900917, physical reads 0, read-ahead reads 0, ...
-- Câu 2, trình tối ưu chọn quét clustered index
Table 'DonHang'. Scan count 4, logical reads 45880, physical reads 0, read-ahead reads 0, ...
900.000 lần đọc logic là chạm khoảng 6,9 GB page trong buffer pool cho một màn hình, cộng CPU để latch và đọc từng page. Quét đọc ít hơn 20 lần, và đọc tuần tự nên read-ahead kéo page lên theo khối lớn khi page chưa có trong RAM. Khi kế hoạch chạy song song, Scan count sẽ khác.
Điểm lật
Mỗi dòng đi qua lookup tốn khoảng 3 lần đọc. Quét tốn khoảng 45.880 lần đọc dù trả bao nhiêu dòng. Hai đường bằng nhau ở khoảng 45.880 / 3 ≈ 15.300 dòng, tức khoảng 0,15% bảng. Dưới mức đó seek cộng lookup đọc ít hơn. Trên mức đó quét đọc ít hơn.
Trình tối ưu không so số lần đọc logic trực tiếp. Mô hình chi phí của nó tính lần đọc ngẫu nhiên của lookup đắt hơn lần đọc tuần tự của scan, nên trên thực tế kế hoạch thường lật sang scan sớm hơn con số trên. Kimberly Tripp mô tả điểm lật (tipping point) nằm quanh 25% đến 33% số page của bảng, tính bằng số dòng. Với DonHang là khoảng 45.873 / 4 ≈ 11.500 đến 45.873 / 3 ≈ 15.300 dòng. Đây là quy tắc ước chừng, không phải công thức: độ rộng dòng, song song và bộ nhớ đều xê dịch nó.
Hai hệ quả cho BanHang:
- Kế hoạch được chọn theo số dòng ước lượng từ thống kê (mục 7), không theo số dòng thật. Ước lượng 50 dòng cho khách 42 thì trình tối ưu chọn lookup và tốn 900.000 lần đọc.
- Kế hoạch được dùng lại. Kế hoạch biên dịch cho khách 77 rồi chạy cho khách 42 cho đúng kết quả 900.000 lần đọc ở trên. Cơ chế nằm ở Đọc kế hoạch thực thi. Một ca điều tra đầy đủ nằm ở Điều tra truy vấn chậm.
4. Chỉ mục phủ, INCLUDE và thứ tự cột khóa
Một chỉ mục phủ (covering) câu truy vấn khi mọi cột câu đó cần đều có trên chỉ mục. Không còn key lookup.
IX_DonHang_KhachHang đã phủ những câu chỉ cần KhachHangId, NgayTao và DonHangId, vì DonHangId có sẵn ở tầng lá dưới dạng row locator:
SELECT DonHangId, NgayTao
FROM dbo.DonHang
WHERE KhachHangId = 42
AND NgayTao >= CAST('20260901' AS datetime2(0))
AND NgayTao < CAST('20261001' AS datetime2(0));
Khách 42 có 300.000 đơn trong 1.005 ngày từ 2024-01-01, khoảng 300 đơn mỗi ngày. Chia đều thì tháng 9 có khoảng 9.000 đơn, nằm liền nhau trên 9.000 / 337 ≈ 27 page lá của partition 3. Cộng 2 page trên lá là khoảng 29 lần đọc. Không có lookup.
INCLUDE
INCLUDE thêm cột vào tầng lá, không thêm vào khóa. Cột INCLUDE không được sắp xếp, không nằm ở root và tầng trung gian, nên không làm dòng tầng trên dài ra.
Phủ câu lịch sử đơn ở mục 3 cần thêm TrangThai (1 byte) và TongTien (9 byte). Dòng lá thành 18 byte khóa và row locator + 1 + 9 + null bitmap 2 + (5 + 7) / 8 = 3 + header 1 = 32 byte. 8.096 / (32 + 2) = 238 dòng mỗi page lá. Tầng trên giữ 25 byte và 299 dòng mỗi page. Tầng lá thành 12.606 + 14.286 + 15.127 = 42.019 page, khoảng 328 MB. So với định nghĩa gốc là thêm 12.343 page, khoảng 96 MB.
Câu lịch sử đơn của khách 42 trên chỉ mục đã INCLUDE đọc 300.000 / 238 ≈ 1.261 page lá, cộng 2 page trên lá ở mỗi partition: khoảng 1.270 lần đọc, so với khoảng 900.900 khi seek cộng lookup và khoảng 45.880 khi quét PK_DonHang.
Nếu chọn phủ, định nghĩa đổi bằng DROP_EXISTING. Lệnh dựng lại chỉ mục từ đầu và trên Standard giữ khóa bảng đến khi xong. Enterprise thêm ONLINE = ON.
CREATE INDEX IX_DonHang_KhachHang
ON dbo.DonHang (KhachHangId, NgayTao)
INCLUDE (TrangThai, TongTien)
WITH (DROP_EXISTING = ON)
ON ps_DonHang_Ngay (NgayTao);
Định nghĩa gốc vẫn là mốc
Các phép tính khác trong bài, và các chương khác, dùng định nghĩa gốc IX_DonHang_KhachHang (KhachHangId, NgayTao). Lệnh trên là một phương án. Điều tra truy vấn chậm đi qua quyết định này với số đo trước và sau.
Giá của INCLUDE nằm ở phía ghi. TongTien ít khi đổi. TrangThai đổi khoảng 3 lần trong đời một đơn: Mới, Đã thanh toán, Đang giao, Hoàn tất. Với khoảng 13.100 đơn mỗi ngày năm 2026, đó là thêm khoảng 39.000 lần sửa dòng trên chỉ mục này mỗi ngày.
Thứ tự cột khóa
Quy tắc: cột so sánh bằng (=, IN) đứng trước, rồi một cột khoảng hoặc cột sắp xếp. Sau cột khoảng, các cột khóa phía sau không thu hẹp được seek nữa. Chúng chỉ còn là điều kiện lọc phụ (residual predicate) áp lên từng dòng trong khoảng.
Với khóa (KhachHangId, NgayTao):
| Điều kiện | Cách dùng chỉ mục |
|---|---|
KhachHangId = 42 |
Seek, đọc khoảng 891 page lá |
KhachHangId = 42 AND NgayTao trong tháng 9 |
Seek trên cả hai cột, khoảng 27 page lá |
KhachHangId IN (42, 77) |
Seek hai khoảng |
KhachHangId > 1000 AND NgayTao >= '20260901' |
Seek theo KhachHangId. NgayTao là điều kiện phụ trên mọi dòng của khách từ 1001 trở đi |
Chỉ NgayTao >= '20260901' |
Không seek được trên chỉ mục này. PK_DonHang phục vụ câu đó |
KhachHangId = 42 ORDER BY NgayTao |
Thứ tự có sẵn trong từng partition, và giữ được qua các partition vì NgayTao là cột phân vùng. Xem dưới |
Đảo thứ tự thì mất seek theo khách. PK_DonHang có NgayTao đứng đầu, nên chính nó cho thấy giá của thứ tự ngược. Câu tháng 9 của khách 42 đi theo PK_DonHang phải đọc mọi đơn tháng 9: khoảng 13.100 × 30 = 393.000 dòng, tức 393.000 / 218 ≈ 1.803 page, rồi lọc KhachHangId từng dòng. 1.803 page so với 27 page. "Cột chọn lọc nhất đứng đầu" không phải quy tắc chính. NgayTao gần như duy nhất, chọn lọc hơn KhachHangId nhiều, nhưng đặt nó lên đầu làm mất phép so sánh bằng trên khách. Độ chọn lọc chỉ dùng để xếp thứ tự giữa các cột cùng là so sánh bằng.
TOP và ORDER BY trên chỉ mục phân vùng
Màn hình "50 đơn mới nhất của khách":
SELECT TOP (50) DonHangId, NgayTao
FROM dbo.DonHang
WHERE KhachHangId = 42
ORDER BY NgayTao DESC;
IX_DonHang_KhachHang là chỉ mục aligned: mỗi partition một cây, và thứ tự (KhachHangId, NgayTao) chỉ có trong từng cây. Ở câu này, cột sắp xếp lại chính là cột phân vùng, và cột đứng trước nó trong khóa có điều kiện bằng. Partition được chia theo khoảng NgayTao, nên đọc partition 4 rồi 3, 2, 1, mỗi cây đọc ngược, cho đúng thứ tự NgayTao giảm dần trên toàn bảng. Bản thử trên SQL Server 2019 ở Điều tra truy vấn chậm cho đúng kế hoạch đó: Index Seek Ordered, BACKWARD qua các partition, không có Sort, và Top dừng sau dòng thứ 50. Khách 42 có đủ 50 đơn trong năm 2026, nên engine chỉ đụng partition 4 (trống) và partition 3.
Kết quả này không đúng cho mọi dạng câu. KB 2965553 của Microsoft mô tả TOP, MIN, MAX trên bảng phân vùng phải quét toàn bộ chỉ mục khi cột sắp xếp không phải cột phân vùng. Cũng trên bản thử đó, chỉ thêm AND TrangThai <> 5 là kế hoạch đổi thành seek xuôi cộng Top N Sort: đọc mọi đơn của khách rồi mới giữ 50. Với khách 42 là 300.000 dòng đi qua Sort để giữ lại 50.
Đừng suy ra từ lý thuyết. Mỗi lần đổi câu, mở kế hoạch thực tế và tìm toán tử Sort. Nếu có, hai cách giảm:
- Thêm cận dưới cho
NgayTaokhi nghiệp vụ chấp nhận, chẳng hạn 90 ngày gần nhất. Câu chỉ còn chạm partition năm nay và số dòng phải sắp nhỏ đi. Gần đầu năm, khoảng đó vắt qua hai partition. - Chỉ mục không aligned: một cây cho cả bảng, thứ tự toàn cục. Đổi lại, chỉ mục không aligned đang bật chặn
SWITCHpartition mà Kỹ thuật thường dùng dùng để đẩy cả năm ra khỏi bảng.
5. Điều kiện seek được
Một điều kiện dùng được để seek (sargable) khi cột đứng trần ở một vế, so với một biểu thức không phụ thuộc dòng, cùng kiểu dữ liệu với cột. Bọc cột trong hàm hoặc phép tính thì engine phải tính biểu thức cho từng dòng rồi mới so, nên chỉ quét được. Với DonHang, điều kiện bọc NgayTao còn làm mất khả năng bỏ qua partition, vì engine không còn biết giá trị nào rơi vào partition nào.
| Viết | Vấn đề | Viết lại |
|---|---|---|
YEAR(NgayTao) = 2026 |
Hàm bọc cột. Quét cả 4 partition, khoảng 45.880 page | NgayTao >= '20260101' AND NgayTao < '20270101'. Chỉ chạm partition 3 |
CONVERT(char(8), NgayTao, 112) = '20261001' |
Đổi cột sang chuỗi. Quét cả bảng | Khoảng nửa mở một ngày. Khoảng 13.100 dòng, khoảng 61 page lá |
DATEADD(day, 30, NgayTao) >= @Moc |
Phép tính trên cột | NgayTao >= DATEADD(day, -30, @Moc) |
MaSanPham LIKE '%042' |
Ký tự đại diện đứng đầu, không có tiền tố để định vị | Tiền tố LIKE 'SP-0004%' seek được. Tìm theo đuôi thì cột tính toán REVERSE(MaSanPham) có chỉ mục |
ISNULL(SoDienThoai, '') = '0901234567' |
Hàm bọc cột | SoDienThoai = '0901234567'. Hai cách cho cùng kết quả vì hằng số khác chuỗi rỗng |
MaSanPham = @Ma với @Ma nvarchar |
Cột varchar bị chuyển ngầm sang nvarchar |
Tham số cùng kiểu cột, varchar(20) |
Dòng cuối phụ thuộc collation. Với collation Windows như Vietnamese_100_CI_AS của BanHang, engine vẫn có thể seek qua một khoảng tính lúc chạy. Với collation SQL thì quét. Chi tiết nằm ở Kiểu dữ liệu, collation và khóa chính. Đây là dạng chuyển kiểu ngầm hay gặp nhất, do code ORM gửi chuỗi Unicode theo mặc định.
CAST(NgayTao AS date) = '20261001' là ngoại lệ. Engine nhận ra phép đổi từ ngày giờ sang ngày giữ thứ tự, tự tính một khoảng và vẫn seek. Khoảng tự tính có thể rộng hơn cần thiết: một thử nghiệm công khai trên cột datetime cho thấy seek đọc cả ngày hôm trước rồi lọc lại bằng điều kiện phụ. Ước lượng số dòng của dạng này cũng kém hơn dạng khoảng. Viết khoảng nửa mở thì không phải dựa vào ngoại lệ.
Ba câu so sánh trên máy thật:
SET STATISTICS IO ON;
-- Quét mọi partition
SELECT DonHangId, TongTien FROM dbo.DonHang
WHERE CONVERT(char(8), NgayTao, 112) = '20261001';
-- Seek, nhưng khoảng do engine tự tính
SELECT DonHangId, TongTien FROM dbo.DonHang
WHERE CAST(NgayTao AS date) = '20261001';
-- Seek vào partition 3, đúng một ngày
SELECT DonHangId, TongTien FROM dbo.DonHang
WHERE NgayTao >= CAST('20261001' AS datetime2(0))
AND NgayTao < CAST('20261002' AS datetime2(0));
Trong kế hoạch thực tế, điều kiện dùng để định vị nằm ở mục Seek Predicates của toán tử. Điều kiện chỉ để lọc nằm ở Predicate. Câu thứ nhất không có Seek Predicates. Theo ước lượng, logical reads của nó quanh 45.880, của câu thứ ba quanh 63: khoảng 61 page lá cộng 2 page trên lá.
6. Chỉ mục lọc và chỉ mục duy nhất
IX_DonHang_DangMo
Kỹ thuật thường dùng đã tạo IX_DonHang_DangMo (KhachHangId, NgayTao) INCLUDE (TongTien) WHERE TrangThai = 1. Đơn mới chiếm 1% bảng, khoảng 100.000 dòng.
Dòng lá: 18 byte khóa và row locator, 9 byte TongTien, null bitmap 3 byte, header 1 byte, tổng 31 byte. 8.096 / 33 = 245 dòng mỗi page. 100.000 / 245 ≈ 409 page lá, khoảng 3,2 MB, bằng 1,4% tầng lá của IX_DonHang_KhachHang. Chỉ mục lọc có thống kê lọc riêng trên đúng 100.000 dòng đó, nên ước lượng cho đơn đang mở sát hơn thống kê cả bảng.
Trình tối ưu chỉ chọn chỉ mục lọc khi chứng minh được điều kiện của câu nằm gọn trong điều kiện lọc. Kế hoạch được lưu để dùng lại phải đúng với mọi giá trị tham số. Nếu TrangThai là tham số, kế hoạch phải đúng cả khi @TrangThai = 4, nên chỉ mục chỉ chứa TrangThai = 1 bị loại.
-- TrangThai là tham số: IX_DonHang_DangMo không được dùng
EXEC sys.sp_executesql
N'SELECT KhachHangId, NgayTao, TongTien FROM dbo.DonHang
WHERE KhachHangId = @KhachHangId AND TrangThai = @TrangThai;',
N'@KhachHangId int, @TrangThai tinyint',
@KhachHangId = 42, @TrangThai = 1;
-- Giá trị lọc viết thẳng trong câu: khớp điều kiện của chỉ mục
EXEC sys.sp_executesql
N'SELECT KhachHangId, NgayTao, TongTien FROM dbo.DonHang
WHERE KhachHangId = @KhachHangId AND TrangThai = 1;',
N'@KhachHangId int',
@KhachHangId = 42;
XML kế hoạch của câu thứ nhất thường mang cảnh báo UnmatchedIndexes: có chỉ mục lọc lẽ ra dùng được nhưng không khớp vì tham số hóa.
Chỉ mục lọc và tham số
Màn hình "đơn đang mở" phải gửi TrangThai = 1 dưới dạng hằng số trong câu SQL, không qua tham số. Nếu buộc phải dùng tham số, OPTION (RECOMPILE) cho phép trình tối ưu nhìn giá trị thật, đổi lại mỗi lần chạy là một lần biên dịch. Database đặt PARAMETERIZATION FORCED biến cả hằng số thành tham số và có thể làm chỉ mục lọc mất tác dụng.
Giới hạn khác của chỉ mục lọc, theo tài liệu Microsoft:
- Điều kiện lọc chỉ gồm phép so sánh đơn giản trên cột của một bảng. Không có
LIKE, không tham chiếu cột tính toán. - Nếu điều kiện lọc gây đổi kiểu ở vế trái phép so sánh thì lệnh tạo báo lỗi. Đặt
CASThoặcCONVERTở vế phải. - Không đặt điều kiện lọc lên ràng buộc
PRIMARY KEYhayUNIQUE, nhưng đặt được lên chỉ mục có thuộc tínhUNIQUE. - Chỉ mục lọc chứa phần lớn bảng tốn công bảo trì hơn chỉ mục đầy đủ. Lợi chỉ rõ khi tập lọc nhỏ, như 1% ở đây.
Chỉ mục duy nhất
Chỉ mục unique cho trình tối ưu biết một giá trị khóa khớp tối đa một dòng. Ước lượng chắc chắn hơn, và seek dừng ngay khi tìm thấy. Với nonclustered unique, row locator chỉ nằm ở tầng lá, nên dòng ở tầng trên ngắn hơn bản không unique.
SQL Server coi các giá trị NULL là bằng nhau trong chỉ mục unique: chỉ một dòng được mang NULL. Khách chưa có số điện thoại để NULL, nên ràng buộc "một số điện thoại thuộc một khách" cần chỉ mục unique có lọc WHERE SoDienThoai IS NOT NULL. Kiểu dữ liệu, collation và khóa chính đã tạo đúng chỉ mục đó, UX_KhachHang_SoDienThoai, kèm cách tìm số trùng trước khi tạo.
Chỉ mục này vừa là ràng buộc vừa là chỉ mục lọc. Nó chỉ chứa khách có số điện thoại, nên nhỏ hơn chỉ mục đầy đủ. Câu WHERE SoDienThoai = '0901234567' khớp điều kiện lọc vì một giá trị cụ thể không thể là NULL, và seek trên nó dừng sau đúng một dòng.
Chỉ mục unique phân vùng phải chứa cột phân vùng trong khóa. Khóa chính (NgayTao, DonHangId) thỏa điều kiện đó, nhưng chỉ bảo đảm cặp hai cột không trùng. Muốn riêng DonHangId duy nhất trên toàn bảng thì phải dùng chỉ mục unique không aligned, và chỉ mục đó chặn SWITCH. Cách còn lại là dựa vào nơi sinh mã, chẳng hạn một sequence. Kiểu dữ liệu, collation và khóa chính bàn cách sinh khóa.
7. Thống kê
Mục 3 cho thấy kế hoạch đúng hay sai phụ thuộc vào số dòng ước lượng. Số đó đến từ thống kê.
Mỗi chỉ mục có một đối tượng thống kê trên các cột khóa. AUTO_CREATE_STATISTICS tạo thêm thống kê một cột, tên bắt đầu bằng _WA_Sys_, cho cột xuất hiện trong điều kiện mà chưa có histogram. Một đối tượng thống kê gồm ba phần:
| Phần | Nội dung |
|---|---|
| Header | Thời điểm cập nhật, số dòng lúc đó, số dòng lấy mẫu, số bước histogram |
| Density vector | Mật độ = 1 / số giá trị phân biệt, cho từng tiền tố cột: (KhachHangId), (KhachHangId, NgayTao), (KhachHangId, NgayTao, DonHangId) |
| Histogram | Phân bố của cột đầu tiên, tối đa 200 bước |
Mỗi bước histogram có RANGE_HI_KEY (giá trị cận trên), EQ_ROWS (số dòng bằng đúng cận trên), RANGE_ROWS (số dòng nằm giữa cận trên của bước trước và cận này), DISTINCT_RANGE_ROWS (số giá trị phân biệt trong khoảng đó) và AVG_RANGE_ROWS = RANGE_ROWS / DISTINCT_RANGE_ROWS.
Xem thống kê của IX_DonHang_KhachHang
DBCC SHOW_STATISTICS (N'dbo.DonHang', IX_DonHang_KhachHang)
WITH STAT_HEADER, DENSITY_VECTOR;
SELECT
s.name AS stats_name, sp.last_updated, sp.rows, sp.rows_sampled,
sp.steps, sp.modification_counter
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE s.object_id = OBJECT_ID(N'dbo.DonHang');
SELECT
h.step_number, h.range_high_key, h.range_rows, h.equal_rows,
h.distinct_range_rows, h.average_range_rows
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_histogram(s.object_id, s.stats_id) AS h
WHERE s.object_id = OBJECT_ID(N'dbo.DonHang')
AND s.name = N'IX_DonHang_KhachHang'
AND CAST(h.range_high_key AS int) <= 2000;
sys.dm_db_stats_histogram có từ SQL Server 2016 SP1 CU2. range_high_key có kiểu sql_variant, nên phải CAST trước khi so với số.
Density vector minh họa, giả sử cả 200.000 khách đều có đơn:
| All density | Average Length | Columns |
|---|---|---|
| 5E-06 | 4 | KhachHangId |
| 1E-07 | 10 | KhachHangId, NgayTao |
| 1E-07 | 18 | KhachHangId, NgayTao, DonHangId |
1 / 200.000 = 5E-06. Hai tiền tố sau gần như duy nhất, nên mật độ xấp xỉ 1 / 10.000.000. Average Length 4, 10, 18 byte khớp với phần khóa đã tính ở mục 2.
Ba bước histogram minh họa quanh khách 42. Bước trước đó có cận trên là khách 7.
| RANGE_HI_KEY | RANGE_ROWS | EQ_ROWS | DISTINCT_RANGE_ROWS | AVG_RANGE_ROWS |
|---|---|---|---|---|
| 41 | 1.520 | 47 | 33 | 46,06 |
| 42 | 0 | 300.120 | 0 | 1 |
| 1.105 | 52.800 | 51 | 1.062 | 49,72 |
Bước 41 phủ khách 8 đến 40, tức 33 giá trị: 1.520 / 33 = 46,06. Bước 1.105 phủ khách 43 đến 1.104, tức 1.062 giá trị: 52.800 / 1.062 = 49,72. Thuật toán dựng histogram chọn cận sao cho giữ được nhiều thông tin nhất, nên một giá trị lệch hẳn như khách 42 thường có bước riêng. Không có gì bảo đảm điều đó. Với histogram lấy mẫu, các số là ước lượng và có thể lẻ.
Cùng một điều kiện KhachHangId = ..., trình tối ưu ước lượng theo ba đường:
| Cách viết | Nguồn ước lượng | Số dòng ước lượng |
|---|---|---|
KhachHangId = 42 hằng số, hoặc tham số biên dịch với 42 |
EQ_ROWS của bước 42 |
300.120 |
KhachHangId = 77 |
AVG_RANGE_ROWS của bước 1.105 |
49,72 |
KhachHangId = @x với @x là biến cục bộ |
All density × số dòng = 5E-06 × 10.000.000 | 50 |
Khách 77 thật ra có 20 đơn. 49,72 so với 20 vẫn là "ít", nên kế hoạch vẫn đúng. Với biến cục bộ, khách 42 được ước lượng 50 dòng, và trình tối ưu chọn seek cộng 300.000 lookup.
Cột chỉ có vài giá trị cho histogram rõ hơn nữa. Thống kê tự tạo trên TrangThai, sau lần đầu một câu lọc theo cột này, thường có 5 bước với EQ_ROWS khoảng 100.000, 200.000, 200.000, 9.000.000 và 500.000. TrangThai = 4 ước lượng 90% bảng và không bao giờ đáng seek. TrangThai = 1 ước lượng 1%, và IX_DonHang_DangMo ở mục 6 phục vụ đúng phần đó.
Khi nào thống kê tự cập nhật
AUTO_UPDATE_STATISTICS đếm số thay đổi trên cột đầu của thống kê kể từ lần cập nhật trước (modification_counter) và so với một ngưỡng tính theo số dòng của bảng. Vượt ngưỡng thì thống kê bị coi là cũ, nhưng chỉ được cập nhật khi một câu cần đến nó được biên dịch, hoặc trước khi chạy một kế hoạch đã lưu có dùng nó.
| Compatibility level | Ngưỡng với n > 500 dòng | n = 10.000.000 |
|---|---|---|
| Dưới 130 | 500 + 0,20 × n | 2.000.500 thay đổi |
| Từ 130, SQL Server 2016 trở đi | MIN(500 + 0,20 × n, √(1.000 × n)) | MIN(2.000.500; 100.000) = 100.000 thay đổi |
√(1.000 × 10.000.000) = √10¹⁰ = 100.000. Năm 2026 có 3.600.000 đơn trong 274 ngày, khoảng 13.100 đơn mỗi ngày. Mỗi đơn mới là một thay đổi trên NgayTao (cột đầu của PK_DonHang) và trên KhachHangId (cột đầu của IX_DonHang_KhachHang). Hai thống kê đó tự cập nhật khoảng 100.000 / 13.100 ≈ 7,6 ngày một lần. Ở compatibility dưới 130 là 2.000.500 / 13.100 ≈ 152 ngày. Từ SQL Server 2008 R2 đến 2014, hoặc trên bản mới hơn với compatibility từ 120 trở xuống, trace flag 2371 bật ngưỡng động. UPDATE cột TrangThai không cộng vào bộ đếm của hai thống kê này, vì TrangThai không phải cột đầu của chúng.
Thống kê là của cả bảng, không của từng partition. Ngưỡng tính trên n = 10 triệu, không phải 3,6 triệu dòng của partition đang nhận đơn. Hai điểm phân vùng làm khác đi:
- Từ SQL Server 2014, tạo hoặc rebuild chỉ mục phân vùng dựng thống kê bằng mẫu mặc định, không quét đủ. Chỉ mục không phân vùng thì vẫn quét đủ khi rebuild. Muốn quét đủ trên
DonHang, chạyUPDATE STATISTICS ... WITH FULLSCAN. - Thống kê
INCREMENTAL = ONgiữ phần thống kê theo từng partition, cập nhật riêng partition cần rồi gộp lại thành thống kê chung mà trình tối ưu dùng.
-- Một lần, trong cửa sổ bảo trì: chuyển sang thống kê theo partition
UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang)
WITH FULLSCAN, INCREMENTAL = ON;
-- Hằng đêm: chỉ đọc lại partition năm 2026 rồi gộp
UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang)
WITH RESAMPLE ON PARTITIONS (3);
ON PARTITIONS bắt buộc đi với RESAMPLE, vì phần thống kê lấy mẫu ở các tỷ lệ khác nhau không gộp được. Không áp cho IX_DonHang_DangMo vì thống kê của chỉ mục lọc không có dạng incremental. Kiểm tra cột is_incremental trong sys.stats sau mỗi lần rebuild chỉ mục: rebuild không ghi STATISTICS_INCREMENTAL = ON có thể đưa thống kê về dạng thường.
Đơn hôm nay nằm ngoài histogram
Giả sử thống kê của PK_DonHang cập nhật lần cuối lúc 03:00 ngày 2026-09-25. Bước cuối của histogram trên NgayTao dừng ở khoảng 02:59 hôm đó. Đến trưa 2026-10-02 đã có thêm khoảng 7 ngày × 13.100 ≈ 92.000 đơn, vẫn dưới ngưỡng 100.000, nên thống kê chưa được đánh dấu cũ. Mọi đơn đó nằm sau bước cuối.
SELECT DonHangId, KhachHangId, TongTien
FROM dbo.DonHang
WHERE NgayTao >= CAST('20261001' AS datetime2(0));
Câu này trả về khoảng 19.700 dòng (một ngày rưỡi × 13.100), toàn bộ nằm ngoài histogram. Đây là bài toán khóa tăng dần (ascending key):
- CE cũ (phiên bản 70) coi giá trị sau bước cuối là gần như không tồn tại và thường ước lượng 1 dòng. Kế hoạch dễ chọn nested loops và lookup, và cấp bộ nhớ quá ít cho phép sắp hoặc phép hash phía sau.
- Từ CE 120 (SQL Server 2014), trình tối ưu giả định cột tăng dần có thể có giá trị lớn hơn bước cuối, và cho một ước lượng lớn hơn hẳn 1. Con số cụ thể tùy phiên bản CE. Đọc
Estimated Number of Rowstrong kế hoạch thay vì đoán.
Cách chắc chắn là để bước cuối không cũ quá một ngày: job cập nhật thống kê partition 3 hằng đêm như trên.
Phiên bản CE đi theo compatibility level của database: từ 110 trở xuống là CE 70; từ 120 trở lên CE mang cùng số với compatibility (120, 130, 140, 150, 160). BanHang trên SQL Server 2019 ở compatibility 150 dùng CE 150. Khi một câu chậm đi sau khi nâng compatibility, có thể quay về CE cũ cho cả database bằng ALTER DATABASE SCOPED CONFIGURATION SET LEGACY_CARDINALITY_ESTIMATION = ON, hoặc cho một câu bằng OPTION (USE HINT ('FORCE_LEGACY_CARDINALITY_ESTIMATION')) từ SQL Server 2016 SP1. Bật Query Store trước khi đổi để thấy câu nào đổi kế hoạch, như chương kỹ thuật đã nói.
8. Cái giá của chỉ mục khi ghi
Mỗi chỉ mục là một bản sao có thứ tự riêng của một phần bảng. Mỗi dòng ghi vào bảng phải ghi vào mọi bản sao chứa nó, mỗi lần ghi một bản ghi log riêng.
Một INSERT đơn mới vào DonHang, với IX_DonHang_KhachHang, IX_DonHang_DangMo và NCCI_DonHang cùng có mặt:
| Cấu trúc | Dòng mới ghi vào đâu | Ghi chú |
|---|---|---|
PK_DonHang |
Page lá cuối của partition 3, vì NgayTao tăng dần |
Hết chỗ thì cấp page mới ở mép phải |
IX_DonHang_KhachHang |
Cuối dải của khách đó, một trong khoảng 10.683 page lá của partition 3 | Chèn vào giữa cây: page đầy thì tách đôi |
IX_DonHang_DangMo |
Đơn mới có TrangThai = 1 nên có mặt ở đây |
Rời chỉ mục khi TrangThai đổi sang 2 |
NCCI_DonHang |
Delta store đang mở | Nén thành rowgroup cột khi đủ dòng |
Bốn cấu trúc cho một đơn. Với 13.100 đơn mỗi ngày là khoảng 52.400 lần chèn dòng chỉ mục. Mỗi lần chèn cần page đích trong buffer pool: page cuối của PK_DonHang gần như luôn nóng, còn page giữa IX_DonHang_KhachHang của một khách ít mua có thể phải đọc từ đĩa trước khi ghi.
UPDATE chỉ chạm chỉ mục chứa cột bị sửa. Đổi TrangThai từ 1 sang 2 sửa dòng trên PK_DonHang và xóa dòng khỏi IX_DonHang_DangMo. Với NCCI_DonHang, dòng còn ở delta store thì sửa tại chỗ, dòng đã nằm trong rowgroup nén thì bị đánh dấu xóa và bản mới vào delta store. IX_DonHang_KhachHang theo định nghĩa gốc không bị chạm. Nếu đã INCLUDE (TrangThai, TongTien) như mục 4, nó cũng phải sửa.
Đo số bản ghi log của một lần chèn, trong giao dịch được hủy ngay sau đó:
BEGIN TRAN;
INSERT dbo.DonHang (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien)
VALUES (99000001, CAST('2026-10-02T12:30:00' AS datetime2(0)), 77, 1, 250000.00);
SELECT dt.database_transaction_log_record_count, dt.database_transaction_log_bytes_used
FROM sys.dm_tran_database_transactions AS dt
JOIN sys.dm_tran_current_transaction AS ct
ON dt.transaction_id = ct.transaction_id
WHERE dt.database_id = DB_ID();
ROLLBACK;
Chạy lại với TrangThai = 4 thì dòng không vào IX_DonHang_DangMo, và số bản ghi log giảm theo. Lần chèn nào gây tách page giữa cây thì số bản ghi và số byte log tăng hẳn: nửa page dòng chuyển sang page mới đều được ghi log. Kiến trúc lưu trữ mô tả lần tách đó.
Đo tách page tích lũy theo chỉ mục:
SELECT
i.name AS index_name,
SUM(os.leaf_insert_count) AS leaf_inserts,
SUM(os.leaf_allocation_count) AS leaf_page_allocations
FROM sys.dm_db_index_operational_stats(DB_ID(), OBJECT_ID(N'dbo.DonHang'), NULL, NULL) AS os
JOIN sys.indexes AS i
ON i.object_id = os.object_id AND i.index_id = os.index_id
GROUP BY i.name
ORDER BY leaf_page_allocations DESC;
Với chỉ mục B-tree, mỗi lần cấp page ở tầng lá ứng với một lần tách page, kể cả lần "tách" ở mép phải của PK_DonHang vốn chỉ thêm page trống. So tỷ lệ hai cột giữa các chỉ mục để thấy chỉ mục nào tách dày. Bộ đếm sống theo metadata cache: về 0 khi khởi động lại, và có thể về 0 sớm hơn với bảng ít dùng. Hạ fillfactor chỉ nên làm khi số đo này cho thấy tách dày, như chương kỹ thuật đã nói.
9. Tìm chỉ mục thiếu và chỉ mục thừa
Gợi ý chỉ mục thiếu
Khi biên dịch, trình tối ưu ghi lại chỉ mục mà nó cho là tốt nhất cho câu đó nếu chưa có. Ba DMV gom các gợi ý:
SELECT TOP (20)
mid.statement AS table_name,
mid.equality_columns, mid.inequality_columns, mid.included_columns,
migs.user_seeks, migs.user_scans, migs.avg_total_user_cost,
migs.avg_user_impact, migs.last_user_seek
FROM sys.dm_db_missing_index_details AS mid
JOIN sys.dm_db_missing_index_groups AS mig
ON mig.index_handle = mid.index_handle
JOIN sys.dm_db_missing_index_group_stats AS migs
ON migs.group_handle = mig.index_group_handle
WHERE mid.database_id = DB_ID()
ORDER BY migs.avg_total_user_cost * migs.avg_user_impact
* (migs.user_seeks + migs.user_scans) DESC;
Một dòng minh họa sau vài ngày chạy màn hình lịch sử đơn:
| equality_columns | inequality_columns | included_columns | user_seeks | avg_user_impact |
|---|---|---|---|---|
[KhachHangId] |
NULL |
[NgayTao], [TrangThai], [TongTien] |
18.420 | 97,8 |
Gợi ý này gần trùng IX_DonHang_KhachHang. Tạo nguyên văn là thêm một chỉ mục thứ hai cùng dẫn đầu bằng KhachHangId, và mỗi đơn mới phải ghi vào cả hai. Cách đúng là mở rộng chỉ mục đang có bằng INCLUDE, như mục 4.
Giới hạn của tính năng này, theo tài liệu Microsoft:
- Chỉ chia cột thành nhóm so sánh bằng (
equality_columns) và nhóm so sánh khoảng (inequality_columns). Không cho thứ tự cột khóa. - Không cân nhắc kích thước khi gợi ý nhiều cột
INCLUDE. Không gợi ý chỉ mục lọc hay chỉ mục unique. - Không gộp với chỉ mục đang có, và các câu khác nhau sinh nhiều gợi ý gần giống nhau.
- Dựa trên ước lượng lúc biên dịch, không kiểm lại sau khi chạy. Không ghi cho kế hoạch trivial.
- Gom tối đa 600 nhóm. Quá ngưỡng thì ngừng ghi.
- Mất khi khởi động lại, failover hoặc đưa database offline. Mất riêng cho một bảng khi metadata của bảng đổi: thêm cột, tạo chỉ mục, hoặc
ALTER INDEXtrên bảng đó.
Chỉ mục không ai đọc
SELECT
i.name AS index_name, i.type_desc, i.is_unique,
ISNULL(us.user_seeks, 0) AS user_seeks,
ISNULL(us.user_scans, 0) AS user_scans,
ISNULL(us.user_lookups, 0) AS user_lookups,
ISNULL(us.user_updates, 0) AS user_updates,
us.last_user_seek, us.last_user_scan
FROM sys.indexes AS i
LEFT JOIN sys.dm_db_index_usage_stats AS us
ON us.database_id = DB_ID()
AND us.object_id = i.object_id
AND us.index_id = i.index_id
WHERE i.object_id = OBJECT_ID(N'dbo.DonHang')
ORDER BY
ISNULL(us.user_seeks, 0) + ISNULL(us.user_scans, 0) + ISNULL(us.user_lookups, 0),
ISNULL(us.user_updates, 0) DESC;
SELECT sqlserver_start_time
FROM sys.dm_os_sys_info;
user_seeks, user_scans, user_lookups đếm số lần thực thi câu truy vấn có dùng chỉ mục, không đếm số dòng. user_lookups chỉ có ở clustered index: đó là số lần key lookup quay về PK_DonHang. user_updates đếm số câu lệnh ghi đã phải bảo trì chỉ mục: xóa 1.000 dòng trong một câu tăng 1. Chỉ mục có số đọc bằng 0 và user_updates lớn sau một thời gian chạy đủ dài là ứng viên để bỏ.
Bộ đếm bắt đầu lại từ rỗng mỗi khi instance khởi động, và dòng của một database bị xóa khi database đó detach hoặc tắt, chẳng hạn do AUTO_CLOSE. So sqlserver_start_time với chu kỳ nghiệp vụ: chỉ mục chỉ phục vụ báo cáo cuối tháng hoặc cuối quý trông như không dùng nếu máy vừa khởi động lại giữa tháng. Chỉ mục unique có thể không bao giờ được đọc mà vẫn đang giữ ràng buộc.
Gỡ một chỉ mục an toàn
Ví dụ với IX_DonHang_DangMo, giả sử màn hình đơn đang mở đã chuyển sang hệ khác.
- Lưu lệnh
CREATE INDEXgốc vào kho mã. Định nghĩa nằm ở chương 2. - Tìm câu đang dùng chỉ mục trong Query Store và trong code có hint:
SELECT DISTINCT qsq.query_id, LEFT(qt.query_sql_text, 200) AS query_text
FROM sys.query_store_plan AS qsp
JOIN sys.query_store_query AS qsq ON qsq.query_id = qsp.query_id
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = qsq.query_text_id
WHERE qsp.query_plan LIKE N'%IX_DonHang_DangMo%';
SELECT OBJECT_SCHEMA_NAME(m.object_id) AS schema_name, OBJECT_NAME(m.object_id) AS module_name
FROM sys.sql_modules AS m
WHERE m.definition LIKE N'%IX_DonHang_DangMo%';
- Disable thay vì xóa. Dữ liệu chỉ mục được giải phóng, định nghĩa và thống kê ở lại. Câu nào dùng hint trỏ vào chỉ mục đã disable sẽ báo lỗi ngay, nên phụ thuộc ẩn lộ ra sớm.
- Theo dõi Query Store qua ít nhất một chu kỳ nghiệp vụ đầy đủ: câu nào tăng thời gian chạy hoặc đổi kế hoạch.
- Có câu chậm đi thì bật lại bằng
REBUILD, lệnh dựng lại toàn bộ chỉ mục từ bảng. Không có hồi quy thì xóa hẳn.
ALTER INDEX IX_DonHang_DangMo ON dbo.DonHang DISABLE;
-- Sau một chu kỳ nghiệp vụ, một trong hai:
ALTER INDEX IX_DonHang_DangMo ON dbo.DonHang REBUILD;
-- DROP INDEX IX_DonHang_DangMo ON dbo.DonHang;
Chỉ disable chỉ mục nonclustered không unique
Disable chỉ mục unique tắt luôn ràng buộc PRIMARY KEY hoặc UNIQUE dựa trên nó, và các khóa ngoại trỏ vào đó. Disable clustered index làm cả bảng không đọc ghi được cho đến khi rebuild hoặc drop. Bật lại một chỉ mục đã disable là dựng lại từ đầu: trên bản Standard, rebuild giữ khóa bảng suốt thời gian chạy.
10. Danh sách kiểm khi thiết kế chỉ mục cho BanHang
- [ ] Khóa clustered hẹp, duy nhất, tăng dần, không đổi.
(NgayTao, DonHangId)là 14 byte, và mỗi chỉ mục nonclustered mang thêm 8 byteDonHangId. - [ ] Mỗi chỉ mục nonclustered gắn với một câu truy vấn có tên, ghi trong kho mã cạnh lệnh
CREATE INDEX. - [ ] Thứ tự khóa: so sánh bằng, rồi một cột khoảng hoặc sắp xếp.
INCLUDEcho cột chỉ cần trả về, và chỉ cho câu chạy nhiều. - [ ] Đếm số chỉ mục trên
DonHang. Mỗi chỉ mục thêm một lần ghi và một bản ghi log cho mỗi đơn. Không đưa cột đổi liên tục nhưTrangThaivào khóa hayINCLUDEnếu không có câu đáng giá cần nó. - [ ] Chỉ mục mới tạo
ON ps_DonHang_Ngay (NgayTao)để giữ aligned. Chỉ mục unique không aligned chặnSWITCH. - [ ] Chỉ mục lọc cho tập con nhỏ và nóng. Giá trị lọc viết thẳng trong câu SQL.
- [ ] Điều kiện trên
NgayTaoviết dạng khoảng nửa mở. Tham số cùng kiểu cột. - [ ]
AUTO_UPDATE_STATISTICSbật, compatibility từ 130. Job cập nhật thống kê partition năm hiện tại hằng đêm.UPDATE STATISTICSsau mỗi lần nạp lớn. - [ ] Mỗi tháng xem
sys.dm_db_index_usage_statscùngsqlserver_start_time. Gợi ý chỉ mục thiếu được gộp vào chỉ mục đang có, không chép nguyên văn. - [ ] Query Store bật trước mọi lần thêm, đổi hoặc xóa chỉ mục.
Những chỗ hay hiểu sai
- "Seek luôn nhanh hơn scan." Seek cộng 300.000 key lookup tốn khoảng 900.000 lần đọc. Quét cả
DonHangtốn khoảng 45.880. - "Cột chọn lọc nhất đặt đầu." Cột so sánh bằng đặt đầu. Độ chọn lọc chỉ xếp thứ tự giữa các cột cùng là so sánh bằng.
- "Phân vùng làm seek nhanh hơn." Mỗi partition của
PK_DonHangvẫn 3 tầng, bằng bảng không phân vùng. Câu không có cột phân vùng phải seek vào từng partition. - "
INCLUDEthêm cột vào mọi tầng của cây."INCLUDEchỉ nằm ở tầng lá. Root và tầng trung gian giữ nguyên. - "Hàm nào bọc cột cũng làm mất seek." Phần lớn là vậy.
CAST(cột AS date)trên cột ngày giờ là ngoại lệ vẫn seek, với khoảng có thể rộng hơn cần. - "Thống kê tự cập nhật sau 20% thay đổi." Từ compatibility 130, bảng 10 triệu dòng cập nhật sau 100.000 thay đổi, tức 1%.
- "Rebuild chỉ mục luôn cập nhật thống kê bằng quét đủ." Với chỉ mục phân vùng, từ SQL Server 2014, rebuild dùng mẫu mặc định.
- "Gợi ý chỉ mục thiếu cho sẵn câu
CREATE INDEXđúng." Gợi ý không có thứ tự cột, không biết chỉ mục đang có, và mất khi khởi động lại. - "
user_seeks = 0nghĩa là chỉ mục chưa bao giờ được dùng." Nghĩa là chưa được dùng kể từ lần khởi động gần nhất. - "Disable chỉ mục nonclustered thì dữ liệu vẫn còn, bật lại tức thì." Disable giải phóng dữ liệu chỉ mục. Bật lại là dựng lại toàn bộ.
Đọc tiếp
- Kiểu dữ liệu, collation và khóa chính: độ rộng kiểu, khóa GUID, chuyển kiểu ngầm từ tham số
nvarchar, tìm kiếm không dấu. - Transaction, khóa và mức isolation: khóa, chặn và mức isolation khi đọc và ghi trên chính các chỉ mục này.
- Đọc kế hoạch thực thi: đọc đủ một kế hoạch, parameter sniffing, PSP của SQL Server 2022, memory grant của Sort.
- Điều tra truy vấn chậm: ca khách 42 từ triệu chứng đến chỉ mục phủ.
- Kiến trúc lưu trữ và Kỹ thuật thường dùng: page, tách page, fillfactor, columnstore và
SWITCH. - Kiến trúc lưu trữ PostgreSQL: heap và chỉ mục B-tree ở một engine không có clustered index.
Nguồn
- Index architecture and design guide
- Estimate the size of a clustered index
- Estimate the size of a nonclustered index
- Partitioned tables and indexes
- sys.dm_db_index_physical_stats
- SET STATISTICS IO
- Create filtered indexes
- Forced parameterization with filtered indexes
- Statistics
- DBCC SHOW_STATISTICS
- sys.dm_db_stats_properties
- sys.dm_db_stats_histogram
- UPDATE STATISTICS
- Cardinality estimation
- sys.dm_db_index_operational_stats
- Tune nonclustered indexes with missing index suggestions
- sys.dm_db_index_usage_stats
- Disable indexes and constraints
- Kimberly Tripp — The tipping point query answers
- Rob Farley — Beware the width of the covering range