Cost model & cardinality — vì sao planner chọn sai
Optimizer chọn plan rẻ nhất bằng cách ước lượng cost từ statistics. Cardinality estimation, vì sao sai số dồn qua join, và cách giúp planner đúng — agnostic.
TL;DR: Optimizer chọn plan bằng cách gán cho mỗi plan ứng viên một con số cost (chi phí ước lượng) rồi chọn cái rẻ nhất. Cost được tính từ statistics — dữ liệu thống kê về bảng mà database thu thập sẵn: số dòng, số giá trị riêng biệt mỗi cột (n_distinct), và phân bố giá trị (histogram). Bước cốt lõi là cardinality estimation: đoán xem mỗi toán tử sẽ trả ra bao nhiêu dòng. Đoán đúng → chọn access path và join algorithm tốt. Đoán sai → chọn nhầm, query chậm hàng chục lần. Sai số đặc biệt nguy hiểm vì nó dồn lại qua nhiều join: lệch ở bảng đầu nhân lên ở các bước sau, dẫn tới bad plan. Hai nguồn sai phổ biến nhất: statistics cũ/lệch (database chưa cập nhật sau khi dữ liệu đổi nhiều) và query viết khiến ước lượng khó (hàm bọc cột, tương quan giữa cột). Hướng xử lý ở mức nguyên lý: giữ statistics tươi và viết điều kiện để optimizer ước lượng dễ.
Một query join ba bảng chạy 20ms suốt nhiều tháng. Một sáng nó đột nhiên mất 12 giây — không ai đổi query, không đổi index, không đổi dữ liệu cấu trúc. Thủ phạm: đêm trước có một đợt nạp 5 triệu dòng vào một bảng, nhưng statistics chưa được cập nhật. Optimizer vẫn tưởng bảng đó có vài nghìn dòng như cũ, ước lượng sai số dòng, chọn nested loop thay vì hash join — và nested loop trên bảng giờ đã khổng lồ thành thảm hoạ. Cập nhật statistics, optimizer ước lượng lại đúng, chọn hash join, query về 20ms.
Đây là một trong những loại sự cố khó hiểu nhất với người mới: query y nguyên mà tốc độ đổi đột ngột. Gốc rễ nằm ở chỗ optimizer không nhìn dữ liệu thật lúc lập plan — nó nhìn thống kê tóm tắt về dữ liệu. Khi thống kê lệch thực tế, mọi ước lượng phía sau lệch theo. Bài này mổ cost model hoạt động ra sao, cardinality estimation là gì, vì sao sai số nhân lên qua join, và cách giữ optimizer ước lượng đúng — tất cả agnostic.
1. Analogy — Lên lộ trình giao hàng bằng bản đồ cũ
Một tài xế giao hàng lên lộ trình tối ưu dựa trên bản đồ và dự báo giao thông, không phải chạy thử mọi con đường. Nếu bản đồ cập nhật (biết đường nào mới mở, đoạn nào kẹt), lộ trình chọn sẽ tốt. Nhưng nếu dùng bản đồ cũ — không biết một con đường tắt vừa bị chặn, hay một khu vừa xây xong gây kẹt — tài xế chọn lộ trình "tối ưu trên giấy" nhưng thực tế tắc cứng. Anh ta không sai về cách tính; anh ta sai vì dữ liệu đầu vào lệch thực tế.
Optimizer y hệt. Nó không chạy thử mọi plan rồi đo — quá đắt. Nó ước lượng cost từ statistics (bản đồ về dữ liệu). Statistics tươi → plan tốt. Statistics cũ → plan "tối ưu trên giấy" nhưng chạy chậm thực tế. Và như tài xế ước lượng sai một đoạn đầu khiến cả lộ trình dài hỏng theo, optimizer ước lượng sai số dòng ở một bước khiến các bước sau chọn nhầm — sai số dồn lại.
| Giao hàng | Optimizer |
|---|---|
| Bản đồ + dự báo giao thông | Statistics (số dòng, n_distinct, histogram) |
| Ước lượng thời gian mỗi đoạn | Cardinality estimation (số dòng mỗi toán tử) |
| Chọn lộ trình ngắn nhất theo ước lượng | Chọn plan rẻ nhất theo cost |
| Bản đồ cũ → lộ trình tắc | Statistics cũ → bad plan |
| Sai một đoạn đầu hỏng cả lộ trình | Sai cardinality bảng đầu dồn qua các join |
Optimizer chọn plan bằng bản đồ (statistics), không phải chạy thử. Bản đồ cũ → đường tắc dù tính đúng. Cập nhật statistics = cập nhật bản đồ.
2. Cost model — gán giá cho mỗi plan
Ở bài 01 ta thấy optimizer sinh nhiều plan tương đương. Làm sao chọn? Nó gán cho mỗi plan một con số cost — ước lượng tài nguyên phải tiêu để chạy plan đó, rồi chọn plan cost thấp nhất.
Cost không phải thời gian thực mà là một đại lượng tổng hợp, thường gồm:
- I/O cost: số page (khối dữ liệu) phải đọc từ đĩa — thường là thành phần lớn nhất, vì đọc đĩa chậm hơn nhiều so với tính toán trong bộ nhớ.
- CPU cost: số phép so sánh, băm, sắp xếp phải thực hiện.
- (Một số engine thêm chi phí mạng, bộ nhớ...)
cost(plan) ~ (so page doc tu dia) * gia_io
+ (so dong xu ly) * gia_cpu
+ ...
-- Optimizer so cost giua cac plan, chon thap nhat. Day la UOC LUONG, khong phai do that.
Điểm mấu chốt: mọi thành phần cost đều phụ thuộc vào số dòng. Đọc bao nhiêu page tuỳ bảng bao nhiêu dòng và điều kiện lọc giữ lại bao nhiêu. So sánh bao nhiêu lần tuỳ join ra bao nhiêu dòng. Nên trước khi tính cost, optimizer phải trả lời câu hỏi nền tảng: mỗi toán tử sẽ xử lý bao nhiêu dòng? Đó chính là cardinality estimation — và nó là khâu dễ sai nhất. Như Wikipedia ghi: "ước lượng chi phí của các plan thay thế là một trong những bài toán khó nhất của query optimization", chủ yếu vì cardinality estimation khó.
3. Statistics — dữ liệu optimizer dựa vào
Optimizer không quét dữ liệu thật lúc plan (quá đắt). Nó dựa vào statistics — bản tóm tắt về dữ liệu mà database thu thập định kỳ (qua một lệnh quy ước thường gọi là ANALYZE, hoặc tự động). Ba loại thống kê cốt lõi, agnostic:
- Số dòng bảng (row count / table cardinality): bảng có bao nhiêu dòng. Quyết định chi phí full scan và kích thước mỗi phía join.
- n_distinct (số giá trị riêng biệt): một cột có bao nhiêu giá trị khác nhau. Cột
gendercó n_distinct = 2; cộtuser_idcó n_distinct = số user. Dùng để đoán một điều kiện bằng nhau giữ lại bao nhiêu dòng. - Histogram (biểu đồ phân bố): chia khoảng giá trị của cột thành các "thùng" (bucket) ghi mỗi khoảng có bao nhiêu dòng. Cần khi dữ liệu lệch (skew) — ví dụ 90% đơn hàng có
status='done', 10% rải các trạng thái khác. Không có histogram, optimizer giả định phân bố đều và đoán sai nặng với dữ liệu lệch.
Từ ba thống kê này, optimizer ước lượng selectivity của một điều kiện. Ví dụ ước lượng đơn giản nhất cho điều kiện bằng nhau, giả định phân bố đều:
-- Uoc luong so dong WHERE col = value tra ra:
selectivity ~ 1 / n_distinct(col)
so dong uoc luong ~ row_count * selectivity
-- Vi du: bang 1,000,000 dong, cot status co n_distinct = 5
-- WHERE status = 'open' -> ~ 1,000,000 / 5 = 200,000 dong (gia dinh deu)
Nếu phân bố thực không đều (status open thực ra chỉ 2% chứ không phải 20%), ước lượng "phân bố đều" sai 10 lần — đó là lúc histogram cần thiết để sửa.
4. Cardinality estimation — khâu dễ sai nhất, và sai số dồn qua join
Cardinality estimation là việc đoán số dòng đầu ra của mỗi toán tử trong cây plan. Optimizer làm từ lá lên: ước lượng số dòng mỗi scan trả ra, rồi mỗi join trả ra, lan dần lên gốc.
Vấn đề chí mạng: sai số nhân lên qua mỗi join. Mỗi bước ước lượng dựa trên đầu ra ước lượng của bước dưới — nên lệch ở bảng đầu nhân với lệch ở bước join sau, dồn thành sai số khổng lồ ở các join cuối.
flowchart TB S1["Scan A<br/>uoc luong 1000<br/>(that: 10000, lech 10x)"] --> J1["Join A-B<br/>lech tiep 5x"] S2["Scan B<br/>uoc luong dung"] --> J1 J1 --> J2["Join voi C<br/>sai so don: 10 x 5 = 50x"] S3["Scan C"] --> J2 J2 --> R["Plan chon dua tren<br/>uoc luong lech 50 lan<br/>-> BAD PLAN"]
Tình huống cụ thể: optimizer ước lượng join A-B trả 1000 dòng (thực ra 50.000). Dựa vào "chỉ 1000 dòng", nó chọn nested loop để join tiếp với C — hợp lý nếu thật 1000 dòng. Nhưng thực tế 50.000 dòng × tra C mỗi dòng = nested loop biến thành O(n*m) thảm hoạ. Nếu ước lượng đúng 50.000, optimizer đã chọn hash join (bài 02). Một sai số cardinality ở bước giữa làm hỏng lựa chọn thuật toán ở bước sau.
Vì sao cardinality hay sai? Vài nguồn gốc:
- Statistics cũ: dữ liệu đã đổi nhiều (nạp lớn, xoá lớn) nhưng thống kê chưa cập nhật — như đầu bài.
- Giả định phân bố đều sai: dữ liệu lệch mà không có histogram đủ chi tiết.
- Tương quan giữa cột (correlation): optimizer thường giả định các điều kiện độc lập.
WHERE city = 'Hanoi' AND district = 'Ba Dinh'— hai điều kiện này tương quan (Ba Dinh chỉ ở Hà Nội), nhưng optimizer nhân hai selectivity như độc lập → ước lượng quá thấp. - Hàm bọc cột:
WHERE UPPER(email) = ...khiến optimizer không tra được histogram củaemail→ rơi về ước lượng mặc định thô.
5. Hướng xử lý — giúp optimizer ước lượng đúng
Vì bad plan gốc ở ước lượng sai, hai hướng khắc phục ở mức nguyên lý (áp dụng mọi engine):
A. Giữ statistics tươi. Đây là biện pháp số một. Sau khi dữ liệu thay đổi lớn (nạp/xoá hàng loạt), chạy cập nhật statistics (lệnh quy ước ANALYZE). Hầu hết database có cơ chế tự động cập nhật khi tỉ lệ dòng thay đổi vượt ngưỡng, nhưng sau một đợt nạp lớn đột biến, cập nhật thủ công ngay là cách chắc chắn nhất. Đầu bài chính là ca này.
-- Sau khi nap/xoa lon, cap nhat statistics de optimizer uoc luong dung
-- (ten lenh khac nhau giua engine; ANALYZE la quy uoc pho bien)
ANALYZE tasks;
B. Viết query để ước lượng dễ. Cách viết điều kiện ảnh hưởng độ chính xác ước lượng:
- Để điều kiện chạm thẳng cột thô (không bọc hàm, không ép kiểu) — optimizer mới tra được histogram/n_distinct của cột đó. Bọc hàm → ước lượng mặc định thô → dễ sai.
- Lọc selective sớm — đưa điều kiện thu hẹp dữ liệu vào sớm để các bước join sau làm việc trên số dòng nhỏ và dễ ước lượng hơn.
- Cảnh giác điều kiện tương quan — khi lọc nhiều cột tương quan (city + district), biết rằng optimizer có thể ước lượng quá thấp; một số engine hỗ trợ khai báo thống kê đa cột (extended statistics) để sửa.
-- WRONG: ham boc cot -> optimizer mat histogram, uoc luong tho
SELECT * FROM users WHERE LOWER(email) = '[email protected]';
-- DUNG: cham thang cot tho -> optimizer tra duoc thong ke -> uoc luong dung
SELECT * FROM users WHERE email = '[email protected]';
Cardinality estimation về bản chất là đoán từ tóm tắt thống kê, nên không bao giờ chính xác tuyệt đối — đặc biệt với query nhiều join hoặc dữ liệu lệch/tương quan. Mục tiêu thực tế không phải "ước lượng hoàn hảo" mà là "đủ đúng để chọn plan không tệ". Khi gặp query chậm bất thường, quy trình chuẩn: xem plan bằng EXPLAIN, so số dòng ước lượng với số dòng thực ở mỗi toán tử — nơi nào lệch lớn chính là nguồn của bad plan, và thường sửa bằng cập nhật statistics hoặc viết lại điều kiện.
6. Pitfall — bad plan do cardinality sai
Triệu chứng kinh điển: query y nguyên, index y nguyên, nhưng tốc độ đột ngột xấu đi. Gần như luôn là cardinality estimation sai dẫn tới optimizer đổi sang bad plan. Các tình huống thường gặp:
- Sau đợt nạp/xoá lớn, chưa cập nhật statistics. Optimizer dùng số dòng cũ → ước lượng sai → chọn nhầm join algorithm (nested loop trên bảng giờ đã lớn). Sửa: chạy
ANALYZE. - Dữ liệu lệch, không đủ histogram. Giả định phân bố đều ước lượng sai nặng cho giá trị hiếm/phổ biến. Sửa: tăng độ chi tiết histogram (cơ chế tuỳ engine).
- Điều kiện tương quan bị nhân như độc lập. Lọc nhiều cột liên quan → ước lượng quá thấp → chọn plan tưởng ít dòng. Sửa: khai báo thống kê đa cột nếu engine hỗ trợ.
-- Trieu chung: query nay tung 20ms, dot nhien 12s sau dot nap lon
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN line_items li ON li.order_id = o.id
WHERE o.created_at >= '2026-06-01';
-- DUNG (huong xu ly):
-- 1. Cap nhat statistics sau khi du lieu doi lon:
ANALYZE orders; ANALYZE line_items;
-- 2. Xem plan, so "uoc luong" vs "thuc te" o moi node de tim cho lech:
-- EXPLAIN ... (dinh dang output tuy engine)
Quy tắc: khi query chậm bất thường mà bạn "không đổi gì", nghi statistics trước tiên. Optimizer chỉ tốt bằng dữ liệu thống kê nó được cho — bản đồ cũ thì lộ trình hỏng dù thuật toán đúng.
7. 📚 Deep Dive
- Wikipedia — Query optimization — cost-based optimizer "đánh giá resource footprint của các plan", và khẳng định ước lượng cost (qua cardinality estimation) là "một trong những bài toán khó nhất". Nền tảng agnostic cho toàn bài.
- Designing Data-Intensive Applications (Kleppmann) — Chương 3 "Storage and Retrieval" — vì sao cost model dựa trên I/O (đọc page) là thành phần lớn nhất, và mối liên hệ statistics ↔ access path.
- Use The Index, Luke — Anatomy of an SQL Index — index ảnh hưởng cost thế nào; bổ trợ phần access path mà cost model chấm điểm.
Ghi chú: Wikipedia cho khung khái niệm cost/cardinality trung lập engine và xác nhận đây là bài toán khó cốt lõi. DDIA giải thích vì sao I/O chi phối cost. Cả hai agnostic — không gắn output của một engine cụ thể, đúng tinh thần module.
8. Liên hệ các bài khác
- Bài 01 — Từ SQL đến kế hoạch thực thi: giai đoạn plan sinh nhiều plan ứng viên; bài này giải thích optimizer chấm điểm chúng thế nào để chọn.
- Bài 02 — Thuật toán JOIN: cardinality sai dẫn thẳng tới chọn nhầm nested loop / hash / merge — cost model là cầu nối giữa ước lượng và lựa chọn thuật toán.
- Module 6 — Tại sao cần index: cost model chấm access path dựa trên index — selectivity ước lượng quyết định index có được dùng không.
- Module 3 — INNER JOIN: thứ tự join (do cost quyết định) ảnh hưởng số dòng trung gian — gốc của sai số dồn.
- Module 2 — Pattern matching:
LIKEvới wildcard khiến selectivity khó ước lượng — một ví dụ điều kiện làm khó cardinality estimation.
9. Tóm tắt
- Optimizer chọn plan bằng cách gán cost (ước lượng I/O + CPU) cho mỗi plan rồi chọn cái thấp nhất — đây là ước lượng, không phải đo thật.
- Cost dựa trên statistics: số dòng bảng, n_distinct (giá trị riêng biệt mỗi cột), histogram (phân bố, cần khi dữ liệu lệch).
- Cardinality estimation — đoán số dòng đầu ra mỗi toán tử — là khâu dễ sai nhất, và mọi thành phần cost đều phụ thuộc nó.
- Sai số cardinality dồn lại qua nhiều join: lệch ở bảng đầu nhân lên ở các bước sau → chọn nhầm thuật toán → bad plan.
- Nguồn sai chính: statistics cũ, dữ liệu lệch không đủ histogram, điều kiện tương quan bị nhân như độc lập, hàm bọc cột.
- Hướng xử lý: giữ statistics tươi (
ANALYZEsau nạp/xoá lớn) và viết query để ước lượng dễ (chạm cột thô, lọc selective sớm); khi chậm bất thường, so ước lượng vs thực tế quaEXPLAINđể tìm chỗ lệch.
10. Tự kiểm tra
Q1Optimizer chọn plan rẻ nhất dựa trên 'cost'. Cost gồm những thành phần nào, và vì sao mọi thành phần đều phụ thuộc vào số dòng?▸
Cost là đại lượng tổng hợp ước lượng tài nguyên, gồm chủ yếu I/O cost (số page phải đọc từ đĩa — thường lớn nhất vì đĩa chậm) và CPU cost (số phép so sánh, băm, sắp xếp).
Mọi thành phần phụ thuộc số dòng vì: đọc bao nhiêu page tuỳ bảng bao nhiêu dòng và lọc giữ lại bao nhiêu; so sánh/băm bao nhiêu lần tuỳ join ra bao nhiêu dòng. Nên trước khi tính cost, optimizer phải ước lượng số dòng mỗi toán tử (cardinality) — đó là lý do cardinality estimation là nền của cả cost model.
Q2Optimizer dựa vào 'statistics' chứ không quét dữ liệu thật lúc lập plan. Kể ba loại statistics cốt lõi và mỗi loại dùng để làm gì.▸
- Số dòng bảng (row count): bảng bao nhiêu dòng — quyết định chi phí full scan và kích thước mỗi phía join.
- n_distinct: số giá trị riêng biệt của một cột — dùng đoán điều kiện bằng nhau giữ lại bao nhiêu dòng (selectivity ~
1 / n_distinct). - Histogram: phân bố giá trị chia theo bucket — cần khi dữ liệu lệch, vì không có nó optimizer giả định phân bố đều và đoán sai nặng.
Optimizer không quét dữ liệu thật vì quá đắt; nó dựa vào bản tóm tắt thống kê này (cập nhật qua lệnh quy ước ANALYZE).
Q3Vì sao sai số cardinality lại đặc biệt nguy hiểm trong query có nhiều join, hơn là query một bảng?▸
Vì optimizer ước lượng từ lá lên, mỗi join dựa trên đầu ra ước lượng của bước dưới — nên sai số nhân lên qua mỗi join, không cộng. Lệch ở bảng đầu (ví dụ 10 lần) nhân với lệch ở join sau (5 lần) thành lệch 50 lần ở join cuối.
Hệ quả: optimizer chọn thuật toán dựa trên con số đã lệch lớn. Ví dụ tưởng join ra 1000 dòng nên chọn nested loop, thực ra 50.000 dòng → nested loop biến thành O(n*m) thảm hoạ. Query một bảng chỉ có một bước nên sai số không dồn — ít rủi ro hơn nhiều.
Q4Một query chạy 20ms suốt nhiều tháng rồi đột nhiên 12 giây — không ai đổi query, index hay schema. Nguyên nhân khả dĩ nhất là gì, và vì sao?▸
Khả dĩ nhất: statistics cũ sau một đợt thay đổi dữ liệu lớn (nạp/xoá hàng loạt). Optimizer vẫn dùng số dòng cũ, ước lượng cardinality sai, chọn nhầm thuật toán (ví dụ nested loop trên bảng giờ đã khổng lồ thay vì hash join).
Vì optimizer không nhìn dữ liệu thật lúc plan mà nhìn thống kê tóm tắt — khi thống kê lệch thực tế, plan "tối ưu trên giấy" lại chậm thật. Sửa: chạy ANALYZE để cập nhật statistics; optimizer ước lượng lại đúng và chọn plan tốt. Đây là lý do "nghi statistics trước tiên" khi query chậm bất thường.
Q5Điều kiện WHERE city = 'Hanoi' AND district = 'Ba Dinh' khiến optimizer ước lượng số dòng quá thấp. Vì sao, và đó thuộc loại lỗi cardinality nào?▸
Đây là lỗi tương quan giữa cột (correlation). Optimizer thường giả định các điều kiện độc lập nên nhân selectivity của chúng: sel(city) * sel(district). Nhưng hai cột tương quan — Ba Dinh chỉ thuộc Hà Nội — nên việc nhân hai selectivity như độc lập cho ra con số quá nhỏ so với thực tế.
Hệ quả: optimizer tưởng kết quả rất ít dòng, có thể chọn nested loop hoặc bỏ qua một index hữu ích. Sửa ở mức nguyên lý: một số engine hỗ trợ khai báo thống kê đa cột (extended statistics) để optimizer biết hai cột liên quan và ước lượng đúng.
Q6Vì sao viết WHERE LOWER(email) = '[email protected]' không chỉ vô hiệu index mà còn làm cardinality estimation sai?▸
Statistics (n_distinct, histogram) được thu thập trên giá trị gốc của cột email, không trên LOWER(email). Khi điều kiện bọc cột trong hàm, optimizer không tra được histogram của email cho biểu thức đã biến đổi → rơi về một ước lượng mặc định thô (ví dụ giả định một tỉ lệ cố định).
Hệ quả kép: vừa mất index (giá trị biến đổi không có trong index sắp theo cột gốc), vừa ước lượng số dòng sai → có thể chọn nhầm access path/join. Sửa: chuẩn hoá dữ liệu khi lưu rồi so chạm thẳng cột thô (WHERE email = '[email protected]') để optimizer dùng được cả index lẫn statistics.
Bài tiếp theo: Vòng đời dữ liệu
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên