性能采样与 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
图中各组件的职责与真实代码一一对应:
WalWriter:Core 侧唯一的落盘入口,持有DataOutputStream,以synchronized方法保证多线程追加安全,负责写入文件头(magicWAL1+ version)与记录帧,见 WalWriter.java;- 心跳守护线程:进程启动即写首条心跳,此后每若干秒追加一条;性能快照与心跳事件同帧搭车落盘(设计意图见 docs/Telemetry.md);
WalReader:Desktop 侧消费入口,读取时丢弃破损写入(torn write)与校验失败的尾部记录;- SentryHelper:唯一与 Sentry 通信的组件;本页只涉及其消费 WAL 的部分,初始化细节见兄弟页面。
双进程各持有一个独立 WAL(文件名含 PID),因此「删除即提交」可以按文件粒度原子地完成——上传成功后删除整个文件,无需记录级确认。
Core Flow
记录帧的二进制布局
WAL 文件由一个文件头(首次创建时写入)与若干追加的记录帧组成。WalWriter 的构造逻辑决定了「文件不存在或长度为 0 时才写头」——这保证了同一进程重复打开同一 WAL 时不会重复插入文件头:
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)。追加一条记录的完整写入序列:
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,帧级互斥避免交错写入产生不可解析的帧。
打开与写入端到端时序
心跳与结束态判定
会话生命周期采用心跳而非「退出时写终止记录」,是整个机制对抗异常终止的核心。进程启动即写首条心跳,此后守护线程每若干秒追加一条;消费端按 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)。
这带来三个工程收益:
- 帧率不受磁盘抖动影响:渲染回调(
render())是帧预算最敏感的路径,任何同步磁盘写入(尤其flush()到物理介质)都可能造成可感知的掉帧; - 采样与落盘解耦:聚合器可以在渲染线程内以极低成本累积样本,由心跳线程批量序列化为一个 payload;
- 天然的对齐:性能快照与心跳同帧,消费端能在同一条记录里同时获得「会话活性」与「当时的性能表现」。
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)。
Related Links
- docs/Telemetry.md — 遥测系统总览:隐私声明、采集范围、架构与技术细节
- WalWriter.java — WAL 追加写入器(本页主要代码来源)
- WalReader.java — WAL 读取器(消费端)
- WalCodec.java — 记录编解码器接口
- WalCoreHeartbeatCodec.java — Core 心跳(性能快照载体)编解码器