NoSQL 2 — MongoDB: mô hình tài liệu & thiết kế schema

13 thg 7, 2026 2 lượt xem
#sql
#nosql
#schema-design
#mongodb
#document

NoSQL 2 — MongoDB: mô hình tài liệu & thiết kế schema

bài tổng quan NoSQL chúng ta đã thấy NoSQL không phải "không có schema" mà là "schema nằm ở chỗ khác". Với MongoDB, schema nằm trong chính hình dạng của từng document và — quan trọng hơn — trong cách bạn quyết định nhóm dữ liệu lại với nhau theo mẫu truy cập. Người quen SQL thường mang thói quen chuẩn hoá (normalize) sang MongoDB, tách mọi thứ thành các collection nhỏ rồi join lại. Đó là con đường ngắn nhất dẫn tới một hệ thống MongoDB chậm, khó bảo trì và thua cả PostgreSQL. Bài này giúp bạn nghĩ đúng ngay từ đầu: mô hình tài liệu là gì, và thiết kế schema tài liệu tốt trông ra sao trong bối cảnh dữ liệu ngân hàng.

MongoDB là gì

MongoDB là một document database: đơn vị lưu trữ cơ bản không phải là hàng (row) trong một bảng phẳng, mà là một document — một cấu trúc dạng JSON có thể lồng nhau (nested), chứa mảng, chứa document con. Một document mô tả trọn vẹn một "thực thể" theo cách gần với đối tượng trong code ứng dụng, thay vì bị xé lẻ ra nhiều bảng.

Ba khái niệm tổ chức, ánh xạ (một cách gần đúng) với RDBMS:

MongoDBRDBMS tương đươngCảnh báo khác biệt
documentrow (hàng)Document có thể lồng nhau nhiều tầng, chứa mảng; row thì phẳng.
collectiontable (bảng)Collection không bắt buộc mọi document cùng schema.
databasedatabase/schemaGần như tương đương.
fieldcolumn (cột)Field có thể vắng mặt ở document này nhưng có ở document khác.
_idprimary keyMongoDB tự sinh _id kiểu ObjectId nếu bạn không cấp.
$lookupJOINTồn tại nhưng đắt, nên tránh làm nền tảng thiết kế.
indexindexTương tự, có chi tiết riêng ở bài index & hiệu năng.

Ánh xạ này hữu ích để bắt đầu nhưng nguy hiểm nếu bám cứng. Sai lầm kinh điển: coi collection như bảng và cố chuẩn hoá triệt để. MongoDB được thiết kế để bạn nhúng dữ liệu liên quan vào cùng một document, đọc trong một lần truy cập đĩa, không join. Nếu bạn tách "khách hàng", "địa chỉ", "số điện thoại" thành ba collection rồi $lookup, bạn vừa vứt bỏ lợi thế lớn nhất của MongoDB vừa gánh phần chi phí join mà RDBMS làm tốt hơn nhiều.

BSON và các kiểu dữ liệu

Về mặt logic bạn thao tác với JSON, nhưng MongoDB lưu trữ vật lý bằng BSON (Binary JSON) — một định dạng nhị phân mở rộng JSON. BSON thêm những thứ JSON thuần không có: kiểu dữ liệu phong phú hơn, độ dài mã hoá sẵn (để duyệt nhanh), và thứ tự field ổn định. Các kiểu quan trọng cần nắm:

  • ObjectId: giá trị 12 byte MongoDB sinh cho _id. Nó không phải số ngẫu nhiên — 4 byte đầu là timestamp tạo, nên ObjectId gần như tăng dần theo thời gian và có thể trích ra thời điểm tạo. Đây là lý do dùng _id mặc định thường ổn cho phân trang theo thời gian.
  • Date: thời điểm chính xác tới mili-giây (UTC, 64-bit). LUÔN lưu thời gian dưới dạng Date chứ không phải chuỗi, để so sánh và index đúng.
  • Decimal128: số thập phân chính xác 34 chữ số, không có sai số nhị phân như double. Bắt buộc dùng cho tiền tệ — số dư tài khoản, số tiền giao dịch. Dùng double cho tiền là lỗi nghiêm trọng: 0.1 + 0.2 != 0.3.
  • Array: mảng giá trị, có thể chứa cả document. MongoDB index được từng phần tử mảng (multikey index) — nền tảng cho nhiều mẫu thiết kế.
  • Embedded document (nested): document lồng trong document. Truy cập bằng dot notation: address.city.
  • Ngoài ra: String, Int32/Int64, Boolean, Binary, Null.
{
  "_id": ObjectId("665f1a2b9c4e7f0012ab34cd"),
  "cif": "CIF0098421",
  "full_name": "Nguyễn Văn An",
  "opened_at": ISODate("2021-03-15T00:00:00Z"),
  "kyc_level": 2,
  "address": { "street": "12 Lê Lợi", "district": "Hoàn Kiếm", "city": "Hà Nội" },
  "phones": ["0912345678", "02439367777"],
  "balances": { "VND": NumberDecimal("15230000.00"), "USD": NumberDecimal("1200.50") }
}

Lưu ý: đây là ví dụ document MongoDB (cú pháp ObjectId, ISODate, NumberDecimal), KHÔNG phải SQL và không chạy trên sandbox PostgreSQL của Knowledge Base.

Schema linh hoạt — nhưng vẫn cần chuẩn

MongoDB không ép mọi document trong một collection có cùng cấu trúc. Bạn thêm field mới cho document mới mà không cần ALTER TABLE, không khoá bảng, không migration đau đớn. Đây là lợi thế thật khi lược đồ dữ liệu tiến hoá nhanh — ví dụ danh mục sản phẩm ngân hàng bổ sung thuộc tính mới theo từng đợt ra mắt.

Nhưng "linh hoạt" bị hiểu lầm thành "tuỳ tiện" thì trả giá đắt. Nếu cùng một collection mà chỗ ghi phone, chỗ ghi phoneNumber, chỗ để chuỗi, chỗ để mảng, thì code ứng dụng biến thành rừng if-else và mọi truy vấn đều rủi ro. Nguyên tắc: schemaless ở tầng engine, nhưng có chuẩn ở tầng ứng dụng và đội ngũ. Bạn vẫn phải thống nhất tên field, kiểu dữ liệu, đơn vị (tiền lưu Decimal128, thời gian lưu Date UTC), và ghi tài liệu lại.

Schema validation bằng JSON Schema

MongoDB cho phép ép ràng buộc ở tầng collection bằng JSON Schema validator. Đây là cách "khoá" những field bắt buộc và kiểu dữ liệu quan trọng trong khi vẫn để phần còn lại linh hoạt:

{
  "$jsonSchema": {
    "bsonType": "object",
    "required": ["cif", "full_name", "kyc_level", "opened_at"],
    "properties": {
      "cif":       { "bsonType": "string", "pattern": "^CIF[0-9]{7}$" },
      "full_name": { "bsonType": "string", "minLength": 1 },
      "kyc_level": { "bsonType": "int", "minimum": 0, "maximum": 3 },
      "opened_at": { "bsonType": "date" },
      "balances":  { "bsonType": "object" }
    }
  }
}

Validator này áp khi insert/update; document vi phạm bị từ chối (mức strict) hoặc chỉ cảnh báo (mức moderate/warn). Với dữ liệu ngân hàng, hãy dùng strict cho những field cốt lõi (CIF, số dư, cấp KYC) để tránh dữ liệu bẩn lọt vào từ bug ứng dụng. Đây là điểm giao với kiểm soát chất lượng dữ liệu: validator là hàng rào cuối cùng ở tầng lưu trữ.

Nguyên tắc thiết kế then chốt: embedding vs referencing

Đây là quyết định quan trọng nhất trong toàn bộ thiết kế MongoDB, và nó chi phối hiệu năng hơn mọi thứ khác. Câu hỏi luôn là: dữ liệu liên quan nên nhúng (embed) vào cùng document, hay để riêng và tham chiếu (reference) qua khoá?

Kim chỉ nam duy nhất bạn cần thuộc lòng: "dữ liệu được truy cập cùng nhau nên được lưu cùng nhau" (data that is accessed together should be stored together).

Khi nào EMBEDDING (nhúng)

Nhúng document con hoặc mảng vào document cha khi:

  • Dữ liệu thường được đọc cùng nhau với document cha (địa chỉ, danh sách số điện thoại của khách).
  • Quan hệ là one-to-few — số phần tử bị chặn và nhỏ (một khách có vài địa chỉ, vài số điện thoại, không phải hàng nghìn).
  • Dữ liệu con không tồn tại độc lập ngoài cha, và ít bị chia sẻ.

Lợi ích: đọc một lần lấy đủ, không join, đảm bảo tính toàn vẹn ở mức document (ghi một document là atomic). Đây là mặc định nên ưu tiên. Ví dụ hồ sơ khách hàng: nhúng address, phones, kyc_documents (vài cái) ngay trong document customer.

Khi nào REFERENCING (tham chiếu)

Để dữ liệu ở collection riêng và lưu khoá tham chiếu khi:

  • Quan hệ là one-to-many lớn hoặc many-to-many — một tài khoản có hàng chục nghìn giao dịch. Nhúng hết vào là tự sát.
  • Dữ liệu được chia sẻ giữa nhiều document (một sản phẩm ngân hàng gắn với nhiều khách hàng — nhúng bản sao sản phẩm vào từng khách gây trùng lặp và địa ngục cập nhật).
  • Nguy cơ document phình to vượt giới hạn 16MB của MongoDB. Đây là trần cứng cho một document; mảng nhúng tăng không kiểm soát sẽ đâm vào trần này.
  • Dữ liệu con có vòng đời và tần suất cập nhật rất khác document cha.

Với giao dịch, luôn tách: transactions là collection riêng, mỗi document có account_id trỏ về tài khoản. Bạn truy vấn giao dịch theo account_id với index, không nhồi vào document tài khoản.

So sánh nhanh

Tiêu chíEmbeddingReferencing
Số lần đọc1 (nhanh)2+ hoặc $lookup
Quan hệ phù hợpone-to-fewone-to-many lớn, many-to-many
Trùng lặp dữ liệucó thể (nếu chia sẻ)không
Cập nhật dữ liệu chungphải sửa nhiều nơisửa một nơi
Rủi ro 16MBcó nếu mảng lớnkhông
Tính atomicghi 1 document = atomiccần transaction đa document

Không có lựa chọn "đúng tuyệt đối" — bạn cân nhắc theo mẫu truy cập thực tế của ứng dụng. Một dữ liệu có thể vừa nhúng bản rút gọn vừa tham chiếu bản đầy đủ (xem mẫu subset và extended reference bên dưới).

Các mẫu thiết kế phổ biến

Cộng đồng MongoDB đã đúc kết nhiều design pattern để cân bằng giữa đọc nhanh và trùng lặp có kiểm soát. Bốn mẫu hay dùng nhất:

1. Subset — nhúng tập con

Vấn đề: bạn muốn nhúng để đọc nhanh, nhưng dữ liệu con quá nhiều để nhúng hết. Giải pháp: nhúng phần thường dùng nhất, tham chiếu phần còn lại. Ví dụ document tài khoản nhúng 10 giao dịch gần nhất để hiển thị ngay trên màn hình tổng quan, còn toàn bộ lịch sử giao dịch nằm ở collection transactions:

{
  "_id": "ACC-771203",
  "customer_id": "CIF0098421",
  "currency": "VND",
  "balance": NumberDecimal("15230000.00"),
  "recent_txns": [
    { "txn_id": "T90021", "amount": NumberDecimal("-500000"), "at": ISODate("2026-06-30T08:12:00Z"), "kind": "withdrawal" },
    { "txn_id": "T90015", "amount": NumberDecimal("2000000"),  "at": ISODate("2026-06-29T14:03:00Z"), "kind": "deposit" }
  ]
}

Màn hình tổng quan chỉ đọc recent_txns — một lần, cực nhanh. Cần xem đầy đủ mới sang transactions.

2. Computed — tính sẵn

Vấn đề: tính toán tổng hợp (tổng chi tiêu tháng, tổng số giao dịch) lặp lại mỗi lần đọc gây tốn kém. Giải pháp: tính một lần lúc ghi, lưu kết quả. Ví dụ document khách hàng lưu sẵn total_balance (tổng số dư quy đổi) và txn_count_30d, cập nhật khi có giao dịch mới thay vì tính lại mỗi lần đọc dashboard. Đổi lại phải bảo trì tính nhất quán khi dữ liệu gốc đổi.

3. Bucket — gom nhóm cho time-series

Vấn đề: dữ liệu chuỗi thời gian (log giao dịch, số dư theo ngày) nếu mỗi điểm là một document sẽ tạo ra hàng tỷ document nhỏ, tốn overhead. Giải pháp bucket: gom nhiều điểm vào một document theo khoảng thời gian (ví dụ một document chứa mọi giao dịch của một tài khoản trong một ngày):

{
  "_id": "ACC-771203#2026-06-30",
  "account_id": "ACC-771203",
  "date": ISODate("2026-06-30T00:00:00Z"),
  "count": 3,
  "sum_amount": NumberDecimal("1500000"),
  "txns": [
    { "amount": NumberDecimal("-500000"), "at": ISODate("2026-06-30T08:12:00Z") },
    { "amount": NumberDecimal("2000000"),  "at": ISODate("2026-06-30T10:44:00Z") },
    { "amount": NumberDecimal("0"),         "at": ISODate("2026-06-30T15:00:00Z") }
  ]
}

Mỗi document đã kèm sẵn countsum_amount (kết hợp mẫu computed). Bucket giảm số document, giảm index, và đọc theo khoảng thời gian cực gọn. (MongoDB còn có time-series collections chuyên dụng tự động hoá bucket này.)

4. Extended reference — tham chiếu mở rộng

Vấn đề: bạn tham chiếu để tránh trùng lặp, nhưng mỗi lần đọc lại phải $lookup để lấy vài field hiển thị. Giải pháp: bên cạnh khoá tham chiếu, sao chép thêm vài field ít đổi, thường dùng vào document. Ví dụ document giao dịch tham chiếu account_id nhưng nhúng thêm account_nocustomer_name để in sao kê mà không phải join:

{
  "_id": "T90021",
  "account_id": "ACC-771203",
  "account_no": "0771203000",
  "customer_name": "Nguyễn Văn An",
  "amount": NumberDecimal("-500000"),
  "kind": "withdrawal",
  "at": ISODate("2026-06-30T08:12:00Z")
}

Đổi lại: nếu khách đổi tên, các document giao dịch cũ giữ tên cũ. Với sao kê giao dịch, đó thường là mong muốn (ghi lại tên tại thời điểm giao dịch). Nhưng phải ý thức đây là đánh đổi.

Denormalize có kiểm soát và đánh đổi

Bốn mẫu trên đều là các dạng denormalize (phi chuẩn hoá) có kiểm soát: cố tình sao chép/tính sẵn dữ liệu để đọc nhanh, chấp nhận trách nhiệm giữ nhất quán khi ghi. Đây là tư duy ngược với chuẩn hoá quan hệ mà bạn quen ở thiết kế RDBMS, và cũng có họ hàng với tư duy phi chuẩn hoá trong mô hình chiều dimensional/Kimball của kho dữ liệu.

Nguyên tắc kiểm soát:

  • Chỉ sao chép field ít thay đổi (tên khách, mã sản phẩm) — tránh sao chép field biến động liên tục.
  • Xác định rõ nguồn sự thật (source of truth) cho mỗi field. Bản sao chỉ là cache đọc.
  • Khi field gốc đổi, có cơ chế lan truyền cập nhật (job nền, change stream, hoặc chấp nhận "eventual" nếu nghiệp vụ cho phép).
  • Đo bằng mẫu truy cập thực tế: nếu 95% truy vấn đọc kèm nhau thì nhúng đáng giá; nếu hiếm khi đọc cùng thì đừng nhúng.

Đánh đổi cốt lõi: đọc nhanh hơn, ghi phức tạp hơn và có trùng lặp. MongoDB (và NoSQL nói chung) đặt cược rằng phần lớn hệ thống đọc nhiều hơn ghi, nên tối ưu đường đọc là thắng. Nhưng bạn — người thiết kế — phải chủ động chọn, không để mặc định.

Use case thực tế

Bối cảnh: NCB xây dịch vụ "Hồ sơ khách hàng 360" (Customer 360) hiển thị trên app mobile: khi khách mở app, màn hình đầu tiên cần hiện tên, hạng khách, danh sách tài khoản kèm số dư, và 5 giao dịch gần nhất — tất cả trong dưới 200ms. Ước tính 3 triệu khách, trung bình 2.3 tài khoản/khách, mỗi tài khoản ~40 giao dịch/tháng (tổng ~276 triệu giao dịch/tháng).

Thiết kế (áp dụng các mẫu ở trên):

  1. Collection customers — document gốc, nhúng (embed) những gì đọc cùng nhau:

    • Nhúng address, phones (one-to-few) → có ngay khi đọc profile.
    • Nhúng mảng accounts rút gọn (mẫu subset): mỗi phần tử là { account_no, currency, balance (Decimal128), recent_txns: [5 giao dịch] }. Vì một khách chỉ vài tài khoản và mỗi tài khoản chỉ giữ 5 giao dịch, document nằm xa trần 16MB.
    • Field customer_tiertotal_balance_vnd tính sẵn (mẫu computed), cập nhật qua change stream khi có giao dịch.
  2. Collection transactionstham chiếu (referencing): 276 triệu giao dịch/tháng không thể nhúng hết. Mỗi document có account_id, và dùng extended reference nhúng thêm account_no, customer_name để in sao kê không cần join. Index trên { account_id: 1, at: -1 } để lấy giao dịch mới nhất nhanh (chi tiết ở bài index & hiệu năng).

  3. Collection txn_daily — mẫu bucket cho báo cáo: gom giao dịch theo account_id + ngày, kèm countsum_amount tính sẵn, phục vụ biểu đồ chi tiêu theo ngày mà không quét collection giao dịch thô.

Kết quả: màn hình Customer 360 chỉ cần 1 lần đọc document customers theo _id (dùng index primary) → lấy đủ profile + tài khoản + 5 giao dịch gần nhất, đáp ứng mốc <200ms. Xem đầy đủ lịch sử mới truy vấn transactions. Cấu trúc balances dùng Decimal128 nên số dư chính xác tuyệt đối, không sai số tiền tệ. Validator JSON Schema ở strict chặn document thiếu CIF hoặc sai kiểu số dư.

Các thao tác đọc/ghi cụ thể (find, aggregate, update mảng nhúng) và cách đo hiệu năng sẽ ở bài CRUD & aggregation.

Ghi nhớ

  • MongoDB là document database: đơn vị là document JSON/BSON lồng nhau; collection ≈ table, database ≈ database — nhưng đừng bê nguyên tư duy chuẩn hoá RDBMS sang.
  • BSON mở rộng JSON với kiểu giàu hơn: ObjectId (có timestamp, gần tăng dần), Date (UTC ms), Decimal128 (BẮT BUỘC cho tiền tệ, tránh sai số double), array (multikey index), nested document.
  • Schema linh hoạt ở engine nhưng phải có chuẩn ở đội ngũ; dùng JSON Schema validator (strict) để khoá field cốt lõi như CIF, số dư, KYC.
  • Quyết định lớn nhất là embedding vs referencing, dẫn dắt bởi câu "dữ liệu truy cập cùng nhau nên lưu cùng nhau".
  • Embed cho one-to-few, đọc cùng nhau, không chia sẻ (địa chỉ, SĐT). Reference cho one-to-many lớn, dữ liệu chia sẻ, tránh document phình vượt trần cứng 16MB (giao dịch).
  • Bốn mẫu chủ lực: subset (nhúng tập con hay dùng), computed (tính sẵn), bucket (gom time-series), extended reference (nhúng thêm vài field để khỏi join).
  • Denormalize có kiểm soát: đổi đọc nhanh lấy ghi phức tạp + trùng lặp; xác định rõ nguồn sự thật và cơ chế lan truyền cập nhật.
  • Thiết kế theo mẫu truy cập thực tế của ứng dụng, không theo lý thuyết chuẩn hoá.

Bài viết liên quan

Cách index hoạt động (B-Tree), đọc EXPLAIN, seq scan vs index scan và mẫu tối ưu truy vấn.

13 thg 7, 2026 4

Khoá, ràng buộc, quan hệ và chuẩn hoá 1NF/2NF/3NF — thiết kế lược đồ đúng từ đầu.

13 thg 7, 2026 4

Kết nhiều bảng đúng cách: các loại JOIN, bẫy thường gặp và phép hợp tập.

13 thg 7, 2026 3

Truy vấn lồng, CTE (WITH), CTE đệ quy và hàm cửa sổ — vũ khí cho phân tích nâng cao.

13 thg 7, 2026 3