MySQL

Truy vấn MySQL nâng cao

JOIN, subquery, derived tables & LATERAL (8.0.14+), set operations, CTE recursive, window function (8.0+), regular expressions, JSON query, transaction, InnoDB locking, EXPLAIN, Hypergraph Optimizer (9.7), index.

1. JOIN — giống chuẩn

INNER, LEFT, RIGHT, CROSS y hệt SQL Server / Postgres. MySQL không có FULL OUTER JOIN — phải mô phỏng:

SELECT * FROM a LEFT JOIN b ON a.id = b.a_id
UNION
SELECT * FROM a RIGHT JOIN b ON a.id = b.a_id;

STRAIGHT_JOIN — ép thứ tự

-- Khi optimizer chọn thứ tự sai, ép join theo thứ tự viết
SELECT STRAIGHT_JOIN * FROM small s JOIN big b ON b.s_id = s.id;

2. Subquery

Cú pháp chuẩn. MySQL trước đây correlated subquery rất chậm, từ 8.0 và đặc biệt Hypergraph Optimizer (9.7 Community) đã cải thiện nhiều.

-- Scalar
SELECT name, (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS cnt
FROM users u;

-- EXISTS
SELECT * FROM users u
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id);

-- Derived table (bắt buộc alias!)
SELECT * FROM (
    SELECT user_id, SUM(total) total FROM orders GROUP BY user_id
) t WHERE t.total > 10000000;
MySQL bắt buộc đặt alias cho derived table. Postgres và SQL Server không bắt buộc.

3. Derived tables & LATERAL (8.0.14+)

Derived table

Derived table là subquery trong FROM clause. MySQL bắt buộc phải có alias:

SELECT *
FROM (
    SELECT user_id, SUM(total) AS total
    FROM orders
    GROUP BY user_id
) t
WHERE t.total > 10000000;

LATERAL — tham chiếu cột từ bảng trước

Từ MySQL 8.0.14, LATERAL cho phép subquery trong FROM hoặc JOIN tham chiếu đến cột của bảng phía trước — điều derived table thông thường không làm được:

-- Lấy 3 đơn hàng gần nhất của mỗi user
SELECT u.name, o.order_date, o.total
FROM users u
JOIN LATERAL (
    SELECT order_date, total
    FROM orders
    WHERE user_id = u.id          -- ← tham chiếu u.id từ bảng ngoài
    ORDER BY order_date DESC
    LIMIT 3
) o ON TRUE;
Câu PV:"LATERAL khác gì JOIN thường?"

"JOIN thường không thể tham chiếu cột từ bảng bên trái trong subquery. LATERAL cho phép điều đó — giống CROSS APPLY trong SQL Server. Dùng để lấy top-N per group với LIMIT, hoặc gọi function với tham số từ mỗi row."

LATERAL vs Window Function — cùng bài toán

Cùng bài toán "top N per group", LATERAL và ROW_NUMBER() cho kết quả giống nhau, nhưng LATERAL thường nhanh hơn khi N nhỏ và có index tốt (có thể dừng sớm):

-- Cách 1: Window function (luôn scan toàn bộ partition)
WITH ranked AS (
    SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) rn
    FROM orders
)
SELECT * FROM ranked WHERE rn <= 3;

-- Cách 2: LATERAL (có thể dừng sau khi lấy đủ 3 row)
SELECT u.name, o.*
FROM users u
JOIN LATERAL (
    SELECT * FROM orders
    WHERE user_id = u.id
    ORDER BY created_at DESC
    LIMIT 3
) o ON TRUE;

4. Set operations

-- UNION — loại bỏ duplicate (phải sort/distinct → chậm hơn)
SELECT email FROM customers
UNION
SELECT email FROM suppliers;

-- UNION ALL — giữ tất cả row, kể cả duplicate (nhanh hơn)
SELECT email FROM customers
UNION ALL
SELECT email FROM suppliers;

-- INTERSECT — row xuất hiện trong CẢ 2 tập (MySQL 8.0.31+)
SELECT email FROM customers
INTERSECT
SELECT email FROM suppliers;

-- EXCEPT — row có trong tập 1 nhưng KHÔNG có trong tập 2 (MySQL 8.0.31+)
SELECT email FROM customers
EXCEPT
SELECT email FROM banned;
Câu PV:"UNION và UNION ALL khác nhau thế nào? Khi nào dùng cái nào?"

"UNION loại bỏ duplicate → phải sort hoặc hash distinct → chậm hơn. UNION ALL giữ tất cả → nhanh hơn. Luôn dùng UNION ALL trừ khi thực sự cần loại bỏ duplicate."

INTERSECT và EXCEPT chỉ có từ MySQL 8.0.31+. Trước đó phải mô phỏng bằng JOIN hoặc NOT EXISTS / NOT IN.

Yêu cầu của set operations

  • Số cột phải bằng nhau giữa các SELECT
  • Kiểu dữ liệu tương thích ở mỗi vị trí
  • Tên cột lấy từ SELECT đầu tiên
  • ORDER BY chỉ được đặt ở cuối cùng, áp dụng cho toàn bộ kết quả
SELECT id, name, 'customer' AS type FROM customers
UNION ALL
SELECT id, name, 'supplier' FROM suppliers
ORDER BY type, name;

5. CTE — Common Table Expression (8.0+)

CTE cơ bản

WITH high_value AS (
    SELECT * FROM orders WHERE total > 10000000
)
SELECT u.name, COUNT(*)
FROM high_value h JOIN users u ON u.id = h.user_id
GROUP BY u.name;

Nhiều CTE trong một query

WITH
    active_users AS (
        SELECT id, name FROM users WHERE status = 'active'
    ),
    user_totals AS (
        SELECT user_id, SUM(total) AS total_spent
        FROM orders
        WHERE created_at >= '2025-01-01'
        GROUP BY user_id
    ),
    ranked AS (
        SELECT
            au.name,
            ut.total_spent,
            RANK() OVER (ORDER BY ut.total_spent DESC) AS rk
        FROM active_users au
        JOIN user_totals ut ON ut.user_id = au.id
    )
SELECT name, total_spent, rk
FROM ranked
WHERE rk <= 10;
⭐ Nhiều CTE trong cùng một query — mỗi CTE cách nhau bằng dấu phẩy, chỉ cần một từ khóa WITH ở đầu. Pattern này giúp query phức tạp dễ đọc, dễ debug hơn nhiều so với nested subquery.

Recursive CTE — sơ đồ tổ chức (organizational chart)

WITH RECURSIVE org AS (
    -- Anchor: CEO (không có manager)
    SELECT id, name, manager_id, 0 AS lvl,
           CAST(name AS CHAR(500)) AS path
    FROM employees WHERE manager_id IS NULL

    UNION ALL

    -- Recursive: nhân viên dưới quyền
    SELECT e.id, e.name, e.manager_id, o.lvl + 1,
           CONCAT(o.path, ' > ', e.name)
    FROM employees e JOIN org o ON o.id = e.manager_id
)
SELECT id, name, lvl, path
FROM org
ORDER BY path;

Kết quả: hiển thị toàn bộ cây tổ chức với level và đường dẫn từ CEO đến từng nhân viên.

Recursive CTE — Bill of Materials (BOM)

-- Sản phẩm lắp ráp: tìm tất cả linh kiện con của sản phẩm #1
WITH RECURSIVE bom AS (
    -- Anchor: linh kiện trực tiếp
    SELECT product_id, component_id, quantity, 1 AS lvl
    FROM bill_of_materials
    WHERE product_id = 1

    UNION ALL

    -- Recursive: linh kiện con của linh kiện
    SELECT b.product_id, b.component_id, b.quantity, bom.lvl + 1
    FROM bill_of_materials b
    JOIN bom ON bom.component_id = b.product_id
)
SELECT b.lvl, b.product_id, p.name AS assembly,
       b.component_id, c.name AS component, b.quantity
FROM bom b
JOIN products p ON p.id = b.product_id
JOIN products c ON c.id = b.component_id
ORDER BY b.lvl, b.product_id;

Generate sequence (calendar, number table)

WITH RECURSIVE seq(n) AS (
    SELECT 1
    UNION ALL
    SELECT n + 1 FROM seq WHERE n < 100
)
SELECT * FROM seq;

-- Generate calendar
WITH RECURSIVE dates(d) AS (
    SELECT '2025-01-01'
    UNION ALL
    SELECT d + INTERVAL 1 DAY FROM dates WHERE d < '2025-12-31'
)
SELECT d FROM dates;
Câu PV:"Recursive CTE dừng như thế nào? Làm sao tránh infinite loop?"

"Recursive member trả về empty set là dừng. MySQL có cte_max_recursion_depth (default 1000) để giới hạn số vòng lặp — tăng lên nếu cần: SET SESSION cte_max_recursion_depth = 10000;. Luôn đảm bảo điều kiện JOIN trong recursive member sẽ hội tụ."

MySQL không có MATERIALIZED hint như Postgres. Trong MySQL, CTE luôn được optimizer tự quyết định có materialize (lưu tạm vào bảng nhớ) hay merge thẳng vào query chính. Dùng EXPLAIN để kiểm tra.

CTE kết hợp Window Function

WITH monthly_sales AS (
    SELECT
        DATE_FORMAT(created_at, '%Y-%m') AS month,
        SUM(total) AS revenue
    FROM orders
    GROUP BY DATE_FORMAT(created_at, '%Y-%m')
),
with_growth AS (
    SELECT
        month, revenue,
        LAG(revenue) OVER (ORDER BY month) AS prev_revenue,
        ROUND(
            (revenue - LAG(revenue) OVER (ORDER BY month))
            / LAG(revenue) OVER (ORDER BY month) * 100,
            2
        ) AS growth_pct
    FROM monthly_sales
)
SELECT * FROM with_growth
WHERE growth_pct IS NOT NULL
ORDER BY month;

6. Window function (8.0+)

Tất cả function chuẩn ANSI được hỗ trợ:

  • Ranking: ROW_NUMBER, RANK, DENSE_RANK, NTILE, PERCENT_RANK, CUME_DIST.
  • Offset: LAG, LEAD, FIRST_VALUE, LAST_VALUE, NTH_VALUE.
  • Aggregate: SUM, AVG, COUNT, MIN, MAX với OVER.

Frame Specification — ROWS vs RANGE vs GROUPS

Frame xác định phạm vi row trong partition mà window function tính toán:

-- ROWS: đếm theo số dòng vật lý
AVG(total) OVER (
    PARTITION BY user_id
    ORDER BY created_at
    ROWS BETWEEN 6 PRECEDING AND CURRENT ROW    -- 7 dòng gần nhất
)

-- RANGE: đếm theo giá trị của ORDER BY column
AVG(total) OVER (
    PARTITION BY user_id
    ORDER BY created_at
    RANGE BETWEEN INTERVAL 7 DAY PRECEDING AND CURRENT ROW  -- 7 ngày gần nhất
)

-- GROUPS: đếm theo nhóm peer (các row có cùng giá trị ORDER BY)
AVG(total) OVER (
    PARTITION BY user_id
    ORDER BY created_at
    GROUPS BETWEEN 1 PRECEDING AND CURRENT ROW  -- nhóm hiện tại + 1 nhóm trước
)
Frame UnitĐếm theoDùng khi
ROWSSố dòng vật lýDữ liệu dày, mỗi dòng là 1 record
RANGEGiá trị của ORDER BYDữ liệu thưa, cần khoảng thời gian thực
GROUPSNhóm peer (cùng giá trị)Cần aggregate theo nhóm logic

Top-N per group (idiom kinh điển)

WITH ranked AS (
    SELECT *,
        ROW_NUMBER() OVER (
            PARTITION BY user_id ORDER BY created_at DESC
        ) AS rn,
        RANK() OVER (
            PARTITION BY user_id ORDER BY total DESC
        ) AS rank_by_value
    FROM orders
)
SELECT * FROM ranked
WHERE rn <= 3;       -- 3 đơn gần nhất mỗi user

-- RANK vs DENSE_RANK vs ROW_NUMBER:
-- VALUES:    100, 100,  90,  80,  80
-- RANK:        1,   1,   3,   4,   4   ← bỏ gap
-- DENSE_RANK:  1,   1,   2,   3,   3   ← không bỏ gap
-- ROW_NUMBER:  1,   2,   3,   4,   5   ← unique

Running total & Moving Average

SELECT
    order_date,
    total,
    -- Running total: tổng lũy kế
    SUM(total) OVER (ORDER BY order_date) AS running_total,

    -- Moving average 7 dòng (ROWS)
    AVG(total) OVER (
        ORDER BY order_date
        ROWS BETWEEN 6 PRECEDING AND CURRENT ROW
    ) AS ma7_rows,

    -- Moving average 7 ngày thực (RANGE)
    AVG(total) OVER (
        ORDER BY order_date
        RANGE BETWEEN INTERVAL 6 DAY PRECEDING AND CURRENT ROW
    ) AS ma7_range
FROM orders;

So sánh period-over-period (LAG / LEAD)

SELECT
    DATE_FORMAT(created_at, '%Y-%m') AS month,
    SUM(total) AS revenue,
    LAG(SUM(total)) OVER (
        ORDER BY DATE_FORMAT(created_at, '%Y-%m')
    ) AS prev_month,
    LEAD(SUM(total)) OVER (
        ORDER BY DATE_FORMAT(created_at, '%Y-%m')
    ) AS next_month,
    ROUND(
        (SUM(total) - LAG(SUM(total)) OVER (
            ORDER BY DATE_FORMAT(created_at, '%Y-%m')
        ))
        / LAG(SUM(total)) OVER (
            ORDER BY DATE_FORMAT(created_at, '%Y-%m')
        ) * 100,
        2
    ) AS mom_growth_pct
FROM orders
GROUP BY DATE_FORMAT(created_at, '%Y-%m');

FIRST_VALUE / LAST_VALUE / NTH_VALUE

SELECT
    user_id, order_date, total,
    FIRST_VALUE(total) OVER (
        PARTITION BY user_id ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS first_order_value,
    NTH_VALUE(total, 2) OVER (
        PARTITION BY user_id ORDER BY order_date
        ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
    ) AS second_order_value
FROM orders;
LAST_VALUE thường không hoạt động như mong đợi với default frame (RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) — nó luôn trả về current row. Phải explicit ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING để lấy giá trị cuối cùng của partition.

PERCENT_RANK & CUME_DIST

SELECT
    name, score,
    ROUND(PERCENT_RANK() OVER (ORDER BY score DESC) * 100, 1) AS percentile,
    ROUND(CUME_DIST() OVER (ORDER BY score DESC) * 100, 1) AS cumulative_pct
FROM exam_results;
Câu PV:"Trước 8.0 MySQL không có window function — làm sao tính top N per group?"

Trick cũ dùng user variable: SET @rn := IF(@last_user = user_id, @rn+1, 1). Xấu xí, dễ bug. Khuyến nghị: upgrade 8.0+ ngay nếu còn đang stuck 5.7.

7. Regular Expressions

MySQL hỗ trợ regex đầy đủ với nhiều function từ 8.0+.

REGEXP / RLIKE — kiểm tra khớp mẫu

-- Tìm email Gmail
SELECT email FROM users
WHERE email REGEXP '^[a-zA-Z0-9._%+-]+@gmail\\.com$';

-- RLIKE là alias của REGEXP
SELECT * FROM products
WHERE name RLIKE '^(iPhone|iPad|Mac)';

-- Case-insensitive (mặc định)
SELECT * FROM users WHERE name REGEXP '^nguyen';

REGEXP_REPLACE — thay thế theo mẫu

-- Chuẩn hóa số điện thoại: xóa tất cả ký tự không phải số
SELECT REGEXP_REPLACE(phone, '[^0-9]', '') AS clean_phone
FROM contacts;

-- Mask email: giữ ký tự đầu và domain, ẩn phần giữa
SELECT REGEXP_REPLACE(email, '(.).*(@.*)', '$1***$2') AS masked_email
FROM users;

REGEXP_SUBSTR — trích xuất chuỗi con

-- Trích xuất domain từ email
SELECT email,
       REGEXP_SUBSTR(email, '@(.+)$') AS domain
FROM users;

-- Trích xuất mã bưu điện từ địa chỉ
SELECT address,
       REGEXP_SUBSTR(address, '[0-9]{5,6}') AS postal_code
FROM shipping_addresses;

REGEXP_INSTR — tìm vị trí khớp

-- Vị trí bắt đầu của dãy số đầu tiên trong chuỗi
SELECT REGEXP_INSTR('Product-12345-XYZ', '[0-9]+') AS number_start;
-- → 9

-- Vị trí kết thúc (dùng option thứ 6 = 1)
SELECT REGEXP_INSTR('Product-12345-XYZ', '[0-9]+', 1, 1, 1) AS number_end;
-- → 13
Performance tip: Regex chậm hơn LIKE và full-text search. Nếu pattern đơn giản, luôn dùng LIKE (có thể dùng index nếu không bắt đầu bằng %). Chỉ dùng regex khi thực sự cần pattern phức tạp.
MySQL dùng ICU regex engine (International Components for Unicode) từ 8.0.4+, hỗ trợ Unicode đầy đủ. Trước đó dùng Henry Spencer implementation với một số hạn chế về Unicode.

8. JSON query

Access JSON data

SELECT
    payload->'$.user' AS user_json,              -- trả về JSON object (có quote)
    payload->>'$.user.name' AS user_name,        -- trả về text (đã unquote)
    JSON_EXTRACT(payload, '$.tags[0]') AS first_tag;
-> vs ->>:-> trả về JSON value (có quote), ->> trả về text đã unquote — tương đương JSON_UNQUOTE(JSON_EXTRACT(...)). Dùng ->> khi cần so sánh hoặc hiển thị.

Search trong JSON

-- Kiểm tra array có chứa giá trị
SELECT * FROM events
WHERE JSON_CONTAINS(payload->'$.tags', '"vue"');

-- Kiểm tra path có tồn tại
SELECT * FROM events WHERE JSON_CONTAINS_PATH(payload, 'one', '$.user.id');

-- Search text trong toàn bộ document
SELECT * FROM events
WHERE JSON_SEARCH(payload, 'all', '%vue%') IS NOT NULL;

JSON_TABLE() — chuyển JSON thành bảng quan hệ (8.0.4+)

JSON_TABLE() là công cụ mạnh nhất để làm việc với JSON trong MySQL — biến JSON array thành rows quan hệ:

-- Cho JSON: {"items": [{"name": "Áo thun", "qty": 2}, {"name": "Quần jean", "qty": 1}]}
SELECT jt.*
FROM orders,
JSON_TABLE(
    payload,
    '$.items[*]'
    COLUMNS (
        item_name VARCHAR(100) PATH '$.name',
        quantity INT PATH '$.qty',
        row_num FOR ORDINALITY
    )
) AS jt;
-- Nested JSON — lồng nhiều cấp với NESTED PATH
-- Cho JSON: {"categories": [{"cat": "Thời trang", "products": [{"name": "Áo", "price": 200}]}]}
SELECT jt.cat_name, jt.product_name, jt.price
FROM orders,
JSON_TABLE(payload, '$.categories[*]'
    COLUMNS (
        cat_name VARCHAR(50) PATH '$.cat',
        NESTED PATH '$.products[*]' COLUMNS (
            product_name VARCHAR(100) PATH '$.name',
            price DECIMAL(10,2) PATH '$.price'
        )
    )
) AS jt;
Chi tiết về JSON data type, index, và best practices: xem JSON trong MySQL.

Index trên JSON path

-- Cách 1: Generated column (mọi version hỗ trợ JSON)
ALTER TABLE events
    ADD COLUMN event_type VARCHAR(50)
        AS (payload->>'$.type') VIRTUAL,
    ADD INDEX idx_type (event_type);

-- Query dùng generated column → dùng index
SELECT * FROM events WHERE event_type = 'click';

-- Cách 2: Functional index (8.0.13+) — không cần thêm cột
CREATE INDEX idx_event_type ON events ((CAST(payload->>'$.type' AS CHAR(50))));

9. Aggregate

-- GROUP BY chuẩn
SELECT city, COUNT(*) cnt
FROM users WHERE active = 1
GROUP BY city
HAVING cnt > 10
ORDER BY cnt DESC;

-- GROUP BY chỉ cho phép cột trong GROUP BY hoặc aggregate
-- (default ONLY_FULL_GROUP_BY mode từ 5.7+)
-- ❌ SELECT id, name, COUNT(*) FROM users GROUP BY city  -- lỗi

-- GROUPING SETS / ROLLUP
SELECT city, status, COUNT(*)
FROM orders
GROUP BY city, status WITH ROLLUP;     -- thêm row subtotal + grand total
ONLY_FULL_GROUP_BY SQL mode (default 5.7+) ép GROUP BY chuẩn ANSI. Trước đó MySQL cho SELECT id, name FROM ... GROUP BY city chạy nhưng trả id/name bất kỳ row trong group → bug ẩn.

10. Transaction & InnoDB locking

Cú pháp

START TRANSACTION;
UPDATE accounts SET balance = balance - 1000 WHERE id = 1;
UPDATE accounts SET balance = balance + 1000 WHERE id = 2;
COMMIT;
-- Hoặc ROLLBACK;

-- Savepoint
START TRANSACTION;
INSERT ...;
SAVEPOINT sp1;
UPDATE ...;
ROLLBACK TO sp1;
COMMIT;

Auto-commit

-- Mặc định MỖI statement = 1 transaction (auto-commit ON)
SET AUTOCOMMIT = 0;
-- Sau đó phải COMMIT/ROLLBACK explicit

Isolation level — default REPEATABLE READ

Câu PV cốt lõi:"MySQL default isolation là gì?"

"REPEATABLE READ — cao hơn SQL Server / Postgres (cả 2 mặc định READ COMMITTED). MySQL chọn REPEATABLE READ vì lý do replication (statement-based binlog cần consistent read)."

-- Set level
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 4 level MySQL hỗ trợ
-- READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE

Gap lock và Next-key lock (đặc thù InnoDB)

-- Trong REPEATABLE READ, InnoDB lock cả khoảng trống giữa các index value
-- để chống phantom read
SELECT * FROM orders WHERE id BETWEEN 10 AND 20 FOR UPDATE;
-- Lock không chỉ row 10-20 mà cả "gap" giữa các row → INSERT vào khoảng này bị block
Câu PV nâng cao:"Gap lock là gì?"

"InnoDB ở REPEATABLE READ lock cả khoảng trống giữa các index value, ngăn INSERT phantom. Tránh phantom read mà không cần Serializable. Hiệu ứng phụ: dễ deadlock hơn — fresher cần biết để debug khi gặp."

SELECT ... FOR UPDATE / FOR SHARE

START TRANSACTION;

-- Lock exclusive — chặn UPDATE và FOR UPDATE khác
SELECT * FROM products WHERE id = 1 FOR UPDATE;

-- Lock shared — cho phép FOR SHARE khác đọc, chặn UPDATE
SELECT * FROM products WHERE id = 1 FOR SHARE;

UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

Deadlock

-- Xem deadlock gần nhất
SHOW ENGINE INNODB STATUS;

-- Bật log deadlock vào error log
SET GLOBAL innodb_print_all_deadlocks = ON;

InnoDB tự detect và rollback 1 transaction. Tránh: lock cùng thứ tự, giữ transaction ngắn.

11. EXPLAIN

EXPLAIN SELECT * FROM orders WHERE user_id = 1;

EXPLAIN ANALYZE
SELECT * FROM orders WHERE user_id = 1;   -- 8.0+, chạy thực và đo

Output columns

CộtÝ nghĩa
idID của SELECT
select_typeSIMPLE / SUBQUERY / DERIVED / PRIMARY
tableBảng
typeAccess type — quan trọng nhất
possible_keysIndex có thể dùng
keyIndex thực sự dùng
key_lenSố byte key dùng
rowsEstimate số row đọc
ExtraUsing where / Using index / Using filesort …

Access type (best → worst)

system   — bảng 1 row
const    — match constant 1 row (PK)
eq_ref   — JOIN unique key, 1 row mỗi outer
ref      — JOIN non-unique key
range    — index range
index    — full index scan
ALL      — full table scan ⚠️
Từ MySQL 9.7 Community, Hypergraph Optimizer (DPhyp) có thể tạo ra EXPLAIN plan hoàn toàn khác — join order và access path được tối ưu tốt hơn. Xem chi tiết: Hypergraph Optimizer.

12. Hypergraph Optimizer (MySQL 9.7 Community Edition)

Cột mốc quan trọng: MySQL 9.7 Community Edition chính thức mang Hypergraph Optimizer đến với tất cả người dùng — trước đây optimizer này chỉ có trong HeatWave (cloud). Đây là bước nhảy vọt về query optimization, đặc biệt cho các query JOIN nhiều bảng.

Hypergraph Optimizer là gì?

Hypergraph Optimizer sử dụng thuật toán DPhyp (Dynamic Programming Hypergraph) để tìm join order tối ưu, thay cho optimizer truyền thống vốn dùng greedy search. Kết quả thực tế:

  • 2–5x nhanh hơn cho JOIN-heavy queries (star schema, analytics, nhiều bảng)
  • Join order tốt hơn — DPhyp khám phá toàn bộ không gian join order thay vì greedy chọn từng bước
  • Đặc biệt hiệu quả với OLAP / analytics queries trên star schema (fact + nhiều dimension)
  • Subquery optimization tốt hơn — correlated subquery được rewrite hiệu quả hơn

Cách bật Hypergraph Optimizer

-- Bật ở session level (chỉ ảnh hưởng connection hiện tại)
SET SESSION optimizer_switch = 'hypergraph=on';

-- Bật ở global level (áp dụng cho tất cả connection mới)
SET GLOBAL optimizer_switch = 'hypergraph=on';

-- Per-statement: dùng optimizer hint — không cần thay đổi setting
SELECT /*+ SET_VAR(optimizer_switch='hypergraph=on') */
    c.name, SUM(o.total) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
GROUP BY c.name
ORDER BY revenue DESC;

Kiểm tra optimizer đã dùng

-- Sau khi chạy query, kiểm tra optimizer nào đã được sử dụng
SELECT @@last_optimizer_used;
-- Kết quả: 'hypergraph' hoặc 'old'

Nên dùng Hypergraph hay old optimizer?

Tình huốngNên dùng
Query JOIN 3+ bảngHypergraph
Star schema (fact + dimension tables)Hypergraph
Subquery phức tạp, correlated subqueryHypergraph
OLTP query đơn giản (1-2 bảng, indexed tốt)Old (planning overhead thấp hơn)
Ứng dụng đã tối ưu index kỹ, query ổn địnhTest cả 2, chọn cái nhanh hơn

Lưu ý quan trọng

  • Không thay thế index tốt — Hypergraph chỉ chọn join order tốt hơn, không bù đắp cho việc thiếu index. Full table scan vẫn là full table scan.
  • Vẫn cần EXPLAIN để verify plan — dùng EXPLAIN ANALYZE để xem thời gian thực tế
  • Test trước khi bật global — một số edge case có thể regression, luôn test trên staging trước
  • Planning time có thể tăng nhẹ — DPhyp explore nhiều plan hơn → planning lâu hơn, nhưng execution nhanh hơn nhiều → tổng thời gian giảm
-- Kiểm tra version MySQL
SELECT VERSION();

-- Xem trạng thái hypergraph trong optimizer_switch
SHOW VARIABLES LIKE 'optimizer_switch';

-- Force dùng old optimizer nếu cần (per-statement)
SELECT /*+ SET_VAR(optimizer_switch='hypergraph=off') */
    * FROM orders WHERE user_id = 1;
Câu PV:"Hypergraph Optimizer trong MySQL 9.7 là gì? Khác gì optimizer cũ?"

"Hypergraph Optimizer sử dụng thuật toán DPhyp thay vì greedy search để tìm join order tối ưu. Trước đây chỉ có trên HeatWave (cloud), từ 9.7 có trên Community Edition. Ưu điểm chính: khám phá toàn bộ không gian join order thay vì greedy từng bước → plan tốt hơn cho query nhiều JOIN. Đánh đổi: planning time cao hơn, nhưng execution nhanh hơn 2-5x với query phức tạp. Dùng SET SESSION optimizer_switch = 'hypergraph=on' để bật, kiểm tra bằng SELECT @@last_optimizer_used."

13. Index trong InnoDB

  • Primary key = Clustered index (như SQL Server). Bảng InnoDB luôn clustered theo PK.
  • Secondary index = non-clustered, leaf chứa PK value (không phải row pointer).
CREATE INDEX idx_orders_user_date ON orders(user_id, created_at);

-- Covering index
CREATE INDEX idx_orders_cover ON orders(user_id, created_at, total, status);

-- Functional index (8.0.13+)
CREATE INDEX idx_lower_email ON users ((LOWER(email)));

-- Descending index (8.0+)
CREATE INDEX idx_orders_recent ON orders(created_at DESC);

-- Drop
DROP INDEX idx_orders_user_date ON orders;
Mọi secondary index trong InnoDB chứa PK ở leaf. Vì sao? Để locate row qua clustered index. Hệ quả: PK càng nhỏ → mọi index càng nhỏ. → PK nên là BIGINT AUTO_INCREMENT thay vì UUID/composite key dài.

© 2026 .NET Fresher Guide. All rights reserved.

Về Trang Web

Hướng dẫn toàn diện để chuẩn bị phỏng vấn vị trí .NET Fresher với nội dung từ lý thuyết đến thực hành.