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关系)。同时,我们还会通过一个具体的博客案例来进一步说明这些内容。
目录
先决条件
本指南属于概念性讲解,因此您不需要已经拥有正在运行的Firebase项目才能学习。只需具备以下基础即可:
基本的JavaScript语法知识,因为所有的代码示例都会使用Firebase JS SDK(v9及以上版本)。
对JSON对象有基本的了解,包括键、值、嵌套对象以及数组等概念。
如果具备SQL或关系型数据库的相关经验会更好,因为本指南在讲解过程中会不断与之进行对比(但并非强制要求)。
(可选)如果您想亲自尝试这些示例,可以创建一个免费的Firebase项目。Firestore快速入门指南会指导您完成设置过程。
学习本指南并不需要任何先前的NoSQL或Firebase使用经验。
关系型思维模式:SQL如何处理数据
在关系型数据库中,数据被组织成由明确关联关系连接的表格。这种设计方式依赖于规范化机制来消除数据冗余。
例如,为了存储用户信息及其对应的国家信息,我们将数据分成了两个表:
Users表:包含列id(主键)、last_name、first_name以及#country_id(外键,用于关联Countries表)Countries表:包含列country_id(主键)和country_name
当Users表中有一行数据为1, MINTOUMBA, Caleb, 1,而Countries表中有一行数据为1, Canada时,通过外键#country_id,我们就能自动判断Caleb属于加拿大。因此,我们根本不需要在Users表中直接写入“Canada”这个字。
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的独特之处在于它的层次化结构:一个文档可以包含子集合,而这些子集合又可以继续包含更多的文档,以此类推。
在上面的图中,根级别的posts集合包含了post_001文档,而这个文档又包含了一个comments子集合,其中存放着comment_001和comment_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)来将这一更新传播到所有复制该值的地方。
适用场景:适用于数据量动态变化且增长速度快的情况,例如评论、订单历史记录、活动日志等。
更准确的判断标准:是否应该引用而非嵌入相关数据,取决于数据量的大小。对于那些会无限增长的数据集(如评论、订单历史记录),使用子集合来存储会比使用数组更加合适。
是否要对某个字段进行反规范化处理,应取决于保持其数据同步所需的成本,而不是该字段变化的频率:像commentCount、likeCount这样的计数器,由于是通过原子性操作进行更新的,因此没有任何需要同步的其他副本,所以无论这些字段变化的频率多高,进行反规范化处理的成本都很低。
而像authorName这样的字段,其值会在所有引用它的文档中被复制。只有当这种字段很少发生变化时,才适合对其进行反规范化处理;因为一旦该字段的值发生改变,就需要将这一更新传播到所有包含它的文档中。
如何构建关系模型(1-1、1-N、N-N关系)
一对一关系
可以选择将相关字段嵌入到同一个文档中,或者使用相同的文档ID将它们存储在不同的集合中,例如users/uid_123和privateProfiles/uid_123。这种做法非常适合将公开数据与需要不同安全策略的敏感数据分开存储。
一对多关系
根据数据量和查询需求,主要有以下三种处理方式:
当您几乎总是通过父文档来查询评论,并且数据量较大时,子集合(如
posts/post_001/comments/*)是理想的选择。如果您还需要根据特定用户的身份来查询所有评论,而与这些评论所属的帖子无关,那么使用
带有引用字段的根集合(例如包含postId字段的comments集合)会非常方便(查询语句为where("userId", "==", uid))。只有当数据量较小且保持固定范围时,才适合使用嵌入数组这种结构(参见上述选项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_1、user_2等)可能会导致“热点现象”:大量写操作会集中在索引的某个特定范围内,从而降低系统性能。除非有特殊原因,否则应让Firestore生成随机且分布均匀的文档ID。注意复合索引的使用:任何同时包含多个
.where()过滤条件,或者结合了不同字段的.where()与.orderBy()的查询,都需要使用复合索引。在设计阶段就应考虑到这些需求,而不要等到生产环境中才发现问题(Firestore会提供生成缺失索引的指导信息)。关注“热点”文档的写操作频率:建议单个文档的持续写操作速率不应超过每秒1次。例如,如果许多用户频繁更新某个全局计数器,那么这个文档很快就会成为性能瓶颈。Firestore可以通过排队处理短时间的高并发写操作,但当写操作频率超过每秒1次时,系统就会出现竞争条件错误。解决这个问题的常用方法是使用分片计数器:将计数数据分散存储在多个子文档中,并在读取时再进行汇总。
谨慎使用子集合:虽然子集合使用起来很方便,但它们总是需要单独进行查询。如果你需要同时获取这些数据,那么直接将它们嵌入到主数据结构中或采用反规范化处理,通常会获得更好的性能。
在设计数据模型时同步制定安全规则: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数据库具备了良好的扩展性,而不会在六个月后就需要进行大规模的修改才能继续使用。
相关文章
如何利用知识图谱来检测金融欺诈行为:一份实用指南
在企业支付工作中,大多数反欺诈架构都是孤立地评估每笔交易的。 当一张发票到达系统时,自动规则引擎会依据静态阈值来检查它:确认金额是否在正常范围内,供应商是否在批准的供应商名单上,以及发送请求的IP地址是否可疑。如果这些单独的属性看起来都符合要求,支付流程就会继续进行。 但这种方法的本质缺陷在于其结构设计。像资金转移链条、循环开票机制或虚假供应商这类复杂的欺诈手段,很少会生成那些能触发单一阈值警报的交易。例如,一家空壳公司开具4,800美元的发票,从而低于5,000美元的双重审批门槛,单独来看这个交易似乎完全合法。但当25家看似独立的供应商都将资金汇入同一个银行账户时,这种协同作案的模式就会暴露
阅读全文
《营销中的Python:营销分析、自动化及数据驱动发展的实用指南》[完整书籍]
市场营销一直以来都涉及处理大量的数据。在数字营销出现之前,企业必须追踪销售情况、顾客的购买行为、优惠券的使用情况、店铺的客流量以及调查问卷的反馈结果,才能了解顾客的需求。 虽然数字营销并没有发明市场分析技术,但它确实大大增加了可获取的信息量。 如今,几乎每一次互动都会产生数据。一个人可能会看到一则广告,点击它,访问一个网站,阅读一篇文章,订阅邮件列表,打开几封邮件,将某件商品加入购物车,然后离开网站,之后又通过搜索引擎重新回到该网站,最终完成购买。而所有这些互动都可能成为有价值的数据。 对于市场营销人员来说,这无疑带来了巨大的机会,但同时也带来了一个问题:拥有更多的数据并不一定能帮助人们做出更
阅读全文
在PostgreSQL中构建混合搜索引擎:BM25算法与向量检索技术的结合应用
大多数应用程序的搜索功能都不得不在两种方案之间做出取舍:要么使用简单的文本过滤机制,要么像Elasticsearch那样搭建独立的搜索系统,并配置复杂的同步流程。 在freeCodeCamp.org的YouTube频道上,我制作了一门新的视频课程,演示了如何在PostgreSQL中构建一套完整的、适用于生产环境的搜索系统。通过将词汇匹配技术与高维向量嵌入技术结合到同一个数据库中,我们既可以消除对外部服务的依赖,又能确保搜索索引满足ACID规范的要求。 搜索查询通常需要两种不同的检索方式: "Apollo 13" 这样的精确查询需要关键词与数据内容进行精准匹配,而这种处理方式正是关键词引擎的强项
阅读全文
“从设计层面确保安全性”:在编写任何代码之前,如何将安全机制纳入系统架构之中
在大多数工程团队中,都会进行这样的对话——不过往往为时已晚。 想象一下:某个功能被部署到生产环境中之后,才会进行安全审查;或者在进行渗透测试时发现了问题;更糟糕的是,还会收到事故报告。 这些漏洞确实是存在的,修复它们也需要投入大量成本。而在这些讨论中,总会有人说:“我们需要为这个功能添加安全措施。” 但这个问题就出在这里:安全并不是事后才添加到软件中的东西,而应该从项目一开始就被纳入设计之中。一旦开始考虑为已经存在的东西添加安全功能,那就意味着你已经进入了“事故应对模式”。 “安全驱动设计”是一种完全不同的思维方式——在制定架构决策之前,就应该明确安全要求;在编写任何代码之前,就必须了解潜在的
阅读全文