I. Lời nói đầu
Đợt này mình rảnh rảnh đọc sách System Design Interview của anh Alex Xu. Ngay chapter 1 đã giới thiệu về rất nhiều loại Database, từ relational database cho tới non-relational database. Mình cũng từng dùng được 1 vài database nhưng thật sự ko hiểu nổi cơ chế đằng sau như thế nào.
Cụ thể thì mình luôn tự hỏi: “Thật sự lưu dữ liệu dạng bảng (table) trong SQL nó là cái quái gì ? Dữ liệu được lưu vào đâu, máy tính sẽ đọc như thế nào để có thể lưu và truy vấn nó ra sao ?”
Cũng nhờ thời này AI rất mạnh, mình đã có thể hiểu được bản chất của các cơ chế này. Mình tính viết thành 1 series tìm hiểu và phân tích về cách hoạt động của các dạng database. Trước tiên thì trong bài này, mình sẽ ghi lại những gì mình tìm hiểu được về Relational Database, hay còn gọi là SQL.
II. Cơ chế đằng sau Relational Database
Bản chất là: database không “lưu bảng” giống như file Excel, mà nó lưu dữ liệu thành các page/block nhị phân trên disk. “Bảng”, “dòng”, “cột” chỉ là mô hình logic để người dùng nhìn và query bằng SQL.
Ta có thể phân tách thành 3 layer
2.1. Layer logic: table, row, column
Khi ta tạo bảng:
1 | CREATE TABLE users ( |
Ta nhìn thấy dữ liệu dạng:
| id | name | age |
|---|---|---|
| 1 | Long | 25 |
| 2 | Nam | 30 |
Đây là cách database trình bày dữ liệu cho con người. Nhưng trên ổ đĩa, nó không lưu y nguyên thành bảng đẹp như vậy.
1. Table không phải là một “file bảng” đơn giản
Khi bạn tạo bảng:
1 | CREATE TABLE users ( |
Bạn nhìn thấy:
| id | name | age |
|---|---|---|
| 1 | Long | 25 |
| 2 | Nam | 30 |
Nhưng database engine không lưu kiểu:
1 | id,name,age |
Trừ khi đó là file CSV. Còn database như MySQL InnoDB, Oracle, PostgreSQL… thường lưu theo cấu trúc nhị phân có tổ chức.
Có thể hình dung:
1 | Table users |
2. Datafile là gì?
Database cần một nơi vật lý để lưu dữ liệu trên ổ cứng/SSD.
Với MySQL InnoDB, nếu dùng innodb_file_per_table, mỗi bảng có thể có file riêng:
1 | users.ibd |
Với Oracle, dữ liệu nằm trong các datafile:
1 | users01.dbf |
Nhưng datafile này không phải text file. Bạn mở bằng Notepad sẽ không đọc được bình thường, vì bên trong là dữ liệu nhị phân.
Ví dụ tưởng tượng file users.ibd:
1 | users.ibd |
Database không xử lý từng byte rời rạc một cách tùy tiện. Nó chia file thành các đơn vị lớn hơn gọi là page hoặc block.
3. Page/block là gì?
Page/block là đơn vị đọc/ghi cơ bản của database.
Ví dụ MySQL InnoDB thường dùng page 16 KB.
Nghĩa là khi cần đọc một dòng dữ liệu, database thường không chỉ đọc đúng vài chục byte của dòng đó. Nó đọc cả page chứa dòng đó vào memory.
Ví dụ:
1 | Page size = 16 KB |
Một page không chỉ chứa data row. Nó còn chứa metadata để database quản lý page đó.
Một page có thể gồm:
1 | Page |
Nói dễ hiểu: page giống như một trang trong cuốn sổ, mỗi trang chứa nhiều dòng ghi chép, nhưng trên trang đó còn có số trang, đánh dấu, phần trống, thông tin kiểm tra.
4. Vì sao phải chia thành page/block?
Vì ổ đĩa và memory hoạt động hiệu quả hơn khi đọc/ghi theo block lớn.
Nếu mỗi lần query database phải đọc từng byte nhỏ lẻ thì rất chậm.
Thay vào đó:
1 | Disk → đọc một page 16 KB → đưa vào RAM → xử lý nhiều record trong page đó |
Ví dụ bạn query:
1 | SELECT * FROM users WHERE id = 2; |
Nếu dòng id = 2 nằm trong Page 100, database sẽ đọc Page 100 vào memory.
1 | Disk |
Dù bạn chỉ cần id=2, database vẫn thường đọc cả page chứa nó.
Đây là lý do database rất quan tâm đến:
1 | Page size |
5. Record/row nằm trong page như thế nào?
Giả sử page có 16 KB.
Một record nhỏ có thể chỉ vài chục bytes.
Ví dụ bảng:
1 | CREATE TABLE users ( |
Dòng:
1 | id = 1 |
Có thể được lưu đại khái như sau:
1 | Record |
Tức là:
1 | [header][id][name_length][name_data][age] |
Trong đó:
1 | header → metadata của record |
6. Record header chứa gì?
Record header là phần database dùng để quản lý row.
Nó có thể chứa các thông tin kiểu như:
1 | - Record này còn sống hay đã bị đánh dấu xóa? |
Ví dụ rất đơn giản:
1 | Record header |
Bạn có thể hiểu: row không chỉ gồm dữ liệu bạn nhập, mà còn có dữ liệu phụ để database quản lý giao dịch, xóa, update, rollback, concurrency.
Ví dụ bạn thấy:
1 | id=1, name=Long, age=25 |
Nhưng database có thể lưu thêm:
1 | row này được tạo bởi transaction nào |
2.2. Physic layer: file, page/block, record
Database lưu dữ liệu trong các file dữ liệu.
Ví dụ:
MySQL InnoDB thường có:
1 | .ibd file |
Oracle có:
1 | .dbf datafile |
Bên trong các file này, dữ liệu được chia thành nhiều page hoặc block.
Ví dụ đơn giản:
1 | Data file |
Mỗi page/block có kích thước cố định.
Ví dụ phổ biến:
1 | MySQL InnoDB page: 16 KB |
Trong mỗi page/block sẽ chứa nhiều record, tức là nhiều dòng dữ liệu.
Ví dụ:
1 | Page 1 |
Nói ngắn gọn:
1 | Table |
3. Một dòng dữ liệu được lưu như thế nào?
Giả sử có dòng:
1 | id = 1 |
Database sẽ encode nó thành dạng nhị phân.
Ví dụ rất đơn giản hóa:
1 | [header][id][name_length][name_data][age] |
Có thể hình dung:
1 | Row record: |
Các kiểu dữ liệu khác nhau sẽ được lưu khác nhau:
1 | INT → số nhị phân cố định, ví dụ 4 bytes |
Ví dụ:
1 | INT |
không lưu thành ký tự "123" như text, mà lưu thành số nhị phân.
Còn:
1 | VARCHAR('Long') |
thường lưu kiểu:
1 | độ dài = 4 |
Ví dụ trực quan
Bạn có bảng:
1 | users |
Bên ngoài bạn nhìn thấy:
1 | +----+------+-----+ |
Nhưng bên trong disk có thể giống thế này:
1 | File: users.ibd |
Database engine sẽ biết:
1 | Bảng users nằm ở những page nào |
Vậy index thì sao?
Nếu không có index, muốn tìm:
1 | SELECT * FROM users WHERE id = 2; |
Database có thể phải đọc nhiều page, nhiều record để tìm.
Index sinh ra để tránh việc này.
Trong MySQL InnoDB, index thường dùng cấu trúc B+Tree.
Có thể hiểu như mục lục sách:
1 | Index on id |
Khi tìm id = 2, database không cần đọc toàn bộ bảng, mà đi qua cây index để tìm đúng page chứa record.
Với InnoDB, nếu bảng có primary key, dữ liệu của bảng được lưu theo clustered index. Nghĩa là:
1 | Primary key index chứa luôn dữ liệu row |
Nói cách khác, trong InnoDB:
1 | Table data được tổ chức theo B+Tree của primary key |
Không phải bảng nằm riêng, index nằm riêng hoàn toàn như nhiều người tưởng.
MySQL và Oracle có giống nhau không?
Về bản chất thì giống:
1 | SQL table |
Nhưng cách triển khai khác nhau.
MySQL InnoDB
Thường có:
1 | Tablespace |
Dữ liệu table trong InnoDB thường nằm trong B+Tree của primary key.
Ví dụ:
1 | users.ibd |
Oracle Database
Oracle tổ chức theo:
1 | Tablespace |
Có thể hình dung:
1 | Tablespace |
Một bảng trong Oracle là một segment, segment gồm nhiều extent, extent gồm nhiều block, block chứa các row.
Khi INSERT thì chuyện gì xảy ra?
Ví dụ:
1 | INSERT INTO users VALUES (1, 'Long', 25); |
Database sẽ làm đại khái:
1 | 1. Parse SQL |
Điểm quan trọng: database thường không ghi thẳng từng row xuống disk ngay lập tức.
Nó ghi vào memory trước, ví dụ:
1 | MySQL: Buffer Pool |
Sau đó mới flush xuống datafile.
Nhưng để đảm bảo mất điện không mất dữ liệu, database ghi log trước:
1 | MySQL InnoDB: redo log, undo log, binlog |
Một row có nằm liền nhau không?
Thường một row nhỏ sẽ nằm trong một page/block.
Nhưng nếu row quá lớn, ví dụ có:
1 | TEXT |
thì database có thể lưu một phần ở ngoài page chính, còn trong row chỉ giữ con trỏ/tham chiếu.
Ví dụ:
1 | Record chính: |
Dữ liệu lớn:
1 | LOB page / external storage |
Tóm tắt cực ngắn
Database lưu bảng như sau:
1 | Table logic |
Nên bản chất không phải là:
1 | database lưu file Excel |
mà là:
1 | database lưu các page nhị phân có cấu trúc, |
Một câu dễ nhớ:
Table là cách con người nhìn dữ liệu; page/block và record nhị phân mới là cách database thật sự lưu dữ liệu.
Đúng rồi, đoạn này là phần cốt lõi nhất để hiểu database lưu dữ liệu thật sự như thế nào. Mình phân tích kỹ hơn theo hướng “từ bảng logic xuống bytes trên disk”.
7. INT được lưu như thế nào?
Khi bạn lưu:
1 | id INT |
và insert:
1 | INSERT INTO users VALUES (1, 'Long', 25); |
Giá trị 1 không được lưu thành ký tự '1'.
Nếu lưu dạng text, ký tự '1' có thể là:
1 | ASCII '1' = 0x31 |
Nhưng INT thường lưu dưới dạng binary number.
Ví dụ 1 trong 4 bytes:
1 | 00000000 00000000 00000000 00000001 |
Hay dạng hex:
1 | 00 00 00 01 |
Giá trị 25:
1 | 00000000 00000000 00000000 00011001 |
Dạng hex:
1 | 00 00 00 19 |
Vậy dòng:
1 | id = 1 |
không phải lưu kiểu:
1 | "1" và "25" |
mà là số nhị phân để máy tính xử lý nhanh hơn.
8. VARCHAR được lưu như thế nào?
Với:
1 | name VARCHAR(100) |
và giá trị:
1 | Long |
Database cần biết chuỗi này dài bao nhiêu, vì VARCHAR là độ dài biến đổi.
Nên nó thường lưu kiểu:
1 | [length][data] |
Ví dụ:
1 | name = 'Long' |
Có thể hình dung:
1 | 04 4C 6F 6E 67 |
Trong đó:
1 | 04 → độ dài chuỗi là 4 bytes |
Nếu encoding là UTF-8:
1 | L = 4C |
Nhưng nếu chuỗi có tiếng Việt như:
1 | Long Phạm |
thì không thể đơn giản tính “số ký tự = số byte”.
Ví dụ chữ:
1 | ạ |
trong UTF-8 có thể chiếm nhiều hơn 1 byte.
Vì vậy VARCHAR(100) thường nghĩa là giới hạn theo số ký tự hoặc byte tùy database/charset/cấu hình, nhưng khi lưu xuống disk thì cuối cùng vẫn là bytes.
9. CHAR khác VARCHAR thế nào khi lưu?
Giả sử:
1 | code CHAR(10) |
Nếu lưu:
1 | code = 'ABC' |
CHAR(10) có xu hướng lưu cố định độ dài, có thể padding thêm khoảng trắng:
1 | 'A' 'B' 'C' ' ' ' ' ' ' ' ' ' ' ' ' ' ' |
Còn VARCHAR(10) lưu linh hoạt:
1 | length = 3 |
So sánh dễ hiểu:
1 | CHAR(10) → luôn dành chỗ cố định gần 10 ký tự |
Vì vậy CHAR hợp với dữ liệu có độ dài cố định, ví dụ:
1 | country_code CHAR(2) → VN, US, JP |
Còn VARCHAR hợp với tên, email, địa chỉ, mô tả ngắn.
10. NULL được lưu thế nào?
Giả sử bảng:
1 | CREATE TABLE users ( |
Nếu row:
1 | id = 1 |
Database cần biết name không có giá trị.
Nó không nhất thiết lưu chữ "NULL".
Thường nó dùng một vùng gọi là NULL bitmap.
Ví dụ:
1 | NULL bitmap: |
Tức là:
1 | [header][null_bitmap][id][age] |
Cột name có thể không cần lưu data thật vì nó đang NULL.
Điểm quan trọng:
1 | NULL không giống chuỗi rỗng '' |
11. Fixed-length và variable-length columns
Các kiểu như:
1 | INT |
thường có độ dài khá cố định.
Ví dụ:
1 | INT → 4 bytes |
Các kiểu như:
1 | VARCHAR |
có độ dài biến đổi.
Một record có cả 2 loại:
1 | Record: |
Nhưng database thực tế có thể tổ chức phức tạp hơn, ví dụ dùng offset array để biết field biến đổi bắt đầu/kết thúc ở đâu.
Ví dụ:
1 | Record |
Với variable field, database cần biết:
1 | name bắt đầu ở byte nào? |
12. Một page chứa nhiều record bằng cách nào?
Giả sử page 16 KB.
1 | Page 100, size 16 KB |
Database không chỉ “xếp dòng từ trên xuống” đơn giản. Nó thường có cơ chế quản lý vị trí record trong page.
Một mô hình phổ biến là:
1 | Page đầu: chứa record data |
Hình dung:
1 | +------------------------------------------------+ |
Slot directory giúp database biết record nằm ở offset nào trong page.
Ví dụ:
1 | Slot 1 → record bắt đầu tại byte 120 |
Nhờ vậy khi record bị update, di chuyển, hoặc page bị reorganize, database vẫn quản lý được vị trí của từng row.
13. Khi UPDATE làm row dài hơn thì sao?
Ví dụ ban đầu:
1 | name = 'Long' |
sau đó update:
1 | UPDATE users SET name = 'Long Pham Nguyen Van A' WHERE id = 1; |
Chuỗi mới dài hơn rất nhiều.
Nếu trong page còn đủ free space, database có thể mở rộng record trong cùng page.
Nếu không đủ, tùy engine, có thể:
1 | - chuyển record sang vị trí khác trong page |
Với MVCC như InnoDB/Oracle, update không đơn giản là “đè trực tiếp giá trị cũ”. Database còn phải giữ thông tin để transaction khác có thể đọc version cũ nếu cần.
Ví dụ:
1 | Transaction A đang đọc name='Long' |
Database phải đảm bảo Transaction A vẫn có thể thấy dữ liệu cũ theo isolation level.
Vì vậy update thường liên quan đến:
1 | data page |
14. BLOB/CLOB/TEXT lưu thế nào?
Với dữ liệu lớn như:
1 | ảnh |
Database thường không nhét toàn bộ vào row chính nếu quá lớn.
Thay vào đó:
1 | Row chính |
Ví dụ:
1 | Record trong page chính: |
Dữ liệu ảnh nằm ở nơi khác:
1 | LOB Page 500 |
Vì nếu nhét BLOB lớn vào page chính, một row có thể chiếm hết cả page, làm giảm hiệu năng scan/index.
15. Metadata giúp database hiểu bytes đó là gì
Một câu hỏi hay là: nếu trên disk chỉ là bytes, làm sao database biết đoạn nào là id, đoạn nào là name, đoạn nào là age?
Câu trả lời là nhờ data dictionary / system catalog / metadata.
Khi bạn tạo table:
1 | CREATE TABLE users ( |
Database lưu metadata kiểu:
1 | Table users: |
Khi đọc record bytes, database dựa vào metadata này để decode lại thành row logic.
Ví dụ bytes trong record:
1 | [header][00 00 00 01][04][4C 6F 6E 67][00 00 00 19] |
Database biết:
1 | column 1 là INT 4 bytes → đọc ra id = 1 |
16. Ví dụ gom lại một row hoàn chỉnh
Giả sử row:
1 | id = 1 |
Một record đơn giản hóa có thể như này:
1 | +----------------+----------------+----------------+----------------+----------------+ |
Dạng minh họa:
1 | [HDR][000][00 00 00 01][04][4C 6F 6E 67][00 00 00 19] |
Trong đó:
1 | HDR → metadata record |
Đây là minh họa đơn giản hóa, không phải layout chính xác tuyệt đối của MySQL hay Oracle, nhưng đúng về bản chất.
17. Page chứa row đó như thế nào?
Row trên sẽ nằm trong một page:
1 | Page 100 |
Khi query:
1 | SELECT name FROM users WHERE id = 1; |
Database sẽ:
1 | 1. Dựa vào index hoặc scan để tìm page chứa id=1 |
18. Tóm lại bản chất đoạn này
Có thể nhớ theo chuỗi sau:
1 | Bảng users không được lưu như Excel. |
Câu quan trọng nhất:
Database lưu dữ liệu dạng bảng ở tầng logic, nhưng ở tầng vật lý nó lưu thành các record nhị phân nằm trong page/block của datafile.