Bài 15: Phân Tích JOIN – Lựa Chọn Thuật Toán Nested Loop, Hash, Merge

"Chọn sai thuật toán JOIN, query của bạn có thể 'đắm chìm' trong biển dữ liệu!"


1. Vấn Đề: Query JOIN 2 Bảng Lớn Chậm Bất Thường

Scenario:
Hai bảng dữ liệu:

  • customers (1 triệu khách hàng, khóa chính customer_id).

  • orders (10 triệu đơn hàng, khóa ngoại customer_id, order_date).

Thực hiện query:

SELECT c.customer_name, COUNT(o.order_id) AS total_orders  
FROM customers c  
JOIN orders o ON c.customer_id = o.customer_id  
WHERE c.country = 'Vietnam'  
GROUP BY c.customer_name  
ORDER BY total_orders DESC;

Kết quả: Thời gian thực thi ~20 giây.


2. Phân Tích Execution Plan

Bước 1: Chạy EXPLAIN ANALYZE (PostgreSQL)
Sort (cost=450000.00..451000.00 rows=400000 width=40) 
  (actual time=19000.123..19500.456 rows=50000 loops=1)  
  Sort Key: (COUNT(o.order_id)) DESC  
  Sort Method: external merge  Disk: 80000KB  
  -> HashAggregate (cost=300000.00..340000.00 rows=400000 width=40) 
    (actual time=17000.123..18500.789 rows=50000 loops=1)  
    Group Key: c.customer_name  
    -> Hash Join (cost=150000.00..250000.00 rows=1000000 width=40) 
      (actual time=1000.123..15000.456 rows=800000 loops=1)  
      Hash Cond: (o.customer_id = c.customer_id)  
      -> Seq Scan on orders o (cost=0.00..150000.00 rows=10000000 width=8) 
        (actual time=0.012..8000.123 rows=10000000 loops=1)  
      -> Hash (cost=120000.00..120000.00 rows=200000 width=36) 
        (actual time=1000.012..1000.012 rows=200000 loops=1)  
        Buckets: 32768  Batches: 1  Memory Usage: 200MB  
        -> Seq Scan on customers c (cost=0.00..120000.00 rows=200000 width=36) 
          (actual time=0.010..500.123 rows=200000 loops=1)  
          Filter: (country = 'Vietnam')  
          Rows Removed by Filter: 800000
Bước 2: Giải Mã Vấn Đề
  • Hash Join:

    • Bảng lớn orders (10 triệu dòng) được quét toàn bộ (Seq Scan).

    • Bảng customers: Lọc 200,000 dòng (khách hàng ở Vietnam) → Tạo hash table.

  • Bottleneck Chính:

    1. Hash Join trên bảng orders lớn → Tốn bộ nhớ và I/O.

    2. Thiếu index trên customers.countryorders.customer_id.

    3. Sort trên Disk do dữ liệu tổng hợp lớn.


3. Tối Ưu: Lựa Chọn Thuật Toán JOIN Phù Hợp

Bước 1: Thêm Index Để Tối Ưu Điều Kiện WHERE và JOIN
-- Index trên customers.country để lọc nhanh  
CREATE INDEX idx_customers_country ON customers(country);  

-- Index trên orders.customer_id để tối ưu JOIN  
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
Bước 2: Điều Chỉnh Join Algorithm Sang Merge Join
SET enable_hashjoin = off;  -- Tắt Hash Join  
SET enable_nestloop = off;  -- Tắt Nested Loop
Bước 3: Execution Plan Sau Khi Tối Ưu
Sort (cost=180000.00..180500.00 rows=200000 width=40) 
     (actual time=3000.123..3200.456 rows=50000 loops=1)  
  Sort Key: (COUNT(o.order_id)) DESC  
  Sort Method: quicksort  Memory: 50MB  
  -> HashAggregate (cost=120000.00..140000.00 rows=200000 width=40) 
                   (actual time=2500.123..2800.789 rows=50000 loops=1)  
        Group Key: c.customer_name  
        -> Merge Join (cost=50000.00..100000.00 rows=200000 width=40) 
                      (actual time=200.123..1500.456 rows=800000 loops=1)  
              Merge Cond: (c.customer_id = o.customer_id)  
              -> Index Scan using idx_customers_country on customers c 
                           (cost=0.42..5000.00 rows=200000 width=36) 
                           (actual time=0.032..200.123 rows=200000 loops=1)  
                    Index Cond: (country = 'Vietnam')  
              -> Index Scan using idx_orders_customer_id on orders o 
                           (cost=0.42..80000.00 rows=10000000 width=8) 
                           (actual time=0.010..800.123 rows=800000 loops=1)
Kết Quả:
  • Merge Join Thay Thế Hash Join:

    • Index Scan trên cả hai bảng → Giảm I/O.

    • Dữ liệu đã được sắp xếp theo customer_id → Không cần build hash table.

  • Giảm Execution Time: Từ 20 giây3.2 giây (84% cải thiện).


4. Tổng Kết

Chỉ SốTrước Khi Tối ƯuSau Khi Tối Ưu
Total Cost450,000180,000
Execution Time19,500 ms3,200 ms
Phương Pháp JOINHash JoinMerge Join
Sort MethodDisk (External Merge)Memory (Quicksort)

Lý Do Hiệu Quả:

  • Index trên customers.country giúp lọc nhanh 200,000 dòng.

  • Index trên orders.customer_id cho phép Merge Join sử dụng Index Scan.

  • Merge Join tận dụng dữ liệu đã sắp xếp → Tiết kiệm bộ nhớ.


5. Bài Tập Thực Hành

Dataset Mẫu:
CREATE TABLE products (  
    product_id INT PRIMARY KEY,  
    category_id INT,  
    product_name VARCHAR(100)  
);  
CREATE TABLE sales (  
    sale_id INT PRIMARY KEY,  
    product_id INT,  
    sale_date DATE  
);  
-- Insert 500,000 products và 5 triệu sales
Yêu Cầu:
  1. Chạy query:

     SELECT p.category_id, COUNT(s.sale_id)  
     FROM products p  
     JOIN sales s ON p.product_id = s.product_id  
     WHERE p.category_id IN (1, 2, 3)  
     GROUP BY p.category_id;
    
  2. Phân tích Execution Plan và xác định loại JOIN được sử dụng.

  3. Thử nghiệm tắt/bật các loại JOIN (Hash, Merge, Nested Loop) và đo hiệu suất.

Câu Hỏi:
  • Tại sao Merge Join yêu cầu dữ liệu đầu vào đã được sắp xếp?

6. Mở Rộng & Thảo Luận

So Sánh 3 Thuật Toán JOIN
Thuật ToánƯu ĐiểmNhược ĐiểmTrường Hợp Sử Dụng
Nested LoopHiệu quả khi một bảng nhỏChậm với bảng lớnBảng nhỏ + Index trên JOIN condition
Hash JoinNhanh khi cả hai bảng lớnTốn bộ nhớ để build hash tableKhông cần dữ liệu sắp xếp
Merge JoinTiết kiệm bộ nhớ, ổn địnhYêu cầu dữ liệu đã sắp xếpDữ liệu đã có index phù hợp
Cách "Hướng Dẫn" Database Chọn Thuật Toán
  • PostgreSQL:

      SET enable_hashjoin = off;  -- Tắt Hash Join  
      SET enable_mergejoin = on;  -- Bật Merge Join
    
  • MySQL:

      SELECT /*+ NO_HASH_JOIN(p, s) */ ...  -- Sử dụng optimizer hints
    
Edge Case: Khi Nào Không Thể Tối Ưu JOIN?
  • Dữ liệu quá lớn: Bảng hàng tỷ dòng → Cần partitioning hoặc sharding.

  • JOIN trên cột không có index: Không thể áp dụng Merge/Nested Loop → Buộc dùng Hash Join.

Ví Dụ Force Index Trong MySQL
SELECT c.customer_name, COUNT(o.order_id)  
FROM customers c FORCE INDEX (idx_customers_country)  
JOIN orders o FORCE INDEX (idx_orders_customer_id)  
ON c.customer_id = o.customer_id  
WHERE c.country = 'Vietnam'  
GROUP BY c.customer_name;

Kết Luận

Việc lựa chọn thuật toán JOIN phù hợp là chìa khóa để tối ưu query. Trong ví dụ này, chuyển từ Hash Join sang Merge Join kết hợp index đã giảm 84% thời gian thực thi. Trong bài tiếp theo (Bài 16), chúng ta sẽ giải quyết các vấn đề phức tạp với subquery và CTE!

👉 Bài Tập Về Nhà: Tải dataset tại đây, thử nghiệm force index trên MySQL và điều chỉnh JOIN algorithm trên PostgreSQL!