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ụ

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ụ
aircrafts: mỗi dòng là một mẫu máy bay (aircraft_code,model,range)airports: mỗi dòng là một sân bay (airport_code,airport_name,city)flights: mỗi dòng là một chuyến bay cất cánh;aircraft_code,departure_airportvàarrival_airporttrỏ tới hai bảng ở trênbookings: mỗi dòng là một đơn đặt chỗ, cótotal_amounttickets: mỗi dòng là một vé hành khách; nhiều vé có thể chung mộtbook_refticket_flights: mỗi dòng là một vé trên một chặng bay, cóamountcủa chặng đóboarding_passes: mỗi dòng là một thẻ lên máy bay đã phát cho một chặng
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:
aircraftsnằm bên trái, vì đó là bảng mà mọi dòng đều phải được giữ lại.- Truy vấn đếm
COUNT(f.flight_id), không phảiCOUNT(*). Với mẫu máy bay không có dòng khớp, phép JOIN tạo ra một dòng vớiNULLở mọi cột củaflights;COUNT(*)sẽ đếm dòng đó và báo 1.
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, 简体中文