# 埋点分段持久化队列实施报告(M2 第二波) > 作者:Frontend Developer(Flutter) > 日期:2026-09-07 > 依据:`iterations/iteration-1/13-tracking-implementation-spec.md` §3.3/§3.4(分段队列原始设计)、`iteration-2/06-experiment-tracking-plan.md` §基础设施评估(约两周离线积压容量)、`iteration-2/10-flutter-analytics-repair.md`(第一波修复语义基线) > 仓库:patbond-flutter dev 分支,提交 `33b993c`(基线 `1afec6a`) --- ## 1. 背景 第一波按计划只做了内存队列的一行级加固(失败重回队列、上限 500 丢最旧),分段持久化推迟到本波。本波将队列升级为 13 号规范 §3.3 的 shared_preferences 分段持久化方案:应用被杀/冷启动不再丢失未上传事件,离线积压容量约两周(500 条上限,06 号报告估算)。 ## 2. 设计要点 ### 2.1 存储布局(新文件 `lib/analytics/analytics_event_store.dart`) 按 13 号规范 §3.3 的 key 布局实现: | Key | 内容 | | --- | --- | | `pb.analytics.segIndex` | JSON 数组:段 ID 有序列表(旧 → 新) | | `pb.analytics.seg.` | JSON 数组:该段最多 20 条序列化事件 | | `pb.analytics.droppedCount` | 本地累计丢弃计数(溢出淘汰 + 4xx 丢批 + 损坏段),诊断用 | - **写入**:`trackEvent` 追加到当前开放段并只重写该段(≤ 20 条、几 KB),避免整队列单 key 的 O(n) 重写放大;段满 20 条封段、开新段。 - **上限与淘汰**:总量 500 条(25 段),超限丢最旧整段并累加 `droppedCount`。 - **at-least-once**:上传拿到终态才删段——202 受理删段,4xx 永久拒绝删段并计入丢弃数;网络错误/5xx 段原样保留在本地。应用在响应前被杀,事件仍在,冷启动重发,服务端靠 eventId(UUIDv7)幂等去重。 - **内存为唯一事实来源**:shared_preferences 是尽力而为的镜像,持久化不可用(如插件未初始化)时降级纯内存队列,任何存取失败只打日志绝不抛出(埋点旁路原则)。 ### 2.2 并发与损坏容错 - **冲刷中新事件不丢**:`takeBatch` 取最旧整段拼批时即封段(sealed),上传在途期间新事件只会写入新的开放段;批内容与对应段不再变化,202 后整段删除安全。 - **损坏段**:JSON 解析失败的段直接删 key 丢弃、计入 `droppedCount`,恢复流程不崩溃;段索引本身损坏时按 key 前缀清扫孤儿段后从空队列重建。 - **恢复顺序**:restore 前已入队的内存事件排在恢复事件之后(恢复的更旧,优先上传/淘汰),并在恢复时补落盘。 ### 2.3 服务接入(`lib/analytics/analytics_service.dart` + `lib/app/app.dart`) - 冲刷触发点保持不变:满 20 条 + 离开前台 `flushNow()`;新增冷启动 `restore()`(app.dart initState 后台调用,不阻塞渲染)恢复积压并冲刷一次——即 13 号 §3.4 四个触发点落地三个(30 秒定时器仍未做,见 §4)。 - 冲刷改为按段拼批 ≤ 50 条循环上传,对齐契约单批上限(13 号 §1.1;旧实现失败重回后可能单批远超 50 被服务端整批 400 拒绝,本波顺带修复)。 - 第一波语义无回退:`flushNow()`、4xx 毒丸丢弃、eventId UUIDv7、sessionId 注入、`_platformName()` 均保留。 ## 3. 测试变化 - 基线 51 → **64 全绿**(+13);`flutter analyze` 0 问题、`dart format` 无 diff。 - 新增 `test/analytics/analytics_event_store_test.dart`(8 个):持久化恢复与分段数、501 条触发丢最旧整段、损坏段容错与索引清理、索引损坏清扫重建、封段隔离在途批次、按段拼批 ≤50、删段后 prefs 无残留、无持久化降级纯内存。 - 新增 `test/analytics/analytics_persistent_queue_test.dart`(5 个,本地 HttpServer 模拟 202/400):满 20 冲刷且 202 后清段、flushNow 冲刷不满额队列、上传失败持久化 + 冷启动恢复自动重传、4xx 删段丢弃计数、60 条积压按 40+20 分批上传。 - 既有 8 个 AnalyticsService 测试未改动全部通过(`pendingEvents` 语义兼容)。 ## 4. 与 13 号规范符合度对照 | 规范条目(§3.3/§3.4) | 状态 | 说明 | | --- | --- | --- | | 分段存储 key 布局(segIndex / seg.\ / droppedCount) | 符合 | key 名与规范一致 | | 每段 ≤ 20 条、写入只重写当前段 | 符合 | | | 总上限 500 条、超限丢最旧整段 | 符合 | | | 202 后才删段(at-least-once) | 符合 | 取整段组批,无部分消费段重写的需要 | | 单批 ≤ 50 条 | 符合 | 每批最多 2 整段(40 条),循环冲刷 | | 冷启动恢复 + 冲刷触发 | 符合 | `restore()` 于 app 启动挂接 | | 满 20 条 / 退后台冲刷触发 | 符合 | 第一波语义保留 | | `pb.analytics.anonymousId` / `lastActiveAt` 持久化 | 未做 | anonymousId 仍每冷启动重新生成,属会话/身份持久化范畴,非本工单队列范围,建议下波补 | | 30 秒定时冲刷 | 未做 | 本波任务明确保持触发点不变;低活跃场景已由退后台 + 冷启动冲刷兜底 | | 指数退避(5s ×2 上限 5min)、429 按 Retry-After | 未做 | 沿用第一波语义:4xx(含 429)一律永久丢弃;有限流上量前风险低,遗留下波 | | 401 去 Authorization 重试一次 | 未做 | 第一波遗留项,本波未扩展 | ## 5. 交付物 - 代码:patbond-flutter `dev` 提交 `33b993c`(已推送),改动 5 文件 +577/−28。 - 新增:`lib/analytics/analytics_event_store.dart`、`test/analytics/analytics_event_store_test.dart`、`test/analytics/analytics_persistent_queue_test.dart` - 修改:`lib/analytics/analytics_service.dart`(接入持久化队列、分批冲刷)、`lib/app/app.dart`(冷启动 restore 挂接) - 依赖:无新增(`shared_preferences ^2.5.4` 已在 pubspec)