PAYATHON 2026

如何按用户的 Firebase Storage 使用量计费?

支付阿杰

结论

Firebase Storage 不提供按 Firebase Authentication 用户拆分的存储量、上传流量和下载流量账单。默认 bucket 的 Cloud Billing 和监控数据通常只能统计到 bucket 或项目层级,无法直接查出某个 UID 在当月的具体用量。

要实现用户级计费,需要自行搭建计量系统:

  1. 通过对象路径或可信的服务端元数据确认文件归属。
  2. 监听对象创建、覆盖和删除事件,记录存储量的每次变化。
  3. 根据相邻事件的时间间隔计算 byte-seconds,再换算成 GB-month 或 GiB-month。
  4. 通过受控下载入口或 CDN、网关日志统计下载流量。
  5. 按月生成用户账单,并与 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,再由定时任务按月聚合。

计算一个账期的用量,需要取得三类数据:

  1. 账期开始时的占用量。
  2. 账期内发生的所有容量变化事件。
  3. 最后一次事件发生后,持续到账期结束的占用量。

伪代码如下:

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 数据:用于成本核算和总量对账。

只要提前定义好计费口径、单位、账期、舍入规则和流量归属规则,就可以稳定地按用量和使用时长收费。

备注:内容仅供参考。