← 返回蜂巢洞察

Firestore数据建模指南:内嵌文档与引用方式的选择(结合一个博客案例进行分析)

当开发人员从关系型数据库世界(如MySQL、PostgreSQL)转向Firebase这种NoSQL文档数据库时,他们往往会沿用原有的习惯,试图在新的系统中复制表结构、外键以及关联操作。 其结果是什么呢?复杂的查询语句、飞涨的读取成本,以及那种在添加少量功能后就变得难以维护的数据库结构。 要理解Firestore的工作原理,我们首先需要对比它所基于的关系型模型。只有弄清楚SQL是如何处理数据的,才能清楚地看到Firestore与它的区别所在,从而了解如何正确地构建NoSQL数据结构。 在本指南中,我们将介绍NoSQL的设计原则、数据嵌入与引用机制,以及各种类型的关系建模方法(1-1、1-N、N

当开发人员从关系型数据库世界(如MySQL、PostgreSQL)转向Firebase这种NoSQL文档数据库时,他们往往会沿用原有的习惯,试图在新的系统中复制表结构、外键以及关联操作。

其结果是什么呢?复杂的查询语句、飞涨的读取成本,以及那种在添加少量功能后就变得难以维护的数据库结构。

要理解Firestore的工作原理,我们首先需要对比它所基于的关系型模型。只有弄清楚SQL是如何处理数据的,才能清楚地看到Firestore与它的区别所在,从而了解如何正确地构建NoSQL数据结构。

在本指南中,我们将介绍NoSQL的设计原则、数据嵌入与引用机制,以及各种类型的关系建模方法(1-1、1-N、N-N关系)。同时,我们还会通过一个具体的博客案例来进一步说明这些内容。

目录

  1. 先决条件

  2. 关系型思维模式:SQL如何处理数据

  3. Firestore的架构范式:带有关联关系的NoSQL数据库

  4. 核心构成要素:文档与集合

  5. 黄金法则:为读取而设计数据模型,而非写入操作

  6. 数据嵌入与引用机制(以及反规范化处理)

  7. 如何构建各种类型的关系模型(1-1、1-N、N-N关系)

  8. 最佳实践与需要避免的误区

  9. 案例研究:构建可扩展的博客数据库

先决条件

本指南属于概念性讲解,因此您不需要已经拥有正在运行的Firebase项目才能学习。只需具备以下基础即可:

  • 基本的JavaScript语法知识,因为所有的代码示例都会使用Firebase JS SDK(v9及以上版本)。

  • 对JSON对象有基本的了解,包括键、值、嵌套对象以及数组等概念。

  • 如果具备SQL或关系型数据库的相关经验会更好,因为本指南在讲解过程中会不断与之进行对比(但并非强制要求)。

  • (可选)如果您想亲自尝试这些示例,可以创建一个免费的Firebase项目。Firestore快速入门指南会指导您完成设置过程。

学习本指南并不需要任何先前的NoSQL或Firebase使用经验。

关系型思维模式:SQL如何处理数据

在关系型数据库中,数据被组织成由明确关联关系连接的表格。这种设计方式依赖于规范化机制来消除数据冗余。

例如,为了存储用户信息及其对应的国家信息,我们将数据分成了两个表:

  • Users表:包含列id(主键)、last_namefirst_name以及#country_id(外键,用于关联Countries表)

  • Countries表:包含列country_id(主键)和country_name

Users表中有一行数据为1, MINTOUMBA, Caleb, 1,而Countries表中有一行数据为1, Canada时,通过外键#country_id,我们就能自动判断Caleb属于加拿大。因此,我们根本不需要在Users表中直接写入“Canada”这个字。

关系模型示意图,显示了通过外键将Users表与Countries表关联起来

SQL模型的优缺点:写入操作较为简单(只需在一个地方更新数据),但读取操作则相对复杂,因为每次想要显示用户的国籍信息时,都必须执行数据库连接操作。

这与Firestore的工作方式正好相反,我们接下来会详细说明这一点。

Firestore模式:具有关系功能的NoSQL数据库

Firestore是一种NoSQL文档型数据库——顾名思义,它并不完全遵循SQL规范。这种数据库存储的是类似JSON格式的文档,这些文档被组织成集合,且没有强制性的数据结构规范。

在Firestore发展的早期阶段,这也意味着它不支持原生的连接操作,也不支持GROUP BY语句。标准的查询引擎并不支持这些功能,因此除了count()sum()average()等聚合操作外,其他任何复杂的计算都必须在应用程序代码中自行实现。

对于标准版来说,这种情况至今依然存在。标准版仍然是大多数移动应用和Web应用的默认选择,也是本指南重点介绍的版本。

后来,Google推出了Firestore企业版,该版本采用了全新的Pipeline查询引擎,这一引擎于2026年4月正式投入使用。Pipeline查询引擎支持多阶段查询语法,并提供了数百种新的功能,其中包括通过相关子查询实现的关系型连接操作,以及真正的aggregate(...)聚合函数,这些功能都可以实现对数据库数据的GROUP BY操作。

这是否意味着数据建模已经不再重要了呢?对于大多数应用来说,并非如此。Pipeline查询在60秒的超时限制内执行,其工作内存上限为128 MiB;当不存在索引时,系统会回退到全集合扫描的方式来进行数据检索。不过,企业版确实放弃了实时监听功能以及离线支持功能,而这些功能却是大多数Firestore客户端应用所必需的。

对于那些需要进行分析性查询、管理操作或报告生成的场景来说,Pipeline查询确实提供了很好的解决方案。但是,它们并不能替代那些为提升读取性能而专门设计的数据库结构,因为应用程序的日常界面仍然需要这种优化过的数据存储方式。

如果你在标准版上开发一款面向用户的应用程序,那么以下提到的嵌入关系模型及反规范化处理方法仍然是构建数据关系的常用方式。

但需要注意的是,即便是在标准版上,NoSQL也并不意味着“不存在数据关系”。你完全可以在集合之间建立复杂的数据关联关系。不同之处在于,Firestore并不会像SQL中的JOIN操作那样自动处理这些关系;作为开发者,你需要明确地构建和查询这些关系,并通过应用程序代码或Cloud Functions来确保数据的完整性——除非你特意选择了企业版才能使用基于Pipeline技术的关联功能。

核心构建模块:文档与集合

在设计任何数据结构之前,我们先来了解Firestore的两个核心构建模块:

  • 文档:存储的基本单位。它是一种类似JSON的对象,通过唯一的ID进行标识,其中包含各种类型的数据字段(字符串、数字、布尔值、时间戳、地理坐标或对其他文档的引用)。

  • 集合:用于存放文档的容器。与SQL表不同,同一个集合中的文档并不需要具有相同的结构。

Firestore的独特之处在于它的层次化结构:一个文档可以包含子集合,而这些子集合又可以继续包含更多的文档,以此类推。

Firestore层次结构图:posts集合中包含post_001文档,该文档下还包含comments子集合,其中存放着comment_001和comment_002等评论文档

在上面的图中,根级别的posts集合包含了post_001文档,而这个文档又包含了一个comments子集合,其中存放着comment_001comment_002这两条评论。你可以将集合和文档嵌套多层,但后来我们会发现,这种做法最好慎用。

重要规则:在读取父文档时,子集合永远不会被自动检索出来。与SQL中的JOIN不同,你必须单独执行查询操作才能获取子集合中的数据。

黄金法则:以读操作为依据设计数据结构

这是NoSQL建模中最为重要的概念,也是那些来自SQL背景的开发者最容易忽略的一点:数据结构的设计应该基于应用程序的读取需求,而不是数据的写入方式。

在编写任何数据库代码之前,先问问自己:

  • 我的应用程序中哪些界面会显示这些数据?

  • 我需要单独使用这一段数据,还是总是需要它与其他数据一起使用?

  • 与写入或更新数据相比,我读取这些数据的频率是否更高?

如果用户的某位作者每次更新用户名时,都会有10,000次其他人查看该作者的个人资料,那么就应该优化读取性能:在每一篇帖子中直接复制作者的用户名。这与我们之前提到的SQL处理方式完全相反——在SQL中,人们通常会先进行数据规范化处理以避免重复,即使这样会导致读取速度变慢。

嵌入与引用(反规范化)

在Firestore中,表示实体之间的关系主要有两种策略。

直接将评论嵌入到帖子文档中与通过单独的评论子集合进行引用的对比

选项A:嵌入(嵌套方式)

你可以将相关数据直接存储在父文档中,形式可以是数组或对象。

// 一篇包含评论的帖子
{
  title: "Firestore简介",
  author: "Caleb",
  comments: [
    { user: "Ama", text: "很棒的文章!" },
    { user: "Kofi", text: "感谢提供的示例" }
  ]
}
  • 优点:一次读取操作就能获取所有数据,且数据一致性得到保障。

  • 缺点:Firestore文档的大小有严格的1 MB限制。如果嵌套列表中的数据量无限增加(比如一篇热门帖子的评论数量很多),一旦超过这个限制,写入操作就会失败;而且每次对父文档进行写入操作时,都会将整个文档重新发送给所有正在实时监听的客户端。

  • 适用场景:适用于数据量较小、结构固定的列表,比如文章的标签、用户的设置信息或收藏列表等。

选项B:引用(反规范化)

你可以将相关实体分成不同的集合或子集合,并故意重复某些字段,从而避免进行第二次读取操作。

// posts/post_001
{
  title: "Firestore简介",
  authorId: "uid_123",
  authorName: "Caleb",      // 这里进行了反规范化处理,避免了需要再次访问"users"集合
  authorAvatar: "https://...", 
  commentCount: 12          // 这个计数字段也是通过反规范化方式存储的
}

// posts/post_001/comments/comment_001
{
  userId: "uid_456",
  userName: "Ama",
  text: "很棒的文章!",
  createdAt: Timestamp
}

在这里,我们将作者的名字和头像信息复制到每一篇帖子中,这样在显示帖子列表时就不需要再次访问users集合了。

这就是所谓的“反规范化”:我们通过允许数据存在重复来换取更快的读取速度,这与SQL中的规范化处理方式正好相反。不过这种做法的代价是,如果用户的名字发生了变化,这些复制的数据就需要进行更新——通常这是通过在users集合被修改时触发Cloud Function来处理的。

  • 优点:没有文档大小限制,且各个实体可以独立被查询。

  • 缺点:如果未进行足够的反规范化处理,就需要多次读取数据。当某个重复出现的值发生变化时,就需要编写代码(通常是Cloud Function)来将这一更新传播到所有复制该值的地方。

  • 适用场景:适用于数据量动态变化且增长速度快的情况,例如评论、订单历史记录、活动日志等。

更准确的判断标准:是否应该引用而非嵌入相关数据,取决于数据量的大小。对于那些会无限增长的数据集(如评论、订单历史记录),使用子集合来存储会比使用数组更加合适。

是否要对某个字段进行反规范化处理,应取决于保持其数据同步所需的成本,而不是该字段变化的频率:像commentCountlikeCount这样的计数器,由于是通过原子性操作进行更新的,因此没有任何需要同步的其他副本,所以无论这些字段变化的频率多高,进行反规范化处理的成本都很低。

而像authorName这样的字段,其值会在所有引用它的文档中被复制。只有当这种字段很少发生变化时,才适合对其进行反规范化处理;因为一旦该字段的值发生改变,就需要将这一更新传播到所有包含它的文档中。

如何构建关系模型(1-1、1-N、N-N关系)

一对一关系

可以选择将相关字段嵌入到同一个文档中,或者使用相同的文档ID将它们存储在不同的集合中,例如users/uid_123privateProfiles/uid_123。这种做法非常适合将公开数据与需要不同安全策略的敏感数据分开存储。

一对多关系

一对多关系图示,显示一篇帖子文档通过子集合与多条评论文档相关联

根据数据量和查询需求,主要有以下三种处理方式:

  1. 当您几乎总是通过父文档来查询评论,并且数据量较大时,子集合(如posts/post_001/comments/*)是理想的选择。

  2. 如果您还需要根据特定用户的身份来查询所有评论,而与这些评论所属的帖子无关,那么使用带有引用字段的根集合(例如包含postId字段的comments集合)会非常方便(查询语句为where("userId", "==", uid))。

  3. 只有当数据量较小且保持固定范围时,才适合使用嵌入数组这种结构(参见上述选项A)。

多对多关系

在NoSQL数据库中,构建多对多关系是最具挑战性的,因为不存在像SQL那样的自动连接表。常见的处理方式有三种:

多对多关系图示,显示用于关联用户和群体的成员关系集合

(1). 连接集合,其功能相当于SQL中的数据透视表:

// memberships/{membershipId}
{
  userId: "uid_123",
  groupId: "group_789",
  role: "admin",
  joinedAt: Timestamp
}

你可以使用.where("userId", "==", uid)来查询用户所属的所有群组,或者使用.where("groupId", "==", gid)来查询某个群组的所有成员。

(2). 双方都使用ID数组(跨维度反规范化):

// users/uid_123      -> groupIds: ["group_789", "group_456"]
// groups/group_789   -> memberIds: ["uid_123", "uid_456"]

从任意一方读取数据都非常方便,但这种方法只适用于那些规模较小、数据量不超过1MB的列表;因为当数据量较大时,对长数组进行原子性更新会带来性能问题。

(3). 混合模式,这是实际应用中最常见的做法:对于那些很少被从另一方查询到的关系(例如用户最喜欢的帖子),可以使用数组来存储;而对于那些需要频繁在双向上进行查询且数据容易发生变化的关系(例如团队成员信息),则应该使用连接集合。

最佳实践与需避免的误区

  • 限制嵌套层次:Firestore允许无限层地嵌套子集合,但超过两三层后,查询语句和安全规则的维护难度会显著增加。因此,在可能的情况下,最好将数据结构扁平化,并使用引用关系来关联不同层级的数据。

  • 避免使用自动递增的文档ID:顺序编号的文档ID(如user_1user_2等)可能会导致“热点现象”:大量写操作会集中在索引的某个特定范围内,从而降低系统性能。除非有特殊原因,否则应让Firestore生成随机且分布均匀的文档ID。

  • 注意复合索引的使用:任何同时包含多个.where()过滤条件,或者结合了不同字段的.where().orderBy()的查询,都需要使用复合索引。在设计阶段就应考虑到这些需求,而不要等到生产环境中才发现问题(Firestore会提供生成缺失索引的指导信息)。

  • 关注“热点”文档的写操作频率:建议单个文档的持续写操作速率不应超过每秒1次。例如,如果许多用户频繁更新某个全局计数器,那么这个文档很快就会成为性能瓶颈。Firestore可以通过排队处理短时间的高并发写操作,但当写操作频率超过每秒1次时,系统就会出现竞争条件错误。解决这个问题的常用方法是使用分片计数器:将计数数据分散存储在多个子文档中,并在读取时再进行汇总。

  • 谨慎使用子集合:虽然子集合使用起来很方便,但它们总是需要单独进行查询。如果你需要同时获取这些数据,那么直接将它们嵌入到主数据结构中或采用反规范化处理,通常会获得更好的性能。

  • 在设计数据模型时同步制定安全规则:Firestore的安全规则应与数据模型一起进行设计。如果数据结构设计不合理,那么编写精确的安全规则就会变得非常困难。

案例研究:设计可扩展的博客数据库

让我们将本指南中的所有原则结合到一个具体的例子中来说明——一个包含文章、评论和点赞功能的博客系统。

博客应用的完整Firestore数据结构,包括文章、评论子集合以及点赞集合

// posts/{postId}
{
  title: "Firestore数据模型设计",
  slug: "modeling-firestore",
  authorId: "uid_123",
  authorName: "Caleb",         // 非规范化存储:避免额外查询“users”表
  content: "...",
  tags: ["firebase", "nosql"], // 嵌入式存储:结构简洁,数据量有限
  commentCount: 3,             // 非规范化存储的计数字段
  likeCount: 47,               // 非规范化存储的计数字段(如果访问量很大,可以考虑分片存储)
  createdAt: Timestamp
}

// posts/{postId}/comments/{commentId}  → 子集合:与文章一起被读取
{
  userId: "uid_456",
  userName: "Ama",
  text: "这篇文章非常棒",
 createdAt: Timestamp
}

// likes/{likeId}  → 根目录集合+引用字段
{                    // 这种存储方式可以让你快速判断某个用户是否喜欢了某篇文章
  postId: "post_001",
  userId: "uid_456"
}

在这里,每一个数据存储方案都是为了解决特定的读取需求而设计的。标签总是与文章一起显示,因此被嵌入到文章数据结构中;评论的数量可能会很多,而且几乎总是会与对应的文章一起被读取,所以它们被存储在子集合中;而对于点赞信息来说,由于需要同时根据文章和用户来进行查询,才能确定某个用户是否喜欢了某篇文章,因此它们被存储在根目录集合中,并且包含了两个可用于索引的字段。

结论

在SQL中,人们通过数据规范化来消除冗余,但这种做法会使得读取操作变得复杂,需要通过联接操作来获取数据。而在Firestore中,情况恰恰相反:人们允许存在一定的冗余,这样可以提高读取操作的效率并降低读取成本,不过这样做的代价是写入操作的速度会稍慢一些。

在Firestore中设计数据结构,并不是简单地用不同的语法来应用关系型数据库的思维方式,而是一种完全不同的思考方式——这种方式是以应用程序的读取需求为中心来构建数据结构的。

在选择数据的存储方式时,一定要先考虑“自己将如何读取这些数据,以及读取的频率是多少”。同时,从设计阶段就开始了解Firestore的具体限制条件(例如每份文档的最大大小为1MB、复合索引的使用规则以及热点数据缓存机制),而不要等到生产环境才发现这些问题。

正是这种在读取效率与写入成本之间的平衡,使得Firestore数据库具备了良好的扩展性,而不会在六个月后就需要进行大规模的修改才能继续使用。

相关文章

技术实践

演讲主题:自主时代下的自主数据产品:重新思考通用人工智能的数据架构

约尔格·沙德详细解释了如何应对复杂的“数据管理问题”,从而构建出可扩展且安全的AI架构。他还介绍了自主数据产品是如何作为数据的“容器”来使用的——这些产品能够封装数据处理流程、数据结构以及元数据。通过使用MCP这样的协议,逐步发现并优化相关工具,可以有效避免数据关联关系的混乱,确保各项治理政策得到执行,并实现可靠的多模态数据访问功能。 作者:约尔格·沙德

阅读全文
技术实践

什么是HyDE?如何利用假设性文档来提升RAG的质量?

检索增强生成技术,通常被称为RAG,已成为利用大型语言模型构建应用程序时最常用的方法之一。 与让大型语言模型完全依据其训练数据来回答问题不同,RAG系统会从外部知识库中检索相关信息,并将这些信息作为上下文提供给模型。 其基本原理非常简单: 将用户的问题转化为嵌入向量。 在向量数据库中搜索语义上相似的文档片段。 将检索到的这些片段传递给大型语言模型。 基于这些片段生成答案。 然而,这个看似简单的过程存在一个重大缺陷:用户提出的问题与包含答案的文档在表达方式上可能存在很大差异。 例如,用户可能会提出这样的问题: 为什么我的AWS Glue作业在处理了几百万条记录后速度会显著下降? 而知识库中相关的

阅读全文
技术实践

从RPC到gRPC:了解远程过程调用、Protocol Buffers以及现代分布式系统的通信机制

任何应用程序在某些时候都需要与其他系统进行交互。移动应用会与后端服务进行通信;后端服务又会与支付网关对接;认证服务需要与用户服务进行交互;数据传输流程则要与存储系统相连。 问题不在于系统是否需要相互沟通,而在于应该如何实现这种沟通。 多年来,基于HTTP和JSON的REST架构一直是人们的首选方案。它运行稳定、使用简单,而且相关的开发工具也随处可见。然而,随着系统规模的扩大——无论是参与交互的服务数量增加、数据交换量增大,还是对实时通信的需求提高——REST架构逐渐暴露出了其局限性。 这时,远程过程调用、Protocol Buffers以及gRPC这些技术应运而生了。 通过这本手册,你将了解什

阅读全文
技术实践

如何使用Node.js和Google Gemini通过函数调用来构建一个人工智能代理

github.com/ziaongit/nodejs-gemini-agent 。 目录 功能调用机制的工作原理 我们正在构建什么 先决条件 项目设置 工具的定义 工具功能的实现 构建智能代理的循环机制 命令行入口点的设置 添加Express HTTP服务器 智能代理的测试 故障排除 接下来要构建什么 功能调用机制的工作原理 这里有一个让人感到惊讶的地方:Gemini并不会直接运行你的代码。它只会返回一个结构化对象,其中包含诸如“调用 get_weather 函数、将 city 设置为柏林”这样的指令。你的代码会接收到这些指令,然后执行相应的功能并将结果反馈回去。Gemini会检查这些结果是否

阅读全文