Cơ sở dữ liệuSQL Server, phần 22/34
Mỗi chỉ mục làm đơn mới ghi thêm: chỉ mục thiếu, chỉ mục thừa và gỡ an toàn
Tạo nguyên văn gợi ý chỉ mục thiếu cho DonHang sinh ra một bản sao y hệt IX_DonHang_KhachHang, không câu nào nhanh hơn, còn log của 5.000 đơn mới tăng từ 10,27 lên 18,04 MB. Cách tìm chỉ mục thừa bằng định nghĩa và bộ đếm, rồi gỡ bằng DISABLE trước khi DROP.
Mục lục
- 1. Vấn đề: tạo nguyên văn gợi ý chỉ mục thiếu, log ghi đơn tăng 76%
- 2. Mục đích: bỏ bản sao, log ghi đơn về mức ba chỉ mục
- 3. Cơ sở lý thuyết: mỗi chỉ mục là một bản sao phải ghi theo mọi đơn
- 4. Cách giải quyết: so định nghĩa, disable, theo dõi, rồi mới drop
- 5. Cách cài đặt: truy vấn DMV và hai migration EF Core
- 6. Chứng minh: log của 5.000 đơn từ 18,04 về 10,27 MB, số page đọc không đổi
- 7. Kết luận
- Đọc tiếp
- Nguồn
Đọc nhanh
- Vấn đề: Gợi ý chỉ mục thiếu cho câu lịch sử đơn đòi một chỉ mục trên
KhachHangIdvới tác động 99,92%; tạo nguyên văn thì được bản sao y hệtIX_DonHang_KhachHang, và mỗi đơn mới phải ghi thêm vào nó. - Cách giải: So định nghĩa và bộ đếm sử dụng để tìm chỉ mục thừa, rồi gỡ theo ba bước: disable, theo dõi một chu kỳ nghiệp vụ, mới drop.
- Chứng minh: Trên bảng thử, log của 5.000 đơn mới từ 18,04 MB về 10,27 MB sau khi disable bản sao, còn ba màn hình đọc đúng số page như trước.
- Trong .NET: Gỡ bằng hai migration EF Core:
migrationBuilder.Sql("ALTER INDEX ... DISABLE")ở lần phát hành này,DropIndexở lần sau.
1. Vấn đề: tạo nguyên văn gợi ý chỉ mục thiếu, log ghi đơn tăng 76%
Màn hình lịch sử đơn của đại lý 42 quét cả DonHang (bài 4.1). Một bạn trong nhóm mở sys.dm_db_missing_index_details và thấy đúng một gợi ý cho bảng: equality_columns = [KhachHangId], không có cột INCLUDE, avg_user_impact 99,92. Bạn ấy tạo nguyên văn:
CREATE INDEX IX_DonHang_GoiY ON dbo.DonHang ([KhachHangId]) ON ps_DonHang_Ngay (NgayTao);
Trên bảng thử 1 triệu đơn, câu lịch sử đơn vẫn đọc 4.608 page như trước. DonHang giờ có bốn chỉ mục: PK_DonHang, IX_DonHang_KhachHang, IX_DonHang_DangMo và IX_DonHang_GoiY. Mỗi đơn mới ghi vào cả bốn, và log sinh ra khi ghi 5.000 đơn tăng từ 10,27 MB lên 18,04 MB, thêm 76%.
BanHang ghi khoảng 13.100 đơn mỗi ngày. Mọi byte log phải xuống đĩa trước khi giao dịch commit, và đi vào bản log backup 15 phút một lần. Tệ hơn, bộ đếm sử dụng sau đó cho thấy IX_DonHang_KhachHang, chỉ mục gốc mà model EF Core khai báo, có 0 lượt đọc. Người dọn dẹp tiếp theo dễ xóa nhầm chính nó.
2. Mục đích: bỏ bản sao, log ghi đơn về mức ba chỉ mục
- Tìm ra chỉ mục thừa bằng số đo: định nghĩa trùng nhau, và bộ đếm đọc của từng chỉ mục.
- Log sinh ra khi ghi 5.000 đơn mới về mức ba chỉ mục, khoảng 10,3 MB trên bảng thử.
- Không màn hình đọc nào chậm đi: lịch sử đơn, chọn đơn đổi trả, đơn đang mở giữ nguyên số page đọc.
- Gỡ có đường lùi: chỉ mục chỉ bị xóa hẳn sau một chu kỳ nghiệp vụ không có câu nào chậm đi.
- Ngoài phạm vi: sửa chính câu lịch sử đơn, đã làm ở bài 4.1 bằng khoảng nửa mở và
INCLUDE.
3. Cơ sở lý thuyết: mỗi chỉ mục là một bản sao phải ghi theo mọi đơn
Một đơn mới ghi vào mọi chỉ mục chứa nó
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ó, và mỗi lần ghi có bản ghi log riêng.
flowchart LR I["INSERT một đơn mới, TrangThai = 1"] --> P["PK_DonHang: page cuối, mép phải"] I --> K["IX_DonHang_KhachHang: giữa cây, cuối dải của khách"] I --> D["IX_DonHang_DangMo: đơn mới có mặt ở đây"] I --> G["IX_DonHang_GoiY: cùng chỗ như IX_DonHang_KhachHang"]
Chỗ dòng mới rơi vào quyết định giá ghi:
| Cấu trúc | Dòng mới ghi vào đâu | Hệ quả |
|---|---|---|
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, không phải dời dòng |
IX_DonHang_KhachHang |
Cuối dải của khách đó, giữa cây | Page đích đầy thì tách đôi: nửa số dòng chuyển sang page mới, và mọi dòng chuyển đều được ghi log |
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 |
IX_DonHang_GoiY |
Cùng chỗ như IX_DonHang_KhachHang |
Lặp lại đúng chừng ấy công việc |
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; hai chỉ mục theo khách không bị chạm, trừ khi đã INCLUDE (TrangThai).
Gợi ý chỉ mục thiếu không biết chỉ mục đang có
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 sys.dm_db_missing_index_details, _groups và _group_stats gom các gợi ý. Giới hạn, theo tài liệu Microsoft:
- Chỉ chia cột thành nhóm so sánh bằng và nhóm so sánh khoảng, không cho thứ tự cột khóa.
- Không gộp với chỉ mục đang có; các câu khác nhau sinh nhiều gợi ý gần giống nhau. Không gợi ý chỉ mục lọc.
- Dựa trên ước lượng lúc biên dịch, không kiểm lại sau khi chạy. Gom tối đa 600 nhóm.
- Mất khi khởi động lại, failover hoặc database đóng. Mất riêng cho một bảng khi metadata của bảng đổi, như tạo chỉ mục.
Gợi ý (KhachHangId) trùng hẳn với chỉ mục đang có, vì chỉ mục nonclustered không unique luôn được nối thêm các cột khóa clustered vào khóa. IX_DonHang_GoiY (KhachHangId) thành (KhachHangId, NgayTao, DonHangId), đúng bằng khóa thực của IX_DonHang_KhachHang (KhachHangId, NgayTao) như ở bài đầu chương. Hai cây có cùng khóa thực, cùng số page và cùng độ rộng dòng lá (mục 6), nên không câu nào có lý do nhanh hơn.
Bộ đếm sử dụng chỉ nói chỉ mục nào đang được chọn
sys.dm_db_index_usage_stats đếm số lần thực thi câu có dùng từng chỉ mục, không đếm số dòng. user_seeks, user_scans, user_lookups là lượt đọc; user_updates đếm số câu lệnh ghi đã phải bảo trì chỉ mục. 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ỏ.
Ba điều làm con số này dễ đọc sai:
- 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 đóng. Database tạo mới trên LocalDB bật
AUTO_CLOSE, nên bộ đếm trống mỗi lần ứng dụng đóng kết nối cuối. Sosqlserver_start_timevới chu kỳ nghiệp vụ: chỉ mục chỉ phục vụ báo cáo cuối tháng trông như không dùng nếu máy vừa khởi động lại giữa tháng. - Với hai chỉ mục giống nhau, trình tối ưu chọn một cái, và cái kia có 0 lượt đọc. Cái có 0 lượt đọc không nhất thiết là cái mới tạo.
- Chỉ mục unique có thể không bao giờ được đọc mà vẫn đang giữ ràng buộc.
DISABLE là bước lùi được
ALTER INDEX ... DISABLE giải phóng dữ liệu của chỉ mục nhưng giữ định nghĩa. Chỉ mục bị disable không còn được ghi theo mỗi đơn mới, và câu nào dùng hint trỏ vào nó sẽ báo lỗi ngay, nên phụ thuộc ẩn lộ ra sớm. Bật lại bằng REBUILD, lệnh dựng lại toàn bộ chỉ mục từ bảng.
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. Trên bản Standard, rebuild để bật lại giữ khóa bảng suốt thời gian chạy.
4. Cách giải quyết: so định nghĩa, disable, theo dõi, rồi mới drop
| Cách | Ưu | Nhược | Khi nào dùng |
|---|---|---|---|
| Tạo nguyên văn mọi gợi ý | Nhanh, không cần hiểu câu | Trùng chỉ mục đang có, không có thứ tự cột, mỗi đơn mới ghi thêm | Không nên |
| Xóa ngay chỉ mục có 0 lượt đọc | Đơn giản | Có thể xóa nhầm IX_DonHang_KhachHang; không có đường lùi |
Không nên |
| So định nghĩa và bộ đếm, disable, theo dõi một chu kỳ, rồi drop | Có đường lùi; phụ thuộc ẩn lộ ra trước khi mất chỉ mục | Phải chờ một chu kỳ nghiệp vụ | Mọi lần gỡ chỉ mục trên bảng đang chạy |
Gộp gợi ý vào chỉ mục đang có bằng INCLUDE |
Sửa đúng gốc khi gợi ý có cột mới | Chỉ mục rộng hơn | Gợi ý có included_columns mà chỉ mục đang có thiếu (bài 4.1) |
Chọn cách thứ ba. Gợi ý ở đây không có cột mới, nên không có gì để gộp; bản sao chỉ cần bỏ đi. Giữ lại IX_DonHang_KhachHang vì nó là chỉ mục model EF Core khai báo và các câu có hint trỏ tới, dù bộ đếm cho nó 0 lượt đọc.
Các bước:
- Liệt kê chỉ mục của bảng kèm cột khóa, cột
INCLUDE, số page; tìm các chỉ mục cùng dẫn đầu bằng một cột. Đọc bộ đếm sử dụng kèmsqlserver_start_time. - Lưu lệnh
CREATE INDEXgốc vào kho mã. - Tìm câu đang nhắc tới chỉ mục trong Query Store (
sys.query_store_plan) và trong module có hint (sys.sql_modules). - Disable, rồi 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ì
REBUILDđể bật lại. Không có thì drop.
5. Cách cài đặt: truy vấn DMV và hai migration EF Core
Môi trường: LocalDB SQL Server 2019 (15.0.4382), .NET 10.0.401, Microsoft.EntityFrameworkCore.SqlServer 10.0.3. Bước 1 và 3 là ba truy vấn DMV, chạy được trên production:
Bước 1: chỉ mục của DonHang với cột khóa, cột INCLUDE, số page, lượt đọc và ghi
SELECT
i.name AS index_name, i.type_desc, i.is_unique, i.is_disabled,
STUFF((SELECT N', ' + c.name FROM sys.index_columns AS ic
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.key_ordinal > 0
ORDER BY ic.key_ordinal FOR XML PATH('')), 1, 2, N'') AS key_columns,
STUFF((SELECT N', ' + c.name FROM sys.index_columns AS ic
JOIN sys.columns AS c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 1
FOR XML PATH('')), 1, 2, N'') AS included_columns,
(SELECT SUM(ps.used_page_count) FROM sys.dm_db_partition_stats AS ps
WHERE ps.object_id = i.object_id AND ps.index_id = i.index_id) AS used_pages,
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
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 key_columns, i.name;
SELECT sqlserver_start_time FROM sys.dm_os_sys_info;
20 gợi ý chỉ mục thiếu đáng xem nhất của database hiện tạ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;
Bước 3: tìm câu trong Query Store và module nhắc tới IX_DonHang_GoiY
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_GoiY%';
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_GoiY%';
Bước 4 và 5 đi qua migration, để mọi môi trường gỡ chỉ mục theo cùng thứ tự. Nếu chỉ mục thừa đã được khai báo bằng HasIndex trong model, xóa khai báo đó sẽ làm dotnet ef migrations add sinh thẳng DropIndex; viết tay migration disable trước:
// Bản phát hành 1: chỉ disable, Down bật lại bằng REBUILD
class TatChiMucGoiY : Migration
{
protected override void Up(MigrationBuilder b) =>
b.Sql("ALTER INDEX IX_DonHang_GoiY ON dbo.DonHang DISABLE;");
protected override void Down(MigrationBuilder b) =>
b.Sql("ALTER INDEX IX_DonHang_GoiY ON dbo.DonHang REBUILD;");
}
// Bản phát hành 2, sau một chu kỳ nghiệp vụ không có câu nào chậm đi
class XoaChiMucGoiY : Migration
{
protected override void Up(MigrationBuilder b) =>
b.DropIndex(name: "IX_DonHang_GoiY", table: "DonHang");
protected override void Down(MigrationBuilder b) =>
b.CreateIndex(name: "IX_DonHang_GoiY", table: "DonHang", column: "KhachHangId");
}
Chương trình đo dưới làm lại cả câu chuyện trên bảng thử Kumeo_chimuc dựng ở bài đầu chương: chạy câu lịch sử đơn, đọc gợi ý chỉ mục thiếu và tạo nguyên văn. Sau đó nó ghi 5.000 đơn mới bằng SaveChangesAsync với từng bộ chỉ mục, năm vòng xen kẽ. Mỗi lần ghi nằm trong một giao dịch được rollback, sau khi rebuild các chỉ mục đang bật, nên mọi lần đo bắt đầu từ cùng trạng thái.
Mỗi lần đo đọc ba số: log tăng thêm từ sys.dm_db_log_space_usage (sau một CHECKPOINT), CPU của các lệnh INSERT từ sys.dm_exec_query_stats, và số page lá cấp mới từ sys.dm_db_index_operational_stats. Cuối cùng chương trình disable bản sao, đo lại ba màn hình đọc, rồi in SQL mà hai migration ở trên sinh ra. #:property PublishAot=false vì EF Core không dựng model lúc chạy khi bật PublishAot.
ChiPhiGhi.cs: gợi ý chỉ mục thiếu, giá ghi của từng bộ chỉ mục, bộ đếm sử dụng; dotnet run ChiPhiGhi.cs
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.3
#:property PublishAot=false
// Tạo nguyên văn gợi ý chỉ mục thiếu, đo mỗi chỉ mục tốn bao nhiêu khi ghi 5.000 đơn mới,
// xem bộ đếm sử dụng, gỡ chỉ mục thừa bằng DISABLE, rồi in SQL của hai migration gỡ chỉ mục.
// Chạy: dotnet run ChiPhiGhi.cs (cần database Kumeo_chimuc đã dựng ở bài đầu chương; khoảng 4 phút)
using System.Data.Common;
using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
const string cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_chimuc;Integrated Security=true;TrustServerCertificate=true";
await using var db = new BanHangDb(cs);
db.Database.SetCommandTimeout(600);
var conn = (SqlConnection)db.Database.GetDbConnection();
long reads = 0;
conn.InfoMessage += (_, e) =>
{
foreach (Match m in Regex.Matches(e.Message, @"logical reads (\d+)"))
reads += long.Parse(m.Groups[1].Value);
};
// Giữ một kết nối mở suốt chương trình: LocalDB bật AUTO_CLOSE, database đóng là mất mọi bộ đếm
await db.Database.OpenConnectionAsync();
// 0. Bộ chỉ mục của các bài trước: PK, IX_DonHang_KhachHang gốc, IX_DonHang_DangMo
await Sql("""
IF INDEXPROPERTY(OBJECT_ID(N'dbo.DonHang'), N'IX_DonHang_GoiY', 'IndexID') IS NOT NULL
DROP INDEX IX_DonHang_GoiY ON dbo.DonHang;
CREATE INDEX IX_DonHang_KhachHang ON dbo.DonHang (KhachHangId, NgayTao)
WITH (DROP_EXISTING = ON) ON ps_DonHang_Ngay (NgayTao);
IF INDEXPROPERTY(OBJECT_ID(N'dbo.DonHang'), N'IX_DonHang_DangMo', 'IndexID') IS NULL
CREATE INDEX IX_DonHang_DangMo ON dbo.DonHang (KhachHangId, NgayTao)
INCLUDE (TongTien) WHERE TrangThai = 1;
""");
// 1. Màn hình lịch sử đơn của khách 42 chạy 5 lần, rồi đọc gợi ý chỉ mục thiếu
for (int i = 0; i < 5; i++) await LichSu();
var g = await db.Database.SqlQueryRaw<GoiY>("""
SELECT mid.equality_columns AS Bang, mid.inequality_columns AS Khoang, mid.included_columns AS KemTheo,
migs.user_seeks AS LanSeek, migs.avg_user_impact AS TacDong
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() AND mid.object_id = OBJECT_ID(N'dbo.DonHang')
""").SingleAsync();
Console.WriteLine($"Gợi ý: equality {g.Bang}, inequality {g.Khoang ?? "NULL"}, included {g.KemTheo ?? "NULL"}, " +
$"user_seeks {g.LanSeek}, avg_user_impact {g.TacDong}");
// 2. Tạo nguyên văn gợi ý đó, rồi so với chỉ mục đang có
var include = g.KemTheo is null ? "" : $"INCLUDE ({g.KemTheo})";
await Sql($"CREATE INDEX IX_DonHang_GoiY ON dbo.DonHang ({g.Bang}) {include} ON ps_DonHang_Ngay (NgayTao)");
foreach (var c in await db.Database.SqlQueryRaw<CauTruc>("""
SELECT i.name AS Ten, SUM(ps.used_page_count) AS Page,
(SELECT MAX(p.avg_record_size_in_bytes)
FROM sys.dm_db_index_physical_stats(DB_ID(), i.object_id, i.index_id, NULL, 'DETAILED') AS p
WHERE p.index_level = 0) AS DongLa
FROM sys.indexes AS i
JOIN sys.dm_db_partition_stats AS ps ON ps.object_id = i.object_id AND ps.index_id = i.index_id
WHERE i.object_id = OBJECT_ID(N'dbo.DonHang') AND i.name IN (N'IX_DonHang_KhachHang', N'IX_DonHang_GoiY')
GROUP BY i.object_id, i.index_id, i.name
""").ToListAsync())
Console.WriteLine($" {c.Ten,-22} {c.Page,6:N0} page, dòng lá {c.DongLa} byte");
Console.WriteLine($" Lịch sử đơn khách 42 sau khi tạo: {await DemDoc(LichSu):N0} logical reads");
// 3. Ghi 5.000 đơn mới với từng bộ chỉ mục, năm vòng xen kẽ: log sinh ra, CPU của lệnh INSERT, page lá cấp mới.
// Bộ 3 chính là trạng thái sau khi disable IX_DonHang_GoiY: chỉ mục bị disable không còn được ghi.
string[] phu = ["IX_DonHang_KhachHang", "IX_DonHang_DangMo", "IX_DonHang_GoiY"];
var boChiMuc = new (string Ten, string[] Bat, List<LanGhi> Lan)[]
{
("1. Chỉ PK_DonHang", [], []),
("2. + IX_DonHang_KhachHang", phu[..1], []),
("3. + IX_DonHang_DangMo", phu[..2], []),
("4. + IX_DonHang_GoiY", phu, []),
};
for (int vong = 0; vong < 5; vong++)
foreach (var bo in boChiMuc) bo.Lan.Add(await DoGhi(bo.Bat));
Console.WriteLine();
Console.WriteLine($"{"Bộ chỉ mục",-28} {"log MB",7} {"CPU ms",7} {"thấp–cao",-10} page lá cấp mới");
foreach (var (ten, _, lan) in boChiMuc)
{
var cpu = lan.Select(x => x.CpuMs).Order().ToList();
var log = lan.Select(x => x.LogMb).Order().ToList();
Console.WriteLine($"{ten,-28} {log[2],7:N2} {cpu[2],7} {$"{cpu[0]}–{cpu[4]}",-10} {lan[^1].TachPage}");
}
// 4. Một lượt của ba màn hình đọc: số page đọc, và chỉ mục nào được dùng
Console.WriteLine();
await LuotDoc("Bốn chỉ mục");
// 5. Gỡ bản sao bằng DISABLE, rồi chạy lại ba màn hình
await Sql("ALTER INDEX IX_DonHang_GoiY ON dbo.DonHang DISABLE");
await LuotDoc("Đã disable IX_DonHang_GoiY");
// 6. Không câu nào chậm đi: xóa hẳn, rồi dựng lại các chỉ mục còn lại cho các bài khác
await Sql("DROP INDEX IX_DonHang_GoiY ON dbo.DonHang");
await Sql("ALTER INDEX ALL ON dbo.DonHang REBUILD");
// 7. Trong dự án: gỡ bằng hai migration, DISABLE trước, DROP ở lần phát hành sau
Console.WriteLine();
var sinhSql = db.GetService<IMigrationsSqlGenerator>();
foreach (var m in new Migration[] { new TatChiMucGoiY(), new XoaChiMucGoiY() })
foreach (var lenh in sinhSql.Generate(m.UpOperations))
Console.WriteLine($"{m.GetType().Name}: {lenh.CommandText.Trim()}");
async Task<LanGhi> DoGhi(string[] bat)
{
// Mỗi lần đo bắt đầu từ cùng trạng thái: PK và chỉ mục được bật vừa rebuild, chỉ mục còn lại disable
await Sql("ALTER INDEX PK_DonHang ON dbo.DonHang REBUILD");
foreach (var ix in phu)
await Sql($"ALTER INDEX {ix} ON dbo.DonHang {(bat.Contains(ix) ? "REBUILD" : "DISABLE")}");
var capTruoc = await CapPage();
await Sql("ALTER DATABASE SCOPED CONFIGURATION CLEAR PROCEDURE_CACHE"); // bộ đếm CPU của INSERT về 0
await Sql("CHECKPOINT"); // dọn log cũ, để phần log tăng thêm chỉ là của 5.000 đơn
long maxId = await db.DonHang.MaxAsync(d => d.DonHangId);
await using var tx = await db.Database.BeginTransactionAsync();
var logTruoc = await LogDung();
var dau = new DateTime(2026, 10, 2, 12, 0, 0);
db.DonHang.AddRange(Enumerable.Range(1, 5000).Select(i => new DonHang
{
DonHangId = maxId + i,
NgayTao = dau.AddSeconds(i * 6),
KhachHangId = 1 + (int)((i * 7919L) % 48500),
TrangThai = 1,
TongTien = 250000m,
}));
await db.SaveChangesAsync();
var logMb = (await LogDung() - logTruoc) / 1048576.0;
var cpu = await CpuInsert();
var capSau = await CapPage();
await tx.RollbackAsync(); // trả bảng về như cũ cho lần đo sau
db.ChangeTracker.Clear();
var tachPage = string.Join(", ", capSau.Select(s => (s.Key, Moi: s.Value - capTruoc.GetValueOrDefault(s.Key)))
.Where(s => s.Moi > 0).OrderBy(s => s.Key).Select(s => $"{s.Key.Replace("IX_DonHang_", "")} {s.Moi}"));
return new LanGhi(logMb, cpu, tachPage);
}
async Task LuotDoc(string nhan)
{
var truoc = await DocDung();
var lichSu = await DemDoc(LichSu);
var chonDon = await DemDoc(() => db.DonHang.Where(d => d.KhachHangId == 77)
.Select(d => new { d.DonHangId, d.NgayTao }).ToListAsync());
var dangMo = await DemDoc(() => db.DonHang.Where(d => d.KhachHangId == 42 && d.TrangThai == 1)
.Select(d => new { d.NgayTao, d.TongTien }).ToListAsync());
var sau = await DocDung();
Console.WriteLine($"-- {nhan}: lịch sử đơn {lichSu:N0}, chọn đơn {chonDon:N0}, đơn đang mở {dangMo:N0} logical reads");
Console.WriteLine(" Lượt đọc của từng chỉ mục: " +
string.Join(", ", sau.OrderBy(s => s.Key).Select(s => $"{s.Key} {s.Value - truoc.GetValueOrDefault(s.Key)}")));
}
Task LichSu() => db.DonHang.Where(d => d.KhachHangId == 42)
.Select(d => new { d.DonHangId, d.NgayTao, d.TrangThai, d.TongTien }).ToListAsync();
async Task<long> DemDoc(Func<Task> q)
{
await Sql("SET STATISTICS IO ON");
reads = 0;
await q();
await Sql("SET STATISTICS IO OFF");
return reads;
}
async Task<long> LogDung() => await db.Database.SqlQueryRaw<long>(
"SELECT used_log_space_in_bytes AS Value FROM sys.dm_db_log_space_usage").SingleAsync();
// Tổng CPU (ms) của các lệnh INSERT vào DonHang trong plan cache
async Task<long> CpuInsert() => await db.Database.SqlQueryRaw<long>("""
SELECT ISNULL(SUM(qs.total_worker_time), 0) / 1000 AS Value
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
WHERE st.text LIKE N'%INSERT INTO \[DonHang\]%' ESCAPE N'\'
""").SingleAsync();
async Task<Dictionary<string, long>> CapPage() =>
(await db.Database.SqlQueryRaw<SoDem>("""
SELECT i.name AS Ten, SUM(os.leaf_allocation_count) AS So
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
""").ToListAsync()).ToDictionary(s => s.Ten, s => s.So);
async Task<Dictionary<string, long>> DocDung() =>
(await db.Database.SqlQueryRaw<SoDem>("""
SELECT i.name AS Ten, ISNULL(us.user_seeks + us.user_scans + us.user_lookups, 0) AS So
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')
""").ToListAsync()).ToDictionary(s => s.Ten, s => s.So);
Task Sql(string sql) => db.Database.ExecuteSqlRawAsync(sql);
record GoiY(string Bang, string? Khoang, string? KemTheo, long LanSeek, double TacDong);
record CauTruc(string Ten, long Page, double DongLa);
record SoDem(string Ten, long So);
record LanGhi(double LogMb, long CpuMs, string TachPage);
// Bản phát hành 1: chỉ disable, Down bật lại bằng REBUILD
class TatChiMucGoiY : Migration
{
protected override void Up(MigrationBuilder b) =>
b.Sql("ALTER INDEX IX_DonHang_GoiY ON dbo.DonHang DISABLE;");
protected override void Down(MigrationBuilder b) =>
b.Sql("ALTER INDEX IX_DonHang_GoiY ON dbo.DonHang REBUILD;");
}
// Bản phát hành 2, sau một chu kỳ nghiệp vụ không có câu nào chậm đi
class XoaChiMucGoiY : Migration
{
protected override void Up(MigrationBuilder b) =>
b.DropIndex(name: "IX_DonHang_GoiY", table: "DonHang");
protected override void Down(MigrationBuilder b) =>
b.CreateIndex(name: "IX_DonHang_GoiY", table: "DonHang", column: "KhachHangId");
}
class DonHang
{
public long DonHangId { get; set; }
public DateTime NgayTao { get; set; }
public int KhachHangId { get; set; }
public byte TrangThai { get; set; }
public decimal TongTien { get; set; }
}
class BanHangDb(string cs) : DbContext
{
public DbSet<DonHang> DonHang => Set<DonHang>();
protected override void OnConfiguring(DbContextOptionsBuilder o) =>
o.UseSqlServer(cs).AddInterceptors(new DocHetThongBao());
protected override void OnModelCreating(ModelBuilder b)
{
var e = b.Entity<DonHang>();
e.ToTable("DonHang");
e.HasKey(d => new { d.NgayTao, d.DonHangId });
e.Property(d => d.NgayTao).HasColumnType("datetime2(0)");
e.Property(d => d.TongTien).HasColumnType("decimal(18, 2)");
}
}
// SqlClient chỉ phát InfoMessage của STATISTICS IO khi reader được đọc tới hết.
class DocHetThongBao : DbCommandInterceptor
{
public override InterceptionResult DataReaderClosing(DbCommand c, DataReaderClosingEventData e, InterceptionResult r)
{
while (e.DataReader.NextResult()) { }
return r;
}
public override async ValueTask<InterceptionResult> DataReaderClosingAsync(DbCommand c, DataReaderClosingEventData e, InterceptionResult r)
{
while (await e.DataReader.NextResultAsync()) { }
return r;
}
}
6. Chứng minh: log của 5.000 đơn từ 18,04 về 10,27 MB, số page đọc không đổi
Đo trên LocalDB SQL Server 2019 (15.0.4382), .NET 10.0.401, EF Core 10.0.3, bảng thử 1 triệu đơn, laptop Windows 11. Log là trung vị của năm lần đo; số page cấp mới và log gần như không đổi giữa các lần, kể cả giữa hai lần chạy cả chương trình (lần trước ra 1,08, 8,89, 10,28 và 18,03 MB). CPU dao động nhiều, nên in kèm lần thấp nhất và cao nhất.
Gợi ý: equality [KhachHangId], inequality NULL, included NULL, user_seeks 5, avg_user_impact 99.92
IX_DonHang_KhachHang 2,986 page, dòng lá 22 byte
IX_DonHang_GoiY 2,986 page, dòng lá 22 byte
Lịch sử đơn khách 42 sau khi tạo: 4,608 logical reads
Bộ chỉ mục log MB CPU ms thấp–cao page lá cấp mới
1. Chỉ PK_DonHang 1.09 89 32–135 PK_DonHang 23
2. + IX_DonHang_KhachHang 8.90 570 439–693 KhachHang 786, PK_DonHang 23
3. + IX_DonHang_DangMo 10.27 584 395–918 DangMo 32, KhachHang 786, PK_DonHang 23
4. + IX_DonHang_GoiY 18.04 834 379–1009 DangMo 32, GoiY 786, KhachHang 786, PK_DonHang 23
-- Bốn chỉ mục: lịch sử đơn 4,631, chọn đơn 9, đơn đang mở 9 logical reads
Lượt đọc của từng chỉ mục: IX_DonHang_DangMo 1, IX_DonHang_GoiY 1, IX_DonHang_KhachHang 0, PK_DonHang 1
-- Đã disable IX_DonHang_GoiY: lịch sử đơn 4,631, chọn đơn 9, đơn đang mở 9 logical reads
Lượt đọc của từng chỉ mục: IX_DonHang_DangMo 1, IX_DonHang_GoiY 0, IX_DonHang_KhachHang 1, PK_DonHang 1
TatChiMucGoiY: ALTER INDEX IX_DonHang_GoiY ON dbo.DonHang DISABLE;
XoaChiMucGoiY: DROP INDEX [IX_DonHang_GoiY] ON [DonHang];
So với tiêu chí ở mục 2:
- Tìm ra chỉ mục thừa bằng số đo. Hai chỉ mục cùng 2.986 page và cùng dòng lá 22 byte: một cây đúng bằng cây kia. Bộ đếm sử dụng lại chỉ ra
IX_DonHang_KhachHangcó 0 lượt đọc, vì trình tối ưu chọn bản sao cho câu chọn đơn. Chỉ bộ đếm thì sẽ xóa nhầm; so định nghĩa mới chỉ ra đúng cái thừa. - Log về mức ba chỉ mục. Bộ 3 chính là trạng thái sau khi disable bản sao: 10,27 MB, so với 18,04 MB khi có bản sao. Bản sao thêm 7,77 MB cho 5.000 đơn, khoảng 1,6 KB mỗi đơn.
- Không màn hình nào chậm đi. Lịch sử đơn, chọn đơn, đơn đang mở đọc 4.631, 9 và 9 page cả trước lẫn sau khi disable. 4.631 thay vì 4.608 vì lần ghi đo cuối cùng để lại 23 page trống ở mép phải
PK_DonHangsau khi rollback. - Gỡ có đường lùi. Migration thứ nhất chỉ sinh
ALTER INDEX ... DISABLE, vớiREBUILDởDown;DROP INDEXnằm ở migration thứ hai.
Phần lớn giá ghi của chỉ mục chèn giữa cây là tách page. IX_DonHang_KhachHang cấp 786 page lá mới cho 5.000 đơn, gần bằng 809 page lá của partition 2026 (272.636 / 337): ngay sau rebuild, page lá đầy, nên gần như page nào nhận đơn mới cũng tách một lần. PK_DonHang chỉ cấp 23 page ở mép phải. Đây là trường hợp xấu, ngay sau một lần rebuild; khi page đã có chỗ trống, mỗi chỉ mục vẫn thêm một lần ghi và một bản ghi log cho mỗi đơn, nhưng ít tách page hơn.
Chưa chứng minh: thời gian phản hồi của API ghi đơn. Trung vị CPU của lệnh INSERT tăng theo số chỉ mục, từ 89 lên 834 ms, nhưng trên laptop cùng một bộ có thể lệch gần gấp ba giữa các lần đo, và lần trước chương trình ra 85, 346, 409, 607 ms. Bài không dùng CPU làm tiêu chí.
7. Kết luận
Mỗi chỉ mục là một bản sao mà mọi đơn mới phải ghi theo. Gợi ý chỉ mục thiếu không biết chỉ mục đang có, còn bộ đếm sử dụng chỉ nói chỉ mục nào đang được chọn; quyết định gỡ cần cả hai, cộng định nghĩa, và một bước disable có đường lùi.
Trong dự án .NET của bạn:
- Không tạo nguyên văn gợi ý chỉ mục thiếu. So
equality_columnsvàincluded_columnsvới chỉ mục đang có, nhớ rằng khóa clustered đã nằm sẵn trong mọi chỉ mục nonclustered. - Mỗi tháng chạy truy vấn bước 1 cho các bảng nóng, kèm
sqlserver_start_time, và tìm các chỉ mục cùng dẫn đầu bằng một cột. - Gỡ chỉ mục bằng hai migration:
migrationBuilder.Sql("ALTER INDEX ... DISABLE")trước,DropIndexsau một chu kỳ nghiệp vụ. Bật Query Store trước khi bắt đầu. - Trước khi thêm chỉ mục vào bảng ghi nhiều, đo log của một lô ghi bằng
sys.dm_db_log_space_usagenhư chương trình ở mục 5.
Những chỗ hay hiểu sai
- "Gợi ý chỉ mục thiếu cho sẵn câu
CREATE INDEXđúng." Gợi ý không có thứ tự cột và không biết chỉ mục đang có; ở đây nó đòi đúng một bản sao. - "
user_seeks = 0nghĩa là chỉ mục thừa." Nó chỉ nói chỉ mục không được chọn kể từ lần khởi động hoặc lần database mở gần nhất. - "Disable rồi bật lại là 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
- Chỉ mục lọc cho đơn đang mở: bài trước, chỉ mục thứ ba trên
DonHang. - Đơn hôm nay nằm ngoài histogram: thống kê và ngưỡng tự cập nhật: bài sau, nguồn của số dòng ước lượng.
- Lịch sử đơn quét cả bảng: chỉ mục phủ và điều kiện seek được: sửa đúng gốc câu lịch sử đơn.
- Parameter sniffing và Query Store: theo dõi câu đổi kế hoạch sau khi gỡ chỉ mục.