Hình dung một bếp trưởng chuẩn bị tiệc theo số khách được báo. Báo "ba người" thì kê một bàn nhỏ, nấu ba phần. Báo "năm trăm người" thì thuê cả sảnh, gọi thêm đầu bếp, đặt năm trăm suất. Bây giờ tưởng tượng con số bị báo sai một trăm nghìn lần: mọi quyết định phía sau — bàn ghế, thức ăn, nhân sự — đều sai thảm hại, không phải vì quyết định nào tồi, mà vì con số đầu vào sai. Bộ tối ưu của PostgreSQL là ông bếp trưởng ấy, và con số đầu vào của nó là ước lượng số dòng. Phần 8 chỉ ra cách đọc từng con số trong kế hoạch. Bài này tập trung vào một con số duy nhất: chênh lệch giữa rows dự đoán và rows thật, vì gần như mọi truy vấn chậm bất thường đều bắt đầu từ đó.

Bảng thử là 2.500.000 đơn hàng.

Vì sao ước lượng quan trọng: nó quyết định kế hoạch

Cùng một câu truy vấn, chỉ đổi giá trị lọc:

Tỉ lệ dòng khớp Kế hoạch được chọn Thời gian Số trang đụng tới
0,10% Index Only Scan 4,5 ms 2.005
9,90% Bitmap Heap Scan 20,4 ms 14.875
90,00% Seq Scan 58,3 ms 14.706

Ba kế hoạch hoàn toàn khác nhau, và bộ tối ưu chọn đúng cả ba lần — vì nó biết mỗi giá trị chiếm bao nhiêu phần trăm bảng.

Để ý dòng giữa: ở mức 9,9%, đường đi qua chỉ mục phải đụng 14.875 trang, còn quét toàn bộ bảng ở mức 90% chỉ đụng 14.706 trang. Dùng chỉ mục đọc nhiều trang hơn quét thẳng — vì mỗi dòng tìm được lại phải nhảy sang một trang dữ liệu khác. Đây là nền cho phần sau về ngưỡng của Seq Scan.

Bây giờ hãy tưởng tượng bộ tối ưu tin rằng 90% kia chỉ là 0,1%. Nó sẽ chọn Index Scan và nhảy ngẫu nhiên 1,8 triệu lần.

Nguồn sai thứ nhất: thống kê cột đã cũ

Tôi chèn 500.000 dòng mang giá trị 'TinhMoi' vào một cột đã có thống kê, rồi không chạy ANALYZE:

Dự đoán Thực tế Lệch
Chưa ANALYZE 3 dòng 500.001 166.667 lần
Sau ANALYZE 630.105 500.001 26%

Ba dòng. Bộ tối ưu tin rằng giá trị đó gần như không tồn tại, vì trong ảnh chụp thống kê cũ nó thật sự không tồn tại.

Đây là kịch bản quen thuộc sau một lần nạp dữ liệu lớn, một migration, hoặc một chiến dịch làm lượng đơn tăng vọt trong một ngày. autovacuum sẽ tự chạy ANALYZE, nhưng nó chạy theo ngưỡng — mặc định khi số dòng thay đổi vượt 10% bảng — nên có một khoảng trống giữa lúc dữ liệu đổi và lúc thống kê bắt kịp.

Sau khi nạp dữ liệu lớn, hãy ANALYZE ngay thay vì chờ.

Nguồn sai thứ hai: hàm bọc quanh cột

WHERE extract(year from ngay) = 2026
Dự đoán Thực tế Lệch
Có hàm bọc 15.624 1.157.598 74 lần
Viết thành khoảng 633.228 502.740 26%

Thống kê của PostgreSQL gắn với cột, không gắn với biểu thức. Khi bạn bọc cột trong một hàm, mọi thứ nó biết về phân bố giá trị trở nên vô dụng và nó rơi về một hằng số ước lượng.

Cách viết lại:

WHERE ngay >= '2026-01-01' AND ngay < '2027-01-01'

Đo được: 136,0 ms xuống 93,7 ms, nhanh hơn 1,45 lần.

Nhưng phải nói cho đúng: trong phép đo của tôi, cả hai cách đều chọn Hash Join — kế hoạch không đổi. Khoản 42 mili giây tiết kiệm được đến từ việc không phải gọi extract() cho từng dòng trong số 2,5 triệu dòng, chứ không phải từ việc chọn được kế hoạch tốt hơn. Ở quy mô lớn hơn hoặc với join phức tạp hơn, ước lượng lệch 74 lần có thể làm đổi kế hoạch, nhưng tôi không tái hiện được điều đó ở đây.

Nếu buộc phải giữ biểu thức, có thể tạo chỉ mục biểu thức — khi đó PostgreSQL thu thập thống kê cho chính biểu thức đó. Phần 14 sẽ đo.

Nguồn sai thứ ba: LIKE — nhẹ hơn tưởng

Điều kiện Dự đoán Thực tế Lệch
tinh = 'Tinh7' 36.876 31.746 16%
tinh LIKE 'Tinh7%' 36.876 31.746 16%
tinh LIKE '%7' 237.501 190.476 25%

LIKE với ký tự đại diện ở đầu không phá ước lượng — nó chỉ lệch 25%, tốt hơn nhiều so với danh tiếng của nó. PostgreSQL dùng biểu đồ phân bố để đoán, và đoán khá tốt.

Cái '%7' thật sự làm hỏng là khả năng dùng chỉ mục B-tree, không phải ước lượng. Đó là hai vấn đề khác nhau và người ta hay gộp làm một.

Điều bộ tối ưu vẫn làm đúng dù thống kê rỗng

Tôi tạo một bảng mới, ANALYZE lúc nó còn rỗng, rồi chèn 800.000 dòng và không ANALYZE lại:

pg_class:  relpages = 0    reltuples = 0
tep tren dia: 28 MB
bo toi uu doan: 800.040 dong   (that: 800.000)

Sai số 0,005%. Với relpages = 0, PostgreSQL không tin thống kê — nó đọc kích thước tệp thật rồi chia cho bề rộng dòng trung bình.

Tôi thử ép nó chọn nhầm thuật toán join bằng thống kê cũ và không làm được: cả trước lẫn sau ANALYZE đều là Hash Join (58,0 so với 47,6 ms). Đây là điều đáng chỉnh lại trong hiểu biết phổ biến — "quên ANALYZE là bộ tối ưu mù tịt" không đúng.

Cái ANALYZE thật sự cứu là thống kê từng cột: phân bố giá trị, số giá trị khác nhau, giá trị phổ biến nhất. Đó là lý do ca 'TinhMoi' lệch 166.667 lần trong khi ca đếm cả bảng chỉ lệch 0,005%.

Quy trình khi gặp truy vấn chậm

  1. So rows dự đoán với thực tế ở từng nút. Nút đầu tiên lệch trên mười lần là nguyên nhân; mọi thứ bên trên nó chỉ là hậu quả.
  2. Nếu lệch ở một nút quét bảng → chạy ANALYZE bảng đó rồi đo lại.
  3. Nếu điều kiện có hàm bọc quanh cột → viết lại thành khoảng, hoặc tạo chỉ mục biểu thức.
  4. Nếu hai điều kiện trên hai cột liên quan nhau → CREATE STATISTICS như phần 8 đã đo.
  5. Nếu ước lượng đã đúng mà vẫn chậm → vấn đề nằm ở chỉ mục hoặc ở lượng dữ liệu, không nằm ở bộ tối ưu.

Mẫu số chung

Ông bếp trưởng ở đầu bài chỉ ra một sự thật đúng với mọi hệ ra quyết định dựa trên chi phí: nó chỉ tốt bằng ước lượng lực lượng — số phần tử — mà nó nhận vào. Một ước lượng sai ở một đầu vào âm thầm đầu độc mọi quyết định xây trên nó, và trong một đường ống, sai số nhân dồn lên: đoán lệch 10 lần ở một nút quét, rồi join với nút khác, thành lệch trăm lần ở phía trên. Hệ quả cho việc gỡ lỗi rất cụ thể: tìm nút đầu tiên nơi dự đoán lệch thực tế — đó là nguyên nhân; mọi thứ phía trên chỉ là triệu chứng.

Cùng nguyên lý ấy, đổi ngăn xếp:

  • Ước lượng lực lượng nổi tiếng là bài toán khó nhất của tối ưu truy vấn, và sai số của nó nhân dồn theo cấp số qua các phép join. Đó chính là lý do Spark có Adaptive Query Execution: nó lập lại kế hoạch giữa chừng dựa trên số dòng thật đo được sau mỗi giai đoạn, thay vì tin ước lượng tĩnh tính một lần từ đầu — thừa nhận thẳng rằng con số vào có thể sai.
  • Nguyên lý "tìm điểm phân kỳ đầu tiên" là cách gỡ lỗi phổ quát: git bisect tìm commit hỏng đầu tiên chứ không xét từng commit; đọc một stack trace là lần về khung gốc nơi mọi thứ bắt đầu sai, không phải khung trên cùng nơi nó nổ. Đừng chữa triệu chứng ồn ào nhất, hãy tìm chỗ thực tế lần đầu rời khỏi mô hình.

Nên câu hỏi đúng khi một truy vấn (hay một pipeline, một hệ quyết định) chạy tệ bất thường không phải "nút nào chậm nhất" mà là "nút đầu tiên nào có ước lượng lệch xa thực tế" — vì đó là nơi con số đầu vào bắt đầu sai, và mọi quyết định sau nó đều thừa hưởng cái sai ấy. Muốn tìm bảng có thống kê cũ nhất đang chờ, soát nhanh:

SELECT relname,
       n_live_tup,
       n_mod_since_analyze,
       round(100.0 * n_mod_since_analyze / nullif(n_live_tup,0), 1) AS phan_tram_doi,
       last_autoanalyze
FROM pg_stat_user_tables
WHERE n_mod_since_analyze > 0
ORDER BY n_mod_since_analyze DESC
LIMIT 10;

Cột phan_tram_doi trên 10% nghĩa là bảng đó đã đổi nhiều hơn ngưỡng autovacuum và đang chờ tới lượt. Nếu bạn vừa nạp dữ liệu và sắp chạy báo cáo, ANALYZE ten_bang; mất vài giây và tránh được một kế hoạch sai.

Phần sau đo ngưỡng cụ thể: bao nhiêu phần trăm bảng thì Seq Scan thắng Index Scan.