如何按用户的 Firebase Storage 使用量计费?
结论
Firebase Storage 不提供按 Firebase Authentication 用户拆分的存储量、上传流量和下载流量账单。默认 bucket 的 Cloud Billing 和监控数据通常只能统计到 bucket 或项目层级,无法直接查出某个 UID 在当月的具体用量。
要实现用户级计费,需要自行搭建计量系统:
- 通过对象路径或可信的服务端元数据确认文件归属。
- 监听对象创建、覆盖和删除事件,记录存储量的每次变化。
- 根据相邻事件的时间间隔计算
byte-seconds,再换算成GB-month或GiB-month。 - 通过受控下载入口或 CDN、网关日志统计下载流量。
- 按月生成用户账单,并与 Google Cloud 总账单核对。
假设 1 GiB 的月单价为 $0.10。某用户整月保存 1 GiB,并在月中又增加 1 GiB,那么当月存储费用约为:
1 GiB × 1 month × $0.10
+ 1 GiB × 0.5 month × $0.10
= $0.15
这里的 $0.10 只是业务定价示例,不一定等于 Firebase 或 Google Cloud 的实际成本。
计费模型
不能只在月末读取用户当时占用的空间。月末快照无法判断文件是在月初还是月末上传,也不会反映月中已被删除的文件。
更合适的计量单位是”字节使用时长”:
storage_usage = Σ(bytes_i × duration_i)
费用可按实际账期折算:
storage_charge =
Σ(bytes_i × duration_seconds_i)
÷ unit_bytes
÷ billing_period_seconds
× price_per_unit_month
如果使用 GiB:
unit_bytes = 1024³
如果使用十进制 GB:
unit_bytes = 1000³
产品定价和程序必须统一采用 GB 或 GiB,否则账单可能出现约 7% 的偏差。
也可以保存每一次容量变化事件,计算方式与上面等价:
| 时间 | 当前占用 |
|---|---|
| 9 月 1 日 | 1 GiB |
| 9 月 16 日 | 2 GiB |
| 10 月 1 日 | 账期结束 |
结算时分别计算以下两个时间区间:
[9 月 1 日, 9 月 16 日) × 1 GiB
[9 月 16 日, 10 月 1 日) × 2 GiB
文件归属设计
建议把 UID 写入对象路径,并使用 Firebase Storage Security Rules 限制用户只能操作自己的目录:
users/{uid}/files/{fileId}
示例规则:
rules_version = '2';
service firebase.storage {
match /b/{bucket}/o {
match /users/{uid}/files/{path=**} {
allow read, write: if request.auth != null
&& request.auth.uid == uid;
}
}
}
计量服务可以直接从对象路径提取 UID。不要信任客户端提交的 customMetadata.ownerUid,因为客户端可以伪造或修改这个值。如果必须使用自定义元数据,应由可信后端写入,并通过规则禁止客户端修改归属字段。
记录存储量变化
可以使用 Cloud Functions for Firebase 监听 Cloud Storage 对象事件:
onObjectFinalized:文件创建或新版本写入完成。onObjectDeleted:文件被永久删除。- 如果启用了 Object Versioning,还要确定旧版本是否计费,并处理归档版本。
下面的 TypeScript 示例维护用户当前占用量,同时写入不可变的用量事件。代码使用对象名称的 SHA-256 作为 Firestore 文档 ID,避免对象路径中的 / 影响文档结构。
import { createHash } from "node:crypto";
import { onObjectFinalized, onObjectDeleted } from "firebase-functions/v2/storage";
import { initializeApp } from "firebase-admin/app";
import {
FieldValue,
getFirestore,
Timestamp,
} from "firebase-admin/firestore";
initializeApp();
const db = getFirestore();
function ownerUid(objectName: string): string | null {
const match = objectName.match(/^users\/([^/]+)\/files\/.+$/);
return match?.[1] ?? null;
}
function objectDocumentId(objectName: string): string {
return createHash("sha256").update(objectName).digest("hex");
}
function eventDocumentId(eventId: string): string {
return createHash("sha256").update(eventId).digest("hex");
}
export const meterObjectFinalized = onObjectFinalized(async (event) => {
const object = event.data;
const name = object.name;
const uid = ownerUid(name);
if (!uid) {
return;
}
const size = Number(object.size ?? "0");
const generation = object.generation;
const occurredAt = Timestamp.fromDate(new Date(event.time));
if (!Number.isSafeInteger(size) || size < 0) {
throw new Error(`Invalid object size: ${object.size}`);
}
const objectRef = db.collection("meteredObjects")
.doc(objectDocumentId(name));
const userRef = db.collection("userStorageUsage").doc(uid);
const eventRef = db.collection("storageUsageEvents")
.doc(eventDocumentId(event.id));
await db.runTransaction(async (tx) => {
const [eventSnapshot, objectSnapshot, userSnapshot] =
await Promise.all([
tx.get(eventRef),
tx.get(objectRef),
tx.get(userRef),
]);
// Cloud Events 可能被重复投递,因此必须保证幂等。
if (eventSnapshot.exists) {
return;
}
const previousSize = objectSnapshot.exists
? Number(objectSnapshot.get("size"))
: 0;
const currentBytes = userSnapshot.exists
? Number(userSnapshot.get("currentBytes"))
: 0;
const deltaBytes = size - previousSize;
const nextBytes = currentBytes + deltaBytes;
if (!Number.isSafeInteger(nextBytes) || nextBytes < 0) {
throw new Error(`Invalid user storage total for ${uid}`);
}
tx.set(objectRef, {
uid,
name,
size,
generation,
updatedAt: occurredAt,
});
tx.set(userRef, {
currentBytes: nextBytes,
updatedAt: occurredAt,
}, { merge: true });
tx.create(eventRef, {
eventId: event.id,
type: "finalized",
uid,
objectName: name,
generation,
previousBytes: currentBytes,
deltaBytes,
currentBytes: nextBytes,
occurredAt,
recordedAt: FieldValue.serverTimestamp(),
});
});
});
export const meterObjectDeleted = onObjectDeleted(async (event) => {
const object = event.data;
const name = object.name;
const uid = ownerUid(name);
if (!uid) {
return;
}
const deletedGeneration = object.generation;
const occurredAt = Timestamp.fromDate(new Date(event.time));
const objectRef = db.collection("meteredObjects")
.doc(objectDocumentId(name));
const userRef = db.collection("userStorageUsage").doc(uid);
const eventRef = db.collection("storageUsageEvents")
.doc(eventDocumentId(event.id));
await db.runTransaction(async (tx) => {
const [eventSnapshot, objectSnapshot, userSnapshot] =
await Promise.all([
tx.get(eventRef),
tx.get(objectRef),
tx.get(userRef),
]);
if (eventSnapshot.exists) {
return;
}
if (!objectSnapshot.exists) {
tx.create(eventRef, {
eventId: event.id,
type: "delete_ignored",
uid,
objectName: name,
generation: deletedGeneration,
occurredAt,
recordedAt: FieldValue.serverTimestamp(),
});
return;
}
// 覆盖文件时,旧 generation 的删除事件可能晚到。
// 只有删除的是当前 generation,才减少用户占用量。
if (objectSnapshot.get("generation") !== deletedGeneration) {
tx.create(eventRef, {
eventId: event.id,
type: "stale_delete_ignored",
uid,
objectName: name,
generation: deletedGeneration,
occurredAt,
recordedAt: FieldValue.serverTimestamp(),
});
return;
}
const size = Number(objectSnapshot.get("size"));
const currentBytes = userSnapshot.exists
? Number(userSnapshot.get("currentBytes"))
: 0;
const nextBytes = Math.max(0, currentBytes - size);
tx.delete(objectRef);
tx.set(userRef, {
currentBytes: nextBytes,
updatedAt: occurredAt,
}, { merge: true });
tx.create(eventRef, {
eventId: event.id,
type: "deleted",
uid,
objectName: name,
generation: deletedGeneration,
previousBytes: currentBytes,
deltaBytes: -size,
currentBytes: nextBytes,
occurredAt,
recordedAt: FieldValue.serverTimestamp(),
});
});
});
这段代码只说明计量系统的基本结构。用于生产环境时,还要处理事件乱序、存量对象初始化、账期边界和异常补偿。
月度结算
事件数量不多时,可以从 Firestore 读取某个用户的事件,按时间排序后计算。用户和文件较多时,更适合将事件写入 BigQuery,再由定时任务按月聚合。
计算一个账期的用量,需要取得三类数据:
- 账期开始时的占用量。
- 账期内发生的所有容量变化事件。
- 最后一次事件发生后,持续到账期结束的占用量。
伪代码如下:
type UsagePoint = {
occurredAtMs: number;
currentBytes: bigint;
};
function calculateByteMilliseconds(
periodStartMs: number,
periodEndMs: number,
initialBytes: bigint,
events: UsagePoint[],
): bigint {
let total = 0n;
let currentBytes = initialBytes;
let cursor = periodStartMs;
for (const event of events) {
if (event.occurredAtMs < periodStartMs) {
continue;
}
if (event.occurredAtMs >= periodEndMs) {
break;
}
total += currentBytes * BigInt(event.occurredAtMs - cursor);
currentBytes = event.currentBytes;
cursor = event.occurredAtMs;
}
total += currentBytes * BigInt(periodEndMs - cursor);
return total;
}
不要用普通的 JavaScript number 保存累计的 byte-milliseconds。1 GiB 乘以一个月的毫秒数,就可能超过 Number.MAX_SAFE_INTEGER。可以改用 bigint、数据库的高精度数值类型,或 BigQuery 中的 NUMERIC/BIGNUMERIC。
金额也不应通过二进制浮点数直接累计。通常应使用高精度十进制数计算,并按照明确的规则,将最终结果四舍五入到最小货币单位。
transfers 如何统计
存储容量与网络传输需要分别计量。
上传流量
onObjectFinalized 中的对象大小只表示最终保存的文件大小,不能准确反映实际网络传输量。断点续传、失败重试和重复上传都可能让真实传输字节数超过对象大小。
如果业务按”成功上传后的文件大小”收费,可以直接使用对象的 size。如果需要按实际网络字节收费,就要从上传网关、代理或相关基础设施日志中取得传输数据。
下载流量
Firebase Storage Security Rules 负责授权,但不提供按 UID 累计下载字节数的计数器。客户端上报的下载量也不适合作为账单依据,因为数据可以被篡改,而且无法可靠处理 Range 请求、中断和重试。
需要精确计费时,建议使用受控下载链路:
客户端
→ 身份认证
→ 下载网关或 CDN
→ Cloud Storage
网关需要记录每次请求:
{
"uid": "firebase-auth-uid",
"objectName": "users/uid/files/example.bin",
"responseBytes": 1048576,
"status": 200,
"occurredAt": "2026-09-01T10:00:00Z",
"requestId": "unique-request-id"
}
结算时按 uid 汇总实际发送的响应字节,同时明确以下业务规则:
- Range 请求按实际返回字节还是完整文件大小收费。
- 下载中断按已发送字节还是请求文件大小收费。
- CDN 缓存命中是否收费。
- 同一请求重试是否重复计费。
- 用户分享下载链接后,流量归链接创建者还是实际访问者。
- 上传流量、下载流量和同区域内部流量是否采用不同单价。
如果客户端能够绕过网关,直接使用 Firebase 下载 URL 或长期有效的下载 token,就无法确保所有下载都经过同一套用户计量链路。
推荐的生产架构
可以采用以下组合:
- Firebase Authentication:确认用户身份。
- Storage Security Rules:隔离用户目录。
- Cloud Storage 事件:记录文件的创建、覆盖和删除。
- Firestore:保存当前占用量、对象状态和事件幂等标记。
- BigQuery:保存长期用量明细,执行高精度月度聚合。
- Cloud Scheduler:触发账期结算、对账和补偿任务。
- 下载网关或 CDN 日志:记录可信的用户下载流量。
- Cloud Billing 导出:核对整个 bucket 的实际云成本。
用户账单应根据不可变的用量明细生成,不要直接修改累计金额。发生退款、漏记或价格调整时,可以用明细重新计算,同时保留审计依据。
注意事项
事件并非严格只投递一次
Cloud Storage 事件可能重复投递,也可能延迟或乱序。处理函数必须通过事件 ID 保证幂等,并结合对象 generation,防止覆盖操作导致用量被重复增加或扣减。
需要处理已有文件
事件监听只能记录启用后的变化。上线计量系统前,应扫描 bucket,建立对象基线,并记录统一的计费起始时间。扫描与事件监听之间需要设计明确的交接点,否则可能漏记或重复记录文件。
Object Versioning 会改变计费范围
如果 bucket 启用了 Object Versioning,旧 generation 仍可能占用实际存储空间。需要确定计费范围:
- 只向用户收取当前版本的费用;
- 或者连同历史版本一起收费。
如果按实际云成本收费,通常不能忽略仍被保留的历史版本。
不要把 Firestore 计数器作为唯一事实来源
程序错误、事件延迟或人工操作都可能使计数器出现偏差。应定期扫描 bucket,根据对象路径、大小和 generation 重建快照,再与计量数据进行比较。
业务账单不等于 Firebase 账单
Google Cloud 的存储费用可能受区域、存储类别、操作次数、网络目的地、免费额度和价格调整影响。面向用户制定的固定单价属于业务计费模型,不会自动与 Firebase 的实际账单完全一致。
实践中可以同时保留两套数据:
- 用户级计量:用于向客户收费。
- 项目级 Cloud Billing 数据:用于成本核算和总量对账。
只要提前定义好计费口径、单位、账期、舍入规则和流量归属规则,就可以稳定地按用量和使用时长收费。
备注:内容仅供参考。
