假设你正在做一个在线学习平台。学生点击“提交答案”时,系统至少要保存答案正文、提交时间、提交次数和当前状态;如果这是最后一次机会,还得立刻把“允许再次提交”改成“否”。这些变化不能只成功一半。答案存下来了,但提交次数没增加,或者状态已经变成“已交卷”,答案却还是旧版本,都会让后续评分变得很麻烦。
这时候,真正值得先问的问题不是“应该建几张表”,而是:为了完成一次业务动作,哪些数据必须一起读取、一起判断、一起更新? 我们把这组需要作为整体看待的数据圈出来,就开始接近本章的主角——聚合。
聚合不是把所有相关数据塞进一个巨大对象,也不是 NoSQL 的同义词。它是一种划分业务边界的方法:边界里面的数据共同维护一组规则,边界外的数据通过标识相连。键值、文档和列族数据库经常把一个键、一个文档或一行当作自然的读写单位,所以聚合思想在这些数据库里格外重要。
学完这一章,你应该能够做到四件事:
数据模型描述的是我们如何理解和操作数据,存储模型描述的是数据库内部怎样把字节写进内存、磁盘或分布式节点。两者不是一回事。不过,数据模型会影响一次请求需要读多少数据、跨多少节点和承担多大的写入范围,所以建模时不能完全忽略访问成本。
我们先不急着看数据库类型,而是顺着一次提交动作往下走。
学生提交答案时,系统要检查提交是否仍然开放、当前是第几次提交、答案内容是否合法,然后生成一个新版本。这里有一条必须一直成立的业务规则:
已使用提交次数 ≤ 允许提交次数如果“提交次数”和“答案版本”被两个完全独立的入口随意修改,这条规则就很容易被并发请求破坏。更稳妥的做法,是让“作业提交”成为统一入口,所有修改都经过它。我们可以把它理解为聚合根:外部告诉它“提交这份答案”,由它检查规则并更新内部状态,而不是直接伸手改某个内部字段。

图里的“学生资料”和“课程内容”确实与提交有关,但它们不一定属于“作业提交聚合”。修改学生昵称不应该重写所有提交记录,教师修改课程简介也不应该锁住学生交作业。提交聚合只保存必要的 studentId、courseId 或 assignmentId,需要展示名称时再查询对应数据,或者保存一份明确允许稍后同步的快照。
由此可以得到一个很实用的判断:关系密切,不等于必须放进同一个聚合;必须在一次业务动作结束时共同满足规则,才是更强的边界信号。
一个边界画下去,通常会同时影响四件事。
边界太小,一次业务动作要跨好几个聚合协调,应用代码会出现重试、补偿和状态对账。边界太大,每次只改一个小字段也要碰到庞大的对象,热点、冲突和传输量又会上升。因此,聚合划分本质上是在一致性和可扩展性之间找一个可解释的平衡点。
“一个聚合一次原子更新”是很有用的建模起点,但不是所有 NoSQL 产品的绝对能力上限。现代数据库可能提供跨文档、跨项目或跨分区事务,只是这类事务通常需要更多协调、容量和失败处理。建模时应先让最常见的一致性需求落在自然边界内,再把跨边界事务留给确实不能拆开的业务。
假设学生打开“我的学习”页面,页面需要显示课程名称、章节完成情况、最近一次练习分数和最后学习时间。关系模型和聚合模型都能存这些数据,差别不在于谁能表达,而在于它们如何组织、如何读取。

关系模型倾向于把不同实体和事实拆成结构稳定的表。例如:
这样做的好处是课程名称通常只存一份。教师改名时,只改课程记录,所有关联查询都会看到新名称。代价是“我的学习”要从多个表组合结果:
SELECT
c.title,
ch.title AS chapter_title,
p.completion_rate,
s.score,
p.last_studied_at
FROM learning_progress AS p
JOIN course AS c ON c.id = p.course_id
JOIN chapter AS ch ON
关系模型把“减少重复”和“灵活组合”放在了很重要的位置。当查询经常跨不同方向变化、需要临时统计,或者数据之间存在复杂的多对多关系时,这种能力很有价值。
如果“我的学习”是高频页面,我们也可以把学生的学习状态组织成一个聚合:
{
"studentId": "stu_12345",
"profileVersion": 8,
"courses": [
{
"courseId": "course_001",
"courseTitleSnapshot": "NoSQL 数据库入门",
"lastStudiedAt": "2026-08-10T20:30:00+08:00",
"chapters": [
{
"chapterId": "ch_002",
"completionRate": 80,
"latestScore": 92
页面通过学生编号取回一份数据,就能完成大部分渲染。这里的 courseTitleSnapshot 是有意保存的快照,不是假装重复不存在。我们需要提前定义它的语义:它是“学生上次学习时看到的标题”,还是“为了省一次查询而复制的当前标题”?前者允许保留历史,后者就需要课程改名后同步更新或接受短暂延迟。
冗余本身不是错误,语义不清的冗余才危险。只要我们说清复制数据为什么存在、谁是权威来源、什么时候同步、失败后如何修复,冗余就可以成为换取更少读取和更低延迟的设计工具。
聚合建模不是把关系表机械地改写成 JSON。我们应该先列出系统真正要做的事情:
读取:按学生编号打开“我的学习”
读取:按课程编号查看全班章节完成率
写入:学生完成一个章节
写入:教师修改课程标题
统计:每天生成课程活跃度报表第一和第三个动作以学生为中心,第二和第四个动作以课程为中心,第五个动作会扫描许多学生和课程。一个聚合很难同时完美服务所有方向。实际系统常用多个有明确职责的模型:学生聚合承担在线进度读写,课程聚合承担课程编辑,统计管道把变化转成报表。这里不是“重复建模越多越好”,而是让每份模型服务一个清晰的访问目的。
即使已经决定使用文档或键值模型,我们仍要判断相关数据是直接嵌入,还是只保存引用。

下面这些信号同时出现得越多,嵌入通常越自然:
例如,一次提交下的“答案附件摘要”只有少量条目,删除提交时也应该一起删除,那么把摘要嵌入提交文档会很顺手。
如果出现下面的情况,我们就要认真考虑拆开:
以课程评论为例,课程可能长期存在,而评论会不断增加。把所有评论无限嵌入课程文档,开始时读取方便,后来却会遇到文档膨胀、更新竞争和分页困难。把评论拆成独立记录,并通过课程编号查询,边界会更稳。
把数据放在一个聚合里,并不自动消除并发覆盖。两位用户或两个后台任务可能同时读到版本 8,然后都尝试写回。一个常见办法是使用版本号做乐观并发控制:
const result = db.learningProfiles.updateOne(
{
studentId: "stu_12345",
version: 8
},
{
$set: {
"courses.0.chapters.0.completionRate": 100
},
$inc: {
version: 1
}
}
)
if (result.modifiedCount === 0) {
throw new Error("数据已变化,请重新读取后再提交")
}条件里的 version: 8 表示“只有数据仍是我刚刚读到的版本,才允许修改”。成功后版本加一;如果另一个请求抢先完成,当前更新就不会命中,我们可以重新读取、合并或提示冲突。这个做法把并发规则落在了聚合入口,而不是依赖“大家刚好不会同时修改”。
想象登录会话的读取过程。每个请求带着会话令牌,服务端只需要用令牌找到用户编号、权限和过期时间。它不需要先搜索“所有来自上海的会话”,也不需要把会话和课程表连接。这个场景天然适合键值模型。

最朴素的键值结构可以写成:
键:session:813
值:{ 用户编号: "stu_12345", 权限: ["学习者"], 过期时间: 1786456800 }键是数据的地址。一个好的键通常包含对象类型和唯一标识,例如:
session:813
student:stu_12345:settings
course:course_001:chapter_order冒号只是一种便于人阅读和管理的命名约定,不代表数据库自动理解了“学生”和“设置”的业务关系。应用仍然要负责键的生成、冲突避免、过期策略和访问权限。
概念上的键值模型只要求“通过键找到值”,但具体产品往往提供更丰富的数据结构。比如 Redis 的哈希可以在一个键下面保存多个字段,并对单个字段执行原子操作:
HSET student:stu_12345 name "小雨" level 3 lastChapter "ch_002"
HGET student:stu_12345 lastChapter
HINCRBY student:stu_12345 level 1
EXPIRE student:stu_12345 3600因此,“键值数据库完全不理解值内部结构”只适合描述最简单的抽象,不应该拿来概括每一种产品能力。真正的边界是:你的主要访问路径是不是由键决定?如果业务经常要按专业、分数、城市、嵌套数组字段筛选,那么只靠键会迫使我们维护额外索引、扫描大量数据,或者换用更适合内容查询的模型。
选择键值模型时,先写下“我是否总能拿到完整键”。如果答案是否定的,再写出需要的搜索条件。那些条件可能需要二级索引、集合结构、搜索模块或另一份专门的查询模型。
文档模型仍然会给每条记录一个唯一标识,但数据库能够理解文档里的对象、数组和字段。于是,我们既可以按编号取回整个聚合,也可以按内部字段筛选、投影、排序和建立索引。
{
"_id": "submission_9001",
"studentId": "stu_12345",
"assignmentId": "assign_008",
"status": "已评分",
"attempt": 2,
"answer": {
"text": "聚合边界应围绕一致性规则划分",
"attachments": []
},
"score": {
"value": 92,
"feedback": "边界判断清楚"
我们可以只找“已评分且分数不低于 90”的提交:
db.submissions.find(
{
status: "已评分",
"score.value": { $gte: 90 }
},
{
studentId: 1,
assignmentId: 1,
"score.value": 1
}
)第二个对象是投影,只取页面真正需要的字段。文档能被按内容查询,并不意味着任何查询都会自动变快。常用过滤字段仍需要合适索引;索引越多,查询路径越丰富,但写入、磁盘和内存成本也会上升。
在典型文档数据库里,一次针对单文档的写入可以原子地修改多个嵌套字段。这恰好适合把聚合内部的规则放在一起维护。以“重新提交答案”为例,我们可以在同一次更新里替换答案、增加次数、更新时间并推进版本号。
当数据拆成多个文档后,有些数据库也提供多文档事务。它能解决确实需要共同提交的操作,但我们仍要考虑事务涉及多少记录、是否跨分片、冲突概率、失败重试和容量成本。事务能力是安全网,不是无限扩大聚合的理由。
文档模型允许不同记录拥有不同字段,但生产系统不能把“灵活”理解成“字段随便写”。至少要约定:
如果旧文档里的 score 是数字,新文档里却变成对象,读取代码和索引都会遇到混合类型。一个可演进的做法是增加 schemaVersion,让读取端在过渡期识别不同版本,再用后台任务逐步转换,而不是假设历史数据会自动跟上代码。
现在把场景换成学习行为日志。一个学生可能有登录、看课、暂停、答题、交作业等很多事件,不同学生产生的事件类型并不相同。我们又经常需要查询“某个学生在一段时间内做了什么”。列族模型会把行键、列的分组和排序设计放到中心位置。

在 HBase、Bigtable 这一类模型里,可以用下面的方式理解:
它是一张稀疏的宽表:某行没有某个列,就不必为了表格整齐而存一堆空值。列族通常要预先规划,而列限定符可以随着写入出现。相关列被放进同一列族,也方便针对不同数据设置压缩、缓存和保留期限。
行键 student#007
profile:name -> 小林
profile:registeredAt -> 2026-02-03
progress:course#001 -> 80
activity:20260811T090000 -> 登录
activity:20260811T091200 -> 完成章节二这里把时间编码进活动列限定符,就能在一行内部按范围读取某段时间的事件。不过,一行并不是越宽越好。如果某位学生产生无限日志,这一行会持续膨胀,读写和维护成本都会上升。我们可以给行键增加时间桶:
student#007#2026-08
student#007#2026-09这样每月形成一行,查询一个月仍然方便,单行增长也有了边界。代价是跨多月查询需要读取多行。你会再次看到同一个原则:边界由最重要的访问模式决定,同时接受其他路径的协调成本。
“列族数据库”是一个宽泛类别,不能把所有产品术语强行揉成一套。Cassandra 常用分区键决定数据落在哪个分区,用聚簇列决定分区内部的排序。比如按学生和月份读取活动:
CREATE TABLE activity_by_student_month (
student_id text,
month text,
occurred_at timestamp,
event_id text,
event_type text,
detail text,
PRIMARY KEY ((student_id, month), occurred_at, event_id)
) WITH CLUSTERING ORDER BY (occurred_at DESC);(student_id, month) 是复合分区键,同一学生同一月份的数据进入一个逻辑分区;occurred_at 和 event_id 是聚簇列,负责分区内部顺序和唯一性。查询时通常先给出完整分区键,再在聚簇列上做范围限制:
SELECT occurred_at, event_type, detail
FROM activity_by_student_month
WHERE student_id = 'stu_007'
AND month = '2026-08'
AND occurred_at >= '2026-08-01T00:00:00+08:00'
AND occurred_at < '2026-08-12T00:00:00+08:00';如果产品经理后来提出“按事件类型查全站最近一小时记录”,上面这张表就不擅长。列族建模常见的做法不是临时扫全表,而是为新的查询方向设计另一张表或异步索引。数据可能会重复,但读取路径清楚、延迟更可预测。
行键或分区键并不只是“唯一编号”。它还影响数据落点、排序、范围查询和流量分布。假设所有事件都直接用递增时间作为行键:
20260811T090001
20260811T090002
20260811T090003新写入会不断落到排序空间的同一端。在按键范围切分数据的系统里,大量最新写入可能压到同一个服务节点,这就是热点。

解决热点不能只靠“把键彻底随机化”。完全随机虽然容易分散写入,却会破坏按学生、设备或时间范围扫描的便利。更实用的设计会组合业务前缀、时间桶和有限散列槽:
03#student007#202608#20260811T090001
11#student008#202608#20260811T090002
07#student009#202608#20260811T090003读取某个范围时,应用可能需要并行查询若干散列槽,再合并结果。我们用更多读取协调换来了均匀写入。因此,槽位数量也不是越多越好:太少压不住热点,太多会扩大查询扇出。
不要等到压测阶段才第一次检查键分布。用一批接近真实的数据生成行键,统计每个分区的记录数、写入速率和最大行大小,通常能很早发现“所有新数据都挤到一个地方”或“某个超级用户撑爆单个分区”的问题。
聚合边界很少能靠一张白板一次画对。更可靠的方式是从访问模式开始,用真实数据验证,再根据冲突和增长情况迭代。

先列出最关键的读写动作,而且要写得足够具体。不要只写“查询学生”,而要写“按学生编号读取最近学习的十门课程”和“完成章节后更新进度与最后学习时间”。具体动作才能暴露排序、范围和一致性要求。
再为每个写动作写出必须保持的业务规则。提交次数不能超过上限、订单总额必须等于明细合计、库存不能小于零,这些规则会告诉我们哪些数据需要在业务动作结束时共同正确。
接着圈出候选聚合,指定聚合根和外部引用。边界内的修改只能通过统一入口进行;边界外只保存编号或语义清楚的快照,避免对象关系一路嵌套到整个系统。
然后按读取频率、增长上限、独立生命周期和共享程度,决定嵌入还是引用。对于高频但不要求立即一致的查询,可以准备异步更新的读模型,不必强迫主聚合承担所有查询形状。
假设关键动作是“学生完成一个章节”。业务规则是:完成率在 0 到 100 之间、章节完成后更新时间必须推进、同一个事件重复到达不能重复加进度。候选聚合可以是“学生在某门课程中的学习状态”,键为 studentId + courseId,里面保存各章节的完成状态和已处理事件编号。
课程正文不属于这个聚合,因为教师修改正文与学生推进进度不是同一条一致性规则。全班统计也不属于它,因为统计会跨很多学生,可以由进度变化事件异步更新。这样一次完成章节只写一个学生课程聚合,课程编辑不会与学生学习争抢同一个对象,报表偶尔延迟也不会阻塞主流程。
不过,如果一门课程有几万个细小章节,整个课程进度可能变得过大。我们可以按模块拆成多个有界记录,或者只在主聚合保存总体进度,把详细事件放进按月分桶的日志。边界不是永久不变的,它应该随着真实规模和访问模式调整。
学生、课程、作业、提交和评分当然彼此相关,但把它们全部嵌在一起会得到一个没有边界的超级文档。先找需要共同维护的规则,再决定谁属于边界。
冗余可以减少读取,但每份复制都要有语义和同步策略。课程标题快照可以允许保留历史,而“当前课程标题副本”则需要处理更新失败和延迟。
没有数据库强制表结构,不等于应用不需要约束。核心字段、类型、版本、数组上限和迁移办法仍要明确,否则灵活会变成长期兼容负担。
跨记录事务能保护关键操作,但它不会自动解决热点、超大对象、访问路径混乱和高冲突。好的边界能让最常见的业务在较小范围内完成。
列族模型围绕行键、分区和预期查询设计,通常不提供关系数据库那样自由的连接。表面像表格,访问思路却完全不同。
随机键可能均匀写入,却让范围读取不得不扫描或扇出到很多分区。好键要同时考虑分布、排序和查询,必要时用有限散列槽做折中。
请再判断这个场景:学习平台要保存某门课程最近五年的全部访问事件,并支持按学生和月份查询。你会把所有事件嵌进课程文档,还是设计按学生与月份分桶的活动记录?先写下理由,再展开答案。
聚合数据模型真正改变的,不是 JSON 代替了表格,而是我们的提问顺序:先问一次业务要共同维护什么,再问数据要怎样存。
关系模型倾向于拆开事实、减少重复,并在查询时灵活组合;聚合模型倾向于把经常一起访问和更新的数据放在一个清晰边界内,以较少的网络往返完成工作。嵌入让共同读取和单边界更新更直接,引用则更适合独立、共享或持续增长的数据。键值模型依靠键直达数据,文档模型还能理解并查询内部字段,列族模型则围绕行键、分区和排序组织稀疏宽行。
没有一种边界能同时让所有查询、更新和统计都最省力。真正可靠的做法是:列出关键访问模式,找出一致性规则,控制聚合增长,明确冗余语义,设计可分布的键,再用真实规模验证。做到这一步,我们选择的就不再只是某个 NoSQL 产品,而是一套能解释、能测试、也能随业务演进的数据组织方式。
最后用接近真实规模的数据验证文档或分区大小、单次读取量、索引成本、写入冲突和热点分布。把异常重试、跨边界失败和修复流程也放进测试,而不是只测成功路径。