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

Self join trong SQL: bảng tự nối với chính nó, kèm ví dụ thực tế

Bản đồ đường bay gập lại lên chính nó, hai bản sao của cùng một bảng được nối bằng các mũi tên giữa các sân bay

Self join là một phép JOIN bình thường, chỉ khác là cả hai phía đều là cùng một bảng. Bạn ghi tên bảng hai lần với hai bí danh, ví dụ flights f1 JOIN flights f2, và điều kiện nối nói rõ một dòng bên trái liên quan thế nào với một dòng bên phải: cùng thành phố, chặng tiếp theo của hành trình, phiên bản kế tiếp trong lịch sử. Không có từ khóa SELF JOIN; loại JOIN nào cũng dùng được (INNER, LEFT, thậm chí CROSS). Self join trả lời các câu hỏi về quan hệ giữa các dòng trong cùng một bảng: cặp, chuỗi và điểm nối.

Mọi ví dụ đề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, 33.121 chuyến bay, 366.733 vé). Truy vấn dùng cú pháp PostgreSQL và chạy trên DuckDB, động cơ của phòng học SQL trên Nomto; mọi bảng kết quả đều là kết quả chạy thật. Cú pháp self join giống hệt nhau trong MySQL và SQL Server, hai hệ quản trị bạn hay gặp nhất ở các công ty Việt Nam.

Cú pháp self join

SELECT a.col, b.col
FROM some_table a
JOIN some_table b
  ON <how row a relates to row b>;

Bí danh là bắt buộc. Thiếu chúng, airports.city = airports.city sẽ mơ hồ, và bạn không thể nói một cột lấy từ bản sao nào. Khi truy vấn dài hơn vài dòng, hãy đặt bí danh theo vai trò của từng bản sao (origin và dest, prev và next, out và back), thay vì chỉ a và b.

Cặp không trùng: a < b thay vì a <> b

Self join đơn giản nhất ghép các dòng có điểm chung. Bài JOIN trong SQL dùng nó để tìm những thành phố có nhiều sân bay. Còn đây là một truy vấn ghép cặp không có khóa chung: những sân bay khác thành phố nằm gần nhau nhất, theo khoảng cách đường tròn lớn tính từ tọa độ.

SELECT a1.airport_code, a1.city,
       a2.airport_code AS other, a2.city AS other_city,
       ROUND(2 * 6371 * ASIN(SQRT(
         POWER(SIN(RADIANS(a2.latitude - a1.latitude) / 2), 2) +
         COS(RADIANS(a1.latitude)) * COS(RADIANS(a2.latitude)) *
         POWER(SIN(RADIANS(a2.longitude - a1.longitude) / 2), 2)
       ))) AS km
FROM airports a1
JOIN airports a2 ON a1.airport_code < a2.airport_code
WHERE a1.city <> a2.city
ORDER BY km
LIMIT 6;
airport_code city other other_city km
NFG Nefteyugansk SGC Surgut 48
NJC Nizhnevartovsk SWT Strezhevoy 69
AAQ Anapa GDZ Gelendzhik 70
CSY Cheboksary JOK Yoshkar-Ola 76
IAR Yaroslavl IWA Ivanovo 84
NAL Nalchik OGZ Vladikavkaz 86

Điều kiện trong ON quyết định bạn nhận được bao nhiêu dòng. Với 104 sân bay:

điều kiện nối số dòng bạn nhận được gì
ON TRUE (mọi tổ hợp) 10816 104 × 104, kể cả mỗi sân bay ghép với chính nó
ON a1.airport_code <> a2.airport_code 10712 mỗi cặp hai lần: NFG–SGC và SGC–NFG
ON a1.airport_code < a2.airport_code 5356 mỗi cặp một lần: 104 × 103 / 2

Mẹo < dùng được với mọi cột có thứ tự cố định: ID, mã, tên. Nó loại bỏ cùng lúc dòng tự khớp với chính mình và cặp đối xứng, đồng thời giảm một nửa khối lượng tính toán. Chỉ dùng <> khi chiều quan hệ có ý nghĩa, ví dụ “A gần B hơn mọi sân bay khác”, khi A và B đóng vai trò khác nhau. Truy vấn trên so sánh từng sân bay với mọi sân bay còn lại: với một trăm dòng thì không sao, nhưng số phép so sánh tăng theo bình phương kích thước bảng, nên với bảng lớn hãy thêm điều kiện thu hẹp ứng viên (cùng vùng, một khung giới hạn theo vĩ độ).

Chuyến bay nối chuyến: điểm cuối của dòng này gặp điểm đầu của dòng kia

Những self join hữu ích nhất nối các sự kiện thành chuỗi: chuyến bay hạ cánh ở một sân bay được nối với chuyến cất cánh từ chính sân bay đó ngay sau. Các chuyến nối từ Kazan qua Moscow Domodedovo ngày 20/8, thời gian chờ từ một đến bốn giờ:

SELECT f1.flight_no AS leg1, f1.arrival_airport AS via,
       f1.scheduled_arrival::time AS lands,
       f2.flight_no AS leg2, f2.arrival_airport AS final_dest,
       f2.scheduled_departure::time AS departs,
       f2.scheduled_departure - f1.scheduled_arrival AS layover
FROM flights f1
JOIN flights f2
  ON  f2.departure_airport = f1.arrival_airport
  AND f2.scheduled_departure BETWEEN f1.scheduled_arrival + INTERVAL '1 hour'
                                 AND f1.scheduled_arrival + INTERVAL '4 hours'
  AND f2.arrival_airport <> f1.departure_airport
WHERE f1.departure_airport = 'KZN'
  AND f1.arrival_airport = 'DME'
  AND f1.scheduled_departure::date = '2026-08-20'
ORDER BY f2.scheduled_departure;
leg1 via lands leg2 final_dest departs layover
PG0203 DME 18:40:00 PG0118 EGO 19:55:00 01:15:00
PG0203 DME 18:40:00 PG0168 UUS 20:05:00 01:25:00
PG0203 DME 18:40:00 PG0208 KHV 20:40:00 02:00:00

Một chuyến bay buổi tối từ Kazan nối tiếp được đi Belgorod, Yuzhno-Sakhalinsk và Khabarovsk. Ba chi tiết làm cho truy vấn này đúng:

Bộ lọc ngày trong WHERE chỉ áp dụng cho chặng đầu; chặng hai có thể khởi hành sang ngày hôm sau nếu chặng đầu hạ cánh muộn vào buổi tối. Bỏ bộ lọc f1.arrival_airport = 'DME' thì cùng truy vấn đó liệt kê cả 17 chuyến nối từ Kazan trong ngày, qua St Petersburg, Rostov, Kirov và Domodedovo. Muốn nối ba chặng trở lên, hãy lặp lại phép JOIN hoặc chuyển sang CTE đệ quy.

Chuyến khứ hồi: ghép một dòng với dòng “đối xứng” của nó

Một vé có thể gồm nhiều chặng bay. Vé khứ hồi là vé có một chặng đi từ A đến B và một chặng sau đó đi từ B về A. Đó là self join các chặng của cùng một vé, với sân bay đi và đến đổi chỗ trong điều kiện. Để đơn giản, chỉ xét những vé có đúng hai chặng (183.338 vé):

WITH seg AS (
  SELECT tf.ticket_no, f.departure_airport, f.arrival_airport, f.scheduled_departure
  FROM ticket_flights tf
  JOIN flights f ON f.flight_id = tf.flight_id
  WHERE tf.ticket_no IN (
    SELECT ticket_no FROM ticket_flights GROUP BY ticket_no HAVING COUNT(*) = 2
  )
)
SELECT outbound.departure_airport AS home, outbound.arrival_airport AS destination,
       COUNT(*) AS round_trips,
       ROUND(AVG(back.scheduled_departure::date - outbound.scheduled_departure::date), 1) AS avg_days_away
FROM seg outbound
JOIN seg back
  ON  back.ticket_no = outbound.ticket_no
  AND back.departure_airport = outbound.arrival_airport
  AND back.arrival_airport = outbound.departure_airport
  AND back.scheduled_departure > outbound.scheduled_departure
GROUP BY home, destination
ORDER BY round_trips DESC
LIMIT 5;
home destination round_trips avg_days_away
SVO AER 6201 8.5
SVO OVB 5979 8.5
DME OVB 5730 8.4
SVO SVX 5350 8.7
DME KRR 4523 8.4

Trong tất cả các vé hai chặng, 177.847 vé là vé khứ hồi, và hành khách đi xa trung bình 8,4 ngày. Điều kiện back.scheduled_departure > outbound.scheduled_departure đóng vai trò giống a < b ở trên: thiếu nó, mỗi chuyến khứ hồi còn khớp thêm một lần “ngược”, với chặng về bị coi là chặng đi, và số đếm tăng gấp đôi.

Vì sao chỉ lấy vé hai chặng? Vé có nối chuyến, chẳng hạn Moscow → Belgorod → Anapa rồi quay về, gồm bốn chặng, và điều kiện đối xứng sẽ ghép mỗi chặng với chặng về của chính nó: hai cặp cho một vé. JOIN một bảng với chính nó sẽ nhân số dòng mỗi khi một dòng có nhiều dòng khớp, đúng hiện tượng nhân bản dòng như với mọi phép JOIN. Trước khi tổng hợp kết quả của self join, hãy kiểm tra xem một dòng kết quả đại diện cho cái gì.

Dòng lịch sử: nối mỗi phiên bản với phiên bản kế tiếp

Những bảng lưu phiên bản với valid_from và valid_to có thể tự nối để đặt mỗi phiên bản cạnh phiên bản đã thay thế nó. Thay đổi hạng thành viên khách hàng thân thiết, trong đó ngày kết thúc của dòng cũ bằng ngày bắt đầu của dòng mới:

SELECT prev.tier AS from_tier, nxt.tier AS to_tier, COUNT(*) AS moves
FROM loyalty_tier_history prev
JOIN loyalty_tier_history nxt
  ON  nxt.member_id = prev.member_id
  AND nxt.valid_from = prev.valid_to
GROUP BY from_tier, to_tier
ORDER BY moves DESC;
from_tier to_tier moves
basic silver 4179
silver gold 643
gold platinum 142
silver basic 21
gold silver 4

Đây là mẫu kinh điển “nhân viên và quản lý” áp dụng cho thời gian: một dòng trỏ tới một dòng khác trong cùng bảng, ở đây qua ngày tháng thay vì cột manager_id. Với sơ đồ tổ chức, bạn sẽ viết JOIN employees mgr ON mgr.employee_id = emp.manager_id, và LEFT JOIN sẽ giữ lại giám đốc điều hành, người không có quản lý. Bài “tìm nhân viên có lương cao hơn quản lý của mình” là câu hỏi self join quen thuộc trong các buổi phỏng vấn SQL, và nó dùng đúng mẫu này.

Self join hay hàm cửa sổ

Bảng hạng thành viên có 58.194 dòng cho 53.000 thành viên, nên phải có 5.194 lần chuyển hạng. Self join chỉ tìm được 4.989. Đặt cùng câu hỏi bằng LAG, hàm chỉ đơn giản lấy dòng trước theo ngày:

SELECT prev_tier AS from_tier, tier AS to_tier, COUNT(*) AS moves
FROM (
  SELECT tier,
         LAG(tier) OVER (PARTITION BY member_id ORDER BY valid_from) AS prev_tier
  FROM loyalty_tier_history
) t
WHERE prev_tier IS NOT NULL
GROUP BY from_tier, to_tier
ORDER BY moves DESC;
from_tier to_tier moves
basic silver 4364
silver gold 659
gold platinum 145
silver basic 22
gold silver 4

Đủ 5.194 lần chuyển. 205 lần self join bỏ sót là những dòng có valid_to không khớp với valid_from kế tiếp: 110 phiên bản cũ chưa bao giờ được đóng, và 95 phiên bản được đóng trễ vài ngày, chồng lên phiên bản sau. Phép nối bằng nhau trên ngày tháng âm thầm làm rơi những liên kết đó; còn LAG hoàn toàn không quan tâm đến valid_to. Bài SCD type 2 (tiếng Anh) chỉ cách tìm và sửa những dòng như vậy.

Không có câu trả lời nào là “đúng duy nhất”. Self join tin vào các liên kết được ghi sẵn, hàm cửa sổ tin vào thứ tự. Chính sự chênh lệch giữa hai kết quả là một phép kiểm tra chất lượng dữ liệu. Quy tắc chung:

câu hỏi công cụ phù hợp hơn
dòng trước hoặc dòng sau trong một chuỗi có thứ tự LAG / LEAD
xếp hạng, tổng lũy kế, số thứ tự trong nhóm hàm cửa sổ
các cặp dòng thỏa một điều kiện (cùng thành phố, cách nhau dưới 100 km) self join
các dòng liên quan theo khoảng chứ không theo vị trí (cất cánh 1–4 giờ sau khi hạ cánh) self join
một dòng trỏ tới dòng khác qua ID (quản lý, danh mục cha) self join, hoặc CTE đệ quy khi có nhiều cấp

Hàm cửa sổ đọc bảng một lần rồi sắp xếp; self join đọc bảng hai lần và có thể sinh nhiều dòng khớp cho mỗi dòng. Khi cả hai đều trả lời được câu hỏi, hàm cửa sổ thường nhanh hơn và không thể gây nhân bản dòng. LAG và LEAD được giải thích chi tiết trong bài hàm LAG và LEAD trong SQL.

LEFT self join: giữ lại những dòng không có cặp

INNER self join bỏ những dòng không có dòng khớp. Muốn giữ chúng, hãy dùng LEFT JOIN và kiểm tra NULL ở phía bên phải. Những dòng hạng thành viên “không từ đâu tới”, tức dòng đầu tiên trong lịch sử của mỗi người, là những dòng không có phiên bản trước:

SELECT COUNT(*) AS rows_without_predecessor
FROM loyalty_tier_history cur
LEFT JOIN loyalty_tier_history prev
  ON  prev.member_id = cur.member_id
  AND prev.valid_to = cur.valid_from
WHERE prev.member_id IS NULL;
rows_without_predecessor
53205

53.000 dòng trong số đó là dòng đầu tiên của các thành viên. 205 dòng còn lại chính là những liên kết hỏng ở phần trước, lần này được tìm ra mà không cần hàm cửa sổ. “Anti self join” kiểu này là cách tìm các dòng mồ côi: danh mục mất danh mục cha, chuyến bay không có chiều về, phiên bản không có phiên bản trước.

Các lỗi thường gặp

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

Self join trong SQL là gì?

Là phép JOIN một bảng với chính nó. Bạn gọi bảng hai lần với hai bí danh khác nhau và nối hai bản sao theo một điều kiện, ví dụ FROM employees e JOIN employees m ON m.employee_id = e.manager_id. Self join dùng để so sánh các dòng trong cùng một bảng với nhau.

SELF JOIN có phải là từ khóa không?

Không. Self join được viết bằng cú pháp JOIN, LEFT JOIN hoặc CROSS JOIN thông thường; điều làm nó thành self join là cả hai phía đều là cùng một bảng.

Làm sao tránh cặp trùng trong self join?

Nối theo a.id < b.id thay vì a.id <> b.id. Cách này loại bỏ dòng tự khớp với chính nó và chỉ giữ một trong hai cặp đối xứng (A–B mà không có B–A).

Khi nào nên dùng self join thay vì LAG hoặc LEAD?

Dùng LAG/LEAD khi cần dòng trước hoặc dòng sau theo một thứ tự cố định. Dùng self join khi quan hệ được xác định bằng điều kiện chứ không phải vị trí: các cặp trong một khoảng cách, chuyến nối trong một khung thời gian, một dòng trỏ tới dòng khác qua ID.

Self join có thể là LEFT JOIN không?

Có. LEFT self join giữ lại những dòng không có cặp, với giá trị NULL ở phía bên phải. Thêm WHERE right_side.key IS NULL sẽ biến nó thành anti join để tìm những dòng không có dòng khớp, chẳng hạn bản ghi bị mất bản ghi cha.

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