引言:为什么选择 Firestore
在 Google Cloud 的数据库版图中,Firestore 作为全托管 NoSQL 文档数据库,承担着区别于 Cloud Spanner 和 BigQuery 的独特角色。Spanner 追求关系型强一致与全球分布,BigQuery 专注分析型查询,而 Firestore 则瞄准实时应用场景——从移动端离线同步到 Web 实时协作,从 IoT 设备状态管理到游戏排行榜,它的实时推送、离线持久化和自动扩缩容能力使其成为事件驱动架构的理想数据层。
然而,将 Firestore 用到生产级水准远非「写个文档、加个监听」那么简单。数据建模的反范式化取舍、复合索引的精确设计、安全规则的边界防护、多区域部署的延迟与成本权衡——每一个环节都可能成为系统的瓶颈或漏洞。本文基于多个生产项目的实践经验,系统性地梳理 Firestore 从建模到运维的完整路径。
一、数据模型设计:文档、集合与子集合的架构决策
1.1 核心概念回顾
Firestore 的数据组织遵循三级结构:文档 → 集合 → 子集合。每个文档是一个键值对映射,最大 1MB,不支持超过 500 个字段在单次写入中修改。集合是文档的容器,子集合则允许在文档下嵌套更细粒度的数据组织。
这种层级结构看起来像文件系统,但有一个关键区别:集合路径中的文档并不需要实际存在。你可以直接写入
1 | users/alice/orders/ord123 |
,即使
1 | alice |
这个文档尚未创建。这一特性在数据建模时极为重要——它允许你用逻辑路径组织数据,而不必为每个中间节点创建占位文档。
1.2 反范式化策略
Firestore 不支持跨文档 JOIN,这迫使你在建模时做出反范式化决策。核心原则:读多写少的数据适合冗余,读少写多的数据适合引用。
以电商订单系统为例:
- 冗余方案:在订单文档中内嵌用户名和头像 URL。读取订单时一次查询拿齐,但用户改名时需要批量更新所有历史订单。
- 引用方案:在订单文档中只存 userId,客户端分别查询用户详情。写入简单,但读取需要两次请求。
- 混合方案:订单文档冗余 userName(极少变更的高频读取字段),引用 userId(用于关联查询)。用户改名时,通过 Cloud Function 异步回填历史订单。
1
2
3
4
5
6
7
8
9
10
11
12
13 // 混合建模示例:订单文档
{
orderId: "ord_2024_a1b2c3",
userId: "usr_alice", // 引用:用于权限校验和关联查询
userName: "Alice Chen", // 冗余:高频读取,低频变更
userAvatar: "gs://avatars/...", // 冗余:同上
items: [ // 内嵌数组:订单明细属于订单生命周期
{ sku: "SKU-001", name: "无线耳机", qty: 2, price: 299 }
],
totalAmount: 598,
status: "paid",
createdAt: Timestamp.now()
}
1.3 子集合 vs 顶级集合
子集合的合理使用是 Firestore 建模的核心技巧。关键判断标准:
| 场景 | 推荐结构 | 原因 |
|---|---|---|
| 数据属于父文档生命周期 | 子集合 | 删除父文档时级联清理,安全规则可继承父文档权限 |
| 数据需要独立查询和分页 | 顶级集合 + 外键 | 子集合查询必须指定完整父路径,无法跨父文档查询子集合 |
| 数据量可能超过单文档限制 | 子集合 | 子集合没有文档数量限制,单文档限制1MB |
一个常见陷阱:想跨所有用户查询订单。如果订单放在
1 | users/{userId}/orders |
子集合中,Firestore 不支持「查询所有用户的订单」——你必须使用顶级集合
1 | orders |
,并加上
1 | userId |
字段做过滤。
二、索引策略:从单字段到复合索引的精确控制
2.1 自动索引与默认行为
Firestore 自动为每个文档的每个字段创建单字段索引(升序和降序各一个),并为数组字段创建包含索引。这意味着简单查询(单字段等值或范围)无需手动配置即可工作。但复合查询(涉及多个字段的等值+范围组合)必须显式创建复合索引。
自动索引的副作用:写入放大。每次写入一个包含 20 个字段的文档,Firestore 内部可能触发 40+ 次索引更新。对于高频写入场景,这直接影响吞吐量和成本。
2.2 复合索引设计
复合索引的核心规则:等值过滤在前,范围过滤在后,排序字段在最后。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 // 查询:查找某用户已支付的订单,按创建时间倒序
const q = query(
collection(db, 'orders'),
where('userId', '==', 'usr_alice'), // 等值
where('status', '==', 'paid'), // 等值
orderBy('createdAt', 'desc') // 排序
);
// 对应的复合索引(在 firestore.indexes.json 中声明)
{
"indexes": [
{
"collectionGroup": "orders",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "userId", "order": "ASCENDING" },
{ "fieldPath": "status", "order": "ASCENDING" },
{ "fieldPath": "createdAt", "order": "DESCENDING" }
]
}
]
}
常见错误:在范围过滤字段后再加 orderBy 另一个字段。Firestore 不支持在范围字段后对不同字段排序——这是 NoSQL 的物理限制,因为范围扫描在索引上已经是有序的,无法再按另一维度重排。
2.3 集合组索引
当你需要跨所有父路径查询同名子集合时,集合组索引是唯一解。例如所有
1 | users/*/orders |
中的订单按状态筛选:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 // 集合组查询
const q = query(
collectionGroup(db, 'orders'),
where('status', '==', 'shipped')
);
// 索引声明中 queryScope 设为 COLLECTION_GROUP
{
"collectionGroup": "orders",
"queryScope": "COLLECTION_GROUP",
"fields": [
{ "fieldPath": "status", "order": "ASCENDING" }
]
}
2.4 索引豁免:减少写入成本
对于不需要查询的字段(如日志原文、大 JSON 块),关闭自动索引可显著降低写入成本:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 // firestore.indexes.json 中的 fieldOverrides
{
"fieldOverrides": [
{
"collectionGroup": "logs",
"fieldPath": "rawPayload",
"indexes": [], // 完全豁免
"ttl": false
},
{
"collectionGroup": "logs",
"fieldPath": "timestamp",
"indexes": [
{ "order": "ASCENDING" },
{ "order": "DESCENDING" }
]
}
]
}
三、安全规则:最小权限的数据防护网
3.1 规则结构与评估模型
Firestore 安全规则是一种声明式的权限语言,运行在服务端,在每次读/写请求到达数据层之前进行拦截。核心评估模型:如果任何一条规则匹配并允许请求,则放行;否则拒绝。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32 rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// 辅助函数
function isSignedIn() {
return request.auth != null;
}
function isOwner(userId) {
return isSignedIn() and request.auth.uid == userId;
}
// 用户只能读写自己的文档
match /users/{userId} {
allow read: if isOwner(userId);
allow write: if isOwner(userId)
and request.resource.data.keys().hasAll(['name', 'email'])
and request.resource.data.email is string
and request.resource.data.email.size() > 0;
}
// 订单:创建需登录,读取需为订单所有者或管理员
match /orders/{orderId} {
allow create: if isSignedIn()
and request.resource.data.userId == request.auth.uid;
allow read: if resource.data.userId == request.auth.uid
or isSignedIn() and get(/databases//documents/users/).data.role == 'admin';
allow update: if resource.data.userId == request.auth.uid
and request.resource.data.diff(resource.data).affectedKeys()
.hasOnly(['status']); // 只允许更新 status 字段
}
}
}
3.2 安全规则的性能陷阱
规则中的
1 | get() |
和
1 | exists() |
调用会触发额外的文档读取,计入配额和成本。在列表查询场景中,每条规则对每个结果文档执行一次评估——如果规则中引用了 3 个
1 | get() |
,查询 100 条文档将产生 300 次额外读取。
优化策略:
- 用
1request.auth.token
替代
1get()——将角色信息存入自定义 Auth Claims,规则中直接从 token 读取,零额外读取。
- 用冗余字段替代跨文档校验——如把
1isPublic
布尔值直接存在文档中,而非每次查权限集合。
- 对列表查询使用
1query
约束——规则可以检查查询条件,提前拦截不合规的请求。
1
2
3
4
5
6
7 // 使用 query 约束:限制列表查询只能查自己的数据
match /orders/{orderId} {
allow list: if isSignedIn()
and query.whereFilters().userId == request.auth.uid;
// 这样 Firestore 会在安全层就拒绝不带 userId 过滤的列表查询
// 客户端无法绕过,因为规则运行在服务端
}
四、实时监听与离线同步:正确使用 onSnapshot
4.1 onSnapshot 的生命周期
1 | onSnapshot |
是 Firestore 实时能力的核心 API。它建立 WebSocket 长连接,服务端在数据变更时主动推送增量更新。但它的行为在在线/离线切换时并不直观:
- 在线状态:首次订阅返回完整快照,后续仅推送差异文档。
- 离线切换:网络断开时,客户端从本地缓存读取;网络恢复后,自动重连并同步增量。
- metadata.pendingWrites:本地写入尚未同步到服务端的文档,pendingWrites 为 true——UI 层需要据此显示「同步中」状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 // 生产级 onSnapshot 用法
const unsubscribe = onSnapshot(
query(collection(db, 'tasks'), where('projectId', '==', projectId)),
{ includeMetadataChanges: true }, // 监听元数据变更以检测同步状态
(snapshot) => {
const source = snapshot.metadata.fromCache ? 'cache' : 'server';
snapshot.docChanges().forEach((change) => {
if (change.type === 'added') renderTask(change.doc);
if (change.type === 'modified') updateTask(change.doc);
if (change.type === 'removed') removeTask(change.doc.id);
// 标记本地待同步状态
if (change.doc.metadata.hasPendingWrites) {
markSyncing(change.doc.id);
} else {
markSynced(change.doc.id);
}
});
},
(error) => {
console.error('Snapshot error:', error);
if (error.code === 'permission-denied') {
showAuthError();
}
}
);
// 组件卸载时必须取消订阅,否则内存泄漏
onUnmount(() => unsubscribe());
4.2 离线持久化配置
Firestore Web SDK 默认不启用离线持久化(移动端 SDK 默认启用)。对于 PWA 或需要离线能力的 Web 应用:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 // 启用 IndexedDB 持久化
import { enableIndexedDbPersistence } from 'firebase/firestore';
try {
await enableIndexedDbPersistence(db, { forceOwningTab: true });
} catch (err) {
if (err.code === 'failed-precondition') {
// 多标签页场景:另一个标签页已持有持久化锁
console.warn('Persistence disabled: multiple tabs open');
} else if (err.code === 'unimplemented') {
// 浏览器不支持 IndexedDB
console.warn('Persistence unavailable: browser limitation');
}
}
// 多标签页支持:使用 synchronizeTabs
import { enableMultiTabIndexedDbPersistence } from 'firebase/firestore';
await enableMultiTabIndexedDbPersistence(db);
五、事务与批量写入:保证一致性的正确姿势
5.1 事务(Transaction)
Firestore 事务采用乐观并发控制:读取文档的当前值,在客户端修改,提交时如果服务端版本已变更则自动重试。事务最多执行 5 次,超过则抛出异常。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28 // 转账场景:原子性扣除余额
import { runTransaction } from 'firebase/firestore';
try {
await runTransaction(db, async (transaction) => {
const fromDoc = await transaction.get(fromRef);
const toDoc = await transaction.get(toRef);
if (!fromDoc.exists() || !toDoc.exists()) {
throw new Error('Account not found');
}
const fromBalance = fromDoc.data().balance;
const toBalance = toDoc.data().balance;
if (fromBalance < amount) {
throw new Error('Insufficient balance');
}
transaction.update(fromRef, { balance: fromBalance - amount });
transaction.update(toRef, { balance: toBalance + amount });
// 事务写入不计入配额——如果提交失败,不会产生写操作费用
});
console.log('Transfer successful');
} catch (e) {
console.error('Transaction failed:', e);
}
5.2 批量写入(WriteBatch)
批量写入用于不依赖读取结果的原子写入。最多 500 次操作,每个操作计为一次写入配额。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 // 批量导入初始数据
import { writeBatch } from 'firebase/firestore';
const batch = writeBatch(db);
const items = [...]; // 要导入的数据
// 每 500 条一个批次
for (let i = 0; i < items.length; i += 500) {
const chunk = items.slice(i, i + 500);
chunk.forEach((item) => {
const ref = doc(db, 'products', item.sku);
batch.set(ref, item, { merge: true });
});
await batch.commit();
// 创建新 batch 用于下一批
// 注意:WriteBatch 提交后不可重用
}
六、多区域部署与成本优化
6.1 区域选择策略
Firestore 提供两种模式:Datastore 模式(兼容 App Engine,强一致读)和原生模式(支持实时监听和离线同步)。区域选择决定了延迟和数据驻留:
- 单区域(如 nam5、europe-west):最低延迟,但无跨区域冗余。适用于用户集中在一个地理区域的场景。
- 多区域(如 nam5 美国、europe-west 欧洲):99.999% SLA,数据在多个区域同步复制。写入延迟增加 50-100ms(跨洋复制),但读取延迟几乎无影响。
关键约束:创建数据库时选择区域,之后不可更改。迁移需要导出再导入,耗时数小时到数天。
6.2 计费模型与成本控制
Firestore 按操作计费,无预置容量费用。三类操作:
| 操作类型 | 单价(免费额度后) | 优化方向 |
|---|---|---|
| 文档读取 | $0.036 / 10万次 | 减少 onSnapshot 订阅范围;使用离线缓存;客户端分页 |
| 文档写入 | $0.108 / 10万次 | 减少索引(豁免不查询的字段);合并小写入为批量写入 |
| 文档删除 | $0.012 / 10万次 | 使用 TTL 自动过期替代手动清理 |
典型成本陷阱:一个 onSnapshot 监听器如果匹配 10,000 个文档,首次订阅就产生 10,000 次读取。每次文档变更推送时,也会产生对应文档的读取费用。
6.3 TTL 自动过期
Firestore 原生支持 TTL 策略,自动删除过期文档。适用于日志、会话、临时缓存等场景:
1
2
3
4
5
6
7
8
9
10
11 // 通过 gcloud 设置 TTL
// 基于 expireAt 字段自动删除文档
gcloud firestore fields ttls update --collection-group=logs --field=expireAt --ttl-enabled=true
// 文档写入时设置过期时间
{
message: "User login detected",
level: "info",
expireAt: Timestamp.fromDate(new Date(Date.now() + 30 * 24 * 3600 * 1000)), // 30天后过期
createdAt: Timestamp.now()
}
TTL 删除是尽力而为的——不保证精确到期时刻删除,通常在到期后 72 小时内完成。TTL 删除操作不产生删除费用,但 TTL 字段的索引更新会产生写入费用。
七、与 Cloud Functions 的联动:事件驱动架构
7.1 Firestore 触发器
Firestore 触发器是构建事件驱动架构的关键粘合层。三种触发类型:
- document.create:文档创建时触发
- document.update:文档更新时触发
- document.delete:文档删除时触发
- document.write:以上任一事件均触发
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 // 订单创建后自动发送通知并更新统计
import * as functions from 'firebase-functions/v2';
import { getFirestore } from 'firebase-admin/firestore';
export const onOrderCreated = functions.firestore
.onDocumentCreated('orders/{orderId}', async (event) => {
const snapshot = event.data;
if (!snapshot) return;
const order = snapshot.data();
const db = getFirestore();
// 1. 更新用户统计(冗余计数字段,避免频繁 count 查询)
const userRef = db.doc('users/' + order.userId);
await db.runTransaction(async (txn) => {
const userDoc = await txn.get(userRef);
const currentCount = userDoc.data()?.orderCount ?? 0;
txn.update(userRef, {
orderCount: currentCount + 1,
lastOrderAt: order.createdAt
});
});
// 2. 发送推送通知
await sendPushNotification(order.userId, {
title: '订单创建成功',
body: '订单 ' + order.orderId + ' 已创建'
});
});
7.2 触发器的幂等性设计
Firestore 触发器可能重复投递——同一事件可能触发多次。生产级函数必须幂等:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22 // 幂等处理:用事件 ID 做去重
export const onOrderCreated = functions.firestore
.onDocumentCreated('orders/{orderId}', async (event) => {
const eventId = event.id; // Firestore 内置的事件唯一 ID
const db = getFirestore();
// 检查是否已处理
const processedRef = db.doc('_processed/' + eventId);
const processedDoc = await processedRef.get();
if (processedDoc.exists) {
console.log('Duplicate event, skipping:', eventId);
return;
}
// 执行业务逻辑...
// 标记为已处理
await processedRef.set({
processedAt: Timestamp.now(),
orderId: event.params.orderId
});
});
八、监控与运维:生产环境的可观测性
8.1 Cloud Monitoring 集成
Firestore 自动向 Cloud Monitoring 暴露指标,关键监控项:
- document.read_count / write_count / delete_count:按操作类型监控吞吐量,识别异常流量。
- snapshot.listen_count:活跃实时监听器数量,过高可能暗示客户端泄漏。
- request.latency:P50/P99 延迟,关注区域选择是否合理。
建议告警阈值:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 // 通过 Terraform 配置告警
resource "google_monitoring_alert_policy" "firestore_high_reads" {
display_name = "Firestore 读取量异常"
condition {
condition_threshold {
filter = "resource.type = 'firestore_instance' AND metric.name = 'firestore.googleapis.com/document/read_count'"
comparison = "COMPARISON_GT"
threshold_value = 1000000 // 每小时超过 100 万次读取告警
duration = "3600s"
aggregations {
alignment_period = "3600s"
per_series_aligner = "RATE"
}
}
}
notification_channels = [var.notification_channel]
}
8.2 安全规则测试
Firestore 提供本地模拟器用于安全规则测试,避免在生产环境中试错:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30 // 使用 @firebase/rules-unit-testing
import { initializeTestEnvironment, assertFails, assertSucceeds } from '@firebase/rules-unit-testing';
describe('Orders security rules', () => {
let testEnv;
beforeAll(async () => {
testEnv = await initializeTestEnvironment({
projectId: 'test-project',
firestore: { rules: fs.readFileSync('firestore.rules', 'utf8') }
});
});
it('用户只能创建自己的订单', async () => {
const aliceDb = testEnv.authenticatedContext('alice').firestore();
await assertSucceeds(
aliceDb.collection('orders').add({ userId: 'alice', amount: 100 })
);
await assertFails(
aliceDb.collection('orders').add({ userId: 'bob', amount: 100 })
);
});
it('未认证用户不能读取订单', async () => {
const anonDb = testEnv.unauthenticatedContext().firestore();
await assertFails(
anonDb.collection('orders').get()
);
});
});
结语
Firestore 的价值在于其全托管属性与实时能力的组合,但这种便利性往往掩盖了生产级使用的复杂度。数据建模需要精心权衡冗余与引用,索引设计直接影响查询能力和写入成本,安全规则是数据防护的最后防线而非可选装饰,实时监听的内存和计费影响需要细致管理,多区域部署一旦选定便不可更改。
以上每个环节都不是孤立的——数据建模决定索引形态,索引影响写入成本,写入成本制约安全规则的 get() 使用,实时监听又放大了读取量。只有理解这些环节之间的联动关系,才能在 Firestore 上构建出既满足实时需求、又在成本和性能之间取得平衡的生产系统。
汤不热吧