Browser Native Data Engine
浏览器端超大数据存储、解析、查询、渲染与交互架构实践
业务背景:日志的自由接入,把复杂度推到了浏览器
日志系统的价值在于帮助用户处理分布式系统中的海量日志:通过 Agent 采集、全文检索和场景化查询,让开发、运维和业务人员能够定位故障、分析链路和追溯行为。采集侧为了保留用户的自主接入能力,不要求所有日志统一格式,也不把单条日志和总量限制在一个理想范围内。
这意味着前端收到的数据可能是非结构化文本、嵌套 JSON、超长字段、带高亮标记的查询结果,甚至包含大整数。真实案例中,单条日志超过 1 MB、5 MB、10 MB,甚至达到 17 MB;一次查询 24~50 条数据,响应体达到 180~400 MB 并不罕见。

用户真正需要的并不是在第一帧看到所有字节,而是对结果集进行一组局部操作:查看某一行、搜索关键词、定位命中、展开 JSON、复制原文、查看高亮、按字段筛选。这组需求要求数据可寻址、可延迟解析、可重复读取,而不是要求所有 payload 同时进入 UI。
bklog/web 的默认检索页是 retrieve-v3(仅当 localStorage.retrieve_version === 'v1' 时回退旧版)。主查询链路为:Actions 组装参数 → WebWorker fetch /search/*?stream=true → NDJSON/JSON 解析 → IndexedDB 落盘 → Vuex 只保留 row_keys 与结果元数据 → 结果组件按 key 读取渲染投影。普通单索引、联合索引、场景检索三条后端分支必须与字段请求保持一致。大整数 · 超长字段
场景检索 · 分页/流式
展开 · 筛选 · 定位
响应式放大 · 结果集所有权
两个真实请求:从请求体到浏览器崩溃
这里不是五个独立场景,而是两次真实请求的两个观察面。40 条请求同时提供了 content-length 和 HTTP 面板数据;4 MB、5 MB 是这批结果中具有代表性的单行日志;24 条请求则展示了“行数减少,但响应仍然巨大”的另一种风险。
40 条数据:约 400 MB 的一次检索
这是同一请求的两个记录:content-length 从字节角度给出响应长度,HTTP 面板从 KB 角度展示网络统计。两个数字不是两个场景,而是同一次请求在不同观测面上的表现。


24 条数据:约 183 MB 的“较小”请求
24 条看起来比 40 条轻,但 HTTP 面板仍显示 187,344 KB。这个请求说明:风险不由行数单独决定,少量超长日志依然可以让单次检索进入浏览器高压区。

单行视图:两个请求中的长尾数据
4 MB 和 5 MB 单行日志不是额外的第三、第四个请求,而是上述请求返回结果中的代表性 row。它们解释了为什么“平均每行大小”会掩盖真实问题:一次请求可能由少量超长行贡献主要的解析和渲染峰值。


两个请求的共同故障链
无论是 40 条还是 24 条,问题都不是某个单独数字,而是数据从网络进入浏览器之后连续穿过了多个放大点:
日志场景复杂度:Response 之后才是真正的处理链
请求返回的 ROW LIST 不是一组简单的字符串数组。每个 row 通常是一个 Object,字段类型混合存在:Value 可能是 JSON string、Text、Object、Number 或 Boolean;Object 的 KEY 可能超过 1000 个,嵌套深度实际可能达到 10 层以上。于是,Response 到达以后,浏览器面对的不是一次展示,而是一组连续的数据加工任务。
1000+ keys · 10+ nested levels
| 处理能力 | 业务要求 | 新增内存 / CPU 成本 |
|---|---|---|
| 复杂字段解析 | 兼容 JSON string、Text、Object、Number、Boolean,以及 1000+ KEY、10+ 层级 | 对象节点、属性表、递归调用栈、字符串与引用增加 |
| 分词交互 | Text 分词,并支持用户按 token 点击 | token 数组、边界信息、点击索引、分词结果缓存 |
| 检索高亮 | 命中内容可见,支持字段级高亮 | mark 片段、highlight overlay、原始值与展示值并存 |
| JSON 解析 | JSON string 可格式化、递归展开 | 再次 parse、格式化字符串、树节点和展开状态 |
| 时间处理 | timestamp 转 localString,dateNano 保留精度 | 日期对象、格式化字符串、大数/纳秒转换中间值 |
| 别名映射 | 原始字段与展示字段可用别名访问 | 字段索引、映射表、原始 key 与 alias key 的重复引用 |
| Vue 2 响应式 | Row 能被组件、watch、computed 持续观察 | Observer/Dep/Watcher、依赖收集、派生值和更新队列 |
交互实验:改变“平均单行大小”,观察风险阶段
当前规模下,整包 JSON 和 UI 副本仍有明显峰值;应优先采用流式摄取与按行持久化。
旧版本的关键代价:为了避免普通 JSON.parse 对大整数造成精度丢失,响应从 JSON 处理改为 Blob,再读取文本并优先执行 BigNumber Parse。它解决了数据正确性,却把整包 Blob、文本字符串、BigNumber 解析对象和渲染对象同时推入内存,形成更高的峰值和更长的 CPU 占用。因此,Blob 不是内存优化方案;它只是改变了响应读取方式。真正的优化需要把 BigNumber 解析拆到 Worker,并进一步采用 NDJSON 的逐行解析与批量落库。
| 阶段 | 如果继续沿用全量模型 | Native Data Engine 的对应策略 |
|---|---|---|
| Request | 一次请求 size 过大,首屏等待不可控 | 分页、stream=true、明确 begin/size |
| Response | response.text() 让大字符串整包落地 | ReadableStream 按字节读取,NDJSON 逐行解析 |
| Row view | origin、render、highlight、editor 多份复制 | rowKey + Metadata/Payload/RenderMeta 分离 |
| Browser error | 主线程长任务、GC、OOM、页面崩溃 | Worker、batch write、24 MB 热缓存、TTL/GC、降级 |
origin_data / renderOverlay / renderMeta 分离;Browser error 对应主线程只持有 row_keys、热缓存上限、TTL/GC 与 IndexedDB 降级。详见 03~05 章。为什么 JSON.parse 会让浏览器“卡住”,甚至崩溃
“JSON.parse 很快”只在小对象上成立。对本场景,JSON.parse 只是把约 200 MB 的网络响应转换成 Object Tree,并不是最终内存。后续还会发生别名映射、JSON string 二次解析、文本分词、高亮、时间格式化、Vue 2 响应式转换以及 Watch / Computed 依赖收集。真正需要回答的问题是:同一份数据在这些阶段是否仍然被引用,以及每个阶段增加了多少对象、字符串和索引。
解析链路实验:同一份数据的成本如何叠加
动画只用于说明解析过程:数据越大,单次同步任务越长;主线程在任务结束前无法响应输入和绘制。
这是“同时存活对象”的示意,不是浏览器 heap 的精确测量。复杂 Row 可能同时保留原始字节、文本、对象树、派生展示值和响应式依赖;每一层都会增加峰值,且 GC 不一定能立即回收仍被引用的对象。
const text = await response.text(); // 整包落地 const rows = JSON.parse(text); // 同步阻塞 const view = rows.map(formatRow); // 再复制/格式化 render(view); // 触发布局与绘制
2.1 分阶段估算:200 MB Response 如何走向 2.2~3.6 GB
场景假设:ROW LIST 是 Array<Object>;单行可能有 1000+ Key、10+ 层嵌套;Value 混合 JSON string、Text、Object、Number、Boolean;后处理包含 Alias、Tokenizer、Highlight、JSON Format、Timestamp Format,并且全量进入 Vue 2 响应式系统。
| 阶段 | 新增或保留的数据结构 | 200 MB 场景的累计内存估算 | 为什么会增长 |
|---|---|---|---|
| Network Buffer | Byte[] / 网络缓冲 | 200 MB | 响应进入浏览器的起点 |
| response.text() | UTF-16 String | 380~420 MB | 整包文本副本;编码和解码造成额外占用 |
| JSON.parse() | Object Tree | 700~1100 MB | 属性、引用、字符串、嵌套节点和大数对象被创建 |
| Alias Mapping | New Object / 字段索引 | 850~1300 MB | 原始 key 与 alias key 在映射期间同时存在 |
| JSON String Parse | 嵌套 Object / Array | 1000~1700 MB | 字段内 JSON string 二次对象化、格式化树节点 |
| Tokenizer | Token Object / 索引 | 1200~2000 MB | 文本变成可点击 token,边界、类型和引用被保存 |
| Highlight | Highlight Metadata / Overlay | 1300~2200 MB | 原始值、命中值和展示值并存 |
| Timestamp Format | Local String / Nano String | 1350~2250 MB | 日期对象、格式化字符串和纳秒中间值 |
| Vue2 Observer | Getter / Setter / Dep | 1800~3000 MB | 大量属性进入 Object.defineProperty 响应式系统 |
| Watch / Computed | Watcher / Dependency Graph | 2200~3600 MB | 仍被引用的依赖图不能被 GC 回收 |
2.2 线性 Response,不代表线性内存
| Response | JSON Parse 典型区间 | Vue Reactive 典型区间 | 最终内存估算 |
|---|---|---|---|
| 50 MB | 150~250 MB | 400~700 MB | 0.8~1.2 GB |
| 100 MB | 300~500 MB | 800 MB~1.3 GB | 1.5~2.3 GB |
| 200 MB | 700 MB~1.2 GB | 1.2~2 GB | 2.2~3.6 GB |
| 400 MB | 1.5~2.5 GB | 2~4 GB | 4.4~7.2 GB |
这些区间用于解释内存膨胀趋势,不应替代实际监控数据。相同 Response 在字段数、对象层级、JSON string 比例、token 数量和 Vue 响应式范围不同的情况下,可能落在不同区间。
2.3 内存放大:响应体不是最终内存
如果响应体是 200 MB,浏览器至少可能同时保留 UTF-16 字符串、解析后的对象、用于展示的投影对象,以及组件/编辑器内部的数据结构。真实占用取决于引擎、字段类型和复制路径,不能简单用“响应体大小”估算;但可以确定的是,一次性全量模型会放大峰值。
2.4 长任务:主线程是单线程调度器
浏览器主线程需要同时处理 JavaScript、样式计算、Layout、Paint、输入和框架更新。当 JSON.parse、深层遍历或大规模格式化形成一个长任务时,事件循环无法及时处理用户输入,表现为滚动掉帧、点击无响应、标签页假死。若内存分配持续失败,最终可能是页面崩溃,而非一个可捕获的业务异常。
2.5 DOM 与编辑器:VirtualList 不能替代数据架构
VirtualList 解决的是“当前 DOM 节点数量”,不能解决全量数据已经被解析、复制和保存在内存中的问题。Monaco、CodeMirror 等编辑器还会建立 token、行索引和装饰模型;把 17 MB 文本交给它们,成本会再次增加。
| 问题 | 直接影响 | 架构层面的根因 |
|---|---|---|
| 全量 JSON.parse | 主线程长任务 | 解析边界与 UI 边界耦合 |
| 字符串/对象复制 | GC 频繁、峰值内存高 | 数据没有引用和投影模型 |
| 全量 DOM/编辑器 | 掉帧、布局成本高 | 渲染层接管了数据集 |
| 搜索遍历 payload | 每次搜索都慢 | 没有稳定的 queryKey、rowKey 和可查询元数据 |
| 旧请求晚返回 | 结果乱序污染 | 缺少请求身份、取消和版本判断 |
从问题阶段到 Browser Native Data Engine
这不是“先做一个页面,最后加一个缓存”的线性升级,而是一轮由数据规模驱动的探索:先确认问题来自哪里,再组合能够延长系统可用区间的方案,最后重新定义浏览器中数据的所有权、解析边界和生命周期。
Entire Loading:问题阶段
API → JSON → Vue Store → Render
- 响应完整落地,整包解析
- ROW LIST 全量进入 Store
- 字段格式化、分词、高亮、JSON 展开和时间处理同步发生
- Vue 2 对大对象建立 Observer、Watcher 和 Computed 依赖
组合探索:Slice + Lazy + Incremental
这不是三个孤立阶段,而是一组逐步组合的缓解策略。
Browser Native Data Engine:架构阶段
从“优化渲染”转向“管理数据生命周期”。
row_keys;完整行以 queryKey:seq 落入 IndexedDB。探索一:如何控制解析膨胀
第一步不是“把 JSON.parse 换成另一个 API”,而是拆开整包解析的连续成本。响应按字节读取,判断真实 Content-Type;流式响应按 meta / row / done 处理,普通 JSON envelope 保留 fallback(ndjson-stream.ts / Worker ingest)。每个 row 在 Worker 内生成 origin、renderOverlay 与 renderMeta,再由 StreamWriter 批量落库,避免主线程同时持有整包字符串、对象树和 UI 副本。
探索二:如何让数据离开内存而不是离开能力
如果把所有能力都建立在 Vuex 的完整 rows 上,搜索、复制和展开都必须持有大对象。新的所有权模型是:Vuex 持有查询状态、row_keys、total 和 loading;retrieve-row.repository 持有可恢复 Row Entity;retrieve-row-cache 保留约 24MB 热数据;renderMeta/overlay 保存截断与高亮派生信息。数据可从内存淘汰,能力仍通过 key 重新读取。
探索三:如何解决 Vue 依赖放大
Vue 2 的响应式系统适合业务状态,不适合承载数百 MB 的深层数据集。将大 payload 从响应式对象中隔离出来,只把轻量的 query state、row_keys 和交互状态交给 Vue;可见行通过异步读取得到非响应式投影,避免对完整 ROW LIST 建立 watch/computed 依赖图。
探索四:如何避免主进程阻塞
当解析、BigNumber、字段遍历和批量写入全部发生在主线程时,事件循环没有机会处理输入和绘制。retrieve-search-worker.service 串行调度,Worker 负责 fetch、解析、取消和 IndexedDB 写入;主线程只接收 meta、首行 progress、完整 row_keys、timings 和错误。Worker 是解析边界与 UI 边界的隔离层,不是附加优化。
最终分层架构:把探索结果固化为职责
retrieve-v3;Query/Sync ≈ retrieve-search-actions + Worker Service;Storage ≈ Repository / RowCache / Dexie;Primitives ≈ Worker · ReadableStream · AbortController · IndexedDB。主查询不走 Axios 完整回包写入 Vuex,而是 Worker fetch 旁路(详见 05 / 06)。从 Data Engine 到 Browser Native Data Platform
这里的 Native 不是指浏览器内置了某个数据库产品,而是指利用浏览器原生能力构造一套数据处理运行时:ReadableStream、Web Worker、IndexedDB、AbortController、事件循环和虚拟化渲染共同成为数据引擎的基础设施。
但这些基础设施只有在“读取之后立即预处理、预处理结果可复用、原始数据与展示数据分离”的前提下,才能真正降低内存压力。ReadableStream 获取到数据后,不直接把完整 ROW LIST 交给 View,而是在 Worker 内完成面向展示和交互的轻量预处理。
它与普通前端页面的区别是:系统管理的不是一次 HTTP Response,而是一个可持续访问的 DataSet。页面拥有查询状态和用户意图;Platform 负责 DataSet、Entity Identity、Component、Projection、生命周期和存储 Provider。UI 通过 EntityId 和 Projection 获取结果,永远不直接依赖底层 Storage。
顺序、字节、expireAt
复制与上下文
类型 / 别名 / 列信息
截断 / 高亮 / 投影
统一保存检索行、字段元数据、活跃查询和性能记录。IndexedDB(bklog-web-storage)是持久化仓库,Memory 是约 24MB 热缓存;Repository 提供 replace/append、bulkGet、按 queryKey 读取与 GC。
retrieve-row.repository.ts · db.ts · retrieve-row-cache.service.ts · retrieve-field-cache.service.ts
不直接返回大对象,而是建立 row_query_key 与 rowKey=queryKey:seq。字段请求与主查询按普通 / 联合 / 场景分支对齐;字段规范化写入 field_scope 后才允许主查询。
retrieve-search-actions.js · services/retrieve.ts
只为可见区域读取渲染投影;首行 progress 用于列宽,完整 row_keys 在 done 后提交。origin tab 复用 retrieve-v2 结果面板,但数据访问已改为按 key。
retrieve-v3/search-result · retrieve-v2/search-result-panel · renderMeta
高亮、复制、JSON 展开、定位都以 rowKey 定位实体:展示走 overlay/renderMeta,复制走 origin。Grep / 聚类 / 图表等 tab 复用查询状态,但不直接持有完整首屏行数组。
RequestPool 取消旧字段/查询;Worker searchQueue 串行;requestId、pageInstanceId、queryKey、startSeq 判定 stale。activeRetrieveQueries 心跳保护 GC;IDB 失败降级 volatile memory。
retrieve-search-worker.service.ts · storageHealth
数据层设计:从"响应对象"到"可寻址实体"
传统数据层将 Response 视为"一个大对象"——业务流程围绕 Object Tree 展开,组件通过 response.data.list 直接消费。当 Response 放大到数百 MB 时,这种依赖关系成为灾难:任何一个组件只要有 row 引用,整批数据就无法被 GC 回收。
可寻址实体模型的核心转变是:数据的身份不再由"它在对象树中的路径"决定,而是由 queryKey / rowKey 决定。Vuex 只保存查询状态、结果元数据和 row_keys;完整行由 Worker 解析后写入 IndexedDB,页面通过 key 按需读取渲染投影。
- 组件直接持有 row 引用
- 全量解析、全量响应式
- GC 无法回收大对象
- Vuex 只持有 row_keys
- Worker 流式解析并落盘
- 实体可持久化、可回收、可寻址
5.1 所有权边界:谁持有什么
与当前 retrieve-v3 实现一致:主线程不持有完整行数组;Worker 不渲染 UI;View 不直连 IndexedDB。边界按下图划分。
loading · total · timings
row_keys / queryKeycancel / timeout
progress → action
origin/data/renderMeta
batch write
fieldMetas · activeQueries
24MB 热缓存 / 降级
5.2 写入路径 vs 读取路径
旧图把摄取与渲染揉在同一条“15 步点阵”里,消息方向不清晰。正确拆法是两条反向路径:写入让大对象离开主线程,读取只按可见窗口取投影。
row_query_key、startSeq、writeMode意图 → 查询参数searchStream 入队;RequestPool / AbortController 取消旧流参数 → postMessagePOST /search/*?stream=true,按 Content-Type 选 NDJSON 或 JSON envelopeHTTP → 字节流meta / row / done;行内保留 origin_data、data,并生成 renderMeta / overlay字节 → RowEntityqueryKey:seqEntity → retrieveRowsrow_keys / total / timingsEntity → 控制消息row_keys、loading、total,不持有完整 rowsVuex → 行键列表retrieveRows 取 entitykey → RowEntityrow_keys 在 done 后回传,避免流式过程中频繁重排表格。5.3 跨参与者消息序列(标准读写)
下表按“发送方 → 接收方”列出关键消息,对应 retrieve-search-actions → Worker Service → Worker → IndexedDB → Vuex → 结果组件。
queryKey:seq · replace|append后端响应 ├─ meta / 结果元数据 ──► Worker progress ──► Vuex indexSetQueryResult └─ row / origin_data + data └─► RetrieveRowRepository └─► IndexedDB retrieveRows └─► row_keys ──► Vuex └─► SearchResultPanel / LogRows └─► retrieveRowCacheService 按 key 读取 └─► 渲染行 = origin + overlay + renderMeta
5.4 行实体结构:一份记录,三类载荷
实现上不是三套独立主键,而是 retrieveRows 中一条 RetrieveRowEntity(key = queryKey:seq)同时携带原始行与渲染派生信息;字段元数据则按 field_scope 单独持久化。
key = queryKey:seq
Worker 批量写入的最小可寻址单元。UI 永远通过该 key 访问,而不是持有对象引用。
renderOverlay · 高亮 mark 叠加(不污染原文)
renderMeta · 字段投影、截断、分词边界等
seq / bytes / expireAt · 顺序、体积、TTL(默认 30min)
key / scope = field_scope
查询前写入:规范化字段、字段树、别名索引。主查询必须等待字段元数据就绪。
fieldTree · aliasIndex
供 Worker 投影与表格列头
不存完整行
只保存可驱动 UI 的轻量状态与行键,作为结果集句柄。
total · loading · timings
indexItem / indexFieldInfo
5.5 IndexedDB Schema(bklog-web-storage)
以当前 Dexie schema 为准。行数据与字段元数据分表;活跃查询心跳保护 GC 不误删正在使用的结果集。
IndexedDB Tables
| Table | Key | Indexes | 用途 |
|---|---|---|---|
| retrieveRows | key (queryKey:seq) | queryKey · [queryKey+seq] · seq · expireAt | 行实体 + overlay/meta |
| retrieveFieldMetas | key | scope · updatedAt · expireAt | 字段规范化与别名 |
| retrieveFieldWidths | key | scope · [scope+fieldName] | 列宽提示/用户宽度 |
| activeRetrieveQueries | ownerId | queryKey · updatedAt · expireAt | 活跃查询心跳 / GC 保护 |
| apiCaches | key | expireAt · updatedAt | API 元数据缓存(不含结果行) |
| performanceRecords | ++id | sessionId · type · timestamp | 摄取/渲染耗时观测 |
复合索引 [queryKey+seq] 支持按查询分页读取;行 TTL 默认 30 分钟。
主线程 Memory / 降级
| Layer | Capacity | Eviction | Access |
|---|---|---|---|
| Row Hot Cache | ~24 MB | LRU(按字节估算) | 可见行渲染 |
| Vuex Result Meta | 轻量 | 新查询 / 卸载重置 | loading · row_keys · total |
| Active Query Heartbeat | 极小 | 每分钟心跳 / 过期清理 | 保护当前 queryKey |
| Volatile fallback | 页面内存 | 刷新即失 | IndexedDB 不可用时 |
IDB 读写失败 → storageHealth 降级 → volatile memory,并提示刷新后不保留缓存。
5.6 写入策略:批量阈值 + replace/append + 竞态
批量化写入
StreamWriter 以 10 行或 8 MB 为默认批次(任一阈值达到即 flush)。每 5 行或每批后 setTimeout(0) 让出事件循环。
双模式:replace / append
首屏 replace:新 queryKey,删除该 key 旧行后写入。分页 append:同 queryKey,startSeq = 当前 row_keys.length。
请求身份与 stale
queryKey + requestId + pageInstanceId 共同判定。过期响应只清理自身查询键,不覆盖当前 Vuex。
热缓存与 GC
热缓存约 24MB;活跃查询心跳保护;新查询、卸载、TTL 到期触发释放。IDB 失败则 volatile 降级。
5.7 数据流标识:各阶段可寻址身份
UI 不持有数据对象,只持有标识;通过标识访问 Repository / Cache。
| 阶段 | 数据流标识 | 承载者 | 可寻址方式 | 生命周期 |
|---|---|---|---|---|
| 字段准备 | field_scope | Field Cache / IDB | scope 主键 | 空间/索引维度 |
| 查询发起 | row_query_key | Vuex / Actions | 一次检索身份 | 查询会话 |
| 流式摄取 | seq | Worker StreamWriter | 行序号 | 写入期间 |
| 持久化 | queryKey:seq | IndexedDB retrieveRows | entity.key | TTL ≈ 30min |
| 结果句柄 | row_keys[] | Vuex indexSetQueryResult | 有序 key 列表 | 当前结果集 |
| 热缓存 | rowKey | RowCacheService | Map key / LRU | 字节上限内 |
| 渲染 | rowKey + projection | 结果组件 | render / origin / copy | 可见窗口 |
| GC | expireAt + activeQuery | Repository / Heartbeat | 排除活跃 queryKey | 后台回收 |
rowKey,通过 cache/repository 按需取 render 或 origin。这样“数据不在内存”与“数据仍可访问”可以同时成立。5.8 参与者职责隔离
fieldInfo · cancel · stale check
batchWrite · progress/done
TTL · GC · health / volatile
expand · copy · locate
主线程不持有完整 Response 行数组;Worker 不负责 UI 渲染;Repository 不暴露 原始 IDB 连接给业务组件;View 不直接读取 IndexedDB。协作协议是 key + projection + progress 控制消息。
/search/* 完成。Entity 可寻址解决的是“可恢复、可引用、可回收”,不是“浏览器内任意查询”。一次检索请求的完整生命周期
完整生命周期包含两段:页面初始化(索引集与字段)与主查询流式摄取。前者走主线程 http.request;后者走 Worker fetch 旁路。二者都依赖 Vuex 中的空间/索引状态,但只有后者把大结果集实体化到 IndexedDB。
replace / append
replace / append
replace / append
导出 / Grep / 聚类(独立请求)
首屏与分页为什么使用不同写入模式
首屏使用 replace:新 row_query_key 建立后删除该查询旧行,并释放上一查询缓存。分页使用 append:沿用当前 queryKey,以已有 row_keys.length 作为 startSeq。若首屏流尚未结束,分页会被标记为 initial-search-loading 并忽略,避免 append 抢占 replace。
进度事件为什么只发送 meta 和首行
Worker 在 meta 到达时更新 total 等结果元数据;首行写入后发一次 progress,用于尽早计算默认列宽;完整 row_keys 在 done 后回传。主线程收到的是小型控制消息,而不是逐行大对象,避免流式过程中反复重排表格。
旧响应如何被丢弃
每次查询携带独立 queryKey、requestId、pageInstanceId。回调应用前检查是否仍为当前请求;不匹配则标记 stale,只清理自身查询键写入的缓存,不覆盖当前 Vuex 结果。分页取消属于控制流:保留已有行键和总数,不把正常竞态展示为错误。
卸载与回收
页面卸载时取消进行中的请求,释放当前查询的活跃标记与行缓存,并重置结果元数据、字段运行态。GC 结合 TTL(行默认约 30 分钟)与 activeRetrieveQueries 心跳,排除仍在使用的 queryKey。
项目内关键实现的对应关系
以下对应日志检索 V3 主链路中的职责划分。结果面板部分仍复用既有展示组件,但数据访问已改为按行键读取存储层,而不是持有完整结果数组。
| 功能模块 | 承担职责 | 设计要点 |
|---|---|---|
| 静态壳 / 启动 | 首屏占位与全局预加载 | 页面打开先展示加载壳;并行准备空间、用户与全局配置;应用挂载后淡出占位;菜单等非关键数据空闲补齐 |
| 入口分流 | 新版 / 旧版检索选择 | 默认进入 V3;仅显式兼容开关时回退旧版容器 |
| 检索页面编排 | 初始化与状态门控 | 解析 URL、空间与索引模式;拉取索引集与场景配置;字段就绪后再触发首屏查询;卸载时清理请求与缓存 |
| 检索状态编排 | 字段与主查询协调 | 普通 / 联合 / 场景三分支对齐;区分首屏替换与分页追加;用行键与请求身份丢弃过期响应 |
| API 契约 | 检索相关接口定义 | 字段、查询、聚合、上下文、导出等;主查询实际由后台任务流式拉取,不把完整大结果直接塞进页面状态 |
| 后台任务调度 | 主线程与摄取任务协调 | 串行排队、任务复用、超时、取消,以及进度/完成消息回传 |
| 后台数据摄取 | 请求、解析与落盘 | 流式拉取、大数安全解析、NDJSON / JSON 兼容、批量写入持久层;大 payload 不回到主线程 |
| 流式解析 | 响应拆解为数据事件 | 按字节读取、逐行解析,批量让出事件循环;后端未流式时回退完整 envelope |
| 行实体仓储 | 可寻址实体存取与回收 | 按查询键与序号生成稳定行键;支持替换/追加、批量读取、TTL 与 GC;保护活跃查询不被误删 |
| 缓存与健康管理 | 热数据、字段缓存与降级 | 热缓存设容量上限并近似 LRU;字段元数据按作用域持久化;持久层不可用时回退易失内存 |
| 结果展示 | 按行键渲染与交互 | 原始日志 / Grep / 聚类 / 图表等能力共享查询状态;只读取可见窗口投影,不持有完整首屏行数组 |
主查询旁路 vs 常规接口调用
走统一 HTTP 封装与服务定义
在后台任务中流式拉取,不经完整回包写入页面状态
NDJSON 与普通 JSON 的兼容
const contentType = response.headers.get('content-type') || ''; if (shouldUseNDJSONStream(url, contentType)) { result = await ingestNDJSONStream(message, response, timings); } else { // 后端未流式或中间层缓冲时,完整 envelope 仍可正常处理 result = await ingestJsonEnvelopeRows(message, await parseJsonEnvelope(response), timings); }
为什么使用大数安全解析
日志中的时间戳、ID 或业务序号可能超过 JavaScript 安全整数范围。后台摄取侧使用大数安全解析,避免普通 JSON.parse 把大整数静默舍入。这是数据正确性要求;性能优化来自“放到后台任务 + 流式/批量”,而不是去掉精度保护。
故障与降级边界
- 后台任务不可用:直接报告能力不可用,不假装能完成大数据摄取。
- 持久化存储不可用:提示兼容性问题并降级到有限易失内存;刷新后不保留。
- 请求超时或任务错误:取消任务、清理等待队列,必要时重建任务运行时。
- 存储写入失败:重置健康状态,保留当前页可用性,但不继续把无限数据塞进主线程。
- 响应被取消:作为控制流处理,保留已成功缓存的结果,不把正常取消显示为无数据。
- 字段与查询分支不一致:会导致投影/列错误;编排层必须按检索类型选择同一分支接口。
方案收益、边界与工程权衡
| 传统模型 | Browser Native Data Engine(retrieve-v3) | 代价 |
|---|---|---|
| 整包 JSON.parse / Blob+BigNumber | NDJSON 增量解析 + JSON envelope fallback | 服务端需支持流协议;客户端维护双路径 |
| Vuex 持有完整 rows | Vuex 持有 query state + row_keys | 组件改为异步按 key 读取 |
| 主线程处理全部工作 | Worker 摄取与落盘 | 消息协议、生命周期、错误诊断更复杂 |
| 内存/LocalStorage 缓存 | IndexedDB Repository + TTL/GC + 热缓存 | schema、健康检测、浏览器兼容与降级 |
| 全量渲染 | 可见窗口 + projection | 复制/展开需 key→entity 流程 |
| 单次请求无身份 | queryKey + requestId + searchQueue | 竞态与 stale 清理逻辑必须正确 |
| 一种查询路径 | 普通 / 联合 / 场景三分支对齐 | 字段与 search API 变更要同步三处 |
/search/*、aggs/* 完成。客户端侧能力以明确场景和预算为前提。如何验证这套架构真的有效
- 记录 fetch、stream、write、total 各阶段耗时(Worker timings),而不是只看接口耗时。
- 记录解析峰值、热缓存字节数、IndexedDB 写入批次、行数和 GC 清理量。
- 在 4 MB、5 MB、17 MB 单行和 24/40/50 行组合下测试首屏、滚动、搜索、展开、复制和取消。
- 用 Performance Long Task、内存快照和页面崩溃率验证主线程是否从大 payload 解耦。
- 覆盖慢网络、后端返回普通 JSON、Worker 失败、IndexedDB 禁用、连续快速查询、首屏未完成时分页、联合/场景分支。
- 卸载页面后确认活跃查询标记释放、请求取消,且无错误把取消展示为“无数据”。
从超大日志承载走向可复用的浏览器数据基础设施
当前 retrieve-v3 已把“检索结果”从一次性响应提升为浏览器内可寻址数据集:流式摄取、Worker 解析、IndexedDB 实体、row_keys 查询、可见窗口渲染、竞态与降级边界。后续演进应围绕引擎能力,而不是单个页面组件。
三分支字段/查询 · stale 控制
JSONBigNumber · 批量落盘
24MB 热缓存 · TTL/GC/降级
origin 复用 v2 panel
Browser Data Engine 的本质,不是“把 IndexedDB 接进页面”,而是重新设计数据在浏览器中的生命周期:数据流式到达、在 Worker 中解析、以实体形式持久化、以 key 形式查询、以窗口形式渲染、以事件形式交互,并在每个环节设置容量和故障边界。
展望:从本地数据引擎到 Browser Native AI
当结果集以 Entity 形式进入 IndexedDB,浏览器就具备可持续访问的本地数据面。后续可以在不重复拉取完整 Response 的前提下,围绕当前 queryKey 做复盘、局部查询和智能交互——前提是答案必须绑定 rowKey、字段与时间范围。
row_keys 与受限热缓存时,页面内存可稳定在约 300 MB 量级。这说明:累计处理量不再等价于主进程占用,关键在于是否避免把 Response、Row Object 与派生视图同时留在内存中。具体数字需用 Performance / Heap Snapshot 在目标环境校准。实际证据:常规业务与极端连续大请求
证据分两档:95% 常规业务下,瞬时内存约 500+ MB、稳定后约 350 MB 左右;极端连续大请求下,四次合计传输约 1.05 GB,主线程仍可稳定在约 1,236 MB,而不是随累计下载线性堆到数 GB。
10.1 95% 业务:瞬时约 500+ MB,稳定约 350 MB 左右
常规检索以较小的流式响应为主(单次约百 KB~两百 KB 量级)。摄取与渲染过程中主线程会出现瞬时抬升,回落后稳定在约 350 MB 左右。
search/?stream=true,单次约 170~200 KB
10.2 极端场景:四次大请求合计约 1.05 GB,内存约 1,236 MB
连续发起多次超大结果检索时,网络累计超过 1 GB;主线程 JS 堆稳定在约 1,236 MB,并未按累计下载量线性放大。
| 请求 | 接口 | 状态 | 大小(HTTP 面板) |
|---|---|---|---|
| 1 | search/?stream=true | 200 | 305,112 KB |
| 2 | search/?stream=true | 200 | 231,363 KB |
| 3 | search/?stream=true | 200 | 257,177 KB |
| 4 | search/?stream=true | 200 | 257,024 KB |
| 合计 | 1,050,676 KB ≈ 1,026 MiB ≈ 1.05 GB | ||