MongoDB so với MySQL
Xem thửTình huống mở đầu
Sếp hỏi hai câu cho buổi họp sáng: tổng tiền của từng đơn đã thanh toán kèm email khách, và tổng chi tiêu của từng khách. Anh Hùng, người quen SQL, viết xong câu đầu trong hai phút: ba bảng, hai JOIN, một GROUP BY. Chị Mai, người quen MongoDB, cũng xong trong hai phút nhưng chỉ dùng một collection và không có JOIN nào. Hai người đưa ra cùng một con số.
Câu hỏi của bài: hai cách khác nhau thế nào, và khi nào cách này tiện hơn cách kia? Hãy đoán trước: chị Mai có tiện hơn ở cả hai câu không?
Ôn nhanh bài trước: ai giữ luật dữ liệu trong MongoDB mặc định, và cơ chế nào để đẩy một phần luật về database? Đáp án: ứng dụng giữ, còn validation $jsonSchema giúp đẩy luật hình dạng về database. Bài này so sánh cách hỏi dữ liệu thay vì cách ghi.
Mục tiêu bài học
Sau bài này bạn có thể:
- Viết cùng một câu hỏi tổng tiền bằng
JOINvàGROUP BYtrong MySQL, và bằng aggregation trong MongoDB. - Chỉ ra chỗ MongoDB ngắn hơn (document đã nhúng) và chỗ hai bên giống nhau (gom theo thực thể nằm ở nhiều đơn).
- Nhận ra hậu quả khi mang nguyên thiết kế 3 bảng sang MongoDB rồi nối bằng
$lookup. - Tránh ba bẫy: đơn rỗng bị rơi mất,
$lookuptrả mảng, và kiểu chuỗi trong phép nhân.
Bức tranh lớn
Xuyên suốt bài, dữ liệu đơn hàng là hóa đơn của quán. MySQL cất hóa đơn thành ba ngăn: phần đầu hóa đơn, thông tin khách, các dòng món. Muốn tính tiền thì lấy cả ba ra ghép. MongoDB giữ cả tờ hóa đơn nguyên vẹn nên tính tiền ngay trên tờ đó. Dự án xuyên suốt là shop với ba đơn mẫu.
Hình dưới trả lời một câu hỏi: hai đường đi khác nhau có cho cùng đáp án không?
Đang tải sơ đồ…
Cách đọc hình: ô xanh dương là câu hỏi. Hai ô tím là hai hệ. Hai ô cyan là cách mỗi hệ trả lời. Cả hai nhánh hội tụ ở ô xanh lá: hai dòng giống nhau cho đơn 1001 và 1002. Điều cần nhìn là chữ trong hai ô cyan, JOIN ba bảng so với match rồi project.
Chỗ ẩn dụ không còn đúng: một tờ hóa đơn giấy không thể vừa nằm nguyên tờ vừa nằm tách ba ngăn. Dữ liệu thì làm được cả hai, nên tùy câu hỏi mà một cách đỡ việc hơn.
Khái niệm cốt lõi
1. Cùng câu hỏi, hai đường đi
MySQL chuẩn hóa dữ liệu thành bảng nhỏ, khi hỏi thì JOIN ghép lại rồi GROUP BY gom. MongoDB, với dữ liệu nhúng, tính ngay trong từng document bằng aggregation.
2. Nhúng rút ngắn đường đi, nhưng chỉ cho câu hỏi hợp với cách nhúng
Câu hỏi xoay quanh một đơn (tổng tiền từng đơn) rất hợp với document nhúng. Câu hỏi xoay quanh thực thể nằm ở nhiều đơn (tổng chi từng khách) vẫn phải tách mảng rồi gom nhóm bằng $unwind và $group.
3. Mang thiết kế bảng sang MongoDB là con đường vòng
Nếu bạn tách khách và đơn thành hai collection và nối bằng $lookup, bạn tự tạo lại JOIN nhưng mất sự hỗ trợ của khóa ngoại, và kết quả $lookup là một mảng cần xử lý thêm.
Ví dụ đầu tiên
Dữ liệu giống nhau ở cả hai hệ: 2 khách, 3 đơn (1001 và 1002 đã PAID, 1003 còn NEW), 4 dòng hàng. MySQL 8.0.44 trước:
CREATE TABLE customers (id INT PRIMARY KEY, email VARCHAR(30));
CREATE TABLE orders (id INT PRIMARY KEY, customer_id INT, status VARCHAR(10));
CREATE TABLE order_items (order_id INT, sku VARCHAR(10), qty INT, price INT);
INSERT INTO customers VALUES (7, 'an@example.com'), (8, 'binh@example.com');
INSERT INTO orders VALUES (1001, 7, 'PAID'), (1002, 8, 'PAID'), (1003, 7, 'NEW');
INSERT INTO order_items VALUES (1001, 'KB-01', 2, 450000), (1001, 'MS-02', 1, 220000), (1002, 'HD-03', 1, 780000), (1003, 'CB-04', 3, 35000);
Câu hỏi 1: tổng tiền từng đơn PAID kèm email.
SELECT o.id, c.email, SUM(i.qty * i.price) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'PAID'
GROUP BY o.id, c.email
ORDER BY o.id;
+------+------------------+---------+
| id | email | total |
+------+------------------+---------+
| 1001 | an@example.com | 1120000 |
| 1002 | binh@example.com | 780000 |
+------+------------------+---------+
Kết quả [Đã chạy: MySQL 8.0.44]. Cùng câu hỏi trong MongoDB 4.4.19, dữ liệu mỗi đơn mang sẵn email khách và mảng dòng hàng:
var r = db.orders.insertMany([
{ _id: 1001, status: "PAID", customer: { id: 7, email: "an@example.com" }, items: [{ sku: "KB-01", qty: 2, price: 450000 }, { sku: "MS-02", qty: 1, price: 220000 }] },
{ _id: 1002, status: "PAID", customer: { id: 8, email: "binh@example.com" }, items: [{ sku: "HD-03", qty: 1, price: 780000 }] },
{ _id: 1003, status: "NEW", customer: { id: 7, email: "an@example.com" }, items: [{ sku: "CB-04", qty: 3, price: 35000 }] }
])
print("orders:", db.orders.countDocuments({}))
db.orders.aggregate([
{ $match: { status: "PAID" } },
{ $project: {
email: "$customer.email",
total: { $sum: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.qty", "$$i.price"] } } } }
} },
{ $sort: { _id: 1 } }
])
{ "_id" : 1001, "email" : "an@example.com", "total" : 1120000 }
{ "_id" : 1002, "email" : "binh@example.com", "total" : 780000 }
Dự đoán trước khi xem: hai kết quả có bằng nhau không? Có: hai dòng 1001 (1.120.000) và 1002 (780.000), khác nhau chỉ ở cách in.
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
JOIN customers c ON c.id = o.customer_id | Ghép mỗi đơn với khách của nó | Email nằm ở bảng khác nên phải ghép |
SUM(i.qty * i.price) ... GROUP BY o.id, c.email | Cộng tiền các dòng hàng theo từng đơn | Phép nối làm mỗi dòng hàng thành một dòng, nên phải gom lại |
$match: { status: "PAID" } | Giữ các đơn đã thanh toán | Lọc sớm để các bước sau xử lý ít document |
$map: { input: "$items", as: "i", in: ... } | Với mỗi phần tử của items, tính qty nhân price | Duyệt mảng ngay trong document, không cần tách |
$sum: ... | Cộng các số do $map trả về | Ra tổng tiền của đơn |
Câu hỏi 2: tổng chi của từng khách. Lúc này thực thể cần gom (khách) nằm ở nhiều đơn:
SELECT c.email, SUM(i.qty * i.price) AS revenue
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items i ON i.order_id = o.id
GROUP BY c.email
ORDER BY c.email;
+------------------+---------+
| email | revenue |
+------------------+---------+
| an@example.com | 1225000 |
| binh@example.com | 780000 |
+------------------+---------+
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$customer.email", revenue: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } },
{ $sort: { _id: 1 } }
])
{ "_id" : "an@example.com", "revenue" : 1225000 }
{ "_id" : "binh@example.com", "revenue" : 780000 }
Cả hai cho An 1.225.000 (1.120.000 của đơn 1001 và 105.000 của đơn 1003) và Binh 780.000. Lần này MongoDB cũng phải $unwind rồi $group, độ dài hai bên ngang nhau.
Xem từng bước
Animation dưới đây đi qua câu hỏi 1 ở cả hai hệ. Hãy trả lời câu dự đoán trước khi bấm bước đầu tiên, rồi bấm nút chuyển để so sánh.
Đang tải Animation…
Bảng trace bằng chữ [Đã chạy: MySQL 8.0.44 và MongoDB 4.4.19, ba đơn mẫu]:
| Câu hỏi | MySQL | MongoDB |
|---|---|---|
| 1. Tổng từng đơn PAID kèm email | 3 bảng, hai JOIN, GROUP BY | 1 collection, match rồi project với map |
| 2. Tổng chi từng khách | 3 bảng, hai JOIN, GROUP BY | 1 collection, unwind rồi group |
| Kết quả | 1001: 1.120.000, 1002: 780.000 | Giống hệt |
Bạn vừa làm được: viết cùng một báo cáo ở hai hệ, kiểm chứng kết quả bằng nhau, và nói được chỗ nào ngắn hơn.
Concept Check
Câu 1. Câu hỏi nào MongoDB với document nhúng trả lời ngắn nhất?
- A. Tổng chi tiêu của từng khách, vì khách nằm ở nhiều đơn.
- B. Tổng tiền của từng đơn, vì dòng hàng nằm sẵn trong đơn.
- C. Sản phẩm bán chạy nhất, vì sản phẩm nằm ở nhiều đơn.
- D. Khách nào chưa có đơn nào, vì cần đối chiếu hai tập dữ liệu.
Câu 2. Vì sao phép JOIN của MySQL cần GROUP BY ở cuối?
- A. Vì
GROUP BYbắt buộc đi cùng mọi câuSELECT. - B. Vì
JOINlàm mỗi dòng hàng thành một dòng kết quả nên phải gom lại theo đơn. - C. Vì MySQL không có hàm tính tổng nếu thiếu
GROUP BY. - D. Vì
GROUP BYsắp xếp kết quả theoidcủa đơn.
Câu 3. Bạn chia khách và đơn thành hai collection rồi nối bằng $lookup. Điều nào đúng?
- A. MongoDB tự kiểm tra khóa ngoại giữa hai collection.
- B. Kết quả
$lookuplà một mảng, cần thêm bước để lấy ra giá trị. - C.
$lookupnhanh hơn việc nhúng khách vào đơn trong mọi trường hợp. - D. Không thể truy vấn gì khi dữ liệu nằm ở hai collection.
Đáp án và giải thích:
- Câu 1: B. Dòng hàng nằm sẵn trong từng đơn nên
$maptính ngay. A, C, D đều xoay quanh thực thể nằm nhiều nơi, nên vẫn cần gom nhóm. - Câu 2: B. Phép nối trả về dạng phẳng, nhiều dòng cho một đơn. A và C sai về cú pháp SQL. D nhầm với
ORDER BY. - Câu 3: B. A sai vì MongoDB không có khóa ngoại. C sai vì không có quy luật chung. D sai vì
$lookuplàm được việc nối, chỉ tốn công hơn.
Làm cùng mình
Sếp hỏi thêm: những đơn nào có chuột MS-02? Mình làm cùng bạn ở cả hai hệ.
Bước 1. Vẽ ra giấy
Với MySQL, bảng nào chứa mã sản phẩm? Với MongoDB, trường nào chứa nó? Vẽ ra đường đi ngắn nhất ở mỗi hệ.
Bước 2. Điền chỗ trống
db.orders.find(
// TODO 1: lọc đơn có phần tử trong mảng items với sku là MS-02
// TODO 2: chỉ lấy _id và status
)
Gợi ý: nhớ lại đường dẫn có dấu chấm đi vào mảng.
Chạy xong bạn sẽ thấy [Đã chạy] đúng một đơn. Đối chiếu ở Lời giải và đáp án.
Bước 3. Dự đoán rồi thử
Viết bản SQL tương đương: bảng nào cần JOIN? Dự đoán số dòng kết quả trước khi chạy.
Bước 4. Vẽ lại
Ghi một câu: vì sao câu này trong MongoDB gần như là một find đơn giản?
Thử phá nó đi
Ba bẫy thật khi chuyển giữa hai hệ [Đã chạy: MySQL 8.0.44 và MongoDB 4.4.19]:
| Bẫy | Điều xảy ra | Nguyên nhân | Cách tránh |
|---|---|---|---|
| Đơn 1004 không có dòng hàng | Biến mất khỏi kết quả JOIN và $unwind | Cả hai chỉ giữ đơn có ít nhất một dòng hàng | Dùng LEFT JOIN, hoặc $map, hoặc preserveNullAndEmptyArrays |
$lookup thay JOIN | Kết quả có trường mảng c thay vì giá trị | $lookup luôn trả mảng các document khớp | Thêm $unwind hoặc $arrayElemAt |
qty lưu chuỗi "2" | Lỗi 16555 khi nhân | $multiply chỉ nhận số | Giữ kiểu số khi ghi |
Bẫy thứ nhất, MySQL (đã thêm đơn 1004 không có dòng hàng):
INSERT INTO orders VALUES (1004, 8, 'NEW');
SELECT o.id, COALESCE(SUM(i.qty * i.price), 0) AS inner_total
FROM orders o JOIN order_items i ON i.order_id = o.id
GROUP BY o.id ORDER BY o.id;
SELECT o.id, COALESCE(SUM(i.qty * i.price), 0) AS left_total
FROM orders o LEFT JOIN order_items i ON i.order_id = o.id
GROUP BY o.id ORDER BY o.id;
+------+-------------+
| id | inner_total |
+------+-------------+
| 1001 | 1120000 |
| 1002 | 780000 |
| 1003 | 105000 |
+------+-------------+
+------+------------+
| id | left_total |
+------+------------+
| 1001 | 1120000 |
| 1002 | 780000 |
| 1003 | 105000 |
| 1004 | 0 |
+------+------------+
Bẫy thứ nhất, MongoDB:
db.orders.insertOne({ _id: 1004, status: "NEW", customer: { id: 8, email: "binh@example.com" }, items: [] })
print("with $unwind:")
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: { _id: "$_id", total: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } },
{ $sort: { _id: 1 } }
])
print("with $map:")
db.orders.aggregate([
{ $project: { total: { $sum: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.qty", "$$i.price"] } } } } } },
{ $sort: { _id: 1 } }
])
{ "acknowledged" : true, "insertedId" : 1004 }
with $unwind:
{ "_id" : 1001, "total" : 1120000 }
{ "_id" : 1002, "total" : 780000 }
{ "_id" : 1003, "total" : 105000 }
with $map:
{ "_id" : 1001, "total" : 1120000 }
{ "_id" : 1002, "total" : 780000 }
{ "_id" : 1003, "total" : 105000 }
{ "_id" : 1004, "total" : 0 }
Bẫy thứ hai:
db.customers.insertMany([{ _id: 7, email: "an@example.com" }, { _id: 8, email: "binh@example.com" }])
db.refs.insertOne({ _id: 1001, customerId: 7 })
db.refs.aggregate([
{ $lookup: { from: "customers", localField: "customerId", foreignField: "_id", as: "c" } }
])
{ "acknowledged" : true, "insertedIds" : [ 7, 8 ] }
{ "acknowledged" : true, "insertedId" : 1001 }
{ "_id" : 1001, "customerId" : 7, "c" : [ { "_id" : 7, "email" : "an@example.com" } ] }
Bẫy thứ ba:
db.bad.insertOne({ _id: 1, items: [{ qty: "2", price: 450000 }] })
try {
db.bad.aggregate([
{ $project: { t: { $sum: { $map: { input: "$items", as: "i", in: { $multiply: ["$$i.qty", "$$i.price"] } } } } } }
]).toArray()
} catch (e) {
print("error code:", e.code)
print(e.message.split('"errmsg" : "')[1].split('"')[0])
}
{ "acknowledged" : true, "insertedId" : 1 }
error code: 16555
$multiply only supports numeric types, not string
Hình dưới đối chiếu niềm tin sai và thực tế của bẫy thứ hai.
Đang tải sơ đồ…
Cách đọc hình: hai chuỗi độc lập. Chuỗi đỏ là niềm tin sai: copy thiết kế bảng sang là dùng ngay $lookup như JOIN. Chuỗi xanh là thực tế: kết quả mảng, thêm bước, đọc dữ liệu hai lần.
Tự tay làm
Dùng chung ba đơn mẫu của bài.
Cơ bản
Bài 1. Tìm đơn theo sản phẩm. Đầu vào: ba đơn. Tìm đơn có sản phẩm MS-02. Tiêu chí đạt: đúng đơn 1001. Gợi ý: "items.sku".
Vận dụng
Bài 2. Báo cáo ở hai hệ. Đầu vào: ba đơn. Viết và chạy câu "tổng chi từng khách" ở cả hai hệ rồi so kết quả. Tiêu chí đạt: hai bên cùng An 1.225.000 và Binh 780.000. Gợi ý: nhìn lại hai khối lệnh trong phần Ví dụ đầu tiên.
Thử thách (tùy chọn)
Bài 3. Giữ đơn rỗng. Đầu vào: thêm đơn 1004 không có dòng hàng. Viết truy vấn MongoDB trả về tổng từng đơn, đơn 1004 là 0, bằng $unwind. Tiêu chí đạt: bốn dòng. Gợi ý: tùy chọn preserveNullAndEmptyArrays.
Tự kiểm tra
Trả lời bằng lời của bạn, không nhìn lại bài.
- (Mục tiêu 1, nhớ lại) Hai khối lệnh nào trong MySQL tạo nên câu "tổng tiền từng đơn"?
- (Mục tiêu 2, giải thích) Vì sao câu "tổng chi từng khách" trong MongoDB không ngắn hơn MySQL?
- (Mục tiêu 3, giải thích) Kết quả của
$lookupkhácJOINở điểm nào? - (Mục tiêu 4, dự đoán) Đơn 1004 không có dòng hàng sẽ xuất hiện ra sao khi dùng
JOINtrong MySQL,$unwindvà$map? - (Mục tiêu 4, tìm lỗi)
qtyđược lưu là chuỗi "2" và aggregation báo lỗi 16555. Nguyên nhân và cách sửa?
Điều cần nhớ
- Cùng một câu hỏi tổng tiền, MySQL và MongoDB cho cùng kết quả bằng hai đường đi khác nhau.
- Document nhúng rút ngắn đường đi khi câu hỏi xoay quanh một đơn hàng.
- Câu hỏi gom theo thực thể nằm ở nhiều đơn vẫn cần
$unwindvà$group. - Bẫy chung của cả hai hệ:
JOINthường và$unwindđều làm rơi đơn rỗng.
Cheat sheet:
| Muốn | MySQL | MongoDB |
|---|---|---|
| Tổng từng đơn | JOIN ... GROUP BY | $project với $map và $sum |
| Gom theo khách | GROUP BY c.email | $unwind rồi $group |
| Giữ đơn rỗng | LEFT JOIN | $map hoặc preserveNullAndEmptyArrays |
| Nối collection | JOIN | $lookup (kết quả là mảng) |
Hình tóm tắt của bài là sơ đồ ở mục Bức tranh lớn.
Câu hỏi để dành cho ngày mai: nếu trong máy chưa cài sẵn MongoDB, các bước cài đặt trên Ubuntu gồm những gì, và làm sao biết cài đúng?
Đào sâu
Phần này dành cho bạn tò mò.
- Cơ chế bên trong. Khi
$projectdùng$map, mỗi document được xử lý độc lập nên không cần ghép dữ liệu giữa các document. Còn$groupphải nhìn mọi document để gom, nên tốn bộ nhớ hơn. - So sánh với lựa chọn khác. Nhiều hệ thống dùng cả hai: MySQL cho sổ cái cần ràng buộc chặt, MongoDB cho catalog hay luồng sự kiện cần hình dạng linh hoạt. Đây là một cách phối hợp phổ biến, không phải quy tắc.
- Hiểu lầm nâng cao. So sánh tốc độ thô giữa hai hệ mà không cùng khối lượng công việc và chỉ mục là vô nghĩa. Bài này không đo thời gian nên không kết luận hệ nào nhanh hơn [Có thể khác nhau].
- Giới hạn của bài. Bài chưa nói đến chỉ mục cho
$lookup, cách thiết kế khi nào nên nhúng và khi nào tham chiếu, hay$lookuptrên collection sharded. - Đọc thêm. MongoDB Manual 4.4, trang SQL to Aggregation Mapping Chart: https://www.mongodb.com/docs/v4.4/reference/sql-aggregation-comparison/
Bài tiếp theo
Chương này kết thúc ở đây. Bài đầu tiên của chương sau, Cài đặt và cấu hình MongoDB trên Ubuntu, bắt tay vào cài server. Mini-dự án nối tiếp: kiểm tra từng bước xem server của bạn đã chạy đúng chưa.
Lời giải và đáp án
Làm cùng mình, Bước 2.
{ "items.sku": "MS-02" },
{ status: 1 }
db.orders.find({ "items.sku": "MS-02" }, { _id: 1, status: 1 })
{ "_id" : 1001, "status" : "PAID" }
Bước 3: bản SQL cần JOIN bảng order_items với bảng orders rồi lọc sku = 'MS-02', ra 1 dòng (đơn 1001 có một dòng hàng MS-02). Bước 4: vì mã sản phẩm nằm trong mảng items của chính đơn, nên đường dẫn có dấu chấm đủ để lọc.
Bài 1. Cùng đáp án với Bước 2: đơn 1001.
Bài 2. MySQL:
+------------------+---------+
| email | revenue |
+------------------+---------+
| an@example.com | 1225000 |
| binh@example.com | 780000 |
+------------------+---------+
MongoDB:
{ "_id" : "an@example.com", "revenue" : 1225000 }
{ "_id" : "binh@example.com", "revenue" : 780000 }
Hai bên cùng An 1.225.000 và Binh 780.000.
Bài 3.
db.orders.insertOne({ _id: 1004, status: "NEW", customer: { id: 8, email: "binh@example.com" }, items: [] })
db.orders.aggregate([
{ $unwind: { path: "$items", preserveNullAndEmptyArrays: true } },
{ $group: { _id: "$_id", total: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } },
{ $sort: { _id: 1 } }
])
{ "acknowledged" : true, "insertedId" : 1004 }
{ "_id" : 1001, "total" : 1120000 }
{ "_id" : 1002, "total" : 780000 }
{ "_id" : 1003, "total" : 105000 }
{ "_id" : 1004, "total" : 0 }
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
$unwind: { path: "$items", preserveNullAndEmptyArrays: true } | Tách mảng nhưng giữ cả document có mảng rỗng | Nếu không có tùy chọn, đơn 1004 bị bỏ |
$sum: { $multiply: [...] } | Cộng qty nhân price, trường thiếu được coi là không cộng | Đơn 1004 ra tổng 0 |
$sort: { _id: 1 } | Sắp theo mã đơn | Để thứ tự ổn định |
Tự kiểm tra.
JOINđể ghép ba bảng vàGROUP BYđể gom tiền theo đơn.- Vì khách nằm ở nhiều đơn, nên MongoDB cũng phải
$unwindmảng rồi$grouptheo email, tương tựJOINvàGROUP BY. $lookuptrả về một mảng các document khớp trong một trường mới, cònJOINtrả về các dòng phẳng.JOINthường và$unwindmặc định đều bỏ đơn 1004;LEFT JOIN,$maphoặcpreserveNullAndEmptyArraysgiữ nó lại với tổng 0.$multiplychỉ nhận số, màqtylà chuỗi. Sửa bằng cách ghiqtylà số, hoặc ép kiểu trong pipeline bằng toán tử chuyển kiểu.