Firestore 是 Google 提供的 NoSQL 文档数据库。对于长期使用 MySQL、PostgreSQL、SQL Server 或 Oracle 的开发人员来说,真正困难的地方通常不是学习几个 API,而是改变数据建模的方法。关系型数据库习惯从表、主键、外键、范式和 JOIN 出发,而 Firestore 更强调文档、集合、子集合、索引以及实际的数据访问模式。
Firestore 的基本存储单位是 document,文档属于 collection。一个文档可以包含字符串、数字、时间戳、数组以及嵌套对象,也可以在文档下面建立 subcollection。与关系型数据库不同,Firestore 是 schemaless 的,同一个 collection 中的文档可以拥有不同字段,但为了查询和应用逻辑保持稳定,实际项目仍然应该尽量保持字段名称和数据类型的一致性。
因此,关系型数据库开发人员进入 Firestore 后首先需要改变的,是“先设计表,再考虑查询”的习惯。Firestore 更适合从“这个业务页面到底需要读取什么数据”开始设计。例如一个电商订单页面可能需要同时显示订单基本信息、客户名称、商品摘要和配送状态。如果这些信息每次都需要通过多个关系表 JOIN 才能得到,那么在 Firestore 中就应该重新考虑数据结构,而不是机械地把原来的几张 SQL 表逐张转换成 collection。
例如用户数据可以设计成:
{
"name": "张三",
"address": {
"city": "上海",
"zip": "200000"
}
}
如果设备数量非常少,而且读取用户资料时总是需要同时显示设备信息,可以把设备作为数组保存。但如果设备数量不断增加、设备独立更新,或者需要按照设备状态、型号和更新时间单独查询,那么把设备全部塞进用户文档就不是一个好的长期设计。此时更适合使用:
users/{userId}/devices/{deviceId}
这种 subcollection 结构。
这体现了 Firestore 与关系型数据库最重要的区别之一:不是所有“关系”都应该被转换成一张独立关系表,也不是所有相关数据都应该塞进一个巨大文档。真正需要考虑的是数据是否经常一起读取、是否独立更新、数量是否会增长以及是否需要单独查询。
反规范化在 Firestore 中非常常见。例如订单文档可以保存 customerId,同时保存结算时使用的客户名称。这样订单列表就不必为了显示客户名称而额外读取用户文档。这里的数据重复并不一定是设计错误,因为它可能换来更简单的读取路径。
但反规范化也会产生同步问题。如果客户名称发生变化,而数千个订单文档中都保存着旧名称,那么应用就需要决定是否批量更新这些历史数据,还是明确规定订单中的客户名称属于交易发生时的快照。后者往往反而更合理,因为订单历史本来就不应该随着客户资料变化而被重新改写。
因此,Firestore 的“数据冗余”不能简单理解为“越少越好”。关系型数据库通常强调规范化以减少重复,而 Firestore 更强调针对读取模式进行合理的数据组织。数据重复是否值得,最终要看读取成本、写入成本、数据一致性要求以及业务语义。
多对多关系同样需要改变思路。例如用户关注用户的社交关系,如果一个用户只有几十个关注对象,可以考虑数组;但如果一个用户可能拥有数千甚至数万条关系,就不应该把所有关系无限堆积在一个用户文档里。可以建立独立的关系 collection,例如:
follows/{followId}
其中保存:
{
"followerId": "userA",
"followedId": "userB",
"createdAt": "..."
}
如果业务同时需要查询“我关注了谁”和“谁关注了我”,就需要针对这两种访问方式设计相应字段和索引,而不是期待 Firestore 像 SQL 数据库一样临时执行 JOIN。
这也是 Firestore 查询设计的核心。Firestore 没有关系型数据库意义上的 JOIN,查询主要围绕 collection、document、where 条件、排序、limit 和 cursor 展开。官方文档也明确支持组合过滤和排序,并通过索引执行查询。
因此,开发人员应该先列出实际查询,例如“查询某个用户最近30天的订单”“查询某个城市中处于配送状态的订单”“查询某个设备最近100条日志”,然后反过来设计字段和索引。
分页尤其如此。不要把关系型数据库中的 OFFSET 100000 思维原封不动搬过来。Firestore 支持 query cursor,可以利用 startAt()、startAfter() 等方式从上一次查询的位置继续读取。典型做法是第一次读取一批文档,保存最后一个文档作为下一页的 cursor,然后继续查询。
索引也应该从实际查询出发。Firestore 的查询依赖索引,复杂查询可能需要 composite index。开发人员不应该简单地为所有字段建立大量组合索引,而应该根据真实查询模式设计索引。当前 Firestore 的索引配额也远不是“一个索引最多5个字段”这么简单,数据库级 composite index 配额取决于当前数据库配置和计费状态。
另一个经常被关系型数据库开发人员误解的问题是事务。
Firestore 并不是简单的“最终一致性数据库”。Google 当前官方文档明确说明 Cloud Firestore 提供 strong consistency,并且事务具有 serializable isolation。事务中的读取和写入具有原子性,如果并发修改导致数据竞争,Firestore 可以重新执行事务,直到成功或者因为竞争等原因失败。
因此,真正需要改变的不是“从强一致性转向最终一致性”,而是理解 Firestore 的事务边界和数据竞争机制。事务适合用于“读取当前状态,然后根据这个状态决定写入结果”的场景。例如账户余额、库存数量或者需要避免并发覆盖的状态更新,都可能需要事务。
同时,不能把事务当成一个可以无限扩大的 SQL transaction。Firestore 事务存在请求大小、时间和其他资源限制。当前官方限制包括单次 API 请求最大10 MiB,事务最长270秒,并且60秒没有活动会触发空闲超时;具体操作还受到字段转换等限制。
事务还可能因为数据竞争而自动重试。因此事务函数不能包含不可重复执行的外部副作用。例如不要在事务函数内部直接发送一封邮件、扣除外部支付账户或者调用一个不可幂等的第三方 API,然后假定事务只会执行一次。Firestore 官方文档也明确提醒,事务函数可能因为并发修改而运行多次。
对于关系型数据库开发人员来说,这一点非常重要。SQL 世界中经常把业务逻辑包在一个 transaction 里,然后按照顺序执行大量操作;Firestore 更适合把真正需要原子性的状态变化缩小到明确的事务边界,其余工作通过事件、任务队列或幂等的异步处理完成。
Firestore 的另一个设计重点是文档大小和增长方式。一个文档并不是无限大的容器,因此不能把一个不断增长的日志列表、聊天记录或者订单历史全部放进某个用户文档。数量不断增长的数据通常应该拆成 collection 或 subcollection,使每个文档保持相对稳定。
嵌套数据也不是越深越好。Firestore 支持 nested maps 和 arrays,但存在字段深度限制,目前最大字段深度为20层。这个限制并不意味着“所有项目都应该保持2至3层”,真正的工程原则应该是让数据结构围绕业务访问路径保持清晰,避免形成巨大、频繁修改而且难以独立查询的文档。
安全模型也是从 SQL 数据库迁移到 Firestore 时必须重新理解的部分。客户端直接访问 Firestore 时,开发人员通常通过 Firebase Authentication 配合 Firestore Security Rules 控制访问。Rules 不应该被当成传统数据库中的一层简单 WHERE 条件,而应该根据用户身份、文档路径和业务状态设计访问控制。对于事务和批量写入,Security Rules 还有额外的文档访问调用限制,因此复杂规则也可能成为系统设计的一部分。
最终,关系型数据库开发人员真正需要改变的思维可以概括为几个问题:这个数据最常见的读取方式是什么?哪些字段应该一起读取?哪些数据会独立增长?哪些关系需要单独查询?哪些数据可以合理冗余?哪些状态必须通过事务保持原子性?哪些操作可以异步化?
如果把 MySQL 的表直接一张一张搬成 Firestore collection,通常只是把关系型数据库换了一个外壳。真正的 Firestore 设计应该从业务访问模式重新开始。
关系型数据库擅长表达复杂关系和通过 JOIN 临时组合数据,而 Firestore 更强调围绕访问路径组织文档,并利用索引、冗余、subcollection 和事务完成高频业务操作。两种数据库没有简单的先进与落后之分,关键是数据模型是否与应用的实际工作方式匹配。对于关系型数据库开发人员来说,最大的转变也正是从“数据之间是什么关系”进一步走向“应用究竟怎样读取和修改这些数据”。
Firestore 数据模型怎么设计,关系型数据库开发人员需要改变哪些思路
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP