做自动化系统时,你很快就会碰到一种“不整齐”的数据。一个订单刚创建时,只有订单号、用户编号和金额;付款后多了支付渠道与流水号;发货后又要记录承运商、运单号和状态轨迹。如果每新增一个流程节点都要改表、补列、发迁移脚本,业务还没跑快,数据结构先成了团队的负担。
文档数据库正适合处理这种围绕一个业务对象不断补充上下文的场景。它把一条记录看成一份可以嵌套的文档:核心身份保持稳定,阶段性信息按需出现,数组保存重复项目,对象表达局部结构。我们不必把所有信息拆成很多张表,再为了还原一个订单做一连串连接。

不过,“字段可以变化”不等于“数据可以随便写”。一个可靠的文档模型仍然要回答几件很实际的事:哪些数据总是一起读取,哪些会独立增长,哪些字段必须存在,哪些查询值得建立索引,故障时一次写入要确认到什么程度,以及数据量上来后如何避免热点。这一章我们用 MongoDB 的语法做例子,但重点是文档数据库背后的建模方法。
文档数据库不是“没有结构”,而是把结构从固定表格变成由业务对象、访问方式和校验规则共同约束的文档模式。灵活性给了我们迭代空间,治理规则则保证团队不会把这种空间用成混乱。
你可以先把文档想成一张完整的业务卡片。MongoDB 在底层使用 BSON 保存文档,写代码时我们通常用接近 JSON 的对象来操作它。字符串、数字、布尔值和时间可以直接成为字段;对象可以继续嵌套;数组可以保存商品、步骤或历史状态。
下面是一份自动化订单文档。它不是为了展示“能塞多少字段”,而是把一次处理经常要一起用到的信息放在同一个边界里。
{
_id: ObjectId("66a100000000000000000901"),
orderId: "ORD-2026-0901",
userId: "U-2048",
status: "已付款",
amount: 299,
currency: "CNY",
shippingAddress: {
receiver: "小林",
city: "杭州",
detail: "文一路 88 号"
},
items: [
{ sku: "BOOK-17", name: "数据库设计手册", quantity: 1, price: 299 }
],
payment: {
channel: "银行卡",
transactionId: "PAY-86520",
paidAt: ISODate("2026-08-11T03:20:00Z")
},
statusHistory: [
{ status: "已创建", at: ISODate("2026-08-11T03:18:00Z") },
{ status: "已付款", at: ISODate("2026-08-11T03:20:00Z") }
],
createdAt: ISODate("2026-08-11T03:18:00Z"),
updatedAt: ISODate("2026-08-11T03:20:00Z"),
schemaVersion: 2
}
这里有四类信息值得分开看。orderId、status、amount 是标量字段,适合筛选、排序和统计;shippingAddress 是嵌套对象,它作为一个整体描述地址;items 和 statusHistory 是数组,用来保存数量可变但与订单紧密相关的条目;createdAt、updatedAt 则承担时间边界。字段名本身就是含义的一部分,所以文档具有较强的自描述性。
同一集合里的另一份文档可能还停在“已创建”,没有 payment 和物流信息。这并不表示数据损坏,只表示业务还没有走到那个阶段。真正需要稳定的是核心字段的意义与类型,而不是强迫每份文档拥有完全一样的外形。
点击三个流程状态,观察核心字段和阶段字段怎样变化。你还可以用键盘的 Tab 键移动焦点,再按 Enter 或空格切换状态。
第一层是业务约定。团队要明确核心字段的名称、含义、类型和生命周期,例如 orderId 永远代表业务订单号,status 只能在约定的状态集合里取值,时间字段统一保存为日期类型。第二层是数据库校验。已经稳定的核心规则可以写成集合校验器,让错误数据在写入入口被拦住。第三层是应用兼容。字段演化不可避免时,用 schemaVersion 标明结构版本,读取代码同时兼容一段时间,再通过后台任务逐步迁移旧文档。
db.createCollection("orders", {
validator: {
$jsonSchema: {
bsonType: "object",
required: ["orderId", "userId", "status", "amount", "createdAt"],
properties: {
orderId: { bsonType: "string" },
userId: { bsonType: "string" },
status: { enum: ["已创建", "已付款", "已发货", "已完成", "已取消"] },
amount: { bsonType: [
缺少字段和字段值为 null 不是一回事。缺少字段通常表示“这个概念尚未发生或不适用”,而 null 表示“字段存在,但当前没有具体值”。查询条件、索引行为和数据分析都可能受这一区别影响,所以要由业务语义决定,而不是习惯性地全部写成空值。
把数据做成文档时,最容易走向两个极端。一个极端是仍然按照关系型思路把每个小对象拆到不同集合,结果每次读取都要来回查找;另一个极端是把所有相关数据都塞进一份巨型文档,结果数组越长越大,更新冲突越来越多。更好的问题不是“文档数据库要不要反范式”,而是“这两块数据是否一起读取、一起更新、一起过期”。

订单的收货地址快照通常适合嵌入。你查看订单时一定需要它;它与订单同时创建;订单完成后它基本不再变化;即使用户后来修改常用地址,历史订单也应保留当时的地址。这种“属于当前对象、规模有界、一起读写”的数据,嵌入后更直接。
订单里的少量商品快照也常被嵌入,因为历史订单需要保留下单时的名称与价格。这里虽然出现了重复,却换来了明确的历史语义和一次读取。重复本身不是错误;无法解释重复数据由谁更新、允许旧多久,才是风险。
商品主档通常不应该完整复制进每个订单。库存会频繁变化,商品能独立存在,还会被大量订单共享。评论列表如果可能增长到几十万条,也不适合无边界地塞入文章文档。此时让订单保存 productId,让评论保存在独立集合,会更容易控制文档大小、更新竞争和生命周期。
下面用一段查询还原订单详情。你会发现引用并非禁止关联,而是把关联成本放到了读取路径上。
db.orders.aggregate([
{ $match: { orderId: "ORD-2026-0901" } },
{
$lookup: {
from: "products",
localField: "items.productId",
foreignField: "productId",
as: "productDetails"
}
},
{ $project: { orderId: 1, status: 1, items: 1, productDetails: 1 } }
]);勾选最符合业务关系的描述,实验会给出倾向和理由。它不是替代设计评审,而是迫使我们把“经常一起读”与“可能无限增长”这些信号摆到桌面上。
一个实用的折中是“引用主数据,嵌入业务快照”。订单用 productId 指向商品主档,同时嵌入下单时的商品名与成交价。主档负责当前事实,快照负责历史事实,两者的语义不再互相打架。
先列出业务对象的真实读写动作,例如“按订单号查看完整详情”“把状态从已付款改为已发货”“按用户查询最近订单”。不要从表或集合名称开始,因为访问方式才决定哪些数据值得放在一起。
再划定聚合边界。把需要一起读取、一起更新并且规模有界的数据放进同一文档;把独立增长、频繁单独修改或被多处共享的数据保留为引用。
接着写出核心约束与演化策略。明确必填字段、允许类型、枚举值、唯一性、时间语义和 schemaVersion,并决定旧版本由读取代码兼容还是由后台任务迁移。
最后拿真实查询验证模型。检查是否能用少量查询取回需要的数据,数组是否会失控,热点文档是否会被频繁并发更新,以及索引和分片键能否支持主要访问路径。
文档数据库可以按嵌套字段、数组元素和组合条件查询。点号路径让我们直接进入嵌套对象,$elemMatch 可以要求同一个数组元素同时满足多个条件,聚合管道则把过滤、分组和字段整理串成清晰的数据处理流程。
// 查找杭州用户最近完成的订单,只返回页面真正需要的字段
db.orders.find(
{
"shippingAddress.city": "杭州",
status: "已完成",
amount: { $gte: 200 }
},
{
_id: 0,
orderId: 1,
amount: 1,
createdAt: 1
}
).sort({ createdAt: -1 }).limit(20);// 同一个商品条目必须同时满足编号和数量条件
db.orders.find({
items: {
$elemMatch: {
sku: "BOOK-17",
quantity: { $gte: 2 }
}
}
});真正影响性能的不是语法看起来多简洁,而是查询要扫描多少索引项、读取多少文档、排序是否能借助索引完成。数据量小时,全表扫描也可能很快;增长到千万级之后,同一条查询就会把 CPU 和磁盘拖住。

假设页面经常按“用户编号 + 状态”筛选,再按创建时间倒序取前 20 条,我们可以建立下面的复合索引:
db.orders.createIndex({ userId: 1, status: 1, createdAt: -1 });这个索引能很好地支持 { userId, status } 的等值过滤和 createdAt 的排序,也能支持只按 userId 查询,因为 userId 是索引前缀。它却不能同样高效地服务“只按 status 查询”,因为跳过了最左侧的 userId。复合索引不是字段清单,字段顺序本身就是设计。
索引也不是免费的。每次插入或更新,数据库除了改文档,还要维护相关索引;索引占用磁盘,也会挤占内存中的工作集。读多写少的集合可能值得多建几个精准索引,高频事件写入集合则要更克制。
db.orders.find({
userId: "U-2048",
status: "已完成"
}).sort({ createdAt: -1 }).explain("executionStats");看执行计划时,不要只盯着“是否用了索引”。我们还要比较返回条数、检查的索引键数量和检查的文档数量。如果为了返回 20 条记录检查了几十万条索引项,这个索引即使被选中,也未必真正匹配查询。
选择不同查询,观察 { 用户编号, 状态, 创建时间 } 这个复合索引能利用到哪一段。实验把索引前缀规则做成可视反馈,便于你形成直觉。
文档模型还有一个非常实际的收益:对单个文档的一次写操作具有原子性。即使一次更新同时改了多个字段和嵌套数组,要么全部生效,要么都不生效。订单状态、更新时间和状态轨迹如果放在同一文档里,就能用一个操作一起修改。
db.orders.updateOne(
{ orderId: "ORD-2026-0901", status: "已付款" },
{
$set: {
status: "已发货",
logistics: { carrier: "快运", trackingNo: "SF-7201" },
updatedAt: new Date()
},
$push: {
statusHistory: { status: "已发货", at: new Date() }
}
}
);过滤条件里的 status: "已付款" 很关键。它不仅找到订单,还表达了我们预期的当前状态。如果另一个任务已经把订单改成“已取消”,这次更新将匹配不到文档,应用就能发现冲突,而不是悄悄覆盖对方的结果。高并发下,仅靠 _id 过滤再用 $set 写回旧值,很容易产生“最后一次写入获胜”的丢失更新。
计数器则更适合用 $inc,因为两个并发增量会在服务器端分别累加,而不是由客户端先读出旧值、计算后再互相覆盖。
db.articles.updateOne(
{ articleId: "A-901" },
{ $inc: { viewCount: 1 } }
);当一个业务动作必须跨多个文档保持全有或全无,例如扣减库存、写入订单和记录资金流水,MongoDB 可以使用多文档事务。它覆盖多个操作、集合,甚至分片,但协调、锁定和重试都会带来额外成本。
const session = db.getMongo().startSession();
const shop = session.getDatabase("shop");
try {
session.startTransaction();
const inventoryResult = shop.inventory.updateOne(
{ sku: "BOOK-17", stock: { $gte: 1 } },
{ $inc: { stock: -1 } }
);
if (inventoryResult.modifiedCount
这里还缺一个生产系统必须考虑的细节:事务提交可能遇到暂时性网络错误,驱动通常需要按错误类型安全重试;业务写入也应设计幂等键,避免调用方重试时创建两个订单。事务解决原子性,不会自动解决重复请求、超时后的未知结果和业务补偿。
“数据库支持事务”并不意味着所有关系型场景都应该原样搬过来。先用文档边界减少跨文档协调,再把事务留给真正不可拆分的业务动作。这样通常比到处开启事务更稳定,也更容易扩展。
单台数据库故障时,再好的文档模型也无法继续服务。副本集用一个主节点接收写入,多个从节点复制操作记录。主节点不可用后,符合条件的成员通过选举产生新的主节点,驱动重新选择服务器,应用在短暂中断后继续工作。

写关注不是抽象的“一致性开关”,它具体规定一次写入要得到多少节点确认。w: 1 等主节点确认即可返回,延迟较低;w: "majority" 等多数有投票权的数据节点持久确认后返回,主节点故障时回滚风险更低,但客户端需要等待更多网络与磁盘工作。
db.orders.insertOne(
{
orderId: "ORD-2026-0903",
status: "已创建",
amount: 499,
createdAt: new Date()
},
{
writeConcern: {
w: "majority",
j: true,
wtimeout: 5000
}
}
);订单、支付结果这类难以重建的数据通常值得使用多数确认;可由上游重放的行为事件,可以在明确接受风险后使用更低的确认级别。不要为了“快一点”统一放松,也不要为了“安全”给所有临时指标都上最高成本。数据能否重建、丢失一条会造成什么后果,才是选择依据。
还有一个容易误判的情况:写关注超时只表示规定时间内没有收到足够确认,不必然表示主节点完全没有写入。调用方若直接再发一遍,可能制造重复数据。因此业务唯一键和幂等处理与写关注应配合使用。
默认从主节点读取最容易获得新数据。从从节点读取可以分担某些查询,也能把报表流量放到专门节点,但复制存在时间差,刚写完的数据可能暂时看不到。订单支付完成后的确认页通常不该随意读从节点;允许几秒延迟的趋势看板则可能接受这种取舍。
副本集首先解决冗余和故障切换,不等于免费的横向读扩展。把所有查询扔给从节点会引入陈旧读取、资源竞争和更复杂的故障语义。只有能明确容忍延迟的查询,才适合调整读偏好。
当单个副本集的存储、写入吞吐或工作集无法继续增长时,可以把一个集合横向分布到多个分片。路由层读取查询条件,把请求定向到相关分片;配置服务器保存集群元数据;每个分片本身通常又是副本集,以兼顾容量与可用性。
分片最难的部分不是执行一条开启命令,而是选择分片键。一个好分片键至少要同时照顾三件事:取值足够分散,写入不会长期集中在少数范围,常见查询能带上它完成定向路由。

如果只用不断增长的 createdAt 做范围分片,最新订单会持续落到最末端的范围。即使数据最终能重新平衡,实时写入压力仍可能集中到当前持有这个范围的分片。地区、状态这类低基数字段也危险:看似有业务意义,实际只有少数取值,无法把压力充分摊开。
哈希分片可以把连续编号打散,让写入更均匀,但代价是范围相近的数据不再相邻。若业务经常查询一段订单号或一段时间,哈希键可能需要访问多个分片。分布和查询模式必须一起看,不能只追求“看起来平均”。
sh.shardCollection(
"shop.orders",
{ userId: "hashed" }
);上面的键适合“按用户编号查询订单”且用户分布较广的场景。若运营后台主要按时间跨用户统计,它就无法让每次查询都定向到单个分片。此时可以考虑复合分片键、单独的分析链路,或重新调整读模型。
选择不同分片策略,观察 12 次新订单写入如何分布。这里用简化路由建立直觉,真实系统还要用采样查询和运行指标验证。
先看基数:取值太少会限制可分出的独立范围。再看频率:即使取值很多,若大部分文档只集中在少数值上,仍然会形成热点。接着看单调性:连续增长的编号或时间会把新写入推向边缘。最后看查询:条件里带有分片键时,路由可以定向;不带分片键时,常常要把请求广播到多个分片,再汇总结果。
不要把“以后数据可能很大”当成立刻分片的理由。分片会增加部署、监控、备份、故障定位和查询路由的复杂度。先用正确模型、索引与容量评估把单个副本集用好,等指标说明瓶颈确实落在单集群边界时再扩展。
技术选型最怕从产品名字出发。我们先看业务数据是否天然围绕一个聚合对象组织,再看结构变化速度、访问路径和一致性边界。

文章、课程、页面和表单往往包含不同类型的内容块。某篇文章有代码,另一篇有测验,第三篇有图片与附加信息。把一篇内容的元数据和有界内容块组织成文档,读取页面时可以一次得到完整结构。评论、版本历史或大型媒体文件如果独立增长,则用引用分离出去。
图书有作者和 ISBN,服装有尺码与面料,硬件有规格参数。文档能让不同类别拥有各自的属性对象,同时保留统一的商品编号、名称、价格和分类。核心字段仍需校验,常用筛选属性仍需索引;“属性多变”不能成为字段命名失控的借口。
一个任务从触发、排队、执行到完成,会不断补充输入、输出、重试和日志摘要。把当前状态、核心上下文和有界状态轨迹放在同一文档,可以用单文档原子更新推进状态。体积很大的日志正文更适合外置存储,文档里只保留摘要和地址,避免每次读任务状态都搬运大量日志。
登录、点击、支付回调和设备告警的字段并不完全一致,文档能承接这种差异。不过事件通常写多读少,索引数量要克制;保留时间要明确;高吞吐分析最好进入专门的分析链路,而不是让在线业务集合同时承担任意报表。
跨很多实体的强事务若是系统日常主路径,关系型模型通常更自然。业务用户经常临时组合维度、连接多类数据做报表时,数据仓库和 SQL 工具往往更合适。要持续遍历多跳关系,例如“朋友的朋友中与我共同参与过三个项目的人”,图数据库更贴近问题结构。
这里的结论不是“文档数据库做不到”,而是完成同一件事所需的模型复杂度、运行成本和团队认知是否划算。现代文档数据库可以做事务、聚合和关联,但功能存在不代表它就是每条工作负载的最佳中心。
无限增长的评论、运行日志或状态明细会让文档越来越大,带来网络搬运、内存压力和更新竞争。嵌入前一定要问上限;没有可靠上限时,拆分、分桶或归档通常更安全。
索引越多,写入越慢,占用的磁盘和内存越大。索引应来自真实查询形状,并定期检查是否仍被使用。删除无用索引前也要观察完整业务周期,避免只看一天流量。
误删除和错误更新会被迅速复制到所有节点。副本集保护的是节点故障下的可用性,备份保护的是数据在某个时间点的可恢复性,两者都需要。
从节点也要回放复制日志,共享 CPU、磁盘和内存。报表查询可能拖慢复制,还可能读到旧数据。要通过延迟容忍度和资源隔离设计读路径,而不是机械地“读写分离”。
不带分片键的查询会广播,低基数或单调键会制造热点,跨分片事务与聚合也有协调成本。扩展效果来自数据分布和查询路由,而不是分片数量本身。
有一家课程平台把课程基础信息和所有学习记录放在同一文档中。上线一年后,热门课程的学习记录数组已经非常大,每次修改课程标题也要操作这份巨型文档。你会怎样调整模型?
文档数据库真正解决的,不是“表太多”这么简单的问题,而是让数据边界贴近应用实际使用的业务对象。你先从读写动作出发,把一起读取、一起更新且规模有界的数据组织成文档;再用校验规则和版本字段约束演化;用查询形状设计索引;用条件更新处理并发;用写关注、读偏好和事务明确一致性边界;最后在确有容量压力时,通过副本集和分片扩展。
如果只记住一句话,可以记住这一句:文档的灵活性不是免设计,而是允许我们围绕真实访问方式持续设计。 当团队能说清每一次嵌入、引用、索引和分片键背后的理由时,文档数据库才会从“方便存数据”变成一套可靠的工程能力。