Cơ sở dữ liệuSQL Server, phần 14/24
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.
Trình tối ưu có thể ước lượng 50 dòng cho một đại lý có 300.000 đơn, rồi chọn đường seek cộng key lookup đắt gấp 20 lần quét cả bảng. Con số ước lượng đến từ thống kê, mà thống kê của DonHang chỉ tự làm mới sau khoảng một tuần đơn mới. Đọc xong, bạn đọc được histogram, biết khi nào thống kê bị coi là cũ, và viết worker .NET giữ thống kê partition năm nay luôn mới.
Đọc nhanh
- Histogram chỉ mô tả cột đầu của chỉ mục, tối đa 200 bước; density vector mô tả mọi tiền tố cột khóa.
- Hằng số và tham số lấy ước lượng từ histogram, còn biến cục bộ dùng mật độ trung bình, nên đại lý lớn bị ước lượng như khách thường.
- Thống kê của
DonHangtự cập nhật sau khoảng một tuần đơn mới, và chỉ khi có câu cần nó được biên dịch. - Đơn mới hơn bước cuối của histogram luôn bị đoán số dòng, nên một job đêm cập nhật thống kê partition năm nay.
1. Thống kê gồm gì
Bài đầu của chương cho thấy kế hoạch đúng hay sai phụ thuộc vào số dòng ước lượng: dưới khoảng 15.300 dòng thì seek cộng key lookup rẻ hơn, trên đó quét rẻ hơn. Số dòng ước lượng đến từ thống kê. Mốc là SQL Server 2019, compatibility level 150, dbo.DonHang 10 triệu dòng phân vùng theo năm, với PK_DonHang (NgayTao, DonHangId) và IX_DonHang_KhachHang (KhachHangId, NgayTao).
Mỗi chỉ mục có một đối tượng thống kê trên các cột khóa. AUTO_CREATE_STATISTICS tạo thêm thống kê một cột, tên bắt đầu bằng _WA_Sys_, cho cột xuất hiện trong điều kiện mà chưa có histogram. Một đối tượng thống kê gồm ba phần:
| Phần | Nội dung |
|---|---|
| Header | Thời điểm cập nhật, số dòng lúc đó, số dòng lấy mẫu, số bước histogram |
| Density vector | Mật độ = 1 / số giá trị phân biệt, cho từng tiền tố cột: (KhachHangId), (KhachHangId, NgayTao), (KhachHangId, NgayTao, DonHangId) |
| Histogram | Phân bố của cột đầu tiên, tối đa 200 bước |
Mỗi bước histogram có RANGE_HI_KEY (giá trị cận trên), EQ_ROWS (số dòng bằng đúng cận trên), RANGE_ROWS (số dòng nằm giữa cận trên của bước trước và cận này), DISTINCT_RANGE_ROWS (số giá trị phân biệt trong khoảng đó) và AVG_RANGE_ROWS = RANGE_ROWS / DISTINCT_RANGE_ROWS.
Xem thống kê của IX_DonHang_KhachHang
DBCC SHOW_STATISTICS cho header và density vector; sys.dm_db_stats_properties cho số dòng và bộ đếm thay đổi của mọi thống kê trên bảng; sys.dm_db_stats_histogram, có từ SQL Server 2016 SP1 CU2, trả histogram dạng bảng. Cột range_high_key có kiểu sql_variant, nên phải CAST trước khi so với số.
Header, density vector, bộ đếm thay đổi và histogram của IX_DonHang_KhachHang
DBCC SHOW_STATISTICS (N'dbo.DonHang', IX_DonHang_KhachHang)
WITH STAT_HEADER, DENSITY_VECTOR;
SELECT
s.name AS stats_name, sp.last_updated, sp.rows, sp.rows_sampled,
sp.steps, sp.modification_counter
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE s.object_id = OBJECT_ID(N'dbo.DonHang');
SELECT
h.step_number, h.range_high_key, h.range_rows, h.equal_rows,
h.distinct_range_rows, h.average_range_rows
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_histogram(s.object_id, s.stats_id) AS h
WHERE s.object_id = OBJECT_ID(N'dbo.DonHang')
AND s.name = N'IX_DonHang_KhachHang'
AND CAST(h.range_high_key AS int) <= 2000;
Density vector minh họa, giả sử cả 200.000 khách đều có đơn:
| All density | Average Length | Columns |
|---|---|---|
| 5E-06 | 4 | KhachHangId |
| 1E-07 | 10 | KhachHangId, NgayTao |
| 1E-07 | 18 | KhachHangId, NgayTao, DonHangId |
1 / 200.000 = 5E-06. Hai tiền tố sau gần như duy nhất, nên mật độ xấp xỉ 1 / 10.000.000. Average Length 4, 10, 18 byte khớp với phần khóa của chỉ mục, gồm cả row locator DonHangId.
Ba bước histogram minh họa quanh khách 42. Bước trước đó có cận trên là khách 7.
| RANGE_HI_KEY | RANGE_ROWS | EQ_ROWS | DISTINCT_RANGE_ROWS | AVG_RANGE_ROWS |
|---|---|---|---|---|
| 41 | 1.520 | 47 | 33 | 46,06 |
| 42 | 0 | 300.120 | 0 | 1 |
| 1.105 | 52.800 | 51 | 1.062 | 49,72 |
Bước 41 phủ khách 8 đến 40, tức 33 giá trị: 1.520 / 33 = 46,06. Bước 1.105 phủ khách 43 đến 1.104, tức 1.062 giá trị: 52.800 / 1.062 = 49,72. Thuật toán dựng histogram chọn cận sao cho giữ được nhiều thông tin nhất, nên một giá trị lệch hẳn như khách 42 thường có bước riêng. Không có gì bảo đảm điều đó, và với histogram lấy mẫu, các số là ước lượng.
2. Từ histogram ra số dòng ước lượng
Cùng một điều kiện KhachHangId = ..., trình tối ưu ước lượng theo ba đường:
| Cách viết | Nguồn ước lượng | Số dòng ước lượng |
|---|---|---|
KhachHangId = 42 hằng số, hoặc tham số biên dịch với 42 |
EQ_ROWS của bước 42 |
300.120 |
KhachHangId = 77 |
AVG_RANGE_ROWS của bước 1.105 |
49,72 |
KhachHangId = @x với @x là biến cục bộ |
All density × số dòng = 5E-06 × 10.000.000 | 50 |
Khách 77 thật ra có 20 đơn. 49,72 so với 20 vẫn là "ít", nên kế hoạch vẫn đúng. Với biến cục bộ, khách 42 được ước lượng 50 dòng, và trình tối ưu chọn seek cộng 300.000 lookup.
Qua biến cục bộ, khách 42 được ước lượng 50 dòng trong khi thực tế khoảng 300.000
Bảng số liệu
| Ước lượng | Thực tế | |
|---|---|---|
| KhachHangId = 42 | 300.120 | 300.000 |
| KhachHangId = 77 | 49,72 | 20 |
| @x = 42, biến cục bộ | 50 | 300.000 |
Cột chỉ có vài giá trị cho histogram rõ hơn nữa. Thống kê tự tạo trên TrangThai, sau lần đầu một câu lọc theo cột này, thường có 5 bước với EQ_ROWS khoảng 100.000, 200.000, 200.000, 9.000.000 và 500.000. TrangThai = 4 ước lượng 90% bảng và không bao giờ đáng seek. TrangThai = 1 ước lượng 1%, và chỉ mục lọc IX_DonHang_DangMo ở bài trước phục vụ đúng phần đó.
3. Khi nào thống kê tự cập nhật
AUTO_UPDATE_STATISTICS đếm số thay đổi trên cột đầu của thống kê kể từ lần cập nhật trước (modification_counter) và so với một ngưỡng tính theo số dòng của bảng. Vượt ngưỡng thì thống kê bị coi là cũ, nhưng chỉ được cập nhật khi một câu cần đến nó được biên dịch, hoặc trước khi chạy một kế hoạch đã lưu có dùng nó.
flowchart TB
B["Ghi làm đổi cột đầu: modification_counter tăng"] --> C{"Vượt ngưỡng?"}
C -->|"Chưa"| D["Thống kê giữ nguyên"]
C -->|"Rồi"| E["Bị coi là cũ"]
E -->|"câu cần nó được biên dịch"| G["Cập nhật thống kê, rồi biên dịch tiếp"]
| Compatibility level | Ngưỡng với n > 500 dòng | n = 10.000.000 |
|---|---|---|
| Dưới 130 | 500 + 0,20 × n | 2.000.500 thay đổi |
| Từ 130, SQL Server 2016 trở đi | MIN(500 + 0,20 × n, √(1.000 × n)) | MIN(2.000.500; 100.000) = 100.000 thay đổi |
√(1.000 × 10.000.000) = √10¹⁰ = 100.000. Năm 2026 có 3.600.000 đơn trong 274 ngày, khoảng 13.100 đơn mỗi ngày. Mỗi đơn mới là một thay đổi trên NgayTao, cột đầu của PK_DonHang, và trên KhachHangId, cột đầu của IX_DonHang_KhachHang. Hai thống kê đó tự cập nhật khoảng 100.000 / 13.100 ≈ 7,6 ngày một lần.
Ở compatibility dưới 130 là 2.000.500 / 13.100 ≈ 152 ngày. Từ SQL Server 2008 R2 đến 2014, hoặc trên bản mới hơn với compatibility từ 120 trở xuống, trace flag 2371 bật ngưỡng động. UPDATE cột TrangThai không cộng vào bộ đếm của hai thống kê này, vì TrangThai không phải cột đầu của chúng.
Thống kê là của cả bảng, không của từng partition: ngưỡng tính trên n = 10 triệu, không phải 3,6 triệu dòng của partition đang nhận đơn. Hai điểm phân vùng làm khác đi:
- Từ SQL Server 2014, tạo hoặc rebuild chỉ mục phân vùng dựng thống kê bằng mẫu mặc định, không quét đủ. Muốn quét đủ trên
DonHang, chạyUPDATE STATISTICS ... WITH FULLSCAN. - Thống kê
INCREMENTAL = ONgiữ phần thống kê theo từng partition, cập nhật riêng partition cần rồi gộp lại thành thống kê chung mà trình tối ưu dùng.
-- Một lần, trong cửa sổ bảo trì: chuyển sang thống kê theo partition
UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang)
WITH FULLSCAN, INCREMENTAL = ON;
-- Hằng đêm: chỉ đọc lại partition năm 2026 rồi gộp
UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang)
WITH RESAMPLE ON PARTITIONS (3);
ON PARTITIONS bắt buộc đi với RESAMPLE, vì phần thống kê lấy mẫu ở các tỷ lệ khác nhau không gộp được. Không áp cho IX_DonHang_DangMo, vì thống kê của chỉ mục lọc không có dạng incremental. Kiểm tra cột is_incremental trong sys.stats sau mỗi lần rebuild chỉ mục: rebuild không ghi STATISTICS_INCREMENTAL = ON có thể đưa thống kê về dạng thường. Ngưỡng ở bảng trên là của thống kê thường. Trên bảng thử ở mục 5, sau khi chuyển sang INCREMENTAL = ON, thống kê tự cập nhật sớm hơn: 25.000 thay đổi trong partition năm nay đã đủ, 17.000 thì chưa. Tài liệu không nêu ngưỡng cho dạng này, nên đo trên máy của bạn.
4. Đơn hôm nay nằm ngoài histogram
Giả sử thống kê của PK_DonHang cập nhật lần cuối lúc 03:00 ngày 2026-09-25. Bước cuối của histogram trên NgayTao dừng ở khoảng 02:59 hôm đó. Đến trưa 2026-10-02 đã có thêm khoảng 7 ngày × 13.100 ≈ 92.000 đơn, vẫn dưới ngưỡng 100.000, nên thống kê chưa bị coi là cũ. Mọi đơn đó nằm sau bước cuối.
SELECT DonHangId, KhachHangId, TongTien
FROM dbo.DonHang
WHERE NgayTao >= CAST('20261001' AS datetime2(0));
Câu này trả về khoảng 19.700 dòng (một ngày rưỡi × 13.100), toàn bộ nằm ngoài histogram. Đây là bài toán khóa tăng dần (ascending key):
- CE cũ (phiên bản 70) coi giá trị sau bước cuối là gần như không tồn tại và thường ước lượng 1 dòng. Kế hoạch dễ chọn nested loops và lookup, và cấp bộ nhớ quá ít cho phép sắp hoặc phép hash phía sau.
- Từ CE 120 (SQL Server 2014), trình tối ưu giả định cột tăng dần có thể có giá trị lớn hơn bước cuối, và cho một ước lượng lớn hơn hẳn 1. Con số cụ thể tùy phiên bản CE: đọc
Estimated Number of Rowstrong kế hoạch thay vì đoán.
Cách chắc chắn là để bước cuối không cũ quá một ngày: job cập nhật thống kê partition 3 hằng đêm như ở mục 3.
Phiên bản CE đi theo compatibility level: từ 110 trở xuống là CE 70; từ 120 trở lên CE mang cùng số với compatibility, nên BanHang ở compatibility 150 dùng CE 150. Khi một câu chậm đi sau khi nâng compatibility, có thể quay về CE cũ cho cả database bằng ALTER DATABASE SCOPED CONFIGURATION SET LEGACY_CARDINALITY_ESTIMATION = ON, hoặc cho một câu bằng OPTION (USE HINT ('FORCE_LEGACY_CARDINALITY_ESTIMATION')) từ SQL Server 2016 SP1. Bật Query Store trước khi đổi để thấy câu nào đổi kế hoạch, như chương kỹ thuật đã nói.
5. Áp dụng trong .NET
Job đêm ở mục 3 hợp với một BackgroundService trong worker của BanHang: PeriodicTimer 24 giờ, mỗi lần tìm partition chứa ngày hôm nay rồi đọc lại riêng partition đó.
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(24));
do
{
await using var conn = new SqlConnection(cfg.ConnectionString);
await conn.OpenAsync(ct);
// Mỗi đêm: chỉ đọc lại partition chứa ngày hôm nay rồi gộp
var p = await conn.ExecuteScalarAsync<int>(
"SELECT $PARTITION.pf_DonHang_Ngay(CAST(SYSDATETIME() AS datetime2(0)))");
await conn.ExecuteAsync(
$"UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang) WITH RESAMPLE ON PARTITIONS ({p})",
commandTimeout: 3600);
} while (await timer.WaitForNextTickAsync(ct));
}
Bản đầy đủ thêm bước chuyển sang INCREMENTAL = ON ở lần chạy đầu, và một hàm đọc sys.dm_db_stats_properties rồi tính ngưỡng Math.Min(500 + 0.2 * rows, Math.Sqrt(1000.0 * rows)) để báo thống kê đã đi được bao nhiêu phần đường tới lần tự cập nhật.
Chạy trên LocalDB SQL Server 2019 (15.0.4382, compatibility 150), .NET 10.0.401, Dapper 2.1.66, Microsoft.Data.SqlClient 6.1.4, Microsoft.Extensions.Hosting 10.0.0. Bảng thử 1 triệu đơn dựng ở bài đầu của chương có ngưỡng √(1.000 × 1.000.000) ≈ 31.623 thay đổi. Chương trình thêm đơn mới 1.000 đơn mỗi ngày từ 2026-10-02, sau bước cuối của histogram, rồi đọc ước lượng của câu "đơn từ 2026-10-26" bằng SET SHOWPLAN_XML ON.
-- Mốc, vừa FULLSCAN
PK_DonHang 1,000,000 dòng đổi 0/31,623 0% lúc 22:14:38
IX_DonHang_KhachHang 1,000,000 dòng đổi 0/31,623 0% lúc 22:14:40
Sau 31.000 đơn mới: đơn từ 2026-10-26 ước lượng 9,300 dòng, thực tế 7,000
-- Sau 31.000 đơn mới
PK_DonHang 1,000,000 dòng đổi 31,000/31,623 98% lúc 22:14:38
IX_DonHang_KhachHang 1,000,000 dòng đổi 31,000/31,623 98% lúc 22:14:40
Sau 32.000 đơn mới: đơn từ 2026-10-26 ước lượng 7,950 dòng, thực tế 8,000
-- Sau 32.000 đơn mới
PK_DonHang 1,032,000 dòng đổi 0/32,125 0% lúc 22:14:46
IX_DonHang_KhachHang 1,000,000 dòng đổi 32,000/31,623 101% lúc 22:14:40
-- Sau 1.000 đơn nữa và job đêm
PK_DonHang 1,033,000 dòng đổi 0/32,140 0% lúc 22:14:56
IX_DonHang_KhachHang 1,033,000 dòng đổi 0/32,140 0% lúc 22:14:57
Trước ngưỡng, đơn mới nằm ngoài histogram và chỉ được đoán; qua ngưỡng, ước lượng bám thực tế
Bảng số liệu
| Ước lượng | Thực tế | |
|---|---|---|
| 31.000 đơn mới, thống kê chưa cập nhật | 9.300 | 7.000 |
| 32.000 đơn mới, đã tự cập nhật | 7.950 | 8.000 |
- 31.000 thay đổi, 98% ngưỡng: thống kê chưa cập nhật. Đơn từ 2026-10-26 nằm ngoài histogram, CE 150 đoán 9.300 dòng cho 7.000 dòng thật. Không phải 1 dòng như CE 70 thường cho, nhưng vẫn là đoán.
- 32.000 thay đổi vượt 31.623: lần biên dịch câu trên
NgayTaocập nhậtPK_DonHang, và ước lượng thành 7.950 cho 8.000. Tự cập nhật dùng mẫu, nên con số này xê dịch giữa các lần chạy (các lần trước ra 7.410 và 7.540).IX_DonHang_KhachHangđứng ở 101% vì chưa câu nào cần nó được biên dịch. - Job đêm đưa cả hai về 0 thay đổi, không chờ ngưỡng.
Dapper sinh code lúc chạy, nên dưới PublishAot mà file-based app bật mặc định, nó ném PlatformNotSupportedException; đầu file vì vậy có #:property PublishAot=false.
ThongKe.cs: giả lập đơn mới, đo ngưỡng và ước lượng, rồi chạy worker một lần; dotnet run ThongKe.cs
#:package Dapper@2.1.66
#:package Microsoft.Data.SqlClient@6.1.4
#:package Microsoft.Extensions.Hosting@10.0.0
#:property PublishAot=false
// Giả lập đơn mới đổ vào DonHang, xem ngưỡng tự cập nhật thống kê và ước lượng của đơn mới,
// rồi chạy worker cập nhật thống kê partition năm nay.
// Chạy: dotnet run ThongKe.cs (cần database Kumeo_chimuc đã dựng ở bài 1)
using System.Text.RegularExpressions;
using Dapper;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
const string cs = @"Server=(localdb)\MSSQLLocalDB;Database=Kumeo_chimuc;Integrated Security=true;TrustServerCertificate=true";
await using var conn = new SqlConnection(cs);
await conn.OpenAsync();
// Mốc: thống kê thường (không incremental), vừa quét đủ
await conn.ExecuteAsync("DELETE dbo.DonHang WHERE NgayTao >= '20261002'");
await conn.ExecuteAsync("UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang) WITH FULLSCAN, INCREMENTAL = OFF", commandTimeout: 600);
await BaoCao("Mốc, vừa FULLSCAN");
// 31 ngày đơn mới, mỗi ngày 1.000 đơn, từ 2026-10-02
await ThemDon(ngayDau: new DateTime(2026, 10, 2), soNgay: 31);
await UocLuong("Sau 31.000 đơn mới");
await BaoCao("Sau 31.000 đơn mới");
// Thêm một ngày nữa: vượt ngưỡng, lần biên dịch tiếp theo tự cập nhật
await ThemDon(ngayDau: new DateTime(2026, 11, 2), soNgay: 1);
await UocLuong("Sau 32.000 đơn mới");
await BaoCao("Sau 32.000 đơn mới");
// Worker chạy một lần như job đêm
var builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddSingleton(new CauHinhThongKe(cs, ChayMotLan: true));
builder.Services.AddHostedService<CapNhatThongKe>();
await ThemDon(ngayDau: new DateTime(2026, 11, 3), soNgay: 1);
await builder.Build().RunAsync();
await BaoCao("Sau 1.000 đơn nữa và job đêm");
// Dọn dữ liệu giả lập, trả thống kê về dạng thường
await conn.ExecuteAsync("DELETE dbo.DonHang WHERE NgayTao >= '20261002'");
await conn.ExecuteAsync("UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang) WITH FULLSCAN, INCREMENTAL = OFF", commandTimeout: 600);
async Task ThemDon(DateTime ngayDau, int soNgay) => await conn.ExecuteAsync("""
WITH so AS (SELECT TOP (@n) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS i
FROM sys.all_columns a CROSS JOIN sys.all_columns b)
INSERT dbo.DonHang (DonHangId, NgayTao, KhachHangId, TrangThai, TongTien)
SELECT (SELECT MAX(DonHangId) FROM dbo.DonHang) + i,
DATEADD(second, CAST((i - 1) * 86.4 AS int), @ngayDau),
1 + (i * 7919) % 48500, 1, 250000
FROM so;
""", new { n = soNgay * 1000, ngayDau });
async Task UocLuong(string nhan)
{
const string q = "SELECT DonHangId, KhachHangId, TongTien FROM dbo.DonHang WHERE NgayTao >= '20261026'";
await conn.ExecuteAsync("SET SHOWPLAN_XML ON");
var plan = await conn.QuerySingleAsync<string>(q);
await conn.ExecuteAsync("SET SHOWPLAN_XML OFF");
var uoc = double.Parse(Regex.Match(plan, "StatementEstRows=\"([^\"]+)\"").Groups[1].Value,
System.Globalization.CultureInfo.InvariantCulture);
var that = await conn.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM dbo.DonHang WHERE NgayTao >= '20261026'");
Console.WriteLine($"{nhan}: đơn từ 2026-10-26 ước lượng {uoc:N0} dòng, thực tế {that:N0}");
}
async Task BaoCao(string nhan)
{
Console.WriteLine($"-- {nhan}");
foreach (var t in await CapNhatThongKe.DocThongKe(conn))
Console.WriteLine($" {t.Ten,-20} {t.Rows,9:N0} dòng đổi {t.DaDoi,6:N0}/{t.Nguong,6:N0} {t.DaDoi / t.Nguong,5:P0} lúc {t.LanCuoi:HH:mm:ss}");
}
record CauHinhThongKe(string ConnectionString, bool ChayMotLan);
record TinhTrang(string Ten, long Rows, long DaDoi, DateTime LanCuoi)
{
// Compatibility level từ 130: MIN(500 + 0,2n; căn bậc hai của 1.000n)
public double Nguong => Math.Min(500 + 0.2 * Rows, Math.Sqrt(1000.0 * Rows));
}
class CapNhatThongKe(CauHinhThongKe cfg, IHostApplicationLifetime app) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
using var timer = new PeriodicTimer(TimeSpan.FromHours(24));
do
{
await using var conn = new SqlConnection(cfg.ConnectionString);
await conn.OpenAsync(ct);
// Lần đầu: chuyển sang thống kê theo partition (quét đủ một lần)
var incremental = await conn.ExecuteScalarAsync<bool>(
"SELECT is_incremental FROM sys.stats WHERE object_id = OBJECT_ID(N'dbo.DonHang') AND name = N'PK_DonHang'");
if (!incremental)
await conn.ExecuteAsync("UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang) WITH FULLSCAN, INCREMENTAL = ON",
commandTimeout: 3600);
// Mỗi đêm: chỉ đọc lại partition chứa ngày hôm nay rồi gộp
var p = await conn.ExecuteScalarAsync<int>("SELECT $PARTITION.pf_DonHang_Ngay(CAST(SYSDATETIME() AS datetime2(0)))");
await conn.ExecuteAsync($"UPDATE STATISTICS dbo.DonHang (PK_DonHang, IX_DonHang_KhachHang) WITH RESAMPLE ON PARTITIONS ({p})",
commandTimeout: 3600);
if (cfg.ChayMotLan) { app.StopApplication(); return; }
} while (await timer.WaitForNextTickAsync(ct));
}
public static async Task<IEnumerable<TinhTrang>> DocThongKe(SqlConnection conn) =>
await conn.QueryAsync<TinhTrang>("""
SELECT s.name AS Ten, sp.rows AS Rows, sp.modification_counter AS DaDoi, sp.last_updated AS LanCuoi
FROM sys.stats AS s
CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) AS sp
WHERE s.object_id = OBJECT_ID(N'dbo.DonHang') AND s.name IN (N'PK_DonHang', N'IX_DonHang_KhachHang')
""");
}
Những chỗ hay hiểu sai
- "Thống kê tự cập nhật sau 20% thay đổi." Từ compatibility 130, bảng 10 triệu dòng cập nhật sau 100.000 thay đổi, tức 1%.
- "Vượt ngưỡng là thống kê được cập nhật ngay." Vượt ngưỡng chỉ làm thống kê bị coi là cũ. Nó được cập nhật khi có câu cần nó được biên dịch.
- "Rebuild chỉ mục luôn cập nhật thống kê bằng quét đủ." Với chỉ mục phân vùng, từ SQL Server 2014, rebuild dùng mẫu mặc định.
- "Đổi
TrangThailàm thống kê củaIX_DonHang_KhachHangcũ đi." Bộ đếm chỉ tính thay đổi trên cột đầu của thống kê.
Kết luận
Kế hoạch chỉ tốt bằng số dòng ước lượng, và số đó chỉ mới bằng lần cập nhật thống kê gần nhất. Với bảng nhận đơn liên tục, đừng chờ ngưỡng tự cập nhật: làm mới phần đang nhận dữ liệu mỗi đêm.
Trong dự án .NET của bạn:
- Giữ
AUTO_UPDATE_STATISTICSbật và compatibility từ 130. Theo dõimodification_counterso với ngưỡng √(1.000 × n) quasys.dm_db_stats_properties. - Chạy job đêm bằng
BackgroundServicevớiPeriodicTimer:UPDATE STATISTICS ... WITH RESAMPLE ON PARTITIONS (p)cho partition năm nay, vàUPDATE STATISTICSsau mỗi lần nạp lớn. - Sau mỗi lần rebuild chỉ mục, kiểm
is_incrementaltrongsys.stats. - Truy vấn EF Core gửi giá trị qua tham số của
sp_executesql, nên ước lượng theo histogram của giá trị lúc biên dịch. Biến cục bộ trong stored procedure thì cho mọi khách cùng một ước lượng theo mật độ. - Bật Query Store trước khi nâng compatibility level hoặc quay về CE cũ.
Đọc tiếp
- Chỉ mục lọc, cái giá khi ghi và chỉ mục thừa: bài trước.
- Giao dịch và XACT_ABORT: bài tiếp theo trong series.
- Parameter sniffing và Query Store: parameter sniffing, PSP của SQL Server 2022.
- Memory grant và tràn tempdb: memory grant khi ước lượng sai.
- Điều tra truy vấn chậm, phần 2: thống kê cũ sau một đợt nạp lớn, từ triệu chứng đến cách sửa.
- Chỉ mục B-tree, seek, scan và key lookup: giá của key lookup mà ước lượng sai kéo theo.