Cơ sở dữ liệuSQL Server, phần 21/24

Đọc kế hoạch thực thi: tìm chỗ ước lượng lệch

Đọc kế hoạch thực thi theo chiều nào, xem thuộc tính nào trước, tìm chỗ ước lượng lệch đầu tiên, và đo logical reads từ code C# để so hai phiên bản của một câu.

Mục lục
  1. 1. Đọc theo chiều nào
  2. 2. Số dòng ước lượng và thực tế
  3. 3. Seek Predicate, Predicate và Output List
  4. 4. Logical reads và thời gian của từng toán tử
  5. 5. Ví dụ A: doanh thu tháng 9/2026 theo sản phẩm
  6. 6. Đọc một kế hoạch trong năm phút
  7. 7. Áp dụng trong .NET: đo logical reads từ ứng dụng
  8. Những chỗ hay hiểu sai
  9. Kết luận
  10. Đọc tiếp
  11. Nguồn

Báo cáo doanh thu tháng của BanHang đọc hơn một triệu page, trong khi dữ liệu của tháng đó chỉ khoảng 8.000 page. Kế hoạch thực thi cho thấy vì sao nếu đọc đúng chiều và đúng thuộc tính, còn phần trăm chi phí in trên mỗi toán tử thì dẫn sai hướng. Đọc xong bạn tìm được chỗ ước lượng lệch đầu tiên trong một kế hoạch, sửa nó, và đo lại bằng logical reads từ code C#.

Đọc nhanh

  • Dữ liệu chảy từ phải sang trái, còn lời gọi lấy dòng đi từ trái sang phải.
  • Phần trăm chi phí là ước lượng kể cả trong kế hoạch thực tế; chỗ tốn thật nằm ở logical reads và ActualElapsedms.
  • Đi từ phải sang trái, toán tử đầu tiên có số dòng thực tế lệch ước lượng khoảng 10 lần trở lên là nơi tìm nguyên nhân.
  • Bọc cột ngày trong YEAR() và MONTH() làm trình tối ưu phải đoán số dòng; đổi sang điều kiện khoảng giảm logical reads khoảng 145 lần.

1. Đọc theo chiều nào

Kế hoạch dạng đồ họa có nút gốc SELECT ở bên trái, các toán tử đọc bảng ở tận cùng bên phải. Dòng dữ liệu đi từ phải sang trái theo mũi tên.

Việc điều khiển đi theo chiều ngược lại. Mỗi toán tử là một iterator có ba thao tác: khởi tạo (Init), lấy dòng kế tiếp (GetNext), đóng (Close). Nút gốc gọi GetNext trên con của nó, con gọi xuống con của mình, cho tới toán tử đọc page. Lời gọi đi sang phải, dữ liệu đi sang trái. Mô hình kéo này giải thích hai điều hay gặp:

  • Toán tử streaming (Nested Loops, Compute Scalar, Filter, Stream Aggregate, seek, scan) trả dòng ngay khi có. TOP (20) phía trên một chuỗi toàn streaming dừng việc đọc sau dòng thứ 20.
  • Toán tử blocking (Sort, phía build của Hash Match, Hash Aggregate) phải nhận hết đầu vào rồi mới trả dòng đầu tiên. TOP (20) phía trên một Sort không cứu được việc Sort đọc hết đầu vào.

Kế hoạch trong bài được viết thành cây văn bản. Thụt vào một mức là con của dòng phía trên. Với toán tử có hai đầu vào, con viết trước là đầu vào nằm trên trong bản đồ họa: phía ngoài (outer) của Nested Loops, phía build của Hash Match. Dòng thụt sâu nhất là toán tử ở tận cùng bên phải.

-- minh họa: cách đọc cây trong bài
SELECT
  Nested Loops (Inner Join)                     <- nhận dòng từ hai con
    Index Seek IX_DonHang_KhachHang             <- phía ngoài: chạy 1 lần, trả n dòng
    Key Lookup (Clustered) PK_DonHang           <- phía trong: chạy n lần, mỗi lần 1 dòng

Độ dày mũi tên trong SSMS theo số dòng: tổng mọi lần chạy trong kế hoạch thực tế, một lần chạy trong kế hoạch ước lượng. Mũi tên ra khỏi Key Lookup vì vậy mảnh trong kế hoạch ước lượng và dày trong kế hoạch thực tế. Độ dày cũng không phải số byte: 1 triệu dòng hai cột nhẹ hơn 100.000 dòng có cột nvarchar(max).

Phần trăm là chi phí ước lượng

Con số "Cost: 43%" dưới mỗi toán tử là chi phí ước lượng của toán tử đó chia cho tổng chi phí ước lượng của câu. Kế hoạch thực tế vẫn in đúng con số ước lượng ấy. Khi ước lượng sai, phần trăm cũng sai theo: ở ví dụ mục 5, seek vào ChiTietDonHang chạy 373.100 lần nhưng phần trăm của nó được tính cho 21.000 lần như trình tối ưu tưởng.

2. Số dòng ước lượng và thực tế

Bấm vào một toán tử rồi mở cửa sổ Properties (F4) cho đủ thuộc tính; tooltip chỉ hiện một phần. Từ SSMS 18.5, tên thuộc tính ghi rõ phạm vi:

Thuộc tính Nghĩa
Estimated Number of Rows Per Execution Số dòng ước lượng cho một lần toán tử chạy
Estimated Number of Executions Số lần ước lượng toán tử sẽ chạy
Estimated Number of Rows for All Executions Tích hai số trên. SSMS tự tính, không có trong XML
Actual Number of Rows for All Executions Tổng số dòng thực tế của mọi lần chạy (XML: ActualRows, theo từng thread)
Number of Executions Số lần toán tử thực sự chạy (XML: ActualExecutions)
Number of Rows Read Số dòng toán tử đọc trước khi lọc bằng Predicate (XML: ActualRowsRead)

Toán tử ở phía trong Nested Loops chạy nhiều lần. So Actual Number of Rows for All Executions với Estimated Number of Rows Per Execution là so hai thứ khác nhau. Phải so actual với estimated for all executions, hoặc chia actual cho Number of Executions.

Cách đọc: đi từ phải sang trái, tìm toán tử đầu tiên mà thực tế lệch ước lượng từ khoảng 10 lần trở lên. Mọi toán tử bên trái nó thừa hưởng sai số đó, nên chỗ lệch đầu tiên mới là nơi tìm nguyên nhân: thống kê cũ, hàm bọc cột, chuyển kiểu ngầm, biến cục bộ, hai điều kiện tương quan.

Number of Rows Read lớn hơn nhiều so với số dòng trả ra nghĩa là toán tử đọc rồi bỏ. Clustered Index Scan đọc 10.000.000 dòng để trả 373.100 dòng là dấu hiệu điều kiện không dùng được để seek.

3. Seek Predicate, Predicate và Output List

Seek Predicate là phần điều kiện dùng để đi xuống cây B-tree và xác định khoảng cần đọc. Predicate là phần điều kiện kiểm tra trên từng dòng đã đọc (residual predicate). Một Index Seek có Seek Predicate là KhachHangId = 1507 và Predicate là TongTien > 1000000 đọc mọi đơn của khách 1507 rồi mới lọc theo tiền.

Seek trên bảng phân vùng có thêm khóa ẩn PtnId1000 đứng đầu Seek Predicate. Giá trị của nó cho biết partition nào được đọc.

Output List là các cột toán tử trả lên trên. Output List của Key Lookup là các cột chỉ mục không clustered thiếu. Output List rộng ở phía dưới một Sort hay Hash Match làm memory grant lớn theo, chuyện của bài Memory grant và tràn tempdb.

4. Logical reads và thời gian của từng toán tử

SET STATISTICS IO ON in số page 8 KB đọc từ buffer pool theo từng bảng. Kế hoạch thực tế có ActualLogicalReads theo từng toán tử và từng thread. Logical reads không phụ thuộc cache nóng hay lạnh, nên dùng nó để so hai phiên bản của một câu. Physical reads cho biết lần chạy đó phải lên đĩa bao nhiêu, và đổi theo trạng thái cache.

Quy về bảng: một page lá của DonHang chứa 218 dòng. Đọc 45.873 page là đọc cả bảng 10 triệu đơn. Đọc 69 page là vài chục đơn.

Kế hoạch thực tế có ActualElapsedms và ActualCPUms cho từng toán tử, theo từng thread; SSMS gom dưới mục "Actual Time Statistics". Ở row mode, con số là cộng dồn của toán tử đó và mọi toán tử con. Ở batch mode, con số chỉ của riêng toán tử. Muốn biết một toán tử row mode tự tốn bao nhiêu, lấy số của nó trừ số của con trực tiếp. Nút gốc có QueryTimeStats (CPU và thời gian của cả câu) và WaitStats (các wait lớn nhất của lần chạy).

5. Ví dụ A: doanh thu tháng 9/2026 theo sản phẩm

Báo cáo cuối tháng: doanh thu từng sản phẩm trong tháng 9/2026, không tính đơn đã hủy. Số liệu gốc:

Đối tượng Tháng 9/2026 Ghi chú
Đơn DonHang Khoảng 392.700 3,6 triệu đơn năm 2026 chia cho 275 ngày từ 01/01 đến 02/10, nhân 30 ngày
Đơn không hủy (TrangThai <> 5) Khoảng 373.100 95%
Dòng ChiTietDonHang Khoảng 1.178.100 3 dòng mỗi đơn. 1.119.300 dòng thuộc đơn không hủy
Page lá DonHang của tháng Khoảng 1.802 218 dòng mỗi page
Page lá ChiTietDonHang của tháng Khoảng 6.136 Một dòng chi tiết 40 byte cộng 2 byte slot, 192 dòng mỗi page

Phiên bản đầu: lọc tháng bằng hàm

SELECT
    sp.SanPhamId,
    sp.MaSanPham,
    sp.Ten,
    SUM(ct.SoLuong * ct.DonGia) AS DoanhThu
FROM dbo.DonHang AS d
JOIN dbo.ChiTietDonHang AS ct
    ON ct.NgayTao = d.NgayTao
   AND ct.DonHangId = d.DonHangId
JOIN dbo.SanPham AS sp
    ON sp.SanPhamId = ct.SanPhamId
WHERE YEAR(d.NgayTao) = 2026
  AND MONTH(d.NgayTao) = 9
  AND d.TrangThai <> 5
GROUP BY sp.SanPhamId, sp.MaSanPham, sp.Ten;

Kế hoạch thực tế (minh họa, DOP 4) vẽ lại theo kiểu SSMS, dòng dữ liệu đi từ phải sang trái, các toán tử Parallelism ở giữa được lược bớt:

SELECT Hash Match Inner Join est 4.400 act 4.986 Clustered Index Scan PK_SanPham est 5.000 act 5.000 Hash Match Aggregate est 4.400 act 4.986 Nested Loops Inner Join est 63.000 act 1.119.300 Clustered Index Scan PK_DonHang est 21.000 act 373.100 Clustered Index Seek PK_ChiTietDonHang est 21.000 lần chạy act 373.100 lần chạy
Ô màu cam: thực tế lệch ước lượng khoảng 18 lần. Đã lược các toán tử Parallelism.

Bản văn bản của cùng kế hoạch, kèm Predicate, partition đã đọc và output của SET STATISTICS IO, TIME: logical reads tổng 1.169.141, CPU 3.891 ms, thời gian 1.204 ms.

Kế hoạch thực tế dạng văn bản và SET STATISTICS IO, TIME (minh họa)23 dòng
-- minh họa: YEAR()/MONTH() trên NgayTao, DOP 4
SELECT                                          Optimization Level FULL   DOP 4
  Parallelism (Gather Streams)
    Hash Match (Inner Join)                     ct.SanPhamId = sp.SanPhamId      est 4.400    act 4.986
      Clustered Index Scan PK_SanPham                                            est 5.000    act 5.000
      Hash Match (Aggregate)                    GROUP BY ct.SanPhamId            est 4.400    act 4.986
        Nested Loops (Inner Join)                                                est 63.000   act 1.119.300
          Clustered Index Scan PK_DonHang       est 21.000   act 373.100   Rows Read 10.000.000
            Predicate: YEAR(NgayTao) = 2026 AND MONTH(NgayTao) = 9 AND TrangThai <> 5
            Actual Partition Count 4            Actual Partitions Accessed 1..4
          Clustered Index Seek PK_ChiTietDonHang
            Seek Predicate: NgayTao = d.NgayTao AND DonHangId = d.DonHangId
            est 3 mỗi lần   Estimated Number of Executions 21.000
            act 1.119.300   Number of Executions 373.100

-- minh họa, rút gọn các cột bằng 0
Table 'ChiTietDonHang'. Scan count 373100, logical reads 1123186, physical reads 0, read-ahead reads 0.
Table 'DonHang'. Scan count 5, logical reads 45891, physical reads 0, read-ahead reads 0.
Table 'SanPham'. Scan count 1, logical reads 64, physical reads 0, read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 3891 ms,  elapsed time = 1204 ms.

Đọc theo thứ tự ở các mục trên:

  1. Ước lượng lệch. Đi từ phải sang trái, chỗ lệch đầu tiên là Clustered Index Scan trên PK_DonHang: ước lượng 21.000, thực tế 373.100, gấp khoảng 18 lần. YEAR() và MONTH() bọc cột nên histogram của NgayTao không dùng được; 21.000 là con số đoán, không lấy từ thống kê.
  2. Predicate. Không có Seek Predicate. Cả ba điều kiện nằm ở Predicate, kiểm tra trên 10.000.000 dòng đã đọc.
  3. Partition. Actual Partition Count = 4: đọc cả năm 2024, 2025 và partition rỗng từ 2027. Khoảng 45.873 page lá cho một tháng dữ liệu.
  4. Nested Loops. Vì tưởng phía ngoài chỉ 21.000 dòng, trình tối ưu chọn Nested Loops với seek vào ChiTietDonHang. Thực tế phía trong chạy 373.100 lần, mỗi lần khoảng 3 page: 1.123.186 trong tổng 1.169.141 logical reads nằm ở đây, dù phần trăm chi phí của nó tính theo 21.000 lần chạy và không trông như chỗ tốn nhất.

Sau khi sửa: điều kiện khoảng trên cả hai bảng

Thay hai hàm bằng điều kiện khoảng, và ghi điều kiện ngày trên cả hai bảng để partition elimination chạy ở cả hai phía, không phụ thuộc trình tối ưu có tự suy điều kiện qua phép nối hay không:

WHERE d.NgayTao >= CAST('20260901' AS datetime2(0))
  AND d.NgayTao <  CAST('20261001' AS datetime2(0))
  AND ct.NgayTao >= CAST('20260901' AS datetime2(0))
  AND ct.NgayTao <  CAST('20261001' AS datetime2(0))
  AND d.TrangThai <> 5

Vì sao điều kiện dạng khoảng dùng được chỉ mục còn YEAR(NgayTao) thì không, xem phần sargability ở Chỉ mục phủ và điều kiện seek được. Kế hoạch mới seek đúng partition 3 trên cả hai bảng và nối bằng Hash Match.

Câu đã sửa, kế hoạch thực tế và SET STATISTICS IO, TIME (minh họa)43 dòng
SELECT
    sp.SanPhamId,
    sp.MaSanPham,
    sp.Ten,
    SUM(ct.SoLuong * ct.DonGia) AS DoanhThu
FROM dbo.DonHang AS d
JOIN dbo.ChiTietDonHang AS ct
    ON ct.NgayTao = d.NgayTao
   AND ct.DonHangId = d.DonHangId
JOIN dbo.SanPham AS sp
    ON sp.SanPhamId = ct.SanPhamId
WHERE d.NgayTao >= CAST('20260901' AS datetime2(0))
  AND d.NgayTao <  CAST('20261001' AS datetime2(0))
  AND ct.NgayTao >= CAST('20260901' AS datetime2(0))
  AND ct.NgayTao <  CAST('20261001' AS datetime2(0))
  AND d.TrangThai <> 5
GROUP BY sp.SanPhamId, sp.MaSanPham, sp.Ten;

-- minh họa: điều kiện khoảng, DOP 4
SELECT                                          DOP 4   Granted 38.912 KB   MaxUsed 27.648 KB
  Parallelism (Gather Streams)
    Hash Match (Inner Join)                     ct.SanPhamId = sp.SanPhamId      est 5.000      act 4.986
      Clustered Index Scan PK_SanPham                                            est 5.000      act 5.000
      Hash Match (Aggregate)                    GROUP BY ct.SanPhamId            est 5.000      act 4.986
        Hash Match (Inner Join)                 ct.NgayTao = d.NgayTao AND ct.DonHangId = d.DonHangId
                                                                                 est 1.118.000  act 1.119.300
          Clustered Index Seek PK_DonHang       est 372.600   act 373.100   Rows Read 392.700
            Seek Keys: Prefix PtnId1000 = 3, Start NgayTao >= '2026-09-01', End NgayTao < '2026-10-01'
            Predicate: TrangThai <> 5
            Actual Partition Count 1            Actual Partitions Accessed 3
          Clustered Index Seek PK_ChiTietDonHang                     est 1.177.000  act 1.178.100
            Seek Keys: Prefix PtnId1000 = 3, Start NgayTao >= '2026-09-01', End NgayTao < '2026-10-01'
            Actual Partition Count 1            Actual Partitions Accessed 3

-- minh họa, rút gọn các cột bằng 0
Table 'ChiTietDonHang'. Scan count 5, logical reads 6151, physical reads 0, read-ahead reads 0.
Table 'DonHang'. Scan count 5, logical reads 1818, physical reads 0, read-ahead reads 0.
Table 'SanPham'. Scan count 1, logical reads 64, physical reads 0, read-ahead reads 0.
Table 'Worktable'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0.
Table 'Workfile'. Scan count 0, logical reads 0, physical reads 0, read-ahead reads 0.

 SQL Server Execution Times:
   CPU time = 641 ms,  elapsed time = 236 ms.
Lọc bằng YEAR()/MONTH() Lọc bằng khoảng ngày
Ước lượng DonHang 21.000 (đoán) 372.600 (histogram NgayTao)
Partition đọc 4 1, partition 3
Nối DonHang với ChiTietDonHang Nested Loops, 373.100 lần seek Hash Match, mỗi bảng đọc một lần
Logical reads 1.169.141 8.033
CPU / thời gian 3.891 ms / 1.204 ms 641 ms / 236 ms

Lọc bằng khoảng ngày: ChiTietDonHang từ 1.123.186 xuống 6.151 logical reads

YEAR()/MONTH()Khoảng ngày
ChiTietDonHang1.123.1866.151DonHang45.8911.818SanPham6464
Logical reads theo bảng từ SET STATISTICS IO của hai lần chạy minh họa ở trên. Thang log.
Bảng số liệu
YEAR()/MONTH()Khoảng ngày
ChiTietDonHang1.123.1866.151
DonHang45.8911.818
SanPham6464

Ước lượng đúng kéo theo kiểu nối đúng. Hai bảng đọc theo khóa đã sắp nên Merge Join cũng là ứng viên; với kế hoạch song song trên máy minh họa, trình tối ưu chọn Hash Match. Đọc chi phí trong kế hoạch của máy mình thay vì đoán. Memory grant của Hash Match nằm ở Memory grant và tràn tempdb, bản batch mode khi có columnstore ở Toán tử và Key Lookup.

6. Đọc một kế hoạch trong năm phút

  1. Lấy đúng kế hoạch. Kế hoạch thực tế của đúng câu, đúng giá trị tham số, đúng SET option ứng dụng dùng; không có thì sys.dm_exec_query_plan_stats, rồi cache hoặc Query Store.
  2. Nút gốc SELECT. QueryTimeStats, Degree of Parallelism, MemoryGrantInfo, Optimization Level và lý do dừng tối ưu, Parameter List, cảnh báo, WaitStats.
  3. Chỗ tốn thật. Tìm toán tử có ActualElapsedms riêng lớn nhất (row mode thì trừ phần của con) và có logical reads lớn nhất. Bỏ qua phần trăm chi phí.
  4. Chỗ lệch đầu tiên. Đi từ phải sang trái, tìm toán tử đầu tiên mà actual lệch estimated for all executions từ 10 lần trở lên.
  5. Tại chỗ lệch. Seek Predicate hay Predicate, Number of Rows Read, hàm bọc cột, CONVERT_IMPLICIT, biến cục bộ, thống kê của cột đó.
  6. Nested Loops. Number of Executions của phía trong. Key Lookup chạy hàng nghìn lần trở lên là ứng viên cho chỉ mục phủ (Toán tử và Key Lookup).
  7. Sort và Hash. Granted so với MaxUsed, cảnh báo spill (Memory grant và tràn tempdb).
  8. Song song. Số dòng theo từng thread, lệch tải.
  9. Lịch sử. Số plan_id trong Query Store, ParameterCompiledValue của kế hoạch hiện tại (Parameter sniffing và Query Store).
  10. Đổi một thứ. Sửa đúng một chỗ, chạy lại với SET STATISTICS IO, TIME ON, so với bản trước.

7. Áp dụng trong .NET: đo logical reads từ ứng dụng

Test hiệu năng dựa trên mili giây hay chập chờn vì thời gian đổi theo cache và tải; logical reads thì ổn định. Khi kết nối bật SET STATISTICS IO ON, SQL Server gửi mỗi dòng Table 'DonHang'. Scan count ..., logical reads ... về client dưới dạng info message, và SqlConnection.InfoMessage của Microsoft.Data.SqlClient nhận được chúng.

var dong = new Regex(@"Table '(?<bang>[^']+)'\. Scan count \d+, logical reads (?<reads>\d+)");
conn.InfoMessage += (_, e) =>
{
    foreach (SqlError m in e.Errors)
        if (dong.Match(m.Message) is { Success: true } k)
        {
            var bang = k.Groups["bang"].Value;
            reads[bang] = reads.GetValueOrDefault(bang) + long.Parse(k.Groups["reads"].Value);
        }
};
await new SqlCommand("SET STATISTICS IO ON;", conn).ExecuteNonQueryAsync();

await using (var r = await cmd.ExecuteReaderAsync())
{
    while (await r.ReadAsync()) soDong++;
    // Đọc tới cuối: không gọi NextResultAsync thì message sau dòng cuối bị bỏ
    while (await r.NextResultAsync()) { }
}
var tong = reads.Values.Sum();   // test: Assert.True(tong <= NganSach)

Dòng NextResultAsync là bắt buộc. Với Microsoft.Data.SqlClient 7.1.1, reader đóng ngay sau dòng cuối thì message STATISTICS IO đến sau đó không kích hoạt InfoMessage; đo thử ra 0 page. Dapper đọc hết các result set nên nhận đủ message. EF Core 10.0.12 thì không; với EF Core, đọc last_logical_reads từ sys.dm_exec_query_stats theo tag của câu, như mục .NET của Toán tử và Key Lookup.

Chạy hai phiên bản của ví dụ A trên database thử Kumeo_kehoach (bản thu nhỏ 1/10 của BanHang, script ở bài trước), bằng dotnet run DemReads.cs trên .NET 10.0.12 và LocalDB SQL Server 2019:

Phiên bản Logical reads tổng DonHang ChiTietDonHang SanPham
YEAR()/MONTH() 20.309 4.609 15.652 48
Khoảng ngày 854 185 621 48

Trên dữ liệu nhỏ này trình tối ưu không chọn Nested Loops mà quét hết hai bảng rồi nối bằng Merge Join. Kết luận vẫn như nhau: không seek được theo khoảng ngày thì đọc cả bảng, ở đây gấp khoảng 24 lần. Ngân sách 2.000 page trong chương trình đặt theo số đo của câu đúng cộng khoảng trống cho dữ liệu tăng; bản YEAR()/MONTH() vượt và test đỏ.

Toàn bộ chương trình, chạy bằng dotnet run DemReads.csC# · 62 dòng
#:package Microsoft.Data.SqlClient@7.1.1

using System.Text.RegularExpressions;
using Microsoft.Data.SqlClient;

const string Cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_kehoach;Integrated Security=true;TrustServerCertificate=true";
const long NganSach = 2_000;   // page, đặt theo số đo của câu đúng cộng khoảng trống cho dữ liệu tăng

const string LocBangHam = """
    SELECT sp.SanPhamId, sp.MaSanPham, sp.Ten, SUM(ct.SoLuong * ct.DonGia) AS DoanhThu
    FROM dbo.DonHang AS d
    JOIN dbo.ChiTietDonHang AS ct ON ct.NgayTao = d.NgayTao AND ct.DonHangId = d.DonHangId
    JOIN dbo.SanPham AS sp ON sp.SanPhamId = ct.SanPhamId
    WHERE YEAR(d.NgayTao) = 2026 AND MONTH(d.NgayTao) = 9 AND d.TrangThai <> 5
    GROUP BY sp.SanPhamId, sp.MaSanPham, sp.Ten;
    """;
const string LocBangKhoang = """
    SELECT sp.SanPhamId, sp.MaSanPham, sp.Ten, SUM(ct.SoLuong * ct.DonGia) AS DoanhThu
    FROM dbo.DonHang AS d
    JOIN dbo.ChiTietDonHang AS ct ON ct.NgayTao = d.NgayTao AND ct.DonHangId = d.DonHangId
    JOIN dbo.SanPham AS sp ON sp.SanPhamId = ct.SanPhamId
    WHERE d.NgayTao >= CAST('20260901' AS datetime2(0))
      AND d.NgayTao <  CAST('20261001' AS datetime2(0))
      AND ct.NgayTao >= CAST('20260901' AS datetime2(0))
      AND ct.NgayTao <  CAST('20261001' AS datetime2(0))
      AND d.TrangThai <> 5
    GROUP BY sp.SanPhamId, sp.MaSanPham, sp.Ten;
    """;

var dong = new Regex(@"Table '(?<bang>[^']+)'\. Scan count \d+, logical reads (?<reads>\d+)");
var reads = new Dictionary<string, long>();

await using var conn = new SqlConnection(Cs);
await conn.OpenAsync();
conn.InfoMessage += (_, e) =>
{
    foreach (SqlError m in e.Errors)
        if (dong.Match(m.Message) is { Success: true } k)
            reads[k.Groups["bang"].Value] = reads.GetValueOrDefault(k.Groups["bang"].Value) + long.Parse(k.Groups["reads"].Value);
};
await new SqlCommand("SET STATISTICS IO ON;", conn).ExecuteNonQueryAsync();
Console.WriteLine($".NET {Environment.Version}, SQL Server {conn.ServerVersion} (LocalDB)");

foreach (var (ten, sql) in new[] { ("YEAR()/MONTH()", LocBangHam), ("Khoảng ngày", LocBangKhoang) })
{
    var soDong = 0;
    for (var lan = 1; lan <= 2; lan++)   // lần 1 làm nóng cache, giữ số của lần 2
    {
        reads.Clear();
        soDong = 0;
        await using var cmd = new SqlCommand(sql, conn);
        await using (var r = await cmd.ExecuteReaderAsync())
        {
            while (await r.ReadAsync()) soDong++;
            while (await r.NextResultAsync()) { }   // đọc tới cuối, nếu không message sau dòng cuối bị bỏ
        }
    }
    var tong = reads.Values.Sum();   // trong test tích hợp: Assert.True(tong <= NganSach)
    var theoBang = string.Join(", ", reads.Where(kv => kv.Value > 0).Select(kv => $"{kv.Key} {kv.Value}"));
    Console.WriteLine($"{ten,-15} {soDong} dòng, logical reads {tong,6} ({theoBang}) " +
                      (tong <= NganSach ? "trong ngân sách" : $"VƯỢT ngân sách {NganSach}"));
}

Những chỗ hay hiểu sai

  • "Toán tử 60% là chỗ chậm nhất." Phần trăm là chi phí ước lượng, kể cả trong kế hoạch thực tế. Chỗ chậm đọc ở ActualElapsedms và logical reads.
  • "ActualElapsedms là thời gian riêng của toán tử." Ở row mode con số đó cộng cả các toán tử con. Ở batch mode mới là thời gian riêng.
  • "So mili giây là đủ để biết bản sửa tốt hơn." Thời gian đổi theo cache và tải. So logical reads, rồi mới xem CPU và thời gian.
  • "Bật STATISTICS IO rồi gắn InfoMessage là đủ." Phải đọc reader tới cuối bằng NextResultAsync, nếu không các dòng Table '...' có thể không tới.

Kết luận

Tìm chỗ ước lượng lệch đầu tiên và sửa ở đó; phần trăm chi phí không chỉ ra chỗ tốn. Logical reads là thước đo ổn định để so hai phiên bản của một câu, và đo được ngay trong code .NET.

Trong dự án .NET của bạn:

  • Viết test tích hợp cho các truy vấn báo cáo nặng: bật SET STATISTICS IO ON, cộng logical reads qua SqlConnection.InfoMessage, assert theo một ngân sách page.
  • Đọc reader tới cuối bằng NextResultAsync mỗi khi cần info message; với EF Core, lấy số reads từ sys.dm_exec_query_stats.
  • Tìm trong code LINQ và SQL các điều kiện bọc cột bằng hàm (.Year, .Month, .Date, YEAR()); đổi sang điều kiện khoảng >= và <.

Đọc tiếp

Nguồn

Đọc tiếp

Bài tiếp theo trong series

Toán tử trong kế hoạch thực thi và Key Lookup

Mỗi toán tử hay gặp trong kế hoạch thực thi được chọn khi nào và tốn ở đâu, Key Lookup đắt dần theo số dòng ra sao, và projection trong EF Core quyết định có Key Lookup hay không.

15 phút đọc

Trong SQL Server

Thống kê, histogram và ngưỡng tự cập nhật

Histogram, density vector, ước lượng số dòng và ngưỡng tự cập nhật thống kê trên DonHang 10 triệu dòng, vì sao đơn hôm nay nằm ngoài histogram, và worker .NET giữ thống kê partition năm nay luôn mới.

12 phút đọc

Trong SQL Server

Memory grant, tràn tempdb và song song

Sort và Hash Match xin bộ nhớ thế nào, đọc MemoryGrantInfo và cảnh báo spill ra sao, kế hoạch song song cần nhìn gì, và bắt spill bằng test C# trước khi lên production.

11 phút đọc