36. MongoDB 模式校验与 Schema 治理

$jsonSchema 校验器与 Schema 治理:validationLevel 与 validationAction 语义、必填与类型与枚举校验、bypassDocumentValidation 场景、模式版本迁移,以及驱动层校验分工与评审清单。

“MongoDB 无模式"是最容易被误读的一句话。无模式意味着写入前不强制声明结构,而不是允许数据长成任意形状。当多个服务、多版本客户端同时写同一个集合时,字段缺失、类型漂移、枚举越界会迅速累积成技术债。本文从 $jsonSchema 校验器讲到模式版本迁移,给出数据库层与驱动层分工的可落地治理方案。

1. $jsonSchema 校验器基础

MongoDB 3.2 起支持在集合级别挂载 JSON Schema 校验器,3.6 起支持 $jsonSchema。校验器在写入与更新时生效,是数据库层的第一道防线。

1.1 创建带校验器的集合

db.createCollection("users", {
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["email", "createdAt", "status"],
      properties: {
        email: { bsonType: "string", pattern: "^.+@.+\\..+$" },
        createdAt: { bsonType: "date" },
        status: { enum: ["active", "inactive", "banned"] },
        age: { bsonType: "int", minimum: 0, maximum: 150 }
      }
    }
  },
  validationLevel: "strict",
  validationAction: "error"
})

1.2 给已有集合挂载校验器

已有集合通过 collMod 命令修改校验器:

db.runCommand({
  collMod: "users",
  validator: {
    $jsonSchema: {
      bsonType: "object",
      required: ["email", "status"],
      properties: {
        email: { bsonType: "string" },
        status: { enum: ["active", "inactive", "banned"] }
      }
    }
  },
  validationLevel: "moderate",
  validationAction: "warn"
})
操作命令适用时机
新建集合带校验db.createCollection新集合上线
已有集合加校验db.runCommand({ collMod })存量集合治理
移除校验collMod 传空 validator临时放开
查看校验器db.getCollectionInfos()审计核对

1.3 查看当前校验器

db.getCollectionInfos({ name: "users" })
// 返回 options.validator 与 validationLevel / validationAction

注意:$jsonSchema 校验的是文档结构,不校验唯一性、不校验跨文档一致性。唯一约束要靠唯一索引,跨文档一致性要靠应用层或事务。

2. validationLevel 与 validationAction

两个参数决定"校验多严"与"不合规怎么办”,是最容易配错的组合。

2.1 validationLevel 的三种取值

// strict:插入与更新都校验(默认)
db.runCommand({ collMod: "users", validationLevel: "strict" })

// moderate:只校验插入与"本就合法的文档"的更新,放过已不合规文档的更新
db.runCommand({ collMod: "users", validationLevel: "moderate" })

// off:完全关闭校验,仅保留校验器定义
db.runCommand({ collMod: "users", validationLevel: "off" })

moderate 的价值在于存量脏数据:已经不合规的文档在被更新时不会被拦,避免上线校验器后老数据无法修改。

2.2 validationAction 的两种取值

// error:拒绝不合规写入并报错(默认)
db.runCommand({ collMod: "users", validationAction: "error" })

// warn:允许写入,但在日志中记录告警
db.runCommand({ collMod: "users", validationAction: "warn" })
组合插入不合规更新不合规典型用途
strict + error拒绝拒绝新集合严格治理
strict + warn放行并告警放行并告警观察期灰度
moderate + error拒绝仅拦合规文档存量脏数据过渡
off放行放行紧急放开

决策铁律:上校验器的正确姿势是"先 warn 观察,再 error 拦截"。直接上 strict + error 会让线上写路径在未知的脏数据上集体报错。

2.3 灰度上线的两阶段

// 阶段一:告警观察两周,收集不合规样本
db.runCommand({ collMod: "users", validationLevel: "strict", validationAction: "warn" })

// 阶段二:确认无新增违规后切换为拒绝
db.runCommand({ collMod: "users", validationLevel: "strict", validationAction: "error" })

3. 校验规则进阶

3.1 必填与类型

{
  bsonType: "object",
  required: ["sku", "price", "stock"],
  properties: {
    sku: { bsonType: "string", minLength: 6, maxLength: 32 },
    price: { bsonType: ["int", "long", "double"], minimum: 0 },
    stock: { bsonType: "int", minimum: 0 }
  }
}

注意 bsonType 可传数组表示多种类型;int 与 long 与 double 是不同 BSON 类型,写 number 不是合法值。

3.2 枚举与正则

{
  properties: {
    status: { enum: ["draft", "published", "archived"] },
    slug: { bsonType: "string", pattern: "^[a-z0-9]+(-[a-z0-9]+)*$" },
    contact: {
      bsonType: "object",
      properties: {
        phone: { bsonType: "string", pattern: "^1[3-9]\\d{9}$" }
      }
    }
  }
}

3.3 嵌套对象与数组

{
  bsonType: "object",
  properties: {
    profile: {
      bsonType: "object",
      required: ["nickname"],
      properties: {
        nickname: { bsonType: "string", maxLength: 32 },
        avatar: { bsonType: "string" }
      },
      additionalProperties: false   // 禁止未声明字段
    },
    tags: {
      bsonType: "array",
      maxItems: 10,
      uniqueItems: true,
      items: { bsonType: "string", maxLength: 16 }
    }
  }
}
关键字作用注意
required字段必须存在只检查存在,不检查非空
bsonType类型约束数组写法表示多类型
enum枚举取值值变更需改校验器
pattern正则匹配注意转义反斜杠
additionalProperties禁止多余字段与演进灵活性冲突
uniqueItems数组元素唯一仅校验数组内

注意:additionalProperties: false 会拒绝任何未在 properties 中声明的字段,这在快速迭代的集合里是灾难。除强一致的配置集合外,一般不要开启。

3.4 校验器与索引的配合

校验器保证结构,唯一索引保证业务唯一性,两者互补:

db.products.createIndex({ sku: 1 }, { unique: true })
// 校验器保证 sku 是 string,唯一索引保证 sku 不重复

4. bypassDocumentValidation 与适用场景

某些写入需要绕过校验器,bypassDocumentValidation 就是那个开关。它需要用户具备 bypassDocumentValidation 权限。

4.1 单次写入绕过

db.users.insertOne(
  { email: "legacy@old.example", status: "migrated" },
  { bypassDocumentValidation: true }
)

驱动层同样支持该选项,例如 Node.js 驱动:

await db.collection("users").insertOne(doc, { bypassDocumentValidation: true })

4.2 批量与迁移场景

db.users.bulkWrite(
  [
    { insertOne: { document: { email: "a@x.com", status: "active" } } },
    { updateOne: { filter: { _id: 1 }, update: { $set: { legacy: true } } } }
  ],
  { bypassDocumentValidation: true }
)
场景是否该绕过理由
数据迁移导入是老数据先落库再清洗
紧急修复脚本视情况需审计留痕
正常业务写入否校验就是防线
后台批处理否批处理更应合规

重要:bypassDocumentValidation 一旦被滥用,校验器形同虚设。它应只出现在迁移脚本与修复脚本中,且这些脚本必须走代码评审并记录执行日志。生产业务的写入路径不应出现这个选项。

4.3 校验器与 update 的交互

校验器同样作用于 update:更新后若文档不合规,strict 会拒绝。注意 $set 引入的新字段若未在 properties 声明且开了 additionalProperties: false,会被拒绝。

db.users.updateOne(
  { _id: 1 },
  { $set: { status: "unknown" } }   // enum 不含 unknown,strict 下报错
)

5. 模式版本字段与渐进式迁移

Schema 演进的核心工具是模式版本字段。每条文档带一个 schemaVersion,应用读取时按版本分支,后台脚本按版本批次迁移。

5.1 引入版本字段

// 新文档默认写入当前版本
db.users.insertOne({
  email: "u@x.com",
  status: "active",
  schemaVersion: 2,
  createdAt: new Date()
})

// 存量文档补版本号(旧文档视为版本 1)
db.users.updateMany(
  { schemaVersion: { $exists: false } },
  { $set: { schemaVersion: 1 } }
)

5.2 渐进式迁移脚本

迁移脚本应按批次推进,避免一次性锁库:

// 逐批迁移:把 v1 的 name 拆成 firstName 与 lastName
let processed = 0
while (true) {
  const batch = db.users.find({ schemaVersion: 1 }).limit(1000).toArray()
  if (batch.length === 0) break
  const ops = batch.map(doc => ({
    updateOne: {
      filter: { _id: doc._id, schemaVersion: 1 },
      update: {
        $set: {
          firstName: (doc.name || "").split(" ")[0] || "",
          lastName: (doc.name || "").split(" ")[1] || "",
          schemaVersion: 2
        },
        $unset: { name: "" }
      }
    }
  }))
  db.users.bulkWrite(ops, { ordered: false })
  processed += batch.length
  print(`migrated ${processed}`)
  sleep(50)   // 让出资源,避免打满副本集
}

5.3 双写与读时兼容

迁移期间,应用层读文档时要兼容两个版本:

function displayName(doc) {
  if (doc.schemaVersion >= 2) {
    return `${doc.firstName} ${doc.lastName}`.trim()
  }
  return doc.name || ""
}
阶段写入读取迁移
引入版本字段写 v2兼容 v1/v2补版本号
双写期写 v2,可回填兼容 v1/v2分批迁移
收敛期只写 v2只读 v2校验无 v1
清理只写 v2只读 v2移除兼容分支

决策铁律:模式迁移永远是"先兼容读、再改写入、后迁移数据、最后清理代码"四步。任何跳步都会在灰度发布时炸出兼容性事故。迁移脚本必须可重入、可断点续跑。

6. 驱动层校验分工与治理规范

6.1 数据库层与驱动层的分工

校验项数据库层($jsonSchema)驱动层(Mongoose/Zod 等)
类型与必填支持支持
枚举与正则支持支持
业务规则(跨字段)不支持支持
默认值与转换不支持支持
跨文档唯一不支持(用唯一索引)部分支持
绕过难度难(需权限)易(可跳过)

分工原则:结构约束放数据库层,业务规则放驱动层,两者重叠部分以数据库层为准。

6.2 Mongoose 校验示例

const userSchema = new mongoose.Schema({
  email: { type: String, required: true, match: /^.+@.+\..+$/ },
  status: { type: String, enum: ["active", "inactive", "banned"], default: "active" },
  schemaVersion: { type: Number, default: 2 }
}, { strict: true, timestamps: true })

// strict: true 会丢弃未在 schema 中声明的字段

6.3 代码评审清单

  • 新增字段是否在 $jsonSchema 中声明
  • 字段类型是否与驱动 schema 一致
  • 枚举变更是否同步更新校验器与驱动
  • 是否存在绕过校验器的写入路径
  • 迁移脚本是否可重入、是否记录进度
  • 校验器变更是否走 collMod 并记录审计
// 审计:导出所有集合的校验器做对比
db.getCollectionInfos().forEach(c => {
  if (c.options && c.options.validator) {
    print(c.name, JSON.stringify(c.options.validator))
  }
})

重要:校验器是团队契约的代码化表达,必须纳入版本管理。建议把每个集合的 $jsonSchema 抽成独立文件,用脚本在 CI 中比对线上校验器与仓库定义,防止有人手动 collMod 改出差异。

7. 总结与最佳实践

  • 校验器价值:把结构契约固化到数据库层,多服务多版本写入时防止数据漂移
  • 上线姿势:先 warn 观察,再 error 拦截;存量脏数据用 moderate 过渡
  • 规则设计:必填、类型、枚举、正则、嵌套都要覆盖,慎用 additionalProperties: false
  • 绕过开关:bypassDocumentValidation 只留给迁移与修复脚本,业务写入禁用
  • 版本演进:schemaVersion 字段加四步迁移法,脚本可重入可续跑
  • 分工原则:结构约束在数据库层,业务规则在驱动层,重叠以数据库层为准

决策铁律:无模式不等于无约束。集合一旦被两个以上服务写入,就必须有显式的模式契约。校验器不是性能负担,而是防止数据腐化的最低成本手段;等到脏数据积累到无法迁移时,治理成本会高出十倍。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「mongodb」更多文章

  1. 数据生命周期、TTL 与冷热归档
  2. $graphLookup 与层次结构建模
  3. GridFS 与大文件存储实践