Database Internals

Database Internals
Database Internals · Index · Lock · Transaction · Deep Dive

Database Internals

Từ gốc rễ đến ngọn — hiểu tại sao B-Tree, tại sao WAL, tại sao MVCC, và cách index thực sự hoạt động bên dưới. Dành cho senior engineer cần nắm internals thật sự.
Core engines PostgreSQL · MySQL InnoDB
Key concept Disk I/O là bottleneck của mọi thứ
Applies to System Design · Senior Interview
01

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:

Sự thật cốt lõi

Đọ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:

Buffer Pool (RAM) ~nanoseconds · 8KB pages · volatile cache miss OS Page Cache ~microseconds · managed by kernel page fault Disk — Data Files ~milliseconds · pages 8KB · persistent WAL File — sequential write · append-only · crash recovery

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 đổ.

Mental model quan trọng

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ĩ.

02

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

Mỗi node chứa nhiều key
Page 8KB, key 8 bytes → mỗi node chứa ~500 keys. Tree 3 tầng chứa được 500³ ≈ 125 triệu entries. Tìm bất kỳ row nào chỉ cần đọc 3 pages = 3 disk reads ≈ 30ms.
Self-balancing — luôn O(log n)
Khi insert/delete, B-Tree tự split/merge nodes để giữ cân bằng. Không bao giờ degenerate như BST.
Leaf nodes liên kết thành linked list
Đây là khác biệt của B+Tree so với B-Tree thông thường. Leaf nodes được link với nhau → range scan rất nhanh: traverse đến node đầu tiên rồi scan ngang, không cần quay lại root.
Node size = page size
Được thiết kế để mỗi node fit vừa đúng 1 disk page. Đọc một node = 1 disk I/O. Cache-friendly với OS page cache.
ROOT 30 · 70 INTERNAL 10 · 20 40 · 60 80 · 90 LEAF 5 · 8 12 · 25 35 · 38 45 · 55 75 · 78 85 · 95 Leaf nodes liên kết nhau (linked list) → range scan không cần quay lại root Height ~3-4 tầng cho hàng triệu rows → tối đa 3-4 disk reads để tìm bất kỳ row nào
03

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 đó.

Index B-Tree (email) Heap (table file) alice@ · bob@ · carol@ alice@ → (p3, s2) bob@ → (p1, s4) Page 1 s4: bob · 25 · engineer Page 2 dave · 30 · designer Page 3 s2: alice · 22 · student Index scan = đọc index page + đọc heap page (2 disk reads/row) Index-only scan = chỉ đọc index, không cần heap → covering index

Trade-off của index

Index không miễn phí

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ứ.

04

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ự.

05

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'
Cách kiểm tra

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.

06

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.

Client UPDATE Buffer Pool modify page in RAM 1. write WAL record WAL Buffer in-memory log 2. fsync → disk WAL File (disk) sequential · durable OK (after WAL fsync) Client ← ACK Checkpoint (background) Dirty pages → Data file random write · lazy không block client WAL = sequential write (fast) · Data file = random write (slow) → tách biệt để tối ưu Crash recovery: replay WAL từ last checkpoint → khôi phục đúng state

WAL còn dùng cho gì nữa?

Streaming Replication
Replica liên tục nhận WAL records từ primary và apply. Đây là cơ chế replication mặc định của PostgreSQL — replica luôn là phản chiếu đúng của primary.
Logical Replication
Decode WAL thành logical changes (INSERT/UPDATE/DELETE) — có thể replicate sang DB khác version, khác engine, hoặc feed vào Kafka/Debezium cho CDC.
Point-in-Time Recovery
Kết hợp base backup + WAL archive để restore DB về bất kỳ thời điểm nào trong quá khứ. Thiết yếu cho production DR.
07

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ày
  • xmax — transaction ID xóa/thay thế version này (null = current)
Row: users WHERE id = 1 Version 1: name='Alice', age=22 xmin=100 · xmax=200 · dead — visible to txn started before 200 Version 2: name='Alice', age=23 xmin=200 · xmax=null · current — visible to txn started after 200 Transaction txn_id = 150 → thấy Version 1: age = 22 snapshot isolation · không bị block dù txn 200 đang update cùng lúc Transaction txn_id = 250 → thấy Version 2: age = 23 Version 1 invisible xmax=200 < txn_id=250

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.

Antipattern phổ biến

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.

08

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
09

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

Dirty Read
Đọc data của transaction chưa commit. Transaction kia rollback → mày đọc data không bao giờ tồn tại. Chỉ xảy ra ở Read Uncommitted.
Non-repeatable Read
Đọc cùng row hai lần trong cùng transaction → kết quả khác nhau vì transaction khác đã UPDATE và commit ở giữa. Xảy ra ở Read Committed.
Phantom Read
Query cùng điều kiện hai lần → lần hai có thêm/bớt rows vì transaction khác đã INSERT/DELETE và commit. Xảy ra ở Repeatable Read (trừ InnoDB).
Lost Update
Hai transactions đọc cùng giá trị, cùng modify, cùng write lại → một trong hai bị overwrite. Tránh bằng SELECT FOR UPDATE hoặc optimistic locking.
Write Skew
Hai transactions đọc cùng snapshot, cùng thỏa mãn constraint, cùng update phần khác nhau → constraint bị vi phạm sau khi cả hai commit. Chỉ ngăn được ở Serializable.

Isolation levels

LevelDirty ReadNon-repeatablePhantomWrite SkewDefault ở
Read Uncommitted
Read CommittedkhôngPostgreSQL
Repeatable Readkhôngkhôngcó*MySQL InnoDB
Serializablekhôngkhôngkhôngkhô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.

Thực tế production

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ờ.

10

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

cost=X..Y
X = startup cost (trước khi ra row đầu tiên), Y = total cost. Đơn vị: arbitrary units (roughly = disk page reads). Planner dùng để so sánh các plan.
actual time=X..Y
Thời gian thực tế (ms). X = thời gian đến row đầu tiên, Y = tổng. So sánh với cost để thấy planner estimate có chính xác không.
rows= estimate vs actual
Nếu estimate và actual chênh lệch nhiều (vd: estimate=1 actual=10000) → statistics lỗi thời → chạy ANALYZE table_name để update.
Buffers: shared hit/read
hit = lấy từ buffer pool (fast). read = đọc từ disk (slow). Tỉ lệ hit/total cao = tốt. read nhiều = working set không fit RAM.
Seq Scan vs Index Scan
Seq Scan trên table nhỏ = OK. Seq Scan trên table lớn với low rows output = vấn đề. Index Scan = đi qua B-Tree. Bitmap Heap Scan = batch index lookup, tốt cho medium selectivity.

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
11

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.

MVCC + Lock Simulator — Row: users WHERE id = 1
Isolation level:
Transaction A (txn_id=200)
Transaction B (txn_id=201)
Row state & versions
Event log
12

Câu hỏi phỏng vấn hay gặp

System Design level

Query chậm — debug như thế nào?

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.

Tại sao có index mà query vẫn không dùng?

— 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.

MVCC hoạt động thế nào? Tại sao reader không block writer?

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.

Giải thích B-Tree index và khi nào range query tận dụng được

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 nào và tránh thế nào?

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à gì và tại sao nó nhanh hơn ghi thẳng vào data file?

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.