每月仅需3美元报告Web 3D能力

Hacker News Top 工具

摘要

作者解释了Web3DSurvey.com如何以低于每月3美元的成本,使用BigQuery和GET请求的成本高效架构收集真实的WebGL/WebGPU能力数据,以避免CORS预检。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/24 20:03

# 每月仅需3美元即可报告Web 3D能力 来源:https://ben3d.ca/blog/reporting-web-3d-capabilities-on-a-budget 我运营着 [Web3DSurvey.com](https://web3dsurvey.com/) 作为一项社区服务。它收集真实的 WebGL、WebGL 2 和 WebGPU 能力数据,这样 3D Web 开发者就可以根据实测支持率来选择功能,而不是靠猜测。我之前写过 [为什么要构建它](https://ben3d.ca/blog/introducing-web3dsurvey) 以及 [v2 重写新增了哪些内容](https://ben3d.ca/blog/web-3d-survey-v2)。这篇文章是关于一个大家经常问我的不同问题:运营它需要多少成本,以及整个处理流程是否能撑得住?2026年4月,为网站提供服务和存储数据的各项服务成本约为 3 加元。在 2026年6月,收集器收到了大约五十万个请求。每个请求携带一个样本,最多包含 596 个数据点,每月接近 3 亿个数据点。 我是在 2023 年初开始这个项目的,目的是学习 React 和 BigQuery,这两个我此前都没有在生产环境中使用过。这个学习成果一直保留着,低廉的账单也是如此。架构将收集、报告以及两者的运营成本分离开来。 ## 收集器 ### 收集器测量什么 收集器是一个小脚本,3D 网站通过 iframe 嵌入它。在探测浏览器的 WebGL、WebGL 2、WebGPU、WebXR 和设备能力后,它会将生成的 `stats` 对象提交给 API 服务器。专用的 `api-server` 会过滤掉机器人流量,添加标准化的平台、浏览器和引擎字段,然后将样本以每批十个的方式插入到 BigQuery 中(这可以降低 BigQuery 的费用)。我最初使用的是传统的流式插入 API,但后来换成了 BigQuery Storage Write API。当前的流量在其月度免费摄取配额之内,因此这显著降低了我使用 BigQuery 的成本。 ### 减少对客户端的影响 收集器的设计目的是对宿主页面友好。参与的网站将其作为一个微小的 iframe 嵌入,而不是直接在自己的页面中运行收集器脚本。这实现了两个目标:安全性——防止收集器脚本访问宿主页面中的任何内容;以及性能——避免收集器脚本拖慢宿主页面: ```html <iframe src="https://web3dsurvey.com/collector" style="display: none; width: 0; height: 0; border: none;" title="Web 3D Survey Collector" /> ``` ### 为什么收集器先用 GET 报告 收集器会先尝试使用 GET 请求报告样本,失败后再回退到 POST。对于分析数据来说这听起来有点奇怪,但它可以将发往我服务器的跨域请求数量减半。从嵌入式收集器发出的 JSON POST 会触发一个 CORS `OPTIONS` 预检请求,然后才是实际的 `POST`。而一个将压缩后的负载放在查询字符串中的普通 GET 请求则不需要预检,因此成功的报告只需一个请求就能到达服务器。负载仍然是同一个 `stats` 对象。收集器会将其字符串化,用 gzip 压缩,然后将压缩后的字节进行 base64 编码,并将结果存储在 `stats` 查询参数中: ```javascript const statsAsBytes = new TextEncoder().encode(JSON.stringify(stats)); const compressedStats = gzipSync(statsAsBytes, { level: 9 }); const encodedStats = btoa(String.fromCharCode(...compressedStats)); const searchParams = new URLSearchParams(); searchParams.append('stats', encodedStats); await fetch(`${collectionUrl}?${searchParams.toString()}`); ``` 我这样做并不是为了绕过分析拦截器。那些拦截器通常按域名或路径来阻止,而不是根据请求是 GET 还是 POST。 ### BigQuery 中的五十万个样本 Web3DSurvey 每月接收大约 500,000 个收集器请求。每个请求携带一个样本,最多包含 596 个数据点。这相当于每月约 3 亿个可能的数据点。当浏览器不支持某个 API 时,某些字段为空,但数据表的列宽足够大,以至于我需要一个专为分析扫描而构建的数据库。 我以前尝试过在 PostgreSQL 中存储分析数据。PostgreSQL 非常适合应用程序事务,但针对不断增长的事件表进行广泛的按需分析会变得成本高昂且难以保持响应性。我最终不得不围绕它构建汇总表、后台聚合任务或第二个分析系统。BigQuery 则直接为我提供了那个分析系统。样本被存储到按天分区的五个表中,分别对应浏览器、WebGL、WebGL 2、WebGPU 和 WebXR 数据。每个分区会在 120 天后自动过期: ```bash bq mk --table \ --time_partitioning_field createdAt \ --time_partitioning_type DAY \ --time_partitioning_expiration 10368000 \ --schema ./webgl2.json \ web3dsurvey:stats_v4.webgl2 ``` 保留的表目前在 Google 位于爱荷华州康瑟尔布拉夫斯的 `us-central1` 区域占用约 6.2 GiB 空间。BigQuery 每月包含前 10 GiB 的逻辑存储,因此存储这个数据集目前是免费的。即使没有这个配额,标价也仅为每月约 0.14 美元。付费的部分是插入数据和将其取出进行分析。这个项目使用传统的流式插入,按成功插入的行数收费。按需查询则在每月大量免费配额之后,按扫描的字节数收费。报告只查询最近 7 天的数据,因此它们只扫描保留数据的一小部分。 ## 报告 收集和报告路径独立运行。`api-server` 负责写入样本,而 `web3dsurvey` 服务则负责为公开网站读取聚合数据。一端的流量激增不会影响到另一端。 ### 小查询与 JavaScript 汇总 报告服务不会运行一个巨大的聚合查询。它会运行许多小查询,并在 JavaScript 中组装汇总结果。一个单一的功能查询会拉取按平台和浏览器分组的原始真/假计数,并限制在 7 天窗口内: ```sql SELECT platform.name AS platformName, browser.name AS browserName, COUNTIF(extensions.`EXT_color_buffer_float` = TRUE) AS `true`, COUNTIF(extensions.`EXT_color_buffer_float` = FALSE) AS `false`, FROM `web3dsurvey.stats_v4.webgl` WHERE platform.name IS NOT NULL AND version >= 7 AND DATE(createdAt) >= DATE_SUB(CURRENT_DATE(), INTERVAL 7 DAY) GROUP BY platformName, browserName ``` 然后,JavaScript 将这些计数转换成页面显示的支持率、按平台的细分以及按浏览器系列的汇总。同样的模式也适用于处理累积限制分布、适配器和供应商层级,以及 Top-N 修剪。将聚合逻辑保持在 JavaScript 中可以使 SQL 保持简单,并将逻辑集中在我可以测试的一种语言中。代价是查询扇出。一个完整的 WebGL 报告会对每个扩展和每个参数运行一个这样的查询,再加上适配器和支持查询,所有这些查询都是并行进行的: ```javascript const [featuresArray, limitsArray, rendererInfo /* ... */] = await Promise.all([ Promise.all(extensionNames.map((name) => getFeatureStats(tableName, 'extensions', name))), Promise.all(parameterNames.map((name) => getLimitStats(tableName, 'parameters', name))), getGraphicsAdapterStats(tableName, 'rendererInfo', 'renderer'), // ... ]); ``` 每个查询对 BigQuery 执行大约需要 500 毫秒。一份完整的报告需要数十个这样的查询。如果每次页面浏览都重新运行它们,页面将需要几秒钟才能构建完成。 ### 透明的 JSON 缓存 我不能依赖 BigQuery 内置的结果缓存。当引用的表有最近的流式插入时,即使查询过滤的是数据未更改的旧分区,BigQuery 也会跳过缓存。由于样本全天候都在到达,我需要一个 BigQuery 外部的、我可以控制其生命周期的缓存。我编写了一个位于 BigQuery 前面的通用缓存层。每个聚合调用都通过一个 `cache()` 函数进行,该函数接收名称、查询参数、TTL 以及执行实际工作的创建器函数: ```javascript const rows = await cache( 'getFeatureStats', { tableName, categoryName, featureName, minVersion }, cacheExpiration, async () => bigQuery(query, getFeatureStatsQueryResult), getFeatureStatsQueryResult, ); ``` 缓存首先检查内存,然后是 Cloud Storage 中的一个 JSON 文件,只有在未命中时才会调用创建器。键是参数的哈希值,因此每个唯一的查询都有自己的缓存文件。TTL 为 2 天。当缓存文件存在但已过期时,缓存层会立即提供过期的副本,并在后台重新验证,因此访问者永远不会因为冷查询而等待: ```javascript if (isExpired) { void creator().then((newData) => { this.memoryCache[cacheKey] = { timeStamp: Date.now(), data: newData }; file.save(JSON.stringify(newData), { contentType: 'application/json' }); return newData; }); } ``` 由于底层数据在 7 天窗口内每天仅变化一天,因此 2 天前的聚合数据精度足够高,可以提供服务。唯一的手动步骤是一个 `cacheVersion` 常量,当聚合结果的结构发生变化时,我会更新它,这会使所有过期的文件失效,从而避免网站持续提供旧的布局。 ### 为什么网站不等待数据 这个网站更换过不止一次框架。我在 2023 年初开始时使用 Next.js,因为它当时势头最猛,然后迁移到了 Remix,现在运行在 [TanStack Start](https://ben3d.ca/blog/web-3d-survey-v2) 上,结合了 Router 和 Query。每次迁移都是为了适应我呈现这个特定网站的方式。当前的模型分两个阶段渲染。TanStack Start 在服务端渲染页面外壳和周围的 markdown 内容,使页面立即出现。统计数据本身则通过 TanStack Query 在客户端加载,当每个报告到达时填充骨架占位符。React Compiler 负责处理组件树的记忆化。 我确实尝试过在路由加载器中预取统计数据,以便服务器渲染能够包含数字。但我移除了它。在缓存未命中时,这种方法会将原本快速的页面外壳渲染变成一个持续数秒的等待,因为服务器需要处理查询扇出。先渲染外壳,再加载数据,可以让页面在一秒内变得可用,而数字则在稍后到达。 ## 成本 ### 账单 以下是 2026 年 4 月正在运行的服务按加拿大元计算的费用明细: | 服务 | 2026年4月成本 | | :--- | :--- | | Cloud Run | C$2.30 | | Cloud DNS | C$0.55 | | BigQuery | C$0.18 | | Cloud Storage | C$0.01 | | **总计** | **~C$3.04** | 这两个服务、查询处理、缓存存储和 DNS 的总成本约为 3 加元。BigQuery 的 0.18 加元主要来自传统的流式插入;保留的数据本身在免费存储配额之内。这是 2026 年 6 月直接从 Google Cloud 截取的账单截图(未关闭折扣),仍然只有 3.45 加拿大元: [2026年6月 Web 3D Survey Google Cloud 账单图片] ### 缓存节省了什么 没有缓存的话,每个报告页面浏览都可能触发数十次 BigQuery 查询。使用两天 TTL,每个活动的缓存键大约每两天刷新一次: ``` 30 天 / 2 天缓存生命周期 = 每月约 15 次刷新 ``` 这将会话数对应的查询负载转变为每个活动聚合每月大约 15 次刷新。获得一次浏览的报告和获得数千次浏览的报告对 BigQuery 施加的负载几乎相同。这也使得 500 毫秒的查询延迟远离页面渲染路径。 ### Cloud Run 该项目运行在两个 [Cloud Run](https://ben3d.ca/blog/why-i-left-kubernetes-for-google-cloud-run) 服务上。两者都请求 1 个 vCPU 和 256 MiB 内存,并且每个实例最多处理 80 个并发请求。两个服务都没有预留最小实例数,因此它们都可以缩放到零。稳定的流量往往会使 api-service 保持活跃,但我只按它们使用的时间付费。Cloud Run 在 4 月份的成本为 2.30 加元,这是在约 8.25 加元的标价基础上扣除了约 5.97 加元的使用折扣后的结果。 ### 容器镜像积累 每次推送到 `main` 或 `prod` 分支都会为每个服务构建一个 Docker 镜像并存储在 Artifact Registry 中。在进行更改时我会频繁部署,如果没有清理规则,这些镜像永远不会离开。我喜欢至少保留几个最近的构建版本,以防我不小心破坏服务而需要紧急回滚。我当前的清理策略如下:保留三个最新版本,并删除任何超过三天的版本: ```hcl resource "google_artifact_registry_repository" "general_repository" { # ... cleanup_policy_dry_run = false cleanup_policies { id = "keep-most-recent-2" action = "KEEP" most_recent_versions { keep_count = 2 } } cleanup_policies { id = "delete-older-than-3-days" action = "DELETE" condition { tag_state = "ANY" older_than = "259200s" } } } ``` KEEP 规则的优先级高于 DELETE 规则,因此最新的三个镜像即使超过三天也会被保留下来。 ## 我会改变什么 BigQuery 没有我在较新的项目(如 SelfLoop 和 [Land of Assets](https://ben3d.ca/blog/why-land-of-assets-standardizes-on-gltf))中从 Drizzle Kit 获得的类型安全模式和迁移工作流。Web3DSurvey 使用手写的 JSON 模式和 Shell 脚本来定义其表。添加一个字段意味着要分别更改模式和 TypeScript,并相信它们仍然保持一致。我希望用一个类型化的模式层和现代迁移工具来替换它,但目前似乎没有针对此场景的健壮工具,而且我也不想自己编写。 手动的 `cacheVersion` 是另一个粗糙的地方。它能工作,但如果在更改聚合后忘记更新它,可能会使旧的 JSON 结果保留两天。从结果模式派生版本号将使失效更难被遗漏。 但以上这些都不会改变基本的设计。低廉的账单来自于使用 Cloud Run、分离仅追加的收集和高度缓存的报告、使旧分区过期,以及仅查询最近一周的数据。 ## 预算能买到什么 只需每月几美元,任何 3D Web 开发者都可以获得 WebGL、WebGL 2 和 WebGPU 功能的实测支持率、适配器和供应商细分、浏览器能力统计,以及一份 [实时/机器](https://web3dsurvey.com/machine) 报告,用于将他们面前的设备与全体用户进行比较。如果你运行一个 3D Web 应用并希望做出贡献,收集器只需一行 iframe 代码!

相似文章