JSON 拆解技术如何让 Logfire 查询速度提升 1000 倍
摘要
Pydantic 的技术博客介绍 Logfire 通过 dynamic shredding(动态拆解半结构化 JSON 属性数据)技术实现部分查询提速 1000 倍,原本 30 秒超时的查询现在不到一秒即可完成,将于 2026 年 2 月向所有客户推出。
<p><a href="https://lobste.rs/s/6vjx1k/how_shredding_json_is_giving_logfire">评论</a></p>
查看缓存全文
缓存时间: 2026/10/11 23:39
# JSON 拆分技术如何让 Logfire 查询速度提升 1000 倍
Source: https://pydantic.dev/articles/dynamic-shredding-2026-01-26
## 前言
在 [Logfire](https://pydantic.dev/logfire) 中查询数据的速度即将迎来质的飞跃——在某些场景下,提速超过 1000 倍。以前需要 30 秒才能超时的查询,现在不到 1 秒即可完成。
> "查询时间快多了!现在感觉基本上是即时的",一位早期访问客户这样说道。
这篇技术博客将详细介绍我们如何通过**动态拆分(dynamic shuffling)**——一种针对半结构化属性数据查询的优化技术——实现了这一突破。我们计划从 2026 年 2 月开始向所有客户逐步推出此功能。
## Logfire 数据模型
OpenTelemetry 数据(即 Logfire 摄入的数据)属于半结构化数据,其中包含:
- **固定 schema 字段**(时间戳、span 名称等)
- **半结构化属性**(HTTP 状态码、用户 ID、地区等)
半结构化数据通过 `attributes`(每个 span 的键值对)或资源属性发送(在 Logfire 中为 `otel_resource_attributes`;这些是每个资源的键值对,其中资源指服务 / Kubernetes Pod 等,本质上是你应用程序或服务的一个实例)。
当你编写如下代码时:
```python
app = FastAPI()
@app.get('/user/{user_id}')
async def handle_request(user_id: str) -> dict[str, str]:
with logfire.span(f'handle_request for user {user_id}', user_id=user_id):
return {"user_id": user_id}
```
这会生成类似如下的导出数据:
```json
{
"resource": {
"attributes": {
"service.name": "my-web-service",
"region": "us-west-2"
}
},
"spans": [
// 用户代码手动创建的 span
{
"span_name": "handle_request for user user123",
"start_timestamp": "2024-01-01T12:00:00Z",
"end_timestamp": "2024-01-01T12:00:01Z",
"message": "handle_request for user user123",
"trace_id": "0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1",
"span_id": "abcdef1234567890",
"parent_span_id": "1234567890abcdef",
"attributes": {
"user_id": "user123",
"http.status_code": 200,
},
},
// OpenTelemetry FastAPI 自动插桩创建的 span
{
"span_name": "GET /user/{user_id}",
"start_timestamp": "2024-01-01T12:00:00Z",
"end_timestamp": "2024-01-01T12:00:01Z",
"message": "GET /user/user123",
"trace_id": "0194c7b1e5f4a3b2c1d0e6f7a8b9c0d1",
"span_id": "1234567890abcdef",
"parent_span_id": null,
"attributes": {
"http.method": "GET",
"http.response": 200,
},
}
]
}
```
这里我使用 JSON 来进行说明,虽然我们支持多种线上格式(OTLP protobuf、JSON 等);大多数数据是通过 gRPC 以 protobuf 形式发送的。还有一些细节被我省略了,比如时间戳实际表示为整数(epoch 纳秒)而非 ISO 字符串,以及 scope 导致的额外一层嵌套。
有几点需要注意:
1. `start_timestamp`、`end_timestamp`、`span_name` 和 `message` 等字段是每个 span 都有的固定 schema 字段,并且具有强类型。
2. 属性是半结构化的,在不同 span 之间可能存在很大差异。一些属性如 `http.status_code` 和 `http.method` 较为常见,在 OpenTelemetry 规范中具有语义含义;而另一些如 `user_id` 则是应用特有的,可能在某些地方是整数,在另一些地方是字符串。
我们最终将这些数据以 Parquet 文件的形式存储在对象存储中,列式 schema 大致如下:
```
start_timestamp: Timestamp(Microsecond, "+00:00")
end_timestamp: Timestamp(Microsecond, "+00:00")
span_name: Utf8View
message: Utf8View
trace_id: FixedSizeBinary(16)
span_id: FixedSizeBinary(8)
parent_span_id: FixedSizeBinary(8)
attributes: Utf8View (JSON)
otel_resource_attributes: Utf8View (JSON)
```
了解一些关于列式存储、Parquet 和 zone map 裁剪的背景知识有助于理解本博客的其余内容。我推荐阅读 [MotherDuck 关于该主题的精彩概述](https://motherduck.com/learn-more/columnar-storage-guide/)。
### 我们的起点:快速 JSON 解析
Pydantic 最初是一个高效验证和解析 JSON 数据的库。当我们构建 Logfire 时,我们最初将所有属性以 JSON blob 的形式存储在单个列中,并依赖 [Jiter](https://github.com/pydantic/jiter)(目前任何语言中最快的 JSON 解析器之一)尽可能快地处理 JSON 数据。这就是上面详细介绍的布局(`attributes: Utf8View (JSON)`)。
这种方法虽然简单,但犯了一个根本性的错误:我们优化的是**更快地完成工作**,但我们无法真正**避免这项工作**。压缩效果也不理想——一个变化的属性就足以使 Parquet 的层式字典压缩失效,zstd 也难以高效处理高度变化的数据、大量 JSON 语法开销等。
最大的问题实际上是**额外的 I/O**:由于我们的大多数数据存储在对象存储上,下载大型 JSON blob 的额外 I/O——尤其是启动这些下载的延迟——最终远比任何 CPU 计算更能主导查询运行时间。
### 拆分列以节省 I/O 和计算
要让查询变快,我们需要避免不必要的工作。
对于普通列,DataFusion / Parquet 通过以下方式避免不必要的工作:
- **列式存储**(只读取你需要的列)
- **Zone maps / min-max 统计信息**(跳过不符合过滤条件的整个行组或页)
因此,当你编写如下查询时:
```sql
SELECT count(*) FROM records WHERE duration < 0;
```
系统可以只高效地读取 `duration` 列,并根据存储的统计信息跳过最大 duration < 0 的行组。在这种情况下,这是一个有效的查询——duration 是 f64 类型,理论上可以有 < 0 的值。但实际上 duration 始终 >= 0,所以这个查询会非常快:根据统计信息,我们知道没有行匹配,可以直接返回 0。
那么,如何将这种能力扩展到存储为 JSON 的半结构化属性数据呢?
我们的初始方案是**静态拆分(static shredding)**:我们手动挑选了一小部分常见的查询属性(如 `http.status_code`、`http.method` 等),在摄入时将它们提取到独立的强类型列中。
所以当我们收到:
```
attributes
{'http.response.status_code': 200, 'url.full': 'https://example.com?foo=bar'}
{'my_llm_response': 'large text'}
{'user_id': '123'}
```
假设我们硬编码的拆分配置提取了 `http.response.status_code`、`url.full` 和 `db.query.text`,那么我们会存储:
| _lf_attributes | _lf_attributes_http.response.status_code | _lf_attributes_url.full | _lf_attributes_db.query.text |
|---|---|---|---|
| null | 200 | https://example.com?foo=bar | null |
| {'my_llm_response': 'large text'} | null | null | null |
| {'user_id': '123'} | null | null | null |
现在我们可以运行如下查询:
```sql
SELECT count(*) FROM records WHERE _lf_attributes_http.response.status_code = 500;
```
我们可以利用统计信息来裁剪不匹配的文件 / 行组 / 页,然后只扫描 `_lf_attributes_http.response.status_code` 这个强类型(int64)且压缩效果良好的列。
当然,作为用户你不需要直接使用 `_lf_attributes_http.response.status_code` 列,你可以写:
```sql
SELECT count(*) FROM records WHERE attributes->'http.response.status_code' = 500;
```
我们会在底层自动将此查询重写为使用已拆分的列。
我认为值得一提的是 `_lf_attributes` 前缀:当前静态拆分方法的挑战之一是这些列对用户可见——例如当你运行 `select * from records limit 10` 时,你会看到这些列。由于这些是内部实现细节,我们不希望它们泄漏给你。我们希望从你的角度保持 `attributes` 作为单个 JSON 列的逻辑布局,同时在底层优化物理布局。
### 缺点:灵活性有限、未拆分属性代价高昂以及额外开销
这个方案存在几个问题:
- **灵活性有限**:只有少量硬编码的属性被拆分。如果用户想查询其他属性(如 `user_id`、`session_id`、`region`),他们需要承担 JSON 存储和解析的全部成本。在数据量达数百 GB 的项目上,对这些属性的查询可能很慢。我们无法在不更改代码和重新部署的情况下轻松添加新的拆分属性。索引方面尤其痛苦——我们只有针对硬编码拆分属性集的索引。
- **未拆分属性代价高昂**:过滤未拆分属性的查询(如 `attributes->>'user_id' = 'user123'`)需要扫描整个 JSON 列,导致高 I/O、压缩效果差和昂贵的 JSON 解析。这会造成查询缓慢和高资源消耗。由于数据是分块存储的,即使单个大的未拆分属性也会膨胀整个列,使得即使不涉及该属性的所有查询都变得昂贵。
- **用户可见的列**:拆分列对用户可见,导致查询结果中出现混乱和冗余。用户可能不理解这些内部列的用途,而且它们会使查询编写和结果解读变得复杂。
- **额外开销**:如你所见,有多个列可能全是 `null`(例如,如果应用程序不进行数据库查询,`_lf_attributes_db.query.text` 列始终为 null)。虽然 Parquet / Arrow / DataFusion 对 null 的处理效率相当高,但这些未使用的列在存储和处理上仍有一些开销。每个触及 `attributes` 但不针对硬编码拆分属性集进行过滤的查询都要付出这些成本。
例如,考虑以下查询:
```sql
SELECT * FROM events WHERE attributes->>'my_llm_response' like '%important%';
```
此查询需要扫描整个 `attributes` JSON 列,因为 `my_llm_response` 未被拆分。尽管 `my_llm_response` 是一个大属性,但它可能是一个**罕见**属性——只出现在极少数行中。换个角度想:即使在 agentic 应用中,不涉及 LLM 调用的 span 数量也远多于涉及的 span。
```
┌─────────────────────────────────────────┐
│ 代价 1:下载整个 _lf_attributes 列 │
│ (可能是 GB 级别的数据) │
└─────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 代价 2:压缩效果差 │
│ 可变结构 → zstd 效率仅 10% │
└──────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 代价 3:JSON 解析 │
│ Jiter 采用惰性解析,最坏情况下 │
│ 必须解析整个行 │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 代价 4:内存操作 │
│ 创建提取值的副本 │
│ 在内存中执行比较 │
└─────────────────────────────────────────┘
```
**结果**:这个查询必须解析和扫描可能达 GB 级的 JSON 数据,其中大部分与查询无关,仅仅是为了获取包含 `my_llm_response` 的少数几行。
或者考虑以下查询:
```sql
SELECT * FROM events WHERE attributes->>'user_id' = 'user123';
```
这个查询现在面临相反的问题:即使有 1/3 的行包含 `user_id` 属性,我们也必须扫描整个 JSON 列来找到它们,其中还包括那些膨胀列大小的大型 `my_llm_response` 属性。
## 解决方案:动态拆分
我们不再将所有属性存储在一个 JSON 列中,而是自动将**访问频率最高的属性**提取到独立的强类型列中。这在数据摄入和压缩(compaction)过程中自动完成——无需任何配置。
让我们比较动态拆分前后的数据物理布局。
**拆分前(当前方案)**:
| _lf_attributes | _lf_attributes_http.response.status_code | _lf_attributes_url.full | _lf_attributes_db.query.text |
|---|---|---|---|
| null | 200 | https://example.com?foo=bar | null |
| {'my_llm_response': 'large text'} | null | null | null |
| {'user_id': '123'} | null | null | null |
**拆分后(使用动态拆分)**:
| _lf_attributes | _lf_attributes_http.response.status_code | _lf_attributes_url.full | _lf_attributes_my_llm_response | _lf_attributes_user_id |
|---|---|---|---|---|
| null | 200 | https://example.com?foo=bar | null | null |
| null | null | null | large text | null |
| null | null | null | null | 123 |
这种布局是根据我们实际观测到的数据,在为每个写出的 Parquet 文件时**动态生成**的。我们不需要包含全 null 列,并且可以优先拆分出现频率最高或体积最大的属性。
现在当上述查询执行时:
```sql
SELECT * FROM events WHERE attributes->>'my_llm_response' like '%important%';
```
它只需高效扫描 `_lf_attributes_my_llm_response` 列即可:
```
┌─────────────────────────────────────────┐
│ 优势 1:索引裁剪 │
│ Zone maps 和全文搜索索引 │
│ 让我们跳过整个文件 / 行组 │
└─────────────────────────────────────────┘
↓
┌──────────────────────────────────────────┐
│ 优势 2:只下载相关列 │
│ (_lf_attributes_my_llm_response) │
└──────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 优势 3:更好的压缩 │
│ 英文文本字符串数据 │
│ 通过 zstd + 字典编码压缩 │
│ 未来可能使用 FastLanes? │
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
│ 优势 4:零 JSON 解析 │
│ 数据已被提取并类型化 │
│ 无需反序列化,零拷贝入内存 │
└─────────────────────────────────────────┘
```
这个查询现在可以充分享受渐进式裁剪/过滤和列式存储的全部效率。它与 `my_llm_response` 作为 schema 中的一等列一样快。
## 走向动态拆分的艰难历程
我们已经在这个功能上工作了近 6 个月。它需要在 DataFusion 上游进行大量更改,以支持 JSON 访问器的表达式下推和谓词下推。这是与上游开源项目合作的恩赐与诅咒:我们可以在坚实的基础上构建,但我们也必须等待上游更改落地和发布。
我们以一种不仅惠及我们自己、也惠及任何使用 DataFusion 处理半结构化或嵌套数据的用户的方式来开发此功能,包括 Parquet/Arrow struct 列和新引入的 [Json Variant 类型](https://parquet.apache.org/docs/file-format/types/variantencoding/)。
我们不得不重构 DataFusion 执行引擎的核心部分,将表达式下推到 Parquet 读取器中,使其能够利用物理文件布局来优化表达式求值(在我们的场景中,即决定是需要从 `_lf_attributes` 余量列中读取,还是可以使用已拆分的列)。这项工作已完成,并在 DataFusion 52.1.0(2026 年 1 月)中完全落地。
剩余的工作涉及将这些表达式实际引入扫描算子(scan operator),例如对于如下情况:
```sql
SELECT attributes->>'http.response.status_code', count(*)
FROM records
GROUP BY attributes->>'http.response.status_code';
```
从 GROUP BY 子句中提取 `attributes->>'http.response.status_code'` 表达式并将其下推到 Parquet 扫描中,以便我们直接使用已拆分的列,这有点棘手,涉及复杂的表达式重写和优化 pass,但我们预计在 2026 年 2 月前将此工作落地到 DataFusion 中。
这一切的成果是,我们能够将……
相似文章
@rohanpaul_ai:日志搜索延迟决定了编码代理能否循环调试,还是每次查询都在卡顿等待。@sazabi 自主构建了……
Sazabi 发布 1.0 版本,开放其自研日志摄取、存储与查询层,支持从终端、Web 应用或 AI 代理直接用自然语言查询日志,并宣称拥有全球最快的日志搜索速度。
@Greptime: GreptimeDB 六月的大部分工作归结为一个想法:如果过滤器无法到达数据,那么它就没用。在分布式查询中…
GreptimeDB 通过启用远程动态过滤器在运行时下推到 datanode 扫描,并优化优化器使其在 MergeScan 包装远程计划之前运行,从而确保过滤器到达数据,提升了分布式查询性能。JSON v2 列现在支持类型提示。
@charles_irl:超高吞吐优化会一直进行下去,直到士气好转为止
Quail,一个开源 AI-SQL 引擎,新增了前缀共享(prefix sharing)支持,在对 1,772 条长 agent 轨迹使用 qwen3-4b 打标签的示例中,计算 token 减少 3 倍,查询速度提升 2.6 倍。
在 Python 中以每秒数十 GB 的速度解析 JSON
Daniel Lemire 演示了如何在无 GIL 的 Python 3.14 中,使用 fastsimdjson 的 map_many 多线程与惰性解析,在数秒内解析 7.6 GB 的 GH Archive JSON 数据,性能远超标准库 json、orjson 与 msgspec。
@mylifcc: LiteLLM 正式迁移到 Rust 了! AI Gateway 迎来史诗级性能升级: 单请求开销降低 150 倍(~0.05ms vs Python 7.5ms) 吞吐量提升 15 倍 内存占用降低 11 倍(峰值仅 32MB) 单个 …
LiteLLM已从Python迁移到Rust,性能大幅提升:请求开销降低150倍至0.05ms,吞吐量提升15倍,内存占用降低11倍至32MB。