你接手了一套自动化订单系统。客服希望打开客户资料时立刻看到最近订单,仓库希望按订单逐笔拣货,财务希望按商品统计当天销售额,风控还想顺着账号、设备和收货地址找出可疑关系。乍看之下,这些需求都在“查订单”,真正落到数据模型上,却是在用四条完全不同的路径读同一批数据。
这正是 NoSQL 建模最容易被低估的地方:选中文档、键值、列族或图数据库只是第一步,更难的是决定哪些数据放在一起、哪些关系用引用、哪些查询提前算好,以及结构变化之后由谁来守住一致性。模型设计得好,常用请求一次就能拿到答案;模型设计得不好,业务代码会被回查、补偿、全量扫描和临时字段一点点拖垮。

这一章我们不从数据库产品清单出发,而是沿着一个工程问题向下追:当同一批数据有多种访问方式时,我们怎样为主要路径设计模型,又怎样为次要路径补上合适的读模型?
关系型建模常从实体、属性和规范化开始。NoSQL 建模更适合先问“系统会怎样使用数据”。原因很直接:很多 NoSQL 存储并不擅长在请求到来后临时连接任意数据,它们往往通过预先组织数据,换取可预测的读取次数和延迟。
例如,“查询订单”还不够具体。下面四句话才是可以指导建模的访问模式:
它们分别暴露了查询入口、返回范围、一致性边界和时效要求。只要这四件事没说清楚,“把客户和订单放在一起还是拆开”就没有唯一答案。
NoSQL 数据模型不是现实世界的唯一正确画像,而是为一组已知访问模式准备的读取与写入方案。同一个业务完全可能同时保留主聚合、搜索索引、统计读模型和关系图,它们各自回答不同问题。
开始设计前,我们通常会为每条访问模式补齐五个量:查询从哪个键开始、一次返回多少数据、读写频率大约是多少、一次写入必须原子覆盖哪些内容、结果最多可以陈旧多久。后面的每个取舍,几乎都能从这五个量里找到依据。
聚合可以理解为应用一次读取、保存或并发控制时面对的业务数据单元。以订单为例,订单状态、订单项、金额明细和下单时的收货快照通常会一起展示,也常常需要在一次状态变更中保持一致,因此把它们放进同一份订单文档很自然。
{
"订单编号": "ORD-2026-0811-001",
"客户编号": "CUS-1042",
"状态": "待拣货",
"下单时间": "2026-08-11T09:30:00+08:00",
"收货快照": {
"收货人": "林晓",
"城市": "杭州",
"地址摘要": "西湖区文三路"
},
"订单项": [
{
"商品编号": "SKU-27",
"商品名称快照": "桌面传感器",
"成交单价": 269,
"数量": 2
}
],
"应付金额": 538,
"版本": 7
}这里特意写了“快照”。商品名称、成交单价和收货地址即使来自其他业务对象,下单之后也属于这笔交易的历史事实。若订单页面每次都回查当前商品和当前地址,商品改名或客户搬家后,旧订单反而会被悄悄改写。适度复制数据并不一定是坏事,关键是先分清它是需要随源数据同步的副本,还是应当永久保留的业务快照。
嵌入数据适合下面这类关系:子数据总是跟随父对象读取;生命周期由父对象控制;数量有明确上限;父子经常一起更新;把它们放在一起能明显减少网络往返。
引用更适合另一类关系:对象需要独立查询和更新;同一对象会被许多聚合复用;关系是多对多;子集合会持续增长;嵌入后会制造大量需要同步的重复数据。
例如,客户资料和订单通常应该拆成独立聚合。客户可能修改会员等级,订单又会不断增长。若把全部历史订单嵌进客户文档,热门客户的文档会越来越大,任何一笔订单变化都可能争抢同一个客户对象的写入位置。拆开之后,订单只保存稳定的客户编号:
{
"客户编号": "CUS-1042",
"姓名": "林晓",
"会员等级": "金卡",
"联系方式": {
"手机号掩码": "138****2066"
},
"资料版本": 12
}客服查询“客户最近订单”时,可以按 客户编号 + 下单时间 建立索引,直接从订单集合取回最近 20 笔。这样既不需要让客户文档无限增长,也不必在客户文档里维护一份容易失真的订单编号数组。
边界拆开以后,一次业务动作可能要改多个地方。比如客户取消订单时,系统要更新订单状态、释放库存、退回优惠额度并刷新报表。这些动作不再天然属于一个小而清晰的原子单元。不同数据库提供的事务能力并不相同,即使某个产品支持跨文档事务,也不代表我们应该把每条高频路径都设计成长事务。
更稳妥的做法是先划分事实:订单聚合负责确认“订单已取消”,库存和优惠系统消费这个事实,分别完成自己的状态变化。每个消费者都要做到幂等,也就是同一事件重复到达时不会重复释放库存或重复退款;失败的步骤需要重试,长时间未完成的流程需要告警或补偿。
async function 取消订单(订单编号, 请求编号) {
const 订单 = await 读取订单(订单编号)
if (订单.已处理请求.includes(请求编号)) return 订单
if (订单.状态 === "已发货") throw new Error("已发货订单不能直接取消")
订单.状态 = "已取消"
订单.已处理请求.push(请求编号)
订单.待发布事件.push({
类型:
这段代码把状态变更与“待发布事件”放进同一聚合保存。后续发布器即使中途崩溃,也能再次读取未发布事件继续发送。它没有让分布式步骤瞬间变成一个事务,却把失败之后如何恢复说清楚了。
不要把“数据库支持灵活嵌套”理解为“整条业务链都应该塞进一份大文档”。过大的聚合会扩大写冲突、增加无效传输,并让一个不断增长的数组成为容量和延迟风险。聚合边界既要围住一致性,也要限制增长。
下面的实验会读取你选择的主要查询、子集合规模和一致性要求,再给出建模倾向。它不是自动替你做架构决定,而是让每个决定背后的证据显性化。
这个实验揭示了一个很实用的事实:嵌入与引用不是阵营选择,而是多股力量的平衡。主要读取方式把数据往一起拉,独立生命周期和持续增长把数据往外推,原子性要求又会重新影响边界。
聚合模型擅长回答“从这个键出发,把这一包数据拿回来”。可有些问题的重点不是某个对象本身,而是对象之间的路径。风控人员可能会问:某个新注册账号是否与已冻结账号共用设备?二者是否又使用了相同收货地址?路径长度可能只有两三跳,但关系类型很多,而且问题会不断变化。
如果把账号、设备和地址分别放在关系表或文档集合中,我们当然也能写多次连接或多轮查询。麻烦在于,每增加一层关系,查询和中间结果都更难控制。图数据库把关系提升为一等数据,让“从起点沿指定类型的边继续走”成为最自然的操作。

属性图通常由四个要素组成:
关系本身能带属性这一点很关键。“小林报名了数据库课程”不只是一条连线,报名时间、报名渠道、完成进度都属于这次报名关系,而不是永久属于小林或课程。
MATCH (起点:用户 {用户编号: "U-1008"})
-[:关注]->(好友:用户)
-[报名:报名]->(课程:课程)
WHERE 报名.完成进度 >= 80
RETURN 好友.昵称, 课程.名称, 报名.完成进度
ORDER BY 报名.完成进度 DESC读这段模式时,你可以把圆括号看作节点,把方括号看作关系,把箭头看作遍历方向。查询先通过稳定编号找到起点,再沿“关注”和“报名”走两步。图查询的可读性来自它与业务问题的形状相似。
图遍历并不意味着不需要索引。系统仍然要先通过用户编号、设备指纹或课程编号找到少量起点。找到起点以后,才沿相邻关系继续搜索。若一开始就让查询匹配任意节点,或者不限制关系类型与最大跳数,候选路径可能迅速膨胀。
这也是图建模的一个边界:图数据库擅长相关子图的局部遍历,但“扫描全部节点后做简单汇总”未必是它最有优势的任务。拥有数百万条边的超级节点也需要单独治理,例如按时间或地区拆分关系、增加关系过滤条件,或者准备专用的汇总模型。
点击不同起点,再选择“只走一步”或“走两步”。你会看到起点索引、关系类型和跳数如何共同缩小搜索范围。
图数据库不是“关系多就一定要用”的通用答案。只有当路径遍历、可达性、共同邻居、最短路径或关系模式本身是高频核心查询时,图模型才会明显简化问题。按编号取整包数据、批量统计和顺序日志仍可能更适合其他模型。
自动化平台里的任务记录很容易发生结构变化。邮件任务需要主题和收件人,数据同步任务需要源表和目标表,审批任务还会多出审批链。若强迫所有任务共享一组固定字段,就会出现大量空值;若把所有差异都塞进没有语义的“扩展字段 1、扩展字段 2”,代码又会越来越难懂。
文档数据库允许同一集合中的文档拥有不同字段,这让我们可以把稳定的公共部分与按任务类型变化的配置分开:
{
"任务编号": "TASK-8821",
"任务类型": "数据同步",
"状态": "运行中",
"创建时间": "2026-08-11T10:00:00+08:00",
"模式版本": 3,
"配置": {
"源": { "连接编号": "CONN-A", "数据集": "orders" },
"目标": { "连接编号": "CONN-B", "数据集": "order_archive"

真正的模式仍然存在:任务编号必须是字符串,状态只能取有限值,创建时间要用统一格式,数据同步任务的配置里必须有源和目标。区别只是这些约束不再全部由建表语句提前声明,而是可以分布在数据库校验、写入服务、类型定义、测试和迁移程序中。
一个实用的折中是把字段分成三层:
如果一个团队写 失败次数: 3,另一个团队写 retryCount: "三次",数据库也许都能保存,告警查询却无法可靠比较。无模式最危险的误解,就是把“可以写进去”当成“以后一定读得出来”。
假设版本 1 的学员档案只有姓名和邮箱,版本 2 增加学习偏好,版本 3 又把单个设备字段改成设备数组。不要要求所有历史文档在发布瞬间一次性迁完。读取程序可以先兼容旧版本,写入程序只产生新版本,再由后台任务慢慢回填。
function 读取常用设备(档案) {
if (档案.模式版本 >= 3) {
return 档案.常用设备 ?? []
}
if (档案.模式版本 === 2 && 档案.常用设备名称) {
return [{ 名称: 档案.常用设备名称, 迁移说明: "转换自旧字段" }]
}
return []
}一次安全演进通常经历四个阶段:先让读取端兼容新旧结构;再让写入端开始写新结构;随后后台回填旧数据并监控失败;确认旧版本数量归零后,最后删除兼容代码。顺序反过来,就会出现新代码读不懂旧数据,或者旧服务读不懂新字段的问题。
先列出不可破坏的核心契约,例如任务编号始终可寻址、状态始终可枚举、时间字段始终能比较。它们应当在入口处校验,而不是等报表报错后再清洗。
再给结构变化分配明确的模式版本,并让读取端同时理解当前版本和上一个仍在使用的版本。兼容窗口要有截止条件,不能永久堆叠分支。
接着通过小批量、可重试的后台任务回填历史数据。每批都记录成功数、跳过数和失败原因,重复执行不会制造重复数据。
最后统计各版本存量,确认没有旧写入端继续制造旧结构,再收紧数据库校验并删除过期读取逻辑。
灵活模式在单个小团队里很舒服,一旦多个应用直接写同一集合,隐式约定就容易分叉。应用甲认为 状态 可以为空,应用乙却按状态建立告警;应用丙把时间写成本地字符串,分析任务则按统一时区计算。
这时通常有两种治理方式。第一种是把写入集中到一个拥有明确接口的数据服务,其他应用只能通过接口读写。第二种是划分清晰的所有权边界:每个服务只维护自己负责的文档类型或字段区域,跨边界变化通过事件传递。无论选哪一种,都要有可执行的校验和兼容测试,只有一页字段说明还不够。
“无模式”更准确的理解是“模式可以渐进演进”。生产系统依然需要字段契约、版本策略、校验、可观测性和迁移流程。数据库越宽松,应用层越要明确谁能写、写成什么样、旧数据怎样退出。
订单聚合已经很适合按订单编号读取,但财务要看“过去 24 小时各商品销量”,客服要看“某客户最近 20 笔订单”。如果每次打开页面都扫描大量订单再临时汇总,主聚合的边界就与这些查询方向冲突了。
这时可以为查询单独准备一份派生数据,也就是物化视图或读模型。它把已经计算好的结果存下来,请求到来时直接读取。它不是另一份需要人工维护的真相,而是能从源数据重新生成的投影。

下面是一份按商品和小时组织的销量读模型:
{
"视图键": "SKU-27#2026-08-11T10",
"商品编号": "SKU-27",
"时间桶": "2026-08-11T10:00:00+08:00",
"订单数": 186,
"销售件数": 241,
"销售额": 64829,
"最后事件位置": 918273,
"刷新时间": "2026-08-11T10:04:12+08:00"
}最后事件位置 让处理器知道自己已经消费到哪里。若同一订单事件重复送达,处理器还需要通过事件编号去重,或者使用带条件的更新,避免销量被重复累加。
同步更新是在写源数据的同时更新视图。它的优势是读模型很新,代价是写路径更长、耦合更强。只有当数据库能在可接受的原子边界内同时更新两份数据,或业务能承受失败处理复杂度时,才适合这样做。
事件驱动的增量更新是在源聚合成功后发布事件,由消费者更新一个或多个读模型。它能把写入者和查询模型解耦,也能独立扩展消费者,但读模型会短暂落后,并且必须处理重复、乱序、积压和重放。
定时批量重算按小时或每天扫描一个时间范围,重新计算视图。它最容易解释和校验,适合允许较大陈旧度的报表;数据量很大时,则需要分区、增量窗口和资源调度。
这三种策略可以组合。例如销量榜由事件实时增量更新,每晚再从订单事实重算当天数据,修正漏处理或程序缺陷造成的偏差。实时链路负责快,批量对账负责稳。
async function 应用订单已支付事件(事件) {
if (await 已处理(事件.事件编号)) return
const 视图键 = 事件.商品编号 + "#" + 事件.支付小时
await 原子更新(视图键, {
订单数增加: 1,
销售件数增加: 事件.数量,
销售额增加: 事件.金额,
最后事件位置: 事件.位置
})
await 标记已处理(事件.事件编号)
}上面的伪代码强调的是职责,不绑定具体数据库。真实实现还要判断“更新视图”和“标记已处理”能否落在同一原子范围内;如果不能,就要让重复执行本身安全,例如按订单项保存贡献值,再以覆盖而不是盲目累加的方式更新。
物化视图的核心取舍不是“实时还是不实时”这么简单,而是允许落后多久、落后时怎样被发现、超过阈值以后怎样处理。讲师日报落后半小时可能毫无影响,自动停用高风险账号的视图落后半小时就可能不可接受。
因此读模型至少应暴露刷新时间、处理位置和积压量。页面可以在数据过旧时显示提示,关键决策可以回查源数据,运维系统则应在积压超过阈值时告警。
拖动写入速率和处理能力,再切换刷新方式。你会看到延迟、写入负担和读取成本如何互相交换。
现在把前面的取舍放进同一个系统。用户李先生在家里安装了灯、空调、门锁和传感器。最初的页面只需要“打开家庭面板,一次看到所有设备状态”,于是团队把设备嵌进家庭文档。上线后,设备数量增长,运维团队又要查所有离线门锁,算法团队还想分析传感器与故障设备之间的关联。

问题不是初版设计一定错了,而是访问模式变多了。我们可以按不同职责组织几份模型:
主数据与派生数据要有明确身份。设备当前状态可能是源事实,地区在线率则是可重建的派生结果;家庭面板里的设备名称可以是为了快速显示而保留的副本,但必须说明它由谁更新、允许落后多久。
产品提出新需求:“在 2 秒内找出某个家庭中所有离线设备,并显示它们所在房间;如果同一网关下超过 30% 的设备离线,标记为疑似网关故障。”我们可以这样推导:
先拆出两个访问模式。页面需要按家庭编号读取离线设备及房间;诊断规则需要按网关读取连接设备与离线比例。它们的查询入口并不相同。
再检查数据规模。单个家庭设备数有业务上限,可以在家庭读模型中保留设备摘要;设备遥测会无限增长,必须进入按时间分区的独立存储。
接着确定一致性。设备心跳晚几秒显示通常可以接受,所以家庭面板和网关诊断都可以由心跳事件异步更新,不必阻塞每次设备上报。
然后设计恢复。每条心跳带事件编号和设备状态版本,消费者只接受更新版本;每日任务按设备事实重建网关统计,修正漏事件造成的偏差。
这条推导里没有先问“该用哪一种 NoSQL”。它先把查询、规模、一致性和恢复说清楚,数据库能力才有可比较的落点。

面对新的业务域,你可以按下面的顺序推进。
把每条访问写成“从什么键出发,在什么延迟内,取回或修改什么范围”。标出平均与峰值频率,不要只收集页面截图。后台任务、补偿流程、审计查询和数据导出同样是访问模式。
问清楚哪些字段必须一起成功,哪些结果允许短暂落后。不要笼统写“强一致”或“最终一致”,而要写出可观察的业务后果。例如“库存最多允许晚 2 秒释放,但不能重复释放”就比“最终一致”更能指导实现。
让最重要、最频繁的业务动作在少量聚合内完成。检查子集合是否有上限、单个键是否会成为热点、对象是否拥有独立生命周期,以及更新冲突会不会集中。
主聚合不必讨好所有查询。对稳定的筛选路径建立索引;对高频汇总、反向查询和不同排序准备物化读模型;对路径问题考虑图模型。每份派生模型都记录生成依据、刷新策略和重建方法。
拿峰值数字做桌面演算:最大文档有多大,分区会增长到多少,热门键每秒承受多少写,事件积压一个小时会怎样。再故意让更新执行到一半失败,确认重试是否幂等、用户会看到什么、运维如何发现。
模型上线后要观察真实的文档大小分布、读写延迟、热点键、索引命中、事件积压、视图陈旧度和模式版本存量。NoSQL 模型常随访问模式演进,调整不是失败,而是闭环的一部分。
一个成熟的 NoSQL 模型不需要让所有查询都只读一次,也不追求零重复。它追求的是:主要路径足够直接,重复数据有明确语义,跨边界失败可以恢复,结构演进可以观察,任何派生结果都能解释从哪里来、多久更新一次。
这会把关系型规范化思路原样搬过来,最终每个页面都做多轮远程读取。NoSQL 中可以复制稳定快照或派生摘要,但要区分“历史事实”和“同步副本”,并为同步副本明确更新责任。
这会让聚合无限增长,也让无关更新争抢同一个键。先检查生命周期和规模上限。一起读取只是嵌入的一个理由,不是唯一理由。
字段错误不会消失,只会延后到查询、统计或跨团队集成时爆发。核心字段应当尽早拒绝错误,扩展字段也要按类型维护局部契约。
异步消费者可能重复、乱序、暂停,程序也可能有缺陷。每个重要视图都需要幂等策略、陈旧度指标、对账和重建入口。
图模型的优势集中在受约束的关系遍历。若请求只是按键读取、顺序写日志或做全局简单汇总,换成图结构反而可能增加复杂度。
再做一道开放练习:某自动化平台要保存流程运行记录。单次运行包含有限的步骤摘要,但每个步骤会不断产生详细日志;运营还要按流程模板查看每天的成功率。请分别为“运行聚合”“详细日志”“成功率读模型”设计主键与更新方式,并说明哪一部分允许短暂陈旧。
深入的数据建模不是寻找一种能包办所有查询的结构,而是学会识别不同访问路径之间的冲突。聚合把一起读取、一起变化的数据围进明确边界;引用让独立对象能够各自增长;图模型让关系路径成为直接可查询的结构;灵活模式让字段渐进演进,但也把契约治理交给应用;物化视图则把计算搬到写入或后台处理阶段,用可控的陈旧度换取稳定读取。
你可以把整章浓缩成一句话:从查询出发划边界,用明确的恢复与演进机制为边界之外的复杂性买单。 只要每一份数据都能回答“谁拥有、怎样更新、允许多旧、失败如何恢复”,模型就不只是跑得快,也更容易在真实业务里长期维护。
最后验证热点与容量。拥有大量设备的网关不能让所有更新争抢同一个计数文档,可以按网关和时间片分桶,再汇总为页面需要的结果。