Tuần 9

Performance, Index, EXPLAIN và Phương ngữ

SQL cho Interview — Lộ trình 10 tuần

Nhấn phím → để bắt đầu · T để xem mục lục

1. Tổng quan tuần 9

1.1

1.1 Mục tiêu — "biết tại sao query chậm"

Tám tuần qua bạn tập trung viết query đúng. Tuần này thêm chiều thứ hai: viết query nhanh.

Trong mock interview, sau khi bạn đưa lời giải đúng, interviewer thường hỏi follow-up:

Đây là lúc phân tách candidate junior với senior. Tuần này trang bị cho bạn câu trả lời.

Sau tuần 9 bạn cần làm được

Ghi chú
Tuần này thiên về kiến thức nền, không phải LeetCode. Bài tập sẽ thiên về local testing với DB Fiddle hoặc PostgreSQL local.
1.2

1.2 Mental model — storage, plan, execution

Khi bạn viết một query, ba thứ xảy ra trong DB engine:

  1. Parse — kiểm tra cú pháp, tên cột/bảng tồn tại.
  2. Plan — optimizer chọn execution plan: scan strategy, join order, join algorithm. Đây là phần EXPLAIN cho bạn xem.
  3. Execute — chạy plan, đọc data từ disk/cache, áp filter, ghép, aggregate.

Tốc độ query phụ thuộc chính vào số trang disk phải đọc — không phải số dòng output. Một bảng 1M dòng có index phù hợp có thể đọc 100 trang. Cùng bảng full scan đọc 100,000 trang. Khác biệt 1000×.

Hai biến quan trọng nhất

Quy tắc nắm tay
"Index hữu ích nhất khi query trả về < 5–10% số dòng bảng." Trên đó, optimizer thường chọn full scan — vì đọc tuần tự nhanh hơn random reads qua index.

2. Index Basics

2.1

2.1 B-tree index — cấu trúc và hoạt động

B-tree index
Cấu trúc cây cân bằng (balanced tree). Tìm kiếm, chèn, xóa đều O(log n). Là loại index mặc định của hầu hết RDBMS.
[100 | 500] [20 | 50 | 80] [200 | 350] [600 | 900] leaf: 5,8,15,18,42,... leaf: 110,150,210,... leaf: 510,580,650,...

Tìm id = 250: từ root → node giữa (250 ≥ 200) → leaf. 3 lần đọc disk thay vì scan toàn bảng.

B-tree hỗ trợ tốt

KHÔNG hỗ trợ tốt: LIKE '%X', LIKE '%X%', function trên cột.

2.2

2.2 Composite index — column order quan trọng

Composite index
Index trên nhiều cột, ví dụ (country, city, signup_date). Thứ tự cột là quan trọng — index hoạt động như "sort multi-key".

Quy tắc "leftmost prefix"

Index (A, B, C) hỗ trợ:

Quy tắc thiết kế composite index
Sắp xếp cột theo thứ tự: equality predicate trước, range/sort cuối.
-- Query: WHERE country = 'VN' AND signup_date > '2024-01-01' ORDER BY signup_date
CREATE INDEX idx_country_date ON customers(country, signup_date);
-- (country) trước vì là equality, (signup_date) sau cho range + sort
Cảnh báo
Index (signup_date, country) KHÔNG dùng được cho query trên — vì range trên cột đầu phá khả năng dùng cột thứ hai để lọc tiếp. Order matters.
2.3

2.3 Hash index và các loại khác

Loại indexTốt choYếu ởPhương ngữ
B-treeEquality, range, sort, LIKE prefixLIKE '%X'Mặc định mọi RDBMS
HashEquality (O(1))Range, sortPostgreSQL, MySQL Memory engine
BitmapCột low-cardinality (giới tính, country)OLTP với nhiều updateOracle, SQL Server (filtered)
GIN/GiSTFull-text, JSON, arrayQuery đơn giảnPostgreSQL
Full-textTìm word trong textSubstring nhỏMySQL, PostgreSQL, SQL Server

Khi nào dùng hash index?

Hiếm. PostgreSQL có hash index nhưng B-tree thường tốt tương đương cho equality trên data thông thường. Lý do duy nhất chọn hash: cột rất nhiều giá trị unique và bạn KHÔNG cần range query.

Trong interview
99% câu hỏi index xoay quanh B-tree. Biết tồn tại các loại khác là đủ. Đừng đề xuất bitmap index trừ khi cụ thể đang dùng Oracle.
2.4

2.4 Khi index không giúp (hoặc làm chậm)

Index hữu ích KÉM khi
Index làm CHẬM khi
Quy tắc thực tế

3. EXPLAIN — đọc execution plan

3.1

3.1 EXPLAIN — cú pháp và mục đích

EXPLAIN trả về kế hoạch thực thi (mà optimizer chọn). Không thực thi query, chỉ show plan.

EXPLAIN ANALYZE (PostgreSQL/MySQL 8.0+) chạy thật và đo thời gian — quan trọng hơn cho debug performance.

-- PostgreSQL / MySQL 8+
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;

-- SQL Server
SET SHOWPLAN_TEXT ON;
SELECT * FROM orders WHERE customer_id = 42;

Output PostgreSQL EXPLAIN — cần biết đọc

QUERY PLAN ───────────────────────────────────────────────────────────────────── Index Scan using idx_customer on orders (cost=0.42..8.44 rows=10 width=64) Index Cond: (customer_id = 42)

Đọc:

3.2

3.2 Scan types — đọc ngắn gọn

Scan typeÝ nghĩaKhi nào tốt/xấu
Seq Scan / Full Table ScanĐọc tuần tự cả bảngTốt nếu query trả >10% bảng. Xấu nếu bảng lớn và predicate selective.
Index ScanĐi qua index, lấy row pointer, đọc bảngTốt cho query selective. Xấu nếu trả nhiều dòng (random IO trên bảng đắt).
Index Only ScanToàn bộ data trong index, không đụng bảngNhanh nhất. Cần index phủ hết cột query (covering index).
Bitmap Index ScanBuild bitmap từ index, sau đó fetch dòngTốt cho OR và AND nhiều predicate.
Cảnh báo Seq Scan trên bảng lớn
Khi EXPLAIN trả về Seq Scan trên bảng > 1M dòng và bạn mong đợi index — kiểm tra:
  1. Index có tồn tại không? (\d table_name trong psql)
  2. Có function trên cột? (slide 4.1)
  3. Statistics có cũ không? (ANALYZE table_name)
  4. Predicate có quá lỏng (chọn >10% dòng)?
3.3

3.3 Join algorithms — ba loại chính

AlgorithmCách hoạt độngTốt khi
Nested Loop JoinVới mỗi dòng A, scan B tìm match. O(n×m) hoặc O(n×log m) nếu B có index.A nhỏ, B có index trên join key. OLTP.
Hash JoinBuild hash table trên A, probe B từng dòng. O(n+m).Cả hai bảng vừa, không có index trên join key. OLAP.
Merge JoinSort cả hai theo join key, merge. O(n log n + m log m).Cả hai đã sorted theo join key (vì index hoặc ORDER BY).
Plan đọc thực tế
Hash Join (cost=12.50..1234.00 rows=5000 width=128) Hash Cond: (o.customer_id = c.customer_id) -> Seq Scan on orders o (cost=0.00..200.00 rows=10000 width=64) -> Hash (cost=10.00..10.00 rows=200 width=64) -> Seq Scan on customers c (cost=0.00..10.00 rows=200 width=64)

Đọc bottom-up: scan customers → build hash → scan orders → probe hash. Tốt vì 200 customers nhỏ, hash vào memory dễ.

Trong interview
Khi follow-up "query này dùng join algorithm nào?", thường trả lời: "Tùy size của bảng. Nếu A nhỏ và B có index trên join key, optimizer chọn nested loop. Hai bảng lớn, hash join. Đã sort hoặc clustered, merge join. EXPLAIN sẽ cho biết chính xác."
3.4

3.4 EXPLAIN ANALYZE — actual vs estimated

EXPLAIN cho ước lượng. EXPLAIN ANALYZE chạy thật và so sánh.

Index Scan using idx_customer on orders (cost=0.42..8.44 rows=10 width=64) (actual time=0.024..0.156 rows=8 loops=1) Index Cond: (customer_id = 42) Planning Time: 0.123 ms Execution Time: 0.184 ms

Phần in đậm (actual time=...) là số đo thực tế. So sánh:

Lưu ý EXPLAIN ANALYZE
Thực sự chạy query — với DELETE, UPDATE, INSERT, query CHẠY THẬT và sửa data. Trên production, bao trong transaction rồi rollback:
BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE order_date < '2020-01-01';
ROLLBACK;

4. Anti-patterns thường gặp

4.1

4.1 Function trên cột đã index

Anti-pattern phổ biến nhất. Đặt function lên cột → optimizer không dùng index.

SAI

-- Function trên cột → seq scan
WHERE YEAR(order_date) = 2024

WHERE LOWER(email) = 'a@b.com'

WHERE order_date + INTERVAL 7 DAY > NOW()

ĐÚNG

-- Predicate có thể dùng index
WHERE order_date >= '2024-01-01'
  AND order_date <  '2025-01-01'

WHERE email = LOWER('a@b.com')
-- (giả sử email đã lowercase)

WHERE order_date > NOW() - INTERVAL 7 DAY
Functional index — workaround
Khi không tránh được function (ví dụ case-insensitive search):
-- PostgreSQL/SQL Server hỗ trợ
CREATE INDEX idx_email_lower ON users(LOWER(email));
-- Bây giờ WHERE LOWER(email) = '...' DÙNG được index
MySQL từ 8.0+ hỗ trợ functional index. Trước đó phải tạo cột generated.
4.2

4.2 SELECT * vs cột cụ thể

SELECT * tiện cho ad-hoc query. Nhưng trong production code có ba vấn đề:

  1. Băng thông và memory — kéo về cột không cần. Bảng có cột TEXT/BLOB lớn → đắt.
  2. Phá covering index — index có thể phủ hết cột query (index only scan, nhanh nhất). SELECT * ép phải đọc bảng gốc.
  3. Code fragility — schema thay đổi (thêm cột) → query có behavior khác mà người viết không lường được.
Covering index ví dụ
CREATE INDEX idx_cust_status_total ON orders(customer_id, status, total);

-- Index only scan (nhanh nhất) — không đọc bảng
SELECT customer_id, status, total FROM orders WHERE customer_id = 42;

-- Index scan + table fetch — đọc thêm bảng
SELECT * FROM orders WHERE customer_id = 42;
-- (vì cần order_id, order_date, ... không có trong index)

Trong code review và interview: cột cụ thể là norm, SELECT * chỉ trong scratch query.

4.3

4.3 OR vs UNION ALL với cột khác nhau

Khi predicate có OR trên hai cột khác nhau (mỗi cột có index riêng), optimizer thường không dùng được cả hai index — fallback seq scan.

Có thể chậm

-- Hai cột có index, nhưng OR
SELECT * FROM customers
WHERE email   = 'a@b.com'
   OR phone   = '0901234567';

-- Optimizer thường chọn seq scan

Tối ưu rõ ràng

SELECT * FROM customers
WHERE email = 'a@b.com'
UNION ALL
SELECT * FROM customers
WHERE phone = '0901234567'
  AND email IS DISTINCT FROM 'a@b.com';
-- (loại trùng nếu cùng row có cả hai)
Khi nào áp dụng
Trong interview, đề cập: "Tôi sẽ kiểm tra EXPLAIN. Nếu thấy Seq Scan, refactor sang UNION ALL với điều kiện loại trùng."
4.4

4.4 Anti-pattern khác — quick reference

Anti-patternVấn đềFix
DISTINCT để fix join nhân dòngJOIN sai cardinality, DISTINCT che bugSửa JOIN (thêm điều kiện), hoặc EXISTS thay JOIN
Implicit type conversionWHERE id = '5' với INT → cast → mất indexDùng đúng kiểu: WHERE id = 5
NOT IN trên subquery NULL-ableBug tập rỗng (đã học tuần 2)NOT EXISTS
COUNT để check tồn tạiCOUNT(*) > 0 phải scan hếtEXISTS — dừng ở dòng đầu match
Cursor / loop trong stored procRow-by-row, chậm hơn set-based 100×Refactor thành single UPDATE/INSERT...SELECT
OFFSET lớn cho paginationOFFSET 100000 phải scan và bỏ 100K dòngKeyset pagination: WHERE id > last_seen_id LIMIT N

Keyset pagination — pattern senior

-- Anti-pattern: OFFSET (chậm trên trang xa)
SELECT * FROM orders ORDER BY id LIMIT 50 OFFSET 100000;

-- Pattern: keyset (luôn nhanh)
SELECT * FROM orders WHERE id > 100000 ORDER BY id LIMIT 50;
-- Cần track id cuối từ trang trước

Đề cập keyset pagination trong mock interview là tín hiệu candidate có production experience.

5. Phương ngữ — Tổng kết

5.1

5.1 String và Date functions

Tác vụMySQLPostgreSQLSQL Server
ConcatCONCAT(a,b)a || b hoặc CONCATCONCAT(a,b) hoặc a + b
Length (chars)CHAR_LENGTHLENGTH hoặc CHAR_LENGTHLEN
SubstringSUBSTRING(s,start,len)SUBSTRING(s FROM start FOR len)SUBSTRING(s,start,len)
PositionINSTR / LOCATEPOSITION(x IN s)CHARINDEX(x,s)
Now (datetime)NOW()NOW()GETDATE()
Today (date)CURDATE()CURRENT_DATECAST(GETDATE() AS DATE)
Add daysDATE_ADD(d, INTERVAL 7 DAY)d + INTERVAL '7 days'DATEADD(day, 7, d)
Diff daysDATEDIFF(b, a)b - aDATEDIFF(day, a, b)
Year partYEAR(d)EXTRACT(YEAR FROM d)YEAR(d)
Format dateDATE_FORMATTO_CHARFORMAT (2012+)
Strategy interview
LeetCode mặc định MySQL — tập trung MySQL syntax. StrataScratch / DataLemur dùng PostgreSQL — biết equivalent. Chuẩn ANSI (EXTRACT, SUBSTRING ... FROM ... FOR) chạy 2/3 phương ngữ.
5.2

5.2 Pagination, NULL handling

Pagination — LIMIT/TOP/FETCH

Phương ngữCú pháp
MySQL, PostgreSQL, SQLiteSELECT ... LIMIT 10 OFFSET 20
SQL Server (truyền thống)SELECT TOP 10 ... (không có offset)
SQL Server 2012+ / Oracle 12c+OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY (chuẩn ANSI)

NULL handling — null-safe equality

Phương ngữToán tửHành vi
PostgreSQL, SQLiteIS DISTINCT FROMChuẩn ANSI. NULL = NULL → false (bằng), khác → true
MySQL<=>Null-safe equals. NULL <=> NULL = TRUE
SQL Serverkhông có; viết tay(a = b OR (a IS NULL AND b IS NULL))

NULL replacement

HàmPhương ngữ
COALESCE(a, b, ...)Chuẩn ANSI — chạy mọi nơi. Khuyên dùng.
IFNULL(a, b)MySQL, SQLite
ISNULL(a, b)SQL Server (KHÁC IS NULL!)
NVL(a, b)Oracle
5.3

5.3 Set operations và feature support

FeatureMySQLPostgreSQLSQL ServerOracle
UNION / UNION ALL
INTERSECT✗ (8.0.31+: ✓)
EXCEPT✗ (8.0.31+: ✓)MINUS
FULL OUTER JOIN✗ (mô phỏng UNION)
LATERAL JOIN8.0.14+ (CROSS APPLY)CROSS APPLY12c+
CTE (WITH)8.0+✓ (8.4+)2005+11g+
Recursive CTE8.0+2005+11gR2+
Window functions8.0+✓ (8.4+)2005+ (full 2012+)10g+
FILTER clause9.4+
PERCENTILE_CONT9.4+2012+10g+
JSON support5.7+✓ (9.2+)2016+12c+
Regex matchREGEXP~ / ~*✗ (chỉ LIKE)REGEXP_LIKE

LeetCode dùng MySQL 8+; mọi feature hiện đại đều dùng được. StrataScratch/DataLemur dùng PostgreSQL. Trong production, phải biết platform team đang dùng.

5.4

5.4 Strategy phương ngữ trong interview

  1. Hỏi platform trước. "Code này tôi viết cho dialect nào?" — câu hỏi đầu tiên trong mock interview.
  2. Default đa dialect. Khi không biết platform: viết theo chuẩn ANSI nhiều nhất có thể (COALESCE, EXTRACT, CASE WHEN thay vì IF).
  3. Đề cập alternative. Khi viết MySQL-specific (DATE_FORMAT), có thể nói: "Nếu trên PostgreSQL, sẽ dùng TO_CHAR."
  4. Biết "must-have" mỗi platform.
    • MySQL: LIMIT n OFFSET m, CONCAT, DATE_ADD ... INTERVAL, DATE_FORMAT.
    • PostgreSQL: || concat, EXTRACT, DATE_TRUNC, FILTER.
    • SQL Server: TOP, DATEADD/DATEDIFF, FORMAT, CHARINDEX.
Tín hiệu candidate strong
Khi viết code, comment dòng dialect-specific: -- MySQL syntax. Cho thấy bạn biết code không universal — và có thể adapt khi cần. Senior interviewer thấy đây là production sensibility.

6. Tối ưu — Worked Examples

6.1

6.1 Case study — query chậm và cách fix

Query gốc

SELECT *
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE YEAR(o.order_date) = 2024
  AND c.country = 'Vietnam'
ORDER BY o.order_date DESC
LIMIT 100;

EXPLAIN report

Sort (cost=12345.67..12356.78 rows=4444 width=128) Sort Key: o.order_date DESC -> Hash Join (cost=...) Hash Cond: (c.id = o.customer_id) -> Seq Scan on orders o (cost=...) ← BAD: full scan! Filter: (date_part('year', order_date) = 2024) -> Hash (cost=...) -> Seq Scan on customers c Filter: (country = 'Vietnam')

Vấn đề và fix

  1. YEAR(order_date) ngăn dùng index trên order_date → fix: nửa-mở interval.
  2. Customers seq scan nếu country index thiếu → tạo index.
  3. SELECT * kéo nhiều cột không cần — chọn cột cụ thể.
-- Fix:
CREATE INDEX idx_customer_country ON customers(country);
CREATE INDEX idx_orders_date_customer ON orders(order_date, customer_id);

SELECT o.order_id, o.order_date, o.total, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.order_date >= '2024-01-01'
  AND o.order_date <  '2025-01-01'
  AND c.country = 'Vietnam'
ORDER BY o.order_date DESC
LIMIT 100;

Sau fix: index scan trên orders, hash join nhỏ, sort tránh được nếu LIMIT đủ chặt → có thể nhanh 100×.

6.2

6.2 Index strategy cho 7 pattern (tuần 8)

PatternIndex đề xuất
Top-N per group(group_key, rank_key DESC) — covering nếu có thể
Median per group(group_key, value) — sort cho ROW_NUMBER ngại
Gaps and Islands(group_key, date) — order cho ROW_NUMBER
Consecutive Sequences(group_key, id) — LAG cần sort
PivotIndex trên cột GROUP BY và cột CASE WHEN
Cumulative(partition_key, time_key)
Date arithmeticIndex trên cột date — KHÔNG bọc function
Quy tắc thiết kế
Cho query:
SELECT customer_id, name, total,
       ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY total DESC) rn
FROM orders WHERE status = 'paid';
Index tốt: (status, customer_id, total DESC) Nếu cần covering index, thêm INCLUDE (name) (PostgreSQL 11+, SQL Server) hoặc thêm name vào index list (MySQL).

7. Knowledge Check

7.Q

Knowledge Check — Tuần 9

Q1: Cho query SELECT * FROM orders WHERE YEAR(order_date) = 2024. EXPLAIN trả về Seq Scan dù có index trên order_date. Vì sao?
B đúng. Function trên cột → index không match được pattern. Fix: viết predicate dạng nửa-mở order_date >= '2024-01-01' AND order_date < '2025-01-01'. Hoặc dùng functional index CREATE INDEX ON orders(YEAR(order_date)) (MySQL 8+, PostgreSQL).
Q2: Composite index (country, city, signup_date). Query nào KHÔNG dùng được index?
Quy tắc leftmost prefix: index dùng được khi predicate phủ từ cột đầu tiên. C bỏ qua country (cột đầu), nên không match prefix nào → seq scan. D dùng được phần country, nhưng vì city không có, signup_date dùng kém hiệu quả hơn so với khi có city.
Q3: Pagination trang xa: SELECT * FROM orders ORDER BY id LIMIT 50 OFFSET 1000000. Vấn đề và fix?
B đúng. OFFSET không skip ở storage level — engine vẫn phải đọc 1M dòng đầu rồi vứt đi. Keyset pagination: track id cuối trang trước, query WHERE id > last_id ORDER BY id LIMIT 50. Mỗi trang cùng độ phức tạp, không phụ thuộc trang xa hay gần.

8. Bài tập về nhà

8.1

8.1 Hands-on — local PostgreSQL hoặc DB Fiddle

Tuần này không phải LeetCode — luyện tools thật. Cài PostgreSQL local hoặc dùng DB Fiddle (db-fiddle.com).

Bài tập

  1. Setup data sinh tổng hợp. Tạo bảng orders với 100K dòng (dùng generate_series trên PostgreSQL hoặc recursive CTE trên MySQL — đã học tuần 7).
  2. Query không index. Chạy EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42. Note thời gian.
  3. Tạo index single-col. CREATE INDEX idx_cust ON orders(customer_id). Chạy lại EXPLAIN ANALYZE. So sánh.
  4. Composite index. Query WHERE customer_id = 42 AND status = 'paid' ORDER BY order_date. Thử 3 thứ tự cột khác nhau cho composite index. EXPLAIN cho từng cái. Quan sát.
  5. Anti-pattern test. Chạy WHERE YEAR(order_date) = 2024 trước và sau khi viết lại bằng nửa-mở. Note Seq Scan vs Index Scan.
  6. Pagination test. Chạy LIMIT 50 OFFSET 50000. Refactor sang keyset. So tốc độ.

Bonus — cross-dialect

Lấy 3 query bạn đã làm tuần 6-8. Viết lại trên cả MySQL syntax VÀ PostgreSQL syntax. Note tất cả các điểm khác (function, syntax window, COALESCE/IFNULL, ...).

8.2

8.2 Đọc thêm

Đọc thiết yếu

Tham khảo dialect

Tự kiểm tra (không nhìn slide)

Tuần sau — Mock Interview và Capstone
Tuần 10: quy trình mock interview 45 phút, edge case checklist, capstone integrating tất cả 9 tuần. Đây là tuần áp lực thật — kiểm tra reflexes pattern recognition, code clarity, communication trong điều kiện time pressure.

Mục lục

Nhấn T hoặc Escape để đóng