Bạn cần tìm những đơn có sản phẩm P001 được mua từ hai cái trở lên. Câu truy vấn chạy không lỗi, nhưng lại trả về cả đơn chỉ mua một P001. Nguyên nhân có thể nằm ở cách MongoDB kiểm tra các điều kiện trên một mảng document.
Trong bài này, chúng ta dùng bốn đơn hàng để phân biệt ba việc: chọn đúng document, chọn đúng phần dữ liệu cần hiển thị và sắp xếp kết quả theo thứ tự rõ ràng. Mỗi truy vấn đều có kết quả mong đợi để bạn đối chiếu, thay vì chỉ nhìn thấy lệnh chạy thành công.
Chuỗi MongoDB, bài 3/6. Nếu chưa quen CRUD, đọc bài 1. Để hiểu vì sao đơn hàng chứa mảng items, xem bài 2 về thiết kế dữ liệu.
1. Mục tiêu và điều kiện thực hành
Sau bài học, bạn có thể đọc trường lồng nhau bằng dot notation; phân biệt các điều kiện khớp ở nhiều phần tử với cùng một phần tử; dùng projection và $filter đúng mục đích; nhận diện null, trường thiếu và chuỗi rỗng; phân trang có tiêu chí sắp xếp phụ duy nhất.
Môi trường: MongoDB Community 8.0 và mongosh. Bạn cần biết find, insertMany và document cơ bản. Bài dùng database riêng techdata_mongodb_query_lab, không dùng dữ liệu đã chỉnh sửa ở bài trước. Các định danh và thời điểm dưới đây là dữ liệu mẫu, không phải đơn hàng thật.
Mục lục
- Chuẩn bị bộ dữ liệu bốn đơn hàng
- Đọc document lồng nhau
- Truy vấn mảng giá trị đơn
- Vì sao điều kiện mảng dễ trả sai kết quả?
- Bộ lọc, projection và $filter
- Phân biệt null, thiếu trường và chuỗi rỗng
- Sắp xếp và phân trang
- Lỗi thường gặp và cách kiểm tra
- Bài tập tự luyện
- Tổng kết và bài tiếp theo
- Tài liệu tham khảo
2. Chuẩn bị bộ dữ liệu bốn đơn hàng
Nếu đã tạo container ở bài 1, chạy trong terminal hệ điều hành:
docker start techdata-mongo-lab
docker exec -it techdata-mongo-lab mongosh
Nếu chưa có container, tạo bằng lệnh dưới rồi mở mongosh bằng docker exec ở trên. Đây là cấu hình lab cục bộ, không mở cổng và không bật xác thực; không dùng nguyên cấu hình này để triển khai dịch vụ thật.
docker run -d --name techdata-mongo-lab -v techdata_mongo_lab_data:/data/db mongo:8.0
Từ đây, chạy trong mongosh. Dùng getSiblingDB để chọn database; phép gán db dưới đây thay đổi database làm việc của phiên shell. Chỉ nhập bộ mẫu vào một database lab mới. Nếu đã có orders, dùng tên database lab khác để tránh nhập trùng.
db = db.getSiblingDB("techdata_mongodb_query_lab")
db.getName()
db.orders.countDocuments({})
db.orders.insertMany([
{
_id: "SO001", status: "confirmed",
shipping_address: { city: "TP.HCM", district: "Q7" },
tags: ["study", "vip"], coupon: null,
created_at: ISODate("2026-11-12T02:00:00Z"),
items: [
{ product_id: "P001", qty: 1, unit_price_vnd: 15000000 },
{ product_id: "P002", qty: 3, unit_price_vnd: 200000 }
]
},
{
_id: "SO002", status: "confirmed",
shipping_address: { city: "TP.HCM", district: "Q1" },
tags: ["study"],
created_at: ISODate("2026-11-12T02:00:00Z"),
items: [
{ product_id: "P001", qty: 2, unit_price_vnd: 15000000 },
{ product_id: "P003", qty: 1, unit_price_vnd: 250000 }
]
},
{
_id: "SO003", status: "pending",
shipping_address: { city: "Hà Nội", district: "Cầu Giấy" },
tags: ["vip", "study"], coupon: "DATA10",
created_at: ISODate("2026-11-12T02:10:00Z"),
items: [
{ product_id: "P002", qty: 2, unit_price_vnd: 200000 },
{ product_id: "P001", qty: 4, unit_price_vnd: 15000000 },
{ product_id: "P001", qty: 3, unit_price_vnd: 14000000 }
]
},
{
_id: "SO004", status: "cancelled",
shipping_address: { city: "TP.HCM", district: "Q7" },
tags: [], coupon: "",
created_at: ISODate("2026-11-12T02:20:00Z"),
items: []
}
])
db.orders.countDocuments({})
Kết quả mong đợi: 4. SO003 cố tình có hai dòng P001 với giá khác nhau để minh họa việc lấy một hay mọi dòng khớp. Bộ dữ liệu cho phép lặp mã sản phẩm trong một đơn; đây là giả định của lab, không phải yêu cầu mọi hệ thống phải làm như vậy.
| Đơn | Tình huống cần chú ý | Trạng thái coupon |
|---|---|---|
| SO001 | P001 số lượng 1; P002 số lượng 3 | Có trường, giá trị null |
| SO002 | P001 số lượng 2; P003 số lượng 1 | Không có trường |
| SO003 | Có hai dòng P001, số lượng 4 và 3 | Chuỗi DATA10 |
| SO004 | Mảng items và tags rỗng | Chuỗi rỗng |
3. Đọc document lồng nhau bằng dot notation
Để đọc một trường nằm trong object, dùng đường dẫn có dấu chấm và đặt đường dẫn trong dấu nháy. [1] Ví dụ, shipping_address.city là trường city nằm bên trong shipping_address:
db.orders.find(
{ "shipping_address.city": "TP.HCM" },
{ _id: 1, "shipping_address.district": 1 }
).sort({ _id: 1 })
Kết quả: SO001 có Q7, SO002 có Q1 và SO004 có Q7. Chúng ta chưa lọc trạng thái nên đơn cancelled vẫn xuất hiện. Khi viết truy vấn nghiệp vụ, địa điểm và trạng thái là hai điều kiện riêng, không nên ngầm hiểu đơn ở một địa điểm đều còn hiệu lực.
Thêm điều kiện trạng thái ngay trong bộ lọc:
db.orders.find(
{ "shipping_address.city": "TP.HCM", status: "confirmed" },
{ _id: 1 }
).sort({ _id: 1 })
Lần này chỉ còn SO001 và SO002. Các điều kiện trên hai trường khác nhau trong object bộ lọc được kết hợp theo ý nghĩa đồng thời đúng.
Một lỗi khác là so sánh toàn bộ object khi chỉ muốn kiểm tra city. Điều kiện { shipping_address: { city: "TP.HCM" } } không có cùng ý nghĩa với dot notation. So sánh object đòi hỏi khớp giá trị document, bao gồm cấu trúc và thứ tự trường. [2] Với mẫu có thêm district, câu so sánh object rút gọn đó không khớp các địa chỉ đã nhập.
4. Truy vấn mảng giá trị đơn: chứa, bất kỳ và tất cả
Trường tags là mảng chuỗi. Với bộ dữ liệu này, { tags: "study" } tìm đơn có phần tử study; SO001, SO002 và SO003 đều khớp. Đây không phải điều kiện mảng chỉ được có đúng một phần tử.
db.orders.find({ tags: "study" }, { _id: 1 }).sort({ _id: 1 })
db.orders.find(
{ tags: { $in: ["vip", "priority"] } },
{ _id: 1 }
).sort({ _id: 1 })
db.orders.find(
{ tags: { $all: ["study", "vip"] } },
{ _id: 1 }
).sort({ _id: 1 })
$in yêu cầu khớp ít nhất một giá trị trong danh sách; ở truy vấn thứ hai, SO001 và SO003 khớp nhờ vip. [3] $all yêu cầu có đủ các giá trị được liệt kê; truy vấn thứ ba cũng trả SO001 và SO003 dù thứ tự tags của hai đơn khác nhau. [4]
Nếu viết { tags: ["study", "vip"] }, bạn đang so sánh một giá trị mảng có thứ tự. Với dữ liệu một chiều của lab, chỉ SO001 khớp; SO003 lưu vip trước study. [2] Hãy chọn toán tử theo câu hỏi nghiệp vụ thay vì thấy hai cách cùng trả vài đơn rồi cho rằng chúng tương đương.
5. Vì sao điều kiện mảng dễ trả sai kết quả?
5.1. Câu truy vấn hợp lệ nhưng không diễn đạt đúng yêu cầu
Yêu cầu: tìm đơn có ít nhất một dòng product_id bằng P001 và qty từ 2 trở lên trên chính dòng đó. Hãy xem cách viết sau:
db.orders.find(
{ "items.product_id": "P001", "items.qty": { $gte: 2 } },
{ _id: 1 }
).sort({ _id: 1 })
Kết quả: SO001, SO002 và SO003. SO001 xuất hiện vì có một dòng P001, đồng thời có một dòng khác qty bằng 3. Cách đặt điều kiện tách rời không buộc cả hai cùng khớp trên một phần tử. [5]

Câu truy vấn trên vẫn phù hợp nếu nghiệp vụ thật sự là “đơn có P001 và có một dòng bất kỳ số lượng từ 2”. Lỗi ở đây là không khớp với yêu cầu cụ thể, chứ không phải MongoDB thực hiện sai cú pháp.
5.2. Dùng $elemMatch để ràng buộc cùng một phần tử
db.orders.find(
{ items: { $elemMatch: { product_id: "P001", qty: { $gte: 2 } } } },
{ _id: 1 }
).sort({ _id: 1 })
Kết quả: SO002 và SO003. $elemMatch trong bộ lọc chọn document có ít nhất một phần tử mảng thỏa toàn bộ điều kiện bên trong. [5] SO001 bị loại vì dòng P001 chỉ có qty bằng 1; số lượng của P002 không được dùng để đáp ứng điều kiện cho P001.
Thêm status: "confirmed" bên cạnh items trong bộ lọc để chỉ giữ SO002. Đừng đặt status vào bên trong $elemMatch: status thuộc đơn hàng, không thuộc từng dòng items. Việc xác định trường nằm ở cấp nào là bước quan trọng trước khi ghép nhiều toán tử.
6. Bộ lọc, projection và $filter trả về những gì?
6.1. Lọc đúng đơn không đồng nghĩa tự cắt mảng
db.orders.find(
{ _id: "SO003", items: { $elemMatch: { product_id: "P001" } } },
{ _id: 1, items: 1 }
)
Bạn nhận SO003 với cả ba dòng items, gồm P002 và hai dòng P001. Bộ lọc xác định document nào được chọn; projection items: 1 yêu cầu trả về trường items. Projection thông thường chọn trường, không tự chuyển thành một phép lọc phần tử. [6]

6.2. $elemMatch trong projection chỉ giữ phần tử đầu tiên khớp
db.orders.find(
{ _id: "SO003" },
{
_id: 1,
items: { $elemMatch: { product_id: "P001", qty: { $gte: 2 } } }
}
)
Ở đây $elemMatch nằm trong đối số projection, không nằm trong bộ lọc. Kết quả items chỉ chứa dòng P001 có qty bằng 4, vì đó là phần tử khớp đầu tiên theo thứ tự mảng. Dòng P001 có qty bằng 3 cũng khớp nhưng không được giữ lại bởi toán tử projection này. [7]
Nếu bộ lọc chỉ theo _id và không có phần tử nào thỏa điều kiện projection, document vẫn có thể được trả về nhưng thiếu trường items trong kết quả. Do đó, không nên dùng projection để thay cho việc lọc document cần tồn tại phần tử phù hợp.
6.3. Muốn mọi phần tử khớp: dùng $filter
db.orders.aggregate([
{ $match: { _id: "SO003" } },
{
$project: {
_id: 1,
items: {
$filter: {
input: "$items",
as: "line",
cond: {
$and: [
{ $eq: ["$$line.product_id", "P001"] },
{ $gte: ["$$line.qty", 2] }
]
}
}
}
}
}
])
Kết quả: SO003 với hai dòng P001, qty lần lượt 4 và 3. $filter lựa chọn phần tử theo điều kiện và giữ thứ tự ban đầu của mảng; ví dụ không đặt limit nên giữ mọi phần tử khớp. [8]
Đọc từng thành phần: input là mảng cần duyệt; as đặt tên line cho phần tử đang xét; hai dấu đô trong $$line truy cập biến ấy; cond là điều kiện giữ phần tử. $match và $project là các bước của aggregation, còn $filter là biểu thức nằm bên trong bước chọn dữ liệu. Bài 4 sẽ đi sâu vào pipeline thay vì yêu cầu bạn nhớ tất cả ngay lúc này.
Với input thiếu hoặc null, $filter trả null; input là giá trị không phải mảng và không phải null có thể làm pipeline lỗi. [8] Trong lab, mọi items đều là mảng. Khi nguồn dữ liệu không bảo đảm điều đó, hãy kiểm tra schema và xử lý trường hợp sai kiểu theo yêu cầu, đừng tự biến mọi giá trị lỗi thành mảng rỗng rồi mất dấu nguyên nhân.
7. Phân biệt null, thiếu trường và chuỗi rỗng
Bốn đơn được cố tình nhập coupon theo bốn trạng thái. Hãy kiểm tra riêng từng trường hợp thay vì coi tất cả là “không có mã giảm giá”.
db.orders.find({ coupon: null }, { _id: 1 }).sort({ _id: 1 })
db.orders.find({ coupon: { $exists: false } }, { _id: 1 })
db.orders.find({ coupon: { $type: "null" } }, { _id: 1 })
db.orders.find({ coupon: "" }, { _id: 1 })
| Bộ lọc | Kết quả trên lab | Ý nghĩa cần phân biệt |
|---|---|---|
| coupon: null | SO001, SO002 | Khớp null hoặc trường không tồn tại |
| coupon: { $exists: false } | SO002 | Trường không tồn tại |
| coupon: { $type: "null" } | SO001 | Giá trị BSON null trong dữ liệu mẫu |
| coupon: "" | SO004 | Chuỗi tồn tại nhưng rỗng |
$exists: true vẫn khớp trường có giá trị null; $exists kiểm tra sự tồn tại, không khẳng định giá trị đã đầy đủ. [9] $type chọn theo kiểu BSON; ví dụ trên áp dụng cho coupon dạng giá trị đơn trong bộ mẫu, không mở rộng thành quy tắc cho mọi cấu trúc mảng. [10] Điều kiện bằng null có hành vi riêng đối với trường thiếu. [11]

Trong dự án thật, cần thống nhất thiếu coupon nghĩa là chưa thu thập, không áp dụng hay chưa xử lý. Cùng một trạng thái kỹ thuật có thể mang các ý nghĩa nghiệp vụ khác nhau. Trước khi thống kê tỷ lệ dùng mã giảm giá, hãy viết quy tắc phân loại và đối chiếu dữ liệu nguồn.
8. Sắp xếp và phân trang có thứ tự rõ ràng
8.1. Dùng sort, skip và limit cho bộ dữ liệu nhỏ
db.orders.find({}, { _id: 1, created_at: 1 })
.sort({ created_at: -1, _id: -1 })
.limit(2)
db.orders.find({}, { _id: 1, created_at: 1 })
.sort({ created_at: -1, _id: -1 })
.skip(2)
.limit(2)
Trang đầu có SO004 và SO003. Trang thứ hai có SO002 và SO001. Hai đơn cuối có cùng created_at nên _id được dùng làm tiêu chí phụ duy nhất; trong lab, _id là chuỗi định dạng thống nhất. Tài liệu MongoDB khuyến nghị có trường duy nhất trong sort khi kết hợp phân trang. [12]
Thứ tự rõ ràng không tạo ảnh chụp cố định của toàn bộ dữ liệu qua nhiều yêu cầu đọc. Nếu có đơn mới được thêm giữa lúc lấy trang 1 và trang 2, phân trang bằng offset vẫn có thể bị dịch vị trí. Khi kiểm tra kết quả lab, giữ nguyên dữ liệu bốn đơn để quan sát đúng cơ chế.
8.2. Tiếp trang theo cặp khóa đã đọc
Sau trang đầu, phần tử cuối là SO003 tại 02:10 UTC. Với thứ tự giảm dần theo created_at rồi _id, trang tiếp theo gồm các document có thời điểm nhỏ hơn, hoặc cùng thời điểm nhưng _id nhỏ hơn:
const lastCreatedAt = ISODate("2026-11-12T02:10:00Z")
const lastId = "SO003"
db.orders.find(
{
$or: [
{ created_at: { $lt: lastCreatedAt } },
{ created_at: lastCreatedAt, _id: { $lt: lastId } }
]
},
{ _id: 1, created_at: 1 }
).sort({ created_at: -1, _id: -1 }).limit(2)
Kết quả là SO002 rồi SO001. Nhánh cùng thời điểm vẫn cần dù SO003 không trùng giờ với đơn khác trong dữ liệu hiện tại: nếu con trỏ dừng ở SO002 lúc 02:00, nhánh đó giúp SO001 không bị bỏ qua.
Đây là cách xây điều kiện tiếp trang theo khóa, không phải cam kết nhanh hơn trong mọi tình huống. skip với offset lớn phải bỏ qua nhiều kết quả; truy vấn theo khoảng và chỉ mục phù hợp là hướng cần đánh giá. [12] Nếu bộ lọc gốc có status, hãy giữ điều kiện status ở cả trang đầu và các trang sau. Khi created_at có thể bị sửa, cần xác định rõ tác động lên thứ tự và trải nghiệm phân trang.
9. Lỗi thường gặp và cách kiểm tra
| Dấu hiệu | Kiểm tra trước | Cách xử lý |
|---|---|---|
| SO001 lọt vào truy vấn P001 qty ≥ 2 | Các điều kiện có đang áp dụng lên hai phần tử? | Dùng $elemMatch nếu cần cùng một dòng |
| Đơn đúng nhưng vẫn thấy dòng P002 | Projection có đang trả toàn bộ items? | Chọn $elemMatch projection hoặc $filter theo đầu ra cần |
| Chỉ thấy một dòng P001 | Đang dùng $elemMatch trong projection? | Dùng $filter nếu muốn mọi dòng khớp |
| Không có coupon nhưng vẫn khớp null | Trường không tồn tại hay thật sự null? | Đối chiếu $exists và $type |
| Trang sau bị thiếu hoặc lặp | Sort có khóa duy nhất và dữ liệu có đổi không? | Thêm tiêu chí phụ; đánh giá cách phân trang phù hợp |
Khi review truy vấn, hãy mô tả đầu ra bằng lời, tạo ít nhất một document phải bị loại và đối chiếu cả danh sách _id. Đếm được hai kết quả chưa đủ nếu hai _id đó không đúng. Bộ dữ liệu nhỏ được thiết kế để kiểm tra ý nghĩa, không dùng làm bằng chứng hiệu năng.
10. Bài tập tự luyện và gợi ý lời giải
Bài 1: chỉ lấy đơn confirmed có P001 từ hai sản phẩm
Viết bộ lọc kết hợp trạng thái đơn với điều kiện cùng một dòng hàng. Kết quả phải chỉ có SO002.
db.orders.find(
{
status: "confirmed",
items: { $elemMatch: { product_id: "P001", qty: { $gte: 2 } } }
},
{ _id: 1 }
)
Bài 2: lấy các dòng P001 có số lượng từ ba trở lên trong SO003
Đầu ra cần cả hai dòng, số lượng 4 và 3. Sửa điều kiện $gte thành 3 trong ví dụ $filter ở mục 6.3. Sau đó tăng thành 4: chỉ còn dòng qty bằng 4. Nếu dùng $elemMatch projection, bạn không nhận đầy đủ hai dòng ở lần thử đầu.
Bài 3: tìm đơn có đủ hai nhãn nhưng không phụ thuộc thứ tự
Tìm đơn chứa study và vip. Dùng $all, kết quả SO001 và SO003. Đổi thành so sánh mảng bằng ["study", "vip"] rồi giải thích vì sao SO003 biến mất. Với dữ liệu mẫu, khác biệt nằm ở thứ tự, không phải thiếu nhãn.
Bài 4: phân biệt đủ nhánh khi tiếp trang
Đặt lastCreatedAt thành 02:00 UTC và lastId thành SO002 trong ví dụ tiếp trang. Chạy với nguyên bộ lọc, bạn phải thấy SO001. Thử bỏ nhánh thời điểm bằng nhau: kết quả rỗng. Đây là trường hợp kiểm tra quan trọng khi dữ liệu có nhiều bản ghi cùng timestamp.
Bài 5: lập tiêu chí review truy vấn của chính bạn
Chọn một yêu cầu từ dự án đang học, viết một document chắc chắn khớp, một document chắc chắn không khớp và một trường hợp biên. Với yêu cầu cùng phần tử, trường hợp biên nên có hai điều kiện đúng ở hai phần tử khác nhau. Với phân trang, tạo hai document trùng trường sắp xếp chính. Giải thích trước kết quả, rồi mới chạy truy vấn.
11. Tổng kết và bài tiếp theo
Điểm cần nhớ là phạm vi áp dụng điều kiện. Dot notation giúp đi vào cấu trúc lồng nhau; $elemMatch trong bộ lọc buộc nhiều điều kiện lên cùng phần tử; projection quyết định phần được trả về; $filter dùng khi cần chọn các phần tử theo biểu thức. Với null và trường thiếu, cần xác định đúng trạng thái trước khi viết thống kê.
Trước khi hoàn thành một truy vấn, hãy trả lời bốn câu hỏi: document nào phải được chọn, document nào phải bị loại, mảng đầu ra còn những phần tử nào và thứ tự có rõ ràng không? Đây là cách tự kiểm tra hữu ích hơn việc chỉ nhìn thấy câu lệnh không báo lỗi.
Bài 4: Aggregation Pipeline trong MongoDB, từ lọc đơn đến tách dòng hàng, gom nhóm và tính doanh thu. Chúng ta sẽ tiếp tục dùng tình huống cửa hàng để giải thích từng bước xử lý.
Bạn có thể tham khảo các chương trình đào tạo Data & AI tại TechData.AI nếu muốn học theo lộ trình có nền tảng và dự án thực hành.

Comments are closed!