Từ SQL đến kế hoạch thực thi — declarative, planner và access path
SQL nói 'lấy cái gì', không nói 'lấy thế nào'. Database dịch sang kế hoạch thực thi: parse, plan, execute; cây toán tử và access path — nguyên lý agnostic.
TL;DR: SQL là ngôn ngữ declarative (khai báo) — bạn mô tả kết quả mong muốn, không mô tả cách lấy. Cùng một câu SELECT, database có hàng chục cách thực thi khác nhau, và bộ phận query optimizer (planner) chọn một cách rồi giao cho executor chạy. Quá trình đó là một pipeline: parse (phân tích cú pháp) → rewrite (viết lại logic) → plan/optimize (chọn kế hoạch rẻ nhất) → execute (chạy). Kết quả của bước plan là một cây toán tử (operator tree) gồm các phép scan, join, sort, aggregate. Một quyết định cốt lõi trong cây đó là access path — đọc dữ liệu bằng full table scan hay index scan — chọn theo độ chọn lọc (selectivity) của điều kiện lọc. Mọi RDBMS đều theo nguyên lý này dù tên gọi và output khác nhau.
Một dev viết SELECT * FROM tasks WHERE assignee_id = 7 và thấy nó chạy 5ms. Cũng câu đó, đổi điều kiện thành WHERE assignee_id > 0 (gần như khớp mọi dòng), lại chạy 900ms. Cú pháp gần như y hệt — nhưng database thực thi hai cách hoàn toàn khác: câu đầu nhảy thẳng vào index lấy vài dòng, câu sau quét cả bảng vì lọc gần hết. Dev không hề ra lệnh "dùng index" hay "quét bảng" — database tự quyết.
Điểm mấu chốt: bạn không lập trình cách lấy dữ liệu. Bạn khai báo cái cần lấy, và một engine bên trong dịch nó thành các bước cụ thể. Bài này mở nắp engine đó: vì sao SQL là declarative, pipeline biến câu lệnh thành kế hoạch, hình dạng cây toán tử, và quyết định access path đầu tiên — tất cả ở mức nguyên lý dùng được cho mọi database.
1. Analogy — Đặt món ở nhà hàng, không vào bếp nấu
Khi bạn gọi "cho tôi một phần cơm gà", bạn nói kết quả mong muốn, không chỉ đạo bếp từng bước: vo gạo trước hay luộc gà trước, dùng nồi nào, lửa to hay nhỏ. Người bếp trưởng (chef) tự lên quy trình nấu dựa trên nguyên liệu sẵn có, số đầu bếp rảnh, và bếp nào đang trống — rồi giao cho phụ bếp thực hiện.
SQL hoạt động y hệt. Bạn khai báo món (SELECT ... WHERE ...), database đóng vai bếp trưởng: nhìn vào dữ liệu sẵn có (bảng, index, thống kê), lập một kế hoạch thực thi rẻ nhất, rồi giao cho executor chạy. Hai khách gọi cùng món vào hai thời điểm khác nhau có thể được nấu theo hai quy trình khác — vì nguyên liệu và bếp thay đổi. Database cũng vậy: cùng câu SQL, plan có thể đổi khi dữ liệu lớn lên hoặc index mới được thêm.
| Nhà hàng | Database |
|---|---|
| Khách gọi "cơm gà" (kết quả) | Câu SQL declarative — nói cái gì cần lấy |
| Bếp trưởng lên quy trình nấu | Query optimizer (planner) lập kế hoạch |
| Quy trình nấu cụ thể (bước, dụng cụ) | Query plan — cây toán tử |
| Phụ bếp thực hiện từng bước | Executor chạy plan |
| Chọn bếp/nồi theo nguyên liệu sẵn | Chọn access path/join theo index + statistics |
| Cùng món, hai cách nấu tuỳ lúc | Cùng SQL, plan đổi khi dữ liệu/index đổi |
SQL = đặt món (nói cái gì). Database = bếp trưởng tự lên quy trình nấu (cách làm). Bạn không bao giờ chỉ đạo bếp từng bước — đó là việc của optimizer.
2. Declarative vs imperative — vì sao SQL không nói "cách làm"
Hầu hết ngôn ngữ lập trình bạn quen là imperative (mệnh lệnh): bạn viết từng bước — mở file, lặp qua từng dòng, so sánh, gom kết quả. Bạn kiểm soát thuật toán.
SQL thì declarative: bạn mô tả quan hệ kết quả mong muốn, để database tự chọn thuật toán.
-- Declarative: chi mo ta KET QUA mong muon
SELECT u.name, COUNT(t.id) AS open_tasks
FROM users u
JOIN tasks t ON t.assignee_id = u.id
WHERE t.status = 'open'
GROUP BY u.name;
Câu trên không nói: "lặp qua bảng users, với mỗi user tìm task của họ, đếm cái nào status open". Nó chỉ mô tả cái cần. Database tự do chọn: quét tasks trước rồi nhóm theo user, hay quét users trước rồi với mỗi user tra index tasks; dùng nested loop hay hash để join. Tất cả đều cho cùng kết quả — chỉ khác tốc độ.
Vì sao thiết kế declarative? Ba lý do nền tảng:
- Database biết dữ liệu, bạn không. Optimizer có thống kê (bảng bao nhiêu dòng, một cột có bao nhiêu giá trị riêng biệt) để chọn cách rẻ nhất. Một dev viết imperative không thể đoán đúng điều này cho mọi dữ liệu.
- Plan tự thích nghi. Hôm nay bảng 1000 dòng, plan này tốt; năm sau 10 triệu dòng, optimizer tự đổi plan — bạn không phải viết lại query.
- Tách logic khỏi thực thi. Bạn diễn đạt ý định một lần; cùng câu chạy tối ưu trên engine khác nhau, phần cứng khác nhau.
Bạn vẫn ảnh hưởng được plan — qua việc tạo index hợp lý, viết điều kiện lọc rõ ràng, và (ở một số engine) gợi ý (hint). Nhưng cách chuẩn là cung cấp cho optimizer thông tin tốt (index + statistics chính xác) rồi tin nó chọn — chứ không vi mô quản lý từng bước. Vì sao optimizer đôi khi chọn sai và cách giúp nó chọn đúng là chủ đề của bài 03.
3. Pipeline — từ chuỗi SQL đến kế hoạch chạy được
Câu SQL bạn gửi đi là một chuỗi ký tự. Database biến nó thành các bước thực thi qua một pipeline bốn giai đoạn. Tên cụ thể khác nhau giữa các engine, nhưng nguyên lý đồng nhất:
flowchart LR SQL["Cau SQL<br/>(chuoi ky tu)"] --> P["1. PARSE<br/>cu phap -> cay"] P --> R["2. REWRITE<br/>viet lai logic"] R --> O["3. PLAN / OPTIMIZE<br/>chon ke hoach re nhat"] O --> E["4. EXECUTE<br/>chay plan, tra ket qua"] E --> RES["Ket qua"]
Giai đoạn 1 — Parse (phân tích cú pháp). Database kiểm tra câu lệnh đúng ngữ pháp SQL không, các bảng/cột có tồn tại không, kiểu dữ liệu hợp lệ không. Kết quả là một cây cú pháp (parse tree) biểu diễn cấu trúc câu lệnh. Sai chính tả SLECT hay tham chiếu cột không tồn tại bị bắt ngay ở đây.
Giai đoạn 2 — Rewrite (viết lại). Database biến đổi câu lệnh thành dạng tương đương nhưng dễ tối ưu hơn — không đổi kết quả. Ví dụ: thay view bằng định nghĩa của nó, đẩy điều kiện lọc xuống gần nguồn dữ liệu (predicate pushdown), đơn giản hoá biểu thức hằng. Đây là biến đổi logic, chưa quyết định thuật toán.
Giai đoạn 3 — Plan / Optimize (lập kế hoạch). Đây là trái tim. Optimizer sinh ra nhiều plan tương đương về mặt logic — khác nhau ở access path (đọc bảng kiểu gì), join algorithm (ghép bảng kiểu gì), thứ tự join — rồi ước lượng chi phí từng plan và chọn cái rẻ nhất. Cách ước lượng chi phí dựa trên cost model + statistics, mổ kỹ ở bài 03.
Giai đoạn 4 — Execute (thực thi). Executor chạy plan đã chọn: đọc dữ liệu theo access path, áp các toán tử (join, sort, aggregate) theo đúng cây, trả kết quả về client.
Lập plan tốn CPU. Nhiều database cache plan đã chọn cho một dạng câu lệnh, để lần sau chạy lại câu tương tự (chỉ khác tham số) thì bỏ qua giai đoạn 1-3, chạy thẳng plan cũ. Lợi: tiết kiệm. Bẫy: plan cũ có thể không còn tối ưu khi dữ liệu đã đổi nhiều — một nguồn gây query chậm bất ngờ, liên quan tới statistics cũ (bài 03).
4. Query plan là một cây toán tử
Kết quả của giai đoạn plan là một cây toán tử (operator tree). Mỗi node là một phép xử lý cơ bản; dữ liệu chảy từ lá lên gốc. Lá là các phép đọc dữ liệu (scan); các node trên ghép, lọc, sắp xếp, tổng hợp; gốc trả kết quả cuối.
Hình dung plan cho câu join + group ở section 2:
[Aggregate: COUNT, GROUP BY u.name] <- goc: tong hop
|
[Join: t.assignee_id = u.id] <- ghep hai nguon
/ \
[Scan users u] [Scan tasks t WHERE status='open']
(doc bang users) (doc tasks, loc status)
^ ^
la la (access path quyet dinh o day)
Mỗi toán tử nhận đầu vào từ node con và sinh đầu ra cho node cha — giống các trạm trong dây chuyền. Các toán tử phổ biến:
| Toán tử | Vai trò | Ví dụ |
|---|---|---|
| Scan | Đọc dữ liệu từ bảng/index (lá của cây) | Full table scan, index scan |
| Filter | Loại bỏ dòng không khớp điều kiện | Áp WHERE status='open' |
| Join | Ghép hai nguồn theo điều kiện | Nested loop / hash / merge (bài 02) |
| Sort | Sắp xếp dòng | Phục vụ ORDER BY hoặc merge join |
| Aggregate | Gom nhóm + tính tổng hợp | COUNT, SUM, GROUP BY |
Cùng một câu SQL, optimizer có thể dựng nhiều cây khác nhau cho cùng kết quả: đổi thứ tự hai nhánh join, đổi scan từ full sang index, chèn/bỏ node sort. Mỗi cây là một plan ứng viên; optimizer chấm chi phí rồi chọn.
Bạn không phải đoán database chọn cây nào — mọi RDBMS đều có lệnh hiển thị plan đã chọn, theo quy ước thường gọi là EXPLAIN (đặt trước câu query). Nó in ra cây toán tử cùng access path và ước lượng chi phí. Lưu ý: định dạng output khác nhau giữa các engine (tên toán tử, cách hiển thị cost, đơn vị) — nên bài này dạy khái niệm cây toán tử và access path, không dạy đọc output của một engine cụ thể. Khi làm việc với một database thật, tra tài liệu EXPLAIN của chính engine đó để ánh xạ khái niệm sang output.
5. Access path — quyết định đọc dữ liệu ở các lá
Quyết định quan trọng nhất ở mỗi node scan (lá) là access path: lấy dữ liệu từ bảng bằng cách nào. Ở mức nguyên lý có ba lựa chọn cốt lõi:
- Full table scan (quét toàn bảng): đọc tuần tự mọi dòng từ đầu đến cuối, kiểm tra điều kiện trên từng dòng. Không cần index. Tối ưu khi query lấy phần lớn bảng — vì dù sao cũng phải chạm gần hết, đọc tuần tự rẻ hơn nhảy lung tung.
- Index scan (quét qua index): dùng cấu trúc index đã sắp xếp để nhảy thẳng tới các dòng khớp, rồi với mỗi mục index, lấy dòng đầy đủ từ bảng (heap). Tối ưu khi query lấy ít dòng — index giúp bỏ qua phần lớn bảng.
- Index-only scan (chỉ đọc index): trường hợp đặc biệt khi mọi cột query cần đều đã nằm trong index — database trả kết quả thẳng từ index, không chạm bảng. Nhanh nhất vì tiết kiệm hẳn bước fetch dòng gốc. (Nền tảng heap vs index ở Module 6 — Tại sao cần index.)
Optimizer chọn access path nào? Dựa trên selectivity (độ chọn lọc) của điều kiện lọc — tức tỉ lệ dòng mà điều kiện giữ lại:
| Selectivity | Ý nghĩa | Access path tối ưu |
|---|---|---|
| Rất thấp (lọc ra ít dòng) | WHERE id = 7 giữ 1 / 10 triệu dòng | Index scan — nhảy thẳng |
| Trung bình | Giữ vài phần trăm bảng | Tuỳ cost model cân nhắc |
| Cao (lọc giữ gần hết) | WHERE status IS NOT NULL giữ 95% | Full scan — dù sao cũng đọc gần hết |
Đây là nghịch lý nhiều người mới ngạc nhiên: có index không có nghĩa database sẽ dùng index. Nếu điều kiện lọc giữ lại phần lớn bảng, full scan đọc tuần tự rẻ hơn index scan — vì index scan với nhiều dòng phải nhảy qua lại giữa index và bảng (random I/O), tốn hơn đọc tuần tự cả khối. Quay lại ví dụ đầu bài: assignee_id = 7 selective cao (ít dòng) → index scan, 5ms; assignee_id > 0 giữ gần hết bảng → full scan, 900ms. Cùng cột, cùng index có sẵn — khác access path vì khác selectivity.
-- Selective cao -> optimizer thuong chon index scan
SELECT * FROM tasks WHERE id = 12345; -- 1 dong / 10M
-- Selective thap (giu gan het) -> optimizer thuong chon full scan
SELECT * FROM tasks WHERE id > 0; -- ~10M / 10M dong
6. Pitfall — index không được dùng dù đã tạo
Tạo index không bảo đảm query nhanh. Index chỉ giúp khi optimizer quyết định dùng nó — và nó chỉ dùng khi điều kiện đủ selective. Vài cách vô tình khiến index bị bỏ qua:
- Điều kiện không selective. Lọc giữ phần lớn bảng → full scan rẻ hơn → optimizer bỏ index. Bình thường, không phải lỗi.
- Bọc cột trong hàm. Điều kiện kiểu
WHERE UPPER(email) = '[email protected]'thường vô hiệu index trênemail, vì index sắp xếp theoemailgốc, không theoUPPER(email). Database không "đoán" được giá trị đã biến đổi nằm ở đâu trong index. - Ép kiểu ngầm. So sánh cột số với chuỗi (hoặc ngược lại) có thể buộc database biến đổi cột → mất khả năng dùng index.
-- WRONG: ham boc cot -> index tren email co the bi bo qua
SELECT * FROM users WHERE UPPER(email) = '[email protected]';
-- DUNG (huong tiep can): chuan hoa du lieu khi luu, so sanh truc tiep cot
SELECT * FROM users WHERE email = '[email protected]';
-- WRONG: ep kieu ngam (cot so so voi chuoi)
SELECT * FROM tasks WHERE id = '12345';
-- DUNG: so sanh dung kieu
SELECT * FROM tasks WHERE id = 12345;
Quy tắc: để điều kiện lọc chạm thẳng cột thô (không bọc hàm, không ép kiểu) thì optimizer mới ánh xạ được nó vào index. Khi nghi index không được dùng, xem plan bằng EXPLAIN của engine để xác nhận access path thực tế thay vì đoán.
7. 📚 Deep Dive
- Wikipedia — Query optimization — định nghĩa optimizer, query plan dạng cây, logical vs physical optimization, và lý do "ước lượng chi phí là một trong những bài toán khó nhất". Agnostic, nền tảng cho cả module.
- Use The Index, Luke — Anatomy of an SQL Index — index là cấu trúc riêng (B-tree + doubly linked list) lưu bản sao đã sắp xếp; vì sao index scan nhảy thẳng được. Tài liệu index agnostic, có ví dụ nhiều engine.
- Designing Data-Intensive Applications (Kleppmann) — Chương 3 "Storage and Retrieval" — cơ chế lưu trữ + index ở mức nguyên lý, giải thích vì sao access path quyết định chi phí I/O.
Ghi chú: Wikipedia cho khung khái niệm optimizer/plan chính xác và trung lập. "Use The Index, Luke" đào sâu phần index/access path mà vẫn agnostic. DDIA Chương 3 nối access path với cơ chế lưu trữ vật lý — đọc trước khi sang join algorithm ở bài kế.
8. Liên hệ các bài khác
- Bài 02 — Thuật toán JOIN: node Join trong cây toán tử thực ra chạy bằng một trong ba thuật toán; optimizer chọn cái nào và vì sao.
- Bài 03 — Cost model & cardinality: giai đoạn plan chấm chi phí thế nào, và vì sao ước lượng sai dẫn tới chọn nhầm access path/join.
- Module 6 — Tại sao cần index: nền tảng heap vs index, full scan vs index scan vs index-only — access path ở bài này xây trực tiếp trên đó.
- Module 3 — INNER JOIN: cú pháp JOIN khai báo mà bài 02 sẽ giải thích cơ chế thực thi bên dưới.
- Module 2 — Pattern matching: vì sao
LIKE '%x'(wildcard đầu chuỗi) thường không dùng được index — một ví dụ điển hình của access path bị giới hạn bởi cách viết điều kiện.
9. Tóm tắt
- SQL là declarative: bạn khai báo kết quả, database tự chọn thuật toán lấy dữ liệu — vì nó biết dữ liệu (statistics + index) còn bạn thì không.
- Pipeline biến SQL thành plan: parse (cú pháp) → rewrite (biến đổi logic giữ nguyên kết quả) → plan/optimize (chọn plan rẻ nhất) → execute.
- Kết quả của giai đoạn plan là một cây toán tử: lá là scan, các node trên là filter/join/sort/aggregate; dữ liệu chảy từ lá lên gốc.
- Access path là quyết định ở mỗi lá: full scan (đọc cả bảng) vs index scan (nhảy thẳng) vs index-only (không chạm bảng) — chọn theo selectivity của điều kiện lọc.
- Có index không bảo đảm dùng index: điều kiện không selective, bọc cột trong hàm, hoặc ép kiểu ngầm đều có thể khiến optimizer bỏ qua index.
- Mọi RDBMS đều có lệnh xem plan (quy ước
EXPLAIN) nhưng output khác nhau — nên học khái niệm cây/access path, rồi ánh xạ sang output của engine cụ thể.
10. Tự kiểm tra
Q1SQL được gọi là ngôn ngữ 'declarative'. Điều đó nghĩa là gì, và vì sao thiết kế declarative lại có lợi so với việc dev tự viết imperative từng bước?▸
Declarative nghĩa là bạn mô tả kết quả mong muốn (cái gì cần lấy), không mô tả thuật toán (cách lấy từng bước). Cùng câu SELECT, database tự chọn một trong nhiều cách thực thi tương đương.
Lợi ích: (1) database biết dữ liệu qua statistics + index nên chọn cách rẻ nhất, điều dev không đoán đúng cho mọi dữ liệu; (2) plan tự thích nghi khi dữ liệu lớn lên — không phải viết lại query; (3) tách ý định khỏi thực thi, cùng câu chạy tối ưu trên engine/phần cứng khác nhau.
Q2Mô tả bốn giai đoạn của pipeline biến câu SQL thành kết quả. Giai đoạn nào quyết định query nhanh hay chậm, và vì sao?▸
- Parse: kiểm tra cú pháp + bảng/cột tồn tại, sinh parse tree.
- Rewrite: biến đổi câu lệnh sang dạng tương đương dễ tối ưu (không đổi kết quả).
- Plan/optimize: sinh nhiều plan tương đương, ước lượng chi phí, chọn plan rẻ nhất.
- Execute: chạy plan đã chọn, trả kết quả.
Giai đoạn plan/optimize quyết định tốc độ, vì nó chọn access path, join algorithm và thứ tự join — các plan tương đương về kết quả nhưng chênh nhau nhiều bậc về chi phí. Chọn nhầm plan là gốc của hầu hết query chậm.
Q3Một query plan được mô tả là 'cây toán tử'. Dữ liệu chảy theo hướng nào trong cây, và một node Scan ở lá khác gì node Aggregate ở gốc?▸
Dữ liệu chảy từ lá lên gốc. Mỗi node nhận đầu vào từ node con và sinh đầu ra cho node cha, giống các trạm trong dây chuyền.
Node Scan ở lá đọc dữ liệu thô từ bảng/index — đây là nơi quyết định access path (full scan hay index scan). Node Aggregate ở gốc nhận dòng đã qua join/filter từ bên dưới rồi gom nhóm + tính tổng hợp (COUNT, SUM, GROUP BY), trả kết quả cuối. Lá lấy dữ liệu vào; gốc kết xuất ra.
Q4Vì sao một bảng đã có index trên cột nhưng database vẫn chọn full table scan thay vì index scan? Khi nào điều đó là hợp lý?▸
Vì optimizer chọn access path theo selectivity — tỉ lệ dòng điều kiện giữ lại. Khi điều kiện lọc giữ phần lớn bảng (selective thấp, ví dụ WHERE id > 0), full scan đọc tuần tự cả khối rẻ hơn index scan, vì index scan với nhiều dòng phải nhảy qua lại giữa index và bảng (random I/O) — tốn hơn đọc tuần tự.
Đó là quyết định hợp lý, không phải lỗi: optimizer đang chọn cách rẻ hơn. Index chỉ thắng khi điều kiện đủ selective để bỏ qua phần lớn bảng (ví dụ WHERE id = 7).
Q5Một dev tạo index trên cột email rồi viết WHERE UPPER(email) = '[email protected]' nhưng query vẫn chậm. Vì sao index không giúp, và sửa thế nào?▸
Index trên email sắp xếp theo giá trị email gốc, không theo UPPER(email). Khi điều kiện bọc cột trong hàm, database không biết giá trị đã biến đổi nằm ở đâu trong index → buộc phải quét và tính UPPER() trên mọi dòng (full scan).
Sửa: để điều kiện chạm thẳng cột thô. Chuẩn hoá dữ liệu khi lưu (ví dụ lưu email đã lowercase) rồi so sánh trực tiếp WHERE email = '[email protected]'. Cùng vấn đề với ép kiểu ngầm (so cột số với chuỗi) — luôn so đúng kiểu để giữ khả năng dùng index.
Q6Vì sao bài này dạy 'khái niệm cây toán tử và access path' thay vì dạy đọc output EXPLAIN của một database cụ thể?▸
Vì mọi RDBMS đều theo cùng nguyên lý (declarative SQL, optimizer chọn plan dạng cây, access path theo selectivity) nhưng định dạng output EXPLAIN khác nhau giữa các engine — tên toán tử, cách hiển thị cost, đơn vị đều riêng. Học output một engine chỉ dùng được cho engine đó.
Học khái niệm trước thì khi gặp bất kỳ database nào, bạn chỉ cần tra tài liệu EXPLAIN của nó để ánh xạ các khái niệm (scan, join, access path, cost) sang ký hiệu cụ thể — kiến thức chuyển được giữa mọi engine.
Bài tiếp theo: Thuật toán JOIN — nested loop, hash, merge
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