← 返回全部文章

无法识别任何人的性能遥测

发布于:
candyrushperformancetoolingevidence

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 29 月 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 #87321/21 个遥测 EditMode 测试
PublicWebsite #777168/168 通过,其中 25 个是新的端点契约测试
PublicWebsite #781323/323,此前为 276
SimVerify #441,015 通过,0 失败,此前为 926
UTools #9016 个新的清洗器测试

这说明了什么,没说明什么

自 9 月 7 日起,游戏的正式发布配置就已打开上传器。SimVerify 的 qa 和正式版汇总档在 9 月 11 日启用,此前开发档已经用八个真实会话完成了端到端验证。本文不对正式版的流量 规模做任何声明;在一个时间窗口凑满五个会话之前,页面对它什么都不显示,这正是设计 的本意。

可推广的经验

让隐私成为类型的属性,而不是处理流程的属性。 如果 payload 类型根本装不下标识符, 后面任何代码路径都泄露不了它。

拒收,而不是剥离。 过滤会静默失败,拒收会在测试中失败。

清楚你的数据能支撑什么。 按会话的摘要无法得出合并后的百分位数,所以页面不假装 能做到;它发布跨会话的中位数,并扣留小样本。