Document Model (P2/3): Schema linh hoạt và `$jsonSchema`

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

Ở phần trước: mỗi document có _id duy nhất trong collection, và một lệnh ghi lên một document là atomic, dù sửa nhiều field cùng lúc. Bài này bàn về việc schema nằm ở đâu.

Flexible schema: vì sao "schemaless" là một từ gây hiểu nhầm

Ý chính

MongoDB không bắt mọi document trong một collection phải có cùng field hay cùng kiểu. Đó là flexible schema. Nhưng như vậy không có nghĩa là "không có schema". Schema vẫn tồn tại, chỉ là nó chuyển từ database sang code ứng dụng, nơi khó nhìn thấy và khó kiểm soát hơn.

Mental model

Một tủ hồ sơ không có mẫu tờ khai thống nhất. Năm đầu ai cũng ghi "Số điện thoại". Năm sau có người ghi hai số. Một nhân viên mới ghi nhầm thành "Số đt". Tủ vẫn nhận hết, không phàn nàn gì. Vấn đề chỉ lộ ra vào ngày bạn cần tìm "tất cả khách có số điện thoại".

Thí nghiệm: ba phiên bản app, một collection

db.customers.insertMany([
  // app v1
  { name: "Lan",  phone: "0901234567", createdAt: new Date("2025-01-10") },
  // app v2: phone thành array, một chỗ gửi createdAt dạng string
  { name: "Binh", phone: ["0907654321", "0281234567"], createdAt: "2025-06-01" },
  // app v3: hotfix gõ nhầm tên field
  { name: "Chi",  phnoe: "0912345678", createdAt: new Date("2026-02-14") }
])

MongoDB nhận cả ba, không một lời cảnh báo. Giờ hỏi một câu rất bình thường: khách nào tạo sau ngày 1/3/2025?

db.customers.find({ createdAt: { $gt: new Date("2025-03-01") } }, { _id: 0, name: 1 })
[ { name: 'Chi' } ]

Binh tạo ngày 1/6/2025, rõ ràng thoả điều kiện, nhưng biến mất khỏi kết quả mà không có lỗi nào. Lý do: createdAt của Binh là string, còn điều kiện so sánh với Date. Toán tử so sánh như $gt chỉ so với những giá trị cùng nhóm kiểu BSON với giá trị trong query (gọi là type bracketing), nên document có createdAt là string bị bỏ qua. Chi tiết về kiểu và thứ tự so sánh là chủ đề của bài BSON & ObjectId.

Muốn biết collection của mình đã "trôi" (schema drift) tới đâu, bạn có thể đếm kiểu dữ liệu thật của từng field:

db.customers.aggregate([
  { $group: { _id: { phone: { $type: "$phone" }, createdAt: { $type: "$createdAt" } }, n: { $sum: 1 } } }
])
{ _id: { phone: 'array',   createdAt: 'string' }, n: 1 }
{ _id: { phone: 'missing', createdAt: 'date'   }, n: 1 }
{ _id: { phone: 'string',  createdAt: 'date'   }, n: 1 }

Ba document, ba "schema" khác nhau. Code đọc dữ liệu giờ phải xử lý cả ba trường hợp. Đó chính là schema, chỉ là nó nằm rải rác trong các if của ứng dụng.

Flexible schema có giá trị thật, nếu dùng có chủ đích

Flexible schema
   │
   ├── ✓ thêm field mới không cần migration khoá bảng
   ├── ✓ một collection chứa được dữ liệu đa hình (sản phẩm điện tử vs quần áo
   │     có thuộc tính khác nhau)
   ├── ✓ rollout dần: document cũ và mới cùng tồn tại trong lúc chuyển đổi
   ├── ✗ lỗi kiểu / lỗi chính tả được lưu im lặng
   ├── ✗ query trả thiếu kết quả mà không báo lỗi
   └── ✗ mọi nơi đọc dữ liệu phải "phòng thủ" trước mọi phiên bản cũ

Câu hỏi đúng không phải "có schema hay không", mà là ai giữ schema, và giữ chặt tới mức nào. MongoDB cho bạn một công cụ để database cùng giữ: schema validation.

Schema validation với $jsonSchema

Ý chính

Schema validation là bộ quy tắc gắn vào collection: field nào bắt buộc, field nào phải là kiểu gì, giá trị nào được phép. MongoDB kiểm tra quy tắc lúc ghi (insert, update). Bạn chọn mức độ: chặn hẳn, chỉ cảnh báo, áp cho mọi document hay chỉ cho document đang hợp lệ.

Mental model: bảo vệ ở cửa

Schema validation giống bảo vệ đứng ở cửa toà nhà. Ai vào (insert) hoặc sửa giấy tờ (update) đều bị kiểm tra theo một danh sách quy tắc. Nhưng bảo vệ mới đến ca không đi lục soát những người đã ở sẵn trong toà nhà. Đặc điểm cuối này rất quan trọng, ta sẽ kiểm chứng ngay bên dưới.

Tạo collection với validator

$jsonSchema dựa trên JSON Schema draft 4, kèm phần mở rộng bsonType để kiểm tra kiểu BSON ("int", "date", "objectId"...). Một số keyword như $ref, default, format không được hỗ trợ.

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["tenantId", "email", "status", "createdAt"],
      properties: {
        tenantId:  { bsonType: "string" },
        email:     { bsonType: "string", pattern: "^.+@.+$" },
        status:    { enum: ["active", "suspended", "deleted"] },
        age:       { bsonType: "int", minimum: 0, maximum: 150 },
        createdAt: { bsonType: "date" }
      }
    }
  }
})

Chú ý: age không nằm trong required, nên có thể vắng mặt, nhưng nếu có thì phải là int từ 0 đến 150. Validator không cần mô tả mọi field. Bạn khoá những gì quan trọng và để phần còn lại linh hoạt.

Giờ insert một document sai bốn chỗ:

db.users.insertOne({ tenantId: "t1", email: "binh-at-example.com", status: "actve", age: "30" })

Lỗi thật (code 121, đã rút gọn phần lặp):

Document failed validation
{
  failingDocumentId: ObjectId('6ac711a775d15c817526dc14'),
  details: {
    operatorName: '$jsonSchema',
    schemaRulesNotSatisfied: [
      { operatorName: 'properties',
        propertiesNotSatisfied: [
          { propertyName: 'email',  details: [ { operatorName: 'pattern',
              reason: 'regular expression did not match', consideredValue: 'binh-at-example.com' } ] },
          { propertyName: 'status', details: [ { operatorName: 'enum',
              reason: 'value was not found in enum', consideredValue: 'actve' } ] },
          { propertyName: 'age',    details: [ { operatorName: 'bsonType', specifiedAs: { bsonType: 'int' },
              reason: 'type did not match', consideredValue: '30', consideredType: 'string' } ] }
        ] },
      { operatorName: 'required', missingProperties: [ 'createdAt' ] }
    ]
  }
}

Lỗi chỉ ra từng quy tắc bị vi phạm, kèm giá trị gây lỗi. Ứng dụng có thể log nguyên errInfo này để debug, thay vì chỉ nhận một câu "invalid".

Một chi tiết nhỏ mà đáng giá, thấy được ngay trong lab: trong mongosh, age: 30 được lưu thành int nên qua validator, còn age: Double(30) thì bị từ chối với cùng lỗi Document failed validation. Mỗi driver, mỗi ngôn ngữ có quy tắc riêng khi chuyển số sang BSON. Validator theo bsonType sẽ bắt được những lệch kiểu như vậy, và bài BSON & ObjectId sẽ giải thích vì sao chúng quan trọng.

validationLevel: kiểm tra những document nào

Bây giờ là phần "bảo vệ không lục soát người đã ở trong". Mình tạo collection legacy không có validator, insert 3 document, trong đó 2 cái sai (status "ACTIVE" viết hoa, thiếu createdAt, dùng mail thay vì email). Sau đó gắn validator bằng collMod:

db.runCommand({ collMod: "legacy", validator: schema, validationLevel: "strict" })
{ ok: 1 }

Lệnh thành công ngay. MongoDB không quét dữ liệu cũ khi bạn thêm validator. Muốn biết document nào đang vi phạm, bạn tự hỏi bằng chính schema đó:

db.legacy.find({ $nor: [ schema ] }, { _id: 1 })
[{"_id":2},{"_id":3}]

Vậy điều gì xảy ra khi ứng dụng sửa document _id: 2, dù chỉ thêm một field vô hại?

db.legacy.updateOne({ _id: 2 }, { $set: { note: "hello" } })
validationLevelSửa document đang sai (_id: 2)Làm hỏng document đang đúng (_id: 1, set status: "gone")Insert mới
strict (mặc định)✗ Document failed validation✗ bị từ chốikiểm tra
moderate✓ modifiedCount: 1✗ bị từ chốikiểm tra

Kết quả trong bảng là output thật từ lab. Hiểu nôm na:

  • strict: mọi insert và update đều phải cho ra document hợp lệ. Dữ liệu cũ sai sẽ "kẹt": muốn sửa gì cũng phải sửa cho đúng luôn.
  • moderate: document đang hợp lệ thì phải giữ hợp lệ, còn document vốn đã sai thì được cập nhật mà không bị kiểm tra. Đây là chế độ hợp lý khi bạn đưa validation vào một collection cũ và chưa kịp làm sạch dữ liệu.

Tài liệu MongoDB hiện tại còn liệt kê mức constraint (đảm bảo mọi document đều thoả quy tắc), đánh dấu mới ở phiên bản 9.0. Trên lab 8.3.11 của mình, lệnh này bị từ chối:

MongoServerError: Validation level 'constraint' is not supported with current FCV

validationAction: vi phạm thì làm gì

validationActionHành viGhi chú
error (mặc định)từ chối thao tác
warncho qua, ghi cảnh báo vào log của mongoddữ liệu sai vẫn được lưu
errorAndLogtừ chối và ghi logmới từ 8.1

Với warn, lệnh insertOne({ _id: 4, email: 123 }) trả về { acknowledged: true, insertedId: 4 }, còn log của mongod có dòng này (rút gọn):

{"s":"W","c":"STORAGE","id":20294,"msg":"Document would fail validation",
 "attr":{"namespace":"lab02.legacy","document":{"_id":4,"email":123},"errInfo":{...}}}

Với errorAndLog, insert bị từ chối (Document failed validation) và log có dòng "msg":"Document failed validation" kèm document vi phạm.

Một lộ trình thực tế để siết schema cho collection đang chạy:

1. collMod: validator + validationLevel "moderate" + validationAction "warn"
        ↓  theo dõi log vài ngày, tìm code nào còn ghi sai
2. sửa code ghi dữ liệu, làm sạch document cũ (tìm bằng $nor: [schema])
        ↓
3. chuyển validationAction "error" (hoặc "errorAndLog")
        ↓
4. khi dữ liệu cũ đã sạch: validationLevel "strict"

Những chỗ dễ vấp

additionalProperties: false và _id. Nếu bạn muốn cấm field lạ, nhớ khai báo cả _id trong properties. Mình thử schema chỉ có email và additionalProperties: false, rồi insert { email: "a@x.com" }:

Document failed validation
{ operatorName: 'additionalProperties', specifiedAs: { additionalProperties: false },
  additionalProperties: [ '_id' ] }

_id do driver tự thêm, nên nó bị coi là "field lạ", và mọi lệnh insert đều thất bại.

Validation có thể bị bỏ qua có chủ đích. Lệnh ghi có option bypassDocumentValidation: true (cần quyền phù hợp). Trong lab, insert với option này nhận { email: 789 } mà không lỗi. Validator là hàng rào cho code thông thường, không phải bức tường tuyệt đối.

Validation không thay thế kiểm tra ở ứng dụng. Ứng dụng vẫn nên validate để trả lỗi thân thiện cho người dùng. Validator ở database là lớp phòng thủ cuối, bắt những gì lọt qua: script migration, service khác, một hotfix vội.

Chi phí. Mỗi insert/update phải chạy thêm bước kiểm tra schema. Mình không đo chi phí này trong bài, nên không đưa con số. Nếu workload ghi của bạn rất lớn, hãy tự đo với schema thật của mình.

Cột mốc: Bạn đã có thể giải thích vì sao schemaless dễ gây hiểu nhầm, và viết một validator $jsonSchema để chặn dữ liệu sai lúc ghi. Tiếp theo: Giới hạn 16 MB; so với PostgreSQL JSONB.

Hỏi & đáp

Trong collection customers, createdAt của Binh là chuỗi "2025-06-01", của Chi là Date năm 2026. Query find({ createdAt: { $gt: new Date("2025-03-01") } }) trả về gì?

  1. Báo lỗi, vì không so được chuỗi với Date

    MongoDB không báo lỗi. Document có kiểu khác nhóm chỉ đơn giản không được so. Xem mục "Thí nghiệm: ba phiên bản app, một collection".

  2. Chỉ Chi; Binh biến mất khỏi kết quả mà không có lỗi nào

    $gt chỉ so với giá trị cùng nhóm kiểu BSON với toán hạng (type bracketing). createdAt của Binh là string nên bị bỏ qua. Lab trả [ { name: 'Chi' } ]. Xem mục "Thí nghiệm: ba phiên bản app, một collection".

  3. Cả Binh và Chi, vì MongoDB tự đổi chuỗi ngày sang Date khi so sánh

    MongoDB không tự chuyển kiểu: kiểu là một phần của dữ liệu. Binh biến mất khỏi kết quả dù rõ ràng thoả điều kiện. Xem mục "Thí nghiệm: ba phiên bản app, một collection".

Bạn gắn validator vào collection legacy đang có dữ liệu bằng collMod với validationLevel: "strict". Điều nào đúng?

  1. MongoDB quét dữ liệu cũ trước, và từ chối collMod nếu có document vi phạm

    Lệnh trả { ok: 1 } ngay: MongoDB không quét dữ liệu cũ khi thêm validator. Muốn biết document nào sai, tự hỏi bằng find({ $nor: [schema] }). Xem mục "validationLevel".

  2. Document cũ sai vẫn sửa được bình thường, vì strict chỉ áp cho insert mới

    Đó gần với hành vi của moderate. Với strict, chỉ $set: { note: "hello" } vào document _id: 2 đang sai cũng bị từ chối. Xem bảng ở mục "validationLevel".

  3. Thành công ngay; document sai vẫn còn, update lên chúng phải ra document hợp lệ

    Bảo vệ không lục soát người đã ở trong: collMod trả { ok: 1 }, _id: 2 và _id: 3 vẫn sai. Với strict, mọi update phải cho ra document hợp lệ, nên thêm một field vô hại vào _id: 2 cũng gặp Document failed validation. Xem mục "validationLevel".