Data Modeling: embed hay reference? Thiết kế từ cách dữ liệu được đọc

29 phút đọcSeries: MongoDB: từ gốc đến internals

Bạn đang ở đâu trong series

  • Cần biết trước: bài 01 (đường đi của một query, COLLSCAN là mặc định), bài 02 (document lồng nhau, mảng, single-document atomicity, giới hạn 16 MB) và bài 03 (BSON: kiểu dữ liệu, kích thước, ObjectId 12 byte).
  • Bài này giới thiệu: thiết kế theo access pattern, embed và reference, relationship cardinality (1-1, 1-few, 1-many, 1-squillions, many-many), denormalization và giá của nó khi update, anti-pattern mảng phình không giới hạn, chi phí $lookup, và so sánh với PostgreSQL trên cùng một hệ thống đơn hàng.
  • Dẫn tới: bài 05 (Schema Design Patterns & Schema Evolution): đặt tên cho các hình dạng document hay gặp, thay đổi schema khi hệ thống đang chạy, và document to thì tốn gì.

Bài 02 trả lời câu hỏi "document là gì". Bài này trả lời câu hỏi khó hơn: nên nhét cái gì vào cùng một document, và cái gì nên để riêng?

Vì sao chuyện này quan trọng

Hãy tưởng tượng bạn đang xây backend cho một sàn bán hàng nhiều cửa hàng (multi-tenant). Màn hình được mở nhiều nhất là chi tiết đơn hàng: mã đơn, tên và số điện thoại người mua, 5 dòng hàng, tổng tiền.

Nếu bạn đến từ SQL, phản xạ đầu tiên là vẽ bốn bảng customers, orders, order_items, products rồi JOIN. Mang nguyên phản xạ đó sang MongoDB thì bạn sẽ có bốn collection và một pipeline $lookup. Nó chạy được. Câu hỏi là nó có hợp với cách ứng dụng của bạn đọc và ghi dữ liệu không.

Thiết kế schema trong MongoDB quyết định ba thứ mà về sau rất khó sửa:

  • mỗi màn hình phải đọc bao nhiêu document (1 hay 9?),
  • một lần sửa dữ liệu phải ghi vào bao nhiêu chỗ (1 hay hàng nghìn?),
  • thao tác nào được atomic miễn phí, thao tác nào phải cần transaction.

Index (bài 07) có thể làm một thiết kế tệ chạy nhanh hơn. Nhưng nó không biến 9 lần đọc thành 1 lần đọc. Đó là việc của data modeling.

Mental model: tờ hóa đơn siêu thị

Bạn mua đồ ở siêu thị. Tờ hóa đơn in ra có:

  • tên siêu thị, ngày giờ,
  • tên thẻ thành viên của bạn (không in cả hồ sơ khách hàng),
  • từng món: tên hàng, số lượng, giá tại thời điểm mua,
  • tổng tiền.

Tờ hóa đơn không ghi "món số 1: xem sản phẩm #456 trong catalog". Nó chép tên và giá vào luôn. Tuần sau giá tăng, không ai đi in lại hóa đơn cũ. Tờ hóa đơn đúng vì nó là bản chụp.

Ngược lại, hồ sơ thẻ thành viên không kẹp toàn bộ hóa đơn bạn từng mua. Sau 5 năm, xấp giấy đó sẽ dày hơn cả cuốn danh bạ. Hồ sơ chỉ giữ thông tin của bạn. Muốn xem lịch sử, nhân viên tra theo số thẻ.

Ánh xạ sang MongoDB:

Tờ hóa đơn                         = document Order
Món hàng in sẵn trên hóa đơn       = embed (mảng items nằm trong order)
Tên thẻ thành viên in trên bill    = reference kèm bản chép vài field
Giá tại thời điểm mua              = denormalization có chủ đích (snapshot)
Tra lịch sử theo số thẻ            = reference (order giữ customerId)
Không kẹp mọi bill vào hồ sơ       = tránh mảng phình không giới hạn

Đây chỉ là cách hình dung. Hóa đơn giấy không bao giờ phải cập nhật, còn document thì có. Phần lớn bài này nói về chính cái giá cập nhật đó.

Bắt đầu từ access pattern, không từ thực thể

30 giây

Trong SQL, bạn thường mô hình hóa thực thể trước (khách, đơn, sản phẩm), chuẩn hóa cho hết trùng lặp, rồi mới viết query. Trong MongoDB, bạn làm ngược lại: liệt kê ứng dụng đọc và ghi gì, bao lâu một lần, cùng với cái gì, rồi tạo hình document cho khớp. Tài liệu MongoDB tóm lại thành một câu: dữ liệu được truy cập cùng nhau thì nên được lưu cùng nhau.

Access pattern đơn giản là "một kiểu thao tác mà ứng dụng thực hiện với dữ liệu": màn hình nào, API nào, đọc hay ghi, lọc theo gì, cần những field nào.

Liệt kê access pattern cho hệ thống đơn hàng

Tần suất dưới đây là minh họa, bạn cần thay bằng số thật của hệ thống mình:

#Thao tácTần suất (minh họa)Đọc/ghiCần gì cùng lúc
1Xem chi tiết đơn~10.000 lần/phútđọcđơn + dòng hàng + tên/SĐT người mua
2Tạo đơn~500 lần/phútghiđơn + dòng hàng, phải trọn vẹn
3Danh sách đơn của một khách~1.000 lần/phútđọcheader đơn, tổng tiền
4Khách đổi tên / SĐTvài lần/ngàyghihồ sơ khách
5Báo cáo "top sản phẩm tháng"1 lần/giờ, chạy nềnđọcdòng hàng của mọi đơn

Từ bảng này, các quyết định gần như tự hiện ra:

  • Pattern 1 và 2 chiếm gần hết tải, và cả hai đều đụng "đơn + dòng hàng" như một khối. Đó là lý do mạnh để embed items vào order.
  • Pattern 1 cần tên và SĐT người mua, nhưng không cần email, địa chỉ nhà hay lịch sử điểm thưởng. Vậy chỉ copy đúng hai field đó vào order.
  • Pattern 4 hiếm. Cái giá phải sửa nhiều chỗ khi khách đổi tên được trả không thường xuyên.
  • Pattern 5 đọc dữ liệu theo trục khác (theo sản phẩm, không theo đơn). Nó hiếm và chạy nền, nên chấp nhận chạy chậm hơn, hoặc tính sẵn con số cần báo cáo mỗi khi có đơn mới (cách làm này có tên riêng, bài 05 sẽ nói).
            "Ai đọc dữ liệu này, bao lâu một lần, cùng với cái gì?"
                                   │
              ┌────────────────────┴─────────────────────┐
              ▼                                          ▼
  Đọc cùng nhau, số lượng có giới hạn,       Đọc riêng, số lượng lớn/không giới hạn,
  cùng vòng đời với cha                      hoặc được sửa độc lập, nhiều nơi dùng chung
              │                                          │
              ▼                                          ▼
            EMBED                                    REFERENCE
  ✓ 1 document, 1 lần đọc                    ✓ một nguồn sự thật, sửa 1 chỗ
  ✓ ghi atomic không cần transaction         ✓ document nhỏ, không phình
  ✗ document to ra, có thể trùng dữ liệu     ✗ đọc thêm document ($lookup / query 2)
                                             ✗ ghi nhiều document thì không atomic

Embed và reference

Embed: nhét con vào trong cha

// orders — mô hình A (embed)
{
  _id: 4242,
  tenantId: "t43",
  customer: { _id: 6393, name: "Nguyễn Hà", phone: "0910006393" }, // copy 2 field
  status: "PAID",
  createdAt: ISODate("2026-01-03T22:42:00Z"),
  items: [
    { productId: 1951, name: "Sản phẩm 1951", qty: 3, price: 110000 },
    { productId: 1870, name: "Sản phẩm 1870", qty: 3, price: 40000 }
    // ... thêm 5 dòng nữa
  ],
  total: 2010000
}

Đọc chi tiết đơn là đọc một document qua index _id. Tạo đơn là một lệnh insert. Như bài 02 đã nói, ghi một document là atomic. Vì vậy không thể xảy ra chuyện đơn được tạo mà thiếu một nửa số dòng hàng.

Reference: con đứng riêng, giữ "số thẻ" của cha

// orders — mô hình B (reference)
{ _id: 4242, tenantId: "t43", customerId: 6393, status: "PAID", createdAt: ISODate("...") }

// order_items — mỗi dòng hàng là một document
{ _id: ObjectId("..."), orderId: 4242, tenantId: "t43", productId: 1951, name: "Sản phẩm 1951", qty: 3, price: 110000 }

Muốn hiển thị đơn, bạn ghép lại bằng $lookup (join phía server) hoặc bằng hai query từ ứng dụng. Muốn tạo đơn, bạn ghi 1 + N document. Nếu cần "tất cả hoặc không gì cả" thì phải dùng multi-document transaction (bài 16). Bản thân tài liệu MongoDB cũng nhắc rằng transaction thường tốn hơn ghi một document, và không nên dùng nó thay cho thiết kế schema tốt.

Giá của từng lựa chọn

EMBED                                  REFERENCE
  ├── ✓ đọc 1 document                   ├── ✓ dữ liệu chung chỉ nằm 1 chỗ
  ├── ✓ ghi atomic miễn phí              ├── ✓ con có thể sống độc lập, query riêng
  ├── ✗ document lớn hơn                 ├── ✓ document cha nhỏ, ổn định
  ├── ✗ dữ liệu copy phải được đồng bộ   ├── ✗ mỗi lần đọc phải ghép nhiều document
  └── ✗ không hợp khi con tăng mãi       └── ✗ cần index ở phía được join

Tài liệu MongoDB liệt kê các tín hiệu nên reference: phía con có cardinality cao, dữ liệu nhúng tăng không giới hạn, con được ghi vào những thời điểm khác nhau trong một workload ghi nhiều, hoặc con có thể tồn tại mà không cần cha. Mấy chữ "cardinality cao" và "tăng không giới hạn" cần được nói rõ hơn. Đó là phần tiếp theo.

Relationship cardinality: đếm "bao nhiêu" trước khi chọn

Relationship cardinality (cardinality của một quan hệ) là "một phần tử bên này ứng với bao nhiêu phần tử bên kia". Chữ cardinality còn được dùng theo nghĩa khác: ở bài 14, query cardinality là số document đi qua một stage của query, thứ mà planner phải ước lượng. Hai nghĩa không liên quan nhau; trong bài này, cardinality luôn là cardinality của quan hệ.

Với quan hệ, "one-to-many" trong SQL là một khái niệm quá rộng. Trong MongoDB, nhiều là 5 hay 5 triệu sẽ cho ra hai thiết kế khác hẳn nhau. Cộng đồng MongoDB (bài "6 Rules of Thumb" trên blog MongoDB) chia nhỏ ra như sau:

Quan hệVí dụ trong hệ thống đơn hàngThường chọnLý do
1-1đơn ↔ địa chỉ giao hàngembedluôn đọc cùng nhau, cùng vòng đời
1-fewđơn ↔ dòng hàng (thường dưới vài chục)embed mảngcó giới hạn, đọc và ghi cùng đơn
1-manysản phẩm ↔ review (hàng trăm đến hàng nghìn)reference, có thể giữ vài review mới nhất trong sản phẩmreview được phân trang, viết vào lúc khác
1-squillionskhách ↔ toàn bộ đơn trong 5 năm; thiết bị ↔ logreference, đặt ở phía "nhiều"không có trần, mảng sẽ vỡ
many-manyđơn ↔ sản phẩm; sản phẩm ↔ danh mụcmảng id ở phía nhỏ và có giới hạn, hoặc copy snapshotphải chọn phía nào giữ liên kết

Vài điểm đáng chú ý:

1-1 không phải lúc nào cũng embed. Nếu một phần của document rất lớn mà hiếm khi đọc (ví dụ mô tả sản phẩm dài vài chục KB), tách nó ra giúp document chính nhỏ lại: giữ phần hay đọc trong document chính, phần ít đọc để riêng. Bài 05 đặt tên và đo cái lợi của cách tách này.

Với 1-squillions, reference phải đặt ở phía "nhiều". Đừng đặt orderIds: [...] trong customer. Hãy đặt customerId trong mỗi order. Tài liệu MongoDB nói đúng điều này với ví dụ nhà xuất bản và sách: nếu số sách của một nhà xuất bản không có giới hạn, giữ mảng id sách trong nhà xuất bản sẽ tạo ra "mutable, growing arrays". Hãy để mỗi cuốn sách giữ publisher_id.

Many-many giữa đơn và sản phẩm là ví dụ thú vị nhất. Trong SQL, order_items chính là bảng nối của quan hệ many-many này. Trong MongoDB, mỗi dòng hàng nhúng trong order giữ productId (reference) cộng một bản chụp tên và giá (copy). Cái bạn embed không phải "sản phẩm" mà là "sản phẩm như lúc được bán". Bản chụp đó không cần đồng bộ với catalog, giống tờ hóa đơn siêu thị.

Anti-pattern: mảng phình không giới hạn

Giả sử bạn nghĩ "khách có nhiều đơn, vậy cho customer một mảng orderIds". Mỗi đơn mới là một lần $push. Mảng này không có trần.

Bài 02 đã chạy thí nghiệm này với mảng comments của một bài viết: kích thước tăng tuyến tính theo số phần tử, và khi chạm 16 MB thì lệnh ghi bị từ chối. Mảng orderIds đi theo đúng con đường đó. Như bài 03 đã cho thấy, mảng trong BSON là một document có key là chỉ số dạng chuỗi ("0", "1", …), nên mỗi ObjectId trong mảng tốn khoảng 19–20 byte chứ không phải 12. Đo bằng bsonsize() trong mongosh trên lab của bài này: 1.000 phần tử khoảng 0,02 MB, 100.000 phần tử khoảng 1,8 MB, 1 triệu phần tử khoảng 19 MB, nghĩa là trần 16 MB bị vượt trước khi tới mốc 1 triệu. Khi chạm trần, mọi lệnh $push tiếp theo đều thất bại, và bạn phải migrate schema dưới áp lực production.

Nhưng 16 MB chỉ là bức tường cuối cùng. Tài liệu MongoDB nói mảng không giới hạn còn "làm căng tài nguyên ứng dụng và giảm hiệu năng index" từ rất lâu trước đó:

  • Mỗi lần đọc customer (dù chỉ để lấy tên), server vẫn phải đọc cả document từ storage. Projection chỉ cắt bớt phần gửi qua mạng, không cắt phần phải đọc (trừ trường hợp covered query, nói ở bài 08).
  • Nếu bạn đánh index trên field mảng, đó là multikey index: một index key cho mỗi phần tử. Bài 09 sẽ đi sâu vào loại index này.
  • Document lớn chiếm nhiều chỗ trong WiredTiger cache. Phần cache đó lẽ ra dành cho dữ liệu nóng khác (bài 21 nói về working set).

Cách sửa: đặt reference ở phía "nhiều". Bỏ orderIds khỏi customer, để mỗi order giữ customerId, và đánh index trên customerId để lấy danh sách đơn của một khách. Mỗi đơn mới là một insert vào orders; document customer không lớn thêm byte nào. Nếu thật sự cần vài đơn gần nhất ngay trong customer, hãy giữ một mảng có trần (ví dụ 10 phần tử, cắt bằng $slice mỗi lần $push) bên cạnh collection orders đầy đủ. Bài 05 sẽ gọi tên cách làm này và tính cái giá ghi hai chỗ của nó.

Denormalization và cái giá khi update

30 giây

Normalization (chuẩn hóa) nghĩa là mỗi sự thật chỉ nằm một chỗ. Denormalization nghĩa là cố ý chép một sự thật ra nhiều chỗ để đọc nhanh hơn. Đọc được lợi, còn ghi phải trả giá: khi sự thật thay đổi, bạn phải đi sửa mọi bản chép.

Khách 6393 đổi tên

NORMALIZED (mô hình B)            DENORMALIZED (mô hình A)
customers[6393].name = "..."      customers[6393].name = "..."
        │                                 │
        ▼                                 ▼
   1 document                      + mọi order có customer._id = 6393
                                     (10 đơn? 10.000 đơn?)

Không phải bản chép nào cũng cần đồng bộ

Tài liệu MongoDB chia dữ liệu bị chép thành vài loại, và đây là phần nhiều người bỏ qua:

  • Immutable (không đổi): productId, ngày tạo khách. Chép thoải mái.
  • Temporal (giá trị theo thời gian, lịch sử có ý nghĩa): giá sản phẩm lúc bán, địa chỉ giao của đơn đó. Bạn không được cập nhật. Sửa giá trong đơn cũ là làm sai sổ sách.
  • Sensitive to staleness (cũ một chút là sai): phải cập nhật ngay, có thể trong transaction.
  • Not sensitive to staleness (cũ vài phút cũng không sao): cập nhật bằng background job.

Tên người mua in trên đơn thuộc loại nào? Đó là câu hỏi nghiệp vụ, không phải câu hỏi kỹ thuật. Nhiều hệ thống coi tên và SĐT trên đơn là thông tin giao hàng tại thời điểm đặt, tức là temporal, và không bao giờ sửa. Nếu đơn vị vận hành muốn tên mới hiển thị trên đơn cũ, đó là loại "chấp nhận cũ" và có thể đồng bộ dần.

Hai cái bẫy khi đồng bộ bản chép

Bẫy 1: không có index trên field dùng để tìm bản chép. updateMany({"customer._id": 6393}, ...) mà không có index thì phải quét toàn bộ collection orders để tìm khoảng 10 đơn. Phần thí nghiệm bên dưới đo đúng chuyện này.

Bẫy 2: updateMany không atomic trên toàn bộ. Tài liệu MongoDB ghi rõ: mỗi document được ghi atomic, nhưng updateMany() nói chung thì không. Nếu lỗi giữa chừng, các document đã sửa vẫn giữ nguyên, phần còn lại chưa được sửa. Vì vậy lệnh đồng bộ nên idempotent (chạy lại không gây hại, ví dụ $set tên mới) để có thể retry an toàn.

Quy tắc ngón tay cái từ blog MongoDB: hãy xét tỉ lệ đọc/ghi. Field được đọc nhiều mà hiếm khi sửa là ứng viên tốt để denormalize. Field bị sửa liên tục thì công đi tìm và sửa mọi bản chép sẽ nuốt mất cái lợi khi đọc.

Cái giá của $lookup

30 giây

$lookup là một stage trong aggregation pipeline. Với mỗi document đi vào, nó tìm các document khớp ở collection khác và gắn chúng vào thành một mảng mới. Nó giống LEFT OUTER JOIN, nhưng chạy từng document một.

Mental model: đi lấy hàng trong kho

Bạn là nhân viên soạn đơn. Mỗi đơn cần 5 món nằm ở kho bên cạnh.

  • Embed: món hàng đã được đóng sẵn trong thùng của đơn. Bạn nhấc thùng lên là xong.
  • Reference có sổ tra (index): bạn mở sổ tra "đơn 4242 → kệ B3, ô 1–5", đi thẳng đến đó lấy.
  • Reference không có sổ tra: bạn đi dọc mọi kệ trong kho, nhìn từng món xem có ghi "đơn 4242" không. Rồi làm lại y như vậy cho đơn tiếp theo.
for each order đi vào $lookup:
    tìm order_items có orderId = order._id
        có index  → seek thẳng vào vài key
        không có  → quét cả collection
    gắn kết quả vào order.items (mảng)

Tài liệu $lookup nói thẳng: phép so khớp bằng nhau với một điều kiện join chạy tốt hơn khi collection bị join có index trên foreignField. Nếu không có index đó, $lookup "nhiều khả năng có hiệu năng kém". Trang anti-pattern "Reduce $lookup Operations" bổ sung: $lookup hữu ích khi dùng thỉnh thoảng, nhưng có thể chậm và tốn tài nguyên hơn so với thao tác chỉ đụng một collection.

Câu hỏi hay hơn là: chậm hơn bao nhiêu, và vì sao? Hãy đo.

Thí nghiệm: một màn hình, hai mô hình

Setup

MongoDB version : 8.3.11 (image mongo:8), standalone
Hardware        : Docker trên Apple M4, giới hạn 4 CPU, 4 GB RAM
Configuration   : WiredTiger cache 1 GB
Database        : lab03
Client          : mongosh chạy trong cùng container (không có network thật)

Toàn bộ dữ liệu và index (khoảng 125 MB chưa nén) nằm gọn trong cache 1 GB sau lần đọc đầu tiên. Nghĩa là đây là phép đo khi dữ liệu nóng. Khi dữ liệu không vừa cache, mỗi document phải đọc thêm có thể đồng nghĩa với một lần đọc đĩa, và khoảng cách giữa hai mô hình thường lớn hơn. Tôi không đo trường hợp đó ở đây.

Dataset

50 tenant, 10.000 khách, 2.000 sản phẩm, 100.000 đơn. Mỗi đơn có 1–9 dòng hàng (phân bố đều, trung bình 5). Cùng một dữ liệu được ghi theo hai mô hình:

  • A (embed): orders_emb. Mỗi đơn là một document chứa items[], một bản chép customer {_id, name, phone} và total tính sẵn.
  • B (reference): orders_ref (header đơn có customerId) cộng order_items (mỗi dòng hàng một document có orderId) cộng customers.

Script tạo dữ liệu dùng PRNG có seed cố định, nên chạy lại sẽ ra đúng bộ dữ liệu này:

// seed.js — chạy: docker exec -i mongo-lab mongosh --quiet --eval "$(cat seed.js)"
db = db.getSiblingDB("lab03");
db.dropDatabase();

let seed = 42;                       // mulberry32: PRNG có seed cố định
function rnd() {
  seed = (seed + 0x6D2B79F5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
const pick = n => Math.floor(rnd() * n);
const ho = ["Nguyễn", "Trần", "Lê", "Phạm", "Hoàng", "Võ", "Đặng", "Bùi"];
const ten = ["An", "Bình", "Chi", "Dũng", "Hà", "Khoa", "Linh", "Minh", "Nam", "Thảo"];

const customers = [];
for (let c = 1; c <= 10000; c++) customers.push({
  _id: c, tenantId: "t" + String(c % 50).padStart(2, "0"),
  name: ho[pick(8)] + " " + ten[pick(10)], email: "user" + c + "@example.com",
  phone: "09" + String(10000000 + c), address: { street: c + " Lê Lợi", city: "TP.HCM" }
});
db.customers.insertMany(customers);

const products = [];
for (let p = 1; p <= 2000; p++) products.push({ _id: p, name: "Sản phẩm " + p, price: (1 + pick(50)) * 10000 });

const t0 = new Date("2026-01-01T00:00:00Z").getTime();
let emb = [], ref = [], items = [];
for (let o = 1; o <= 100000; o++) {
  const cust = customers[pick(10000)], lines = [];
  for (let i = 0, n = 1 + pick(9); i < n; i++) {
    const p = products[pick(2000)];
    lines.push({ productId: p._id, name: p.name, qty: 1 + pick(3), price: p.price });
  }
  const total = lines.reduce((s, l) => s + l.qty * l.price, 0);
  const createdAt = new Date(t0 + o * 60000), status = ["PAID", "SHIPPED", "DELIVERED"][pick(3)];
  emb.push({ _id: o, tenantId: cust.tenantId,
             customer: { _id: cust._id, name: cust.name, phone: cust.phone },
             status, createdAt, items: lines, total });
  ref.push({ _id: o, tenantId: cust.tenantId, customerId: cust._id, status, createdAt });
  for (const l of lines) items.push({ orderId: o, tenantId: cust.tenantId, ...l });
  if (emb.length === 5000) {
    db.orders_emb.insertMany(emb); db.orders_ref.insertMany(ref); db.order_items.insertMany(items);
    emb = []; ref = []; items = [];
  }
}

Kết quả sau khi seed (db.collection.stats()):

customers    count:   10000  avgObjSize:   164 B  size: 1.6 MB
orders_emb   count:  100000  avgObjSize:   511 B  size: 48.8 MB
orders_ref   count:  100000  avgObjSize:    86 B  size: 8.3 MB
order_items  count:  497736  avgObjSize:   115 B  size: 54.8 MB

Phân bố thực tế: mỗi khách có 1–26 đơn (trung bình 10), mỗi tenant có 1.888–2.101 đơn.

Experiment 1: xem chi tiết đơn 4242

Mọi query đều mang tenantId, như một ứng dụng multi-tenant thật phải làm, để tenant này không bao giờ đọc được đơn của tenant khác.

// A: embed
db.orders_emb.findOne({ _id: 4242, tenantId: "t43" })

// B: reference + 2 lần $lookup
db.orders_ref.aggregate([
  { $match: { _id: 4242, tenantId: "t43" } },
  { $lookup: { from: "order_items", localField: "_id",        foreignField: "orderId", as: "items" } },
  { $lookup: { from: "customers",   localField: "customerId", foreignField: "_id",     as: "customer" } }
])

Cả hai đều trả về cùng một đơn với 7 dòng hàng. Xem explain("executionStats") (đã rút gọn: in cây stage và các con số chính):

--- A. embed: find
FETCH
  IXSCAN _id_
{ nReturned: 1, totalKeysExamined: 1, totalDocsExamined: 1, executionTimeMillis: 0 }
--- B. reference: aggregate + 2 x $lookup   (order_items CHƯA có index trên orderId)
EQ_LOOKUP from=customers strategy=IndexedLoopJoin index=_id_
  EQ_LOOKUP from=order_items strategy=NestedLoopJoin
    FETCH
      IXSCAN _id_
{
  nReturned: 1,
  totalKeysExamined: 2,
  totalDocsExamined: 497738,
  executionTimeMillis: 65,
  collectionScans: 1,
  indexesUsed: [ '_id_', '_id_' ]
}

Đọc từ dưới lên:

  • IXSCAN _id_ → FETCH: tìm đơn 4242 qua index _id rồi lấy document ra. tenantId được kiểm tra sau khi fetch, vì đơn này chỉ có index _id. Index ghép {tenantId, ...} là chuyện của bài 08.
  • EQ_LOOKUP from=order_items: không có index trên order_items.orderId, nên server quét toàn bộ 497.736 dòng hàng để tìm 7 dòng của đơn này (collectionScans: 1). Đó là lý do totalDocsExamined = 497.738 (1 đơn + 497.736 dòng hàng + 1 khách).
  • EQ_LOOKUP from=customers ... index=_id_: join sang customers theo _id thì luôn có index, nên chỉ mất 1 key và 1 document.

EQ_LOOKUP là cách explain trên bản 8.3.11 hiển thị $lookup, và trường strategy cho biết server chọn cách join nào. Bài này chỉ cần đọc một điều từ đó: phía bị join có index hay bị quét. Các strategy là gì và server chọn chúng ra sao để dành cho bài 12 ($lookup & Joins); vì sao stage có tên EQ_LOOKUP (slot-based execution engine) là chuyện của bài 15.

Bây giờ thêm index trên foreignField. Bài 07 sẽ giải thích index hoạt động thế nào. Ở đây chỉ cần biết nó là "sổ tra" trong ví dụ cái kho:

db.order_items.createIndex({ orderId: 1 })
--- B. reference: aggregate + 2 x $lookup   (order_items CÓ index orderId_1)
EQ_LOOKUP from=customers strategy=IndexedLoopJoin index=_id_
  EQ_LOOKUP from=order_items strategy=IndexedLoopJoin index=orderId_1
    FETCH
      IXSCAN _id_
{
  nReturned: 1,
  totalKeysExamined: 9,
  totalDocsExamined: 9,
  executionTimeMillis: 1,
  collectionScans: 0,
  indexesUsed: [ '_id_', 'orderId_1', '_id_' ]
}
BEFORE (không index)                  AFTER (có index orderId_1)        EMBED

$match đơn 4242                       $match đơn 4242                   IXSCAN _id_
   ↓                                     ↓                                 ↓
quét 497.736 order_items              seek 7 key orderId                1 document
   ↓                                     ↓                                 ↓
7 dòng khớp                           7 document                        xong
   ↓                                     ↓
seek customers _id                    seek customers _id
   ↓                                     ↓
docsExamined = 497.738                docsExamined = 9                  docsExamined = 1

Đo thời gian qua nhiều lần chạy

Một lần explain không đủ để kết luận. Tôi chạy cùng thao tác trên 2.000 đơn khác nhau (cố định, rải đều trên 100.000 đơn), làm nóng cache trước, và đo từng lần gọi bằng performance.now() trong mongosh:

function bench(label, fn, n) {
  for (let i = 0; i < Math.min(n, 50); i++) fn(ids[i], tenantOf[ids[i]]);   // làm nóng
  const t = [];
  for (let i = 0; i < n; i++) {
    const a = performance.now(); fn(ids[i], tenantOf[ids[i]]); t.push(performance.now() - a);
  }
  t.sort((x, y) => x - y);
  // in p50, p95, avg
}

Container được chạy chung với lab của các bài khác. Tôi chỉ đo khi CPU của container đã về dưới 30% trong 3 lần kiểm tra liên tiếp, và chạy hai vòng; mỗi vòng đo mô hình A và B có index hai lần. Trường hợp không index chỉ chạy 30 đơn vì mỗi lần mất hàng chục ms. Kết quả thật:

=== round 1
B. reference + $lookup (no index)  n=30    p50=76.98ms  p95=99.53ms  avg=79.50ms
A. embed (findOne theo _id)        n=2000  p50=0.18ms   p95=0.78ms   avg=0.30ms
B. reference + $lookup (index)     n=2000  p50=0.32ms   p95=0.52ms   avg=0.35ms
A. embed (findOne theo _id)        n=2000  p50=0.15ms   p95=0.35ms   avg=0.19ms
B. reference + $lookup (index)     n=2000  p50=0.29ms   p95=0.36ms   avg=0.30ms
=== round 2
B. reference + $lookup (no index)  n=30    p50=72.23ms  p95=78.87ms  avg=72.58ms
A. embed (findOne theo _id)        n=2000  p50=0.15ms   p95=0.23ms   avg=0.16ms
B. reference + $lookup (index)     n=2000  p50=0.29ms   p95=0.37ms   avg=0.30ms
A. embed (findOne theo _id)        n=2000  p50=0.15ms   p95=0.27ms   avg=0.17ms
B. reference + $lookup (index)     n=2000  p50=0.28ms   p95=0.34ms   avg=0.29ms

Thời gian này bao gồm cả phần mongosh dựng lệnh và nhận kết quả, không chỉ thời gian server. Hãy so sánh tương đối giữa các dòng, đừng xem đó là con số latency production.

Experiment 2: đọc 1.000 đơn một lượt

Màn hình danh sách, export hay job đồng bộ thường đọc nhiều đơn cùng lúc. Ở đây là 1.000 đơn qua $in trên _id, đều có index:

A embed     keys=2000 docs=1000 nReturned=1000
B reference keys=7024 docs=7024 nReturned=1000

(các vòng chạy, p50 của 20 lần mỗi vòng)
A embed      p50=6.5ms   9.2ms   7.6ms   6.1ms
B reference  p50=15.7ms  16.1ms  15.5ms  16.6ms

Hãy nhìn docsExamined. Mô hình B phải đụng 7.024 document (1.000 đơn + 5.024 dòng hàng + 1.000 khách) để trả về 1.000 kết quả. Mô hình A đụng đúng 1.000. Con số keys=2000 của A đến từ cách engine đếm key khi nhảy giữa các giá trị trong $in. Bài 07 sẽ nói kỹ hơn. Ở đây, docsExamined là con số phản ánh công việc thật.

Interpretation

Ba điều rút ra, theo thứ tự quan trọng:

  1. Thiếu index ở foreignField mới là thảm họa, không phải bản thân $lookup. Không có index: khoảng 75 ms mỗi đơn, quét gần 500.000 document. Có index: khoảng 0,3 ms. Cùng một mô hình, khác nhau hơn 200 lần (đo trên lab này). Quét gần nửa triệu document cho mỗi đơn sẽ đốt CPU và cache theo số request, nên ở production nó không chỉ chậm một query mà kéo cả hệ thống xuống.
  2. Có index rồi, $lookup vẫn có giá, nhưng không khủng khiếp. Đọc một đơn: p50 khoảng 0,15–0,18 ms (embed) so với 0,28–0,32 ms (lookup), tức chậm hơn khoảng 2 lần. Đọc 1.000 đơn: khoảng 6–9 ms so với 15–17 ms. Nguồn gốc nằm ở số document phải đụng: 1 so với 9, hay 1.000 so với 7.024.
  3. Con số này là khi mọi thứ nằm trong cache. Khi working set lớn hơn RAM, mỗi document thêm có thể là một lần đọc đĩa. Lúc đó "9 document rải ở 3 collection" sẽ đắt hơn "1 document" nhiều hơn tỉ lệ 2 lần ở trên. Đây là suy luận từ cách storage hoạt động, chưa được đo trong bài này. Bài 21 sẽ đo.

Dung lượng: copy dữ liệu có làm database to ra?

Trực giác bảo embed thì trùng lặp, nên tốn chỗ hơn. Số đo trên dataset này nói ngược lại:

orders_emb   docs=100000  data=48.8MB onDisk=13.7MB  indexes: _id_ 1.04MB
orders_ref   docs=100000  data=8.3MB  onDisk=2.5MB   indexes: _id_ 1.04MB
order_items  docs=497736  data=54.8MB onDisk=14.1MB  indexes: _id_ 5.04MB, orderId_1 2.30MB
Mô hình A (embed)Mô hình B (reference)
Dữ liệu chưa nén48,8 MB63,1 MB (8,3 + 54,8)
Trên đĩa (đã nén)13,7 MB16,6 MB (2,5 + 14,1)
Index cần cho màn chi tiết đơn1,04 MB8,38 MB (1,04 + 5,04 + 2,30)

Lý do: ở mô hình B, mỗi dòng hàng là một document riêng. Mỗi document đó mang thêm _id ObjectId của riêng nó, lặp lại orderId và tenantId, và cần thêm hai entry index. Gần 500.000 lần như vậy cộng lại còn nhiều hơn phần mô hình A chép tên và SĐT khách. Đừng tổng quát hóa thành "embed luôn nhỏ hơn". Nếu bạn chép một object lớn vào hàng nghìn document, kết quả sẽ ngược lại. Nhưng nó cho thấy "trùng lặp" và "tốn chỗ" không phải là một.

Experiment 3: cái giá của denormalization khi khách đổi tên

Ở mô hình A, tên khách nằm trong mọi đơn của khách đó. Đổi tên một khách (khách 777 có 14 đơn) trông như sau:

// B: sửa 1 chỗ
db.customers.updateOne({ _id: 777 }, { $set: { name: "Trần Thị Mai" } })

// A: sửa mọi bản chép
db.orders_emb.updateMany({ "customer._id": 777 }, { $set: { "customer.name": "Trần Thị Mai" } })

explain của lệnh update (qua db.runCommand({ explain: { update: ... }, verbosity: "executionStats" })):

B customers.updateOne          matched=1 modified=1
A no index:   plan=UPDATE<-COLLSCAN keysExamined=0  docsExamined=100000 nWouldModify=14
A with index: plan=UPDATE<-FETCH    keysExamined=14 docsExamined=14     nWouldModify=14

Đo trên 20 khách khác nhau cho mỗi trường hợp (vòng chạy khi container yên):

B customers.updateOne (1 chỗ)            docs modified/lượt≈1.0   p50=0.32ms   max=6.23ms
A orders_emb.updateMany, không index     docs modified/lượt≈10.2  p50=17.65ms  max=49.63ms
A orders_emb.updateMany, có index        docs modified/lượt≈9.9   p50=0.32ms   max=1.23ms

Có index { "customer._id": 1 } thì sửa khoảng 10 bản chép nhanh ngang sửa 1 document. Phần đắt là đi tìm bản chép khi không có index: quét 100.000 đơn để sửa 10 đơn. Nhưng index đó không miễn phí: thêm 0,57 MB, và mỗi lần tạo đơn phải ghi thêm một entry index. Còn nếu một "khách" là một doanh nghiệp B2B có 50.000 đơn, mỗi lần đổi tên sẽ là 50.000 lần ghi document, và mỗi lần ghi đó còn phải được replicate (bài 25). Con số 50.000 ở đây là minh họa, không đo.

Conclusion

Đọc theo đơn là thao tác chủ đạo, nên mô hình A đọc ít document hơn, ghi đơn atomic, và trên dataset này còn nhỏ hơn. Cái giá của nó là phải đồng bộ bản chép tên khách, và cái giá đó chấp nhận được vì khách hiếm khi đổi tên. Nếu đảo ngược tần suất, ví dụ field được copy đổi mỗi phút, kết luận cũng đảo theo.

MongoDB vs PostgreSQL: cùng hệ thống đơn hàng

Bắt đầu từ bài toán, không phải từ database. Cùng năm access pattern ở đầu bài.

PostgreSQL (chuẩn hóa)                 MongoDB (mô hình A)

customers ──┐                          Order
            │ customer_id               ├── customer {_id, name, phone}
orders ─────┤                           ├── items [ {productId, name, qty, price} ]
            │ order_id                  ├── status, createdAt
order_items ┤                           └── total
            │ product_id
products ───┘                          customers, products: collection riêng
-- PostgreSQL: màn chi tiết đơn
SELECT o.id, o.status, c.name, c.phone, i.product_id, i.product_name, i.qty, i.unit_price
FROM orders o
JOIN customers c   ON c.id = o.customer_id
JOIN order_items i ON i.order_id = o.id
WHERE o.id = 4242 AND o.tenant_id = 't43';

Vài điều đáng chú ý, theo thứ tự quan trọng:

1. PostgreSQL cũng cần index ở phía bị join. Tài liệu PostgreSQL ghi rõ: khai báo foreign key không tự tạo index trên cột tham chiếu. Thiếu index trên order_items(order_id), PostgreSQL cũng phải tìm cách quét bảng hoặc chọn kiểu join khác. Bài học "index ở phía bị join" ở Experiment 1 không phải chuyện riêng của MongoDB.

2. PostgreSQL cũng denormalize. Gần như mọi hệ thống đơn hàng tử tế đều lưu unit_price (và thường cả product_name) trong order_items, chứ không join sang giá hiện tại của products. Lý do giống tờ hóa đơn siêu thị: đó là dữ liệu temporal. Khác biệt nằm ở mức độ: MongoDB đẩy ý tưởng "lưu theo hình dạng khi đọc" đi xa hơn, đến mức gom cả đơn vào một document.

3. Mỗi bên tối ưu cho một kiểu câu hỏi.

WorkloadMô hình chuẩn hóa (PostgreSQL, hoặc MongoDB mô hình B)Mô hình document (MongoDB mô hình A)
Đọc trọn một đơn theo idnhiều lần index lookup cộng join✓ một document
Tạo đơn trọn vẹntransaction nhiều dòng (PostgreSQL làm rất tốt)✓ một lần insert, atomic sẵn
Khách đổi tên✓ sửa 1 dòng⚠ sửa mọi bản chép (hoặc quyết định không sửa)
Báo cáo "top sản phẩm tháng"✓ quét order_items, GROUP BY⚠ $unwind items của mọi đơn, hoặc tính sẵn số liệu mỗi khi có đơn mới
Câu hỏi ad-hoc theo trục bất kỳ✓ JOIN linh hoạt, planner trưởng thành⚠ trục nào chưa được thiết kế sẵn thì tốn hơn
Đơn có cấu trúc khác nhau theo ngành hàng⚠ thêm cột / bảng phụ / JSONB✓ schema linh hoạt (kèm $jsonSchema từ bài 02)

PostgreSQL cũng có JSONB để lưu một đơn thành một giá trị JSON nếu muốn. Ranh giới "SQL thì phải chuẩn hóa" không cứng như người ta hay nghĩ.

Hai thiết kế này không phải một cái tốt, một cái xấu. Chúng đang tối ưu cho những cách sử dụng dữ liệu khác nhau. Mô hình chuẩn hóa tối ưu cho tính đúng và sự linh hoạt khi bạn chưa biết hết câu hỏi tương lai. Mô hình document tối ưu cho vài câu hỏi bạn đã biết là sẽ hỏi hàng triệu lần. Tôi không đo PostgreSQL trong bài này. So sánh hiệu năng định lượng giữa hai hệ để dành cho bài 37 (Anti-patterns & MongoDB vs PostgreSQL), với cùng dữ liệu và cùng điều kiện.

Những lỗi thường gặp

  • Dịch thẳng schema SQL sang MongoDB. Mỗi bảng thành một collection, mỗi JOIN thành một $lookup. Bạn nhận về cái giá của join mà không có lợi ích của document.
  • Embed mọi thứ "cho nhanh". Comments, logs, lịch sử đơn của khách được nhét vào mảng. Nó chạy tốt ở tuần đầu và vỡ khi dữ liệu thật đổ về.
  • Copy field hay thay đổi. Ví dụ copy số lượng tồn kho của sản phẩm vào từng đơn, rồi phải cập nhật hàng nghìn đơn mỗi lần có hàng về.
  • $lookup mà không có index ở foreignField. Trên lab này, khác biệt là khoảng 75 ms so với 0,3 ms cho mỗi đơn.
  • Cập nhật bản chép mà không có index để tìm nó. updateMany quét cả collection để sửa 10 document.
  • Quên rằng updateMany không atomic trên toàn bộ. Lệnh đồng bộ bản chép phải chạy lại được an toàn.
  • Thiết kế mà không viết access pattern ra giấy. Không có bảng "ai đọc gì, bao nhiêu lần" thì mọi tranh luận embed hay reference chỉ là cảm tính.

Tóm tắt

  • Thiết kế bắt đầu từ access pattern: ai đọc, đọc gì cùng nhau, bao lâu một lần, ai ghi và ghi gì.
  • Embed khi dữ liệu được đọc cùng, có giới hạn và cùng vòng đời. Reference khi nó tăng không giới hạn, được đọc hoặc sửa độc lập, hoặc dùng chung ở nhiều nơi.
  • Relationship cardinality không chỉ là "1-n". Phải hỏi n bằng bao nhiêu. Với 1-squillions, liên kết đặt ở phía "nhiều".
  • Denormalization đổi tốc độ đọc lấy công ghi. Phân loại bản chép (immutable, temporal, cần tươi, chấp nhận cũ) trước khi quyết định có đồng bộ hay không.
  • $lookup cần index ở foreignField. Có index, nó chỉ chậm hơn khoảng 2 lần trên lab này khi dữ liệu nằm trong cache. Không có index, nó quét cả collection cho mỗi document đầu vào.
  • Mảng phình không giới hạn sửa bằng cách đặt reference ở phía "nhiều"; nếu cần vài phần tử gần nhất trong cha, giữ một mảng có trần.
  • PostgreSQL và MongoDB không có bên nào thắng tuyệt đối. Chúng tối ưu cho workload khác nhau.

Bài tiếp theo

Bài này dạy cách chọn: embed hay reference, dựa trên access pattern và cardinality của quan hệ. Trên đường đi, ta đã dùng vài cách làm mà chưa gọi tên: copy name và phone của khách vào đơn, tính sẵn total lúc tạo đơn, giữ một mảng có trần cho vài phần tử gần nhất. Bài 03 cho biết mỗi field tốn bao nhiêu byte; bài này cho thấy document to dần lên khi bạn embed. Còn ba câu hỏi bài này cố tình để lại:

  • Những hình dạng document hay gặp có tên là gì, mỗi cái giải vấn đề nào, tốn gì, và khi nào không nên dùng?
  • Khi schema phải đổi trong lúc hệ thống đang chạy (thêm field, đổi kiểu, tách một field thành object), làm sao để document cũ và mới cùng sống được mà không cần downtime?
  • Document to thì tốn gì cụ thể: một document 10 KB, 500 KB và 10 MB khác nhau bao nhiêu về latency, chỗ trong cache, byte qua mạng và công deserialize ở client?

Bài 05, Schema Design Patterns & Schema Evolution, trả lời cả ba, và đo câu hỏi cuối bằng thí nghiệm thay vì đoán.

Tài liệu tham khảo