Hiểu Index trong SQL Server qua hình ảnh "tòa nhà hồ sơ"
Memo cá nhân + chia sẻ. Mục tiêu: nhớ được cơ chế của Page và Index mà không cần học thuộc, bằng cách gắn mỗi khái niệm với một thứ sờ được.
Mình từng đọc đi đọc lại định nghĩa "Page là đơn vị 8KB", "Clustered vs Non-Clustered" mà vẫn không thấy được nó hoạt động ra sao. Cái thiếu không phải định nghĩa — mà là một hình ảnh hữu hình để bám vào. Bài này dựng lại toàn bộ theo một ẩn dụ duy nhất: một tòa nhà đầy hồ sơ giấy, và một anh Trưởng phòng đi tìm hồ sơ.
1. Bộ từ vựng: ánh xạ 1:1 với thứ hữu hình
Điều quan trọng nhất là giữ một bộ từ nhất quán từ đầu đến cuối. Mỗi khái niệm SQL chỉ có đúng một vật tương ứng:
| Khái niệm SQL | Hữu hình | Vai trò |
|---|---|---|
| Database | Tòa nhà | Chứa tất cả |
| Table | Phòng | Một chủ đề dữ liệu |
| Extent | Tủ | Gom 8 tập hồ sơ (64 KB) |
| Page | Tập hồ sơ | 8 KB — đơn vị đọc/ghi nhỏ nhất |
| Row | Một tờ hồ sơ = một nhân viên | Một bản ghi đầy đủ |
| Query / câu lệnh | Anh Trưởng phòng | Người đi tìm, ghi, sửa |
| Index | Cuốn sổ tra cứu / cách xếp tủ | Để khỏi lật cả tủ |
Đọc chuỗi chứa nhau thành một câu:
Tòa nhà chứa nhiều phòng; mỗi phòng kê các tủ; mỗi tủ xếp các tập hồ sơ; mỗi tập kẹp nhiều tờ — mỗi tờ là một nhân viên.
Lưu ý cách phân biệt hai cái dễ lẫn:
- Page = tập hồ sơ (cái kẹp dày, nhiều tờ).
- Row = một tờ trong tập đó (một nhân viên, ghi đủ ID, tên, lương, ngày sinh...).
2. Page — vì sao SQL Server "lật cả tập" chứ không đọc lẻ từng tờ
Một tập hồ sơ (Page) luôn cố định 8 KB, và đây là đơn vị đọc/ghi nhỏ nhất. SQL Server không bao giờ rút lẻ một tờ — nó luôn lấy nguyên cả tập lên bàn (vào RAM) rồi mới tìm tờ trong đó.
Cấu trúc một tập:
- Header (~96 bytes): bìa tập — ghi số tập, loại tập.
- Phần thân (~7.900 bytes): các tờ hồ sơ thật.
- Row Offset Array: một tờ mục lục mini ở cuối tập, ghi mỗi tờ bắt đầu ở đâu.
Ví dụ trực giác: nếu mỗi tờ hồ sơ nặng ~200 bytes, một tập chứa được khoảng 40 tờ. Bảng 10.000 nhân viên cần khoảng 250 tập.
Khi tập đầy mà cần kẹp thêm tờ vào giữa → phải xé tập, chia nửa số tờ sang tập mới. Đây chính là Page Split — ghi nhớ cái tên này, nó quay lại nhiều lần ở phần Index.
Một câu để nhớ Page:
"SQL Server không đọc tờ, nó bê cả tập — mỗi tập 8 KB, 8 tập thành một tủ (Extent)."
3. Khi chưa có index: anh Trưởng phòng phải lật từng tờ (Heap)
Sếp gọi: "Tìm hồ sơ anh Tuấn."
Không có công cụ gì, anh Trưởng phòng ra tủ, bê tập đầu lên, rút tờ thứ nhất → "Lan" → không phải → tờ thứ hai → "Minh" → không phải... cứ thế tới tờ thứ 47 mới thấy Tuấn.
Đây là Table Scan trên một Heap (tủ không sắp xếp). 50 người còn chịu được, 5.000 người thì toát mồ hôi. Chi phí trung bình là O(n) — phải lật khoảng nửa số tờ.
Vấn đề cần giải: làm sao nhảy thẳng tới đúng tờ thay vì lướt hết.
4. Clustered Index — xếp lại chính cái tủ
Bước tự nhiên nhất từ "tủ lộn xộn" (Heap) là: sắp xếp luôn cái tủ. Anh Trưởng phòng xếp các tờ trong tủ theo mã ID, và dán một tờ mục lục lên chính cái tủ đó. Không có cuốn sổ riêng nào cả — tờ hồ sơ thật chính là thứ được sắp xếp.
MỤC LỤC (dán trên tủ): ID 1→Tập 1 ID 50→Tập 9 ID 100→Tập 17
Tập 1: [ID 02 Minh: hồ sơ ĐẦY ĐỦ] [ID 04 Hoa: hồ sơ đầy đủ] ...
Tập 9: [ID 51 ...: hồ sơ đầy đủ ] [ID 55 ...: hồ sơ đầy đủ ] ...
4.1. Cách "nhảy" thay vì "lướt" (B-Tree)
Nếu tủ to, lật tới đúng tập cũng mệt. Nên mục lục có nhiều tầng: tờ mục lục trên cùng (root) chỉ tóm tắt mỗi tập bắt đầu từ ID nào, trỏ xuống tầng dưới, cứ thế tới tập chứa hồ sơ thật.
MỤC LỤC (root): ID 1→Tập 1 ID 50→Tập 9 ID 100→Tập 17
↓ (ID 9 < 50)
Tập 1 (lá): ID 02 Minh · ID 04 Hoa · ID 07 Lan · ID 09 Ngân · ...
Đây chính là B-Tree, và đây cũng là cơ chế chung cho mọi index. Cách tra giống từ điển giấy, không phải đọc từ đầu:
- Tờ đầu tiên anh lật luôn là tờ mục lục (root) — không bao giờ mở đại một tập.
- Liếc root: "ID 9 nhỏ hơn 50" → đi xuống nhánh trái.
- Tới tập đúng → liếc cái là thấy ID 9.
Mỗi cú liếc không phải "đọc một dòng" mà là một câu hỏi "trước hay sau?", loại bỏ một nửa (thực ra là một phần lớn) số còn lại. Vì vậy:
- Gặp ID 7 hay ID 11 "trên đường" không tốn thêm bước — anh ấy nhảy thẳng theo thứ tự, không đi ngang qua chúng.
- Chi phí tra là O(log n) — điểm phản trực giác đáng nhớ nhất:
| Số nhân viên | Lật từng tờ (Heap, O(n)) | Tra mục lục (B-Tree, O(log n)) |
|---|---|---|
| 1.000 | ~500 bước | ~10 bước |
| 1.000.000 | ~500.000 bước | ~20 bước |
| 1 tỷ | ~500 triệu bước | ~30 bước |
Dữ liệu gấp triệu lần, số bước tra chỉ gấp ~3. Một lưu ý để con số sát thực tế: cơ số log không phải 2. Mỗi tập 8KB chứa hàng trăm khóa, nên mỗi cú nhảy chia cho hàng trăm chứ không chia đôi. Một bảng triệu dòng thường chỉ cao 3–4 tầng → 3–4 lần bê tập là tới nơi.
4.2. Đọc — chỉ một bước
"Lấy hồ sơ ID 9": tra mục lục → nhảy tới đúng tập → tới nơi là cầm được hồ sơ đầy đủ ngay. Không có bước thứ hai, vì tờ lá của B-Tree chính là hồ sơ thật.
Ghi nhớ điều này để so với Non-Clustered ở phần sau: CI đọc 1 bước.
4.3. Ghi — và Page Split
Thêm "Ngân" (ID 9) phải nằm giữa ID 7 và ID 11:
- Tập còn chỗ: chèn nguyên cả hồ sơ vào đúng vị trí, xê các tờ sau xuống.
- Tập đã kín: phải bê nửa số hồ sơ ĐẦY ĐỦ (cả tập giấy dày cộp) sang tập mới, rồi sửa mục lục. Đây là Page Split.
4.4. Vì sao hay chọn ID tự tăng làm Clustered Index
Nếu khóa clustered là số tự tăng (1, 2, 3...), người mới luôn có ID lớn nhất → luôn được nhét vào cuối tủ, chỗ còn trống → gần như không Page Split. Ngược lại, lấy thứ lộn xộn (mã ngẫu nhiên, hay tên) làm khóa → người mới rơi vào giữa tủ liên tục → xé tập, bê nửa tủ suốt ngày.
CI chỉ có 1 cái / một bảng, vì cái tủ chỉ xếp được theo một thứ tự.
5. Non-Clustered Index — cuốn sổ tra cứu riêng
Clustered giải quyết được việc tìm theo ID. Nhưng nếu sếp hỏi theo tên thì sao? Tủ đang xếp theo ID, vô dụng. Anh Trưởng phòng cần một thứ khác: một cuốn sổ tra cứu riêng, tách khỏi cái tủ.
5.1. Tạo sổ (CREATE)
Anh lấy một cuốn sổ mới, lật cả tủ một lần để ghi chép. Mỗi tờ rút ra, anh viết vào sổ đúng hai thứ: cái tên + chỗ tìm lại hồ sơ đó.
Hoa → ID 04
Lan → ID 07
Ngân → ID 09
Tuấn → ID 09 ... (ví dụ)
Ghi xong, anh xếp các dòng theo A → Z và dựng mục lục B-Tree y như trên (cùng cơ chế "nhảy" ở mục 4.1). Cuốn sổ này không chép nội dung hồ sơ — chỉ tên + khóa để tìm lại, nên mỏng, lật nhanh. Cái tủ gốc không bị đụng tới.
Chú ý chỗ ghi địa chỉ: sổ NCI không ghi vị trí vật lý ("tập mấy, tờ mấy") mà ghi chính ID clustered. Lý do ở mục 6.
5.2. Đọc — hai bước (Key Lookup)
Tìm "Tuấn":
Bước 1 — tra sổ (Index Seek): mở sổ, nhảy theo B-Tree tới chữ T → ra "Tuấn → ID 9". Bước 2 — ra tủ lấy hồ sơ thật (Key Lookup): cầm ID 9, tra tiếp Clustered Index (mục 4) để rút hồ sơ đầy đủ.
Đây là khác biệt gốc rễ: NCI đọc 2 bước, CI đọc 1 bước. Tờ lá của CI là hồ sơ thật; tờ lá của NCI chỉ là khóa để tìm tiếp.
Ngoại lệ — Covering Index: Nếu sếp chỉ hỏi đúng những thứ đã ghi sẵn trong sổ (ví dụ chỉ cần tên và số điện thoại), anh ấy đọc luôn trong sổ, bỏ qua bước 2. Trong SQL, đây là việc thêm cột vào index qua INCLUDE.
5.3. Ghi (thêm "Ngân")
Thêm hồ sơ luôn bao gồm một lần tra để biết viết vào đâu (O(log n)), rồi mới chèn dòng vào sổ:
- Trang sổ còn chỗ: viết thêm một dòng, xê các dòng dưới xuống. Nhẹ nhàng.
- Trang sổ đã kín: phải xé tờ sổ, chép một nửa số dòng sang tờ mới, sửa lại mục lục — vẫn là Page Split, nhưng nhẹ hơn CI vì chỉ chép tên + khóa (mỏng), không phải bê nguyên hồ sơ thật.
Và nếu anh ấy giữ nhiều cuốn sổ (theo tên, theo SĐT, theo phòng ban), mỗi lần thêm/sửa một nhân viên phải mở tất cả các sổ ra cập nhật. Đây là cái giá thật của việc tạo nhiều index.
NCI làm được nhiều cuốn / một bảng.
6. Vì sao sổ NCI ghi ID chứ không ghi vị trí vật lý
Ở mục 5.1 có nói sổ NCI ghi ID clustered thay vì "tập mấy, tờ mấy". Đây là lý do.
Nhớ lại mục 4.3: Clustered Index gây Page Split — tức là các tờ hồ sơ bị dời chỗ vật lý liên tục khi tủ thêm/sửa. Nếu sổ NCI ghi địa chỉ cứng "ngăn 4, tờ 4", thì mỗi lần một tờ bị dời, mọi cuốn sổ NCI trỏ vào nó đều phải sửa theo — ác mộng bảo trì.
Giải pháp: sổ NCI ghi khóa ổn định (ID clustered) thay vì vị trí. Tờ hồ sơ có bị dời đâu trong tủ thì ID 9 vẫn là ID 9. Đổi lại, đọc qua NCI thành chuỗi hai lần tra B-Tree:
Tra sổ NCI theo tên → ra "Tuấn, ID 9" → tra tiếp Clustered Index theo ID 9 (mục 4) → lấy hồ sơ.
(Trường hợp bảng là Heap — không có Clustered Index — thì NCI mới ghi địa chỉ vật lý thật, gọi là RID.)
7. Bảng so sánh chốt
| Clustered Index | Non-Clustered Index | |
|---|---|---|
| Vật anh ấy giữ | Cái tủ tự xếp theo ID | Cuốn sổ riêng + cái tủ |
| Tới nơi thấy gì | Hồ sơ đầy đủ luôn | Chỉ địa chỉ, phải ra tủ tiếp |
| Đọc | 1 bước | 2 bước (tra sổ → lấy data) |
| Page Split bê gì | Nửa tập hồ sơ đầy đủ (nặng) | Nửa tờ tên + địa chỉ (nhẹ) |
| Số lượng | 1 / bảng | Nhiều / bảng |
| Khóa nên chọn | ID tự tăng (tránh split) | Cột hay dùng trong điều kiện tìm |
8. Những câu thần chú để nhớ
- Page: "Không đọc tờ, bê cả tập — mỗi tập 8 KB, 8 tập một tủ."
- B-Tree: "Tra như từ điển — nhảy, không lướt. Tờ đầu luôn là mục lục."
- Độ phức tạp: "Lật cả tủ là O(n); tra sổ là O(log n) — triệu dòng cũng chỉ 3–4 lần bê tập."
- Clustered: "Xếp lại cái tủ rồi dán mục lục lên đó — tới nơi cầm hồ sơ ngay, nhưng chỉ một cách xếp."
- Non-Clustered: "Cuốn sổ tra cứu riêng — tra nhanh, làm được nhiều cuốn, nhưng phải ra tủ thêm một lần."
- Cái giá của index: "Đọc thì sổ luôn giúp; ghi thì sổ là gánh nặng — mỗi cuốn sổ là một thứ phải bảo trì."
Ghi chú: bài này là mô hình trực giác, cố tình lược bớt chi tiết kỹ thuật (page 8060 bytes thực dùng, row-overflow, fill factor, allocation unit...). Khi cần chính xác tuyệt đối hãy tra tài liệu Microsoft. Nhưng để "thấy" được cơ chế và nhớ lâu thì hình ảnh tòa nhà này là đủ.
All Rights Reserved