# Báo cáo chạy chậm khi dữ liệu lớn: đọc EXPLAIN và đặt index

> Báo cáo doanh thu theo ngày của một cửa hàng chạy 181,6 ms (median của 10 lần chạy) trên bảng 1.000.000 đơn, vì MySQL đọc cả bảng để lấy ra 596 dòng cần dùng

URL: https://mona.software/bao-cao-chay-cham-doc-explain-va-dat-index/
Ngày đăng: 2026-10-26T10:33:58+07:00
Cập nhật: 2026-10-26T10:34:08+07:00
Tác giả: monamedia

Báo cáo doanh thu theo ngày của một cửa hàng chạy 181,6 ms (median của 10 lần chạy) trên bảng 1.000.000 đơn, vì MySQL đọc cả bảng để lấy ra 596 dòng cần dùng. Một index ba cột `(store_id, status, created_at)` đưa thời gian xuống 0,965 ms. Cột so sánh bằng đứng trước, cột khoảng ngày đứng cuối; đảo thứ tự vẫn nhanh hơn không có index, nhưng chậm hơn 5,8 đến 6,7 lần so với thứ tự đúng.

Mọi lệnh trong bài chạy trong một database riêng, đã xoá sau khi đo. Thời gian ghi là median của 10 lần chạy thường, trừ khi ghi rõ là số trong cây `EXPLAIN ANALYZE` (một lần chạy riêng). Các số trong bài đo trên bảng thử, trên một laptop Apple M2 (8 lõi, 8 GB RAM, macOS 14.4.1) chạy MySQL 8.4.8 với InnoDB. Đó không phải số của hệ thống MONA hay của khách hàng, và chưa kiểm trên MariaDB. Người đọc cần biết `SELECT`, `WHERE`, `GROUP BY` của [ngôn ngữ SQL](https://mona.software/sql-la-gi/) và biết index là gì; không cần biết quản trị cơ sở dữ liệu.

## Dựng bảng thử 1.000.000 đơn và chạy báo cáo khi chưa có index

Bảng `orders` có 50 cửa hàng, ngày tạo rải đều từ 01/01/2025 đến 31/12/2026, 70,07% đơn (700.707 trên 1.000.000) ở trạng thái `paid`. Các cột ngẫu nhiên sinh bằng `CRC32`, vì `RAND(n)` với hạt giống liên tiếp cho giá trị gần nhau và làm cột trạng thái trùng với ngày. Nếu bảng thật được nạp từ file Excel, cách làm sạch và chuyển dữ liệu nằm trong bài [chuyển dữ liệu từ Excel sang phần mềm quản lý](https://mona.software/chuyen-du-lieu-excel-sang-phan-mem-quan-ly/). Lệnh dưới tạo bảng và nạp 1.000.000 dòng bằng phép nối chéo của 6 bản bảng `digits` (mỗi bản 10 dòng):

```
CREATE DATABASE blog_lab_explain;
USE blog_lab_explain;

CREATE TABLE orders (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
  store_id SMALLINT UNSIGNED NOT NULL,
  customer_id INT UNSIGNED NOT NULL,
  status VARCHAR(12) NOT NULL,
  total_amount DECIMAL(12,2) NOT NULL,
  created_at DATETIME NOT NULL,
  note VARCHAR(100) NOT NULL
) ENGINE=InnoDB;

CREATE TABLE digits (d TINYINT UNSIGNED PRIMARY KEY);
INSERT INTO digits VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9);

INSERT INTO orders (store_id, customer_id, status, total_amount, created_at, note)
SELECT 1 + (n MOD 50),
       1 + CRC32(CONCAT('c',n)) MOD 200000,
       ELT(1 + CRC32(CONCAT('s',n)) MOD 10, 'paid','paid','paid','paid','paid','paid','paid','pending','cancelled','refunded'),
       50000 + CRC32(CONCAT('a',n)) MOD 4950000,
       TIMESTAMP('2025-01-01') + INTERVAL (CRC32(CONCAT('d',n)) MOD (730*86400)) SECOND,
       'don hang mau'
FROM (SELECT a.d + 10*b.d + 100*c.d + 1000*d.d + 10000*e.d + 100000*f.d AS n
      FROM digits a, digits b, digits c, digits d, digits e, digits f) t;
ANALYZE TABLE orders;
```

Bảng và dữ liệu giữ nguyên trong mọi lần đo sau. Báo cáo cần đo là doanh thu theo ngày của cửa hàng số 7, chỉ tính đơn `paid`, trong tháng 9/2026. Bộ lọc theo một cửa hàng cũng là cách phần mềm [phân quyền người dùng](https://mona.software/phan-quyen-nguoi-dung-phan-mem-quan-ly-doanh-nghiep/) cho quản lý chi nhánh chỉ xem dữ liệu của chi nhánh mình:

```
SELECT DATE(created_at) AS day, COUNT(*) AS orders, SUM(total_amount) AS revenue
FROM orders
WHERE store_id = 7 AND status = 'paid'
  AND created_at >= '2026-09-01' AND created_at < '2026-10-01'
GROUP BY DATE(created_at) ORDER BY day;
```

Số dòng khớp từng điều kiện là số đếm thật bằng `COUNT(*)` trên bảng thử, không phải ước tính của EXPLAIN, và cho thấy báo cáo chỉ cần 596 trong 1.000.000 dòng. Mỗi số là kết quả của một câu `COUNT(*)` với đúng các điều kiện ghi ở cột trái:

| Điều kiện | Số dòng khớp |
| --- | --- |
| Cửa hàng 7 | 20.000 |
| Cửa hàng 7 và `paid` | 14.071 |
| Tháng 9/2026 (mọi cửa hàng, mọi trạng thái) | 41.544 |
| `paid` và tháng 9/2026 | 29.162 |
| Cửa hàng 7 và tháng 9/2026 | 811 |
| Cả ba điều kiện (kết quả của báo cáo) | 596 |

## Đọc EXPLAIN và EXPLAIN ANALYZE của báo cáo chưa có index

Đặt `EXPLAIN` trước câu `SELECT`: MySQL in kế hoạch thực thi mà không chạy câu lệnh. Kết quả trên bảng chưa có index, nguyên văn từ máy khách `mysql`:

```
+----+-------------+--------+------------+------+---------------+------+---------+------+--------+----------+----------------------------------------------+
| id | select_type | table  | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra                                        |
+----+-------------+--------+------------+------+---------------+------+---------+------+--------+----------+----------------------------------------------+
|  1 | SIMPLE      | orders | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 995845 |     0.11 | Using where; Using temporary; Using filesort |
+----+-------------+--------+------------+------+---------------+------+---------+------+--------+----------+----------------------------------------------+
```

Khi `type` là `ALL` và `rows` gần bằng cả bảng trong khi kết quả chỉ có vài trăm dòng, truy vấn đang quét thừa. Bốn cột cần đọc trước, điền từ kế hoạch trên ([định nghĩa từng cột theo tài liệu MySQL 8.4](https://dev.mysql.com/doc/refman/8.4/en/explain-output.html)):

| Cột | Giá trị ở đây | Cách đọc |
| --- | --- | --- |
| `type` | `ALL` | Quét cả bảng. Tài liệu MySQL xếp `ALL` cuối danh sách kiểu truy cập, kém nhất. |
| `key` | `NULL` | Không có index nào được dùng. |
| `rows` | 995.845 | Số dòng MySQL ước tính phải duyệt. Bảng có đúng 1.000.000 dòng; hai lần `EXPLAIN` trên cùng bảng ra 995.845 (báo cáo chưa có index) và 995.638 (câu thiếu cửa hàng, ở phần sau); đây là ước tính, không phải số đếm. |
| `Extra` | `Using where; Using temporary; Using filesort` | Mỗi dòng đọc xong mới bị lọc; `GROUP BY` dùng bảng tạm; kết quả được sắp xếp thêm một lượt. |

Tài liệu MySQL xếp các kiểu `type` từ tốt đến kém, gồm `range`, `index` và `ALL`:

![Trang MySQL liệt kê các kiểu type của EXPLAIN từ range, index đến ALL](https://mona.software/wp-content/uploads/2026/10/explain-type-mysql-doc-mona.webp)

Phần “EXPLAIN Join Types” của tài liệu MySQL 8.4 mô tả `range`, `index` và `ALL`. Nguồn: dev.mysql.com, chụp ngày 26/10/2026.

Con số ước tính cần được đối chiếu với thực tế. `EXPLAIN ANALYZE` chạy câu lệnh thật và in thêm thời gian cùng số dòng thực của từng bước ([tài liệu MySQL 8.4 về `EXPLAIN ANALYZE`](https://dev.mysql.com/doc/refman/8.4/en/explain.html)). Kết quả cho báo cáo trên:

```
-> Sort: `day`  (actual time=213..213 rows=30 loops=1)
    -> Table scan on <temporary>  (actual time=213..213 rows=30 loops=1)
        -> Aggregate using temporary table  (actual time=213..213 rows=30 loops=1)
            -> Filter: ((orders.store_id = 7) and (orders.`status` = 'paid') and (orders.created_at >= TIMESTAMP'2026-09-01 00:00:00') and (orders.created_at < TIMESTAMP'2026-10-01 00:00:00'))  (cost=100618 rows=1106) (actual time=0.317..213 rows=596 loops=1)
                -> Table scan on orders  (cost=100618 rows=995845) (actual time=0.237..186 rows=1e+6 loops=1)
```

Dòng cuối cho thấy, trong lần chạy này, 186 ms nằm ở việc đọc 1.000.000 dòng (`rows=1e+6`), còn bước `Filter` phía trên chỉ giữ lại 596. MySQL đọc khoảng 1.678 dòng cho mỗi dòng trả về (1.000.000 chia 596). Cây này là một lần chạy riêng (213 ms ở nút ngoài cùng, lần hai 200 ms) và dùng để đọc số dòng; so sánh thời gian trong bài dùng median của 10 lần chạy, ở đây là 181,6 ms. Dùng `EXPLAIN ANALYZE` để so số dòng và lần chạy thường để so thời gian.

![Màn hình thống kê trong phần mềm MONA làm cho The IELTS Workshop](https://mona.software/wp-content/themes/monatheme/home-v2/assets/img/cs/ielts-thongke.webp)

Phần mềm cho việc này

Quy trình đang chạy mà phần mềm có sẵn không làm được?

MONA khảo sát nghiệp vụ trước dòng code đầu tiên.

[Viết phần mềm theo yêu cầu](https://mona.software/viet-phan-mem-theo-yeu-cau/)[Xem case study](https://mona.software/#case-study)

## Thêm một index ba cột và đo lại

Báo cáo lọc bằng dấu `=` trên `store_id` và `status`, rồi lọc khoảng trên `created_at`. Đặt index theo đúng thứ tự đó. Trong khối lệnh sau, câu `SELECT` của `EXPLAIN` được viết gọn là `<câu báo cáo ở trên>`:

```
ALTER TABLE orders ADD INDEX ix_store_status_created (store_id, status, created_at);
ANALYZE TABLE orders;
EXPLAIN <câu báo cáo ở trên>;

+----+-------------+--------+------------+-------+-------------------------+-------------------------+---------+------+------+----------+--------------------------------------------------------+
| id | select_type | table  | partitions | type  | possible_keys           | key                     | key_len | ref  | rows | filtered | Extra                                                  |
+----+-------------+--------+------------+-------+-------------------------+-------------------------+---------+------+------+----------+--------------------------------------------------------+
|  1 | SIMPLE      | orders | NULL       | range | ix_store_status_created | ix_store_status_created | 57      | NULL |  596 |   100.00 | Using index condition; Using temporary; Using filesort |
+----+-------------+--------+------------+-------+-------------------------+-------------------------+---------+------+------+----------+--------------------------------------------------------+
```

Nhánh `Index range scan` thay cho nhánh `Table scan` của kế hoạch trước. Kết quả `EXPLAIN ANALYZE` của báo cáo có index, chạy lần thứ hai:

```
-> Sort: `day`  (actual time=1.4..1.4 rows=30 loops=1)
    -> Table scan on <temporary>  (actual time=1.39..1.39 rows=30 loops=1)
        -> Aggregate using temporary table  (actual time=1.39..1.39 rows=30 loops=1)
            -> Index range scan on orders using ix_store_status_created over (store_id = 7 AND status = 'paid' AND '2026-09-01 00:00:00' <= created_at < '2026-10-01 00:00:00'), with index condition: ((orders.store_id = 7) and (orders.`status` = 'paid') and (orders.created_at >= TIMESTAMP'2026-09-01 00:00:00') and (orders.created_at < TIMESTAMP'2026-10-01 00:00:00'))  (cost=268 rows=596) (actual time=0.171..1.26 rows=596 loops=1)
```

Cột `key_len` cho biết bao nhiêu cột của index đã được dùng. Số 57 là tổng của `store_id` (2 byte), `status` (50 byte: `VARCHAR(12)` ở bộ ký tự utf8mb4 là 12 × 4 + 2) và `created_at` (5 byte), tức cả ba cột. Nếu `key_len` ngắn hơn tổng này, MySQL đã dừng ở một cột phía trước, như ở trường hợp bọc hàm bên dưới. So sánh trước và sau, mỗi số có nguồn là lần đo ghi ở đầu bài:

| Chỉ số | Chưa có index | Có index ba cột |
| --- | --- | --- |
| `type` | ALL | range |
| `key` | NULL | ix_store_status_created |
| `key_len` | NULL | 57 |
| `rows` (ước tính) | 995.845 | 596 |
| Median của 10 lần chạy thường | 181,6 ms | 0,965 ms |
| Dòng server phải xử lý (`Rows_examined` từ performance_schema) | 1.000.030 | 626 |

Cách đo: một phiên MySQL chạy liên tiếp 11 câu giống nhau, đọc `TIMER_WAIT` của 10 câu cuối từ `performance_schema.events_statements_history`, lấy median. Bốn thứ tự index trong bảng so sánh thứ tự cột cũng được đo bằng đúng quy trình này, với một index duy nhất trên bảng ở mỗi lần đo. Đo lại với tên index cuối, `ix_store_status_created`, median là 0,82 ms. Hai lần `EXPLAIN ANALYZE` liên tiếp ngay sau khi tạo index ra 2,12 và 1,28 ms ở nút ngoài cùng, nên hãy chạy hai lần rồi mới đọc. Dòng `Using temporary; Using filesort` vẫn còn vì `GROUP BY DATE(created_at)` gom theo kết quả của một hàm, nhưng bước đó chỉ xử lý 596 dòng và nằm trong 1,4 ms của nút ngoài cùng ở khối trên.

## Thứ tự cột: cột so sánh bằng trước, cột khoảng ngày sau cùng

Quy tắc nằm ở trang tối ưu khoảng giá trị của MySQL. Với index nhiều cột, bộ tối ưu chỉ dùng thêm cột kế tiếp khi phép so sánh là `=`. Gặp `>`, `<`, `BETWEEN` hay `LIKE`, MySQL vẫn dùng phép so sánh đó nhưng bỏ các cột phía sau ([Range Optimization, MySQL 8.4](https://dev.mysql.com/doc/refman/8.4/en/range-optimization.html)). Với báo cáo này, `created_at` là cột khoảng nên phải đứng cuối.

![Trang MySQL nêu quy tắc khoảng giá trị chặn các cột phía sau của index nhiều cột](https://mona.software/wp-content/uploads/2026/10/mysql-range-rule-doc-mona.webp)

Ví dụ trong tài liệu MySQL: `key_part2 >= 10` được dùng, `key_part3 > 10` thì không. Nguồn: dev.mysql.com, chụp ngày 26/10/2026.

Để kiểm quy tắc, cùng bảng và cùng câu báo cáo được chạy với bốn thứ tự cột và với hai index đơn lẻ, mỗi lần chỉ giữ index của phương án đang đo. Cột cuối là số mục index hoặc dòng nằm trong khoảng MySQL phải duyệt, lấy từ các số đếm ở bảng đầu bài và từ cây `EXPLAIN ANALYZE` của phương án hai index:

| Thứ tự cột của index | Median 10 lần | `rows` trong EXPLAIN | Mục index trong khoảng phải duyệt |
| --- | --- | --- | --- |
| Không có index | 181,6 ms | 995.845 | 1.000.000 (đọc cả bảng) |
| `(store_id, status, created_at)` | 0,965 ms | 596 | 596 |
| `(store_id, created_at, status)` | 1,355 ms | 811 | 811 |
| `(status, created_at, store_id)` | 5,56 ms | 56.052 | 29.162 |
| `(created_at, store_id, status)` | 6,435 ms | 83.884 | 41.544 |
| Hai index riêng `(store_id)` và `(created_at)` | 20,82 ms | 38.040 | 20.000 (toàn bộ đơn của cửa hàng 7) |

Kết quả sửa giả thuyết ban đầu của bài: thứ tự sai không vô dụng. Khoảng ngày tháng 9 chỉ chứa 4,15% bảng (41.544 chia 1.000.000), nên hai index đặt khoảng ngày trước vẫn nhanh gấp 28 đến 33 lần không có index (181,6 chia 6,435 và chia 5,56). Nhưng chúng phải duyệt 29.162 đến 41.544 mục để giữ lại 596 dòng, nên chậm hơn thứ tự đúng 5,8 đến 6,7 lần (6,435 và 5,56 chia 0,965). Thứ tự `(store_id, created_at, status)`, với `status` ở cuối, chỉ chậm hơn một chút (1,355 so với 0,965 ms) vì cửa hàng 7 và tháng 9 chỉ còn 811 mục, và `status` được kiểm trong 811 mục đó. Hai index đơn lẻ không thay được index nhiều cột: MySQL chỉ chọn `ix_store`, đọc 20.000 dòng của cửa hàng 7 rồi lọc `status` và ngày, nên median là 20,82 ms, gấp 21,6 lần 0,965 ms (20,82 chia 0,965).

Báo cáo xuất nhập của [phần mềm quản lý kho](https://mona.software/thiet-ke-phan-mem-quan-ly-kho/), lọc theo một kho bằng `=` rồi theo khoảng ngày, có cùng dạng này nên dùng cùng thứ tự cột. Một index `(store_id, status, created_at)` còn phục vụ truy vấn chỉ có `store_id`, hoặc có `store_id` và `status`, vì các cột đó là tiền tố bên trái của index. Tài liệu MySQL về cách dùng index nêu đúng ví dụ này với index ba cột:

![Trang MySQL giải thích index giúp tìm dòng theo WHERE và quy tắc tiền tố bên trái](https://mona.software/wp-content/uploads/2026/10/mysql-index-uses-doc-mona.webp)

Tài liệu MySQL 8.4 về cách MySQL dùng index: index `(col1, col2, col3)` tra được theo `col1`, `(col1, col2)` và cả ba cột. Nguồn: dev.mysql.com, chụp ngày 26/10/2026.

## Hai trường hợp có index mà vẫn quét cả bảng hoặc bỏ cột cuối

Trường hợp thứ nhất: truy vấn thiếu cột đứng đầu index. Triệu chứng là `type` vẫn là `ALL` dù `possible_keys` liệt kê index. Cách kiểm là đọc cột `key`, không đọc `possible_keys`. Báo cáo toàn chuỗi bỏ điều kiện `store_id` và vẫn tính `SUM(total_amount)`, cột không nằm trong index:

```
EXPLAIN SELECT DATE(created_at) AS day, COUNT(*) AS orders, SUM(total_amount) AS revenue
FROM orders WHERE status = 'paid' AND created_at >= '2026-09-01' AND created_at < '2026-10-01'
GROUP BY DATE(created_at) ORDER BY day;

+----+-------------+--------+------------+------+-------------------------+------+---------+------+--------+----------+----------------------------------------------+
| id | select_type | table  | partitions | type | possible_keys           | key  | key_len | ref  | rows   | filtered | Extra                                        |
+----+-------------+--------+------------+------+-------------------------+------+---------+------+--------+----------+----------------------------------------------+
|  1 | SIMPLE      | orders | NULL       | ALL  | ix_store_status_created | NULL | NULL    | NULL | 995638 |     1.11 | Using where; Using temporary; Using filesort |
+----+-------------+--------+------------+------+-------------------------+------+---------+------+--------+----------+----------------------------------------------+
```

Trong cây `EXPLAIN ANALYZE`, câu này ra 341 ms và 405 ms ở hai lần chạy, cao hơn 213 ms và 200 ms của báo cáo một cửa hàng khi chưa có index, vì phải gom 29.162 dòng thay cho 596. Sửa bằng cách thêm điều kiện cột đầu vào truy vấn nếu nghiệp vụ cho phép, hoặc tạo index riêng bắt đầu bằng cột có trong `WHERE` (cách này chưa đo trên bảng thử). Tài liệu MySQL còn một ngoại lệ gọi là skip scan: truy vấn chỉ đọc cột có trong index, một bảng, không `GROUP BY` và không `DISTINCT`. Cùng index đó, câu `SELECT COUNT(*)` với `status` và tháng 9 cho `key_len` 52 và `Extra` là `Using index for skip scan`: truy vấn này được index giúp, còn truy vấn kế bên thì không.

![Trang Use The Index Luke giải thích index nối nhiều cột và truy vấn thiếu cột đầu](https://mona.software/wp-content/uploads/2026/10/luke-concatenated-index-mona.webp)

Trang “Concatenated Indexes” của Use The Index, Luke kể cùng chuyện bằng bảng nhân viên: truy vấn chỉ có `subsidiary_id`, cột thứ hai của index, phải đọc cả bảng. Nguồn: use-the-index-luke.com, chụp ngày 26/10/2026.

Trường hợp thứ hai: bọc cột trong hàm. Kế hoạch vẫn dùng index, vì vậy lỗi này khó thấy khi chỉ đọc cột `key`. Viết `DATE(created_at) >= '2026-09-01'` thay cho khoảng trên cột trần làm MySQL bỏ cột `created_at` của index. Kiểm bằng `key_len`:

```
EXPLAIN SELECT COUNT(*) FROM orders
WHERE store_id = 7 AND status = 'paid'
  AND DATE(created_at) >= '2026-09-01' AND DATE(created_at) < '2026-10-01';

+----+-------------+--------+------------+------+-------------------------+-------------------------+---------+-------------+-------+----------+--------------------------+
| id | select_type | table  | partitions | type | possible_keys           | key                     | key_len | ref         | rows  | filtered | Extra                    |
+----+-------------+--------+------------+------+-------------------------+-------------------------+---------+-------------+-------+----------+--------------------------+
|  1 | SIMPLE      | orders | NULL       | ref  | ix_store_status_created | ix_store_status_created | 52      | const,const | 28774 |   100.00 | Using where; Using index |
+----+-------------+--------+------------+------+-------------------------+-------------------------+---------+-------------+-------+----------+--------------------------+

EXPLAIN SELECT COUNT(*) FROM orders
WHERE store_id = 7 AND status = 'paid'
  AND created_at >= '2026-09-01' AND created_at < '2026-10-01';

+----+-------------+--------+------------+-------+-------------------------+-------------------------+---------+------+------+----------+--------------------------+
| id | select_type | table  | partitions | type  | possible_keys           | key                     | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+--------+------------+-------+-------------------------+-------------------------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | orders | NULL       | range | ix_store_status_created | ix_store_status_created | 57      | NULL |  607 |   100.00 | Using where; Using index |
+----+-------------+--------+------------+-------+-------------------------+-------------------------+---------+------+------+----------+--------------------------+
```

`key_len` là 52 (`store_id` cộng `status`), không phải 57, và `rows` ước tính là 28.774 thay cho 607 của câu viết bằng khoảng trần (cả hai câu đều đếm ra 596 dòng). Sửa bằng cách giữ `created_at >= '2026-09-01' AND created_at < '2026-10-01'`, đúng dạng của báo cáo ban đầu.

## Index đổi lấy dung lượng và tốc độ ghi

Index ba cột chiếm 29,6 MB, trong khi dữ liệu bảng chiếm 65,6 MB (đọc từ `mysql.innodb_index_stats` và `information_schema.tables`). Thêm 100.000 dòng bằng `INSERT ... SELECT`, lặp 5 lần: median là 0,44 giây khi chưa có index và 1,06 giây khi có, tức chậm gấp 2,4 lần (1,06 chia 0,44). Hai index đơn lẻ chiếm 17,5 và 20,5 MB (tổng 38,0 MB) và `INSERT` như trên có median 0,94 giây. Bảng bị ghi liên tục phải tính khoản này trước khi thêm index thứ hai, thứ ba.

Thêm index không giúp trong hai tình huống. Thứ nhất, bảng nhỏ hoặc báo cáo cần đọc gần hết bảng: tài liệu MySQL nói đọc tuần tự có thể nhanh hơn đi qua index ([How MySQL Uses Indexes](https://dev.mysql.com/doc/refman/8.4/en/mysql-indexes.html)). Thứ hai, bảng bị ghi liên tục: mỗi index thêm vào làm `INSERT` chậm đi theo số đo ở trên. Index chọn theo báo cáo hôm nay có thể không phục vụ báo cáo tháng sau, nên mỗi index mới cần đi kèm câu truy vấn đã được `EXPLAIN` kiểm, và nên chạy lại báo cáo theo [kịch bản nghiệm thu UAT](https://mona.software/nghiem-thu-phan-mem-uat-kich-ban-kiem-thu/) để xác nhận kết quả vẫn đủ 596 dòng như trước. Trước khi thêm index trên bảng của hệ thống đang chạy, hãy có bản [sao lưu đã thử khôi phục](https://mona.software/sao-luu-khoi-phuc-du-lieu-phan-mem-doanh-nghiep/); thời gian `ALTER TABLE` trên bảng thật chưa được đo.

## Hỏi đáp

### EXPLAIN có chạy câu truy vấn không?

`EXPLAIN` không chạy câu lệnh, còn `EXPLAIN ANALYZE` thì có. Tài liệu MySQL liệt kê `EXPLAIN ANALYZE` hỗ trợ `SELECT`, `UPDATE` và `DELETE` có nối bảng, vì vậy hai lệnh sau cần chạy trên bản sao dữ liệu. Trên bảng thử, `EXPLAIN` trả kết quả ngay còn `EXPLAIN ANALYZE` của báo cáo chưa có index ra 213 ms ở lần chạy đầu vì phải đọc hết 1.000.000 dòng. `EXPLAIN` thuần chỉ ước tính, số dòng thực chỉ có sau khi chạy.

### Cột nào đứng đầu index nhiều cột?

Cột bị so sánh bằng `=` trong mọi truy vấn cần index đứng trước, cột khoảng đứng cuối. Với báo cáo ở bài, quy tắc cho ra thứ tự `(store_id, status, created_at)`; bảng bốn thứ tự ở trên cho thấy chênh lệch 0,965 ms với 6,435 ms. Lý do là khoảng ngày chặn các cột đứng sau nó, nên index đặt khoảng ngày trước phải duyệt 41.544 mục để giữ lại 596 dòng. Nếu cột `status` đứng sau khoảng ngày, mức chậm chỉ tăng từ 0,965 lên 1,355 ms vì khoảng đã thu còn 811 mục.

### Tạo index riêng cho từng cột có đủ không?

Không đủ cho báo cáo này. Với `(store_id)` và `(created_at)` riêng, MySQL chỉ dùng `ix_store`, đọc 20.000 dòng và ra median 20,82 ms, so với 0,965 ms của một index ba cột đúng thứ tự. Tài liệu MySQL nói bộ tối ưu có thể dùng Index Merge hoặc chọn index lọc chặt nhất; trên bảng thử nó chọn một index. Hai index đơn lẻ chiếm 38,0 MB, một index ba cột chiếm 29,6 MB.

### Làm sao biết index đang được dùng thật?

Đọc `key` (tên index, `NULL` nghĩa là không dùng) rồi đọc `key_len`: 57 là dùng cả ba cột, 52 là dừng sau `status` như ở trường hợp bọc hàm. `possible_keys` chỉ cho biết index có thể chọn. Câu bọc `DATE(created_at)` ở trên cho `key_len` 52 dù index có đủ ba cột.

### Báo cáo cần đọc gần hết bảng thì index có giúp không?

Gần như không. Tài liệu MySQL nói với báo cáo xử lý hầu hết dòng, đọc tuần tự nhanh hơn đi qua index. Báo cáo toàn chuỗi ở trên không lọc cửa hàng nên vẫn quét cả bảng (`type` là `ALL`) dù bảng có index, và `EXPLAIN ANALYZE` ra 341 và 405 ms.

Doanh nghiệp có báo cáo chạy chậm trong phần mềm đang dùng có thể trao đổi với MONA về [dịch vụ viết phần mềm theo yêu cầu](https://mona.software/viet-phan-mem-theo-yeu-cau/), hotline 1900 636 648.

## Câu hỏi thường gặp

### EXPLAIN có chạy câu truy vấn không?

EXPLAIN không chạy câu lệnh, còn EXPLAIN ANALYZE thì có. Tài liệu MySQL liệt kê EXPLAIN ANALYZE hỗ trợ SELECT, UPDATE và DELETE có nối bảng, vì vậy hai lệnh sau cần chạy trên bản sao dữ liệu. Trên bảng thử, EXPLAIN trả kết quả ngay còn EXPLAIN ANALYZE của báo cáo chưa có index ra 213 ms ở lần chạy đầu vì phải đọc hết 1.000.000 dòng. EXPLAIN thuần chỉ ước tính, số dòng thực chỉ có sau khi chạy.

### Cột nào đứng đầu index nhiều cột?

Cột bị so sánh bằng = trong mọi truy vấn cần index đứng trước, cột khoảng đứng cuối. Với báo cáo ở bài, quy tắc cho ra thứ tự (store_id, status, created_at); bảng bốn thứ tự ở trên cho thấy chênh lệch 0,965 ms với 6,435 ms. Lý do là khoảng ngày chặn các cột đứng sau nó, nên index đặt khoảng ngày trước phải duyệt 41.544 mục để giữ lại 596 dòng. Nếu cột status đứng sau khoảng ngày, mức chậm chỉ tăng từ 0,965 lên 1,355 ms vì khoảng đã thu còn 811 mục.

### Tạo index riêng cho từng cột có đủ không?

Không đủ cho báo cáo này. Với (store_id) và (created_at) riêng, MySQL chỉ dùng ix_store, đọc 20.000 dòng và ra median 20,82 ms, so với 0,965 ms của một index ba cột đúng thứ tự. Tài liệu MySQL nói bộ tối ưu có thể dùng Index Merge hoặc chọn index lọc chặt nhất; trên bảng thử nó chọn một index. Hai index đơn lẻ chiếm 38,0 MB, một index ba cột chiếm 29,6 MB.

### Làm sao biết index đang được dùng thật?

Đọc key (tên index, NULL nghĩa là không dùng) rồi đọc key_len: 57 là dùng cả ba cột, 52 là dừng sau status như ở trường hợp bọc hàm. possible_keys chỉ cho biết index có thể chọn. Câu bọc DATE(created_at) ở trên cho key_len 52 dù index có đủ ba cột.

### Báo cáo cần đọc gần hết bảng thì index có giúp không?

Gần như không. Tài liệu MySQL nói với báo cáo xử lý hầu hết dòng, đọc tuần tự nhanh hơn đi qua index. Báo cáo toàn chuỗi ở trên không lọc cửa hàng nên vẫn quét cả bảng (type là ALL) dù bảng có index, và EXPLAIN ANALYZE ra 341 và 405 ms. Doanh nghiệp có báo cáo chạy chậm trong phần mềm đang dùng có thể trao đổi với MONA về dịch vụ viết phần mềm theo yêu cầu, hotline 1900 636 648.
