← 返回蜂巢洞察

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数据库具备了良好的扩展性,而不会在六个月后就需要进行大规模的修改才能继续使用。

相关文章

技术实践

Firestore是如何存储数据的,以及如何使用它来执行CRUD操作

大多数应用程序最终都需要存储和操作数据。如果你使用Firebase进行开发,那么这些数据就会存储在Firestore中——这是谷歌提供的一种灵活且可扩展的NoSQL文档数据库。 但在能够自信地创建、读取、更新或删除数据之前,你首先需要了解Firestore实际上是如何组织信息的。Firestore的结构与SQL数据库不同,如果将其视为SQL数据库来使用,那么最终结果很可能就是得到一个结构混乱、难以查询的数据模型。 在本教程中,你将学习 Firestore的NoSQL数据模型是如何工作的,然后通过使用Firebase Web SDK(v9及以上版本)来构建一个简单的任务管理应用程序,从而练习各种

阅读全文
技术实践

如何使用Python和Neo4j构建知识图谱【完整指南】

你所处理的大部分数据实际上都反映了各种关系:一个客户属于某个账户,某次事件会影响某种服务,而一名工程师负责维护某个代码库。你把所有这些信息存储在表格中,长期以来,这种存储方式一直运行得非常顺利。 然而,有时会有人提出这样的问题: 哪些工程师最近了解过昨晚那次事件所影响的服务情况? 这类问题很容易理解,但编写相应的SQL查询却相当困难。通常需要使用四到五次连接操作,每次连接都会生成一个比最终结果范围更广的中间数据集,而大部分这些中间数据最终都会被丢弃。随着表格规模的扩大,查询速度会变得越来越慢,而且每次查看这个查询语句时,都很难理解其具体逻辑。 为了解决这类问题,人们才创造了图数据库。 在这本手

阅读全文
技术实践

如何负责任地使用Lovable产品

过去,开发应用程序往往就像在没有任何说明书的情况下组装家具,而且还会缺少一半的螺丝。如今,像Lovable这样的人工智能工具可以帮助你用简单明了的语言描述自己的需求,从而将一个想法转化为可运行的网页应用。 这确实很令人兴奋——但同时也意味着一种责任。 Lovable能帮助你快速行动、尝试各种想法,并创造出实用的软件。不过,速度绝不能取代周密的思考。由人工智能生成的程序可能会存在安全问题、导致用户使用体验混乱、包含不准确的信息,或者其代码在演示环境中可以正常运行,但在实际使用中却会出故障。 在这份指南中,你将学习到如何在实际使用Lovable的过程中兼顾安全性、隐私性、可访问性以及用户的安全。我

阅读全文
技术实践

使用OpenTelemetry实现Claude Code的可观测性

像 Claude Code 、 OpenAI Codex 、 Google Antigravity 以及 Cursor 这样的代理编码工具,在日常软件开发中已经变得无处不在。 随着代理系统的不断发展,开发者让这些系统完成的大部分工作都是通过逐个分配子任务来实现的。许多团队也在探索并使用共享的、多租户式的代理基础设施,这种架构的成本不会与某个特定的所有者挂钩。在这种情况下,可观测性就成为了监控基础设施成本的关键因素。 在本指南中,您将了解可观测性的工作原理,然后学习如何启用Claude Code内置的遥测功能,运行后端程序来收集数据,并读取该系统生成的各类指标、日志及追踪信息。这些内容将帮助您更

阅读全文