Database Internals
Database Internals
- Nền tảng — data sống ở đâu và tại sao disk I/O là bottleneck
- B-Tree — trái tim của mọi index
- Index là gì và hoạt động thế nào thực sự
- Các loại index và khi nào dùng
- Khi nào index không được dùng — Query Planner
- WAL — Write-Ahead Log và Durability
- MVCC — tại sao đọc không block ghi
- Locking — Row lock, Table lock, Deadlock
- Isolation Levels và Transaction Anomalies
- EXPLAIN ANALYZE — đọc query plan thực tế
- Demo tương tác — MVCC và Lock visualizer
- Câu hỏi phỏng vấn hay gặp
Nền tảng — data sống ở đâu và tại sao disk I/O là bottleneck
Trước khi nói index hay lock, cần hiểu một sự thật nền tảng mà mọi quyết định thiết kế trong database đều xoay quanh:
Đọc từ RAM mất ~100 nanoseconds. Đọc từ disk (HDD) mất ~10 milliseconds. Tức là disk chậm hơn RAM 100,000 lần. Mọi thứ trong database — B-Tree, Buffer Pool, WAL, MVCC — đều nhằm một mục tiêu: giảm số lần đọc/ghi disk xuống mức tối thiểu.
Bộ nhớ phân tầng
Database không đọc/ghi thẳng xuống disk từng byte. Nó hoạt động qua các tầng cache:
Database đọc data theo đơn vị page (thường 8KB), không phải từng row. Khi mày query 1 row, DB đọc cả page chứa row đó vào buffer pool. Lần query tiếp theo cùng page → cache hit, không cần đụng disk. Đây là lý do hot data nên fit vào RAM — nếu working set lớn hơn RAM, performance sụp đổ.
Mọi khi mày viết một query, hãy tự hỏi: "Query này đọc bao nhiêu pages? Những pages đó có trong buffer pool không?" Đó là cách database engineer suy nghĩ.
B-Tree — trái tim của mọi index
Hầu hết index của PostgreSQL và MySQL đều là B-Tree (chính xác là B+Tree). Trước khi nói index là gì, cần hiểu tại sao lại chọn B-Tree.
Tại sao không phải Binary Search Tree?
BST có thể mất cân bằng → O(n) worst case. Nhưng vấn đề lớn hơn: BST node chứa 1 key, mỗi node = 1 disk read. Tìm kiếm trong 1 triệu records → ~20 disk reads → ~200ms. Không chấp nhận được.
B-Tree giải quyết bằng cách
Index là gì và hoạt động thế nào thực sự
Index là một B-Tree riêng biệt, song song với table. Nó không phải magic — chỉ là một cấu trúc dữ liệu phụ được maintain song song với data thật.
Cấu trúc leaf node của index
Leaf node của index không chứa full row. Nó chứa: index key value + pointer (ctid) về vị trí row thực sự trong heap (table file). Pointer này gồm: page number + slot number trong page đó.
Trade-off của index
Mỗi index phải được update khi INSERT/UPDATE/DELETE — DB phải maintain B-Tree song song. 10 indexes trên 1 table → mỗi write phải update 11 B-Trees (1 heap + 10 indexes). Write throughput giảm tỉ lệ thuận với số index. Đây là lý do không nên index mọi thứ.
Các loại index và khi nào dùng
Composite index — Leftmost Prefix Rule
Index trên nhiều cột. Rule quan trọng nhất phải nhớ: leftmost prefix rule. Index (a, b, c) chỉ dùng được khi query có filter bắt đầu từ a.
-- Index: (last_name, first_name, age)
WHERE last_name = 'Nguyen' -- ✓ dùng được (prefix: last_name)
WHERE last_name = 'Nguyen' AND first_name = 'An' -- ✓ dùng được (prefix: last_name, first_name)
WHERE first_name = 'An' -- ✗ không dùng được (bỏ qua last_name)
WHERE last_name = 'Nguyen' AND age = 25 -- ✓ last_name dùng, age bị skip
WHERE last_name LIKE 'Ng%' AND first_name = 'An' -- ✓ cả hai dùng được (prefix match)
Quy tắc chọn thứ tự cột trong composite index: cột nào có equality filter thì đứng trước, cột nào có range filter thì đứng sau.
Partial index — index một subset
Chỉ index rows thỏa mãn một điều kiện. Kết quả: index nhỏ hơn rất nhiều, nhanh hơn, tốn ít bộ nhớ hơn.
-- Chỉ index orders đang pending — thay vì index toàn bộ 100 triệu orders
CREATE INDEX idx_pending_orders ON orders(created_at)
WHERE status = 'pending';
-- Index này chỉ dùng được cho query có WHERE status='pending'
SELECT * FROM orders WHERE status = 'pending' AND created_at > '2024-01-01'; -- ✓
Covering index — tránh heap lookup
Nếu query chỉ cần các cột đã có trong index, DB không cần đọc heap page — tiết kiệm được 1 disk read mỗi row. Với query trả về hàng nghìn rows đây là sự khác biệt rất lớn.
-- Query này cần: user_id (filter) + email, name (select)
SELECT email, name FROM users WHERE user_id = 123;
-- Index thường: chỉ có user_id → phải fetch heap để lấy email, name
CREATE INDEX idx_user_id ON users(user_id);
-- Covering index: chứa luôn email, name → index-only scan
CREATE INDEX idx_user_covering ON users(user_id) INCLUDE (email, name);
Functional index — index trên expression
-- Nếu mày hay query case-insensitive
CREATE INDEX idx_email_lower ON users(lower(email));
-- Giờ query này mới dùng được index
WHERE lower(email) = 'alice@example.com' -- ✓
WHERE email = 'Alice@example.com' -- ✗ không match functional index
Hash index
Chỉ dùng cho equality (=), không dùng cho range. Nhanh hơn B-Tree cho point lookup. PostgreSQL hỗ trợ, MySQL InnoDB không có persistent hash index.
GiST / GIN index
Full-text search, geometric data, JSONB, array. GIN tốt cho multi-value (array, tsvector). GiST tốt cho spatial queries.
BRIN index
Block Range Index — rất nhỏ, cho dữ liệu có correlation tự nhiên với thứ tự lưu trữ (time-series, auto-increment ID). Không chính xác nhưng rất nhỏ.
Clustered index
MySQL InnoDB: primary key là clustered — rows được lưu theo thứ tự PK. Non-clustered index leaf chứa PK thay vì ctid. PostgreSQL không có clustered index thật sự.
Khi nào index không được dùng
Đây là phần quan trọng nhất cho senior. Có index mà query vẫn chậm — đây là những lý do phổ biến nhất:
1. Selectivity quá thấp
Query planner tính toán rằng nếu index chỉ lọc được ít rows (ví dụ gender chỉ có 2 giá trị → 50% rows mỗi value), đọc full table scan còn nhanh hơn vì tránh được overhead của index lookup + random heap fetch.
-- Planner tự động chọn seq scan vì quá nhiều rows match
WHERE gender = 'male' -- 50% rows → seq scan thường nhanh hơn
WHERE country = 'VN' -- nếu 80% users ở VN → seq scan
WHERE status = 'active' -- nếu 95% active → chắc chắn seq scan
2. Function / Transform trên cột
WHERE YEAR(created_at) = 2024 -- ✗ full scan — function phá vỡ index
WHERE created_at >= '2024-01-01'
AND created_at < '2025-01-01' -- ✓ range scan dùng được index
WHERE UPPER(name) = 'ALICE' -- ✗ full scan
WHERE name = 'Alice' -- ✓ nếu data đồng nhất case
3. Implicit type cast
-- user_id column là VARCHAR
WHERE user_id = 123 -- ✗ DB phải cast mọi row: CAST(user_id AS INT)
WHERE user_id = '123' -- ✓ đúng type
-- phone column là VARCHAR, query dùng integer → full scan
-- Hay xảy ra khi ORM/driver tự động chuyển type
4. LIKE với leading wildcard
WHERE name LIKE '%nguyen' -- ✗ full scan — không biết bắt đầu từ đâu trên B-Tree
WHERE name LIKE 'nguyen%' -- ✓ prefix scan — traverse B-Tree từ 'nguyen'
5. OR across different columns
-- Planner thường không thể dùng index hiệu quả cho OR cross-column
WHERE email = 'x@x.com' OR phone = '0123'
-- Rewrite bằng UNION — mỗi nhánh dùng index riêng của mình
SELECT * FROM users WHERE email = 'x@x.com'
UNION
SELECT * FROM users WHERE phone = '0123'
Dùng EXPLAIN (ANALYZE, BUFFERS) để xem planner chọn gì và lý do. Nếu thấy Seq Scan trên table lớn với filter → đó là dấu hiệu cần điều tra. Xem phần 10 để đọc query plan chi tiết.
WAL — Write-Ahead Log và Durability
WAL là cơ chế đảm bảo Durability trong ACID. Rule đơn giản: ghi log trước khi ghi data.
Tại sao cần WAL?
Vấn đề: khi update một row, DB sửa page trong buffer pool (RAM). Nếu server crash trước khi page được flush xuống disk → data mất. Giải pháp naive: fsync data file sau mỗi write → cực kỳ chậm vì random I/O.
WAL giải quyết: thay vì ghi thẳng vào data file, ghi một log record mô tả thay đổi vào WAL file trước. WAL là sequential write (append-only) → rất nhanh. Client nhận OK ngay sau khi WAL được fsync. Data file được flush sau bởi background checkpoint.
WAL còn dùng cho gì nữa?
MVCC — tại sao đọc không block ghi
Một trong những điểm mạnh nhất của PostgreSQL/MySQL là SELECT không bị block bởi concurrent UPDATE. Cơ chế đằng sau là Multi-Version Concurrency Control.
Cơ chế
Thay vì lock row khi update, DB giữ nhiều version của cùng một row. Mỗi version có metadata:
xmin— transaction ID tạo ra version nàyxmax— transaction ID xóa/thay thế version này (null= current)
VACUUM — dọn dead versions
MVCC sinh ra dead row versions — rows với xmax đã set, không còn transaction nào cần nhìn thấy nữa. Nếu không dọn → table bloat → performance degradation dần dần.
VACUUM của PostgreSQL chạy tự động (autovacuum) để reclaim space. Nếu autovacuum không kịp chạy (write load quá cao) → bloat tích tụ. Monitor bằng pg_stat_user_tables.n_dead_tup.
Long-running transaction giữ snapshot cũ → VACUUM không thể dọn dead versions vì còn transaction nào đó có thể cần nhìn thấy chúng. Một transaction bỏ quên mở trong hàng giờ có thể làm table bloat cả GB.
Locking — Row lock, Table lock, Deadlock
Lock modes và compatibility
MVCC đã xử lý reader/writer conflict. Locks chỉ cần thiết cho writer vs writer.
| Shared (S) | Update (U) | Exclusive (X) | |
|---|---|---|---|
| Shared (S) | ✓ | ✓ | ✗ |
| Update (U) | ✓ | ✗ | ✗ |
| Exclusive (X) | ✗ | ✗ | ✗ |
Nhờ MVCC, SELECT thông thường không acquire Shared lock → reader không block writer, writer không block reader. Chỉ SELECT FOR UPDATE/SHARE mới acquire lock.
SELECT FOR UPDATE — pessimistic locking
Kịch bản kinh điển: deduct inventory — hai transaction cùng check stock > 0, cùng thấy stock = 1, cùng deduct → stock âm.
-- WRONG: race condition
BEGIN;
SELECT stock FROM products WHERE id = 1; -- thấy stock=1
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;
-- CORRECT: pessimistic lock
BEGIN;
SELECT stock FROM products WHERE id = 1 FOR UPDATE;
-- Transaction thứ 2 sẽ BLOCK ở đây, đợi txn 1 commit/rollback
UPDATE products SET stock = stock - 1 WHERE id = 1 WHERE stock > 0;
COMMIT;
Deadlock — và cách tránh
Deadlock xảy ra khi hai transaction chờ nhau theo vòng tròn. DB tự detect bằng wait-for graph (tìm cycle) và kill một transaction.
-- Txn A -- Txn B
LOCK row 1 LOCK row 2
LOCK row 2 ← wait LOCK row 1 ← wait
-- → circular wait → DEADLOCK
-- Solution: luôn acquire locks theo cùng thứ tự
-- Cả Txn A lẫn Txn B đều lock row 1 trước, row 2 sau
-- → không bao giờ có circular wait
Advisory locks — application-level
PostgreSQL cung cấp pg_try_advisory_lock(key) — lock theo một số nguyên tùy ý, không gắn với row nào. Dùng cho distributed locking trong ứng dụng khi muốn dùng DB thay Redis làm lock manager.
SELECT pg_try_advisory_lock(12345); -- non-blocking, return true/false
SELECT pg_advisory_lock(12345); -- blocking
SELECT pg_advisory_unlock(12345); -- release
Isolation Levels và Transaction Anomalies
ACID đảm bảo Isolation — nhưng isolation có nhiều mức độ. Higher isolation = fewer anomalies = more locking = lower concurrency.
Các loại anomaly
Isolation levels
| Level | Dirty Read | Non-repeatable | Phantom | Write Skew | Default ở |
|---|---|---|---|---|---|
| Read Uncommitted | có | có | có | có | — |
| Read Committed | không | có | có | có | PostgreSQL |
| Repeatable Read | không | không | có* | có | MySQL InnoDB |
| Serializable | không | không | không | không | — |
* MySQL InnoDB Repeatable Read dùng gap locks để ngăn phantom reads. PostgreSQL Repeatable Read dùng snapshot isolation nên không có phantom trong practice.
PostgreSQL mặc định Read Committed — đủ cho hầu hết use cases. Serializable thường không dùng vì overhead lớn. Thay vào đó dùng SELECT FOR UPDATE ở những điểm cụ thể cần strong consistency. MySQL InnoDB mặc định Repeatable Read — ổn nhưng gap locks đôi khi gây deadlock bất ngờ.
EXPLAIN ANALYZE — đọc query plan thực tế
Đây là kỹ năng thiết yếu nhưng ít được dạy. Mọi performance tuning đều bắt đầu từ đây.
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, o.total
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.email = 'alice@example.com'
AND o.created_at > '2024-01-01';
-- Output mẫu
Hash Join (cost=8.27..16.54 rows=1 width=36)
(actual time=0.082..0.086 rows=3 loops=1)
Buffers: shared hit=6
-> Index Scan using idx_users_email on users u
(cost=0.28..8.30 rows=1 width=16)
(actual time=0.015..0.016 rows=1 loops=1)
Index Cond: (email = 'alice@example.com')
Buffers: shared hit=2
-> Index Scan using idx_orders_user_date on orders o
(cost=0.28..8.14 rows=3 width=24)
(actual time=0.012..0.018 rows=3 loops=1)
Filter: (created_at > '2024-01-01')
Buffers: shared hit=4
Cách đọc output
ANALYZE table_name để update.Dấu hiệu cần điều tra
-- Dấu hiệu BAD trong EXPLAIN ANALYZE
Seq Scan on orders (rows=5000000) -- full scan table lớn
Hash Join (actual rows=1000000) -- join nhiều rows
cost=0.00..89234.56 -- cost cao bất thường
rows=1 (actual rows=50000) -- estimate sai nghiêm trọng
Buffers: shared read=8000 -- quá nhiều disk read
Demo tương tác — MVCC và Lock Visualizer
Mô phỏng hai transactions concurrent để thấy MVCC và locking hoạt động như thế nào. Mỗi transaction có một isolation level riêng.
Câu hỏi phỏng vấn hay gặp
System Design level
1. EXPLAIN (ANALYZE, BUFFERS) để xem plan thực tế.
2. Tìm Seq Scan trên table lớn → kiểm tra index có tồn tại không, có được dùng không.
3. So sánh estimated rows vs actual rows → nếu chênh nhiều: chạy ANALYZE.
4. Kiểm tra Buffers: read cao → working set không fit RAM, cần tăng shared_buffers.
5. Kiểm tra join type — Nested Loop với nhiều rows → thêm index trên join column.
— Selectivity thấp: planner thấy seq scan rẻ hơn.
— Function trên column: WHERE YEAR(col) thay vì range.
— Type mismatch: column VARCHAR nhưng query integer.
— Statistics lỗi thời: chạy ANALYZE.
— Leading wildcard: LIKE '%foo'.
— Index không match query do composite index column order sai.
DB giữ nhiều version của mỗi row với xmin/xmax. Mỗi transaction có snapshot lúc bắt đầu, chỉ thấy versions được commit trước snapshot đó. Reader đọc version phù hợp với snapshot của mình mà không cần lock. Writer tạo version mới, không xóa version cũ ngay. VACUUM dọn dead versions sau.
B-Tree lưu keys theo thứ tự sorted. Leaf nodes liên kết thành linked list. Range query (BETWEEN, >, <) traverse B-Tree đến vị trí bắt đầu rồi scan ngang qua linked list — O(log n + k) với k là số rows match. Đây là lý do index trên time column rất hiệu quả cho time range queries.
Deadlock xảy ra khi hai transactions chờ nhau theo circular dependency. DB detect bằng wait-for graph và kill một transaction. Cách tránh: luôn acquire locks theo cùng một thứ tự trong mọi transaction. Ví dụ: luôn lock user trước, order sau — không bao giờ ngược lại.
WAL là append-only log file — sequential write. Data file là random write (update pages rải rác). Sequential write nhanh hơn random write 10–100x trên HDD, và trên SSD cũng tốt hơn về write amplification. Client nhận ACK sau khi WAL fsync — không cần đợi dirty pages flush.
Câu hỏi nâng cao — staff level
Write amplification trong B-Tree
Mỗi write có thể trigger nhiều page writes khi tree rebalance (node split). Với SSD, write amplification ảnh hưởng đến tuổi thọ và throughput.
Index bloat
DELETE/UPDATE tạo dead index entries. VACUUM dọn heap nhưng index cần VACUUM FULL hoặc REINDEX để reclaim. Index bloat làm B-Tree cao hơn → nhiều disk reads hơn.
Hash join vs Nested Loop vs Merge join
Hash join tốt cho large tables không có index. Nested Loop tốt khi outer table nhỏ và inner có index. Merge join tốt khi cả hai đã sorted.
Connection pooling internals
Mỗi connection = 1 process (PostgreSQL) → overhead lớn. PgBouncer transaction-mode pooling: 1000 clients dùng chung 20 DB connections. Statement-mode không hỗ trợ prepared statements.
Partitioning strategies
Range partitioning (by date) — tốt cho time-series, partition pruning nhanh. Hash partitioning — phân tán đều. List partitioning — theo enum values. Foreign key constraints không work across partitions.
Statistics và query planner
Planner dùng pg_statistic (histogram, MCV) để estimate rows. Estimate sai → plan tệ. default_statistics_target (default=100) tăng lên cho columns có skewed distribution.
Đăng nhận xét