“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字段加四步迁移法,脚本可重入可续跑 - 分工原则:结构约束在数据库层,业务规则在驱动层,重叠以数据库层为准
决策铁律:无模式不等于无约束。集合一旦被两个以上服务写入,就必须有显式的模式契约。校验器不是性能负担,而是防止数据腐化的最低成本手段;等到脏数据积累到无法迁移时,治理成本会高出十倍。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。