---
title: "Composite index là gì? Thứ tự cột và EXPLAIN trên MySQL, PostgreSQL"
description: "Composite index là gì, vì sao tạo index rồi mà query vẫn Seq Scan? Cùng thử trên PostgreSQL, MySQL để nắm quy tắc đặt cột bằng trước, cột khoảng sau."
canonical: "https://200lab.io/blog/composite-index-la-gi"
published: "2026-11-06T12:59:30Z"
updated: "2026-11-06T12:59:30Z"
authors: ["Việt Trần"]
tags: ["Backend"]
reading_minutes: 20
access: "public"
---

Khá nhiều lần khi review database, mình thấy bảng đã có sẵn index nhiều cột nhưng query vẫn chậm. Mở `EXPLAIN` ra thì database đang quét toàn bộ bảng bằng `Seq Scan`. Nguyên nhân thường nằm ở việc đặt sai thứ tự cột khi tạo index, hoặc câu query thiếu mất cột đứng đầu.

Nếu bạn cần ôn lại khái niệm cơ bản về cấu trúc index và cách đọc `EXPLAIN`, bạn có thể đọc lại [bài index là gì](/blog/index-la-gi) trước khi đi tiếp.

Trong bài này, chúng ta sẽ đi sâu vào composite index thông qua các bài chạy thử thực tế trên cả PostgreSQL và MySQL để xem database thực sự dùng các cột trong index như thế nào.

## Composite index là gì?

Composite index (còn gọi là multi-column index, index nhiều cột hay index kết hợp) là một index được tạo trên nhiều cột của cùng một bảng. Cú pháp tạo rất quen thuộc như `CREATE INDEX idx_orders_customer_status_created ON orders (customer_id, status, created_at);`.

Với cấu trúc B-tree, các entry trong index được sắp xếp theo cột đầu tiên trước. Trong phạm vi các dòng có cùng giá trị ở cột đầu tiên, dữ liệu mới tiếp tục được sắp xếp theo cột thứ hai, rồi đến cột thứ ba.

Cơ chế này giống hệt một cuốn danh bạ điện thoại được sắp theo họ trước, những người trùng họ mới xếp tiếp theo tên. Bạn tìm những người mang họ Nguyễn sẽ rất nhanh, nhưng nếu chỉ biết tên là Minh mà không rõ họ thì bạn vẫn phải lật tìm từ trang đầu đến trang cuối.

## Chạy thử composite index trên PostgreSQL

Để có số liệu rõ ràng, mình chạy thử trên máy Linux cá nhân với PostgreSQL 17.11 và MySQL 9.7.4. Mình tự soạn một bảng `orders` cho một shop bán nến gồm các cột `id`, `customer_id`, `status` (gồm các giá trị `pending`, `paid`, `shipped`, `cancelled`), `created_at` và `total`.

Bảng có 200.000 đơn hàng của 5.000 khách, ngày tạo rải đều trong khoảng 300 ngày. Trên PostgreSQL, dữ liệu được sinh bằng hàm `generate_series` kết hợp `setseed(0.42)` để mỗi lần chạy lại đều ra đúng bộ dữ liệu đó. Trên MySQL, mình sinh dữ liệu riêng bằng script Python nên số dòng của từng khách có lệch đôi chút: chẳng hạn khách có mã `42` sở hữu 34 đơn trên PostgreSQL, còn bên MySQL trình tối ưu ước lượng khoảng 42 dòng.

Chúng ta thử xem đoạn code dưới đây nha:

```sql
DROP TABLE IF EXISTS orders;
CREATE TABLE orders (
  id          bigserial PRIMARY KEY,
  customer_id int         NOT NULL,
  status      text        NOT NULL,
  created_at  timestamptz NOT NULL,
  total       int         NOT NULL
);
SELECT setseed(0.42);
-- 200,000 sample orders from 5,000 customers
INSERT INTO orders (customer_id, status, created_at, total)
SELECT (random() * 4999)::int + 1,
       (ARRAY['pending','paid','shipped','cancelled'])[1 + (random() * 3)::int],
       timestamptz '2026-01-01' + random() * interval '300 days',
       (50 + random() * 950)::int * 1000
FROM generate_series(1, 200000);
ANALYZE orders;
```

Khi bảng vừa tạo xong và chưa có thêm index nào ngoài khóa chính, mình thử tìm các đơn đã thanh toán của khách hàng số `42` bằng điều kiện `WHERE customer_id = 42 AND status = 'paid'`.

```sql
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
```

```text
Gather (actual time=2.039..7.160 rows=13 loops=1)
  Workers Planned: 1
  Workers Launched: 1
  Buffers: shared hit=1667
  ->  Parallel Seq Scan on orders (actual time=1.802..4.130 rows=6 loops=2)
        Filter: ((customer_id = 42) AND (status = 'paid'::text))
        Rows Removed by Filter: 99994
        Buffers: shared hit=1667
```

Do chưa có index hỗ trợ, PostgreSQL phải dùng `Parallel Seq Scan` để quét bảng, đọc hết **1.667 page** (thể hiện qua thông số `Buffers: shared hit=1667`). Mỗi worker phải lọc bỏ khoảng 99.994 dòng không khớp để lấy ra đúng 13 dòng kết quả.

Bây giờ mình tạo composite index trên ba cột `(customer_id, status, created_at)` rồi chạy lại đúng câu query vừa rồi.

```sql
CREATE INDEX idx_orders_customer_status_created
    ON orders (customer_id, status, created_at);

EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
```

```text
Bitmap Heap Scan on orders (actual time=0.027..0.042 rows=13 loops=1)
  Recheck Cond: ((customer_id = 42) AND (status = 'paid'::text))
  Heap Blocks: exact=13
  Buffers: shared hit=13 read=3
  ->  Bitmap Index Scan on idx_orders_customer_status_created (actual time=0.022..0.023 rows=13 loops=1)
        Index Cond: ((customer_id = 42) AND (status = 'paid'::text))
        Buffers: shared read=3
```

Trong ví dụ trên, kế hoạch thực thi đã chuyển sang `Bitmap Index Scan` trên index mới tạo. Dòng `Index Cond` ghi nhận cả hai điều kiện `customer_id = 42` và `status = 'paid'`. Nhờ vậy, lượng dữ liệu cần đọc ở `Buffers` giảm từ 1.667 page xuống chỉ còn **3 page** index và **13 page** của bảng.

## Leftmost prefix: khi nào composite index được dùng?

Nguyên tắc hoạt động cốt lõi của composite index trên B-tree là leftmost prefix (tiền tố bên trái). Khi bạn có một index trên `(a, b, c)`, cấu trúc sắp xếp của nó sẽ hỗ trợ tìm kiếm cho các điều kiện lọc trên `(a)`, trên `(a, b)`, và trên `(a, b, c)`.

Nếu mình rút gọn câu query trên chỉ còn điều kiện ở cột đầu tiên là `customer_id = 42`, PostgreSQL vẫn tận dụng tốt index này:

```sql
-- each query below runs with the same EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, SUMMARY OFF) prefix
-- only the first column of the index
SELECT * FROM orders WHERE customer_id = 42;
```

```text
Bitmap Heap Scan on orders (actual time=0.015..0.038 rows=34 loops=1)
  Recheck Cond: (customer_id = 42)
  Heap Blocks: exact=34
  Buffers: shared hit=37 read=1
  ->  Bitmap Index Scan on idx_orders_customer_status_created (actual time=0.011..0.011 rows=34 loops=1)
        Index Cond: (customer_id = 42)
        Buffers: shared hit=3 read=1
```

Output cho thấy `Index Cond: (customer_id = 42)` hoạt động bình thường và trả về đủ 34 đơn hàng của vị khách này.

Chuyện gì xảy ra nếu câu query bỏ qua cột đầu tiên? Mình thử chạy tiếp câu query lọc theo hai cột đứng sau là `WHERE status = 'paid' AND created_at >= '2026-10-01'`.

```sql
-- the first column (customer_id) is missing
SELECT * FROM orders WHERE status = 'paid' AND created_at >= '2026-10-01';
```

```text
Seq Scan on orders (actual time=0.006..8.034 rows=6204 loops=1)
  Filter: ((created_at >= '2026-10-01 00:00:00+07'::timestamp with time zone) AND (status = 'paid'::text))
  Rows Removed by Filter: 193796
  Buffers: shared hit=1667
```

Lúc này PostgreSQL 17 quay về `Seq Scan`, đọc lại toàn bộ **1.667 page**, loại bỏ 193.796 dòng để lấy ra 6.204 dòng thỏa mãn. Dù cả hai cột `status` và `created_at` đều nằm trong index, chúng lại đứng sau `customer_id`. Các entry có trạng thái `paid` bị chia nhỏ và nằm rải rác khắp cây index theo từng `customer_id`, khiến database không thể nhảy thẳng tới một vùng cố định.

Một trường hợp thú vị khác là query có cột đầu và cột cuối, nhưng bỏ qua cột ở giữa: `WHERE customer_id = 42 AND created_at >= '2026-06-01'`.

```sql
-- the middle column (status) is skipped
SELECT * FROM orders WHERE customer_id = 42 AND created_at >= '2026-06-01';
```

```text
Bitmap Heap Scan on orders (actual time=0.014..0.021 rows=10 loops=1)
  Recheck Cond: ((customer_id = 42) AND (created_at >= '2026-06-01 00:00:00+07'::timestamp with time zone))
  Heap Blocks: exact=10
  Buffers: shared hit=14
  ->  Bitmap Index Scan on idx_orders_customer_status_created (actual time=0.011..0.011 rows=10 loops=1)
        Index Cond: ((customer_id = 42) AND (created_at >= '2026-06-01 00:00:00+07'::timestamp with time zone))
        Buffers: shared hit=4
```

Trong kết quả `EXPLAIN`, bạn vẫn thấy index được sử dụng và dòng `Index Cond` liệt kê cả hai điều kiện. Tuy nhiên, theo docs về [multicolumn index của PostgreSQL](https://www.postgresql.org/docs/18/indexes-multicolumn.html), chỉ các điều kiện so sánh bằng trên những cột đứng đầu, cộng thêm điều kiện không bằng trên cột đầu tiên kế tiếp, mới thực sự giúp thu hẹp phạm vi index cần quét. Các điều kiện nằm ở những cột phía bên phải chỉ được đem ra kiểm tra ngay bên trong index nhằm đỡ phải đọc xuống bảng, chứ không giúp thu hẹp đoạn index phải đi qua.

Ở câu query trên, PostgreSQL phải quét hết toàn bộ các entry của khách hàng `42` trong index, sau đó mới lọc tiếp điều kiện `created_at` ngay trên các entry đó. Với một khách hàng chỉ có 34 đơn như ví dụ này thì chi phí quét không đáng kể, nhưng nếu một khách hàng có tới hàng trăm nghìn đơn thì thời gian quét index sẽ khác hẳn.

Bảng dưới đây tóm tắt khả năng sử dụng index `(customer_id, status, created_at)` theo từng nhóm điều kiện `WHERE`:

| Điều kiện trong WHERE | Có dùng index để định vị vùng quét? | Cách database xử lý trên index |
| --- | --- | --- |
| `customer_id = ?` | **Có** | Dùng cột đầu tiên để tìm đúng đoạn entry của khách. |
| `customer_id = ? AND status = ?` | **Có** | Dùng cả 2 cột đầu để thu hẹp vùng quét. |
| `customer_id = ? AND status = ? AND created_at >= ?` | **Có** | Dùng đủ **3 cột** để giới hạn chính xác đoạn index. |
| `status = ? AND created_at >= ?` | **Không** | Thiếu cột đầu nên phải quét toàn bộ bảng (`Seq Scan`). |
| `customer_id = ? AND created_at >= ?` | **Một phần** | Chỉ dùng `customer_id` để khoanh vùng, rồi lọc `created_at` trên toàn bộ entry của khách đó. |

## Đọc EXPLAIN trên MySQL: key_len cho biết index dùng mấy cột

Nếu như PostgreSQL thể hiện các cột được dùng qua dòng `Index Cond`, thì trên MySQL với `EXPLAIN FORMAT=TRADITIONAL`, anh em có một chỉ số rất trực quan là cột `key_len`.

`key_len` cho biết MySQL đã lấy bao nhiêu byte đầu tiên của index key để phục vụ việc tìm kiếm, từ đó ta suy ra được chính xác mấy cột trong composite index đã phát huy tác dụng. Với bảng `orders` trên MySQL 9.7.4, kích thước từng cột trong index `(customer_id, status, created_at)` được tính như sau:
- **Cột `customer_id` kiểu `int` (NOT NULL):** Chiếm **4 byte**.
- **Cột `status` kiểu `varchar(20)` dùng `utf8mb4` (NOT NULL):** Chiếm `20 × 4 + 2 =` **82 byte** (cộng 2 byte lưu độ dài chuỗi).
- **Cột `created_at` kiểu `datetime` (NOT NULL):** Chiếm **5 byte**.

Mình chạy thử loạt câu query tương đương sang MySQL để đối chiếu cột `key_len`:

```sql
-- MySQL 9.7: same orders table (status varchar(20), created_at datetime),
-- 200,000 rows generated by a small Python script
CREATE INDEX idx_orders_customer_status_created
    ON orders (customer_id, status, created_at);

EXPLAIN FORMAT=TRADITIONAL SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';
-- ... same for the other queries
```

```text
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid'
  type=ref key=idx_orders_customer_status_created key_len=86 rows=12 Extra=None
SELECT * FROM orders WHERE customer_id = 42
  type=ref key=idx_orders_customer_status_created key_len=4 rows=42 Extra=None
SELECT * FROM orders WHERE status = 'paid' AND created_at >= '2026-10-01'
  type=ALL key=None key_len=None rows=199784 Extra=Using where
SELECT * FROM orders WHERE customer_id = 42 AND created_at >= '2026-06-01'
  type=ref key=idx_orders_customer_status_created key_len=4 rows=42 Extra=Using index condition
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid' AND created_at >= '2026-06-01'
  type=range key=idx_orders_customer_status_created key_len=91 rows=8 Extra=Using index condition
SELECT COUNT(*) FROM orders WHERE status = 'paid'
  type=range key=idx_orders_customer_status_created key_len=86 rows=49946 Extra=Using where; Using index for skip scan
```

Nhìn vào kết quả trả về từ MySQL, chúng ta đọc được từng trường hợp rất rõ:
- **Lọc theo `customer_id` và `status`:** Kết quả báo `type=ref` và `key_len=86` (`4 + 82`). MySQL đã dùng trọn vẹn hai cột đầu của index.
- **Chỉ lọc theo `customer_id`:** Kết quả có `type=ref` và `key_len=4`. Index được dùng đúng một cột đầu tiên.
- **Lọc theo `status` và `created_at` (thiếu cột đầu):** Kết quả trả về `type=ALL`, `key=NULL`, `rows=199784`. MySQL bỏ qua index và quét toàn bộ bảng (full table scan).
- **Lọc theo `customer_id` và `created_at` (bỏ qua cột `status` ở giữa):** Kết quả báo `type=ref`, `key_len=4` và cột `Extra` ghi `Using index condition`. Con số **4 byte** chứng tỏ chỉ có cột `customer_id` được dùng để định vị trên cây B-tree, còn điều kiện `created_at` được đẩy xuống lọc trong index thông qua cơ chế Index Condition Pushdown.
- **Có đủ cả ba cột `customer_id`, `status` và `created_at >= ...`:** Kết quả chuyển sang `type=range`, `key_len=91` (`4 + 82 + 5`) và `rows=8`. Cả ba cột đều tham gia vào việc khoanh vùng dữ liệu.

Ở câu query cuối cùng `SELECT COUNT(*) FROM orders WHERE status = 'paid'`, dù thiếu hẳn cột đầu `customer_id`, MySQL vẫn trả về `type=range`, `key_len=86` kèm thông báo `Using where; Using index for skip scan` ở cột `Extra`. Thay vì quét bảng, MySQL tự động nhảy qua từng giá trị `customer_id` một, rồi quét đoạn `status = 'paid'` bên trong mỗi khách hàng đó.

> 💡 Nhận định "thiếu cột đầu thì composite index vô dụng" đúng với đa số câu query thường gặp, nhưng không đúng 100%. Cơ chế Skip Scan của MySQL vẫn dùng được index khi thiếu cột đầu, với điều kiện câu query chỉ truy xuất các cột nằm sẵn trong index (nên câu `SELECT *` ở phía trên vẫn bị full table scan), chỉ truy vấn trên một bảng duy nhất và không dùng `GROUP BY` hay `DISTINCT`. Phía PostgreSQL từ bản 18 cũng đã bổ sung B-tree skip scan và planner thường chọn cách này khi cột đầu có ít giá trị khác nhau; riêng bản PostgreSQL 17.11 mình chạy trong bài thì chưa có tính năng này.

## Thứ tự cột trong composite index: cột so sánh bằng đứng trước

Khi đã nắm được nguyên tắc leftmost prefix, câu hỏi tiếp theo lúc thiết kế index là: nếu câu query luôn có mặt cả hai cột, ta nên đặt cột nào đứng trước?

Quay lại câu query `WHERE status = 'paid' AND created_at >= '2026-10-01'` trên PostgreSQL. Mình thử tạo lần lượt hai index có thứ tự ngược nhau (và xóa index cũ trước khi thử cái mới) để đo số lượng page mà database phải đọc:
- **Index A `(created_at, status)`:** Đặt cột lọc theo khoảng (range) lên trước.
- **Index B `(status, created_at)`:** Đặt cột so sánh bằng (equality) lên trước.

```sql
-- index A: range column first
CREATE INDEX idx_orders_created_status ON orders (created_at, status);
-- index B: equality column first
CREATE INDEX idx_orders_status_created ON orders (status, created_at);

-- the same query runs once with each index (the other one is dropped)
SELECT * FROM orders WHERE status = 'paid' AND created_at >= '2026-10-01';
```

```text
-- index A: (created_at, status)
Bitmap Heap Scan on orders (actual time=1.119..2.352 rows=6204 loops=1)
  Recheck Cond: ((created_at >= '2026-10-01 00:00:00+07'::timestamp with time zone) AND (status = 'paid'::text))
  Heap Blocks: exact=1634
  Buffers: shared hit=1634 read=76
  ->  Bitmap Index Scan on idx_orders_created_status (actual time=0.991..0.992 rows=6204 loops=1)
        Index Cond: ((created_at >= '2026-10-01 00:00:00+07'::timestamp with time zone) AND (status = 'paid'::text))
        Buffers: shared read=76

-- index B: (status, created_at)
Bitmap Heap Scan on orders (actual time=0.444..1.698 rows=6204 loops=1)
  Recheck Cond: ((status = 'paid'::text) AND (created_at >= '2026-10-01 00:00:00+07'::timestamp with time zone))
  Heap Blocks: exact=1634
  Buffers: shared hit=1634 read=27
  ->  Bitmap Index Scan on idx_orders_status_created (actual time=0.320..0.321 rows=6204 loops=1)
        Index Cond: ((status = 'paid'::text) AND (created_at >= '2026-10-01 00:00:00+07'::timestamp with time zone))
        Buffers: shared read=27
```

Trong ví dụ trên, cả hai lần chạy đều trả về đúng 6.204 dòng và cùng đọc **1.634 page** từ bảng dữ liệu. Sự khác biệt nằm ở lượng page index phải quét trong `Bitmap Index Scan`: Index A phải đọc **76 page** index, trong khi Index B chỉ cần đọc **27 page** index, tức là ít hơn khoảng 3 lần.

Lý do nằm ở cách B-tree sắp xếp dữ liệu. Với Index B `(status, created_at)`, toàn bộ các entry có `status = 'paid'` đã nằm gom lại một chỗ và được xếp sẵn thứ tự tăng dần theo `created_at`. PostgreSQL chỉ việc nhảy tới điểm bắt đầu của nhóm `paid` tại mốc thời gian trong câu truy vấn rồi đọc thẳng một mạch đến hết. Ngược lại, với Index A `(created_at, status)`, database phải quét qua toàn bộ các đơn hàng từ mốc thời gian đó trở đi của mọi loại trạng thái (`pending`, `shipped`, `cancelled`, `paid`), vừa quét vừa lọc lại những dòng có `status = 'paid'`.

Cơ chế range optimization của MySQL cũng xử lý theo đúng nguyên lý này: bộ tối ưu chỉ tiếp tục dùng thêm phần key kế tiếp (key part) khi điều kiện ở cột hiện tại là các phép so sánh bằng như `=`, `<=>`, hoặc `IS NULL`. Ngay khi gặp một điều kiện lọc theo khoảng như `>`, `<`, `>=`, `BETWEEN` hay `LIKE`, nó sẽ dùng cột đó để giới hạn khoảng quét rồi dừng lại, không xét tới các cột nằm phía sau để thu hẹp vùng tìm kiếm nữa.

Từ đây chúng ta rút ra được quy tắc rõ ràng khi sắp xếp cột: hãy đặt các cột lọc bằng (`=`) ở phía trước, và đẩy cột lọc theo khoảng (`>`, `<`, `BETWEEN`) về phía sau cùng.

## Composite index giúp ORDER BY không cần sort

Ngoài việc tăng tốc cho mệnh đề `WHERE`, một composite index được sắp đúng thứ tự còn giúp database trả về kết quả đã được sắp xếp sẵn cho mệnh đề `ORDER BY` mà không tốn thêm bước Sort trong bộ nhớ.

Trên PostgreSQL, mình giữ lại index `(customer_id, status, created_at)` rồi chạy thử hai câu query lấy 5 đơn hàng mới nhất của khách số `42`.

```sql
-- 1) equality on customer_id and status, then sort by created_at
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC LIMIT 5;

-- 2) status is missing, created_at is no longer in index order
SELECT * FROM orders WHERE customer_id = 42
ORDER BY created_at DESC LIMIT 5;
```

```text
-- query 1
Limit (actual time=0.009..0.011 rows=5 loops=1)
  Buffers: shared hit=8
  ->  Index Scan Backward using idx_orders_customer_status_created on orders (actual time=0.005..0.006 rows=5 loops=1)
        Index Cond: ((customer_id = 42) AND (status = 'paid'::text))
        Buffers: shared hit=8

-- query 2
Limit (actual time=0.033..0.034 rows=5 loops=1)
  Buffers: shared hit=38
  ->  Sort (actual time=0.032..0.033 rows=5 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 25kB
        Buffers: shared hit=38
        ->  Bitmap Heap Scan on orders (actual time=0.008..0.027 rows=34 loops=1)
              Recheck Cond: (customer_id = 42)
              Heap Blocks: exact=34
              Buffers: shared hit=38
              ->  Bitmap Index Scan on idx_orders_customer_status_created (actual time=0.003..0.003 rows=34 loops=1)
                    Index Cond: (customer_id = 42)
                    Buffers: shared hit=4
```

Ở câu query đầu tiên `WHERE customer_id = 42 AND status = 'paid' ORDER BY created_at DESC LIMIT 5`, PostgreSQL dùng `Index Scan Backward`, đọc ngược từ cuối đoạn index của nhóm `paid` lên trên để lấy ra 5 dòng. Kế hoạch thực thi không hề có bước `Sort` nào và chỉ tốn đúng **8 page**.

Sang câu query thứ hai `WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 5`, mình bỏ điều kiện lọc `status` đi. Lúc này PostgreSQL vẫn dùng được index để gom ra 34 đơn hàng của khách số `42`, nhưng phải chèn thêm một bước `Sort (top-N heapsort, 25kB)` và số lượng page cần đọc tăng lên **38 page**. Lý do là bên trong cùng một `customer_id`, các giá trị `created_at` chỉ được sắp xếp tăng dần theo từng cụm `status` riêng lẻ, chứ không xếp liền một mạch cho toàn bộ đơn của khách đó.

## Thiết kế composite index khi chạy production

Khi đưa composite index vào hệ thống thực tế, bạn có thể bám theo vài nguyên tắc cốt lõi dưới đây để giữ cho database gọn và nhanh:

- **Xuất phát từ các câu query thực tế:** Bạn hãy thống kê những câu query chạy thường xuyên nhất trong ứng dụng (cả phần `WHERE` lẫn `ORDER BY`) để thiết kế index, thay vì tạo sẵn theo cảm tính.
- **Ưu tiên thứ tự cột theo toán tử:** Bạn nên xếp các cột so sánh bằng lên đầu, tiếp theo mới đến cột phục vụ sort hoặc cột lọc theo khoảng (range).
- **Tránh tạo các index trùng lặp tiền tố:** Khi bạn đã có index trên `(a, b, c)`, bản thân nó đã phục vụ tốt cho các câu query chỉ lọc theo `(a)` hoặc `(a, b)`, nên một index đứng riêng trên cột `(a)` thường là dư thừa.
- **Đánh đổi chi phí ghi và dung lượng lưu trữ:** Mỗi index tạo thêm đều làm chậm các lệnh `INSERT`, `UPDATE`, `DELETE` và chiếm thêm dung lượng đĩa. Vì vậy chỉ nên tạo composite index ở mức vừa đủ; một index vượt quá 3 cột hiếm khi mang lại lợi ích thực tế, trừ khi cách truy vấn trên bảng đó rất cố định.
- **Luôn kiểm chứng lại bằng `EXPLAIN` trên tập dữ liệu đủ lớn:** Bạn cần chạy `EXPLAIN` (trên PostgreSQL nên bật thêm `ANALYZE, BUFFERS`) với lượng dữ liệu gần giống môi trường thật, bởi trên một bảng quá nhỏ, planner có thể chủ động chọn `Seq Scan` cho nhanh dù bạn đã đánh index đúng chuẩn.

> ⚠️ Sai lầm phổ biến khi làm việc với composite index là tạo index ngược thứ tự kiểu `(created_at, status)` hoặc viết query thiếu mất cột đầu tiên, nhưng thấy lệnh `CREATE INDEX` báo thành công thì yên tâm rằng query đã được tối ưu. Bạn hãy luôn mở `EXPLAIN` kiểm tra `Index Cond` (trên PostgreSQL) hoặc `key_len` (trên MySQL) trước khi đưa code lên production.

> Index nhiều cột chỉ phát huy tác dụng khi thứ tự cột khớp với cách query lọc và sắp xếp dữ liệu, nên hãy luôn đọc kết quả `EXPLAIN` trước khi tin rằng index của mình đang chạy.

Nếu bạn muốn đi sâu vào lúc database bắt đầu quá tải, chọn shard key và thiết kế lookup index thì có thể tham khảo khóa học bên dưới tại 200Lab nhé.

![Thiết kế database nhiều shard - shard key, hot key, lookup index và reshard](https://assets.200lab.io/sha256/ad/adf151d721ef565362ae71c8c3e4d5d6667b3ec68fdacaa20c24c0200cf26b95/generations/b72844946d53c93e542486ec20001cc9384ec44facdd1998cf4372b5d803680f)

- [Thiết kế database nhiều shard - shard key, hot key, lookup index và reshard](https://200lab.io/courses/thiet-ke-database-nhieu-shard-shard-key-hot-key-lookup-index-va-reshard.md)

Chúc các bạn luôn đặt đúng cột đầu và `EXPLAIN` không bao giờ gặp `Seq Scan` ngoài ý muốn! 🔍

## Đọc thêm

- [Transaction là gì, isolation level là gì? Chạy thử trên PostgreSQL, MySQL](https://200lab.io/blog/transaction-isolation-level-la-gi.md): Transaction là gì mà trừ kho vẫn sai? Chạy thử 4 mức isolation level trên PostgreSQL, MySQL để hiểu dirty read, phantom read, MVCC và cách chặn lost update.
- [Reverse proxy là gì? Cách cấu hình Nginx và phân biệt với API gateway](https://200lab.io/blog/reverse-proxy-la-gi.md): Reverse proxy là gì, khác forward proxy và API gateway ra sao? Cấu hình reverse proxy Nginx với proxy_pass, upstream, X-Forwarded-For và cách xử lý lỗi 502.
- [Idempotency là gì? Idempotency key chống trừ tiền hai lần khi retry](https://200lab.io/blog/idempotency-key-la-gi.md): Idempotency key giúp POST retry an toàn khi timeout. Middleware Go chạy thật: giữ chỗ atomic, 409 khi trùng đồng thời, replay đúng response cũ.
- [Game System Design GonnaCrash được làm như thế nào](https://200lab.io/blog/game-system-design-gonnacrash-duoc-lam-nhu-the-nao.md): Kéo thả một hệ thống rồi nhìn nó trụ hay sập: cách mình dựng GonnaCrash, từ server làm trọng tài, con bot giải đề đến skin whiteboard.
- [Architecture Pattern - Phần 7: Hexagonal Architecture](https://200lab.io/blog/hexagonal-architecture-la-gi.md): Hexagonal Architecture là pattern kiến trúc mà mọi tương tác từ bên ngoài đều phải đi qua một Adapter để kết nối vào một Port của Application
- [Mọi bài viết chủ đề Backend](https://200lab.io/blog/tags/backend.md)

Toàn bộ nội dung: https://200lab.io/llms.txt
