Repository Wiki
isHarryh/Ark-Pets

性能采样与 WAL 写入

ArkPets 遥测体系中的「性能指标采集」与「跨进程 WAL(Write-Ahead Log)落盘」机制:Core(libGDX 渲染进程)在渲染回调内进行零 I/O 的内存聚合采样,并通过心跳守护线程把快照以追加方式写入 .wal 文件,最终由 Desktop(JavaFX 启动器进程)扫描、消费并上传至 Sentry。

Purpose and Scope

本页覆盖该机制的端到端实现:

  • WAL 包(core/src/cn/harryh/arkpets/telemetry/wal/)的组成与职责划分;
  • WAL 文件头与记录帧的二进制布局、CRC32 校验与 torn-write 处理;
  • 心跳式会话生命周期,以及消费端如何据此判定三种结束态;
  • 性能采样(渲染耗时、帧率、内存、CPU 等)如何在渲染线程内聚合、随心跳搭车落盘;
  • 生产者(Core)与消费者(Desktop)之间的无主 WAL 回收、删除即提交语义。

有意留给兄弟页面的内容:Sentry SDK 的初始化、DSN 解析与遥测开关(SentryHelper)、崩溃堆栈上报的细节、以及日志目录的一般管理,请参见本目录下的相关页面。对于遥测系统的整体隐私声明与采集范围,参见 docs/Telemetry.md。

Overview

ArkPets 由两个独立 JVM 进程组成:Desktop(JavaFX 启动器)与 Core(libGDX 桌宠渲染)。遥测架构的核心约束是:只有 Desktop 与 Sentry 通信,Core 不具备任何通信功能(见 docs/Telemetry.md)。

因此 Core 采集到的性能指标与会话数据必须跨进程回传。系统选择以文件系统为媒介的 WAL 机制,而非管道或 IPC:

  • 崩溃安全:顺序追加写 + CRC32 校验,断电或杀进程后残留的部分写入可在读取端被安全丢弃;
  • 生命周期可推断:会话结束态不依赖「退出时写一条终止记录」,而由周期性心跳反推,规避了进程被强杀时终止记录来不及写入的问题;
  • 渲染线程零 I/O:Core 在渲染回调内只做内存聚合,I/O 全部推迟到心跳守护线程,保证帧率不受磁盘抖动影响;
  • 时间戳语义正确:WAL 记录携带事件发生时刻,而非上报时刻,Sentry 事件 / Metrics 中的时间即真实发生时间(见 docs/Telemetry.md)。

WAL 包的实现文件(core/src/cn/harryh/arkpets/telemetry/wal/)包括:WalWriter(追加写入器)、WalReader(读取器)、WalRecord(记录模型),以及一组按记录类型划分的编解码器 WalCodec、WalCoreHeartbeatCodec、WalDesktopHeartbeatCodec、WalConfigCodec、WalSystemInfoCodec、WalExceptionCodec。

Architecture

Loading diagram...

图中各组件的职责与真实代码一一对应:

  • WalWriter:Core 侧唯一的落盘入口,持有 DataOutputStream,以 synchronized 方法保证多线程追加安全,负责写入文件头(magic WAL1 + version)与记录帧,见 WalWriter.java;
  • 心跳守护线程:进程启动即写首条心跳,此后每若干秒追加一条;性能快照与心跳事件同帧搭车落盘(设计意图见 docs/Telemetry.md);
  • WalReader:Desktop 侧消费入口,读取时丢弃破损写入(torn write)与校验失败的尾部记录;
  • SentryHelper:唯一与 Sentry 通信的组件;本页只涉及其消费 WAL 的部分,初始化细节见兄弟页面。

双进程各持有一个独立 WAL(文件名含 PID),因此「删除即提交」可以按文件粒度原子地完成——上传成功后删除整个文件,无需记录级确认。

Core Flow

记录帧的二进制布局

WAL 文件由一个文件头(首次创建时写入)与若干追加的记录帧组成。WalWriter 的构造逻辑决定了「文件不存在或长度为 0 时才写头」——这保证了同一进程重复打开同一 WAL 时不会重复插入文件头:

java
1private WalWriter(File file) throws IOException { 2 boolean writeHeader = !file.exists() || file.length() == 0; 3 out = new DataOutputStream(new BufferedOutputStream(new FileOutputStream(file, true))); 4 if (writeHeader) { 5 out.write(MAGIC); // "WAL1" 4 字节 ASCII magic 6 out.writeByte(VERSION); // 0x01 7 } 8}

Source: WalWriter.java

记录帧格式为 type | seq | timestamp | payloadLen | payload | crc32(payload),与文档声明的 magic("WAL1") | version | type | seq | timestamp | payloadLen | payload | crc32(payload) 帧布局一致(docs/Telemetry.md)。追加一条记录的完整写入序列:

java
1public synchronized <T> void append(WalCodec<T> codec, T value) throws IOException { 2 byte[] payload = codec.encode(value); 3 out.writeUTF(codec.type()); // 记录类型,如 core_heartbeat 4 out.writeLong(seq++); // 单调递增序列号 5 out.writeLong(System.currentTimeMillis()); // 事件发生时刻(非上报时刻) 6 out.writeInt(payload.length); // payload 长度 7 out.write(payload); // 编码后的业务负载 8 CRC32 crc = new CRC32(); 9 crc.update(payload); 10 out.writeInt((int) crc.getValue()); // payload 的 CRC32 校验 11}

Source: WalWriter.java

设计意图解读:

  • type 走 UTF 而非定长枚举:让记录类型由各 WalCodec(WalCoreHeartbeatCodec、WalDesktopHeartbeatCodec、WalConfigCodec、WalSystemInfoCodec、WalExceptionCodec)自行声明,新增记录类型无需修改帧结构;
  • seq 单调递增:为消费端提供顺序依据,可检测读取过程中是否发生乱序或缺帧;
  • timestamp 取自帧头而非编码时的 payload:性能样本在渲染线程内产生、稍后才随心跳落盘,二者间隔可能达数秒,帧级时间戳保证 Sentry 中的时间代表事件真实发生时刻;
  • CRC32 只覆盖 payload:读取端在校验失败时可精确截断到尾部,丢弃「torn write」部分而保留前面完好的记录;
  • synchronized 修饰 append/flush/close:心跳线程、异常钩子、配置快照采集可能并发调用同一个 writer,帧级互斥避免交错写入产生不可解析的帧。

打开与写入端到端时序

Loading diagram...

心跳与结束态判定

会话生命周期采用心跳而非「退出时写终止记录」,是整个机制对抗异常终止的核心。进程启动即写首条心跳,此后守护线程每若干秒追加一条;消费端按 WAL 内容判定三种结束态(docs/Telemetry.md):

结束方式Sentry 级别WAL 内容特征
正常退出INFO末条心跳为 stopped=true
可捕获的崩溃ERROR含 exception 记录及异常堆栈
断电或杀进程WARN仅含 running 心跳

这种设计意味着:即使进程根本没机会执行任何清理代码,其 WAL 中也至少有首条心跳,Desktop 仍能以 WARN 级别报告「该会话非正常终止」及此前的性能样本。

无主 WAL 的回收与提交语义

Desktop 在启动时和退出时各扫描一次 WAL 目录,消费条件非常保守:仅当对应进程已死亡(判定为无主) 才读取,上传成功后删除文件(docs/Telemetry.md)。这一「删除即提交」的语义把复杂的多阶段确认简化为单步文件操作:

  • 文件名含 PID,据此即可判断属主是否存活,无需跨进程握手;
  • 正在写入的活跃 WAL(属主仍存活)永远不会被消费,从根上避免了读写竞争;
  • 上传失败时文件保留,下次扫描自然重试(补传),幂等性由「上传成功才删除」保证。

性能采样:渲染线程永不 I/O

性能指标(渲染耗时、渲染分辨率、帧率、内存占用、CPU 占用)在 Core 运行期间每若干秒采集一次。其落盘路径的关键约束是:Core 在渲染回调内仅做内存聚合,快照随心跳事件搭车落盘,渲染线程永不执行 I/O(docs/Telemetry.md,设计条目见 L80-L84)。

这带来三个工程收益:

  1. 帧率不受磁盘抖动影响:渲染回调(render())是帧预算最敏感的路径,任何同步磁盘写入(尤其 flush() 到物理介质)都可能造成可感知的掉帧;
  2. 采样与落盘解耦:聚合器可以在渲染线程内以极低成本累积样本,由心跳线程批量序列化为一个 payload;
  3. 天然的对齐:性能快照与心跳同帧,消费端能在同一条记录里同时获得「会话活性」与「当时的性能表现」。

API Reference

以下签名均摘自 WalWriter(实现文件 WalWriter.java)。WalReader、WalRecord 及各 WalCodec 的完整源码本次未在预算内读取,其方法签名不在本页断言。

WalWriter.open(pid: long): WalWriter

打开(或复用)属于指定进程的 WAL 文件,文件路径为 Const.LogConfig.logDir 下的 Const.LogConfig.logWalPattern(含 PID)格式化结果;父目录不存在时自动创建。

Parameters:

  • pid(long):写入进程的进程 ID,用于消费端判定 WAL 归属与存活状态。

Returns: 新的 WalWriter 实例;文件为空或不存在时先写入文件头(magic + version)。

Throws:

  • IOException:文件无法打开或目录无法创建时抛出。

Source: WalWriter.java

append(codec: WalCodec<T>, value: T): <T> void

以 codec 编码 value 并追加一帧:类型(UTF)、序列号、毫秒时间戳、payload 长度、payload、payload 的 CRC32。方法为 synchronized,帧级互斥。

Parameters:

  • codec(WalCodec<T>):负责 encode(value) 与 type() 声明,如 WalCoreHeartbeatCodec、WalConfigCodec;
  • value(T):待编码的业务值。

Throws:

  • IOException:记录无法写入时抛出。

Source: WalWriter.java

flush(): void / close(): void

二者均为 synchronized:flush() 把 BufferedOutputStream 中的缓冲刷到底层流;close() 关闭底层流。WalWriter implements AutoCloseable,推荐以 try-with-resources 管理生命周期。

Source: WalWriter.java

Failure Modes, Edge Cases & Concurrency

场景机制结果
断电 / 杀进程心跳已提前落盘;尾部可能残留半帧消费端按 WARN 报告会话非正常终止;torn write 被丢弃
尾部记录 CRC 失败CRC32 覆盖 payload读取端截断尾部坏帧,保留之前完好记录
多线程并发追加append/flush/close 均为 synchronized帧不交错,帧内字段完整
重复打开同一 WAL仅当文件不存在或长度为 0 才写文件头不会产生重复 magic/version
WAL 属主仍存活Desktop 仅消费「无主」文件活跃 WAL 不会被读取,无读写竞争
上传失败文件不删除下次扫描补传,天然重试
遥测被用户关闭由 SentryHelper 统一处理,未上报的 WAL 数据在本地删除程序不崩溃,隐私承诺可兑现(见 docs/Telemetry.md)
DSN 缺失或非法SDK 禁用,记录 INFO/WARN 日志WAL 保留,待日后补传

Performance & Operational Notes

  • 缓冲写入:WalWriter 通过 BufferedOutputStream 包装 FileOutputStream(file, true)(追加模式),将高频小帧的写入合并为较少的系统调用;心跳线程负责调用 flush();
  • 顺序 I/O 模型:纯追加 + 按文件消费,磁盘上没有随机写或原地更新,消费即删除,日志目录不会无限增长;
  • 消费时机低频:Desktop 只在自身启动与退出时各扫描一次,扫描成本与 WAL 文件数量成正比,属一次性开销;
  • 隐私与退出:用户关闭「上传匿名使用数据」后,程序立即停止上报并清除本机未上报数据(docs/Telemetry.md),这与「删除即提交」的文件粒度操作天然契合。

Extension Points

新增一种 WAL 记录类型的标准路径是新增一个 WalCodec 实现,声明自己的 type() 字符串与 encode 逻辑,然后在合适的时机调用 WalWriter.append(codec, value)。现有五个编解码器(WalCoreHeartbeatCodec、WalDesktopHeartbeatCodec、WalConfigCodec、WalSystemInfoCodec、WalExceptionCodec)即是这一模式的实例;帧结构(type/seq/timestamp/len/payload/crc32)无需任何改动。

配置快照的实现方式也值得复用:Core 启动时经反射一次性采集 ArkConfig 公有标量字段为独立 WAL 记录,从而携带「会话发生时的配置」而非「上报时的值」(docs/Telemetry.md)。新增需随会话冻结的配置类时,可沿用同样的反射采集策略,但需注意排除敏感项(例如 Mirror CDK 密钥,采集范围声明见 docs/Telemetry.md)。

Sources

(2 files)
core/src/cn/harryh/arkpets/telemetry/wal