Cơ sở dữ liệu dạng document hoạt động thế nào?
Xem thửTình huống mở đầu
Bạn mở app bán hàng và bấm vào đơn 1001: một khách tên An ở Hanoi, hai dòng hàng, tổng 1.120.000 đồng. Màn hình hiện ra trong chớp mắt, nhưng phía sau, dữ liệu của đơn này đang nằm ở ba bảng khác nhau. Mỗi lần mở đơn, hệ thống phải ghép lại ba mảnh đó. Một đồng nghiệp hỏi: sao không giữ đơn hàng nguyên một tờ như phiếu giấy, đọc là có đủ?
Câu hỏi của bài này: nếu mỗi đơn hàng là một tờ phiếu nguyên vẹn, ta được gì và mất gì? Hãy dự đoán trước khi đọc tiếp.
Ôn nhanh bài trước: đây là bài đầu tiên của chương, nên mình ôn kiến thức nền: trong cơ sở dữ liệu quan hệ, một dòng (row) thuộc về cái gì? Đáp án: thuộc về một bảng có các cột cố định. Bài này đặt cạnh nó một cách tổ chức khác.
Mục tiêu bài học
Sau bài này bạn có thể:
- Giải thích document, collection và database trong MongoDB bằng đúng một đơn hàng.
- Đọc một document có document lồng (embedded) và mảng, rồi truy vấn vào bên trong nó bằng đường dẫn có dấu chấm.
- Đối chiếu cùng một đơn hàng trong mô hình bảng và mô hình document, nói được mỗi bên đọc bao nhiêu nơi.
- Nêu cái giá của việc nhúng: dữ liệu bị lặp và dễ lệch nhau.
Bức tranh lớn
Xuyên suốt bài, một đơn hàng là một tờ phiếu giấy. Mô hình bảng cắt tờ phiếu thành ba mảnh và cất vào ba ngăn tủ. Mô hình document giữ nguyên tờ phiếu trong một ngăn. Dự án xuyên suốt là cửa hàng shop với collection orders.
Hình dưới trả lời một câu hỏi: cùng một đơn hàng, hai cách lưu khác nhau ở chỗ nào khi đọc?
Đang tải sơ đồ…
Cách đọc hình: ô xanh dương là đơn 1001 trên một tờ phiếu. Nhánh trái (tím) là cách cắt phiếu thành ba bảng, kết quả là phải nối ba bảng khi đọc. Nhánh phải (xanh lá) là cách giữ nguyên phiếu, kết quả là lấy một document. Hai ô cam ở cuối là hệ quả khi đọc, và đó là điểm khác nhau chính.
Chỗ ẩn dụ không còn đúng: tờ phiếu giấy chỉ có chữ, còn document trong MongoDB có kiểu dữ liệu (số, ngày, mảng) và có chỉ mục để tìm nhanh. Phiếu giấy cũng không tự sao chép, nhưng dữ liệu nhúng thì bị lặp thật sự.
Khái niệm cốt lõi
Bài này có ba khái niệm.
1. Document, collection, database
Document là một bản ghi gồm các cặp tên trường và giá trị, giống một object JSON (thực tế được lưu dạng BSON, bài sau sẽ nói). Collection là nhóm các document cùng mảng nghiệp vụ, như orders. Database là nhóm các collection. Nếu so với bảng quan hệ, document gần với dòng, collection gần với bảng, nhưng collection không bắt mọi document có cùng các trường.
2. Nhúng (embed)
Giá trị của một trường có thể là một document khác hoặc một mảng. Đơn hàng giữ khách hàng trong trường customer (document lồng) và các dòng hàng trong items (mảng document). Đây là cách giữ nguyên tờ phiếu.
3. Đường dẫn có dấu chấm
Để lọc theo một trường nằm sâu bên trong, bạn viết đường dẫn trong dấu nháy, như "customer.city". Với mảng, "items.sku" nghĩa là bất kỳ phần tử nào của mảng có sku khớp.
Ví dụ đầu tiên
Trước hết là cách quen thuộc, mô hình bảng, chạy trên MySQL 8.0.44. Ba bảng, một đơn hàng:
CREATE TABLE customers (id INT PRIMARY KEY, name VARCHAR(20), city VARCHAR(20));
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', 'Hanoi');
INSERT INTO orders VALUES (1001, 7, 'PAID');
INSERT INTO order_items VALUES (1001, 'KB-01', 2, 450000), (1001, 'MS-02', 1, 220000);
Để dựng lại đơn 1001, phải nối cả ba bảng:
SELECT o.id, o.status, c.name, c.city, i.sku, i.qty, i.price
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items i ON i.order_id = o.id;
Kết quả [Đã chạy: MySQL 8.0.44]:
+------+--------+------+-------+-------+------+--------+
| id | status | name | city | sku | qty | price |
+------+--------+------+-------+-------+------+--------+
| 1001 | PAID | An | Hanoi | KB-01 | 2 | 450000 |
| 1001 | PAID | An | Hanoi | MS-02 | 1 | 220000 |
+------+--------+------+-------+-------+------+--------+
Hai dòng kết quả, và tên cùng thành phố của khách bị lặp trên cả hai dòng, vì phép nối trả về dạng phẳng. Bây giờ cùng đơn hàng đó trong MongoDB:
db.orders.insertOne({
_id: 1001,
status: "PAID",
customer: { name: "An", city: "Hanoi" }, // thông tin khách nằm ngay trong đơn
items: [ // các dòng hàng là một mảng lồng
{ sku: "KB-01", qty: 2, price: 450000 },
{ sku: "MS-02", qty: 1, price: 220000 }
]
})
Dự đoán trước khi xem: nếu đọc lại bằng findOne({ _id: 1001 }), bạn nhận được mấy kết quả, và customer hiện ra ở đâu?
Kết quả [Đã chạy: MongoDB 4.4.19, mongo shell 4.4.19]:
db.orders.findOne({ _id: 1001 })
{
"_id" : 1001,
"status" : "PAID",
"customer" : {
"name" : "An",
"city" : "Hanoi"
},
"items" : [
{
"sku" : "KB-01",
"qty" : 2,
"price" : 450000
},
{
"sku" : "MS-02",
"qty" : 1,
"price" : 220000
}
]
}
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
db.orders.insertOne({ | Ghi một document vào collection orders. Collection tự được tạo nếu chưa có | Một đơn là một document, không cần tạo bảng trước |
customer: { name: "An", city: "Hanoi" }, | Lưu khách hàng như một document lồng trong trường customer | Dữ liệu khách nằm cùng đơn nên đọc đơn là có khách |
items: [ {...}, {...} ] | Lưu hai dòng hàng như một mảng, mỗi phần tử là một document nhỏ | Các dòng hàng thuộc về đơn này, chúng luôn được đọc cùng nhau |
findOne({ _id: 1001 }) | Trả đúng một document khớp _id | _id là khóa duy nhất của document trong collection |
Hình dưới là cấu trúc của chính document vừa lưu, để bạn thấy nó là một cây chứ không phải một dòng phẳng.
Đang tải sơ đồ…
Cách đọc hình: ô xanh dương là document gốc. Ba ô cyan là ba trường cấp một. Hai ô tím là nội dung bên trong customer và items. Đi từ trên xuống là đi sâu vào trong document, và đường đi đó chính là đường dẫn có dấu chấm: customer.city đi qua ô customer rồi tới city.
Bây giờ lọc theo trường sâu bên trong:
db.orders.find({ "customer.city": "Hanoi" }, { status: 1 })
db.orders.find({ "items.sku": "MS-02" }, { _id: 1 })
{ "_id" : 1001, "status" : "PAID" }
{ "_id" : 1001 }
Dòng đầu chỉ lấy trường status (và _id đi kèm mặc định). Dòng sau tìm bất kỳ đơn nào có phần tử mảng items với sku là MS-02. Cuối cùng, tổng tiền của đơn không nằm sẵn trong document, bạn tính bằng aggregation (bài sau sẽ học kỹ):
db.orders.aggregate([
{ $unwind: "$items" },
{ $group: {
_id: "$_id",
total: { $sum: { $multiply: ["$items.qty", "$items.price"] } }
} }
])
{ "_id" : 1001, "total" : 1120000 }
Kết quả đúng bằng 2 lần 450.000 cộng 1 lần 220.000, tức 1.120.000, khớp với màn hình trong tình huống mở đầu.
Xem từng bước
Animation dưới đây dựng lại đơn 1001 theo hai mô hình. 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, cùng đơn 1001]:
| Việc | Mô hình bảng | Mô hình document |
|---|---|---|
| Nơi phải đọc để có đủ đơn | 3 bảng, nối bằng join | 1 document |
| Khách hàng nằm ở đâu | Bảng riêng, lặp trên mỗi dòng kết quả nối | Nhúng ngay trong đơn |
| Sửa thành phố của khách | Sửa một dòng trong bảng customers | Sửa trong từng đơn đã nhúng |
Bạn vừa làm được: nhìn một đơn hàng và nói chính xác nó được lưu thế nào ở cả hai mô hình, cùng cái được và cái mất.
Concept Check
Đơn 1001 như trên.
Câu 1. Dòng nào lấy được các đơn có khách ở Hanoi?
- A.
db.orders.find({ customer: { city: "Hanoi" } }), vì nó mô tả document lồng. - B.
db.orders.find({ "customer.city": "Hanoi" }), vì đường dẫn đi vào trường sâu. - C.
db.orders.find({ customer.city: "Hanoi" }), vì dấu chấm đủ rõ nghĩa rồi. - D.
db.customers.find({ city: "Hanoi" }), vì khách luôn nằm ở collection riêng.
Câu 2. Trong collection orders, hai document có thể có tập trường khác nhau không?
- A. Không, vì collection bắt mọi document phải có cùng các trường như bảng.
- B. Có, nhưng chỉ khi bạn khai báo trước schema cho từng document.
- C. Có, MongoDB không bắt các document trong collection có cùng trường.
- D. Không, vì trường
_idquy định luôn các trường còn lại của document.
Câu 3. Khi khách An đổi thành phố, vì sao phải cẩn thận nếu họ đã nhúng vào nhiều đơn?
- A. Vì mỗi đơn giữ một bản sao của khách, nên sửa một đơn không sửa các đơn khác.
- B. Vì MongoDB cấm sửa trường nằm trong document lồng sau khi đã ghi.
- C. Vì khi sửa một đơn, MongoDB tự sửa mọi đơn có cùng tên khách.
- D. Vì trường
citylà khóa chính của bảng nên không được thay đổi.
Đáp án và giải thích:
- Câu 1: B. A sai vì nó so khớp cả document lồng, nên chỉ khớp khi
customerbằng đúng{ city: "Hanoi" }(bạn sẽ thấy ở phần Thử phá nó đi). C sai cú pháp vì tên trường có dấu chấm phải đặt trong nháy. D sai vì ví dụ này không có collectioncustomers. - Câu 2: C. A và D mô tả bảng quan hệ, không phải collection. B sai vì không cần khai báo trước, validation chỉ là tùy chọn.
- Câu 3: A. B sai vì sửa trường lồng là việc bình thường. C sai vì không có liên kết ngầm giữa các bản sao. D sai vì collection này không có khái niệm khóa chính ngoài
_id.
Làm cùng mình
Kho đơn của shop đang có đơn 1001. Hôm nay có thêm đơn 1002 của khách Binh ở Saigon, mua 1 tai nghe HD-03 giá 780.000.
Bước 1. Vẽ ra giấy
Vẽ cây của document 1002 như hình cây ở trên: ô gốc, ba trường, các ô bên trong.
Bước 2. Điền chỗ trống
db.orders.insertOne({
_id: 1002,
status: "NEW",
// TODO 1: thêm trường customer là document lồng gồm name và city
// TODO 2: thêm trường items là mảng chứa một dòng hàng
})
db.orders.countDocuments({})
Gợi ý: nhìn lại document 1001 trong phần Ví dụ đầu tiên, giữ đúng tên trường để truy vấn dùng chung được.
Chạy xong bạn sẽ thấy [Đã chạy] số document trong collection tăng lên 2. Đối chiếu với Lời giải và đáp án.
Bước 3. Dự đoán rồi thử
Hai đơn đã có. Dự đoán trước: đếm đơn có "customer.city": "Hanoi" ra mấy? Sau đó chạy bằng countDocuments.
Bước 4. Vẽ lại
Ghi một câu: nếu cần tổng tiền theo từng khách, bạn lấy dữ liệu từ đâu trong mỗi document?
Thử phá nó đi
Ba bẫy thật khi mới chuyển từ bảng sang document [Đã chạy: MongoDB 4.4.19]:
| Bẫy | Điều xảy ra | Nguyên nhân | Cách tránh |
|---|---|---|---|
{ customer: { city: "Hanoi" } } | Không khớp đơn nào dù có khách ở Hanoi | So khớp nguyên cả document lồng, mà customer còn có name | Dùng đường dẫn "customer.city" |
| Hai đơn có dạng khác nhau | Vẫn ghi được, customer thành chuỗi ở đơn này và object ở đơn kia | Collection không ép schema | Quy ước tên và dạng trường, bật validation khi cần |
Sửa customer.city ở một đơn | Các đơn khác của cùng khách vẫn giữ thành phố cũ | Nhúng nghĩa là mỗi đơn giữ một bản sao | Tính trước trường nào cần sửa đồng bộ |
Bẫy thứ nhất:
print("exact subdocument:", db.orders.countDocuments({ customer: { city: "Hanoi" } }))
print("dotted path:", db.orders.countDocuments({ "customer.city": "Hanoi" }))
exact subdocument: 0
dotted path: 1
Bẫy thứ hai, chèn đơn 1002 có customer là một chuỗi:
db.orders.insertOne({ _id: 1002, status: "NEW", customer: "walk-in", note: "pay at counter" })
db.orders.find({}, { status: 1, customer: 1, note: 1 })
{ "acknowledged" : true, "insertedId" : 1002 }
{ "_id" : 1001, "status" : "PAID", "customer" : { "name" : "An", "city" : "Hanoi" } }
{ "_id" : 1002, "status" : "NEW", "customer" : "walk-in", "note" : "pay at counter" }
Bẫy thứ ba, An có hai đơn, chỉ sửa đơn 1001:
db.orders.insertOne({ _id: 1002, status: "NEW", customer: { name: "An", city: "Hanoi" }, items: [] })
db.orders.updateOne({ _id: 1001 }, { $set: { "customer.city": "Saigon" } })
db.orders.find({}, { "customer.city": 1 })
{ "acknowledged" : true, "insertedId" : 1002 }
{ "acknowledged" : true, "matchedCount" : 1, "modifiedCount" : 1 }
{ "_id" : 1001, "customer" : { "city" : "Saigon" } }
{ "_id" : 1002, "customer" : { "city" : "Hanoi" } }
Đơn 1001 đã là Saigon nhưng đơn 1002 vẫn là Hanoi. Hình dưới đối chiếu niềm tin sai với thực tế của lỗi này.
Đang tải sơ đồ…
Cách đọc hình: hai chuỗi độc lập. Chuỗi đỏ là niềm tin sai: sửa một chỗ thì khách đổi ở mọi nơi. Chuỗi xanh là thực tế: mỗi đơn là một bản sao riêng, nên phải sửa từng đơn hoặc chấp nhận bản sao cũ.
Tự tay làm
Dùng chung collection orders và dữ liệu đơn 1001.
Cơ bản
Bài 1. Viết document. Đầu vào: đơn 1003 của khách Chi ở Hue, mua 3 sản phẩm CB-04 giá 35.000. Viết lệnh insertOne đầy đủ rồi đếm collection. Tiêu chí đạt: collection có đúng số document bạn dự đoán. Gợi ý: giữ cùng tên trường với 1001.
Vận dụng
Bài 2. Tổng chi theo khách. Đầu vào: collection có đơn 1001 (An, Hanoi) và 1002 (Binh, Saigon). Tính tổng tiền đã chi của các khách ở Hanoi. Tiêu chí đạt: kết quả đúng 1.120.000 cho An. Gợi ý: lọc "customer.city" trước rồi tách mảng items bằng $unwind.
Thử thách (tùy chọn)
Bài 3. Đổi thành phố đồng bộ. Đầu vào: An có hai đơn là 1001 và 1002, cả hai ở Hanoi. An chuyển đến Saigon. Đồng bộ cả hai đơn bằng một lệnh. Tiêu chí đạt: cả hai đơn có customer.city là Saigon. Gợi ý: updateMany lọc theo "customer.name", bài ở chương 43 sẽ học kỹ hơn.
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) Document, collection và database gần với khái niệm nào của mô hình bảng?
- (Mục tiêu 2, dự đoán) Với đơn 1001,
countDocuments({ customer: { city: "Hanoi" } })ra bao nhiêu, và vì sao? - (Mục tiêu 3, giải thích) Vì sao đọc một đơn trong mô hình document thường ít việc hơn mô hình bảng?
- (Mục tiêu 4, tình huống) Khách đổi thành phố và đã nhúng ở 40 đơn. Nêu hai cách xử lý.
- (Mục tiêu 2, tìm lỗi) Lệnh
db.orders.find({ customer.city: "Hanoi" })sai ở đâu?
Điều cần nhớ
- Document là bản ghi dạng cặp trường và giá trị, collection gom các document, database gom các collection.
- Một document có thể chứa document lồng và mảng, nên giữ nguyên được cả một đơn hàng.
- Truy vấn vào trong dùng đường dẫn có dấu chấm đặt trong nháy; so khớp cả document lồng đòi khớp từng trường.
- Nhúng làm việc đọc nhanh gọn nhưng làm dữ liệu bị lặp và có thể lệch.
Cheat sheet:
| Muốn | Viết | Ghi chú |
|---|---|---|
| Ghi một document | db.orders.insertOne({ ... }) | Collection tự tạo khi ghi lần đầu |
| Lấy một document | db.orders.findOne({ _id: 1001 }) | Trả document hoặc null |
| Lọc theo trường lồng | find({ "customer.city": "Hanoi" }) | Tên có dấu chấm phải đặt trong nháy |
| Lọc theo phần tử mảng | find({ "items.sku": "MS-02" }) | Khớp nếu bất kỳ phần tử nào khớp |
Hình tóm tắt của bài là hình cây document ở mục Ví dụ đầu tiên.
Câu hỏi để dành cho ngày mai: khi nào nên tách khách hàng ra collection riêng thay vì nhúng, và chuyện gì xảy ra với những đơn cũ?
Đào sâu
Phần này dành cho bạn tò mò.
- Cơ chế bên trong. MongoDB lưu document dạng BSON, một định dạng nhị phân mở rộng JSON thêm nhiều kiểu như ngày và số nguyên 64 bit. Một document bị giới hạn kích thước 16 MiB theo tài liệu chính thức, vì vậy mảng nhúng không nên lớn vô hạn.
- So sánh với lựa chọn khác. Thay vì nhúng khách, bạn có thể lưu
customerIdtrong đơn và đọc khách ở collection riêng khi cần, gọi là tham chiếu (reference). Cái giá là hai lần đọc. - Hiểu lầm nâng cao. Không có schema bắt buộc không có nghĩa là không cần thiết kế. Nếu mỗi nơi ghi
customermột kiểu, mọi truy vấn sau này sẽ gãy. - Giới hạn của bài. Bài này chưa nói về kiểu dữ liệu BSON, chỉ mục, hay cách thiết kế khi nào nhúng, khi nào tham chiếu.
- Đọc thêm. MongoDB Manual 4.4, mục Documents: https://www.mongodb.com/docs/v4.4/core/document/
Bài tiếp theo
Bài sau là Cách MongoDB hoạt động: khi bạn gửi một lệnh find, tiến trình mongod làm gì để trả về document. Mini-dự án nối tiếp: đưa 1.000 đơn vào shop rồi xem server tìm một đơn thế nào.
Lời giải và đáp án
Làm cùng mình, Bước 2. Hai chỗ cần điền:
customer: { name: "Binh", city: "Saigon" },
items: [{ sku: "HD-03", qty: 1, price: 780000 }]
db.orders.insertOne({
_id: 1002,
status: "NEW",
customer: { name: "Binh", city: "Saigon" },
items: [{ sku: "HD-03", qty: 1, price: 780000 }]
})
db.orders.countDocuments({})
{ "acknowledged" : true, "insertedId" : 1002 }
2
Bước 3: chỉ đơn 1001 có khách ở Hanoi nên đếm ra 1. Bước 4: lấy từ mảng items, nhân qty với price rồi cộng lại, như đã làm với $unwind và $group.
Bài 1. Tương tự đơn 1002 với _id: 1003, customer: { name: "Chi", city: "Hue" } và items: [{ sku: "CB-04", qty: 3, price: 35000 }]. Đếm collection ra 2 nếu bắt đầu từ chỉ đơn 1001 (hoặc 3 nếu đã có đơn 1002).
Bài 2.
db.orders.aggregate([
{ $match: { "customer.city": "Hanoi" } },
{ $unwind: "$items" },
{ $group: { _id: "$customer.name", spent: { $sum: { $multiply: ["$items.qty", "$items.price"] } } } }
])
{ "_id" : "An", "spent" : 1120000 }
| Dòng code | Máy làm gì | Vì sao viết như vậy |
|---|---|---|
{ $match: { "customer.city": "Hanoi" } } | Giữ lại các đơn của khách ở Hanoi | Lọc sớm để các bước sau xử lý ít document hơn |
{ $unwind: "$items" } | Tách mỗi phần tử mảng thành một document riêng | Để nhân qty với price cho từng dòng hàng |
$group: { _id: "$customer.name", ... } | Gom theo tên khách và cộng tiền | Kết quả một dòng cho mỗi khách |
Bài 3.
db.orders.updateMany({ "customer.name": "An" }, { $set: { "customer.city": "Saigon" } })
db.orders.find({}, { "customer.city": 1 })
{ "acknowledged" : true, "matchedCount" : 2, "modifiedCount" : 2 }
{ "_id" : 1001, "customer" : { "city" : "Saigon" } }
{ "_id" : 1002, "customer" : { "city" : "Saigon" } }
updateMany sửa cả hai đơn của An trong một lệnh, nên không còn lệch như ở phần Thử phá nó đi. Chi phí là phải nhớ mọi nơi đã nhúng khách.
Tự kiểm tra.
- Document gần với dòng, collection gần với bảng, database gần với database (hoặc schema).
- Ra 0, vì đó là so khớp cả document lồng, mà
customercòn có trườngnamenên không bằng{ city: "Hanoi" }. - Vì mọi thứ màn hình cần đã nằm trong một document, không phải nối nhiều bảng.
- Cập nhật cả 40 đơn bằng một lệnh
updateMany, hoặc không nhúng thành phố mà tham chiếu tới collection khách riêng. - Tên trường có dấu chấm phải đặt trong nháy:
"customer.city".