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. 1. Danh sách tham số trong kế hoạch
  2. 2. Parameter sniffing: cơ chế
  3. 3. Chỉ mục lọc và OPTION (RECOMPILE)
  4. 4. Các cách xử lý
  5. 5. Parameter Sensitive Plan optimization
  6. 6. So kế hoạch theo thời gian trong Query Store
  7. 7. Áp dụng trong .NET: EF Core, TagWith và OPTION (RECOMPILE)
  8. Những chỗ hay hiểu sai
  9. Kết luận
  10. Đọc tiếp
  11. 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 ParameterCompiledValue với ParameterRuntimeValue trướ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ếSQL · 11 dòng
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)10 dòng
-- 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

GrantedMaxUsed
Chạy 1, biên dịch cho 16.144 KB4.800 KBChạy 4, biên dịch cho 16.144 KB6.144 KBChạy 4, biên dịch cho 424.576 KB16.384 KBChạy 1, biên dịch cho 424.576 KB4.800 KB
Memory grant của dbo.usp_KhachHang_TheoTrangThai trong bốn lần chạy ở bảng trên, số minh họa. Lần chạy 4 với kế hoạch của 1 dùng hết grant và tràn xuống tempdb.
Bảng số liệu
GrantedMaxUsed
Chạy 1, biên dịch cho 16.144 KB4.800 KB
Chạy 4, biên dịch cho 16.144 KB6.144 KB
Chạy 4, biên dịch cho 424.576 KB16.384 KB
Chạy 1, biên dịch cho 424.576 KB4.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 readsSQL · 15 dòng
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 · 7 dòng
-- 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_idSQL · 23 dòng
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 NamSQL · 15 dòng
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

OPTION (RECOMPILE) cho mỗi trạng thái một grant riêng; không có nó, mọi lần chạy dùng grant của lần biên dịch đầu

Không RECOMPILE, mọi trạng thái2.992 KBRECOMPILE, trạng thái 11.432 KBRECOMPILE, trạng thái 43.928 KB
Grant theo plan_id trong Query Store (max_query_max_used_memory × 8). LocalDB SQL Server 2019, 1.000.000 đơn, EF Core 10.0.12.
Bảng số liệu
Giá trị
Không RECOMPILE, mọi trạng thái2.992 KB
RECOMPILE, trạng thái 11.432 KB
RECOMPILE, trạng thái 43.928 KB

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.csC# · 130 dòng
#: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ình19 dòng
.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_executesql có tham số; kế hoạch được dùng lại như thủ tục.
  • "Tìm câu có TagWith trong Query Store bằng LIKE trên query_sql_text." Query Store bỏ comment ở đầu và cuối câu. Nối qua query_hash, hoặc tìm theo văn bản của chính câu.
  • "max_query_max_used_memory là 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ắn TagWith cho chúng.
  • Khi một truy vấn có tag chậm, đọc ParameterCompiledValue từ kế hoạch trong cache tìm theo tag.
  • Dùng DbCommandInterceptor để áp OPTION (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_count và gỡ ngay khi đã sửa gốc.

Đọc tiếp

Nguồn

Đọc tiếp

Trong SQL Server

Kế hoạch thực thi và plan cache

Câu SQL thành kế hoạch thực thi ra sao, khi nào kế hoạch được dùng lại, lấy kế hoạch ước lượng hay thực tế ở đâu, và tham số hóa từ C# thế nào để mỗi câu chỉ có một kế hoạch.

13 phút đọc