# SQL LEFT JOIN hoạt động như thế nào? Hiểu từ ví dụ A có 5 record và B có 10 record
Khi học SQL, nhiều người thường hiểu đơn giản rằng:
> `LEFT JOIN` sẽ lấy toàn bộ dữ liệu từ bảng bên trái.
Điều này đúng, nhưng chưa đủ.
Một hiểu lầm phổ biến là:
> Nếu bảng A có 5 record thì `LEFT JOIN` luôn trả về 5 record.
Thực tế không phải vậy.
Số lượng record sau khi `LEFT JOIN` phụ thuộc vào:
– Có bao nhiêu record bên B match với từng record bên A.
– Quan hệ giữa A và B là one-to-one hay one-to-many.
– Điều kiện trong `ON`.
– Điều kiện trong `WHERE`.
Trong bài này, chúng ta sẽ phân tích chi tiết cách `LEFT JOIN` hoạt động bên trong database.
—
# 1. LEFT JOIN là gì?
Cú pháp cơ bản:
“`sql
SELECT *
FROM A
LEFT JOIN B
ON A.Id = B.AId;
“`
Ý nghĩa:
> Lấy tất cả record từ bảng A và lấy các record tương ứng từ bảng B nếu match.
Nếu không có record bên B match thì các column của B sẽ có giá trị:
“`text
NULL
“`
—
# 2. Ví dụ A có 5 record
Giả sử bảng A:
“`text
A
Id
—
1
2
3
4
5
“`
Tổng cộng:
“`text
5 records
“`
Bảng B có 10 records:
“`text
B
Id AId
————
1 1
2 1
3 1
4 2
5 2
6 3
7 4
8 4
9 4
10 4
“`
Ta chạy:
“`sql
SELECT *
FROM A
LEFT JOIN B
ON A.Id = B.AId;
“`
—
# 3. Kết quả có bao nhiêu record?
Kết quả sẽ là:
“`text
A.Id B.Id
—————-
1 1
1 2
1 3
2 4
2 5
3 6
4 7
4 8
4 9
4 10
5 NULL
“`
Tổng cộng:
“`text
11 rows
“`
Mặc dù:
“`text
A = 5 records
B = 10 records
“`
nhưng kết quả:
“`text
11 records
“`
—
# 4. Tại sao A.Id = 1 xuất hiện 3 lần?
Bởi vì:
“`text
A.Id = 1
“`
match với:
“`text
B.Id = 1
B.Id = 2
B.Id = 3
“`
SQL không gom các record B vào một object.
Nó tạo một row cho mỗi match.
Do đó:
“`text
A1 + B1
A1 + B2
A1 + B3
“`
Kết quả A1 xuất hiện ba lần.
Đây là điểm quan trọng nhất khi hiểu JOIN.
—
# 5. Nếu không có record B match thì sao?
Ví dụ:
“`text
A.Id = 5
“`
không có record nào trong B có:
“`text
AId = 5
“`
Nhưng vì đây là:
“`sql
LEFT JOIN
“`
record A vẫn được giữ lại.
Kết quả:
“`text
A.Id B.Id
—————
5 NULL
“`
Đó chính là điểm khác biệt lớn giữa:
“`text
LEFT JOIN
“`
và:
“`text
INNER JOIN
“`
—
# 6. LEFT JOIN không đảm bảo số lượng record bằng bảng A
Một quy tắc quan trọng:
> LEFT JOIN đảm bảo mỗi record bên trái xuất hiện ít nhất một lần.
Nhưng không đảm bảo:
> mỗi record bên trái chỉ xuất hiện một lần.
Ví dụ:
“`text
A1 -> 5 records bên B
A2 -> 3 records bên B
A3 -> 0 records bên B
A4 -> 2 records bên B
A5 -> 0 records bên B
“`
Kết quả:
“`text
A1 = 5 rows
A2 = 3 rows
A3 = 1 row
A4 = 2 rows
A5 = 1 row
“`
Tổng:
“`text
5 + 3 + 1 + 2 + 1
= 12 rows
“`
—
# 7. Công thức để hình dung số lượng row
Có thể hình dung:
“`text
Result Count
=
SUM(
MAX(number of matching B records, 1)
)
“`
cho từng record của A.
Ví dụ:
“`text
A1 -> 3 B
A2 -> 2 B
A3 -> 1 B
A4 -> 4 B
A5 -> 0 B
“`
thì:
“`text
3 + 2 + 1 + 4 + 1
=
11
“`
Record A5 vẫn tính là 1 vì `LEFT JOIN` giữ lại A5 và set B thành NULL.
—
# 8. LEFT JOIN khác INNER JOIN thế nào?
Nếu dùng:
“`sql
SELECT *
FROM A
INNER JOIN B
ON A.Id = B.AId;
“`
A5 sẽ biến mất.
Kết quả:
“`text
A1 B1
A1 B2
A1 B3
A2 B4
A2 B5
A3 B6
A4 B7
A4 B8
A4 B9
A4 B10
“`
Tổng:
“`text
10 rows
“`
Còn với:
“`sql
LEFT JOIN
“`
thì thêm:
“`text
A5 NULL
“`
nên thành:
“`text
11 rows
“`
—
# 9. Database có scan B 10 lần cho từng record A không?
Một câu hỏi quan trọng hơn là:
> Database thực sự chạy JOIN như thế nào?
Giả sử:
“`text
A = 5 rows
B = 10 rows
“`
Cách nghĩ đơn giản nhất là:
“`text
A1 compare với B1…B10
A2 compare với B1…B10
A3 compare với B1…B10
…
“`
Nếu brute force:
“`text
5 x 10
=
50 comparisons
“`
Tuy nhiên database thực tế không nhất thiết làm như vậy.
Database optimizer có thể sử dụng các thuật toán JOIN khác nhau.
—
# 10. Ba JOIN algorithm phổ biến
Database thường có ba nhóm thuật toán chính:
“`text
Nested Loop Join
Hash Join
Merge Join
“`
Database Optimizer sẽ quyết định thuật toán nào phù hợp dựa trên:
– số lượng records
– index
– statistics
– filter
– estimated cost
—
# 11. Nested Loop Join
Đây là thuật toán dễ hiểu nhất.
Có thể hình dung:
“`text
for each row in A
{
find matching rows in B
}
“`
Ví dụ:
“`text
A1
↓
find B where AId = 1
A2
↓
find B where AId = 2
“`
Nếu B có index:
“`sql
CREATE INDEX IX_B_AId
ON B(AId);
“`
database có thể thực hiện:
“`text
Index Seek
“`
thay vì scan toàn bộ B.
—
# 12. Tại sao index lại quan trọng?
Giả sử B có:
“`text
10,000,000 records
“`
và chúng ta join:
“`sql
A.Id = B.AId
“`
Nếu không có index phù hợp, việc tìm dữ liệu có thể rất tốn chi phí.
Nếu có:
“`sql
CREATE INDEX IX_B_AId
ON B(AId);
“`
database có thể tìm trực tiếp:
“`text
AId = 123
“`
thay vì scan hàng triệu records.
Một rule quan trọng khi optimize JOIN:
> Foreign key hoặc join key thường là candidate tốt để tạo index.
—
# 13. Hash Join
Hash Join thường được sử dụng khi xử lý lượng dữ liệu lớn.
Database có thể build một hash structure.
Ví dụ:
“`text
B:
AId = 1 -> B1, B2, B3
AId = 2 -> B4, B5
AId = 3 -> B6
AId = 4 -> B7, B8, B9, B10
“`
Sau đó:
“`text
A1 -> lookup key 1
A2 -> lookup key 2
A3 -> lookup key 3
“`
Lookup qua hash table thường nhanh hơn việc so sánh mọi record.
—
# 14. Merge Join
Merge Join phù hợp khi hai input đã được sort theo join key.
Ví dụ:
“`text
A.Id
1
2
3
4
5
“`
B:
“`text
B.AId
1
1
1
2
2
3
4
4
4
4
“`
Database có thể đọc song song:
“`text
A pointer →
B pointer →
“`
và match data theo thứ tự.
Merge Join thường hiệu quả nếu dữ liệu đã được sort hoặc có index phù hợp.
—
# 15. Một lỗi rất phổ biến với LEFT JOIN
Giả sử:
“`sql
SELECT *
FROM A
LEFT JOIN B
ON A.Id = B.AId
WHERE B.Status = ‘Active’;
“`
Nhiều developer nghĩ:
> Đây vẫn là LEFT JOIN nên tất cả A vẫn được giữ lại.
Không hoàn toàn đúng.
Vấn đề nằm ở:
“`sql
WHERE B.Status = ‘Active’
“`
Nếu A không có B:
“`text
B.Status = NULL
“`
Condition:
“`text
NULL = ‘Active’
“`
không trả về TRUE.
Record đó sẽ bị loại khỏi result.
—
# 16. LEFT JOIN vô tình trở thành gần giống INNER JOIN
Ví dụ:
“`sql
SELECT *
FROM A
LEFT JOIN B
ON A.Id = B.AId
WHERE B.Status = ‘Active’;
“`
Các record có:
“`text
B = NULL
“`
sẽ bị remove.
Behavior lúc này rất gần với:
“`sql
INNER JOIN
“`
Đây là bug khá phổ biến.
—
# 17. Đưa điều kiện vào ON
Nếu mục tiêu là:
> lấy toàn bộ A và chỉ lấy B có Status = Active
thì nên viết:
“`sql
SELECT *
FROM A
LEFT JOIN B
ON A.Id = B.AId
AND B.Status = ‘Active’;
“`
Lúc này:
“`text
Condition B.Status = Active
“`
được dùng để quyết định record B nào match.
Nếu không có record B Active:
“`text
A vẫn được giữ lại
B = NULL
“`
—
# 18. ON và WHERE khác nhau như thế nào?
Có thể ghi nhớ:
“`text
ON
↓
quyết định record nào được JOIN
“`
Còn:
“`text
WHERE
↓
lọc result sau khi JOIN
“`
Ví dụ:
“`sql
LEFT JOIN B
ON A.Id = B.AId
AND B.Status = ‘Active’
“`
nghĩa là:
“`text
Lấy B Active để join
“`
Còn:
“`sql
LEFT JOIN B
ON A.Id = B.AId
WHERE B.Status = ‘Active’
“`
nghĩa là:
“`text
Join trước
sau đó loại result không Active
“`
Hai câu SQL có thể tạo ra kết quả rất khác nhau.
—
# 19. Một lỗi khác: JOIN làm duplicate record
Ví dụ:
“`sql
SELECT A.*
FROM A
LEFT JOIN B
ON A.Id = B.AId;
“`
Nếu:
“`text
A1
“`
có 10 B:
“`text
A1
“`
sẽ xuất hiện 10 lần.
Developer đôi khi nhìn thấy duplicate và dùng ngay:
“`sql
DISTINCT
“`
Ví dụ:
“`sql
SELECT DISTINCT A.*
FROM A
LEFT JOIN B
ON A.Id = B.AId;
“`
Điều này có thể che mất vấn đề thiết kế query.
Trước khi dùng `DISTINCT`, nên hỏi:
> Tại sao JOIN tạo duplicate?
Có thể business relationship thực sự là:
“`text
One-to-Many
“`
và duplicate là kết quả hoàn toàn đúng.
—
# 20. Nếu chỉ muốn biết B có tồn tại thì sao?
Giả sử chỉ muốn lấy A có ít nhất một B.
Không nhất thiết phải JOIN.
Có thể dùng:
“`sql
SELECT *
FROM A
WHERE EXISTS
(
SELECT 1
FROM B
WHERE B.AId = A.Id
);
“`
Trong nhiều trường hợp, `EXISTS` thể hiện intent rõ hơn.
Nếu muốn A không có B:
“`sql
SELECT *
FROM A
WHERE NOT EXISTS
(
SELECT 1
FROM B
WHERE B.AId = A.Id
);
“`
—
# 21. LEFT JOIN để tìm record không có relationship
Một use case rất phổ biến:
> Tìm User chưa có Order.
Ví dụ:
“`sql
SELECT U.*
FROM Users U
LEFT JOIN Orders O
ON U.Id = O.UserId
WHERE O.Id IS NULL;
“`
Logic:
“`text
User
↓
LEFT JOIN Order
↓
không match
↓
Order = NULL
“`
Do đó:
“`sql
WHERE O.Id IS NULL
“`
trả về các user chưa có order.
—
# 22. LEFT JOIN trong Entity Framework LINQ
Trong EF Core, pattern LEFT JOIN thường là:
“`csharp
var query =
from a in dbContext.A
join b in dbContext.B
on a.Id equals b.AId into bGroup
from b in bGroup.DefaultIfEmpty()
select new
{
A = a,
B = b
};
“`
Điểm quan trọng:
“`csharp
DefaultIfEmpty()
“`
là thứ biến group join thành behavior tương tự LEFT JOIN.
Nếu không có B:
“`csharp
b == null
“`
—
# 23. Một ví dụ thực tế
Giả sử:
“`text
Users
“`
và:
“`text
Orders
“`
Một user có thể có nhiều orders.
Query:
“`sql
SELECT
U.Id,
U.Name,
O.Id AS OrderId
FROM Users U
LEFT JOIN Orders O
ON U.Id = O.UserId;
“`
Nếu dữ liệu:
“`text
User 1 -> 3 Orders
User 2 -> 1 Order
User 3 -> 0 Order
“`
result:
“`text
User1 Order1
User1 Order2
User1 Order3
User2 Order4
User3 NULL
“`
Tổng result:
“`text
5 rows
“`
mặc dù chỉ có:
“`text
3 Users
“`
—
# 24. Câu hỏi phỏng vấn: A có 5 records, B có 10 records, LEFT JOIN trả bao nhiêu?
Câu trả lời chính xác:
> Không thể xác định chỉ dựa vào số lượng records của A và B.
Phải biết:
“`text
join condition
“`
và:
“`text
cardinality relationship
“`
Ví dụ:
“`text
A = 5
B = 10
“`
có thể trả:
“`text
5 rows
“`
hoặc:
“`text
10 rows
“`
hoặc:
“`text
11 rows
“`
hoặc nhiều hơn.
Tùy vào cách B match với A.
—
# 25. Câu trả lời phỏng vấn tốt
Nếu interviewer hỏi:
> How does LEFT JOIN work internally?
Có thể trả lời:
> A LEFT JOIN preserves every row from the left table. For each row on the left, the database returns all matching rows from the right table. If there is no match, the right-side columns are returned as NULL. Therefore, the result count can be larger than the number of rows in the left table when the relationship is one-to-many.
Nếu hỏi sâu hơn về execution:
> The database does not necessarily compare every row manually. The optimizer can choose algorithms such as Nested Loop Join, Hash Join, or Merge Join depending on indexes, statistics, cardinality, and estimated cost.
—
# 26. Những keyword nên nhớ
Khi học LEFT JOIN, nên nhớ các keyword:
“`text
LEFT JOIN
INNER JOIN
NULL
One-to-Many
Cardinality
Join condition
Nested Loop Join
Hash Join
Merge Join
Index Seek
Table Scan
Execution Plan
ON vs WHERE
“`
—
# 27. Cách nhớ nhanh
Hãy nhớ:
“`text
LEFT JOIN
=
Keep everything from LEFT
“`
Nhưng:
“`text
1 LEFT row
“`
có thể tạo:
“`text
1 result row
“`
hoặc:
“`text
10 result rows
“`
nếu nó match 10 records bên phải.
Vì vậy:
> LEFT JOIN giữ tất cả record bên trái, nhưng không đảm bảo số lượng output bằng số record của bảng bên trái.
—
# Kết luận
Nếu:
“`text
A = 5 records
B = 10 records
“`
thì không thể nói ngay:
“`text
LEFT JOIN = 5 records
“`
Phải xem relationship.
Ví dụ:
“`text
A1 -> 3 B
A2 -> 2 B
A3 -> 1 B
A4 -> 4 B
A5 -> 0 B
“`
thì result:
“`text
11 rows
“`
Điểm quan trọng nhất cần nhớ là:
> LEFT JOIN đảm bảo mỗi record bên trái xuất hiện ít nhất một lần, nhưng một record bên trái có thể xuất hiện nhiều lần nếu nó match nhiều record ở bảng bên phải.
Về mặt execution, database optimizer có thể sử dụng:
“`text
Nested Loop Join
Hash Join
Merge Join
“`
và index trên join column có thể ảnh hưởng rất lớn đến performance.
Cuối cùng, khi dùng LEFT JOIN, hãy đặc biệt chú ý sự khác nhau giữa:
“`sql
ON
“`
và:
“`sql
WHERE
“`
vì một điều kiện đặt sai vị trí có thể vô tình biến LEFT JOIN thành behavior gần giống INNER JOIN.