Tìm hiểu về MySQL EXPLAIN

Tìm hiểu về MySQL EXPLAIN

Xem nhanh

Trước khi thực thi câu lệnh, MySQL sẽ thực hiện quá trình tối ưu hoá. MySQL sẽ thực hiện việc tính toán để đưa ra chi phí thực thi ước lượng (thời gian thực hiện) cho các phương án khác nhau có thể có. Bạn có thể xem thêm bài viết Sơ lược về kiến trúc của MySQL, để hiểu rõ hơn cách MySQL thực thi câu query.

EXPLAIN là câu lệnh sẽ giúp chúng ta có được thông tin về các tính toán tối ưu này. Qua đó, chúng ta có thể đánh giá được câu lệnh có vấn đề gì không, có điểm nào cần cải thiện hay không?

Bài viết này giúp bạn tìm hiểu về cách sử dụng MySQL EXPLAIN. Trong bài viết này tôi sử dụng MySQL Employees Sample Database để thực hiện các ví dụ minh hoạ.

Sơ lược về EXPLAIN

  • EXPLAIN nằm trong nhóm các Utility Statements, làm việc với các câu lệnh: SELECT, DELETE, INSERT, REPLACE, và UPDATE.
  • Khi EXPLAIN được sử dụng, MySQL sẽ hiển thị các thông tin về execution plan từ optimizer. Điều đó có nghĩa là MySQL giải thích cách mà nó xử lý câu lệnh, cũng như các thông tin về các bảng được joined, thứ tự thực hiện.
  • MySQL 8.0.18 giới thiệu EXPLAIN ANALYZE, cho ra output của EXPLAIN với thời gian, và các thông tin bổ sung về iterator.
  • Về cơ bản thì output format của EXPLAIN tập trung vào thông tin về execution plan, còn EXPLAIN ANALYZE lại cung cấp thông tin chi tiết thời gian thực thi. Chúng ta sẽ tìm hiểu về từng format ở các phần tiếp theo của bài viết nhé.

Cú pháp sử dụng

Dưới đây là mô tả cú pháp sử dụng EXPLAIN và EXPLAIN ANALYZE:

Copy
EXPLAIN
    tbl_name [col_name | wild]
EXPLAIN
    [explain_type]
    {explainable_stmt | FOR CONNECTION connection_id}
EXPLAIN ANALYZE [FORMAT = TREE] select_statement

explain_type: { FORMAT = format_name }

format_name: { TRADITIONAL | JSON | TREE }

explainable_stmt: { SELECT | TABLE | DELETE | INSERT | REPLACE | UPDATE}
  • Sử dụng EXPLAIN để lấy các thông tin về cấu trúc bảng, thông tin về execution plan.
  • Sử dụng EXPLAIN ANALYZE để lấy các thông tin về thời gian thực thi hay các thông tin về iterator.
  • EXPLAIN cho phép bạn chọn format output. Dạng output hiển thị theo bảng TRADITIONAL, JSON hoặc TREE.
  • EXPLAIN ANALYZE chỉ chấp nhận format TREE.

Giải thích EXPLAIN output

Copy
EXPLAIN SELECT * FROM dept_emp, employees
WHERE dept_emp.emp_no=employees.emp_no;

Với định dạng mặc định TRADITIONAL, output của EXPLAIN là một table. Mỗi dòng output cung cấp thông tin về 1 table. Với câu query bên trên, kết quả sẽ cho hai dòng như sau:

Output EXPLAIN với định dạng TRADITIONAL

Dưới đây là ý nghĩa output EXPLAIN ở định dạng TRADITIONAL:

select_type Ý nghĩa
id SELECT identifier
Trong trường hợp bạn sử dụng câu truy vấn lồng nhau thì các câu SELECT sẽ được đánh thứ tự để phân biệt.

select_type

Cột này chỉ ra dòng này là một SELECT phức tạp hay đơn giản.
Nếu nhãn là SIMPLE có nghĩa đây là dạng truy vấn đơn giản, không sử dụng UNION hay SUBQUERIES.
Nếu truy vấn có bất kỳ phần con phức tạp nào, phần ngoài cùng được gắn nhãn là PRIMARY và các phần khác có thể được gắn nhãn như sau:
  • SUBQUERY: SELECT trong SUBQUERY đặt trong mệnh đề SELECT
  • DERIVED: SELECT trong SUBQUERY đặt trong mệnh đề FROM. MySQL gọi đây là "bảng dẫn xuất", vì bảng tạm thời có nguồn gốc từ truy vấn con.
  • UNION: Các SELECT thứ hai hoặc tiếp theo trong một UNION. SELECT đầu tiên đã được gắn nhãn PRIMARY.
  • UNION RESULT: SELECT được sử dụng để truy xuất kết quả từ bảng tạm thời ẩn danh của UNION
Ngoài các giá trị trên, bạn có thể xem thêm các loại SELECT khác tại select_types.
table tên table, mà dòng output đang tham khảo tới
partitions Giá trị là null đối với nonpartitioned tables. Ngược lại, hiển thị các partitions đã sử dụng.

type

Đây là một thông tin rất quan trọng, mô tả cách access vào table trong câu query. Tuỳ vào cách access mà tốc độ truy vấn nhanh chậm khác nhau. Dựa vào type, chúng ta có thể điều chỉnh index cho database.
Các loại type thường gặp gồm:
  • const hoặc system: Bảng chỉ có duy nhất 1 row phù hợp với điều kiện tìm kiếm. Vì chỉ có 1 row nên giá trị của các cột trong row này được xem là hằng số. Loại join này rất nhanh vì chỉ đọc 1 lần duy nhất.
    SELECT FROM tbl_name WHERE primary_key=1;
  • eq_ref: Một tra cứu chỉ mục với index type là PRIMARY KEY, UNIQUE NOT NULL, với toán tử =. Sau system và const thì đây là loại join nhanh nhất.
    SELECT
    FROM ref_table,other_table
    WHERE ref_table.key_column=other_table.column;
  • ref: Một tra cứu chỉ mục với index type không phải PRIMARY KEY hoặc UNIQUE, với toán tử =, <=>.
  • range: Quét phạm vi là quét chỉ mục giới hạn, trả về các hàng phù hợp với range. Điều này tốt hơn so với quét toàn bộ chỉ mục.
    SELECT * FROM tbl_name WHERE key_part1 = 10 AND key_part2 IN (10,20,30);
  • index: index scan. Ưu điểm chính của loại này là tránh được việc sorting. Nhược điểm là chi phí đọc toàn bộ bảng theo thứ tự chỉ mục. Điều này thường có nghĩa là truy cập các hàng theo thứ tự ngẫu nhiên rất tốn kém.
  • ALL: table scan. MySQL phải quét qua bảng từ đầu đến cuối để tìm hàng. Đây là loại tệ nhất.
Ngoài các giá trị trên, bạn có thể xem thêm các loại JOIN khác tại join_types.
possible_keys Liệt kê tất cả các indexes liên quan có thể có để tìm các dòng trong table. Các column này có hoặc không sử dụng trong thực tế.
Nếu giá trị là NULL, tức là không có indexes nào liên quan. Trong trường hợp này bạn có thể xem xét lại mệnh đề WHERE để cải thiện hiệu suất câu query, để có thể tham khảo đến các cột indexes phù hợp. Hoặc tạo thêm indexes nếu cần thiết.
key Là các cột indexes thực thế mà MySQL quyết định sử dụng. Cột này có thể chứa khoá không liệt kê trong possible_keys.
key_len key: là index column, và nó sẽ có cây index
key_len: chỉ độ dài cây index mà MySQL sử dụng
Nếu key có giá trị NULL thì key_len cũng có giá trị NULL
ref Hiển thị các cột hoặc các hằng số được so sánh với index trong cột key
rows Thể hiện số rows mà MySQL dự kiến sẽ duyệt qua để thực thi câu query. Con số này là estimate, không chính xác.
filtered Thể hiện tỷ lệ phần trăm dự kiến các hàng của table được filtered bởi điều kiện.
Giá trị lớn nhất là 100: tức là không có quá trình lọc hàng nào xảy ra.
Con số giảm dần: cho thấy số lượng hàng lọc ngày càng tăng. Và nếu hàng được lọc càng nhiều thì có nghĩa số lượng hàng được joined với bảng tiếp theo cũng sẽ càng nhiều.
Ví dụ: số hàng là 1000, tỷ lệ lọc filtered: 50.00 (50%), thì số lượng hàng joined với bảng tiếp theo là: 1000 x 50% = 500

Extra

Các thông tin thêm về cách mà MySQl giải quyết câu query. Những giá trị Extra thường gặp gồm:
  • Using index: MySQL sử dụng covering index
  • Using where: MySQL sẽ lọc các hàng theo điều kiện
  • Using temporary: MySQL sẽ sử dụng một bảng tạm thời trong khi sắp xếp kết quả của truy vấn.
  • Using filesort: query nếu có ORDER BY chẳng hạn thì thông tin lấy về sẽ cần phải sort. Using filesort thường được hiển thị cho trường hợp này
  • Range checked for each record (index map:N): MySQL phát hiện không có good index, nhưng nhận thấy rằng một số chỉ mục có thể được sử dụng sau khi các giá trị cột từ các bảng trước đó được biết đến. Đối với mỗi tổ hợp hàng trong các bảng trước, MySQL sẽ kiểm tra xem có thể sử dụng range or index_merge để truy xuất các hàng hay không.
Để xem thêm về Extra, tham khảo Extra Information

Nhìn chung, mỗi dòng thông tin của EXPLAIN chứa khá nhiều thông tin. Để đánh giá nhanh câu query, nhận biết câu query có đang gặp vấn đề performance hay không, bạn có thể nhìn vào các field: select_type, typeExtra.

  • select_type cho chúng ta biết loại SELECT. Loại SELECT càng đơn giản, ít phụ thuộc, không chứa subquery sẽ càng truy vấn nhanh.
  • type mô tả cách access vào table trong câu query. Tuỳ vào cách access mà tốc độ truy vấn nhanh chậm khác nhau. Dựa vào type, chúng ta có thể điều chỉnh index cho database.
  • Extra đưa ra thêm một số thông tin về cách mà MySQl giải quyết câu query. Khi các giá trị: Using temporary, Using filesort xuất hiện tại đây, chúng ta có thể nhận diện ngay được câu query đang chưa thực sự tốt.

Giải thích EXPLAIN ANALYZE output

Từ MySQL 8.0.18, bên cạnh EXPLAIN, MySQL cung cấp thêm câu lệnh EXPLAIN ANALYZE. Về cơ bản thì EXPLAIN ANALYZE cũng thực hiện câu lệnh EXPLAIN. Tuy nhiên, định dạng output khác so với EXPLAIN, nó hiển thị các thông tin output về thời gian thực thi và iterator.

Với EXPLAIN ANALYZE, định dạng không được hỗ trợ phong phú như EXPLAIN. Nó chỉ chấp nhận định dạng TREE cho output.

Để hiểu rõ hơn sự khác biệt của EXPLAIN ANALYZE và EXPLAIN, chúng ta sẽ cùng đi qua ví dụ bên dưới:

Với EXPLAIN:

Copy
mysql> EXPLAIN SELECT COUNT(*) FROM employees WHERE employees.emp_no > 10400;
+----+-------------+-----------+------------+-------+---------------+---------+---------+------+--------+----------+--------------------------+
| id | select_type | table     | partitions | type  | possible_keys | key     | key_len | ref  | rows   | filtered | Extra                    |
+----+-------------+-----------+------------+-------+---------------+---------+---------+------+--------+----------+--------------------------+
|  1 | SIMPLE      | employees | NULL       | range | PRIMARY       | PRIMARY | 4       | NULL | 149778 |   100.00 | Using where; Using index |
+----+-------------+-----------+------------+-------+---------------+---------+---------+------+--------+----------+--------------------------+
1 row in set, 1 warning (0.00 sec)

Output của EXPLAIN cho biết: Câu SELECT sử dụng index PRIMARY trên bảng employees để tìm kiếm trong range, ước lượng số dòng phải đọc là 149778 dòng. Với InnoDB, đây là là con số ước lượng, không phải số thực tế khi thực thi.

Khi chạy câu query, kết quả thực tế như sau:

Copy
mysql> SELECT COUNT(*) FROM employees WHERE employees.emp_no > 10400;
+----------+
| COUNT(*) |
+----------+
|   299624 |
+----------+
1 row in set (0.08 sec)

Câu query mất 0.08s để đọc 299624 dòng. Như bạn thấy đó, số dòng thực tế cần đọc khi thực thi khác nhiều so với con số mà EXPLAIN đưa ra.

Với EXPLAIN ANALYZE:

Copy
mysql> EXPLAIN ANALYZE SELECT COUNT(*) FROM employees WHERE employees.emp_no > 10400;
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                                  |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Aggregate: count(0)  (cost=44970.80 rows=1) (actual time=129.047..129.047 rows=1 loops=1)
    -> Filter: (employees.emp_no > 10400)  (cost=29993.00 rows=149778) (actual time=0.760..110.114 rows=299624 loops=1)
        -> Covering index range scan on employees using PRIMARY over (10400 < emp_no)  (cost=29993.00 rows=149778) (actual time=0.016..75.085 rows=299624 loops=1)
 |
+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.13 sec)

Phân tích output của EXPLAIN ANALYZE:

Covering index range scan on employees using PRIMARY - đây là bước đầu tiên thực hiện, chỉ ra rằng câu SELECT sử dụng index PRIMARY trên bảng employees để tìm kiếm trong range (10400 < emp_no)

cost=29993.00 rows=149778 - chỉ ra chi phí ước lượng và số dòng cần đọc (con số này giống với số dòng EXPLAIN đưa ra bên trên)

actual time=0.016..75.085 rows=299624 loops=1 - đây là phần số liệu thực tế khi thực thi câu lệnh SELECT:

  • 0.016 - thời gian để trả về dòng đầu tiên (milliseconds)
  • 75.085 - thời gian để trả về tất cả các dòng (milliseconds)
  • rows=299624 - số dòng trả về (con số chính xác), con số giống với con số khi chúng ta chạy câu query thực
  • loops=1 - số lượng vòng loops

Như bạn thấy EXPLAIN ANALYZE hiển thị tất cả các thông tin chi tiết như trên cho từng bước: index scan, filter, aggregate. Điều đó thực sự hữu ích cho việc tối ưu hoá câu truy vấn.

Tại bước cuối cùng: Aggregate: count(0) (cost=44970.80 rows=1) (actual time=129.047..129.047 rows=1 loops=1), thời gian thực tế cần thiết là 129.047ms. Report cuối cùng vì vậy mà hiển thị: 1 row in set (0.13 sec). Như vậy, theo report thì câu query này cần 0.13s để thực thi.

Tuy nhiên, với câu query này, chúng ta chạy ở bên trên, kết quả chạy thực tế cho thấy thời gian thực thi là 0.08s. EXPLAIN ANALYZE thực hiện câu query, đo thời gian thực thi của từng bước, và xuất ra output. Chính vì thế so với câu query đơn thuần thì câu query chứa EXPLAIN ANALYZE sẽ có thời gian thực thi nhiều hơn.

Định dạng output EXPLAIN ANALYZE so với EXPLAIN thì: ngắn gọn, cô đọng, dễ hình dung các bước thực hiện câu query hơn. Tuy nhiên cần lưu ý thời gian thực thi cuối cùng của report EXPLAIN ANALYZE bao gồm cả thời gian xử lý cho việc EXPLAIN.

Sử dụng EXPLAIN để tối ưu một câu query chậm

Qua phần giải thích về output, các bạn đã hiểu được những thông tin từ EXPLAIN đưa ra. Trong phần này, chúng ta sẽ sử dụng những thông tin đó để tối ưu cho một câu query.

Như đã đề cập ở phần mở đầu, tôi sử dụng MySQL Employees Sample Database để làm các ví dụ minh hoạ. Câu query chọn làm ví dụ là: Lấy ra thông tin first_name, last_name, from_date, to_date của tất cả employees có first_name = 'Zvonko' thời gian bắt đầu làm việc nằm trong khoảng thời gian '1989-01-01' đến '1994-01-01', và sắp xếp thứ tự kết quả theo from_date:

Copy
SELECT employees.first_name, employees.last_name, dept_emp.from_date, dept_emp.to_date
FROM employees JOIN dept_emp ON employees.emp_no = dept_emp.emp_no
WHERE employees.first_name = 'Zvonko'
  AND dept_emp.from_date BETWEEN '1989-01-01' AND '1994-01-01'
ORDER BY dept_emp.from_date

Dưới đây là hình ảnh trích lược kết quả câu query:

Thời gian thực thi trước tối ưu

Report kết quả: 98 rows in set (0.10 sec)

Bây giờ, sử dụng EXPLAIN để kiểm tra các thông tin về loại JOIN có vấn đề gì không nhé:

Copy
mysql> EXPLAIN
    -> SELECT employees.first_name, employees.last_name, dept_emp.from_date, dept_emp.to_date
    -> FROM employees JOIN dept_emp ON employees.emp_no = dept_emp.emp_no
    -> WHERE employees.first_name = 'Zvonko'
    ->   AND dept_emp.from_date BETWEEN '1989-01-01' AND '1994-01-01'
    -> ORDER BY dept_emp.from_date;
+----+-------------+-----------+------------+------+---------------+---------+---------+----------------------------+--------+----------+----------------------------------------------+
| id | select_type | table     | partitions | type | possible_keys | key     | key_len | ref                        | rows   | filtered | Extra                                        |
+----+-------------+-----------+------------+------+---------------+---------+---------+----------------------------+--------+----------+----------------------------------------------+
|  1 | SIMPLE      | employees | NULL       | ALL  | PRIMARY       | NULL    | NULL    | NULL                       | 299556 |    10.00 | Using where; Using temporary; Using filesort |
|  1 | SIMPLE      | dept_emp  | NULL       | ref  | PRIMARY       | PRIMARY | 4       | employees.employees.emp_no |      1 |    11.11 | Using where                                  |
+----+-------------+-----------+------------+------+---------------+---------+---------+----------------------------+--------+----------+----------------------------------------------+
2 rows in set, 1 warning (0.00 sec)

Dòng đầu tiên của output cho thấy: type-ALL(loại JOIN tốn kém nhất), key-NULL(không thể xác định được bất cứ khoá nào dùng cho JOIN), Extra-Using temporary; Using filesort(sử dụng sắp xếp, bảng tạm đều tốn chi phí). Tất cả các thông tin này đều cho thấy câu query không tốt.

Với trường hợp type-ALL, chúng ta có thể tối ưu bằng cách thêm indexes để tránh việc quét toàn bộ bảng. Chúng ta nên bắt đầu đánh indexes những cột xuất hiện trong mệnh đề WHERE. Với câu query trên, tôi chọn thêm indexes trên cột employees.first_name:

Copy
ALTER TABLE employees ADD INDEX first_name_index (first_name)

Kiểm tra lại bằng EXPLAIN để xem sự thay đổi:

Copy
mysql> EXPLAIN
    -> SELECT employees.first_name, employees.last_name, dept_emp.from_date, dept_emp.to_date
    -> FROM employees JOIN dept_emp ON employees.emp_no = dept_emp.emp_no
    -> WHERE employees.first_name = 'Zvonko'
    ->   AND dept_emp.from_date BETWEEN '1989-01-01' AND '1994-01-01'
    -> ORDER BY dept_emp.from_date;
+----+-------------+-----------+------------+------+--------------------------+------------------+---------+----------------------------+------+----------+---------------------------------+
| id | select_type | table     | partitions | type | possible_keys            | key              | key_len | ref                        | rows | filtered | Extra                           |
+----+-------------+-----------+------------+------+--------------------------+------------------+---------+----------------------------+------+----------+---------------------------------+
|  1 | SIMPLE      | employees | NULL       | ref  | PRIMARY,first_name_index | first_name_index | 58      | const                      |  258 |   100.00 | Using temporary; Using filesort |
|  1 | SIMPLE      | dept_emp  | NULL       | ref  | PRIMARY                  | PRIMARY          | 4       | employees.employees.emp_no |    1 |    11.11 | Using where                     |
+----+-------------+-----------+------------+------+--------------------------+------------------+---------+----------------------------+------+----------+---------------------------------+
2 rows in set, 1 warning (0.00 sec)

Dòng đầu tiên của output cho thấy: type-ALL(loại JOIN tốn kém nhất) đã thay đổi thành type-ref vì sử dụng key-first_name_index. Thay vì scan qua rows-299556 thì giờ đây là: rows-258. Nhờ vậy mà số rows phải duyệt giảm đi đáng kể.

Chúng ta chạy lại câu query trên một lần nữa để kiểm tra thời gian thực thi thực tế:

Thời gian thực thi sau tối ưu

Report kết quả: 98 rows in set (0.01 sec). So với khi chưa đánh indexes: 98 rows in set (0.10 sec). Thời gian thực thi nhanh hơn 10 lần.

Kết

Bài viết này giải thích ý nghĩa output của EXPLAIN và EXPLAIN ANALYZE, cũng như cách chúng ta sử dụng chúng để tối ưu câu query.

Một vài điểm tóm tắt từ nội dung bài viết:

  • EXPLAIN có thể sử dụng như một công cụ giúp phát hiện nhanh chóng những câu query chưa tối ưu.
  • Sử dụng EXPLAIN ANALYZE nếu bạn cần output tóm tắt các bước thực hiện, đo thời gian thực tế chạy câu query.
  • Sử dụng EXPLAIN nếu bạn cần output chi tiết về loại SELECT, cách các bảng JOIN để phát hiện indexes thiếu hay một vấn đề bất thường của câu query.

Khi viết câu truy vấn dữ liệu, có rất nhiều cách viết câu query, đặc biệt là với dữ liệu lớn, nhiều bảng, nhiều liên kết. Hẳn là bạn sẽ có lúc phân vân không biết cách viết nào là tối ưu. Khi đó, câu lệnh EXPLAIN thực sự hữu ích. Chúng ta không cần cài đặt gì thêm, chỉ cần gọi trước câu truy vấn, bạn sẽ có ngay được kết quả so sánh.

Tôi cũng khá thích format output của EXPLAIN ANALYZE, nó liệt kê ngắn gọn các bước thực hiện và thời gian thực thi. Hãy tận dụng linh hoạt EXPLAIN và EXPLAIN ANALYZE để hỗ trợ bạn tốt nhất trong lúc kiểm tra và tối ưu câu query nhé!

Tài liệu tham khảo

Các bài viết cùng chủ đề