无法识别任何人的性能遥测
9 月,我搭建了 CandyRush 公开性能页面背后的整条管线: 游戏测量自己的运行情况,在每一局结束时发送一份小摘要,服务器把这些会话汇总,网站再 发布结果。设计目标是让隐私页面能说出一句有力且真实的话:
同一位玩家的两个会话无法被关联起来,我们做不到,任何读取存储数据的人也做不到。
这句话之所以成立,是因为系统的构造方式,而不是因为某条政策。它由四个属性支撑:
| 属性 | 在哪里强制执行 |
|---|---|
| 没有持久标识符:启动时生成随机会话 id,退出时丢弃 | 游戏里的 payload 类型 |
| 标识符被拒收,而不是被剥离 | 接收端点返回 400 |
| 先算每个会话的百分位数,再取跨会话的中位数 | 汇总服务 |
| 少于 5 个会话不发布;原始会话 5 天后删除 | 汇总服务和存储生命周期规则 |
它以五个已合并的拉取请求落地,历时七天(9 月 7 日至 13 日),横跨四个仓库:
| 拉取请求 | 做了什么 | 合并日期 |
|---|---|---|
| CandyRush #873 | 帧时间直方图、内存和加载时间、可用于正式版的上传器 | 9 月 7 日 |
| PublicWebsite #777 | 只写的接收端点 | 9 月 7 日 |
| SimVerify #44 | 把原始会话转换成已发布时间窗口的汇总服务 | 9 月 11 日 |
| PublicWebsite #781 | 性能页面上的时间窗口选择器和 schema 2 | 9 月 11 日 |
| UTools #90 | 供诊断上传和崩溃报告共用的日志清洗器 | 9 月 13 日 |
我写了把这项工作拆成跨两个仓库九个步骤的 issue,AI 编码代理实现了每一步,每个拉取 请求在我合并之前都经过了一次对抗性评审。有意思的部分在于决策,下面讲的就是这些。
测量必须存在于正式发布的构建里
issue 里说要在现有的诊断报告器里加一个帧时间直方图。照字面去做,得到的数字是任何 玩家都不可能产生的:诊断报告器在真正的正式构建里会被编译排除,而正式版恰恰是公开 看板要描述的那一档。
所以直方图被移到了新的、始终参与编译的遥测类型里,诊断报告器改为从中读取,而不再 拥有它们。拉取请求把这一点记录为对 issue 的有意偏离,并写明了理由。
直方图本身是固定大小的:0.5 ms 一个桶,最高到 100 ms,共 201 个整数,无论会话是 五分钟还是五小时,占用的内存都一样。游戏据此报告 p50、p95、p99 和 p99.9,以及 1% 和 0.1% 低帧。
两个较小的决策,比看上去更重要:
- 两个直方图,而不是一个。 一个记录每一帧;另一个只记录模拟运行期间渲染的帧。 如果只有一个混合直方图,场景加载时的卡顿会主导尾部,而这对战斗性能毫无说明。 全帧直方图保留下来,让加载卡顿仍然可见。
- "游戏帧" 不等于 "这一帧跑了一个 tick"。 在 144 Hz 下,大多数帧本来就不跑 模拟 tick。朴素的规则会丢掉快帧,几乎完全用慢帧来构建游戏帧分布,这是一种悲观 偏差。分类器改用 12 帧的宽限窗口,再加上模拟循环自己的暂停标志。
payload 还携带显示器能呈现的能力:刷新率、垂直同步和呈现上限。没有这些,一台 40 Hz 的笔记本会和一台不限帧的台式机混在一起,发布出来的中位数衡量的是显示器,而不是游戏。
匿名性是 payload 类型的属性
会话由一个随机数标识,它在游戏启动时生成,在关闭时丢弃。没有 Steam id,没有哈希后的 Steam id,没有保存在磁盘上的 GUID,也没有硬件指纹。一局游戏的种子从构造上就不存在。
哈希方案被考虑过,并被有意否决。哈希后的 Steam id 仍然是一个稳定的键:它会把同一 玩家的每个会话关联起来,而任何拥有该 Steam id 的人都能重新算出它。那是假名化,不是 匿名。让会话无法关联的唯一办法,就是从不发送任何稳定的东西。
由此带来一个实现细节。payload 的 JSON 是手写的,而不是用 Unity 的 JsonUtility,
因为 JsonUtility 无法省略字段。正式版 payload 本会带上 "deviceModel":"",那样
匿名性就只是一种约定,而不是传输格式本身能体现的东西。
服务器拒收,而不是剥离
接收端点是一个只写的 Cloudflare Pages Function。它的检查按固定顺序执行:鉴权、 大小上限、内容类型、请求头、限流、解析、严格的逐字段校验,最后写入对象存储。
有三条规则让匿名性在边缘端可以被检查:
- 带有
X-Steam-Id请求头的请求会被拒收,而不是被忽略。 - 声称属于
prod渠道却携带设备型号、GPU、操作系统或构建 id 的 payload 会得到 400,而不是被悄悄剥离。 - 一局游戏的种子在任何渠道都会被拒收。
为什么要拒收?一个剥离标识符的服务器,就是一个会收到标识符的服务器。剥离会静默 失败:新增一个字段、改一个字段名,或者剥离列表里的一个拼写错误,可识别的数据就开始 落进存储,却没有任何迹象。一个拒收的服务器会大声失败:客户端上传会出错,错误会在 测试中暴露。
限流器同样是失败即关闭的。它放在一个 KV 绑定里,缺少这个绑定时返回 503,而不是 "不限流"。一个因为忘了配置绑定而悄悄失去限流的端点,只会以一种方式让你发现:账单。
原始会话存放在私有存储桶中,生命周期规则为五天。选择五天而不是一天是有意的:原始 会话是这个系统里唯一无法重新推导的产物,所以汇总需要为一次失败的运行留出余量。
汇总时不合并帧
汇总运行在 SimVerify 里,这是同时负责验证排行榜回放的 .NET 服务。
TelemetryRollupService 每五分钟为每一档运行一次:从存储拉取新会话,按与端点相同的
规则逐一校验,存入 SQLite,重新计算今天、昨天、本周和全部周的视图,并带着
lastTickUtc 心跳发布出去,让页面能分辨汇总停摆和只是今天没人玩。
统计方法取决于数据本身是什么。每个会话已经汇总了它自己的帧,所以汇总无法计算把所有 帧合在一起的真正百分位数。页面发布的是每个会话自身百分位数的跨会话中位数,平均值按 帧数加权。任何来自少于五个会话的数字都会被扣留,而不是当作噪声发布;低于这个下限的 平台会被完全省略。
拉取请求中的几个细节:
- 设备字段保留在存储的 payload JSON 里,从不成为数据库列,因此不会诱导出按它们分组 的查询。
- 聚合器是从早先一个 Python 汇总脚本移植过来的。每个 Python 测试都被翻译成了 xUnit, 一个黄金测试把新服务对八个归档开发会话的输出与 Python 脚本的输出进行比对。
- 发布使用
rclone copy,从不使用sync,因此一个错误的本地状态不会删除已经发布的 内容。
页面,以及一个会抛异常的 404
网站改动加入了时间窗口选择器(今天、昨天、本周、全部周,或任意一个过去的 ISO 周), 并把页面迁移到新的 schema。
对抗性评审中唯一的阻塞问题很容易被忽略:存储主机返回的公开 404 不带 CORS 头,所以
在浏览器里 fetch 会直接抛异常,而不是返回一个可读的 404,于是 "尚未发布" 会显示成
错误。现在的测试包含了会抛异常的 fetch 桩,与真实浏览器的行为一致。
诊断日志遵循同样的规则
诊断日志上传是另一条单独的路径,隐私页面也对它单独说明。它需要同样的处理。 CandyRush #905 已经把 Steam id 从上传的键和元数据中去掉,但日志正文里也会打印它,而 且每一个存档路径字符串都带着操作系统的账户名。
UTools #90 加入了一个由两条传输路径共用的清洗器:日志上传器和崩溃报告器。它会替换 用户路径、Steam id 和计算机名:
C:\Users\name\... -> C:\Users\<user>\...
7656119XXXXXXXXXX -> <steamid>
the machine's hostname -> <device>
每条规则都有意收得很窄:
- Steam id 按其固定前缀
7656119匹配,而不是 "任意 17 位数字",这样毫秒时间戳、 构建 id 和帧计数器都能保留下来。 - 主机名只有在至少四个字符长时才会被替换,这样一台叫
PC的机器不会把无关文本弄得 支离破碎。 - 堆栈中的文件路径保持不动。它们是编译时写入的构建机路径,不是玩家的路径,改写它们 会破坏符号化。
有一个发现是经过验证而不是假设的:崩溃报告器的事件类型把 breadcrumbs 暴露为只读集合,
所以在 BeforeSend 钩子里清洗它们会静默地毫无作用。现在每条 breadcrumb 在进入时就被
清洗,这一点通过对随游戏发布的 Sentry.dll(4.9.0)做反射确认过。
测试数量
每个拉取请求都记录了自己的检查结果:
| 仓库 | 测试 |
|---|---|
| CandyRush #873 | 21/21 个遥测 EditMode 测试 |
| PublicWebsite #777 | 168/168 通过,其中 25 个是新的端点契约测试 |
| PublicWebsite #781 | 323/323,此前为 276 |
| SimVerify #44 | 1,015 通过,0 失败,此前为 926 |
| UTools #90 | 16 个新的清洗器测试 |
这说明了什么,没说明什么
自 9 月 7 日起,游戏的正式发布配置就已打开上传器。SimVerify 的 qa 和正式版汇总档在 9 月 11 日启用,此前开发档已经用八个真实会话完成了端到端验证。本文不对正式版的流量 规模做任何声明;在一个时间窗口凑满五个会话之前,页面对它什么都不显示,这正是设计 的本意。
可推广的经验
让隐私成为类型的属性,而不是处理流程的属性。 如果 payload 类型根本装不下标识符, 后面任何代码路径都泄露不了它。
拒收,而不是剥离。 过滤会静默失败,拒收会在测试中失败。
清楚你的数据能支撑什么。 按会话的摘要无法得出合并后的百分位数,所以页面不假装 能做到;它发布跨会话的中位数,并扣留小样本。