Cơ sở dữ liệuSQL Server, phần 24/24
Parameter sniffing và so kế hoạch trong Query Store
Vì sao cùng một thủ tục lúc nhanh lúc chậm, nhận ra parameter sniffing trong kế hoạch, chọn cách sửa, so kế hoạch theo thời gian trong Query Store, và áp OPTION (RECOMPILE) cho đúng một truy vấn EF Core.
Mục lục
- 1. Danh sách tham số trong kế hoạch
- 2. Parameter sniffing: cơ chế
- 3. Chỉ mục lọc và OPTION (RECOMPILE)
- 4. Các cách xử lý
- 5. Parameter Sensitive Plan optimization
- 6. So kế hoạch theo thời gian trong Query Store
- 7. Áp dụng trong .NET: EF Core, TagWith và OPTION (RECOMPILE)
- Những chỗ hay hiểu sai
- Kết luận
- Đọc tiếp
- Nguồn
Trang quản trị của BanHang lúc nhanh lúc chậm với cùng một thủ tục, tùy lần gọi đầu tiên sau khi biên dịch lại dùng giá trị nào. Đó là parameter sniffing: kế hoạch biên dịch cho giá trị đầu được dùng lại cho mọi giá trị sau, kể cả khi một giá trị chiếm 1% số đơn và giá trị kia 90%. Đọc xong bạn nhận ra sniffing trong kế hoạch, chọn cách sửa, so kế hoạch theo thời gian trong Query Store, và áp OPTION (RECOMPILE) cho đúng một truy vấn EF Core.
Đọc nhanh
- So
ParameterCompiledValuevớiParameterRuntimeValuetrước khi sửa bất cứ thứ gì. - Biến cục bộ không được sniff, nên thay tham số bằng biến khi thử trong SSMS là đổi luôn cách ước lượng.
- Mỗi cách sửa đổi một thứ lấy một thứ:
OPTION (RECOMPILE)tốn CPU biên dịch, còn kế hoạch bị ép có thể sai khi dữ liệu đổi. - Query Store giữ mọi kế hoạch của một câu theo giờ, nhưng bỏ comment ở đầu câu nên không chứa tag của
TagWith.
1. Danh sách tham số trong kế hoạch
Nút gốc của câu có tham số có Parameter List. Mỗi tham số có hai giá trị:
Parameter Compiled Value: giá trị lúc kế hoạch được biên dịch. Ước lượng của cả kế hoạch dựa vào giá trị này.Parameter Runtime Value: giá trị của lần chạy đang xem. Chỉ có trong kế hoạch thực tế.
Kế hoạch lấy từ cache chỉ có giá trị biên dịch. Hai giá trị khác nhau và số dòng lệch nhiều là dấu hiệu đầu tiên của parameter sniffing. Biến cục bộ (DECLARE @x ...) không được sniff: trình tối ưu ước lượng theo mật độ trung bình của cột, với TrangThai là 2.000.000 dòng cho mọi giá trị, trừ khi câu có OPTION (RECOMPILE).
2. Parameter sniffing: cơ chế
Lần đầu thủ tục chạy, trình tối ưu đọc giá trị tham số (sniff), ước lượng theo histogram cho đúng giá trị đó, và cất kế hoạch. Các lần sau dùng lại kế hoạch ấy cho mọi giá trị, đến khi kế hoạch bị đẩy khỏi cache hoặc bị biên dịch lại. Câu gửi qua sp_executesql và prepared statement từ driver đi theo cùng cơ chế.
Sniffing có ích: không có nó, mọi ước lượng chỉ là trung bình. Nó thành vấn đề khi dữ liệu lệch, như TrangThai của BanHang:
TrangThai |
Số đơn | Tỷ lệ |
|---|---|---|
| 1 Mới | 100.000 | 1% |
| 4 Hoàn tất | 9.000.000 | 90% |
Thủ tục cho trang quản trị: 20 khách có tổng giá trị đơn lớn nhất theo một trạng thái.
CREATE OR ALTER PROCEDURE dbo.usp_KhachHang_TheoTrangThai
@TrangThai tinyint
AS
SET NOCOUNT ON;
SELECT TOP (20) KhachHangId, COUNT_BIG(*) AS SoDon, SUM(TongTien) AS TongGiaTri
FROM dbo.DonHang
WHERE TrangThai = @TrangThai
GROUP BY KhachHangId
ORDER BY SUM(TongTien) DESC;
GO
DonHang không có chỉ mục bắt đầu bằng TrangThai, nên cả hai giá trị đều scan PK_DonHang và hình dạng kế hoạch gần giống nhau. Thứ khác nhau là số dòng ước lượng sau scan, và vì vậy là số nhóm ước lượng, memory grant của Hash Aggregate, có thể cả việc gộp sơ bộ trong từng thread. Số liệu dưới đây giả định bảng chưa có NCCI_DonHang; nếu có, câu tổng hợp này đọc columnstore và kế hoạch khác hẳn.
Script dưới đây chạy thủ tục bốn lần: biên dịch cho 1 rồi chạy với 4, biên dịch lại cho 4 rồi chạy với 1. Bảng sau là kết quả.
Bốn lần chạy, bật Ctrl+M để lấy kế hoạch thực tế
EXEC sys.sp_recompile N'dbo.usp_KhachHang_TheoTrangThai';
GO
-- Bật Ctrl+M trước khi chạy
EXEC dbo.usp_KhachHang_TheoTrangThai @TrangThai = 1; -- biên dịch cho 1
EXEC dbo.usp_KhachHang_TheoTrangThai @TrangThai = 4; -- dùng lại kế hoạch của 1
GO
EXEC sys.sp_recompile N'dbo.usp_KhachHang_TheoTrangThai';
GO
EXEC dbo.usp_KhachHang_TheoTrangThai @TrangThai = 4; -- biên dịch cho 4
EXEC dbo.usp_KhachHang_TheoTrangThai @TrangThai = 1; -- dùng lại kế hoạch của 4
GO
| Chạy với | Biên dịch cho | Ước lượng sau scan | Thực tế | Granted / MaxUsed | Thời gian |
|---|---|---|---|---|---|
| 1 | 1 | 100.000 | 100.000 | 6.144 KB / 4.800 KB | 520 ms |
| 4 | 1 | 100.000 | 9.000.000 | 6.144 KB / 6.144 KB, tràn tempdb |
1.650 ms |
| 4 | 4 | 9.000.000 | 9.000.000 | 24.576 KB / 16.384 KB | 980 ms |
| 1 | 4 | 9.000.000 | 100.000 | 24.576 KB / 4.800 KB | 540 ms |
Số trong bảng là minh họa. Scan 45.873 page chiếm phần lớn thời gian ở cả bốn dòng, nên chênh lệch nằm ở phần tổng hợp.
sequenceDiagram participant A as Trang quản trị participant S as SQL Server participant C as Plan cache A->>S: @TrangThai = 1, lần gọi đầu S->>C: Biên dịch cho 1, grant 6.144 KB S-->>A: 100.000 dòng, 520 ms A->>S: @TrangThai = 4 C-->>S: Kế hoạch của 1 S-->>A: 9.000.000 dòng, tràn tempdb, 1.650 ms
Kế hoạch biên dịch cho giá trị 1 chạy với giá trị 4 có Hash Aggregate ước lượng 61.800 nhóm, thực tế 199.700 nhóm, kèm cảnh báo spill:
Kế hoạch thực tế dạng văn bản (minh họa)
-- minh họa: kế hoạch biên dịch cho @TrangThai = 1, chạy với @TrangThai = 4
SELECT DOP 4 Granted 6.144 KB MaxUsed 6.144 KB
Top TOP 20
Parallelism (Gather Streams) giữ thứ tự
Sort (Top N Sort) ORDER BY SUM(TongTien) DESC
Hash Match (Aggregate) GROUP BY KhachHangId est 61.800 act 199.700
CẢNH BÁO: spill level 1, 4 spilled thread(s)
Parallelism (Repartition Streams) Hash KhachHangId
Clustered Index Scan PK_DonHang Predicate: TrangThai = @TrangThai
est 100.000 act 9.000.000 Rows Read 10.000.000
Phần XML cho thấy nguyên nhân mà không cần đoán:
<ParameterList>
<ColumnReference Column="@TrangThai" ParameterDataType="tinyint"
ParameterCompiledValue="(1)" ParameterRuntimeValue="(4)" />
</ParameterList>
...
<Warnings>
<SpillToTempDb SpillLevel="1" SpilledThreadCount="4" />
<HashSpillDetails GrantedMemoryKb="5632" UsedMemoryKb="5632"
WritesToTempDb="1280" ReadsFromTempDb="1280" />
</Warnings>
Ba điểm phải thấy: biên dịch cho 1, chạy với 4; ước lượng 100.000 dòng, thực tế 9.000.000; grant tính cho khoảng 61.800 nhóm mà thực tế gần 200.000 nhóm. Chiều ngược lại không tràn, nhưng mỗi lần gọi với giá trị 1 giữ 24.576 KB mà chỉ dùng 4.800 KB. Trang quản trị gọi thủ tục vài chục lần mỗi giây thì phần xin thừa đó cộng dồn thành chờ RESOURCE_SEMAPHORE.
Biên dịch cho 1 thì thiếu bộ nhớ khi chạy với 4; biên dịch cho 4 thì giữ thừa khi chạy với 1
Bảng số liệu
| Granted | MaxUsed | |
|---|---|---|
| Chạy 1, biên dịch cho 1 | 6.144 KB | 4.800 KB |
| Chạy 4, biên dịch cho 1 | 6.144 KB | 6.144 KB |
| Chạy 4, biên dịch cho 4 | 24.576 KB | 16.384 KB |
| Chạy 1, biên dịch cho 4 | 24.576 KB | 4.800 KB |
Memory grant feedback của SQL Server 2019 Enterprise không cứu được trường hợp này: thủ tục bị gọi xen kẽ 1 và 4 làm nhu cầu bộ nhớ dao động, và kế hoạch dần ghi IsMemoryGrantFeedbackAdjusted = No: Feedback Disabled. Cách đọc grant và spill nằm ở Memory grant và tràn tempdb.
3. Chỉ mục lọc và OPTION (RECOMPILE)
Bảng DonHang có sẵn chỉ mục lọc IX_DonHang_DangMo ... WHERE TrangThai = 1 từ chương 2. Nó phủ đủ câu này cho giá trị 1 nhưng kế hoạch trong cache không dùng được: kế hoạch phải đúng cho mọi giá trị của @TrangThai, kể cả 4. Kế hoạch có thể kèm cảnh báo UnmatchedIndexes.
Với OPTION (RECOMPILE), tham số được thay bằng hằng lúc biên dịch và chỉ mục lọc khớp. Với giá trị 1, kế hoạch đọc IX_DonHang_DangMo, khoảng 470 logical reads thay vì 45.891 (minh họa). Với giá trị 4, kế hoạch scan PK_DonHang và grant tính cho 9.000.000 dòng.
Thủ tục có OPTION (RECOMPILE) và lần chạy đo logical reads
CREATE OR ALTER PROCEDURE dbo.usp_KhachHang_TheoTrangThai_Recompile
@TrangThai tinyint
AS
SET NOCOUNT ON;
SELECT TOP (20) KhachHangId, COUNT_BIG(*) AS SoDon, SUM(TongTien) AS TongGiaTri
FROM dbo.DonHang
WHERE TrangThai = @TrangThai
GROUP BY KhachHangId
ORDER BY SUM(TongTien) DESC
OPTION (RECOMPILE);
GO
SET STATISTICS IO ON;
EXEC dbo.usp_KhachHang_TheoTrangThai_Recompile @TrangThai = 1;
SET STATISTICS IO OFF;
4. Các cách xử lý
| Cách | Cơ chế | Được | Mất |
|---|---|---|---|
OPTION (RECOMPILE) |
Biên dịch ở mỗi lần chạy, tham số thành hằng | Kế hoạch đúng cho từng giá trị; dùng được chỉ mục lọc | CPU biên dịch mỗi lần; kế hoạch không ở lại cache; memory grant feedback và PSP không áp dụng |
OPTION (OPTIMIZE FOR (@TrangThai = 4)) |
Luôn ước lượng như giá trị 4 | Ổn định, không tràn với giá trị lớn | Giá trị nhỏ xin thừa bộ nhớ; giá trị đại diện có thể đổi khi dữ liệu đổi; phải sửa code |
OPTION (OPTIMIZE FOR UNKNOWN) |
Ước lượng theo mật độ trung bình: 10.000.000 / 5 giá trị = 2.000.000 dòng | Không phụ thuộc lần gọi đầu tiên | Một kế hoạch trung bình, không tốt nhất cho đầu nào |
| Đổi chỉ mục | Đổi đường đọc | Giá trị hiếm đọc ít page hơn | Grant vẫn dao động theo số dòng; thêm chi phí ghi. Hiệu quả nhất khi hai kế hoạch khác nhau vì Key Lookup, như ví dụ B ở Toán tử và Key Lookup |
| Ép kế hoạch bằng Query Store | sp_query_store_force_plan |
Không sửa code, có hiệu lực ngay | Kế hoạch bị ép có thể sai khi dữ liệu đổi; ép có thể thất bại; phải theo dõi |
| Query Store hint, SQL Server 2022 | sys.sp_query_store_set_hints gắn OPTION (...) vào câu từ bên ngoài |
Như sửa code mà không đụng code | Từ SQL Server 2022; không áp cho câu được tham số hóa đơn giản |
| Parameter Sensitive Plan optimization, SQL Server 2022 | Nhiều kế hoạch cho một câu theo khoảng số dòng | Tự động, không sửa code | Chỉ điều kiện bằng; số biến thể có giới hạn; tắt khi tắt parameter sniffing |
Query Store hint trên SQL Server 2022, áp RECOMPILE cho câu trong thủ tục mà không sửa thủ tục. 1873 là query_id tìm theo cách ở mục 6:
EXEC sys.sp_query_store_set_hints @query_id = 1873, @query_hints = N'OPTION (RECOMPILE)';
SELECT query_hint_id, query_id, query_hint_text, last_query_hint_failure_reason_desc
FROM sys.query_store_query_hints;
EXEC sys.sp_query_store_clear_hints @query_id = 1873; -- gỡ khi không cần nữa
Không xóa cả plan cache để "sửa" sniffing
DBCC FREEPROCCACHE không tham số xóa kế hoạch của mọi database trên instance. Mọi câu biên dịch lại cùng lúc, CPU vọt lên, và lần biên dịch kế tiếp của thủ tục lỗi vẫn có thể sniff đúng giá trị xấu. Muốn biên dịch lại một thủ tục, dùng sp_recompile trên thủ tục đó. Muốn bỏ một kế hoạch, dùng DBCC FREEPROCCACHE (plan_handle) với đúng plan_handle lấy từ sys.dm_exec_query_stats.
5. Parameter Sensitive Plan optimization
Từ SQL Server 2022, compatibility level 160, có trên mọi edition. Lần biên dịch đầu dùng histogram để tìm điều kiện bằng có dữ liệu lệch, tối đa ba điều kiện. Câu đủ điều kiện được cất thành một dispatcher plan, chia số dòng ước lượng thành ba khoảng (thấp, vừa, cao) và biên dịch mỗi khoảng một query variant có kế hoạch riêng.
Ví dụ trong tài liệu Microsoft có ranh giới 100 và 1.000.000 dòng. Nếu ranh giới cho TrangThai tương tự, giá trị 1 và giá trị 4 rơi vào hai khoảng khác nhau và có hai kế hoạch. Ranh giới thật do engine tính từ thống kê; đọc nó ở phần tử Dispatcher (ParameterSensitivePredicate LowBoundary="..." HighBoundary="...") trong XML thay vì giả định.
Tắt cho cả database bằng ALTER DATABASE SCOPED CONFIGURATION SET PARAMETER_SENSITIVE_PLAN_OPTIMIZATION = OFF, cho một câu bằng USE HINT('DISABLE_PARAMETER_SENSITIVE_PLAN'). PSP không hoạt động với câu có RECOMPILE, và tắt theo khi parameter sniffing bị tắt.
Liệt kê dispatcher plan và query variant trong Query Store (SQL Server 2022 trở lên)
-- SQL Server 2022 trở lên
SELECT p.plan_id, p.query_id, p.plan_type_desc,
v.parent_query_id, v.dispatcher_plan_id
FROM sys.query_store_plan AS p
LEFT JOIN sys.query_store_query_variant AS v
ON v.query_variant_query_id = p.query_id
WHERE p.plan_type IN (1, 2); -- 1 Dispatcher Plan, 2 Query Variant Plan
6. So kế hoạch theo thời gian trong Query Store
Plan cache chỉ có kế hoạch hiện tại. Query Store giữ mọi kế hoạch của một câu (query_id), mỗi kế hoạch một plan_id, cùng số liệu chạy theo khoảng thời gian, mặc định mỗi khoảng một giờ. Với QUERY_CAPTURE_MODE = AUTO như chương 2, câu chạy ít và rẻ có thể không được ghi.
Truy vấn dưới đây lấy các kế hoạch của thủ tục ở mục 2 trong 7 ngày qua, với trung bình có trọng số. sys.query_store_runtime_stats có thể có nhiều dòng cho cùng một kế hoạch trong cùng khoảng, nên luôn gộp theo plan_id. avg_duration tính bằng micro giây, avg_logical_io_reads bằng page 8 KB. max_query_max_used_memory cũng tính bằng page 8 KB, và dù tên như vậy, nó là memory grant chứ không phải bộ nhớ đã dùng: tài liệu Microsoft ghi rõ, và trên LocalDB nó bằng GrantedMemory của kế hoạch thực tế.
Các kế hoạch của một thủ tục trong 7 ngày, gộp theo plan_id
USE BanHang;
GO
SELECT
p.query_id,
p.plan_id,
p.is_forced_plan,
p.last_compile_start_time,
SUM(rs.count_executions) AS so_lan_chay,
SUM(rs.avg_duration * rs.count_executions)
/ NULLIF(SUM(rs.count_executions), 0) / 1000.0 AS tb_ms,
SUM(rs.avg_logical_io_reads * rs.count_executions)
/ NULLIF(SUM(rs.count_executions), 0) AS tb_logical_reads,
MAX(rs.max_query_max_used_memory) * 8 AS grant_max_kb
FROM sys.query_store_query AS q
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS i
ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE q.object_id = OBJECT_ID(N'dbo.usp_KhachHang_TheoTrangThai')
AND i.start_time >= DATEADD(DAY, -7, SYSDATETIMEOFFSET())
GROUP BY p.query_id, p.plan_id, p.is_forced_plan, p.last_compile_start_time
ORDER BY p.query_id, p.plan_id;
Kết quả minh họa:
| query_id | plan_id | is_forced_plan | so_lan_chay | tb_ms | tb_logical_reads | grant_max_kb |
|---|---|---|---|---|---|---|
| 1873 | 2208 | 0 | 1.240 | 1.090 | 45.891 | 6.144 |
| 1873 | 2231 | 0 | 1.315 | 760 | 45.891 | 24.576 |
Một query_id, hai plan_id. Hai kế hoạch đọc cùng số page, khác nhau ở thời gian và grant, đúng như mục 2. Muốn biết kế hoạch nào chạy lúc nào, xem theo từng khoảng, đổi sang giờ Việt Nam:
Kế hoạch nào chạy trong từng khoảng, theo giờ Việt Nam
SELECT
SWITCHOFFSET(i.start_time, '+07:00') AS bat_dau_gio_vn,
rs.plan_id,
SUM(rs.count_executions) AS so_lan_chay,
SUM(rs.avg_duration * rs.count_executions)
/ NULLIF(SUM(rs.count_executions), 0) / 1000.0 AS tb_ms
FROM sys.query_store_query AS q
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
JOIN sys.query_store_runtime_stats_interval AS i
ON i.runtime_stats_interval_id = rs.runtime_stats_interval_id
WHERE q.object_id = OBJECT_ID(N'dbo.usp_KhachHang_TheoTrangThai')
AND i.start_time >= DATEADD(DAY, -2, SYSDATETIMEOFFSET())
GROUP BY i.start_time, rs.plan_id
ORDER BY i.start_time, rs.plan_id;
Ép một kế hoạch, kiểm tra kết quả ép, rồi gỡ. 1873 và 2231 là số minh họa, thay bằng số của máy mình. Cột query_plan là nvarchar(max); TRY_CONVERT sang xml để bấm mở trong SSMS, và trả NULL thay vì báo lỗi khi kế hoạch lồng quá sâu:
EXEC sys.sp_query_store_force_plan @query_id = 1873, @plan_id = 2231;
SELECT plan_id, is_forced_plan, force_failure_count, last_force_failure_reason_desc,
TRY_CONVERT(xml, query_plan) AS ke_hoach
FROM sys.query_store_plan
WHERE query_id = 1873;
EXEC sys.sp_query_store_unforce_plan @query_id = 1873, @plan_id = 2231;
Ép kế hoạch làm câu được biên dịch lại theo kế hoạch đã chọn; kết quả thường giống hệt hoặc gần giống, không bảo đảm y hệt. Ép thất bại, ví dụ vì chỉ mục trong kế hoạch đã bị xóa, thì câu tối ưu như bình thường, force_failure_count tăng, và last_force_failure_reason_desc ghi lý do như NO_INDEX. Báo cáo SSMS "Regressed Queries" và "Queries With Forced Plans" cho cùng thông tin.
Giữ bản kế hoạch tốt trước khi đổi
Trước khi ép kế hoạch, thêm chỉ mục, hay đổi compatibility level, lưu XML của kế hoạch đang tốt thành tệp .sqlplan. Sau thay đổi, mở hai tệp bằng Compare Showplan của SSMS để thấy đúng toán tử nào đổi.
7. Áp dụng trong .NET: EF Core, TagWith và OPTION (RECOMPILE)
EF Core biến biến C# trong LINQ thành tham số sp_executesql, nên truy vấn EF Core bị sniff y như thủ tục. Câu "20 khách theo trạng thái" viết bằng LINQ sinh ra WHERE [d].[TrangThai] = @trangThai với @trangThai tinyint: một kế hoạch cho mọi trạng thái. Dapper cũng vậy.
Muốn OPTION (RECOMPILE) cho đúng một truy vấn EF Core, gắn tag cho nó và để một DbCommandInterceptor nối gợi ý vào cuối câu. Với Dapper hay SQL viết tay thì ghi thẳng vào chuỗi.
// Câu nào mang tag "recompile" thì nối OPTION (RECOMPILE) vào cuối.
sealed class RecompileInterceptor : DbCommandInterceptor
{
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
if (command.CommandText.Contains("-- recompile", StringComparison.Ordinal))
command.CommandText += Environment.NewLine + "OPTION (RECOMPILE)";
return ValueTask.FromResult(result);
}
// ReaderExecuting, bản đồng bộ, làm y hệt: xem chương trình đầy đủ
}
// Đăng ký: options.AddInterceptors(new RecompileInterceptor())
await TopKhach(db, trangThai, "bao-cao-top-khach-rc").TagWith("recompile").ToListAsync();
Tìm câu đó trong Query Store có một bẫy. TagWith đặt -- bao-cao-top-khach ở đầu batch, nhưng Query Store bỏ comment trước và sau câu lệnh, nên query_sql_text không chứa tag. Chương trình tìm câu trong plan cache theo tag, lấy query_hash, rồi nối sang sys.query_store_query. Câu có RECOMPILE không ở lại cache, nên tìm theo một đoạn văn bản của chính câu.
Chạy dotnet run SniffingEf.cs (file có #:property PublishAot=false vì EF Core sinh code lúc chạy) trên .NET 10.0.12, EF Core 10.0.12, LocalDB SQL Server 2019, database thử Kumeo_kehoach (script ở bài đầu nhóm). Mỗi kiểu chạy bốn lần với trạng thái 1, 4, 4, 1:
| Cách chạy | Query Store | Grant | Ghi chú từ plan cache |
|---|---|---|---|
Không RECOMPILE |
1 query_id, 1 plan_id, 4 lần chạy |
2.992 KB cho mọi trạng thái | Biên dịch cho (1); dùng 1.352 đến 2.608 KB; spill tối đa 138 page |
Có RECOMPILE qua interceptor |
1 query_id, 2 plan_id, mỗi plan 2 lần chạy |
1.432 KB cho trạng thái 1, 3.928 KB cho trạng thái 4 | Không ở lại cache |
Bản không RECOMPILE biên dịch cho trạng thái 1, rồi chạy với trạng thái 4 và tràn 138 page. Bản có interceptor có hai kế hoạch, mỗi trạng thái một grant. Cái giá là một lần biên dịch mỗi lần gọi: chỉ gắn tag recompile cho truy vấn chạy ít mà dữ liệu lệch, như báo cáo quản trị.
Toàn bộ chương trình, chạy bằng dotnet run SniffingEf.cs
#:property PublishAot=false
#:package Microsoft.EntityFrameworkCore.SqlServer@10.0.12
using System.Data.Common;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_kehoach;Integrated Security=true;TrustServerCertificate=true";
var options = new DbContextOptionsBuilder<BanHangDb>()
.UseSqlServer(Cs)
.AddInterceptors(new RecompileInterceptor())
.Options;
await using (var db = new BanHangDb(options))
{
await db.Database.ExecuteSqlRawAsync("ALTER DATABASE SCOPED CONFIGURATION CLEAR PROCEDURE_CACHE;");
await db.Database.ExecuteSqlRawAsync("ALTER DATABASE CURRENT SET QUERY_STORE CLEAR;");
Console.WriteLine($".NET {Environment.Version}");
Console.WriteLine();
Console.WriteLine(TopKhach(db, 1, "bao-cao-top-khach").ToQueryString());
Console.WriteLine();
}
// Lần gọi đầu với trạng thái 1 (Mới), sau đó trạng thái 4 (Hoàn tất)
foreach (var trangThai in new byte[] { 1, 4, 4, 1 })
{
await using var db = new BanHangDb(options);
await TopKhach(db, trangThai, "bao-cao-top-khach").ToListAsync();
}
// Cùng câu, gắn thêm tag "recompile" để interceptor nối OPTION (RECOMPILE)
foreach (var trangThai in new byte[] { 1, 4, 4, 1 })
{
await using var db = new BanHangDb(options);
await TopKhach(db, trangThai, "bao-cao-top-khach-rc").TagWith("recompile").ToListAsync();
}
await using (var db = new BanHangDb(options))
{
// Câu không RECOMPILE: tag nằm trong văn bản batch của plan cache, nên tìm được qua sys.dm_exec_sql_text
var c = await db.Database.SqlQueryRaw<TrongCache>("""
SELECT qs.query_hash AS QueryHash, qs.execution_count AS SoLan,
qs.min_grant_kb AS GrantMinKb, qs.max_grant_kb AS GrantMaxKb,
qs.min_used_grant_kb AS UsedMinKb, qs.max_used_grant_kb AS UsedMaxKb, qs.max_spills AS SpillMax,
CAST(qp.query_plan AS nvarchar(max)) AS KeHoach
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
WHERE st.text LIKE N'%-- bao-cao-top-khach%' AND st.text NOT LIKE N'%dm_exec_query_stats%'
""").SingleAsync();
var bienDich = Regex.Match(c.KeHoach, "Column=\"@trangThai\"[^>]*ParameterCompiledValue=\"([^\"]+)\"").Groups[1].Value;
Console.WriteLine($"Không RECOMPILE: {c.SoLan} lần chạy, @trangThai biên dịch cho {bienDich}, " +
$"grant {c.GrantMinKb}–{c.GrantMaxKb} KB, dùng {c.UsedMinKb}–{c.UsedMaxKb} KB, spill tối đa {c.SpillMax} page");
// Query Store bỏ comment trước câu lệnh, nên tag không có trong query_sql_text: nối qua query_hash
await db.Database.ExecuteSqlRawAsync("EXEC sys.sp_query_store_flush_db;");
var ke = await db.Database.SqlQueryRaw<TrongQueryStore>("""
SELECT CAST(CASE WHEN qt.query_sql_text LIKE N'%OPTION (RECOMPILE)%' THEN 1 ELSE 0 END AS bit) AS Recompile,
q.query_id AS QueryId, p.plan_id AS PlanId, SUM(rs.count_executions) AS SoLan,
MAX(rs.max_query_max_used_memory) * 8 AS GrantKb -- cột này là memory grant, tính bằng page 8 KB
FROM sys.query_store_query AS q
JOIN sys.query_store_query_text AS qt ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
WHERE q.query_hash = {0}
OR (qt.query_sql_text LIKE N'%OPTION (RECOMPILE)%' AND CHARINDEX(N'[d].[TrangThai] = @trangThai', qt.query_sql_text) > 0)
GROUP BY qt.query_sql_text, q.query_id, p.plan_id
ORDER BY q.query_id, p.plan_id
""", c.QueryHash).ToListAsync();
Console.WriteLine();
Console.WriteLine($"{"RECOMPILE",-10} {"query_id",8} {"plan_id",7} {"lần",4} {"grant KB",9}");
foreach (var r in ke)
Console.WriteLine($"{(r.Recompile ? "có" : "không"),-10} {r.QueryId,8} {r.PlanId,7} {r.SoLan,4} {r.GrantKb,9}");
}
static IQueryable<object> TopKhach(BanHangDb db, byte trangThai, string tag) =>
db.DonHang.TagWith(tag)
.Where(d => d.TrangThai == trangThai)
.GroupBy(d => d.KhachHangId)
.Select(g => new { KhachHangId = g.Key, SoDon = g.LongCount(), TongGiaTri = g.Sum(d => d.TongTien) })
.OrderByDescending(x => x.TongGiaTri)
.Take(20);
// Câu nào mang tag "recompile" thì nối OPTION (RECOMPILE) vào cuối.
sealed class RecompileInterceptor : DbCommandInterceptor
{
static void Them(DbCommand command)
{
if (command.CommandText.Contains("-- recompile", StringComparison.Ordinal))
command.CommandText += Environment.NewLine + "OPTION (RECOMPILE)";
}
public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result)
{
Them(command);
return result;
}
public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command, CommandEventData eventData, InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
Them(command);
return ValueTask.FromResult(result);
}
}
record TrongCache(byte[] QueryHash, long SoLan, long GrantMinKb, long GrantMaxKb, long UsedMinKb, long UsedMaxKb, long SpillMax, string KeHoach);
record TrongQueryStore(bool Recompile, long QueryId, long PlanId, long SoLan, long GrantKb);
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(DbContextOptions<BanHangDb> options) : DbContext(options)
{
public DbSet<DonHang> DonHang => Set<DonHang>();
protected override void OnModelCreating(ModelBuilder b) => b.Entity<DonHang>(e =>
{
e.ToTable("DonHang");
e.HasKey(x => new { x.NgayTao, x.DonHangId });
e.Property(x => x.TongTien).HasPrecision(18, 2);
});
}
Output của chương trình
.NET 10.0.12
DECLARE @p int = 20;
DECLARE @trangThai tinyint = CAST(1 AS tinyint);
-- bao-cao-top-khach
SELECT TOP(@p) [d].[KhachHangId], COUNT_BIG(*) AS [SoDon], COALESCE(SUM([d].[TongTien]), 0.0) AS [TongGiaTri]
FROM [DonHang] AS [d]
WHERE [d].[TrangThai] = @trangThai
GROUP BY [d].[KhachHangId]
ORDER BY COALESCE(SUM([d].[TongTien]), 0.0) DESC
Không RECOMPILE: 4 lần chạy, @trangThai biên dịch cho (1), grant 2992–2992 KB, dùng 1352–2608 KB, spill tối đa 138 page
RECOMPILE query_id plan_id lần grant KB
không 1 1 4 2992
có 2 2 2 1432
có 2 3 2 3928
Những chỗ hay hiểu sai
- "
OPTION (RECOMPILE)là cách sửa an toàn mọi lúc." Nó tốn CPU biên dịch ở mỗi lần chạy, và kế hoạch không ở lại cache để memory grant feedback hay PSP làm việc. - "Ép kế hoạch trong Query Store là xong." Đó là biện pháp giữ tạm: ép có thể thất bại, và kế hoạch bị ép có thể tệ đi khi dữ liệu đổi.
- "Truy vấn EF Core không bị sniff vì không phải thủ tục." EF Core gửi
sp_executesqlcó tham số; kế hoạch được dùng lại như thủ tục. - "Tìm câu có
TagWithtrong Query Store bằngLIKEtrênquery_sql_text." Query Store bỏ comment ở đầu và cuối câu. Nối quaquery_hash, hoặc tìm theo văn bản của chính câu. - "
max_query_max_used_memorylà bộ nhớ câu đã dùng." Cột này là memory grant. Bộ nhớ đã dùng nằm trong kế hoạch thực tế vàlast_used_grant_kb.
Kết luận
Parameter sniffing là giá của việc dùng lại kế hoạch: đúng cho giá trị đầu, có thể sai cho giá trị sau khi dữ liệu lệch. Xác nhận bằng ParameterCompiledValue, rồi chọn cách sửa có cái giá chấp nhận được.
Trong dự án .NET của bạn:
- Liệt kê truy vấn EF Core hay Dapper lọc theo cột lệch mạnh (
TrangThai, khách B2B như khách 42); gắnTagWithcho chúng. - Khi một truy vấn có tag chậm, đọc
ParameterCompiledValuetừ kế hoạch trong cache tìm theo tag. - Dùng
DbCommandInterceptorđể ápOPTION (RECOMPILE)cho đúng truy vấn chạy ít mà dữ liệu lệch; đo CPU biên dịch trước khi mở rộng. - Trên SQL Server 2022, cân nhắc Query Store hint (
sys.sp_query_store_set_hints) thay vì sửa code. - Khi ép kế hoạch, theo dõi
force_failure_countvà gỡ ngay khi đã sửa gốc.
Đọc tiếp
- Bài trước: Memory grant và tràn tempdb.
- Bài đầu nhóm: Kế hoạch thực thi và plan cache.
- Một sự cố parameter sniffing với khách 42, từ lúc phát hiện tới ép kế hoạch và sửa bằng chỉ mục phủ: Điều tra truy vấn chậm.
- Cấu hình Query Store và chỉ mục lọc
IX_DonHang_DangMo: Kỹ thuật thường dùng. Trên Azure SQL Database, Query Store bật sẵn: Azure SQL.