SQL cho phân tích dữ liệu · Hướng dẫn · 30 tháng 10, 2026 · 13 phút đọc

JOIN trong SQL: INNER, LEFT, FULL, self join và anti join kèm ví dụ

Hai vòng tròn giao nhau, một vòng đầy máy bay và một vòng đầy vé, phần giao được làm nổi bật

JOIN trong SQL ghép các dòng của hai bảng theo một điều kiện, thường là khóa ở bảng này bằng khóa ở bảng kia. Loại JOIN quyết định số phận của những dòng không tìm được dòng khớp: INNER JOIN bỏ chúng ở cả hai bên, LEFT JOIN giữ mọi dòng của bảng bên trái và điền NULL cho bên phải, FULL JOIN giữ các dòng không khớp ở cả hai bên. Và điều mà hầu hết các bài giải thích bỏ qua: khi một dòng khớp với nhiều dòng, nó bị lặp lại một lần cho mỗi dòng khớp. Sự lặp lại này, gọi là nhân bản dòng (fan-out), là nguyên nhân của phần lớn các con số tổng sai trong phân tích dữ liệu.

Mọi ví dụ ở đây đều chạy trên cơ sở dữ liệu hãng hàng không của khóa SQL trên Nomto (104 sân bay, 9 mẫu máy bay, 33.121 chuyến bay, 262.788 đơn đặt chỗ, tiền tính bằng đô la Mỹ), và các bảng kết quả là kết quả chạy thật. Bạn có thể dán bất kỳ truy vấn nào vào Sân tập SQL của Nomto để kiểm tra.

Các bảng trong ví dụ

Biết mức chi tiết của từng bảng (một dòng nghĩa là gì) giúp bạn đoán trước kết quả của phép JOIN trước khi chạy.

INNER JOIN: chỉ những dòng khớp

Hiển thị mẫu máy bay đã khai thác từng chuyến cất cánh từ Sochi ngày 1/8:

SELECT f.flight_no, f.scheduled_departure, f.arrival_airport, a.model
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
WHERE f.departure_airport = 'AER'
  AND f.scheduled_departure >= '2026-08-01'
  AND f.scheduled_departure <  '2026-08-02'
ORDER BY f.scheduled_departure
LIMIT 5;
flight_no scheduled_departure arrival_airport model
PG0248 2026-08-01 09:05:00 GOJ Bombardier CRJ-200
PG0322 2026-08-01 10:30:00 NOZ Boeing 737-300
PG0602 2026-08-01 11:50:00 PEZ Cessna 208 Caravan
PG0522 2026-08-01 14:10:00 KUF Boeing 737-300
PG0326 2026-08-01 14:20:00 KGP Boeing 737-300

JOIN đứng một mình nghĩa là INNER JOIN. Mỗi chuyến bay có đúng một mẫu máy bay, nên phép JOIN thêm một cột mà không thay đổi số dòng. Đó là hướng an toàn: nhiều-một.

Giờ đếm số chuyến bay theo mẫu máy bay:

SELECT a.model, COUNT(*) AS flights
FROM flights f
JOIN aircrafts a ON a.aircraft_code = f.aircraft_code
GROUP BY a.model
ORDER BY flights DESC;
model flights
Cessna 208 Caravan 9273
Bombardier CRJ-200 9048
Sukhoi Superjet-100 8504
Airbus A321-200 1952
Boeing 737-300 1274
Airbus A319-100 1239
Boeing 767-300 1221
Boeing 777-300 610

Tám dòng, nhưng đội bay có chín mẫu. INNER JOIN đã âm thầm bỏ đi mẫu máy bay không có chuyến nào. Đôi khi đó là điều bạn muốn; nhưng khi bạn làm báo cáo “cho mọi mẫu máy bay”, đó là lỗi.

LEFT JOIN: giữ mọi dòng bên trái

SELECT a.aircraft_code, a.model, COUNT(f.flight_id) AS flights
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
GROUP BY a.aircraft_code, a.model
ORDER BY flights
LIMIT 3;
aircraft_code model flights
320 Airbus A320-200 0
773 Boeing 777-300 610
763 Boeing 767-300 1221

Airbus A320-200 đã quay lại, với 0 chuyến bay. Có hai chi tiết giúp truy vấn này đúng:

Cái bẫy WHERE: LEFT JOIN biến thành INNER JOIN

Yêu cầu: với mỗi mẫu máy bay, có bao nhiêu chuyến cất cánh từ Kazan (KZN)?

SELECT a.aircraft_code, COUNT(f.flight_id) AS kzn_departures
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.departure_airport = 'KZN'
GROUP BY a.aircraft_code
ORDER BY a.aircraft_code;
aircraft_code kzn_departures
319 61
321 61
CN1 122
CR2 105
SU9 122

Chỉ có năm mẫu. WHERE chạy sau JOIN; với các mẫu không có chuyến nào từ Kazan, f.departure_airport là NULL, và NULL = 'KZN' không đúng, nên các dòng đó bị loại. Chữ LEFT không còn tác dụng gì.

Hãy chuyển điều kiện trên bảng bên phải vào ON:

SELECT a.aircraft_code, COUNT(f.flight_id) AS kzn_departures
FROM aircrafts a
LEFT JOIN flights f
  ON f.aircraft_code = a.aircraft_code
 AND f.departure_airport = 'KZN'
GROUP BY a.aircraft_code
ORDER BY a.aircraft_code;

Giờ cả chín mẫu đều có mặt: vẫn năm con số như trên, cộng thêm 320, 733, 763 và 773 với giá trị 0. Quy tắc dễ nhớ: trong LEFT JOIN, điều kiện trên bảng bên phải đặt trong ON; điều kiện trên bảng bên trái đặt trong WHERE.

Anti join: những dòng không có dòng khớp

“Mẫu máy bay nào không bao giờ bay?”, “chuyến bay nào không bán được vé nào?” là các câu hỏi anti join. Có hai cách viết chuẩn.

LEFT JOIN rồi giữ những dòng mà bên phải bị thiếu:

SELECT a.aircraft_code, a.model
FROM aircrafts a
LEFT JOIN flights f ON f.aircraft_code = a.aircraft_code
WHERE f.flight_id IS NULL;

NOT EXISTS:

SELECT a.aircraft_code, a.model
FROM aircrafts a
WHERE NOT EXISTS (
  SELECT 1 FROM flights f WHERE f.aircraft_code = a.aircraft_code
);

Cả hai đều trả về một dòng:

aircraft_code model
320 Airbus A320-200

NOT EXISTS đọc gần với câu hỏi hơn và không bao giờ nhân bản dòng, nên nhiều data analyst thích dùng nó. Trong phiên bản LEFT JOIN, hãy kiểm tra một cột không thể là NULL khi có dòng khớp thật, chẳng hạn khóa của bảng bên phải.

Một ví dụ lớn hơn: có bao nhiêu chuyến bay đã hạ cánh mà không bán được vé nào?

SELECT COUNT(*) AS arrived_empty
FROM flights f
WHERE f.status = 'Arrived'
  AND NOT EXISTS (
    SELECT 1 FROM ticket_flights tf WHERE tf.flight_id = f.flight_id
  );
arrived_empty
5269

Tránh dùng NOT IN (SELECT ...) cho anti join: nếu truy vấn con trả về dù chỉ một NULL, NOT IN sẽ không trả về dòng nào.

FULL OUTER JOIN: đối chiếu hai tập hợp

FULL JOIN giữ các dòng không khớp ở cả hai bên. Đây là công cụ để so sánh hai danh sách: điểm đến nào được phục vụ từ Sheremetyevo (SVO), điểm nào từ Pulkovo (LED), điểm nào từ cả hai?

WITH svo AS (
  SELECT DISTINCT arrival_airport AS dest FROM flights WHERE departure_airport = 'SVO'
), led AS (
  SELECT DISTINCT arrival_airport AS dest FROM flights WHERE departure_airport = 'LED'
)
SELECT CASE WHEN svo.dest IS NULL THEN 'only LED'
            WHEN led.dest IS NULL THEN 'only SVO'
            ELSE 'both' END AS served_from,
       COUNT(*) AS destinations
FROM svo
FULL JOIN led ON led.dest = svo.dest
GROUP BY 1
ORDER BY 1;
served_from destinations
both 8
only LED 16
only SVO 40

Cả LEFT lẫn RIGHT JOIN đều không thể cho ra đủ ba nhóm trong một lượt. Khi liệt kê từng dòng, hãy dùng COALESCE(svo.dest, led.dest) để mỗi dòng có một khóa không rỗng.

Chú ý DISTINCT bên trong mỗi CTE: nó đưa mỗi bên về một dòng cho mỗi điểm đến trước khi JOIN. Nếu không có nó, mỗi chuyến bay sẽ khớp với mọi chuyến bay tới cùng sân bay ở bên kia, và đó lại là nhân bản dòng.

Self join: một bảng JOIN với chính nó

Đặt hai bí danh cho cùng một bảng, bạn có thể so sánh các dòng của nó với nhau. Thành phố nào có nhiều hơn một sân bay?

SELECT a1.city, a1.airport_code, a1.airport_name,
       a2.airport_code AS other_code, a2.airport_name AS other_name
FROM airports a1
JOIN airports a2
  ON a2.city = a1.city
 AND a1.airport_code < a2.airport_code
ORDER BY a1.city, a1.airport_code, a2.airport_code;
city airport_code airport_name other_code other_name
Moscow DME Domodedovo SVO Sheremetyevo
Moscow DME Domodedovo VKO Vnukovo
Moscow SVO Sheremetyevo VKO Vnukovo
Ulyanovsk ULV Barataevka ULY Ulyanovsk East

Điều kiện a1.airport_code < a2.airport_code làm hai việc: ngăn một sân bay khớp với chính nó, và giữ mỗi cặp một lần thay vì hai lần (DME–SVO nhưng không có SVO–DME). Self join cũng là cách tìm chuyến nối: chuyến đến của một chuyến bay ghép với chuyến cất cánh từ cùng sân bay vài giờ sau đó.

Chuỗi JOIN

Các phép JOIN nối tiếp nhau rất tự nhiên. Số hành khách đến theo thành phố đi theo chuỗi chặng → chuyến bay → sân bay:

SELECT a.city, COUNT(*) AS passengers
FROM ticket_flights tf
JOIN flights f ON f.flight_id = tf.flight_id
JOIN airports a ON a.airport_code = f.arrival_airport
WHERE f.status = 'Arrived'
GROUP BY a.city
ORDER BY passengers DESC
LIMIT 5;
city passengers
Moscow 181311
St. Petersburg 35559
Novosibirsk 27477
Yekaterinburg 20587
Sochi 18859

Mỗi bước ở đây đều là nhiều-một (nhiều chặng mỗi chuyến bay, nhiều chuyến bay mỗi sân bay), nên phép đếm giữ nguyên ở mức chặng và cho kết quả đúng. Rắc rối bắt đầu khi JOIN đi theo chiều ngược lại.

Nhân bản dòng: vì sao JOIN làm trùng dòng

Khi một dòng ở phía “một” gặp nhiều dòng ở phía “nhiều”, nó bị sao chép một lần cho mỗi dòng khớp. Đơn đặt chỗ 65A6EA có năm vé:

SELECT b.book_ref, b.total_amount, t.ticket_no, t.passenger_name
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
WHERE b.book_ref = '65A6EA'
ORDER BY t.ticket_no;
book_ref total_amount ticket_no passenger_name
65A6EA 10656.00 0005433415446 MIKHAIL NAZAROV
65A6EA 10656.00 0005433415447 MAKSIM GORBUNOV
65A6EA 10656.00 0005433415448 ALEKSANDR ABRAMOV
65A6EA 10656.00 0005433415449 VASILIY IVANOV
65A6EA 10656.00 0005433415450 GEVORG ABRAMOV

Số tiền $10.656 của đơn giờ xuất hiện năm lần. Các dòng này không có gì sai; lỗi xảy ra khi bạn cộng chúng lại.

Đếm trùng trong thực tế

Doanh thu từ các đơn đặt chỗ tạo ngày 2/8, lấy thẳng từ bookings:

SELECT COUNT(*) AS bookings, SUM(total_amount) AS revenue
FROM bookings
WHERE book_date >= '2026-08-02' AND book_date < '2026-08-03';
bookings revenue
5761 4563873.00

Cùng phép cộng đó sau khi JOIN với vé, chẳng hạn vì ai đó muốn có thêm số vé:

SELECT COUNT(*) AS rows_after_join,
       COUNT(DISTINCT b.book_ref) AS bookings,
       SUM(b.total_amount) AS revenue
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
WHERE b.book_date >= '2026-08-02' AND b.book_date < '2026-08-03';
rows_after_join bookings revenue
8016 5761 7569035.00

Doanh thu tăng vọt 66% mà không có thông báo lỗi nào. COUNT(DISTINCT book_ref) vẫn ra 5.761, khiến con số tổng bị phình trông có vẻ đáng tin.

Cách sửa hấp dẫn SUM(DISTINCT b.total_amount) còn tệ hơn: nó trả về 1.750.097,00, vì các đơn khác nhau tình cờ có cùng số tiền bị gộp thành một.

Hai lần nhân bản cùng lúc

JOIN hai bảng “nhiều” độc lập vào cùng một bảng cha sẽ nhân chúng với nhau. Chuyến bay 4729 có 161 chặng đã bán và 161 thẻ lên máy bay:

SELECT f.flight_id,
       COUNT(tf.ticket_no) AS segments,
       COUNT(bp.ticket_no) AS boarding_passes
FROM flights f
LEFT JOIN ticket_flights tf ON tf.flight_id = f.flight_id
LEFT JOIN boarding_passes bp ON bp.flight_id = f.flight_id
WHERE f.flight_id = 4729
GROUP BY f.flight_id;
flight_id segments boarding_passes
4729 25921 25921

161 × 161 = 25.921. Mỗi dòng chặng được ghép với mọi dòng thẻ lên máy bay của chuyến đó.

Cách tránh đếm trùng

1. Tổng hợp trước khi JOIN. Đưa mỗi bảng “nhiều” về mức chi tiết của bảng cha trước, rồi JOIN một-một:

WITH t AS (
  SELECT book_ref, COUNT(*) AS tickets
  FROM tickets
  GROUP BY book_ref
)
SELECT COUNT(*) AS bookings, SUM(t.tickets) AS tickets, SUM(b.total_amount) AS revenue
FROM bookings b
JOIN t ON t.book_ref = b.book_ref
WHERE b.book_date >= '2026-08-02' AND b.book_date < '2026-08-03';
bookings tickets revenue
5761 8016 4563873.00

Doanh thu đúng và số vé trong cùng một truy vấn.

2. Cộng chỉ số ở đúng mức chi tiết của nó. Nếu số tiền nằm ở phía “nhiều”, hãy cộng ở đó. Tổng tiền các chặng của cùng các đơn này đúng bằng tổng giá trị các đơn:

SELECT SUM(tf.amount) AS revenue
FROM bookings b
JOIN tickets t ON t.book_ref = b.book_ref
JOIN ticket_flights tf ON tf.ticket_no = t.ticket_no
WHERE b.book_date >= '2026-08-02' AND b.book_date < '2026-08-03';

Truy vấn này trả về 4.563.873,00. Ở đây việc nhân bản dòng vô hại, vì mỗi amount thuộc về đúng một dòng chặng.

3. Dùng truy vấn con vô hướng cho các phép đếm độc lập. Với trường hợp hai lần nhân bản:

SELECT f.flight_id,
       (SELECT COUNT(*) FROM ticket_flights tf WHERE tf.flight_id = f.flight_id) AS segments,
       (SELECT COUNT(*) FROM boarding_passes bp WHERE bp.flight_id = f.flight_id) AS boarding_passes
FROM flights f
WHERE f.flight_id = 4729;
flight_id segments boarding_passes
4729 161 161

4. Kiểm tra số dòng. Trước khi tin một con số tổng, hãy so COUNT(*) trước và sau JOIN, hoặc đối chiếu tổng với bảng nguồn như ở trên. Nếu số dòng tăng mà bạn không chờ đợi điều đó, nghĩa là có một khóa không duy nhất ở chỗ bạn tưởng là duy nhất.

Cách lọc kết quả đã nhóm sau JOIN được giải thích trong bài GROUP BY và HAVING trong SQL; JOIN cũng chiếm một phần lớn trong các câu hỏi phỏng vấn SQL. Muốn tự luyện, hãy làm bài 12 và 13 trong bài tập SQL có lời giải.

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

INNER JOIN và LEFT JOIN khác nhau thế nào?

INNER JOIN chỉ trả về các dòng khớp ở cả hai bên. LEFT JOIN trả về mọi dòng của bảng bên trái; ở chỗ không có dòng khớp, các cột của bảng bên phải là NULL. Trong các ví dụ trên, INNER JOIN làm mất mẫu A320 không có chuyến bay, còn LEFT JOIN giữ lại nó.

JOIN có giống INNER JOIN không?

Có. JOIN không kèm từ khóa nào là INNER JOIN. Tương tự, LEFT JOIN và LEFT OUTER JOIN là một.

Vì sao JOIN trả về nhiều dòng hơn số dòng của bảng?

Vì khóa ở phía bên kia không duy nhất: mỗi dòng bị lặp lại một lần cho mỗi dòng khớp. Đó là điều bình thường với JOIN một-nhiều. Nó chỉ thành lỗi khi sau đó bạn cộng một cột thuộc về phía “một”.

Anti join là gì?

Là truy vấn trả về các dòng của một bảng không có dòng khớp ở bảng khác. Trong SQL bạn viết nó bằng NOT EXISTS hoặc LEFT JOIN ... WHERE right_key IS NULL. PostgreSQL không có từ khóa ANTI JOIN.

Khi nào cần FULL OUTER JOIN?

Khi bạn so sánh hai tập hợp và muốn thấy trong cùng một kết quả cái gì chỉ có ở tập thứ nhất, cái gì chỉ có ở tập thứ hai, và cái gì có ở cả hai: đối chiếu hai hệ thống, hai kỳ báo cáo hay danh sách tuyến bay của hai sân bay.

Ngôn ngữ khác: Español, Português (Brasil), English, 简体中文